Guide

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.

TypeScript
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 charged

Key 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_id from 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

  1. The model passes user_id=other-customer. What does the executor use? The session user. The argument is not authorization.
  2. 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.