# 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](https://zidane.si/docs/extensions-hooks.md)).

## How hooks work

Hooks run on [hookable](https://github.com/unjs/hookable). zidane awaits handlers in registration order. They share one context object per firing, so the last write to a field wins.

```ts
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](https://zidane.si/docs/errors.md)) | |
| `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](https://zidane.si/docs/sessions.md#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](https://zidane.si/docs/durability.md)). | `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](https://zidane.si/docs/decisions.md)).

### 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: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](https://zidane.si/docs/sub-agents.md#observing-children). An extension's `tool.call` and `tool.result` interceptors already see sub-agents' calls ([extension hooks](https://zidane.si/docs/extensions-hooks.md#toolcall)).

## Examples

```ts
// 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](https://zidane.si/docs/providers.md)). |
| `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](https://zidane.si/docs/sessions.md#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.

```ts
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>`.

```ts
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:

```ts
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](https://zidane.si/docs/extensions-hooks.md): `hooks.on` and the interceptors, for extensions.
- [`zidane/agent`](https://zidane.si/docs/reference/agent.md)
- [`zidane/types`](https://zidane.si/docs/reference/types.md)
