Hooks
Observe and change each step of the turn loop, and tune budgets, dedup and tool concurrency.
A hook is a handler zidane calls at a step of the turn loop. Most hooks only observe. Mutable hooks can block a tool call, substitute a result, or rewrite messages and the system prompt. The behavior options for budgets, dedup and tool concurrency are at the end of this page.
This page is for code that builds an agent. An extension uses ctx.hooks.on to observe these hooks, and ctx.hooks.intercept in place of the mutable ones: tool.call for tool:gate, tool.result for tool:transform, and so on (extension hooks).
How hooks work#
Hooks run on hookable. zidane awaits handlers in registration order. They share one context object per firing, so the last write to a field wins.
agent.hooks.hook('tool:gate', (ctx) => {
if (ctx.name === 'shell' && String(ctx.input.command).includes('rm -rf')) {
ctx.block = true
ctx.reason = 'dangerous command'
}
})| Rule | Detail |
|---|---|
| Policy gates win | allowed-tools, writeFileExistingPolicy, toolBudgets, repeatGuard, dedupTools and lazy MCP disclosure install after your hooks. Each skips a call that already has block or result set. |
outputBytes | On *:transform: bytes the tool produced. On *:after: bytes the model sees. Compute it yourself with toolOutputByteLength(). |
ctx.coercions | Absent when no argument was coerced. Guard with if (ctx.coercions). |
| Identity fields | Every tool:* and mcp:tool:* context carries optional runId, parentRunId (sub-agent sharing the parent's session), parentCallId (the spawn call's callId) and depth (0 at top level). |
Hook reference#
Turn and stream#
| Hook | Fires | Mutable |
|---|---|---|
system:before | run({ system }) set a run-level system prompt | |
agent:start | Once per agent.run(), after session:start | |
turn:before | Before each turn's provider.stream() | options (last resort, use context:transform for messages) |
context:transform | Before each LLM call | messages |
system:transform | Per request, after context:transform | system |
stream:start | Right before provider.stream() | |
stream:thinking / stream:text | Each reasoning / text chunk | |
stream:end | Text stream complete (only if there was text) | |
stream:progress | Every 250 ms at most while the turn streams | |
stream:server_tool_use / stream:server_tool_result | A provider-executed tool (e.g. Anthropic web search) opens / closes | |
stream:error | The stream fails for good before stream:end. Not on user abort or a recovered retry. | |
stream:retry | Retryable provider error caught, about to back off (Errors) | |
model:fallback | Terminal failure on the active target, moving to the next | |
context:overflow | context_exceeded caught. Older media and tool output were stubbed up to a cutoff, and the request is re-issued. Each attempt moves the cutoff further forward. Later turns and runs send the same stubs up front. | |
compact:start / compact:progress / compact:end | behavior.autoCompact run (reason: 'auto') or agent.compact() (reason: 'manual') (compaction) | |
steer:inject | A queued agent.steer or agent.followUp message enters the conversation: id (the caller's), text, content (its parts) | |
turn:after | After each assistant turn, even on error | |
tool-results:after | Tool-results turn pushed to the transcript | |
usage | Per-turn usage | |
output | Structured output extracted | |
agent:done | Run finished, any exit path | |
agent:abort | Once, when the run was aborted |
Tool and MCP#
| Hook | Fires | Mutable |
|---|---|---|
tool:gate | Before each tool call. On a resumed checkpoint run, resumed: true marks a call that passed the gate in the process that died (crash recovery). | block and reason refuse with an error result. result substitutes and skips execution. block wins. A resumed call ignores result, and block too unless its body runs again. resumeState (JSON, under a key of your own) is saved with the call's step record and comes back on a resumed call. |
tool:dispatched | After the gate, validation or unknown-tool check, with the outcome | |
tool:before | Execution starts | |
tool:unknown | Model called an unregistered tool | result, suppressError |
tool:transform | After the tool result | result, isError |
tool:after | Tool complete | |
tool:error | Tool threw | result (payload sent to the model) |
tool:cancelled | agent.cancelTool(callId) | |
mcp:tool:gate | Before each MCP call | block, reason, result |
mcp:tool:before | The MCP call is sent | |
mcp:tool:transform | After an MCP result | result |
mcp:tool:after / mcp:tool:error | MCP call complete / failed | |
validation:reject / validation:coerce | Args failed validation / were coerced |
MCP connection and skills#
| Hook | Fires | Mutable |
|---|---|---|
mcp:bootstrap:start / mcp:bootstrap:end | Per server, around connect + listTools | |
mcp:bootstrap:settled | Once per batch, after every server settled. results is sorted by name. | |
mcp:tools:list | Per server, with the raw tool list before filters (to cache a catalog) | |
mcp:connect / mcp:error / mcp:warn / mcp:close | Connection lifecycle | |
mcp:tools:filter | Per server, after config allow/deny, before registration | tools |
mcp:auth:required / mcp:auth:url / mcp:auth:success / mcp:auth:error | OAuth login | |
skills:resolve | Once per agent, after discovery | |
skills:catalog | Once per agent, after catalog build | catalog |
skills:activate / skills:deactivate | Skill toggled |
Session and background tasks#
| Hook | Fires |
|---|---|
session:start / session:end | Run boundaries, always |
session:turns | Turns persisted |
session:meta / session:save | Metadata write / store write |
background:start / background:exit / background:stall | Background task spawned, exited, stalled |
background:reassign | A destroyed sub-agent's running task moved to the parent |
background:adopt | A rebuilt agent took over a task its host kept running |
background:reconcile | A run starts with finished tasks to report, before the <task-notification> blocks |
execution:ready | Once per agent, when the execution handle is bound |
tool-state:commit-failed | The toolState store rejected a tool's durable state. The call itself keeps its result. |
Decisions#
| Hook | Fires |
|---|---|
decision:start | ctx.decide / agent.decide called |
decision:end | Decision settled (ok, reason, usage, answers) |
Every other hook context carries ctx.decide, bound to the active run. It never throws (decisions).
Budgets and guards#
| Hook | Fires |
|---|---|
budget:exceeded | Turn's tool-output bytes over behavior.toolOutputBudget |
tool-budget:exceeded | A behavior.toolBudgets cap tripped |
repeat-guard:exceeded | behavior.repeatGuard tripped |
Provider, cache and repair#
| Hook | Fires | Mutable |
|---|---|---|
cache:break | A turn read much less cached prefix than the last one. When system changed, systemSections names the part. | |
cache:drift | At turn:before, when the request diverges from the last turn's prefix. When system changed, systemSections names the part. | |
history:repair | The loop removed reasoning state the provider rejected from history | |
provider:input-transformations | The provider dropped replayed thinking server-side | |
provider:diagnostic | The provider reports a diagnostic for the turn | |
oauth:refresh | The provider refreshed its OAuth credentials mid-run | |
pairing:repair | Once per repair of a tool call without a result, before sending | |
content-ref:resolve | A block carries a persisted content ref. Set ctx.data to its bytes. | data |
tracing:redact | Before tracing, metrics or logs record a payload | redacted |
replay:done | A durable runtime switched from replay to live execution | |
tool-batch:resumed | A checkpoint run resumes a tool batch that a dead process left, before any call runs again. done lists the call ids rebuilt from their step records. resuming lists the ones that run again or are reported interrupted. | |
resume:mismatch | agent.resume() refused a run whose step records belong to another loop turn than its saved turns (resumeTurn, storedTurns, reason). The run is healed and resume() resolves null. |
cache:break and cache:drift fire only with the cache telemetry module installed (installCacheBreakLogger). systemSections lists skillsCatalog, toolsCatalog, mcpInstructions, rules (contextSections.rulesBlock), hostStatic or hostDynamic (the rest of the prompt above or below SYSTEM_PROMPT_BOUNDARY). The logger keeps its baseline per session id (sessionBaseline, default on, top-level runs only), so a rebuilt agent's first request is compared with the previous agent's last one. replay:done comes from durable-runtime adapters, never from the core loop.
Sub-agent bubble events#
spawn:queued, spawn:before, spawn:complete and spawn:error fire on the parent for its own spawn calls. These child events re-fire on the parent with a child: prefix plus childId and depth:
- stream text, thinking, end, error, retry and server-tool events,
tool:dispatched/before/after/error/cancelled,validation:reject,repeat-guard:exceeded,background:*,turn:afterandartifact:recorded.
The child's own spawns arrive as child:spawn:*. To gate and rewrite a child's tool calls (child:tool:gate and siblings), see observing children. An extension's tool.call and tool.result interceptors already see sub-agents' calls (extension hooks).
Examples#
// Substitute a no-op instead of a repeat call (block wins if both are set).
agent.hooks.hook('tool:gate', (ctx) => {
if (ctx.name === 'todowrite' && (ctx.runToolCounts.todowrite ?? 0) > 0)
ctx.result = 'Already recorded; no-op.'
})
// Redact secrets before the model sees a tool result.
agent.hooks.hook('tool:transform', (ctx) => {
if (typeof ctx.result === 'string')
ctx.result = ctx.result.replace(/\b(API_KEY|TOKEN|PASSWORD)\s*=\s*\S+/gi, '$1=<redacted>')
})
// Truncate large MCP results (sizes vary too much for a built-in default).
agent.hooks.hook('mcp:tool:transform', (ctx) => {
if (ctx.outputBytes <= 8192 || typeof ctx.result !== 'string')
return
const tail = ctx.result.slice(-4096)
ctx.result = `…(${ctx.outputBytes - tail.length} bytes truncated from head)…\n${tail}`
})
// Answer a hallucinated tool name instead of erroring.
agent.hooks.hook('tool:unknown', (ctx) => {
if (ctx.name === 'EnterPlanMode') {
ctx.result = 'EnterPlanMode is not available. Use shell to draft a plan as comments.'
ctx.suppressError = true
}
})
// Drop old turns past a soft cap.
agent.hooks.hook('context:transform', (ctx) => {
const KEEP_RECENT = 30
if (ctx.messages.length > KEEP_RECENT) {
const trimmed = [ctx.messages[0], ...ctx.messages.slice(-KEEP_RECENT + 1)]
ctx.messages.splice(0, ctx.messages.length, ...trimmed)
}
})
// Append to the system prompt on later turns.
agent.hooks.hook('system:transform', (ctx) => {
if (ctx.session && ctx.turn > 1)
ctx.system += `\n\n## Reminder: keep responses concise after turn ${ctx.turn}.`
})
// Observe.
agent.hooks.hook('turn:after', (ctx) => { /* ctx.turn, ctx.usage, ctx.message; always fires */ })
agent.hooks.hook('stream:text', (ctx) => { /* ctx.delta, ctx.text */ })
agent.hooks.hook('agent:done', (ctx) => { /* AgentStats, cumulative incl. children */ })zidane has no built-in handler for the mcp:tool:transform, tool:unknown and context:transform cases above. Most production agents need one of each.
Output and context budgets#
All options in this and the next sections live on behavior.
| Option | Default | Effect |
|---|---|---|
toolOutputBudget | off | When a turn's post-transform tool bytes exceed the cap, injects a "summarize before continuing" message. Fires budget:exceeded. |
compactStrategy: 'tail' | 'off' | On the wire only, elides older tool_result blocks once their total passes compactThreshold (default 128 KiB). The newest compactKeepTurns (default 4) stay intact. On Anthropic, prefer the server-side context-management-2025-06-27 beta (Providers). |
compactLowWaterFraction | off | With 'tail', once kept tool output passes the threshold, trims it to this fraction of the threshold in one step, then waits until it passes the threshold again. Fewer steps, so fewer cache re-bills. |
compactSkip | off | { providers?, minContextWindow? }. Skips 'tail' elision when the provider's name is listed and the context window is at least minContextWindow. On Anthropic with preserved thinking, each stubbed result drops the later thinking blocks. |
autoCompact | off in the SDK, on in the apps | Summarizes older turns when the context fills up. See compaction. |
elideStaleReads | false (also in chat profiles) | Acts after an edit to a file read earlier. 'notice' appends a system reminder and keeps the cached prefix. 'stub' (or true) replaces the stale read on the wire. It saves bytes but re-bills the cache. |
Read dedup and read-before-edit guard#
| Option | Default | Effect |
|---|---|---|
dedupReads | on | An identical re-read returns "unchanged since the previous read" (per-session content hash). After edit, multi_edit or write_file touches the file, the next read returns the full body. |
requireReadBeforeEdit | off | edit / multi_edit refuse when the file was not read this session or changed since. The refusal is a recoverable tool result telling the model to read_file first. write_file counts as a read. |
trackShellReads | on | Counts cat, head, tail and sed -n in shell (scripts included) as reads for the guard. Set false so only read_file counts. |
Tracking and dedup are independent: with dedupReads off, requireReadBeforeEdit still works. Keys are canonical absolute paths, so read_file('src/x') and edit('./src/x', …) match.
Tool dispatch#
Each ToolDef has isConcurrencySafe?: boolean | ((input) => boolean), default false.
Safe calls run in parallel, up to behavior.maxConcurrentTools (default 10). An unsafe call waits for in-flight calls, runs alone, then releases the queue. Results come back in submission order.
| Concurrency | Tools |
|---|---|
| Safe | read_file, glob, grep, list_files, todoread, spawn |
| Per call | shell: read-only commands (ls, cat, git status, rg) run in parallel. rm, npm install, pipes, redirects, chains and subshells run alone. |
| Unsafe | edit, multi_edit, write_file, todowrite, custom and MCP tools |
Set maxConcurrentTools: 1 to run calls one at a time when debugging.
behavior.dedupTools dedups any tool through a hasher keyed by canonical name. A returned string is a cache key, and equal keys replay the earlier result. undefined skips dedup for that call. Never list tools with side effects or changing output.
behavior: {
dedupTools: {
todowrite: input => JSON.stringify(input),
run_query: (input) => {
const q = typeof input.query === 'string' ? input.query.trim().toLowerCase() : undefined
if (!q || q.includes('now()') || q.includes('random()')) return undefined // non-cacheable
return q
},
},
}behavior.toolBudgets caps calls per tool per run. 'steer' lets the call run, then nudges the model once to commit. 'block' refuses with Blocked: <reason>.
behavior: {
toolBudgets: {
todowrite: { max: 6, onExceed: 'steer' },
run_query: { max: 3, onExceed: 'block' },
},
}behavior.repeatGuard (off in the SDK, on in chat profiles) breaks streaks of identical consecutive calls to shell and read_file. It blocks the Nth call with a pivot suggestion (blockThreshold, default 4, or 6 for read_file). It aborts the run at the Mth (abortThreshold, default 8, or 12 for read_file).
behavior.thinkingDecay lowers the thinking budget on later turns:
behavior: {
thinkingBudget: 8192,
thinkingDecay: { afterTurn: 5, floor: 1024 }, // turns 1-5: 8192, 6+: 1024
}With prompt caching on, use this step form over a per-turn function. The thinking budget is part of Anthropic's cache key, so each new value re-bills the full prefix.
Reference#
- Extension hooks:
hooks.onand the interceptors, for extensions. zidane/agentzidane/types