Headless Desk — a support desk your assistant runs
MCP-native support

A support desk with no desk to sit in.

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.

5tables
74real columns
129operations
2tools
0dashboards

PostgreSQL. Mail through Plinth. Models through konsole.

The whole interface is a conversation

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.

agentwhat's in my queue?
deskFour open, worst first. #1042 Export times out on large invoices urgent breaches in 12m #1039 Cannot add a second seat high 3h left #1044 Invoice VAT looks wrong normal tomorrow #1031 Feature request: bulk archive low no clock #1042 is Alicia at Acme — enterprise, third ticket this month, last one rated 2/5.
agentwhat did we tell her last time?
deskIn #1021, three weeks ago, that the exporter had been optimised. It closed as solved. She never replied.
agentreply that it's a gateway timeout not the exporter, fix ships Thursday, set it pending and tag it so we catch the pattern
deskSent to [email protected], subject “Re: Export times out [#1042]”. Status pending, tagged gateway-timeout. Response clock stopped at 47 minutes, inside the one-hour target.

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.

The customer is the record, not the ticket

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.

01

Five tables

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.

02

The thread is the audit log

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.

03

Tags, not columns

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.

TICKETS · EVERY COLUMN
customer_id · number · subject · status · priority · channel · team_id · assignee_id · summary · tags · fields · respond_by · resolve_by · first_response_at · resolved_at · last_message_at · csat

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.

Organisation, team, person

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.

Routing is a configured inbox

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.

A team can answer faster than the desk

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.

Auto-assignment stays inside the team

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.

What is on no team is counted too

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.

Clocks that mean something

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.

The queue sorts by time left, not by age

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.

Breaches are a query, not a nightly job

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.

Solved and closed are different

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.

Policy is one JSON column

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.

Email, in both directions

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.

Threading is decided, not guessed

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.

An unknown address becomes a customer

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.

A model for the hours nobody is watching

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.

Triage on arrival

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.

Drafts that admit what they do not know

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.

Built for the table that grows

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.

Threads page by cursor

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.

Partial indexes that match the query exactly

(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.

Tags are GIN, not LIKE

Filtering asks about membership through jsonb @>. A substring match would find vip inside vip-renewal, and inside a note that merely mentioned it.

Ticket numbers cannot collide

An atomic increment on the organisation row, so two simultaneous opens each get their own number without either reading the tickets table.

Full text is Postgres' own

“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.

Rollups are recomputed, not incremented

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.

Several desks, one deployment

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.

Knowing an id is not enough

Fetching, changing or deleting another organisation's record fails as not found, exactly as if it did not exist.

Numbering is per desk

Two organisations both have a ticket #1, and both can have [email protected] as a customer. Sign-in addresses stay unique across the server.

References cannot be borrowed

You cannot attach your ticket to somebody else's customer, however you spell the id.

Onboarding without a browser

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.

adminset up a billing team on [email protected], first reply within 5 minutes for anything urgent, and put Bo on it
deskCreated the team, pointed at [email protected]. Urgent tickets there are held to a 5-minute first reply — the desk's 4-hour resolution target is unchanged. Bo Chen has been emailed a link to set a password. Until they do, they show as invited rather than active.
admina key of Bo's may have leaked
deskRevoked all 3 of their keys and their browser session. Nothing already done is undone; the history keeps the attribution. They will need a new key.

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.

A records browser, for when you want to look

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.

Open it Session-authenticated and scoped like everything else.
reads discover

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.

entitiesentityoperationsenumsfiltersexamples
writes execute

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.

ticket.openticket.replyqueue.listteam.createpeople.invite

Connect it in a minute

# 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.

Stop paying for seats in a dashboard nobody wants to sit in.