Run a support inbox

You need: support and auth composed, and a subdomain you can point at Cloudflare.

Set it up

The MX constraint is the decision to take first. Provenance is what decides whether a sender is who they claim, classification is what sorts the result, and Using Support is the capability underneath.

pithy add support
pithy support provision \
  --routing-zone <zone-id> \
  --inbound-address support@help.example.com \
  --app-worker api

Read the MX constraint first. It is the single most important thing about this capability, and it is about your whole domain’s mail rather than this feature.

Configure it

support({
  inboundAddresses: ["support@help.example.com"],
  ai: { enabled: true, model: "@cf/meta/llama-3.1-8b-instruct" },
  categories: defineSupportCategories({
    tournament_dispute: "The sender is contesting a tournament result, a disqualification, or a prize.",
  }),
  guard: { maxRawBytes: 2_000_000, maxPerSenderPerHour: 20, maxPerHour: 500, archiveSpam: true },
})

Until inboundAddresses is set the inbox is inert — it ignores every message and says so once, rather than quietly storing mail from an address nobody chose.

Two intakes, one table

Mail arrives at your Worker’s email entry and is claimed on the envelope recipient.

In-app reports come through POST /support/feedback, wearing a session and same-origin.

Same table, same classifier, same taxonomy, same console — because a support person with two consoles has the whole conversation in neither.

The in-app route must never be role-gated

Render the provenance, not a boolean

accountLinkSourceMeans
sessionAn authenticated request proved it
email_addressMatched from a From: header nobody proved
nullNo account

The same operator action — a refund, a password reset — follows from very different evidence, and a single boolean cannot say which one is on screen.

A spoofed thread decorated with a real customer’s billing history is the opening move of support-driven account takeover, and this capability’s own account_access and privacy_request categories route exactly those messages to somebody already primed to act.

Triage on the disagreement

declaredCategory is what the submitter chose. category is what the model decided. They never share a column.

Filter on either, independently. Filtering on both asks for the threads where they agree — and they filed it as billing, the model calls it a bug report is the most useful row on the screen.

Answering

RouteScope
GET /support/threadssupport:threads:read
POST /support/threads/:id/replysupport:threads:reply
POST /support/threads/:id/archivesupport:threads:archive
POST /support/threads/:id/reclassifysupport:threads:reclassify
POST /support/threads/:id/flagssupport:threads:flag

A mailed reply to an app thread is recorded as channel: "email" — and the app submission was minted a message id on the way in, so the customer’s answer threads back rather than opening a new conversation.

Turning the AI off is a supported state

No binding, nothing provisioned — category is uncategorized forever and the declared category is the only one anybody stated.

Which is exactly the deployment that made two columns necessary.

What you own

The mail, the database, the bucket and the inference.

No Pithy-operated service in the path, and nothing here unlocked by a dashboard — every route is reachable with a credential you issued.

ESC