Skip to content

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#

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

EventFiresFields
run:settledAfter each run, on any exitsessionId, 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:eventA run ended, or a session waits for the usersessionId, 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#

TypeScript
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.
OptionTypeDoes
prioritynumberHigher runs first among extensions at the same place in the load order. Default 0
matchstring[]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#

NameInputReturnsRuns
runAgentRunOptionsAgentRunOptionsAt the start of each run
behaviorAgentBehaviorAgentBehaviorAt the start of each run, and before a manual compaction
promptPromptInputPromptInput | { handled: true }When the user submits text that is not a / command
system{ sessionId?, system, messages, turn, turnId }stringBefore each provider request
context{ sessionId?, messages }SessionMessage[]Before each provider request
tool.callToolCallToolCallDecisionBefore each tool call
tool.resultToolCallResultToolResultDecisionAfter each tool call, before the model sees the result
validationvalidation:coerce event and sessionId?The tool inputAfter the loop coerced a tool's arguments
skills.activate{ skills, run }Skill namesAt the start of each run
mcp.toolsmcp:tools:filter eventTool descriptorsPer MCP server, before its tools register
provider{ provider, descriptor }ProviderOnce 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 } | undefinedWhen the loop compacts a conversation
title{ sessionId, messages }string | undefinedWhen the host titles a session
shell.env{ sessionId?, command, cwd }Environment variablesBefore each command in the session's workspace
http.requestHttpRequestInput{ url?, headers?, body? }Before each provider HTTP request
http.responseHttpResponseInput{ status?, headers? }After each provider HTTP response
tracing.redact{ kind, value, meta? }stringBefore tracing, metrics or logs record a payload
content-ref.resolve{ block, childId?, depth? }string | undefinedWhen 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.

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

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

FieldHolds
sessionIdThe session it goes to
textThe text
attachments{ name, mimeType, path? } for each pasted file
metadataHost metadata saved with the turn
deliveryauto, 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:

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

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

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

FieldTypeHolds
toolstringThe tool's name. match compares against it
displayNamestringThe name the model used (an alias), or tool
inputobjectThe arguments
callId, turnIdstringIds of the call and the turn
sessionId, runId, parentRunIdstring | undefinedThe session, the run, and for a sub-agent the run that spawned it
depthnumber | undefinedSub-agent depth. Absent at the top level
mcpServerstring | undefinedThe MCP server, for an MCP tool
runToolCountsRecord<string, number>Calls per tool so far in this run
resumedtrue | undefinedThe call passed the gate in a process that died (durability)

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

DecisionDoes
'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.

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

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

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

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

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

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

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

TypeScript
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 hookInterceptor
tool:gate, mcp:tool:gatetool.call
tool:transform, mcp:tool:transformtool.result
validation:coercevalidation
context:transformcontext
system:transformsystem
mcp:tools:filtermcp.tools
tracing:redacttracing.redact
content-ref:resolvecontent-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:

InterceptorNotes
promptRuns 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
titleRuns 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.
  • Hooks: every agent hook and its fields.
  • Catalogs: the synchronous transforms for tools, agents, models, providers, MCP and skills.
  • Testing: host.fire and host.intercept drive hooks in a test.