# Extension hooks

> Observe agent and host events with hooks.on, and take part in decisions (tool calls, prompts, retries, titles, HTTP) with hooks.intercept.

`ctx.hooks` has two calls. `on` observes an event: the handler reads it and cannot change it. `intercept` takes part in a decision: the handler gets the input and the result so far, and returns a new result or `undefined` to keep it.

Use `on` to count, log, notify or store. Use `intercept` to block a tool call, rewrite a result, answer a prompt, change a retry or set a session title. Agent code (not an extension) uses `agent.hooks.hook` instead ([hooks](https://zidane.si/docs/hooks.md)).

## Observe

```ts
ctx.hooks.on('tool:after', (event) => {
  ctx.logger.info('tool ran', { tool: event.name })
})

ctx.hooks.on('run:settled', (event) => {
  if (event.session)
    ctx.logger.info('run ended', { sessionId: event.sessionId, turns: event.session.turns.length })
}, { sessionId: 'a-session-id' })
```

`on(event, handler, { sessionId? })` returns a `Disposable`. With `sessionId`, the handler hears only that session's events.

`event` is any key of the agent hooks ([hook reference](https://zidane.si/docs/hooks.md#hook-reference)), plus two events the hosts send:

| Event | Fires | Fields |
| - | - | - |
| `run:settled` | After each run, on any exit | `sessionId`, `runId`, `ok` (no error), `aborted` (the user stopped it), `session`: `{ turns, metadata }` as the run left it, tool results included, or `null` when the session is gone |
| `session:event` | A run ended, or a session waits for the user | `sessionId`, `kind` (`done`, `error`, `needs-attention`), `error` for `error`, `requestId` for `needs-attention` |

Observers never hold up the agent. The hook returns without waiting for them. Each call has 5 seconds. A throw or a timeout is logged against the extension and counts toward its [error budget](https://zidane.si/docs/extensions-lifecycle.md#guards).

The event is a read-only view. Setting or deleting one of its fields throws a `TypeError`. The view is shallow: nested objects are the agent's own, so do not change them either.

Sub-agent events reach the parent as `child:*` events, such as `child:tool:after` ([sub-agent events](https://zidane.si/docs/hooks.md#sub-agent-bubble-events)). Inside an observer, `ctx.generate.decide` runs as the run's own decision: it fires the run's `decision:*` hooks and stops with the run ([sessions](https://zidane.si/docs/extensions-sessions.md#generate)).

## Intercept

```ts
ctx.hooks.intercept('tool.call', (call) => {
  const command = typeof call.input.command === 'string' ? call.input.command : ''
  if (/\brm\s+-rf\s+\/(?:\s|$)/.test(command))
    return { block: 'Refused: rm -rf /.' }
  if (/\bgit\s+push\b.*--force/.test(command))
    return 'ask'
  return undefined
}, { match: ['shell'] })
```

`intercept(name, handler, { priority?, match? })` returns a `Disposable`. The handlers of one name form a chain:

- They run in extension load order, then by `priority` (higher first), then in registration order.
- Each gets `input` and `current`, the result so far. The first gets the call site's default.
- A returned value replaces `current`. `undefined` keeps it.
- Some results end the chain: `{ block }` and `{ result }` for `tool.call`, `{ handled: true }` for `prompt`.

| Option | Type | Does |
| - | - | - |
| `priority` | `number` | Higher runs first among extensions at the same place in the load order. Default 0 |
| `match` | `string[]` | Run only for these tools (`tool.call`, `tool.result`), providers (`provider`, `http.request`, `http.response`) or MCP servers (`mcp.tools`). Other interceptors ignore it and log a warning |

`input` and `current` are read-only views, like observer events. Return a new object: spread `current` to keep the fields you do not change. Returning `current` itself is fine.

Each handler call has 5 seconds. A throw or a timeout skips the handler, logs the error against the extension, and the chain goes on with the result so far. `tracing.redact` runs on a hot synchronous path: its handler must not return a promise. One that does is counted as an error and skipped.

## Interceptor reference

| Name | Input | Returns | Runs |
| - | - | - | - |
| [`run`](#run) | `AgentRunOptions` | `AgentRunOptions` | At the start of each run |
| [`behavior`](#behavior) | `AgentBehavior` | `AgentBehavior` | At the start of each run, and before a manual compaction |
| [`prompt`](#prompt) | `PromptInput` | `PromptInput \| { handled: true }` | When the user submits text that is not a `/` command |
| [`system`](#system) | `{ sessionId?, system, messages, turn, turnId }` | `string` | Before each provider request |
| [`context`](#context) | `{ sessionId?, messages }` | `SessionMessage[]` | Before each provider request |
| [`tool.call`](#toolcall) | `ToolCall` | `ToolCallDecision` | Before each tool call |
| [`tool.result`](#toolresult) | `ToolCallResult` | `ToolResultDecision` | After each tool call, before the model sees the result |
| [`validation`](#validation) | `validation:coerce` event and `sessionId?` | The tool input | After the loop coerced a tool's arguments |
| [`skills.activate`](#skillsactivate) | `{ skills, run }` | Skill names | At the start of each run |
| [`mcp.tools`](#mcptools) | `mcp:tools:filter` event | Tool descriptors | Per MCP server, before its tools register |
| [`provider`](#provider) | `{ provider, descriptor }` | `Provider` | Once per built provider, on its first request |
| [`retry`](#retry) | `{ attempt, error, provider, model }` | `{ retry, delayMs? }` | When a request failed with an error the loop would retry |
| [`compaction`](#compaction) | `{ sessionId?, messages, reason }` | `{ summary } \| undefined` | When the loop compacts a conversation |
| [`title`](#title) | `{ sessionId, messages }` | `string \| undefined` | When the host titles a session |
| [`shell.env`](#shellenv) | `{ sessionId?, command, cwd }` | Environment variables | Before each command in the session's workspace |
| [`http.request`](#httprequest-and-httpresponse) | `HttpRequestInput` | `{ url?, headers?, body? }` | Before each provider HTTP request |
| [`http.response`](#httprequest-and-httpresponse) | `HttpResponseInput` | `{ status?, headers? }` | After each provider HTTP response |
| [`tracing.redact`](#tracingredact) | `{ kind, value, meta? }` | `string` | Before tracing, metrics or logs record a payload |
| [`content-ref.resolve`](#content-refresolve) | `{ block, childId?, depth? }` | `string \| undefined` | When a message block carries a stored content reference |

### run

The run's options, after the app built them: `prompt`, `model`, `behavior`, `signal` and the rest. A changed object is copied before use.

```ts
ctx.hooks.intercept('run', (_input, current) =>
  typeof current.prompt === 'string' && current.prompt.includes('[fast]')
    ? { ...current, behavior: { ...current.behavior, maxTurns: 5 } }
    : undefined)
```

### behavior

The resolved `behavior` of the run: budgets, compaction, dedup, concurrency ([hooks](https://zidane.si/docs/hooks.md#output-and-context-budgets)). Keys set on the run itself (`run({ behavior })`) still win over the chain's result.

```ts
ctx.hooks.intercept('behavior', (_input, current) => ({ ...current, maxConcurrentTools: 4 }))
```

### prompt

What the user submitted, before skill resolution. `/` commands are handled first, so a command never reaches this chain.

| Field | Holds |
| - | - |
| `sessionId` | The session it goes to |
| `text` | The text |
| `attachments` | `{ name, mimeType, path? }` for each pasted file |
| `metadata` | Host metadata saved with the turn |
| `delivery` | `auto`, `steer` (into the run going on) or `followUp` (after it) |

Return a changed copy to edit the text, metadata or delivery. Return `{ handled: true }` to consume the prompt: it never reaches the model, and the chain stops. The kitchen-sink example handles `!bump` itself:

```ts
ctx.hooks.intercept('prompt', async (input) => {
  if (input.text.trim() !== '!bump')
    return undefined
  ctx.logger.info('bumped from the prompt')
  return { handled: true }
})
```

Attachments are matched to the submitted files by `name`. A handler can drop them, reorder them or change a `mimeType`, but not add new ones. Metadata must be JSON: other values are dropped.

### system

The system prompt of one request. Return the full new prompt. The input carries the messages, the turn number and the turn id. For a fixed text, `ctx.instructions.add` is simpler and keeps the prompt cache stable ([instructions](https://zidane.si/docs/extensions-catalogs.md#instructions)).

```ts
ctx.hooks.intercept('system', (input, current) =>
  input.turn > 10 ? `${current}\n\nKeep answers short.` : undefined)
```

A system prompt that changes on every turn breaks the provider's prompt cache. Change it rarely.

### context

The messages of one request, as they go on the wire. Return the full new list. The change applies to this request only: the session's saved turns stay as they were.

```ts
ctx.hooks.intercept('context', (_input, current) =>
  current.length > 40 ? [current[0]!, ...current.slice(-39)] : undefined)
```

### tool.call

A tool call, before it runs. It fires for every tool: built-in, extension and MCP (under its `mcp_<server>_<tool>` name), and for sub-agents' calls too.

| Field | Type | Holds |
| - | - | - |
| `tool` | `string` | The tool's name. `match` compares against it |
| `displayName` | `string` | The name the model used (an alias), or `tool` |
| `input` | `object` | The arguments |
| `callId`, `turnId` | `string` | Ids of the call and the turn |
| `sessionId`, `runId`, `parentRunId` | `string \| undefined` | The session, the run, and for a sub-agent the run that spawned it |
| `depth` | `number \| undefined` | Sub-agent depth. Absent at the top level |
| `mcpServer` | `string \| undefined` | The MCP server, for an MCP tool |
| `runToolCounts` | `Record<string, number>` | Calls per tool so far in this run |
| `resumed` | `true \| undefined` | The call passed the gate in a process that died ([durability](https://zidane.si/docs/durability.md)) |

The chain starts at `'allow'`. A handler returns:

| Decision | Does |
| - | - |
| `'allow'` | Runs the call |
| `'ask'` | Sends the call to the approval flow. Without one, the call is blocked |
| `{ block: reason }` | Refuses the call. The model gets `reason` as an error. Ends the chain |
| `{ patch: input }` | Replaces the arguments. The loop checks them against the tool's schema again |
| `{ result }` | Answers the call without running it. Ends the chain |

A call that an earlier gate already blocked or answered skips the chain. On a `resumed` call, `'ask'` and `{ result }` are ignored.

### tool.result

A finished call, before the model sees its result: the call's fields, plus `result`, `isError` and `metadata`. Return `{ result, isError, metadata? }`.

`metadata` adds namespaces to the stored result's metadata, such as the one `ctx.tools.metadata` claims ([tool metadata](https://zidane.si/docs/extensions-catalogs.md#tool-metadata)). Each namespace you return replaces the one already there. Namespaces from earlier handlers stay unless you return them again. Values must be JSON. The input's `metadata` is read-only: write through the return value.

```ts
ctx.hooks.intercept('tool.result', (call, current) => {
  if (call.isError || typeof current.result !== 'string')
    return undefined
  return { ...current, result: current.result.replace(/\b(API_KEY|TOKEN)=\S+/g, '$1=<redacted>'), metadata: { 'my-ext': { redacted: true } } }
}, { match: ['shell'] })
```

### validation

Fires after the loop coerced a tool's arguments to fit its schema, and only then. The input is the `validation:coerce` event: the tool, `input`, `coercions` (the fields coerced) and `schema`. Return the arguments to use. The loop checks a changed input again.

### skills.activate

Runs at the start of each run, when skills resolved. The input is `{ skills, run }`, with `run` holding the run's `model`, `prompt`, `parentRunId` and `depth`. The chain starts at `[]`. Return the names of skills to activate for this run. Unknown names are ignored.

```ts
ctx.hooks.intercept('skills.activate', (input, current) =>
  typeof input.run.prompt === 'string' && input.run.prompt.includes('deploy') ? [...current, 'deploy-checklist'] : undefined)
```

To turn a skill on for every run, use `ctx.skills.transform` and `autoinvoke` instead ([skills](https://zidane.si/docs/extensions-catalogs.md#skills)).

### mcp.tools

Runs once per MCP server, after the config's allow and deny lists, before the tools register. The input is `{ server, transport, tools }`. Return the tools to keep, as the server listed them (not yet named `mcp_<server>_<tool>`). `match` takes server names.

```ts
ctx.hooks.intercept('mcp.tools', (_input, current) => current.filter(t => !t.name.startsWith('delete_')), { match: ['github'] })
```

### provider

Wraps a provider the app built from a descriptor. The input is `{ provider, descriptor }`. Return a `Provider`. `match` takes descriptor keys. Provider factories are synchronous and the chain is not, so the chain runs once, on the provider's first `stream` or `countTokens` call.

To edit descriptors (labels, models, sign-in), use `ctx.providers.transform` ([providers](https://zidane.si/docs/extensions-catalogs.md#providers)).

### retry

Runs when a request failed with an error the loop would retry, in live runs only. The input is `{ attempt, error, provider, model }`, with `attempt` starting at 1. The chain starts at `{ retry: true, delayMs }`, the backoff the loop computed. Return `{ retry: false }` to stop retrying, or a new `delayMs`. Replays reuse the recorded answer.

```ts
ctx.hooks.intercept('retry', (input, current) =>
  input.attempt >= 3 ? { retry: false } : { ...current, delayMs: 2000 })
```

### compaction

Runs when the loop compacts a conversation. The input is `{ sessionId?, messages, reason }`, with `reason` one of `overflow`, `manual` or `auto`. The chain starts at `undefined`. Return `{ summary }` to supply the summary yourself: the model call is skipped. Replays reuse the recorded summary.

### title

Runs when the host titles a session. The input is `{ sessionId, messages }`. The chain starts at `undefined`. A non-empty string becomes the title, and the model call is skipped. The kitchen-sink example prefixes the title:

```ts
ctx.hooks.intercept('title', (_input, current) => (current ? `Kitchen: ${current}` : undefined))
```

### shell.env

Runs before each command in the session's execution context: the shell tool, background tasks, and any tool that runs commands there. The input is `{ sessionId?, command, cwd }`. The chain starts at the caller's environment (empty for the shell tool). Return the variables to set. They go on top of the context's own environment.

```ts
ctx.hooks.intercept('shell.env', (input, env) => ({ ...env, KITCHEN_SINK: '1', KITCHEN_CWD: input.cwd }))
```

### http.request and http.response

Run around each HTTP request a provider sends. `match` takes provider names.

`http.request` gets `{ provider, url, method, headers, body? }`. Header names are lower case. A JSON body is parsed. Return `{ url?, headers?, body? }`: a returned `headers` replaces the whole set, and an object `body` is sent as JSON.

`http.response` gets `{ provider, url, status, headers }`. Return `{ status?, headers? }`. The body streams through unchanged.

```ts
ctx.hooks.intercept('http.request', (input) => ({ headers: { ...input.headers, 'x-team': 'platform' } }), { match: ['anthropic'] })
```

They run for the OpenAI-compatible providers (OpenRouter, Cerebras, Mistral, xAI, Z.ai, Baseten, Arcee, Neuralwatt, local, Cursor), Anthropic (not Bedrock), OpenAI over HTTP, and the Google providers (`google`, `google-vertex` and `googleCompatible` routes such as Antigravity). They never run for OpenAI's Codex WebSocket transport.

### tracing.redact

Runs before tracing, metrics or logs record a payload. The input is `{ kind, value, meta? }`, where `kind` is `system`, `prompt`, `tool-input`, `tool-result`, `mcp-tool-input`, `mcp-tool-result`, `stream-text`, `stream-thinking` or `decision-answers`. The chain starts at the text redacted so far. Return the text to record. The handler must be synchronous and fast.

```ts
ctx.hooks.intercept('tracing.redact', (_input, current) => current.replace(/sk-[A-Za-z0-9]{20,}/g, 'sk-<redacted>'))
```

### content-ref.resolve

Runs when a message block carries a stored content reference (an image or document the session saved by reference). The input is `{ block, childId?, depth? }`, with `block` holding `type`, `ref`, `mediaType` and `encoding`. Return the content as a string. The bytes go on the wire only: the session keeps the reference.

## From the agent hooks

The agent hooks that let a handler change their context are interceptors here:

| Agent hook | Interceptor |
| - | - |
| `tool:gate`, `mcp:tool:gate` | `tool.call` |
| `tool:transform`, `mcp:tool:transform` | `tool.result` |
| `validation:coerce` | `validation` |
| `context:transform` | `context` |
| `system:transform` | `system` |
| `mcp:tools:filter` | `mcp.tools` |
| `tracing:redact` | `tracing.redact` |
| `content-ref:resolve` | `content-ref.resolve` |

`tool:gate` and `tool:transform` already fire for MCP tools, so `tool.call` and `tool.result` cover both. `hooks.on` still accepts these names, to observe them. `oauth:refresh` is observe-only: it fires after the new credentials are stored, including for [integration](https://zidane.si/docs/extensions-integrations.md) refreshes.

## In each app

Hooks and interceptors run in the process that runs the sessions ([where the code runs](https://zidane.si/docs/extensions.md#where-the-code-runs)). The behavior is the same in every app, with these differences:

| Interceptor | Notes |
| - | - |
| `prompt` | Runs once per submission, in the process that runs the extensions: the chat host behind the terminal app, the desktop app, the daemon and the server. A window never runs it itself. It covers sends, steers, follow-ups and queued prompts; a queued prompt is not transformed again when it runs |
| `title` | Runs where the chat host titles sessions: in every app, when it titles a session automatically or on request |
| `tool.call` with `'ask'` | The approval shows in the window that views the session. With no approval flow (a headless run), the call is blocked |

## Pitfalls

- Changing the event or `input` throws. Return a new value from an interceptor instead.
- An interceptor that awaits slow work holds up the agent: every `tool.call` handler runs before every tool call. Keep them fast, and use `match` so they run only where they matter.
- A `system` or `context` change on every turn breaks the prompt cache, and costs tokens.
- `match` on an interceptor that ignores it does nothing. The log has a warning.
- `tracing.redact` must not be async. An async handler is skipped and counted as an error.
- `run:settled` can carry `session: null` when the session was deleted before the run settled.

## Related

- [Hooks](https://zidane.si/docs/hooks.md): every agent hook and its fields.
- [Catalogs](https://zidane.si/docs/extensions-catalogs.md): the synchronous transforms for tools, agents, models, providers, MCP and skills.
- [Testing](https://zidane.si/docs/extensions-testing.md): `host.fire` and `host.intercept` drive hooks in a test.
