Synaplan
Open-source AI platform for companies: self-hosted AI agents, GDPR-compliant chatbot widget, RAG knowledge base, WhatsApp and Outlook AI, MCP, multi-model routing. Docker, Kubernetes or hosted in Germ
AI-assisted support ticket triage
Translation pending
BusinessTypeScript · PLpgSQLGitHub @Sweta0519OnlineOwner unconfirmed
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Run /nomorevibe verify in your project folder to verify ownership and manage the introduction yourself.
Verify in one minuteAsk about other verification options
Support ticket triage app with an AI triage agent (Next.js + Supabase + OpenRouter)
Taken from the repository README
Loading the full README. The saved excerpt is shown below.
A support ticketing app -- customers file tickets, agents work a queue, admins run the team --
built around an AI triage agent. Every new ticket is assessed by an LLM the moment it's
filed, so an agent opening the queue already knows what each ticket is about, how urgent it is
and why, which team should own it, whether it's a repeat of something already open, and how they
might start replying.
Live: https://support-triage-app-one.vercel.app (requires sign-in; open it in an incognito
window and you land on the login page, never on data).
Why the AI feature is the core, not a bolt-onRead the full README ↗A support ticketing app -- customers file tickets, agents work a queue, admins run the team -- built around an AI triage agent. Every new ticket is assessed by an LLM the moment it's filed, so an agent opening the queue already knows what each ticket is about, how urgent it is and why, which team should own it, whether it's a repeat of something already open, and how they might start replying.
Live: https://support-triage-app-one.vercel.app (requires sign-in; open it in an incognito window and you land on the login page, never on data).
Without triage this is a plain CRUD ticket form -- a queue of subjects sorted by date. The value of the product is that a human never has to read a ticket cold:
Remove it and the app has no reason to exist over a shared inbox.
Without the model, this is a plain ticket inbox. The app still runs with no model key or with the provider down, because a customer's ticket must never be lost. But every staff-facing feature on top of the inbox depends on triage:
So staff are told when the AI is missing, rather than seeing untriaged tickets that look normal. A banner on every staff page says "AI triage is off" when no key is configured. It says "AI triage is failing" when the 3 most recently started runs all failed on the provider's side (rate-limited, unavailable, timed out, or stalled) within the last 24 hours, and gives the reason. Failures that can come from one ticket's content don't count, so a customer can't raise the alarm for everyone. Untriaged tickets say "Needs manual triage" instead of "Awaiting AI triage", and the ticket page points staff to setting the fields by hand.
The AI is deliberately advisory: it seeds a staff-only working-state record (priority, category, team) and records its full assessment in an append-only audit table, but it never changes ticket status and never sends anything to a customer. The customer who filed the ticket can't read what it produces, in the UI or through the database API. The one exception is a staff member's deliberate choice: publishing a ticket's AI summary on a public status page (see Optional tasks).
Agents and admins also get Sage, a floating assistant on every staff page. It remembers the conversation (persisted per staff member), and it can search that staff member's own private notes -- runbooks, policies, customer context written under Notes -- to answer from them:
search_notes is a tool the model chooses to call. A
general-knowledge question is answered directly with no retrieval; a question about "our refund
policy" triggers a search, and a poor first result gets a rewritten query and a second search.openai/text-embedding-3-small,
pgvector) with the owner's id on every chunk; the similarity-search function scopes to the
calling session's auth.uid() and runs under RLS, so there is no request that returns another
staff member's chunks -- verified by a two-account test that also calls the RPC directly.Design and calibration notes: docs/notes-rag.md.
Built since the core above, each through a reviewed PR (see Development process):
| Feature | PR |
|---|---|
| Staff confirm or correct the AI's triage. The review is recorded, and a later re-run never overwrites it. | #12 |
| First-reply SLA targets per priority: on the queue, as a banner on the ticket, and as sort order | #13, #16, #27 |
| Admin analytics: SLA, triage quality, agent workload, customer sentiment | #15, #18 |
| Queue cards with AI signals and ticket numbers, made calmer in a Linear style later | #16, #41 |
| Left sidebar navigation for every role | #21 |
| Staff file a ticket on a customer's behalf | #24 |
| CSAT: customers rate resolved tickets, and admins see it per staff member | #25 |
| Triage board with drag-and-drop status moves | #26 |
| Account settings: display name, theme, queue defaults; profile pictures; names instead of emails | #29, #30, #31 |
| Quick-action menu on queue cards, plus personal follow-up flags | #34, #40 |
| Sign-in and sign-up throttling; the current password needed to delete an account | #35, #36 |
| A stalled triage run is shown as stalled and can be re-run | #38 |
| The reply draft is written by a call that sees only this ticket | #39 |
| Team labels, editable on the ticket and on the cards | #42, #43 |
| Model retries: none for interactive calls, bounded backoff for background triage, and the failure reason shown | #44 |
Incident detection: a burst of similar tickets is grouped into one incident, with a staff banner, an incident page and an AI-drafted update posted to every ticket on staff action (docs/incidents.md) | #52 |
Learning from corrections: staff corrections become examples the AI sees on similar tickets; the panel shows what it learned from, admins can exclude one, and Analytics tracks acceptance by week (docs/learning.md) | #53 |
| Staff banner when AI triage is off or failing; untriaged tickets marked as needing manual triage | #48 |
| MCP connector: use Support Triage from Claude Code, Cursor, VS Code or Codex with a personal access token. Each request runs as its owner under RLS. Customers file and follow their tickets; agents work the queue, with the AI's triage as a suggestion only. Setup is on the homepage and on the Account page with the new token filled in () |
Linear and Jira are mainly built for a team's own work: filing issues, planning sprints and moving tasks through a workflow. Both have added ways to take in customer requests (Jira through Jira Service Management), but their core is internal work tracking. This app is customer support from the ground up. Customers sign in, file tickets, see replies and rate the help they got; agents and admins work the queue behind them. The Linear-style cards and labels borrow Linear's calm look, not its purpose.
Help desks such as Zendesk, Freshdesk, Intercom and Help Scout are the closer comparison. What this app puts first:
The large help desks offer AI features too, such as auto-tagging, intent detection and reply suggestions, so the AI itself isn't what's unique here. What this app prioritises is making the AI visible and accountable. It states a reason for its priority, never acts on the customer by itself, is measured against the team's own reviews, and is built privacy- and security-first.
What it doesn't have, compared with a production help desk (this is a capstone project):
Next.js 16 (App Router) · TypeScript · Tailwind · Supabase (Postgres + Auth + RLS + pgvector) ·
OpenRouter (anthropic/claude-haiku-4.5 for assessment, openai/text-embedding-3-small for
embeddings) · Vercel.
Workspaces (SUP-78): each support team is a workspace, and each person belongs to exactly
one. Every RLS policy checks the caller's workspace, workspace_id is set by the database (never
by the client), and composite foreign keys stop a row from pointing into another workspace.
tests/workspace-isolation.spec.ts proves it with two workspaces.
Three roles (customer / agent / admin), held per workspace membership and read from the
database on every request, enforced by RLS on every table, not UI checks. Customers see
only their own tickets; agents see unassigned tickets and their own; admins see all of their
workspace's.
Role changes only through a SECURITY DEFINER RPC that refuses self-changes and demoting a
workspace's last admin. Sign-up never reads a role from client metadata: the database works it
out from what it can check (a new workspace's name, a join link, or an invite for that email).
A BEFORE UPDATE trigger enforces what RLS can't: immutable ticket text, legal status
transitions, staff-only assignees, race-free claiming. Two more triggers, one on update and one
on insert, share enforce_daily_case_cap(): no path can give a capped person more new cases in
a day than their cap (#85). People without a cap are unlimited.
CSRF via Server Actions' built-in Origin/Host check; no mutating Route Handlers exist (the
three Route Handlers, /auth/confirm, /account/export and /avatar/..., are GET-only).
GDPR data rights are self-service on /account: a JSON export of everything the caller can
see (an RLS-scoped SECURITY INVOKER function) and real account deletion. Deletion asks for
the current password first. The auth.users row goes and every table cascades; a trigger
refuses to delete a workspace's last admin while other members remain (the default
workspace's, always), and a departed staff member's replies stay on customers'
tickets unattributed rather than vanishing.
Rate limiting in Postgres:
Full schema, policies and functions: docs/supabase-schema.md.
npm install
cp .env.example .env.local # then fill in the values below
npm run dev # http://localhost:3000
| Variable | Where to get it | Exposed to browser? |
|---|---|---|
NEXT_PUBLIC_SUPABASE_URL | Supabase Dashboard -> Project Settings -> API -> Project URL | Yes (by design) |
NEXT_PUBLIC_SUPABASE_ANON_KEY | Same page -> anon / publishable key | Yes (by design; RLS protects data) |
SUPABASE_SERVICE_ROLE_KEY | Same page -> service_role (secret). Bypasses RLS. | Never |
OPENROUTER_API_KEY | https://openrouter.ai -> Keys | Never |
NEXT_PUBLIC_SITE_URL | http://localhost:3000 locally; the deployed URL in production | Yes |
Database setup (one-time, needs the Supabase CLI which is a dev dependency):
npx supabase login
npx supabase link --project-ref <your-project-ref>
npx supabase db push # applies supabase/migrations/ in order
Then in the Supabase dashboard: Settings -> API -> Data API -> Exposed schemas -> add
ticketing. This app keeps every table in its own ticketing schema, not public (see
docs/supabase-schema.md for why).
Sign up at /signup ("Create a workspace" on the login page): you become the admin of the new
workspace. An account that joined the default workspace (where "Get in touch" sign-ups land)
can be made its admin in the SQL editor. One made at /signup already belongs to its own
workspace, and a person has only one:
update ticketing.workspace_members set role = 'admin'
where user_id = (select id from ticketing.profiles where email = '[email protected]')
and workspace_id = (select id from ticketing.workspaces where is_default);
Without OPENROUTER_API_KEY the app still runs, but tickets stay pending triage, and staff
see the "AI triage is off" banner. As described above, that leaves a plain ticket inbox.
The live database holds a set of demo tickets (#1472-#1483). Before a demo, re-date them into a realistic spread: resolved tickets from the past two weeks, and open ones with a mix of SLA states.
node --env-file=.env.local scripts/backdate-demo.mjs # dry run: shows the new dates
node --env-file=.env.local scripts/backdate-demo.mjs --apply # writes; backup in .demo-backups/
node --env-file=.env.local scripts/backdate-demo.mjs --restore .demo-backups/<file>.json # undo
Ages are relative to the moment it runs, so run it shortly before the demo; the "at risk" tickets go overdue within the hour.
--restore refuses a file that names any other ticket.updated_at. A trigger stamps it on every write, so it can't be set or
restored.--restore command to undo it.A narrated product video (2:17, with captions) plays from the homepage's "Watch the 2-minute demo": https://support-triage-app-one.vercel.app/marketing/demo.mp4
list_queue and then get_ticket, and shows the AI's draft reply marked "not sent
until you post it".
/api/mcp as the test admin, with a token the recording
creates and then revokes.It's recorded from the running app with Playwright, then rendered with ffmpeg and ElevenLabs
narration. The shot list is in demo-video/script.md. The narration needs ELEVENLABS_API_KEY in
.env.local (ELEVENLABS_VOICE optionally picks the voice). Without it, build.py uses macOS
say, a different voice, so check for that before shipping a cut.
node --env-file=.env.local scripts/backdate-demo.mjs --apply # fresh SLA states
npm run build && npm run start # in another terminal
node --env-file=.env.local demo-video/record.mjs # every scene; or e.g. `10b` for one
/usr/bin/python3 demo-video/build.py # -> demo-video/build/support-triage-demo.mp4
Ship the video and its captions together, or the homepage plays the new cut with the old captions:
cp demo-video/build/support-triage-demo.mp4 public/marketing/demo.mp4
cp demo-video/build/support-triage-demo.vtt public/marketing/demo.vtt
It changes the live database, like a real demo:
backdate-demo.mjs --apply re-dates the demo tickets. If a run goes wrong, undo it with
--restore (see Demo data).record.mjs files, reviews, claims, moves, flags, labels, shares, resolves and rates one
demo ticket as the test accounts.It uses the test accounts, so don't run it at the same time as the test suite.
npx playwright install chromium
npx playwright test
The suite runs against the real Supabase project and, for the AI test, the real OpenRouter API.
It needs dedicated test accounts in .env.local (TEST_USER_* customer A, TEST_USER_B_*
customer B, TEST_AGENT_*, TEST_ADMIN_*, TEST_ROLEFLIP_EMAIL; they aren't in .env.example,
so add them by hand) -- create them with the Supabase
Admin API (auth.admin.createUser with email_confirm: true) and set roles via the service role.
About 200 tests in 46 spec files. Feature PRs add their own tests.
settings, people, ai-health, incidents, workload) change shared
test accounts, so they run one after another. To rerun them without the main suite, keep them in
order with one worker:
npx playwright test --project=settings --project=people --project=ai-health --project=incidents --project=workload --no-deps --workers=1.
With --no-deps and several workers they run at the same time, and settings can reset the agent's
display name while people is checking it.npm run build && npm run start, then npx playwright test). That is what Vercel serves, and it can be slower than
the dev server.Highlights:
Eight optional tasks across both sprint projects' lists, spanning all three difficulty tiers. Each
one says how it was delivered. Four came through a feature-branch PR. Four were committed directly
to main on 2026-09-03, before the PR workflow was adopted.
Rule: an optional task is claimed as PR-delivered only after its feature branch is merged. A
task that went straight to main says so, with the commit.
Easy
main (d4badde, 2026-09-03), with
the AI triage agent.usage.cost on the response, not an estimate). It's shown per
ticket, and the admin overview totals the last 1,000 runs ("AI spend (recent)"). A run that
fails stores no result, so its spend is only logged, not counted here. Delivered: PR #2 (branch
feature/usage-cost-indicator).preload,
X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy, and
Permissions-Policy, applied to every route including the public share page. Delivered:
PR #1 (branch feature/m7-readme-security-tests; headers in bec27f5, HSTS in ab93d15,
preload in c12257f).Medium
NEXT_PUBLIC_ variable. Delivered: a Vercel project linked to this repo, configured in
the Vercel dashboard rather than in code. The live origin was pinned in the Server Actions
config in 21a2c42, committed directly to main on 2026-09-03.main (tests/ai-triage.spec.ts in d4badde, 2026-09-03).
It was extended in later PRs (#1, #2, #12, #41).main
(tests/cross-user-security.spec.ts in 73da112, 2026-09-03), then extended in PR #1.Hard
search_notes is a tool the model chooses to call, not a fixed
retrieval step: it skips the search for general-knowledge questions, rewrites the query and
searches again when the first results are poor, cites the note it used by title, and says so
when nothing relevant exists. Chunks are embedded with openai/text-embedding-3-small into a
pgvector documents table; the match function scopes to the caller's auth.uid() and runs
under RLS, so there is no user-id argument to spoof. Verified with a two-account test in the
chat and against the RPC directly (docs/notes-rag.md). Delivered: PR #5 (branch
feature/notes-rag).anon through the Data API at all -- the public page
reads it server-side with the service role, by exact 128-bit token match only, with its own
IP-keyed rate limiter since it's the app's only unauthenticated route. Staff can unpublish at
any time (one-directional -- a raw API call can't silently un-revoke a link), after which the
link 404s, and shares expire automatically after 30 days. Delivered: PR #3 (branch
feature/shareable-status-page).main: the schema, auth and roles, tickets, the queue,
AI triage and the admin surface (1b91083 to 8f1142a). The same day, the README, the security
headers and the test work started on the first feature branch, PR #1.main: one feature, Sage's floating widget (851446d, 2026-09-05), and
small follow-ups such as 3145714 and 807c716.main could be pushed there) and had no review. The diff is reviewed with /code-review --comment and the ai-code-reviewer subagent, and each
finding is answered inline and resolved (docs/reviews/README.md). The full Playwright suite
passes before the commit, and the Vercel preview builds before the merge.main is protected (SUP-35), and the rules bind admins too. GitHub
enforces four things:
docs/ai-triage.md -- pipeline, schema, prompt-injection and privacy analysis (cites OpenRouter
and Supabase sources).docs/notes-rag.md -- Sage's notes retrieval: chunking, the tool loop, threshold calibration,
and why the match function takes no user id.docs/supabase-schema.md -- every table, policy, trigger and function, and how workspaces keep
teams apart.docs/security/ -- security reports:
pr-1-diff-review.md);audit-share-feature.md, PR #3);/security-scan report of 2026-10-11 (SUP-11), with its follow-ups;vercel-waf.md);mcp-review.md, SUP-57).docs/mcp.md -- the MCP connector: what MCP is, tokens, connecting each assistant, how a request
is signed in, CSRF, and every tool by role.docs/reviews/ -- how each PR is reviewed (/code-review --comment plus the ai-code-reviewer
subagent) and where the evidence is. It holds the early reports as files; since PR #12 the
evidence is inline PR comments, each answered and resolved. See docs/reviews/README.md.CLAUDE.md -- the AI rules and conventions the codebase is built to.docs/mcp.md| #65, #67, #68, #69, #70, #71 |
| Staff presence: an Online / Away / Offline dropdown, with a coloured dot, under each agent's and admin's name in the sidebar; signing out sets Offline | #84 |
| Admin > Workload: a daily cap on the new cases each person can be given, counted from midnight in their workplace timezone and enforced in the database on every way a ticket gets assigned | #85 |
| Working hours on Admin > Workload: each person's days and shift set their status Online or Offline by themselves, in their workplace timezone (night and 24-hour shifts included). A status they pick holds until the shift next starts or ends, and signing out keeps them Offline until they sign in again. Working hours never assign tickets | #90 |
Workspaces: many support teams in one app, each in its own workspace. Signing up at /signup creates a workspace with you as its admin; customers join through the workspace's sign-up link and agents through a single-use invite, both shared from Admin > Workspace. RLS keeps every ticket, label, incident and person inside their workspace, and AI triage only ever compares tickets from the same one. The AI actions have workspace-wide budgets as well as per-person limits. A Playwright test with two workspaces checks it all through the web app, the database API, MCP and Sage (docs/supabase-schema.md) | #88, #89, #93 |
Demo video (2:17, narrated and captioned) on the homepage, covering the main features, including an MCP section: an AI assistant working the queue through /api/mcp | #57, #66, #80 |
A Vercel firewall rule is the network-level backstop: 30 POSTs per minute per IP, on every path.
It lives only in the Vercel dashboard, and was confirmed live on 2026-10-16
(docs/security/vercel-waf.md). Security headers include a CSP.
OPENROUTER_API_KEY and SUPABASE_SERVICE_ROLE_KEY are read only in server-only modules.
Ticket text is treated as untrusted model input (closed strict schema, delimited, candidate-
restricted ids). See docs/ai-triage.md for the prompt-injection and privacy analysis.
main was on 2026-09-07.main, so a combination of two merged PRs isn't
built before it lands. The template
(.github/pull_request_template.md, from PR #47 on) has a UI section for redesigns. The exact
settings and how to restore them are in docs/branch-protection.md.6afb89e), main was mirrored to the Turing College submission repo after each
merge. From PR #84 on, it isn't: the work continues only in this repo.