Agents and tools
Safe tool execution
Your executor owns auth, idempotency and approvals. The model proposes. It does not authorize.
Read tools are cheap to retry. Write tools are not. Models retry when results look empty, when they lose track of state, or when your error message says "try again". Side effects need the same seriousness you would give a public HTTP API.
Concept
Before you start: your runtime is the trusted executor in the tool loop. Idempotency means a duplicate request has no extra effect. See idempotency.
Three controls
Idempotency. Accept a client-generated idempotency_key, or derive one from tool_use_id, so a duplicate create_refund does not double-pay.
Confirmations. Destructive or irreversible actions — payments, deletes, outbound email, production deploys — should require a human approval gate or a second explicit user confirmation before your executor runs them. The model proposing delete_account is not the same as the user clicking Approve.
Permissions. Scope credentials to the minimum: read-only DB role for lookup tools, separate write credentials for mutations, per-tenant tokens so one user cannot query another. Enforce auth in your code using the session user id. Never trust a user_id argument from the model as authorization.
Warning
Treat tool results as untrusted text. A web page, email or PDF can contain instructions aimed at the model (prompt injection). Never let retrieved content grant new permissions or override system policy. More in guardrails and safety.
async function executeTool(name: string, input: unknown, ctx: RequestContext) {
assertAllowed(ctx.user, name) // authz before anything else
if (isDestructive(name) && !ctx.approved) {
return { status: "needs_approval", preview: summarize(name, input) }
}
return await withIdempotency(ctx.toolUseId, () => handlers[name](input, ctx))
}That snippet is a sketch of the trust boundary, not a full SDK. The order matters: authorize, then maybe ask for approval, then run once under an idempotency key.
Walking through a double submit
The model calls create_refund for ORD-12345 / $48. Your executor starts the refund. The HTTP call to the model times out. The loop retries with the same tool_use_id.
first call tool_use_id=call_17 → refund row created, $48
retry tool_use_id=call_17 → same key, return the existing row
without a key → second refund, $96 chargedKey on tool_use_id (or a business key such as order_id + action) and store it in the database with a unique constraint. Measure double-submit rate. It should be ~0.
A practical rollout: ship read-only tools first and measure selection quality. Add writes with approval plus idempotency. Tighten output shapes until average tool-result tokens stop growing. Only then open the loop into a fuller agent with higher step caps.
Common mistakes
- Trusting
user_idfrom the model. Bind identity from the session. - Acking a write with no idempotency key. Timeouts will double it.
- Treating a retrieved page as instructions. Results are data, not policy.
Check yourself
- The model passes
user_id=other-customer. What does the executor use? The session user. The argument is not authorization. - Why key writes on
tool_use_id? A timeout retry would otherwise create a second side effect.
Key takeaways
- The model proposes. Your code authorizes, confirms and deduplicates.
- Make writes idempotent. Require approval for destructive actions.
- Never trust tool results as instructions.
- Ship reads first. Add writes only with those controls in place.
Further learning
Idempotency, guardrails. Next, how you know the tools work: evaluating tool use.
Related
Tool use and function calling
Letting models call your code: tool schemas, the tool loop, and designing tools models use well.
Idempotency and retries
Making operations safe to retry, with idempotency keys, deduplicating consumers and jittered backoff, so at-least-once delivery never means a double charge.
Guardrails, security and safety
Prompt injection is unsolved. Cut blast radius with architecture, not a classifier that claims to detect it.