Skip to content

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.

TypeScript
agent.hooks.hook('tool:gate', (ctx) => {
  if (ctx.name === 'shell' && String(ctx.input.command).includes('rm -rf')) {
    ctx.block = true
    ctx.reason = 'dangerous command'
  }
})
RuleDetail
Policy gates winallowed-tools, writeFileExistingPolicy, toolBudgets, repeatGuard, dedupTools and lazy MCP disclosure install after your hooks. Each skips a call that already has block or result set.
outputBytesOn *:transform: bytes the tool produced. On *:after: bytes the model sees. Compute it yourself with toolOutputByteLength().
ctx.coercionsAbsent when no argument was coerced. Guard with if (ctx.coercions).
Identity fieldsEvery 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#

HookFiresMutable
system:beforerun({ system }) set a run-level system prompt
agent:startOnce per agent.run(), after session:start
turn:beforeBefore each turn's provider.stream()options (last resort, use context:transform for messages)
context:transformBefore each LLM callmessages
system:transformPer request, after context:transformsystem
stream:startRight before provider.stream()
stream:thinking / stream:textEach reasoning / text chunk
stream:endText stream complete (only if there was text)
stream:progressEvery 250 ms at most while the turn streams
stream:server_tool_use / stream:server_tool_resultA provider-executed tool (e.g. Anthropic web search) opens / closes
stream:errorThe stream fails for good before stream:end. Not on user abort or a recovered retry.
stream:retryRetryable provider error caught, about to back off (Errors)
model:fallbackTerminal failure on the active target, moving to the next
context:overflowcontext_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:endbehavior.autoCompact run (reason: 'auto') or agent.compact() (reason: 'manual') (compaction)
steer:injectA queued agent.steer or agent.followUp message enters the conversation: id (the caller's), text, content (its parts)
turn:afterAfter each assistant turn, even on error
tool-results:afterTool-results turn pushed to the transcript
usagePer-turn usage
outputStructured output extracted
agent:doneRun finished, any exit path
agent:abortOnce, when the run was aborted

Tool and MCP#

HookFiresMutable
tool:gateBefore 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:dispatchedAfter the gate, validation or unknown-tool check, with the outcome
tool:beforeExecution starts
tool:unknownModel called an unregistered toolresult, suppressError
tool:transformAfter the tool resultresult, isError
tool:afterTool complete
tool:errorTool threwresult (payload sent to the model)
tool:cancelledagent.cancelTool(callId)
mcp:tool:gateBefore each MCP callblock, reason, result
mcp:tool:beforeThe MCP call is sent
mcp:tool:transformAfter an MCP resultresult
mcp:tool:after / mcp:tool:errorMCP call complete / failed
validation:reject / validation:coerceArgs failed validation / were coerced

MCP connection and skills#

HookFiresMutable
mcp:bootstrap:start / mcp:bootstrap:endPer server, around connect + listTools
mcp:bootstrap:settledOnce per batch, after every server settled. results is sorted by name.
mcp:tools:listPer server, with the raw tool list before filters (to cache a catalog)
mcp:connect / mcp:error / mcp:warn / mcp:closeConnection lifecycle
mcp:tools:filterPer server, after config allow/deny, before registrationtools
mcp:auth:required / mcp:auth:url / mcp:auth:success / mcp:auth:errorOAuth login
skills:resolveOnce per agent, after discovery
skills:catalogOnce per agent, after catalog buildcatalog
skills:activate / skills:deactivateSkill toggled

Session and background tasks#

HookFires
session:start / session:endRun boundaries, always
session:turnsTurns persisted
session:meta / session:saveMetadata write / store write
background:start / background:exit / background:stallBackground task spawned, exited, stalled
background:reassignA destroyed sub-agent's running task moved to the parent
background:adoptA rebuilt agent took over a task its host kept running
background:reconcileA run starts with finished tasks to report, before the <task-notification> blocks
execution:readyOnce per agent, when the execution handle is bound
tool-state:commit-failedThe toolState store rejected a tool's durable state. The call itself keeps its result.

Decisions#

HookFires
decision:startctx.decide / agent.decide called
decision:endDecision settled (ok, reason, usage, answers)

Every other hook context carries ctx.decide, bound to the active run. It never throws (decisions).

Budgets and guards#

HookFires
budget:exceededTurn's tool-output bytes over behavior.toolOutputBudget
tool-budget:exceededA behavior.toolBudgets cap tripped
repeat-guard:exceededbehavior.repeatGuard tripped

Provider, cache and repair#

HookFiresMutable
cache:breakA turn read much less cached prefix than the last one. When system changed, systemSections names the part.
cache:driftAt turn:before, when the request diverges from the last turn's prefix. When system changed, systemSections names the part.
history:repairThe loop removed reasoning state the provider rejected from history
provider:input-transformationsThe provider dropped replayed thinking server-side
provider:diagnosticThe provider reports a diagnostic for the turn
oauth:refreshThe provider refreshed its OAuth credentials mid-run
pairing:repairOnce per repair of a tool call without a result, before sending
content-ref:resolveA block carries a persisted content ref. Set ctx.data to its bytes.data
tracing:redactBefore tracing, metrics or logs record a payloadredacted
replay:doneA durable runtime switched from replay to live execution
tool-batch:resumedA 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:mismatchagent.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:after and artifact: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#

TypeScript
// 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.

OptionDefaultEffect
toolOutputBudgetoffWhen 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).
compactLowWaterFractionoffWith '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.
compactSkipoff{ 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.
autoCompactoff in the SDK, on in the appsSummarizes older turns when the context fills up. See compaction.
elideStaleReadsfalse (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#

OptionDefaultEffect
dedupReadsonAn 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.
requireReadBeforeEditoffedit / 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.
trackShellReadsonCounts 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.

ConcurrencyTools
Saferead_file, glob, grep, list_files, todoread, spawn
Per callshell: read-only commands (ls, cat, git status, rg) run in parallel. rm, npm install, pipes, redirects, chains and subshells run alone.
Unsafeedit, 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.

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

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

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