Three axes, one call
It runs on mail that has already cleared the provenance check. Using Support has the surface, provisioning stands the inbox up, and the categories are in the reference.
| Axis | Answers |
|---|---|
| Category | What is this |
| Priority | How fast — urgent, normal, low |
| Sentiment | Who is about to churn — angry, frustrated, neutral, positive |
They only make an inbox sortable together, which is why they come from one call. That is also the cost decision: you pay for this per message, on your own bill.
Sentiment is the churn signal, not a politeness score. angry and frustrated are the ones worth sorting by.
It runs on your binding
The inference cost lands on your Cloudflare account, where it belongs and stays small.
Your customers’ support mail never leaves your infrastructure. Your support AI runs on your own hardware; we never see a customer email is literally true — and it is the strongest thing this project can say about any feature.
Model output is data, never an instruction
Everything downstream treats the response as hostile: the answer is parsed as JSON out of whatever wrapper the model added, validated against the effective taxonomy, and anything that does not fit becomes uncategorized rather than propagating.
A text model will always produce a plausible-sounding label, and an invented one silently poisons every filter downstream. This is validate at every boundary applied to the boundary people forget is one.
The eight that ship
| Key | |
|---|---|
billing | Money: a refund, a double charge, a failed renewal, I paid and did not get it |
account_access | Cannot get in: a magic link that never arrived, a sign-in that fails, a lost address |
bug_report | Something is broken or behaving differently than it should |
feature_request | Something that does not exist yet, or an existing thing changing |
abuse_report | Another user’s behavior — harassment, cheating, spam, impersonation |
privacy_request | A data right: deletion, export, access, correction |
spam | Unsolicited bulk mail, or an obvious phishing or scam attempt |
uncategorized | Nothing else clearly applies, or it is too ambiguous to place |
Each earns its slot by naming something you would do differently — not something a message is about.
Add your own, merged over these:
categories: defineSupportCategories({
tournament_dispute: "The sender is contesting a tournament result, a disqualification, or a prize.",
})Keys are lowercase snake case, and the description is what the model is actually given.
spam is the one key code names directly, because a behavior hangs off it: a thread the classifier calls spam is archived on sight when the guard is set to.
The declared category and the classified one never share a column
A submission may carry declaredCategory — the answer to your screen’s own chooser.
| Column | Whose | Written |
|---|---|---|
declaredCategory | The person writing. A claim | Once, when the thread opens. Never again, and never by the classifier |
category | The model. A judgment | On every classification — first run, retry, manual reclassify, post-upgrade backfill |
The listing filters on either, independently. Filtering on both is asking for the threads where they agree.
On a project with AI disabled — no binding, nothing provisioned — category is uncategorized forever and the declared one is the only category anybody stated. Which is exactly the deployment that made this necessary.
A declared key outside your taxonomy is refused
Not stored, and not downgraded.
Stored, the column becomes a client-writable vocabulary and your filters grow a long tail of Billing, billng, and one-offs nobody declared.
Downgraded to uncategorized, a broken chooser becomes indistinguishable from somebody who genuinely chose nothing.
The model gets the fallback because a model cannot be told it was wrong. A client can, and a 400 is how it is told — your chooser was built from the taxonomy you declared, so a value outside it is your client’s bug.
Sent alongside a thread id, it is refused too. A conversation carries what it was filed under; ignoring a second claim is a chooser that does nothing, and honoring it lets a follow-up rewrite the premise the thread was opened on.
Classification is a Workflow, not part of the inbound handler
An inbound handler has a tight CPU budget and a model call does not fit in it.
A model that is slow or briefly down must never take the persistence of somebody’s support request with it. The row lands first; classification is dispatched after.