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).
Observe#
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), 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.
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). 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).
Intercept#
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
inputandcurrent, the result so far. The first gets the call site's default. - A returned value replaces
current.undefinedkeeps it. - Some results end the chain:
{ block }and{ result }fortool.call,{ handled: true }forprompt.
| 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 | AgentRunOptions | AgentRunOptions | At the start of each run |
behavior | AgentBehavior | AgentBehavior | At the start of each run, and before a manual compaction |
prompt | PromptInput | PromptInput | { handled: true } | When the user submits text that is not a / command |
system | { sessionId?, system, messages, turn, turnId } | string | Before each provider request |
context | { sessionId?, messages } | SessionMessage[] | Before each provider request |
tool.call | ToolCall | ToolCallDecision | Before each tool call |
tool.result | ToolCallResult | ToolResultDecision | After each tool call, before the model sees the result |
validation | validation:coerce event and sessionId? | The tool input | After the loop coerced a tool's arguments |
skills.activate | { skills, run } | Skill names | At the start of each run |
mcp.tools | mcp:tools:filter event | Tool descriptors | Per MCP server, before its tools register |
provider | { provider, descriptor } | Provider | Once per built provider, on its first request |
retry | { attempt, error, provider, model } | { retry, delayMs? } | When a request failed with an error the loop would retry |
compaction | { sessionId?, messages, reason } | { summary } | undefined | When the loop compacts a conversation |
title | { sessionId, messages } | string | undefined | When the host titles a session |
shell.env | { sessionId?, command, cwd } | Environment variables | Before each command in the session's workspace |
http.request | HttpRequestInput | { url?, headers?, body? } | Before each provider HTTP request |
http.response | HttpResponseInput | { status?, headers? } | After each provider HTTP response |
tracing.redact | { kind, value, meta? } | string | Before tracing, metrics or logs record a payload |
content-ref.resolve | { 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.
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). Keys set on the run itself (run({ behavior })) still win over the chain's result.
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:
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).
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.
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) |
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). 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.
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.
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).
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.
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).
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.
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:
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.
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.
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.
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 refreshes.
In each app#
Hooks and interceptors run in the process that runs the sessions (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
inputthrows. Return a new value from an interceptor instead. - An interceptor that awaits slow work holds up the agent: every
tool.callhandler runs before every tool call. Keep them fast, and usematchso they run only where they matter. - A
systemorcontextchange on every turn breaks the prompt cache, and costs tokens. matchon an interceptor that ignores it does nothing. The log has a warning.tracing.redactmust not be async. An async handler is skipped and counted as an error.run:settledcan carrysession: nullwhen the session was deleted before the run settled.