Why inbox AI only drafts, and what it takes to let it act

typescript dev.to

Most "AI support" tools stop one step short of being useful. They read the ticket, they draft a nice reply, and then they hand it back to a human who has to re-read the whole thing and click the action anyway. The AI did the easy part. The person still does the work.

I wanted to see how far past that line I could safely go. Resolvd is my answer: an end-to-end inbox operator that triages, drafts, and acts within a policy. It issues the small refund, sends the order status, closes the ticket, and only escalates the cases that genuinely need a person, with the proposed action already attached so the human decision is one click, not a re-investigation.

The core idea: a guardrail between the model and the action

The central design decision is that the language model never decides what happens. It only describes the situation. A separate, deterministic policy layer decides whether the agent may act on its own or must escalate.

That split matters. If you let the model both classify a ticket and choose to issue a $900 refund, your safety story depends on the model behaving every single time. By putting a plain TypeScript function between the classification and the action, the risky decisions are governed by code I can read, test, and reason about, not by a prompt.

So the pipeline has two clearly separated stages: triage (the model's job) and decide (the guardrail's job).

How triage works

A message hits POST /api/inbound, coming from a helpdesk webhook or an email forwarder. The first stage classifies it. When ANTHROPIC_API_KEY is set, Resolvd calls Claude (the Haiku model) with a system prompt that forces JSON-only output: a category (order_status, refund, complaint, or other), an urgency, a sentiment, an optional refund amount, a one-line summary, and a drafted reply.

When there is no key, it falls back to a transparent keyword heuristic. This was deliberate. The heuristic scans the subject and body for words like refund, chargeback, where, track, broken, angry, pulls a dollar amount out with a regex, and infers urgency from negative sentiment or words like urgent and asap. It is not clever, but it means the service runs and demos without an API key, and it keeps the triage stage honest: nothing downstream should depend on triage being magic. If the Claude call throws, the code logs it and drops to the same heuristic rather than failing the ticket.

Either way, triage produces the same typed shape, and the guardrail does not care which produced it.

How the guardrail decides

The decide function takes the triage result plus the raw payload and returns a decision: a status of resolved or escalated, a proposed action, the action actually taken (only set when auto-resolved), a human-readable reason, and the reply to send. Here is the actual logic, in order.

First, a hard override: if sentiment is negative and urgency is high, it escalates no matter the category. The proposed action becomes "Personal apology + offer remedy." An angry customer is never something I want an automated system quietly closing out, even if the category looks routine.

Then it switches on category:

  • order_status with an order id auto-resolves. This is a read-only lookup, so it is safe to handle without a human. It fetches the status and replies with it. If there is no order id, it escalates with the proposed action "Ask customer for their order number."
  • refund at or under the auto-limit auto-issues the refund and replies. The limit comes from REFUND_AUTO_LIMIT and defaults to 50. A refund over the limit escalates with "Approve refund of $X" as the proposed action; a refund with no stated amount escalates asking a human to confirm the amount first.
  • complaint always escalates to a human by policy.
  • other escalates as "Human review (uncategorized)" because the system could not confidently categorize it.

The dividing line is essentially about reversibility and blast radius. Reading an order's status is harmless. Refunding twenty dollars is bounded and cheap. Refunding nine hundred dollars, or handling an angry complaint, is not something I want a bounded loop doing unsupervised, so those cases go to a person.

Escalation carries the proposed action

The part I care most about is what escalation looks like. A traditional tool escalates by handing a human a raw ticket. Resolvd escalates by handing a human a decision that is already made except for the approval: the category, the reason, the drafted reply, and the specific proposed action ("Approve refund of $900"). A human approves or rejects via POST /api/approve.

That is the difference between "here, you deal with it" and "here is what I would do, say yes or no." The dashboard reflects this too. It shows every ticket, the auto-resolution rate, the action taken or proposed, and the reason for each. You can see at a glance how much the agent handled on its own and exactly why it made each call.

One honest limitation

The actions are not yet wired to real commerce systems. The order lookup is a deterministic stub: it hashes the order id into one of four states (processing, shipped, out for delivery, delivered) so demos stay stable, and the refund path records the decision rather than moving real money. The plumbing for a real deployment (Shopify, an order management system, a payment provider) is a clearly marked seam in the code, not something already connected. So the guardrail logic and the auto-resolve versus escalate flow are real and testable end to end, but the side effects are simulated. I built the decision architecture first on purpose, because that is the part that has to be trustworthy before you ever let it touch production money.

The stack, for the curious: Next.js 14, Supabase for ticket storage, deployed on Cloudflare Workers via OpenNext.

If you have ever watched a support tool "help" by making a person do the same work twice, this is my attempt at the opposite. Code is here: https://github.com/AgentPostmortem/Resolvd

Source: dev.to

arrow_back Back to Tutorials