Your agents already have an assistant. Give it the tickets instead of asking them to click through another dashboard. One endpoint, two tools, and a customer at the centre of all of it.
PostgreSQL. Mail through Plinth. Models through konsole.
There is no ticket list to keep open, no form to fill, no six clicks between reading a complaint and answering it. An agent asks for what they want and the desk does it — because the desk is an MCP server and their assistant is already holding the other end.
Her reply comes back to ticket #1042 rather than opening a second one — that number in the subject is put there by the desk, not by whoever typed the message.
Most desks are built around the ticket, and it shows the moment somebody writes in for the third time: three threads, three half-stories, nobody with the whole picture. Here a ticket cannot exist without a customer, and every ticket they have ever raised reads back as one timeline, in one call.
customers — the person. tickets — one thing they need. messages — the thread. teams — who owns a kind of ticket. agents — who answers. Inside one organisation, and none of it is optional.
Replies, internal notes, status moves, reassignments, field edits — one table, in order. There is no separate history to go looking for and nothing to reconcile.
Whatever your desk cares about — billing, region-emea,
gateway-timeout — is a tag or a key in fields. Nobody migrates a
schema to start tracking something.
Seventeen, and two of them are JSON. Across the whole desk it is 74 columns that are not bookkeeping. Everything a particular desk needs beyond that has somewhere to go without becoming a column most desks leave empty.
Three levels, and each one earns its place. An organisation is the boundary nothing crosses. A team is what an inbox address routes to and what a queue belongs to. A person answers. A ticket can sit on a team before anybody has picked it up — which is the difference between a queue and a pile.
Mail to billing@ opens tickets on the billing team. Not a rule engine, not a
keyword list — the one signal that is always present on an email.
Its targets merge with the organisation's rather than replacing them, so
{ urgent: { respond: 5 } } means a faster first reply and the resolution
target you already had. Move a ticket between teams and its clocks move with it.
It picks the lightest-loaded person on the ticket's team, and refuses rather than quietly going outside it. Routing a billing ticket to whoever is idle is what having teams is meant to stop.
Left out, a desk with a team per inbox reads as fully covered while the unrouted tickets
sit unread. team.list says how many, and how many of those are breaching.
An SLA is only worth having if missing one is visible before it happens. Targets are set from your policy when a ticket is opened and recomputed when its priority moves; what actually happened is recorded as it happens, so attainment is a comparison rather than a report that has to re-derive it.
Sorting by arrival is how an urgent ticket from this morning ends up under a low-priority
one from last week. queue.list ranks by whichever clock is closest to running out.
queue.breaching answers “what goes in the next two hours” off a partial
index that only ever holds unfinished tickets with a clock still running.
A reply to a solved ticket reopens it and restarts the resolution clock. A reply to a closed one opens a new ticket that names the old, because it is a new problem.
Response and resolution minutes per priority, on the organisation. A desk changes it once a year and every ticket write consults it, so reading it has to be free.
Support arrives as mail whether or not the software admits it. Outbound goes through Plinth; inbound arrives at a webhook, and lands on the ticket it belongs to.
In order of how sure we can be: the ticket number the desk put in the subject, the
In-Reply-To of a message we sent, then an open ticket with the same subject.
Nothing else — “their most recent open ticket” is how two unrelated conversations end
up in one thread.
Not an error, not an orphan record. Their name is read out of the address when the message does not carry one, quoted replies are stripped, and mail from your own domain is a colleague rather than a new customer.
Whoever is connected over MCP is already a model — asking a second one to think on their behalf would be redundant. So the LLM here, through konsole, only runs where there is nobody to ask.
Mail landing at three in the morning gets a summary, a priority and tags drawn from the vocabulary your desk already uses — so it is not the fourteenth spelling of “billing”. It fills blanks; it never overrules a person.
ticket.draft_reply returns text, a confident flag and a list of
what it would need to be sure. It never sends. A confidently wrong support reply costs more to
undo than no reply at all.
With no key configured both degrade rather than fail: mail still opens a ticket, and the draft says why it cannot answer.
Customers and tickets stay small. Messages do not — a busy desk writes millions of them, and every one is on somebody's timeline. That is the table the schema is designed around.
A row-value comparison against an index ordered the same way, so page 200 costs what page 1 costs. Offsets get slower the further back you read; cursors do not.
(ticket_id, COALESCE(occurred_at, created_at) DESC, id DESC) WHERE deleted_at IS NULL
— the same expression the read orders by, so reading a thread is an index scan and never a sort.
Filtering asks about membership through jsonb @>. A substring match would find
vip inside vip-renewal, and inside a note that merely mentioned it.
An atomic increment on the organisation row, so two simultaneous opens each get their own number without either reading the tickets table.
“Have we seen this before” is a GIN index over a tsvector, ranked by
overlap. No second datastore to keep in step, and no extension to install.
A customer's open-ticket count comes from one indexed count on the write path, so it cannot drift away from the truth the way a counter eventually does.
Every customer, ticket, message and agent belongs to exactly one organisation, and nothing crosses that boundary. The caller's organisation comes from their key, and the scoping is enforced in the engine every read and write goes through — so a query cannot forget it.
Fetching, changing or deleting another organisation's record fails as not found, exactly as if it did not exist.
Two organisations both have a ticket #1, and both can have [email protected] as a customer. Sign-in addresses stay unique across the server.
You cannot attach your ticket to somebody else's customer, however you spell the id.
Standing up a desk, adding a team, inviting a colleague, dealing with a forgotten password, revoking a leaked key — all of it is on the same MCP surface as the tickets, so an assistant can do the whole thing.
There is deliberately no operation that sets a password — a key that could change the password on its own account would turn a leak into a permanent takeover. A reset link always goes to the address on file.
Working the desk happens in the conversation. But somebody always wants to see everything at once, so there is one page that does: all five tables, filtered, sorted, searched and exported — with each filter counting against the others, so a count never promises rows that are not there once you click it.
The tables, every field, the allowed values, and the full signature of every operation. The reason your assistant never has to guess a field name.
Everything else — opening, replying, routing, reporting, and plain reads and writes on every table. With a dry run that rolls itself back, and an idempotency key so a retry cannot act twice.
# any MCP client that speaks HTTP claude mcp add --transport http desk https://desk.cleartrust.cc/mcp # or run it on stdio, straight from the repo node src/stdio.js
Clients that support OAuth register themselves and walk you through signing in. Anything else takes a key from your account page. Full instructions are on the connect page.