Chapter 65 · Tools and actions
From answering to doing
The moment the cost of a mistake changes. What is different about a system that acts, the four new failure classes, and the one rule that governs the whole section.
11 min read
Sections 1 to 5 built something that reads documents and writes grounded answers. It is useful and it is safe in a specific sense: the worst thing a wrong answer does is mislead one person who can check it.
This section removes that property. A tool that drafts a quote, opens a ticket or updates an order changes the world, and a wrong one changes it wrongly.
What actually changes
| Answering | Doing | |
|---|---|---|
| Worst case | One person misled | A customer invoiced wrongly |
| Reversible | The reader re-reads | Sometimes not |
| Detectable | Citations let them check | The effect is elsewhere |
| Who notices | The asker | Someone downstream, later |
| Test cost | Free, deterministic | Needs a sandbox and fixtures |
| Blast radius | One answer | Everything the tool touches |
The third and fourth rows together are the hard part. A wrong answer is in front of the person who asked, who has context and can spot it. A wrong action lands in a system somebody else reads next Tuesday.
The four new failure classes
Sections 4 and 5 had their own failures — not retrieved, ungrounded, hallucinated citation. These are new and none of them has an analogue in a read-only system.
1. The right action on the wrong object
"Draft a quote for the Henderson order at the usual discount."
Two Henderson accounts exist. The model picks one. The quote is correctly constructed, correctly priced, and sent to the wrong customer.
Everything the model did was right except an identity resolution it was never equipped to do. This is the most common class and chapter 69 is largely about it.
2. The action taken twice
A tool call times out. The retry succeeds. Two tickets, or two quotes, or two credit notes. Chapter 73.
3. The action that should have been refused
The asker may read an account and not change it. Retrieval enforces the read boundary; the tool must enforce the write boundary, and they are different boundaries. Chapter 70.
4. The chain that went further than intended
"Can you check whether the Henderson discount is being applied?"
The model checks, finds a discrepancy, and fixes it — because a tool to fix it existed and fixing it seemed helpful. Nobody asked for a change. Chapter 76.
The governing rule
Everything in this section follows from one principle, and it is worth stating before any code.
The model decides what to attempt. Your code decides whether it is allowed and what actually happens.
// The shape this produces. The model's output is a REQUEST, never a command.
export interface ToolRequest {
tool: string
arguments: Record<string, unknown>
}
// Your code is the thing that acts.
export async function executeToolRequest(
request: ToolRequest, scope: Scope, context: RunContext,
): Promise<ToolResult> {
const tool = TOOLS[request.tool]
if (!tool) return { ok: false, error: 'unknown_tool' }
// 1. Permission. Chapter 70 — the same Scope object as retrieval.
if (!tool.permitted(scope)) return { ok: false, error: 'not_permitted' }
// 2. Argument validation, including identity resolution. Chapter 69.
const args = tool.schema.safeParse(request.arguments)
if (!args.success) return { ok: false, error: 'invalid_arguments', issues: args.error.issues }
// 3. Confirmation, where the action warrants it. Chapter 72.
if (tool.requiresConfirmation && !context.confirmed) {
return { ok: false, kind: 'needs_confirmation', preview: await tool.preview(args.data, scope) }
}
// 4. Idempotency. Chapter 73.
const key = idempotencyKey(request, context)
const existing = await findCompleted(key)
if (existing) return existing
// 5. Audit, before and after. Chapter 124.
return withAudit(key, scope, request, () => tool.run(args.data, scope))
}Five gates, none of which the model participates in. That function is the spine of section 6, and every chapter fills in one of its lines.
What Atlas gains
The first slice was deliberately read-only. The tools now in scope, from the source registry:
| Tool | Kind | Reversible | Confirmation |
|---|---|---|---|
check_inventory | Read | n/a | No |
get_order_status | Read | n/a | No |
lookup_discount_tiers | Read | n/a | No |
search_tickets | Read | n/a | No |
draft_quote | Write, internal | Yes — a draft | No |
open_ticket | Write, internal | Partly | No |
send_quote | Write, external | No | Yes |
apply_credit | Write, financial | No | Yes |
Two columns decide almost everything about how a tool is treated, and they are not the same column. A draft quote is a write and is reversible; sending it is not. Chapter 71 is about that boundary.
Read tools first, and for longer than feels necessary
The gate between the first two stages is the one that matters. If the model picks the wrong account 3% of the time on a read tool, it will pick the wrong account 3% of the time on send_quote — and the first case shows a rep the wrong stock level while the second sends a customer somebody else's pricing.
// So argument accuracy is measured on read tools, where it is free, before
// any write tool exists.
export async function argumentAccuracy(cases: ToolCase[]) {
const rows = await Promise.all(cases.map(async (c) => {
const request = await planToolCall(c.question, { scope: scopeFor(c) })
return {
correctTool: request?.tool === c.expect.tool,
// ⚠️ Exact match on identifiers. "close enough" is the wrong account.
correctArgs: deepEqual(request?.arguments, c.expect.arguments),
}
}))
return { tool: mean(rows.map((r) => +r.correctTool)),
args: mean(rows.map((r) => +r.correctArgs)) }
}Atlas's first measurement on eight read tools, 60 cases: correct tool 0.97, correct arguments 0.91. Nine percent wrong arguments is a long way from a write tool being acceptable, and finding that on read tools cost nothing.
Tools change the context budget
Fourteen tool definitions is roughly 1,800 tokens on every request, which chapter 60 allocated 1,200 to. Something gives.
// Only send the tools this request could plausibly need. Chapter 67 covers
// the selection; the point here is that a tool surface is a context cost
// as much as a design surface.
const relevant = selectTools(question, scope, { maxTokens: BUDGET.tools })This is also a quality lever rather than only a cost one. A model choosing among four relevant tools picks correctly more often than one choosing among fourteen, and the effect is large enough to measure — on Atlas, correct-tool accuracy went from 0.97 to 0.99 when the surface was filtered per request.
What does not change
Worth saying, because the instinct is to rebuild everything.
Permissions are the same Scope object. Chapter 13 insisted every path consume one scope rather than deriving its own, specifically so that section 6 would not become a second permission implementation.
Abstention still applies. A model that cannot determine which account is meant should ask, not guess. The failure mode is identical and the cost is higher.
Evaluation is still mechanical. Tool selection and argument construction are exactly checkable — more so than prose — and chapter 78 tests them without a model at all.
The failure mode: the tool that was added because it was easy
open_ticket is added in an afternoon. It is a single API call, it is internal, a stray ticket is harmless, and it demos well.
Three weeks later: 340 tickets opened by Atlas, of which about 90 are duplicates of tickets a rep had already raised, and the support team has started filtering them out — which means they are also filtering out the ones that matter.
Nothing malfunctioned. The tool worked every time it was called. It was called in situations where a human would have checked first, because nothing told the model that an existing ticket might cover the issue.
// The missing piece is not in the tool. It is in the surface: a write tool
// whose precondition is a read should not exist without that read.
export const openTicket: Tool = {
name: 'open_ticket',
description:
'Open a support ticket. ⚠️ Call search_tickets first and confirm no open ' +
'ticket already covers this issue. Do not open a second ticket for a ' +
'problem that is already tracked.',
// ⚠️ And enforced, not merely requested. Chapter 76.
requires: ['search_tickets'],
async run(args, scope, context) {
if (!context.called.includes('search_tickets')) {
return { ok: false, error: 'precondition_not_met',
message: 'Search existing tickets before opening a new one.' }
}
// ...
},
}The general lesson, and it is the one this section is organised around:
A tool is not a function you expose. It is a decision you delegate, and the guard rails belong in your code rather than in the sentence describing it.
That requires check is three lines and it is the difference between a description the model usually follows and a precondition it cannot skip — the same distinction as permissions in the prompt against permissions in the where clause, applied to actions.
Key points
- A wrong answer misleads the person who asked; a wrong action lands in someone else's system and is noticed later.
- Four new failure classes: right action on the wrong object, action taken twice, action that should have been refused, chain that went too far.
- The model decides what to attempt; your code decides whether it is allowed and what happens.
- Five gates before execution: permission, argument validation, confirmation, idempotency, audit. The model participates in none of them.
- Reversibility and write-ness are different axes. A draft is a reversible write; sending it is not.
- Measure argument accuracy on read tools first. Atlas was at 0.91, which is nowhere near write-ready, and finding that cost nothing.
- Filter the tool surface per request. It is a context cost and a quality lever: 0.97 to 0.99 correct-tool accuracy.
- Permissions, abstention and mechanical evaluation all carry over unchanged.
- A tool is a delegated decision. Preconditions belong in code, not in the description.