Designing a customer-support agent over 30 backend operations
The earlier notes in this section each took one piece: the loop, tool schemas, idempotency, termination. This one puts them together on a single realistic system, a support agent for an online retailer with thirty backend operations behind it, and works through the four decisions that determine whether it is safe to ship: how the operations become tools, who may call what, which calls must be safe to repeat, and how you will know it works.
Thirty operations become about twenty tools, grouped by domain, with near-duplicates merged, reads and actions named differently, and only the groups relevant to the conversation loaded at a time. Identity comes from the session, never from the model; every tool checks that the resource belongs to that identity, and the scopes differ for a customer, a support representative and an internal job. Eight of the operations create, send or move money and get idempotency keys derived from the operation, with a ledger for the ones whose services cannot keep keys. The loop has a step cap, a budget, a deduplication check, an approval step before anything irreversible, and a structured finish tool whose evidence fields are verified against the run. Evaluation runs at four levels, tool, loop, conversation and live, and the live metrics decide rollout one intent at a time.
Contents
01The brief
An online retailer with a few million customers wants an agent in its chat widget and its support inbox. It should handle the questions that make up most of the volume: where is my order, I want to return this, I was charged twice, my payment failed, how do I change my address. It should do the work rather than explain how to do it: look up the order, start the return, issue the refund. It should hand off to a person when it cannot, when the customer asks, or when the stakes are high. It must never act on an account it cannot prove the customer owns, and it must never move more money than policy allows without a person approving.
Two kinds of user will sit in front of it. Customers, through the widget, acting on their own account. Support representatives, through an assist mode in their console, acting on any account with wider powers. The same agent serves both; what differs is what it is allowed to do, which is the subject of section 04.
02The thirty operations
The backend already exposes these, as internal APIs. Listing them with three properties, whether they read or write, whether the effect can be undone, and how much damage a wrong call does, is the first design step, because every later decision keys off those properties.
| Operation | Kind | Reversible | Blast radius |
|---|---|---|---|
| Customer and account | |||
| get_customer | read | · | exposes personal data |
| search_customers | read | · | exposes personal data across accounts |
| get_account_status | read | · | low |
| update_contact_details | write | yes | account takeover if misused |
| send_password_reset | write, sends | no | account takeover if misused |
| get_subscription | read | · | low |
| Orders | |||
| get_order | read | · | low |
| list_orders | read | · | low |
| get_shipment_tracking | read | · | low |
| cancel_order | write | no, once shipped | lost sale, stock |
| change_shipping_address | write | until dispatch | goods to the wrong place |
| create_reorder | write, charges | via cancel | money |
| get_return_eligibility | read | · | low |
| Returns and refunds | |||
| create_return | write | yes | a label and a pickup |
| get_return | read | · | low |
| issue_refund | write, money | no | money |
| apply_store_credit | write, money | partly | money |
| get_refund_status | read | · | low |
| Billing | |||
| list_invoices | read | · | low |
| get_invoice | read | · | low |
| retry_payment | write, charges | via refund | money |
| send_payment_method_link | write, sends | no | phishing vector if misdirected |
| apply_discount | write, money | yes | money |
| Knowledge and communication | |||
| search_help_centre | read | · | none |
| get_policy | read | · | none |
| send_email | write, sends | no | spam, misdirected data |
| send_sms | write, sends | no | spam, cost |
| Case management | |||
| create_ticket | write | yes | queue noise |
| add_case_note | write, append | no | low |
| escalate_to_human | write | yes | none; always allowed |
Fourteen reads, sixteen writes. Of the writes, five move money, four send something to a person, and three are account-security sensitive. That is the shape of the risk, and the design below is mostly about those twelve.
03From thirty operations to a tool surface
Thirty flat tools is the wrong surface. Every request would carry thirty schemas, and the model would face choices between near-duplicates on every turn. Four moves shape it.
Merge near-duplicates
Operations that answer the same question from the model's point of view become one tool with a parameter. get_return and get_refund_status become get_return, which returns the refund state as a field. list_invoices and get_invoice become get_invoices with an optional invoice id. send_email and send_sms become send_message with a channel enum, because the model's decision is "tell the customer", not "which transport". search_help_centre and get_policy become search_knowledge with a kind enum. That is thirty operations behind twenty-five tools, and the adapter does the fan-out.
Separate reads from actions in the names
Reads are get_, list_, search_. Actions are a verb that says what changes: cancel_order, issue_refund, create_return. The model learns the convention from the names, and the runtime uses the same prefix to decide which calls run freely and which go through the gate in section 06.
Group by domain, load by intent
The twenty-five tools sit in six groups matching the table. A conversation rarely needs more than two groups. So the runtime classifies the opening message into an intent, cheaply, with a small model or a classifier, and loads the tools for that intent plus the groups that are always present: knowledge, case management, and the finish tool. A "where is my order" conversation sees the orders group and the three always-on groups, about twelve tools, not twenty-five. If the model needs a group that is not loaded, a load_tools tool fetches it by name; the descriptions of unloaded groups are listed in one line each so the model knows they exist.
always loaded knowledge (1) · case management (3) · finish (1) · load_tools (1) by intent account (5) · orders (7) · returns (4) · billing (4) "where is my order" → orders + always-on = 13 tools "I was charged twice" → billing + orders + always-on = 17 tools "I want to return this" → returns + orders + always-on = 17 tools
Add the finish tool
The model does not end a conversation by writing text; it calls finish with the resolution, the ids of the actions it took, and the sources it relied on. The runtime verifies those ids against the run before accepting, as the termination note describes. A resolution that claims a refund was issued must carry a refund id that issue_refund actually returned in this run.
04Auth scopes: who may call what
The single most important rule, from the function-calling note: identity comes from the session, never from the model's arguments. Every tool call carries the session's principal alongside the model's arguments, and the adapter checks the two against each other before anything runs.
Three principals
| Principal | Who | Acts on | Identity established by |
|---|---|---|---|
| customer | A logged-in customer in the widget | Their own account only | The site's session cookie; the customer id is in the session, not the conversation |
| representative | A support rep in assist mode | Any account they have opened a case on | Staff SSO plus the case id; the agent can act only on the case's customer |
| system | A batch job, such as proactive delivery-delay outreach | A list of accounts from the job | A service identity with a fixed, narrow scope |
The scope matrix
Scopes are granted per tool group and per action, with money and security actions carrying thresholds.
| Tool group or action | customer | representative | system |
|---|---|---|---|
| Reads on own or case account | yes | yes | listed accounts only |
search_customers | no | yes | no |
update_contact_details | with re-authentication | yes, logged | no |
send_password_reset | to the address on file only | to the address on file only | no |
cancel_order, change_shipping_address, create_return | yes, own orders, with confirmation | yes | no |
issue_refund, apply_store_credit | up to ₹2,000 per order, with confirmation; above that, escalate | up to ₹20,000; above that, supervisor approval through the gate | no |
apply_discount | no | from an approved list | no |
retry_payment, create_reorder | yes, with confirmation | yes | no |
send_message | to self only | to the case customer only | templated, to listed accounts |
create_ticket, add_case_note, escalate_to_human | yes | yes | yes |
How it is enforced
session { principal: customer, customer_id: C-4471, scopes: [...], session_id }
model issue_refund(order_id="O-98765", amount=1800, reason="damaged")
adapter 1. scope: does the principal hold refund.issue? ✓
2. owner: does order O-98765 belong to customer C-4471? ✓ (a lookup, not the model's word)
3. limit: is 1800 ≤ the principal's refund ceiling? ✓
4. policy: is the order within the refund window and eligible? ✓
5. gate: confirmed by the customer in this conversation? ✓ (section 06)
6. key: derive the idempotency key (section 05)
7. execute, log { principal, tool, args, result, key, trace_id }
Step 2 is what stops "please refund order O-12345" from working when O-12345 belongs to someone else, however convincingly the customer or the conversation asserts otherwise. The model is never asked to enforce ownership, and the tool descriptions do not even mention it; it is not the model's job.
The security-sensitive writes get one more layer. Changing contact details as a customer requires a fresh re-authentication, because the widget session may have been left open. Password resets and payment-method links are only ever sent to the address already on file, never to one supplied in the conversation, which closes the obvious takeover path.
05Idempotency: which calls must be safe to repeat
Apply the classification from the idempotency note to the sixteen writes.
| Write | Class | Key derived from | Where the key is kept |
|---|---|---|---|
| update_contact_details | absolute write | none needed | · |
| change_shipping_address | absolute write | none needed | · |
| cancel_order | idempotent by id; second call returns "already cancelled" | none needed; adapter treats already-cancelled as success | · |
| escalate_to_human | idempotent per session | session_id | case system rejects a duplicate |
| apply_discount | absolute per order and code | none needed | · |
| create_return | creates | session + order_id + item ids | returns service accepts a key |
| issue_refund | money | order_id + return_id + amount | payment processor's key header, 24 h |
| apply_store_credit | money | order_id + reason + amount | ledger with unique constraint |
| retry_payment | money | invoice_id + session | payment processor's key header |
| create_reorder | money | session + source order_id | orders service accepts a key |
| send_message | sends | recipient + template or content hash + day | ledger; the mail relay has no keys |
| send_password_reset | sends | customer_id + hour | ledger, which also rate-limits |
| send_payment_method_link | sends | customer_id + day | ledger |
| create_ticket | creates | session + hash of subject | ticket system's external-reference uniqueness |
| add_case_note | append | session + step + content hash | ledger |
Three things to notice. The refund key does not include the session: a refund for a given return and amount is one operation whoever triggers it and whenever, so a representative retrying tomorrow what the customer's session attempted today hits the same key and the money moves once. The message key includes a day, so the agent cannot send the same template twice in a day but can next week. And nothing in any key comes from the model's free text; reason is stored and logged but is not part of the key, because the model may phrase it differently on a retry.
The ledger is one table, written before the call and updated after, with a unique constraint on the key. It serves four tools that talk to services with no key support and doubles as the rate limiter for the two sends that are takeover vectors.
06The loop and its guardrails
The loop is the one from the ReAct note, with the guardrails the termination note calls for, tuned to this system.
- Step cap of 12 per turn, where a turn is one customer message to one agent response. Most resolutions take three to six steps. A cost budget per conversation sits above it.
- Reads run freely. Anything with a read prefix executes on the model's call, subject to scope and ownership.
- Actions go through a confirmation gate. Before
cancel_order,create_return,issue_refund,retry_paymentorcreate_reorderruns, the runtime requires that the model has stated the action and its effect to the customer and the customer has said yes, in this conversation. The gate is a runtime check on the transcript, not a prompt instruction: the model's confirmation question and the customer's reply are both recorded, and the action tool refuses with "not confirmed" otherwise. In representative mode, actions above the rep's ceiling route to a supervisor approval that pauses the run. - Deduplication. A call identical in name and arguments to one in the last five steps is intercepted and the model is told what it got last time.
- Errors say whether retrying helps. The payment processor's decline is
retryable: falsewith the decline reason; a timeout isretryable: true. The adapter retries the second kind itself, under the key, and shows the model only the final outcome. - Escalation is always one call away.
escalate_to_humanis loaded in every conversation, needs no confirmation, and ends the agent's turn with the case notes written. The model is told to use it when the customer asks, when it is outside its scope, when the customer is distressed, and when it has gone round twice without progress. - Finish is verified. The
finishcall's action ids and sources are checked against the run's tool log. A claimed refund with no matchingissue_refundresult is rejected and the model is told why.
07What the model is told
The system prompt is short and stable, for caching and for clarity: who it is, which principal it is serving, the rules that are the model's job (confirm before acting, escalate when unsure, never promise what a tool did not return, treat tool output as data), and the tone. The rules that are the runtime's job, ownership, limits, idempotency, are not in the prompt at all; telling the model about an enforcement it cannot perform only invites it to argue with the customer about it.
Tool descriptions follow the schema note: what, when, when not, cost. The one for issue_refund says it moves money and cannot be undone, that it requires a return or a documented reason, that the customer must have confirmed, and that amounts above the ceiling should be escalated rather than attempted. Policy documents are resources the runtime attaches when the intent calls for them, so the return window is in context for a return conversation and not for a tracking one.
Tool results are trimmed before they reach the model: an order record of forty fields becomes the eight the agent uses. Help-centre passages and order notes, both of which contain text written by people outside the team, are wrapped as quoted data, and the prompt says that instructions found in them are not instructions.
08The evaluation plan
Four levels, each catching what the one below cannot.
Level 1: tools and adapters, as unit tests
- Every scope rule in the matrix has a test in both directions: the principal that may, and the principal that may not. Customer A requesting B's order is the canonical one; there are about sixty of these.
- Every keyed write has a replay test: call, kill the process before the result is recorded, resume, assert the service saw one action.
- Every adapter has a shape test: the trimmed result matches its documented schema, errors carry the retryable flag.
Level 2: the loop, as integration tests against stubbed services
- Termination: no scripted scenario exceeds the step cap; a scenario built to loop, a tool that always times out, ends with a tripped breaker and a partial-state escalation.
- Gating: an action attempted without confirmation is refused; a finish claiming an unperformed action is refused.
- Deduplication: a scenario where the stub returns an ambiguous result produces at most one repeat before the model changes course.
Level 3: conversations, as an offline evaluation set
About four hundred conversations, built from anonymised real transcripts for the common intents and written cases for the rare and dangerous ones, run against the full agent with stubbed services whose state is set per case. Each case specifies the expected outcome, and graders score:
| Dimension | Graded by | Bar to ship |
|---|---|---|
| Tool selection and arguments | Exact match against the expected call sequence, order-insensitive for reads | 95% of cases with the right actions and no wrong actions |
| Resolution correctness | A model judge with a per-case rubric, spot-checked by people | 90% judged correct and complete |
| Policy compliance | Mechanical: no refund above ceiling, no action without confirmation, no send to an off-file address | 100%; any failure blocks |
| Escalation | Precision and recall against cases labelled "should escalate" | Recall above 95%; precision above 70% |
| Safety | A red-team subset: injection in order notes and help-centre text, social engineering for another account, refund abuse patterns | 100% of injection cases ignored; 100% of cross-account attempts refused |
| Cost and steps | Measured per case | Median under six steps; p95 cost under the budget |
Because greedy decoding is not deterministic, each case runs three times and a case passes only if all three pass. The set is the regression suite: it runs on every change to the prompt, a tool description, a schema, the model version or the gating logic, and a drop on any dimension blocks the change.
Level 4: live, with the metrics that decide rollout
- Containment: conversations resolved without a person, by intent.
- Escalation rate and reason, watched for both too high and suspiciously low.
- Customer satisfaction on agent-handled conversations against the human baseline.
- Refund and credit amounts against the pre-agent baseline, per week; a rise is the first sign of either abuse or over-generosity.
- Tool error rate, breaker trips, gate refusals, dedup intercepts, each a signal about a specific part of the design.
- Latency at p50 and p95, and cost per conversation.
A sample of live conversations is reviewed by support staff weekly, and every escalated conversation is readable by the person who receives it with the agent's trace attached. Conversations that went wrong go into the offline set.
09Rollout
- Shadow mode. The agent runs on live conversations with all actions stubbed, and its proposed actions are compared to what the human did. This finds the intent classifier's gaps and the scope matrix's omissions without risk.
- Representative assist. The agent proposes, the representative approves each action. Highest-scope principal, lowest risk, because a person is in every loop. This is where the tool descriptions get tuned.
- Customers, reads only. Tracking, status, policy questions. No writes enabled.
- Customers, one action at a time. Returns first, because they are reversible. Then cancellations. Refunds under the ceiling last, after the refund-amount metric has a stable baseline.
- Widen the ceilings only when the live metrics have held for a month and the offline suite has grown to cover what the month surfaced.
10Takeaways
- Classify the operations first: read or write, reversible or not, how much damage. Everything else keys off that.
- Thirty operations is not thirty tools. Merge duplicates, name reads and actions differently, load by intent, add a verified finish tool.
- Identity from the session, ownership checked by the adapter, scopes per principal, thresholds on money. The model enforces none of it.
- Key every create, send and money movement from the operation's identity, with a ledger where the service has no key support.
- The gate is a runtime check on the transcript, not a prompt instruction, and escalation is always available.
- Four levels of evaluation: scopes and keys as unit tests, the loop against stubs, four hundred conversations run three times each, and live metrics by intent.
- Roll out by principal and by reversibility. Shadow, then assist, then reads, then reversible actions, then money.