Classification

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.

AxisAnswers
CategoryWhat is this
PriorityHow fast — urgent, normal, low
SentimentWho 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
billingMoney: a refund, a double charge, a failed renewal, I paid and did not get it
account_accessCannot get in: a magic link that never arrived, a sign-in that fails, a lost address
bug_reportSomething is broken or behaving differently than it should
feature_requestSomething that does not exist yet, or an existing thing changing
abuse_reportAnother user’s behavior — harassment, cheating, spam, impersonation
privacy_requestA data right: deletion, export, access, correction
spamUnsolicited bulk mail, or an obvious phishing or scam attempt
uncategorizedNothing 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.

ColumnWhoseWritten
declaredCategoryThe person writing. A claimOnce, when the thread opens. Never again, and never by the classifier
categoryThe model. A judgmentOn 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.

ESC