Skip to content

A customer-support agent

A customer writes in: “Order #4021 arrived damaged — I want my money back.”

A useful support agent has to do four things that a chatbot does not: look the order up in the system of record, apply your actual refund policy, stop and get a human when the amount crosses a line, and be callable from the web app that already owns the conversation. Each of those is one shipped toolnexus primitive, and they compose without glue.

Reach for this shape when… Look elsewhere when…
The agent must read real systems (order DB, CRM, billing) — not a vector index of last month’s export You only need retrieval over documents; a skill or an HTTP tool is enough on its own
Some actions are irreversible (refunds, cancellations, credits) and need approval above a threshold Everything the agent can do is read-only — skip the suspension layer entirely
Another system (a web app, a helpdesk, another agent) calls the agent, rather than a human sitting at a terminal A human drives a CLI — you don’t need serve
Volume is untrusted: anyone can open a ticket, so cost must be bounded per conversation Internal, rate-limited, trusted callers
Layer What it is here Primitive
Systems of record Orders service (stdio MCP), CRM (remote MCP with a bearer token) mcpConfig + per-server tools allowlist
Knowledge & policy refund-policy/SKILL.md, shipping-claims/SKILL.md — loaded only when relevant skillsDir + the skill tool
The irreversible action An issue_refund tool that suspends above a threshold pending(Request)waitFor / durable status:"pending"
The front door The web app POSTs a task; the agent answers toolkit.serve(addr, { client, a2a })
Cost control Turn cap per run; token/turn budget when run as an agent maxTurns, Budget

The orders service runs locally over stdio; the CRM is a hosted MCP endpoint behind a bearer token. Both live in one file, and neither needs a line of integration code.

{
"mcpServers": {
"orders": {
"type": "local",
"command": ["node", "./mcp-servers/orders/index.js"],
"timeout": 15000,
"tools": {
"get_order": true,
"list_shipments": true,
"get_return_label": true
}
},
"crm": {
"type": "remote",
"url": "https://crm.internal.example.com/mcp",
"headers": { "Authorization": "Bearer ${CRM_TOKEN}" },
"timeout": 15000,
"tools": {
"find_customer": true,
"get_tickets": true
}
}
}
}

That tools map is the load-bearing part. It is the per-server allowlist, keyed on the server’s original tool name and applied before the <server>_<tool> prefix is added:

Filter shape Meaning
absent / empty expose all of that server’s tools
≥ 1 entry true allowlist — only the true names are exposed
entries only false drop-list over the all-on baseline
unknown name ignored (with a warning) — never an error

So even if the orders server also ships issue_refund and cancel_order, the model never sees them: they are not in the allowlist, so they are not in the toolkit, so they cannot be called. The refund path goes through your gated tool instead (step 3).


Refund rules change. Prompt strings that encode them rot, and stuffing all of them into every request burns tokens on the 95% of tickets that are “where is my package”.

Agent skills give you progressive disclosure: only the one-line description of each skill sits in the system prompt; the full body loads on demand when the model calls the skill tool.

support-skills/
refund-policy/
SKILL.md # thresholds, exclusions, who approves what
matrix.md # sampled as a sibling file, referenced by the body
shipping-claims/
SKILL.md
tone-of-voice/
SKILL.md
---
name: refund-policy
description: Rules for issuing, splitting, or refusing a refund, including approval thresholds.
---
Refunds under 50 USD on a damaged-on-arrival claim may be issued directly.
At or above 50 USD, call `issue_refund` anyway — it will pause for a human
approver. Never promise the customer a refund before the tool returns.
Exclusions and the per-category matrix: see `matrix.md` in this directory.

The catalog injected into the system prompt is just:

Skills provide specialized instructions and workflows for specific tasks.
Use the skill tool to load a skill when a task matches its description.
## Available Skills
- **refund-policy**: Rules for issuing, splitting, or refusing a refund, including approval thresholds.
- **shipping-claims**: ...
- **tone-of-voice**: ...

3. The refund gate — one tool that suspends

Section titled “3. The refund gate — one tool that suspends”

This is the only bespoke code in the whole scenario. A native tool that decides, mid-execution, that it cannot finish alone — and returns pending(...) instead of a result.

import { defineTool, pending } from "toolnexus"
const THRESHOLD = 50
const issueRefund = defineTool({
name: "issue_refund",
description: "Refund an order. Amounts at or above the approval threshold require a human.",
inputSchema: {
type: "object",
properties: {
orderId: { type: "string" },
amount: { type: "number" },
reason: { type: "string" },
},
required: ["orderId", "amount", "reason"],
},
run: async ({ orderId, amount, reason }, ctx) => {
// First pass: over threshold and not yet approved → suspend.
if (amount >= THRESHOLD && !ctx?.answer) {
return pending({
kind: "approval",
prompt: `Approve ${amount} USD refund on order ${orderId}? Reason: ${reason}`,
data: { orderId, amount, reason },
})
}
// Retry pass: ctx.answer is present and ok — the approver said yes.
return await billing.refund(orderId, amount) // your own billing call
},
})

Three properties of this gate matter in production:

  • The threshold is enforced in code, not in the prompt. The model cannot talk its way past it.
  • A suspension is not a tool error. The tool observability event carries isError: false with a pending: true marker, so your error-rate dashboards and circuit breakers do not light up every time a human is asked something.
  • The retry is bounded. On ok: true the loop re-executes the same tool with the same args exactly once, with the Answer in ctx.answer. If it suspends again, the loop feeds back "unresolved: <prompt>" — it never spins.

4. Two approval postures — pick by when the human answers

Section titled “4. Two approval postures — pick by when the human answers”

This is the one design decision on the page, and it is not about what you’re asking. It is about whether an approver is available inside this request or hours later.

The approver answers… Give the client… What run()/ask() does
Now — an on-shift agent in a console you can await (seconds) a waitFor Blocks on waitFor, feeds the answer back, runs on to status: "done". The pause is invisible to the caller.
Later — a supervisor approves in a web queue, maybe tomorrow, maybe in another process no waitFor Returns status: "pending" with the Request. You persist it, render it, resume when it’s answered.

The approver is on shift. The waitFor hits your approvals service and blocks the run until it answers.

const client = createClient({
baseUrl, style: "openai", model,
maxTurns: 8,
waitFor: async (req) => {
const decision = await approvals.askOnShift(req.prompt, req.data) // your service
return { id: req.id, ok: decision.approved, reason: decision.approved ? undefined : "declined" }
},
})
const result = await client.run(ticketText, { toolkit })
// result.status === "done" — the refund ran only after an approver said yes

ok: false is not an error either — the loop feeds "declined: <prompt>" back to the model, which then explains the refusal to the customer in its own words. The optional reason ("declined" | "cancelled" | "expired") is advisory; the loop branches only on ok.

Posture B — durable (a web approval queue)

Section titled “Posture B — durable (a web approval queue)”

The HTTP request that started the run has to return now, and a supervisor will approve later — possibly in a different process, after a deploy. Configure no waitFor and use ask with a conversation id so the transcript is stored and replayable.

Phase 1 — halt and hand back the request.

const result = await client.ask(ticketText, { toolkit, id: ticketId }) // no waitFor configured
if (result.status === "pending") {
const req = result.pending! // { id, kind, prompt, url?, data? } — plain data
await approvalQueue.insert({ ticketId, request: req })
return reply(200, "Escalated to a supervisor for approval.") // nothing is blocked
}
return reply(200, result.text)

Phase 2 — the supervisor clicks Approve; resume the same conversation. Same id, a client whose waitFor returns the decision you now hold.

const resume = createClient({
baseUrl, style: "openai", model, maxTurns: 8,
waitFor: async (req) => ({ id: req.id, ok: decision.approved }),
})
const done = await resume.ask(ticketText, { toolkit, id: ticketId })
// done.status === "done" — the transcript replayed, issue_refund re-ran with ctx.answer set

The support agent should not be a script you run. Your helpdesk backend needs to POST a ticket and get an answer. toolkit.serve mounts an A2A endpoint: an Agent Card at /.well-known/agent-card.json built from your skills, and JSON-RPC SendMessage / GetTask at POST /.

const handle = await tk.serve("127.0.0.1:8080", {
client, // the client that fulfils each inbound task
a2a: {
name: "support-agent",
description: "Answers order and refund questions from the systems of record.",
skills: ["refund-policy", "shipping-claims"], // what the card advertises
store: "file:./tasks", // survives a restart
},
onTask: ({ id, state, result }) => metrics.record(id, state, result?.usage),
})
console.log(handle.url) // stop with handle.stop()

Two behaviors make this usable as a real backend:

  • Conversation continuity for free. When an inbound message carries an A2A contextId, the server fulfils it via ask(text, { toolkit, id: contextId }) — so a customer’s follow-up turns continue the same transcript through the client’s ConversationStore.
  • Suspension crosses the wire. If a run halts pending approval and no waitFor is set, the A2A layer reports the task as input-required with the prompt in the status message — never a bogus completed. Your web app sees “waiting on approval”, not a fabricated answer.

6. Budgets — a chatty customer cannot cost unbounded

Section titled “6. Budgets — a chatty customer cannot cost unbounded”

Anyone can open a ticket, so the blast radius of one conversation must be capped in code.

Control Where What it stops
maxTurns (default 10) client option An unbounded tool-calling loop. Exhausting it returns status: "incomplete" with limit: "maxTurns"loud, never a silent "done", and the partial transcript is preserved.
Budget { maxTurns, maxTokens, maxToolCalls, maxWallMs, maxChildren, maxConcurrent, maxDepth } agent runtime Token/wall/tool-call spend across a whole agent tree. Enforced by a live ancestor-chain walk before every turn and spawn, so sibling spend counts.
onBudget(info) → "stop" | "extend" | "suspend" agent runtime Lets you escalate instead of dying — "suspend" routes the overrun through the human-in-the-loop layer as an approval (“this ticket wants another 20k tokens, OK?”).
timeoutMs + retries client options A wedged provider. Whole-run deadline; retries on 429/5xx with backoff and Retry-After.

Run the support agent as an agent rather than a bare client when you want the token budget:

import { agents } from "toolnexus"
const support = agents.agent("support", {
does: "answers order and refund questions",
uses: { tools: tk.tools() }, // the agent's toolkit VIEW — scoping is the security model
soulFile: "./support/AGENTS.md",
budget: { maxTurns: 8, maxTokens: 40_000, maxToolCalls: 20, maxWallMs: 60_000 },
})
const r = await support.run(ticketText, { llm: { baseUrl, style: "openai", model } })
if (r.status === "incomplete") escalateToHuman(r) // the limit is named on the result

The whole agent, end to end: systems over MCP, policy as skills, the gated refund tool, an inline approver, and an A2A front door.

import { createToolkit, createClient } from "toolnexus"
const tk = await createToolkit({
mcpConfig: "./mcp.json", // orders (stdio) + crm (remote), each allowlisted
skillsDir: "./support-skills", // refund-policy, shipping-claims, tone-of-voice
extraTools: [issueRefund], // the gated action
disableTools: ["bash", "write"], // no shell, no writes on a customer-facing agent
})
const client = createClient({
baseUrl: "https://openrouter.ai/api/v1",
style: "openai",
model: "openai/gpt-4o-mini",
systemPrompt: "You are the support agent for Acme. Never promise a refund before the tool returns.",
maxTurns: 8,
timeoutMs: 60_000,
waitFor: async (req) => ({ id: req.id, ok: await approvals.askOnShift(req.prompt) }),
})
const handle = await tk.serve("127.0.0.1:8080", {
client,
a2a: { name: "support-agent", store: "file:./tasks" },
})
console.log("serving on", handle.url, "mcp:", tk.mcpStatus())

What this scenario does not give you. These are host concerns by design — the library is the tool/loop layer, not your product.

Not included Why, and what you do instead
A ticketing UI No inbox, no queue view, no agent console. serve() is a JSON-RPC endpoint; the front end is yours (or your existing helpdesk calling it).
End-user authentication The library never learns which customer is asking. Authenticate at your gateway and pass the verified identity into the prompt or tool args — never let the model assert who it is talking to.
Authorization on the approval waitFor returns an Answer; the library does not check that the approver was entitled to approve. Enforce that inside your approvals service.
Auth on serve() No authentication in the A2A or MCP serve profiles. Loopback or private network + your own gateway.
OAuth for MCP servers Out of scope for v1 — pass a bearer token via headers with ${ENV} expansion. Interactive login is expressible as a kind:"authorization" suspension, but the token dance lives in your waitFor.
PII redaction Nothing scrubs customer data before it reaches the model. Use the beforeTool / afterTool hooks to redact arguments and outputs at the boundary.
Anything the model says The gate is the tool, not the sentence. The threshold check runs in code precisely because the model’s promise (“you’ll get a refund!”) is not enforceable. Set expectations in the skill body.
Approval expiry Request.expiresAt is carried but the library does not sweep stale requests. Your queue decides when a pending approval times out, and resolves it ok: false, reason: "expired".

Back to all scenarios