Skip to content

zidane/agent

The agent core, createAgent(), with manual compaction, run summaries, cache telemetry and read-state helpers.

import { … } from 'zidane/agent'src/agent-entry.ts

Use zidane/agent for the agent loop without the eval, headless and UI surfaces of the root zidane entry. It holds createAgent(), the Agent and AgentOptions types, hook maps, the run-summary collector, prompt-cache drift logging and the per-session read state. An agent can also compact its own conversation with agent.compact(), report when its prompt cache is estimated to expire with agent.cacheExpiresAt(), and estimate what a model, thinking, system or tools change would re-read with agent.cacheImpact(). Every symbol is also exported from zidane. See Agents for the guide.

TypeScript
import { createAgent, createRunSummaryCollector } from 'zidane/agent'
import { basic } from 'zidane/presets'
import { anthropic } from 'zidane/providers/anthropic'

const agent = createAgent({ ...basic, provider: anthropic() })
const collector = createRunSummaryCollector()
const uninstall = collector.install(agent.hooks)

await agent.run({ prompt: 'Name the files in this directory.' })
uninstall()
console.log(collector.latest()?.totals.output)
console.log(agent.cacheImpact({ model: 'claude-opus-5' }).tokens, agent.cacheExpiresAt())

const result = await agent.compact({ keepTurns: 2 })
console.log(result.compacted)

Functions#

createAgentFunction#

Creates an agent: a provider, tools, hooks and optional session behind one run() call.

Setup is synchronous. MCP servers and skills connect on the first run() or warmup(), or in the background when eager is set. Throws when provider is an empty array or a multi-provider list has blank or duplicate provider names.

TypeScript
function createAgent(rawOptions: AgentOptions): Agent
Parameters
NameTypeDescription
rawOptionsAgentOptions

Agent options. Only provider is required.

Agent

The Agent. Call agent.destroy() when you are done to release the execution context.

Example
~~~ts
import { createAgent } from 'zidane'
import { anthropic } from 'zidane/providers/anthropic'

const agent = createAgent({
  provider: anthropic({ apiKey: process.env.ANTHROPIC_API_KEY }),
  system: 'You are a helpful assistant.',
})

const stats = await agent.run({ prompt: 'Say hello.' })
console.log(`Done in ${stats.turns} turns`)
await agent.destroy()
~~~

src/agent.ts:61

createRunSummaryCollectorFunction#

Build a run-summary collector. State is created fresh inside each install() call, so a single collector instance can be installed across multiple agents without attribution cross-talk. latest() returns the most-recent summary across any install — install per-agent collectors if you need separate post-run snapshots.

TypeScript
function createRunSummaryCollector(
  options?: RunSummaryCollectorOptions,
): RunSummaryCollector
Parameters
NameTypeDefaultDescription
options?RunSummaryCollectorOptions{}

Collector options. onSummary is called with each finished summary.

Example
const collector = createRunSummaryCollector({
  onSummary: s => console.log(JSON.stringify(s)),
})
const uninstall = collector.install(agent.hooks)
try { await agent.run({ prompt }) }
finally { uninstall() }

src/run-summary.ts:261

diffCacheDimensionsFunction#

Compare two snapshots and return the list of dimensions whose bytes differ. The details map carries a short human-readable description per changed dimension (e.g. system: 1024 → 1031 bytes).

Pure / no I/O. Use this in tests to assert the diff logic; the logger itself just formats and emits the result.

TypeScript
function diffCacheDimensions(
  prev: CacheDimensionSnapshot,
  curr: CacheDimensionSnapshot,
): CacheDimensionDiff
Parameters
NameTypeDescription
prevCacheDimensionSnapshot

Snapshot of the previous turn.

currCacheDimensionSnapshot

Snapshot of the current turn.

src/cache-telemetry.ts:660

fnv1a32Function#

FNV-1a-style 32-bit hash over UTF-16 code units. Pure and allocation-free. Cryptographic strength is irrelevant here — we only need change detection, and a 32-bit collision is unlikely for the few dimensions tracked.

Feeds the FULL char code into the mix (same as read-state's hashContent) — masking to the low byte would collapse code points that share a low byte, so a non-ASCII system-prompt change could hash identically and silently hide a cache-dimension drift.

Exported for tests; callers shouldn't typically import directly.

TypeScript
function fnv1a32(s: string): number
Parameters
NameTypeDescription
sstring

String to hash.

number

src/cache-telemetry.ts:395

hashMessagesFunction#

Hash each message independently, preserving order.

Per-message rather than cumulative so the diff can tell an APPEND (healthy) from a REWRITE (a cache break) by comparing positionally — see CacheDimensionSnapshot.messageHashes.

Folds field-by-field into a rolling FNV rather than JSON.stringify-ing the whole list at once: on a long run the transcript is hundreds of KB, and building one giant string every turn just to throw it away is the kind of overhead that gets telemetry ripped back out.

Exported so hosts can stamp the same hashes onto their own spans.

TypeScript
function hashMessages(messages: readonly { content: unknown; role: string }[]): number[]
Parameters
NameTypeDescription
messagesreadonly { content: unknown; role: string }[]

Messages in transcript order.

number[]

src/cache-telemetry.ts:605

installCacheBreakLoggerFunction#

Install the cache-break logger on an agent's hook bus.

Subscribes to turn:before (snapshot) + turn:after (compare with prior snapshot, log breaks), plus session:start + agent:start to pick up the session's baseline (unless sessionBaseline: false). Returns an uninstall fn that detaches every listener. Idempotent w.r.t. agent lifecycle — installs/uninstalls cleanly across runs.

Recommended setup:

Text
const uninstall = installCacheBreakLogger(agent.hooks, {
  log: line => logger.debug(line),
  verbose: !!process.env.ZIDANE_DEBUG?.includes('cache'),
})
// ...
uninstall()

Performance: hashing runs on each turn:before. The tools array and each message block are serialized once per object (memoized), so a turn costs the system prompt plus the blocks that are new since the last one. Disabled callers pay nothing (don't install).

TypeScript
function installCacheBreakLogger(
  hooks: Hookable<AgentHooks>,
  options?: CacheBreakLoggerOptions,
): () => void
Parameters
NameTypeDefaultDescription
hooksHookable<AgentHooks>

Agent hook bus to install the logger on.

options?CacheBreakLoggerOptions{}

Logger options. All fields are optional.

() => void

src/cache-telemetry.ts:768

snapshotCacheDimensionsFunction#

Build a CacheDimensionSnapshot from a turn:before payload.

Pure — exported so hosts can compute snapshots outside the logger (e.g. stamping them onto custom telemetry spans). The hash is deterministic on byte-identical inputs.

TypeScript
function snapshotCacheDimensions(
  options: StreamOptions,
  sections?: CachePromptSections,
): CacheDimensionSnapshot
Parameters
NameTypeDescription
optionsStreamOptions

Stream options of the outgoing request.

sections?CachePromptSections

Texts of the sections inside options.system to hash separately. The halves around SYSTEM_PROMPT_BOUNDARY are always hashed.

src/cache-telemetry.ts:417

Interfaces#

AgentInterface#

Agent instance returned by createAgent. Runs prompts, queues steering messages and exposes live state.

TypeScript
interface Agent
Agent properties
NameTypeDescription
[asyncDispose]() => Promise<void>

TC39 explicit-resource-management await using sink — alias for Agent.destroy. Lets hosts write:

Text
await using agent = createAgent({ ... })
await agent.run({ prompt: 'go' })
// agent.destroy() runs automatically at scope exit

Requires Node 20+ (or any runtime that ships the TC39 explicit-resource-management proposal). Older runtimes simply ignore the symbol; nothing breaks. Idempotent because destroy() is.

abort() => void

Aborts the current run: the run signal fires, so an in-flight provider stream and tool calls unwind, and run() resolves with the stats so far. No-op when idle. Before the run started (run() / resume() still loading the sessionId session), the run does not start; while run() continues an interrupted run first (onInterrupted: 'resume'), the prompt does not run either.

activateSkill(name: string) => Promise<void>

Explicitly activate a skill by name. Fires skills:activate with via: 'explicit'. Throws if skills are off for this agent, if the skill isn't in the resolved catalog, or if the maxActive cap is reached. Idempotent — activating an already-active skill is a no-op.

activeSkillsreadonlyreadonly ActiveSkill[]

Snapshot of currently active skills.

cacheExpiresAt() => number | null

When the provider's prompt cache for this conversation is estimated to expire, in epoch ms: the time this agent sent its last request plus the cache lifetime that request used (behavior.cacheTtl, else 5 minutes). null before this agent sent any request. An estimate: the provider can evict earlier, and a provider that defaults to a longer lifetime without cacheTtl set is still counted as 5 minutes.

cacheImpact(change: AgentCacheChange) => AgentCacheImpact

Estimate what change would make the next request read again instead of from the prompt cache. Literal: a model id or thinking level that differs from the last request counts, and system / tools count when true. It knows nothing about how the host applies a change, so a host that sends a change without touching the request (e.g. effort as a note in the conversation) should not ask. With no request sent yet, model and thinking have nothing to compare against and never count.

cancelTool(callId: string, reason?: string) => boolean

Cancel a single in-flight tool call by id without aborting the rest of the run. The matching call's ctx.signal flips and a tool:cancelled hook fires; the call's wire result becomes the canonical TOOL_USE_CANCELLED_MESSAGE with isError: true, while every other concurrent call keeps running and the assistant turn closes normally on the next batch boundary.

Returns true when a live call with that id was found and the cancel flag was flipped (idempotent — repeated calls return false). false when no such call is currently dispatching. The cancel is then parked and applied if a call with that id registers before the next run starts or the agent is destroyed, so a cancel issued after the call has already produced a tool_result has no effect.

The optional reason is forwarded to the tool:cancelled hook ctx and to the abort signal's reason field — useful for telemetry that wants to distinguish "user clicked cancel" from a host-side policy.

compact(options?: AgentCompactOptions) => Promise<AgentCompactResult>

Summarize the session's older turns now, the way auto-compaction does inside a run: same summary, restoration of recently read files, hooks (compact:start / compact:progress / compact:end with reason: 'manual') and persistence. When the history still starts with the last request a run sent, that request is resent with the summary instruction so the provider reads it from its prompt cache.

Needs an idle agent: rejects while a run (or another compaction) is in progress, and isRunning is true while it works, so run() rejects meanwhile. Rejects when the summary call fails.

deactivateSkill(name: string) => Promise<void>

Deactivate a skill by name. Fires skills:deactivate with reason: 'explicit'. No-op when the skill wasn't active.

decideAgentDecide

Make a typed decision request (predicate probability, choice, score) against the agent's decision provider — see AgentOptions.decisions. Never throws unless called with { throw: true }; failures come back as { ok: false, reason }. Inside hooks, prefer ctx.decide, which is bound to the active run.

destroy() => Promise<void>

Destroy the execution context and clean up resources. Idempotent — safe to call from both a finally block and a signal handler.

ensureExecution() => Promise<ExecutionHandle>

Ensure the execution handle exists (spawning it if this is called before the first run), firing the one-time execution:ready hook exactly as run() would. The seam for hosts and extension bridges (ctx.workspace) that need the live (execution, handle) pair before any run has happened. Shares run()'s in-flight spawn promise — concurrent callers observe one spawn — and mirrors run()'s destroy semantics: calling after destroy() re-spawns (the supported resurrection contract), while a destroy landing DURING the spawn reaps the fresh handle and rejects.

executionreadonlyExecutionContext

Execution context that decides where tools run.

followUp(prompt: string | PromptPart[], options?: QueuedMessageOptions) => boolean

Queue a message delivered after the run would otherwise end (the model returned a final answer). Same abort contract as steer: no-ops and returns false once the run is aborted, true when queued. Takes the same prompt and options as steer.

getContextBreakdown(opts?: ContextBreakdownOptions) => Promise<ContextBreakdown | null>

Categorized context-window usage — system prompt, rules, skills, MCP tools/instructions, searchable-tools catalog, tool definitions, conversation, deferred buckets, autocompact buffer, and free space. Computed on demand (the host opens a panel / popover). Available even before the first run: when no run snapshot exists yet it assembles a skills-only pre-run view (no MCP connect), so it never returns null for a live agent — only after destroy().

Per-category counts are heuristic estimates reconciled so the live categories sum to the real last-turn total; when the provider exposes Provider.countTokens (Anthropic, OpenAI), the system/tools buckets use exact counts and drop their estimated flag. One network round-trip on the exact path; the host decides when to call it.

The host supplies effectiveWindow (the model's rawWindow - outputReserve, resolved via the chat model registry which the agent core deliberately doesn't depend on). When omitted, free-space / fraction are computed against used only (bar still renders, just without spare-space).

getContextSegments(opts?: { rulesBlock?: string; rulesFiles?: readonly ContextSegmentFile[]; systemPath?: string }) => Promise<ContextSegment[] | null>

The TEXT of every chunk in the session's default context, each tagged with where it came from — the sibling of Agent.getContextBreakdown, which reports the same assembly as token counts. Hosts render it as a "what is actually loaded" list the user can read, with the file-backed segments openable in an editor.

Reads the live run snapshot, falling back to the same pre-run assembly getContextBreakdown uses, so it's populated before the first message and returns null only after destroy(). Cheap: no provider round-trip and no tool serialization — just the already-assembled strings.

Instruction files (AGENTS.md / CLAUDE.md) are HOST-supplied via opts.rulesFiles: the agent receives the rendered rules block as an opaque substring of the host's system prompt and never reads those files itself, so it knows their paths at best and never their text. A host that already called discoverAgentsMd passes its files straight through — the shapes line up — and gets per-file segments with real paths. Omit it and the list covers only the agent-owned segments.

opts.rulesBlock is the rendered instruction block as it appears inside the system string. Pass it so the base-prompt segment can subtract it and the instruction text isn't reported twice (once in the system prompt, once as its own file rows). Falls back to the assembly's copy, which is only present when the host threaded AgentRunOptions.contextSections into run().

opts.systemPath likewise marks the base prompt as backed by a SYSTEM.md override, making that segment openable. Same rationale: the agent receives an already-composed system string with no provenance attached.

handlereadonlyExecutionHandle | null

Live execution handle, or null until the first run (or ensureExecution) creates it.

hasPendingTaskNotificationsreadonlyboolean

true while at least one background-task exit is queued for delivery on the next run() (the push-fed background:exit queue; pending stall signals don't count). Exits already acknowledged by the model (shell_kill, a successful wait_task, reading the output file) never surface here.

This is the host-side "auto-wake" signal: when it flips true while the agent is idle, a drain-only run({ prompt: '' }) delivers the <task-notification> block without waiting for the user's next prompt — prompt-less runs are explicitly legal in that state (see validateAndPrepareResume).

hooksAgentHookBus

Hook bus for registering handlers on agent lifecycle events.

isRunningreadonlyboolean

true while a run is in progress, from the run() / resume() call on (also while it loads its session or continues an interrupted run first).

killBackgroundTask(taskId: string) => Promise<boolean>

Host-side kill for a background task spawned via shell({ run_in_background: true }). Routes through the execution context's killBackground primitive, which SIGTERMs the whole process group and awaits the output stream's flush.

Returns true when a task was found and killed (or was already exited — the call is idempotent on terminated tasks). Returns false when:

  • the task id doesn't exist in the context's registry, OR
  • the execution context doesn't support killBackground (some remote sandboxes), OR
  • the agent isn't bound to an execution handle yet (no run has ever fired).

Distinct from the model-facing shell_kill tool — that one routes through the model's turn so the kill is visible in the transcript. killBackgroundTask is for the host's UI ("cancel this task" in the TUI's ctrl+k picker); the kill is observational from the model's perspective, surfacing as the usual <task-notification> on the next turn.

mcpConnectionreadonlyMcpConnection | null

The live MCP connection — bound lazily by the first run() / warmup() (null before bootstrap and after destroy()). NON-OWNING for callers: wrap with nonOwningMcpView before handing it to child agents or tools — only this agent's own destroy path may tear the transports down. The seam that lets host-driven spawns (extension ctx.spawnAgents) share the parent's connection instead of re-bootstrapping every server per child (which would also strip OAuth servers of the host's provider).

mcpServersreadonlyreadonly McpServerConfig[]

The TRANSFORMED MCP config list (mcpConfigTransforms applied) the live connection is built from. Pass THIS — not the raw option — alongside mcpConnection when handing MCP state to child agents: a child's mcpServers.length > 0 gate never invokes the shared connector if transform-added servers ride an empty raw list (mirrors mcpServers on the loop context).

metareadonlyReadonly<Record<string, unknown>>

Frozen view of the underlying provider.meta. Read-only to prevent accidental cross-agent contamination — writes are rejected at runtime (via Object.freeze) and at compile time (via Readonly). To override model / capability defaults, construct a new provider.

reset() => Promise<void>

Clear the agent's in-memory state (turns, queues, skill activations). Fires skills:deactivate with reason: 'reset' for each previously active skill. Awaiting lets host apps observe listener rejections. Throws while a run is in progress: call abort() and await waitForIdle() first.

resume(options?: DurableResumeOptions) => Promise<AgentStats | null>

Continue the session's run that a process death left unfinished, from its saved turns and the durable runtime's step records: finished steps answer from their records, a pending safe step runs again, a pending unsafe tool call is reported interrupted. Checkpoint runtimes only: in replay mode the platform re-runs the invocation (run with the same prompt) against its journal.

Resolves null when there is no such run, including one that crashed without step checkpoints (never marked resumable): the next run() heals that one, reporting its dangling tool calls as interrupted. Also null when the run's step records no longer match its saved turns (a history rewrite): the run is healed at once and resume:mismatch fires.

The resumed run keeps its id and record, and runs with the options its record kept (SessionRun.options: caps, an explicit model, thinking and model options), under what options.run passes. Per-run options the record does not keep (a spawned child's system prompt, depth and parent) come from options.run; unset, the agent's own apply.

Rejects without a checkpoint-mode durable runtime (also when durable is unset), while a run is in progress, or when the agent has no session (session / sessionId).

run(options: AgentRunOptions) => Promise<AgentStats>

Runs the agent loop until the model stops, a limit is hit or the run is aborted. Rejects when a run is already in progress. With durable: { onInterrupted: 'resume' }, a run a dead process left in the session is continued first (resume); the result is the new run's stats, or the resumed run's when it was aborted or suspended (the prompt then does not run). On a top-level checkpoint run whose runtime has a lease (DurableRuntime.lease), rejects with RunLeaseHeldError when another live process holds the session's lease, and with StaleEpochError when another process takes the run over. Both are exported from zidane/durable.

sessionreadonlySession | null

Session bound to the agent, or null when none was supplied.

steer(prompt: string | PromptPart[], options?: QueuedMessageOptions) => boolean

Queue a mid-run nudge injected as a user turn at the next tool/turn boundary. Returns false (and queues nothing) when no run is in flight or when the run's AbortController is already tripped — a steer can never revive or confuse a dead run, and a message queued between runs would otherwise leak into the NEXT run and skip its first turn's tool calls. Returns true when queued.

Hosts emulating Stop-hook semantics (queueing a follow-up on turn:after) should rely on this: a guard/user abort can fire mid-turn while turn:after still reports finishReason: 'stop', so an unconditional enqueue would otherwise leak a message into a dead run.

prompt takes the same values as run({ prompt }): text, or parts for text with images and documents. options.id comes back in steer:inject, so a host can tell which message the loop added. options.systemNotes follow the message, as run({ systemNotes }). Returns false for an empty prompt. Throws on a malformed part.

suspend(options?: { graceMs?: number }) => Promise<void>

Stops the current run so resume can continue it later (a host that drains before it exits). The model stream stops at once and its request is sent again on resume. Running tool calls may finish within options.graceMs (unset: they all finish); a call still running then is cut off and stays pending, so a resume reports it interrupted (or runs a safe one again). The run records no failed step and keeps its resumable mark; run() resolves with suspended: true. Without step checkpoints (durable unset, or no session) the run also stops with suspended: true, but nothing can resume it. A replay-mode run is aborted instead: it continues by running its invocation again. Before the run started (its session still loading), the run does not start and run() / resume() resolve suspended: true with zero stats. Resolves once the run stopped; no-op when idle.

toolStatereadonlyAgentToolState

Read-only view of this agent's committed durable tool state. See AgentToolState: hosts inspect what tools have persisted (flags, ids, counters, cursors) without a write path. Reflects only committed writes — a tool's staged-but-uncommitted mutations are not visible until the call settles.

turnsreadonlySessionTurn[]

Conversation turns held in memory by the agent.

waitForIdle() => Promise<void>

Resolves when the current run has finished. Resolves immediately when the agent is idle.

warmup() => Promise<void>

Pre-connect MCP servers without running a turn. Idempotent and concurrency-safe:

  • No MCP servers configured → resolves immediately.
  • Connection already established → resolves immediately.
  • Another warmup() / run() is bootstrapping → awaits the in-flight promise.

Use from host code that wants to hide MCP bootstrap latency behind other startup work (UI init, auth, etc.). Safe to call multiple times and from multiple callers concurrently.

src/agent-types.ts:490

AgentHooksInterface#

Event map for the agent hook bus. Each key is a hook name and each value is the handler signature. Register handlers with agent.hooks.hook(name, fn) or the hooks option of createAgent.

TypeScript
interface AgentHooks
AgentHooks properties
NameTypeDescription
agent:abort(ctx: { reason?: string; runId?: string }) => void

The run was aborted — fires exactly once per aborted run, on every abort exit: a between-turn cancel (the loop's signal.aborted check), a repeat-guard abort, AND a cancel that lands mid-stream (which throws out of the turn and is caught at the run boundary). For a guard abort a repeat-guard:exceeded (action: 'abort') event fired first, so consumers can attribute the cause.

Does NOT fire on graceful non-cancel terminations that still finalize the session run as 'aborted': maxConsecutivePauseTurns exhaustion and the maxCostUsd/maxTotalTokens budget breaker (the latter throws AgentBudgetExceededError). Branch on those via their own signals (finishReason: 'pause', the typed error) rather than agent:abort.

runId — the aborted run, for correlating with turn:after / telemetry spans. reason — the string passed to AbortController.abort(reason) when one was supplied (mirrors signal.reason); absent for a bare abort().

agent:done(ctx: AgentStats) => void

Run finished — fires on all exit paths (completion, maxTurns, abort, error).

The AgentStats carried here is cumulative across the parent agent loop and every recursively-spawned sub-agent (totalIn / totalOut / cost / totalCacheRead / totalCacheCreation). For parent-loop-only counts use ctx.turnUsage (parent-only array); for tree-wide turn counts use flattenTurns(ctx).length.

agent:start(ctx: { agentName?: string; depth: number; parentRunId?: string; providerName?: string; runId: string; startedAt: number; tracingContext?: Readonly<Record<string, string>> }) => void

Fires once per agent.run() invocation, right after session:start (when a session is bound) and before the first turn:before. Symmetric with ['agent:done'].

tracingContext carries opaque parent trace metadata propagated from the spawning agent (typically a W3C { traceparent, tracestate } carrier injected by the parent's tracer on spawn:before). Tracers read it on the child run to continue the parent trace; absent on top-level runs.

artifact:recorded(ctx: { artifact: SessionArtifact; callId: string; name: string; runId: string; turnId: string }) => void

Fires once per artifact the session recorded from a tool result's reserved metadata.artifacts namespace (see session/artifacts.ts). Observability only — the artifact is already persisted; hosts use it to refresh badges / panels live. Never fires on sessionless runs or stores without artifact support.

background:adopt(ctx: { command: string; cwd: string; outputPath: string; pid: number; startedAt: number; taskId: string }) => void

A still-running background task was handed to THIS agent by its host — the agent was built on a host-owned handle that outlived the previous agent (a rebuild after a model swap, settings change, MCP login…; see chat/execution-cache.ts). The task's exit/stall callbacks now fire on this bus. Hosts that track running tasks register it exactly like background:start; unlike that hook it does not mean "just spawned".

background:exit(ctx: { command: string; durationMs: number; exitCode: number; outputPath: string; signal?: Signals; status: 'exited' | 'killed'; taskId: string }) => void

Fires exactly once per task when the child process terminates — natural exit, host-issued kill, or agent.destroy() teardown. Same payload the agent enqueues into pendingTaskNotifications for injection on the next turn; useful for hosts that want to render a "task done" toast immediately rather than wait for the model's next prompt.

background:reassign(ctx: { childId?: string; command: string; fromHandleId: string; outputPath: string; pid: number; startedAt: number; taskId: string; toHandleId: string }) => void

Ownership of a background task was transferred from one execution handle to another — typically the spawn tool reassigning a subagent's still-running tasks up to the parent's handle so they outlive the subagent's destroy().

Hosts that listed the task under the source handle should update their view to reflect the new owner. The TUI's backgroundTasks registry drops the childId tag on the matching entry so the ctrl+k picker shows it as parent-level (and killBackgroundTask actually finds it).

Fires on the new owner's hook bus (the parent's, in the spawn case). The old owner is typically already torn down — wiring the event the other way would deliver it to a destroyed hookable.

background:reconcile(ctx: { exits: TaskExitInfo[] }) => void

Fires at the start of an agent.run() that has background exits to deliver, BEFORE they are rendered into <task-notification> blocks (runs with nothing to deliver skip the hook). exits merges the push-fed queue (background:exit arrivals since the last run) with a pull-based reconcile against ExecutionContext.listBackground — so exits land even when the context never calls onExit (remote / durable contexts can't push from a timer into a host journal).

This is the journalable seam for durable-execution hosts: the hook runs inside the run() await chain, so a handler may replace ctx.exits with a recorded copy, making the wall-clock-derived durationMs, and so the whole <task-notification> block, replay-stable. A replay-mode durable runtime already records the listBackground poll as a reconcile step.

Mutable: handlers may reorder, filter, or substitute entries. The post-hook array is what the model sees.

background:stall(ctx: { bytesWritten: number; command: string; outputPath: string; stalledForMs: number; taskId: string }) => void

The stall watchdog (AgentBehavior.backgroundStallWatchdogMs) detected a background task with no output for the configured window. One-shot per quiet period — re-arms after fresh output. The agent queues a <task-stall> notification for the model's next turn; hosts can additionally render a "task may be stuck" hint.

background:start(ctx: { command: string; cwd: string; outputPath: string; pid: number; startedAt: number; taskId: string }) => void

Fires when a background task is spawned via ExecutionContext.execBackground. Observational — telemetry consumers can mint a "task started" span.

outputPath is the file the context is streaming stdout+stderr to; the model reads it via read_file on demand. pid is the OS pid of the spawned shell wrapper (a process-group leader on POSIX, so a process.kill(-pid) on cleanup tears down the whole subtree).

budget:exceeded(ctx: { budget: number; bytes: number; turn: number; turnId: string }) => void

Fires when a turn's total tool-output bytes exceed behavior.toolOutputBudget. Measured post-tool:transform. Loop injects a synthetic user message after the tool-results turn instructing the model to summarize.

cache:break(info: CacheBreakInfo) => void | Promise<void>

Fires when a turn read back materially less cached prefix than the previous turn established — a prompt-cache break. Emitted by installCacheBreakLogger (zidane's cache-telemetry module), so like compact:* it only fires when that subsystem is installed on this agent's hook bus; the loop itself never emits it.

The payload names the request dimensions that changed (changed) with human-readable diffs (details). An empty changed means the request was byte-stable at this wire and the provider still dropped the prefix — TTL expiry, provider eviction, or (behind a format-casting gateway) churn introduced by the cast itself; see CacheBreakInfo for how to tell them apart. Hosts surface this as UI (TUI footer chip), metrics, or alerts; the text log line remains available for grep-based workflows and, unlike this hook, stays silent on unattributable breaks.

cache:drift(info: CacheDriftInfo) => void | Promise<void>

Fires at turn:before when the outgoing request DIVERGES from the previous completed turn's — the predictive counterpart of 'cache:break'. Emitted by installCacheBreakLogger from the request bytes alone, so it works on providers that report no cache usage and on format-casting gateways where this wire is the last observable point before the cast.

Drift + healthy cacheRead afterwards = the provider absorbed the change; drift + a 'cache:break' on the same turn = the break's attribution. Intentional rewrites (compaction cuts, stale-read stubs) drift by design — divergedAt tells them apart; see CacheDriftInfo.

Delivery is fire-and-forget: turn:before rides the loop's PROPAGATING hook timeout, so the emitter does not await consumers. Synchronous handlers complete before the request goes out; async handlers detach and must not assume the turn is still in flight; errors route to the installer's onEmitError, never into the run.

child:artifact:recorded(ctx: { artifact: SessionArtifact; callId: string; childId: string; depth: number; name: string; runId: string; turnId: string }) => void

Bubbled from a subagent's artifact:recorded (shared-session children). Same payload + childId / depth.

child:background:exit(ctx: { childId: string; command: string; depth: number; durationMs: number; exitCode: number; outputPath: string; signal?: Signals; status: 'exited' | 'killed'; taskId: string }) => void

Bubbled from a subagent's background:exit. Same payload + childId / depth.

child:background:reassign(ctx: { childId: string; command: string; depth: number; fromHandleId: string; outputPath: string; pid: number; startedAt: number; taskId: string; toHandleId: string }) => void

Bubbled from a subagent's background:reassign. Fires when a grandchild's tasks get promoted to the child's handle on the grandchild's destroy — gives the parent a chance to mirror the ownership change in any UI it maintains.

child:background:start(ctx: { childId: string; command: string; cwd: string; depth: number; outputPath: string; pid: number; startedAt: number; taskId: string }) => void

Bubbled from a subagent's background:start. Same payload + childId / depth.

child:mcp:tool:gate(ctx: McpToolHookContext & { block: boolean; childId: string; depth: number; reason: string; result?: string | ToolResultContent[] }) => void

Bubbled mcp:tool:gate from a subagent, with childId and depth.

child:repeat-guard:exceeded(ctx: { action: 'steer' | 'block' | 'abort'; childId: string; count: number; depth: number; threshold: number; tool: string; turnId: string }) => void

Bubbled sibling of ['repeat-guard:exceeded'] — a subagent tripped the repeat guard.

child:spawn:before(ctx: ChildSpawnHookContext) => void

Nested spawn lifecycle — a SUBAGENT's spawn:before / spawn:complete / spawn:error, re-emitted on its parent's bus (and chained up to the root). Payload is the spawned agent's record plus parentChildId, the subagent that spawned it; see ChildSpawnIdentity for why there is no childId. Observational copies: child:spawn:before carries no tracingContext (the spawner's own tracer owns propagation). The root's own spawn:* keeps firing for direct children only.

child:spawn:complete(ctx: ChildRunStats & ChildSpawnIdentity) => void

Bubbled spawn:complete from a subagent.

child:spawn:error(ctx: Omit<SpawnHookContext, 'tracingContext'> & ChildSpawnIdentity & { error: Error }) => void

Bubbled spawn:error from a subagent.

child:stream:end(ctx: StreamHookContext & { childId: string; depth: number; text: string }) => void

Bubbled stream:end from a subagent.

child:stream:error(ctx: StreamHookContext & { childId: string; depth: number; err: unknown }) => void

Bubbled stream:error from a subagent's turn. See ['stream:error'].

child:stream:retry(ctx: StreamHookContext & { attempt: number; childId: string; delayMs: number; depth: number; discardedText?: true; discardedThinking?: true; err: unknown; maxAttempts: number; nextAttempt: number; requestId?: string; statusCode?: number }) => void

Bubbled ['stream:retry'] from a spawned child — lets hosts surface subagent retries.

child:stream:server_tool_result(ctx: StreamHookContext & { childId: string; content: unknown; depth: number; toolName: string; toolUseId: string }) => void

Bubbled stream:server_tool_result from a subagent. See ['stream:server_tool_result'].

child:stream:server_tool_use(ctx: StreamHookContext & { childId: string; depth: number; id: string; input: Record<string, unknown>; name: string }) => void

Bubbled stream:server_tool_use from a subagent. See ['stream:server_tool_use'].

child:stream:text(ctx: StreamHookContext & { childId: string; delta: string; depth: number; text: string }) => void

Bubbled stream:text from a subagent. See the comment above for how child events bubble.

child:stream:thinking(ctx: StreamHookContext & { childId: string; delta: string; depth: number; thinking: string }) => void

Bubbled stream:thinking from a subagent.

child:tool:after(ctx: ToolHookContext & { childId: string; coercions?: readonly string[]; depth: number; isError?: boolean; outcome?: ToolOutcome; outputBytes: number; result: string | ToolResultContent[]; runToolCounts: Readonly<Record<string, number>> }) => void

Bubbled tool:after from a subagent, with childId and depth.

child:tool:before(ctx: ToolHookContext & { childId: string; coercions?: readonly string[]; depth: number; priorContent?: string; runToolCounts: Readonly<Record<string, number>> }) => void

Bubbled tool:before from a subagent, with childId and depth.

child:tool:cancelled(ctx: ToolHookContext & { childId: string; depth: number; reason: string; runToolCounts: Readonly<Record<string, number>> }) => void

Bubbled sibling of ['tool:cancelled'] — fires when a subagent's in-flight tool was cancelled (either directly on the parent via agent.cancelTool(callId) reaching a child's call, or by the child agent's own consumers). Symmetric with child:tool:error and child:tool:after so the TUI's transcript renderer can paint the cancelled badge against the right subagent row.

child:tool:dispatched(ctx: ToolHookContext & { childId: string; depth: number; outcome: 'execute' | 'gate-substitute' | 'gate-block' | 'unknown' | 'invalid-input'; reason?: string; runToolCounts: Readonly<Record<string, number>> }) => void

Bubbled sibling of ['tool:dispatched'] — fires when a subagent dispatches a call. Symmetric with child:tool:after so live UI rendering of subagent transcripts stays paired across all five dispatch paths (gate-block/gate-substitute/unknown/invalid-input/execute).

child:tool:error(ctx: ToolHookContext & { childId: string; depth: number; error: Error }) => void

Bubbled tool:error from a subagent.

child:tool:gate(ctx: ToolHookContext & ToolGateResumeFields & { block: boolean; childId: string; depth: number; metadata: ToolResultMetadata; reason: string; result?: string | ToolResultContent[]; runToolCounts: Readonly<Record<string, number>> }) => void

Gate-style child events. Unlike the other child:* events, the bubble passes the same ctx reference the subagent's loop is awaiting on: setting ctx.block / ctx.reason / ctx.result on a parent listener propagates straight back to the child, refusing or substituting the call.

Use these to gate subagent tool calls (native + MCP) from the parent without registering listeners on every child agent. The parent's own tool:gate / mcp:tool:gate listeners are NOT auto-shared with children — that would also share their budgets and dedup state.

child:tool:progress(ctx: ToolHookContext & { childId: string; depth: number; progress: ToolProgress }) => void

Bubbled tool:progress from a subagent, with childId and depth.

child:tool:transform(ctx: ToolHookContext & { childId: string; coercions?: readonly string[]; depth: number; isError: boolean; metadata: ToolResultMetadata; outputBytes: number; result: string | ToolResultContent[] }) => void

Same-ref bubble of a subagent's tool:transform hook. Parent listeners may mutate ctx.result to rewrite the child tool's output — used by the TUI to append per-edit annotation blocks onto a child agent's multi_edit / edit / write_file results when a parent-level approval gate produced a partial decision.

child:turn:after(ctx: { aborted?: boolean; childId: string; depth: number; message: SessionTurn; toolCounts: { run: Readonly<Record<string, number>>; turn: Readonly<Record<string, number>> }; turn: number; turnId: string; usage: TurnUsage }) => void

Bubbled turn:after from a subagent.

child:validation:reject(ctx: ToolHookContext & { childId: string; depth: number; reason: string; schema: Record<string, unknown> }) => void

Bubbled sibling of ['validation:reject'] — a subagent's call was rejected for invalid input.

compact:end(ctx: CompactEndHookContext) => void

Fires exactly once after every compact:start, on success and failure (ctx.status). On success it carries the replaced/dropped/restored counts and the post-compact effectiveTokens estimate so hosts can drop their context indicator immediately. On failure it carries error; an auto-compaction's run continues with the un-compacted history (the reactive overflow net still applies) and the circuit breaker counts the failure, while agent.compact() rejects with the error (or resolves compacted: false when it was aborted).

compact:progress(ctx: CompactProgressHookContext) => void

Fires (throttled) while the compaction summary streams — carries a rough cumulative output-token estimate (estOutputTokens, chars ÷ 4) so hosts can paint live progress next to the "Compacting…" state. Zero or more per compact:start; best-effort and observational.

compact:restore(ctx: { add: (call: { input: Record<string, unknown>; name: string; output: string }) => void; session?: Session }) => void | Promise<void>

Fires while a compaction builds the turns it re-attaches after the summary. add contributes a synthetic tool call and its result (a preset keeping its state across the summary: the plan as todoread). Journaled with the restoration, so a replay re-attaches the same.

compact:start(ctx: CompactStartHookContext) => void

Fires when loop-native auto-compaction (behavior.autoCompact) crosses its threshold, or agent.compact() starts (ctx.reason: 'manual'), and the summary call is about to run, BEFORE the provider round-trip. Distinct from context:overflow (the reactive, wire-only context_exceeded net) — this is the heavy compaction that summarizes older turns and mutates the session.

Use it to surface a "Compacting…" banner / footer state. Observational — handlers cannot cancel the compaction. Always paired with exactly one compact:end.

content-ref:resolve(ctx: { block: ResolveContentRefBlock; childId?: string; data?: string; depth?: number }) => void

Resolves persisted content refs into provider-bound payload bytes.

Fires only when wire assembly sees a block carrying ref. Handlers set ctx.data to the resolved payload. The bytes are wire-only and are never written back to session.turns.

context:overflow(ctx: StreamHookContext & { attempt: number; err: unknown }) => void

Fires when the loop catches a context_exceeded provider error and, before giving up, performs an emergency in-loop compaction (aggressive tail elision of older tool_result bodies) and re-issues provider.stream(). Does NOT fire when compaction can't shrink the request any further — that lands on stream:error (the terminal failure).

Runs independently of behavior.compactStrategy: the recovery calls the tail compactor directly, so it protects runs that never opted into client-side compaction. It can only shrink tool-result-heavy histories — a request whose mandatory text alone overflows still surfaces the error.

  • attempt — 1-indexed recovery pass (each pass keeps fewer trailing turns intact and elides more; bounded).
  • err — the raw context_exceeded error that triggered recovery.

Observational — handlers cannot cancel the recovery.

context:transform(ctx: { messages: SessionMessage[] }) => void

Fires per request with the session messages about to be sent. Mutate ctx.messages to rewrite the context for this turn.

decision:end(ctx: DecisionEndHookContext) => void

Fires once per decision, success or failure, after decision:start. ok: false carries the failure reason; ok: true carries answers and usage (decision usage is NOT folded into run totals).

decision:start(ctx: DecisionStartHookContext) => void

Fires when agent.decide / ctx.decide starts a decision, before validation and the provider call. provider / model are absent when no decision provider could be resolved. Contexts of decision:* hooks carry no decide, so a decision can't recursively trigger another.

execution:ready(ctx: { handle: ExecutionHandle; spawned: boolean }) => void

Fires exactly once per agent, the first time the execution handle is bound — right after the lazy execution.spawn() (or immediately on the first run when a handle was injected via AgentOptions.handle). spawned is true when the agent minted the handle, false for an injected one.

The seam durable hosts use to record the minted handle id (e.g. a sandbox id) for re-attach on replay/resume — journal it here, then re-attach via AgentOptions.handle + the provider's reconnect option on the next invocation. Observational; the handle is not mutable.

history:repair(ctx: HistoryRepairHookContext) => void

Fires after a provider stream settles when the loop scrubbed provider-rejected reasoning state from canonical history (e.g. Anthropic signature-400 poisoning). In-memory turns and the bound session are already repaired; hosts mirroring turns into an external durable store must heal it using ctx.turnIds or the next reload re-poisons the run. Observational. See HistoryRepairHookContext.

mcp:auth:error(ctx: { error: Error; name: string }) => void

Interactive login failed (browser error, abort, exchange failure).

mcp:auth:required(ctx: { name: string; reason: 'no-tokens' | 'auto-promoted'; transport: 'sse' | 'stdio' | 'streamable-http' }) => void

Fires when a server's bootstrap was skipped because it requires OAuth login and no tokens are stored. The host (TUI) surfaces this as a "needs-auth" status so the user can opt in to interactive login via loginMcpServer. Pairs with mcp:bootstrap:end ok:false — both fire, since the bootstrap did fail (just for a recoverable reason).

reason:

  • 'no-tokens' — config has auth: 'oauth' set (or normalized from Cursor's authMethod: 'mcpOAuth') and the credential store is empty.
  • 'auto-promoted' — server returned 401 + RFC 9728 metadata. No static Authorization header was set, so the server is eligible for OAuth login even though the config didn't explicitly request it.
mcp:auth:success(ctx: { name: string }) => void

Interactive login completed and tokens were persisted.

mcp:auth:url(ctx: { name: string; url: string }) => void

Fires during an INTERACTIVE OAuth login flow — when the SDK wants the user agent to navigate to the authorization URL. Hosts open a browser and surface the URL in the TUI (in case the browser auto-open fails). Distinct from mcp:auth:required (which fires from bootstrap when tokens are missing); this only fires from loginMcpServer.

mcp:bootstrap:end(ctx: { durationMs: number; name: string; transport: string } & ({ cached?: boolean; lazy?: boolean; ok: true; toolCount: number; warnings?: string[] } | { error: Error; ok: false })) => void

Fires at the end of a per-server bootstrap attempt. durationMs spans from the matching mcp:bootstrap:start. On ok: false carries the originating error so consumers can log / trace without relying on a separate mcp:error.

lazy: true on the success arm signals the server registered its tools without opening a connection (see McpServerConfig.lazyConnect); the connect cost is deferred to the first tools/call. cached: true signals the bootstrap skipped tools/list because the host provided cachedTools — orthogonal to lazy (a server can be cached and eager, cached and lazy, or neither).

warnings (success arm only) collects soft signals that surfaced during bootstrap without aborting it — currently just "server rejected notifications/initialized with 4xx" from the tolerant client. Hosts can render a "connected with caveats" badge from this; consumers that don't care can ignore the field.

mcp:bootstrap:settled(ctx: { results: readonly ({ durationMs: number; name: string; transport: string } & ({ lazy?: boolean; ok: true; toolCount: number } | { error?: Error; ok: false; skipped?: boolean }))[] }) => void

Fires exactly ONCE per bootstrap batch, after every server's attempt has settled. results is sorted by server name — a deterministic aggregate, unlike the per-server mcp:bootstrap:end events which fire in network-completion order inside the parallel bootstrap. Durable-execution hosts that journal bootstrap outcomes should hook this instead of buffering + sorting the per-server stream.

mcp:bootstrap:start(ctx: { name: string; transport: string }) => void

Fires at the start of a per-server bootstrap attempt, before any network I/O. Pairs with mcp:bootstrap:end and is always emitted, regardless of outcome.

mcp:close(ctx: { name: string }) => void

Fires when an MCP server connection is closed.

mcp:connect(ctx: { lazy?: boolean; name: string; tools: string[]; transport: string }) => void

Fires once a server's client is live (transport connected, SDK handshake complete). For servers bootstrapped eagerly, fires right after bootstrap; for servers with lazyConnect: true, fires after the first tools/call triggers the deferred connect — lazy: true lets the host distinguish.

mcp:error(ctx: { error: Error; name: string }) => void

Fires when an MCP server fails to connect or errors fatally.

mcp:tool:after(ctx: McpToolHookContext & { outputBytes: number; result: string | ToolResultContent[] }) => void

Fires after an MCP tool call returns, with the result.

mcp:tool:before(ctx: McpToolHookContext) => void

Fires before an MCP tool call is sent to the server.

mcp:tool:error(ctx: McpToolHookContext & { error: Error }) => void

Fires when an MCP tool call throws.

mcp:tool:gate(ctx: McpToolHookContext & { block: boolean; reason: string; result?: string | ToolResultContent[] }) => void

MCP-side counterpart of tool:gate. Same shape: set block to refuse, set result to substitute a successful payload and skip the upstream MCP callTool. When both are set across the handler chain, block wins.

Fires INSIDE the MCP wrapper's execute, after the loop's tool:gate already ran. Does not carry runToolCounts — those are loop-level and already exposed on tool:gate for MCP tools (which are registered as agent tools under their namespaced name mcp_<server>_<tool>). Use tool:gate for budget / dedup logic; reserve mcp:tool:gate for MCP-specific concerns (per-server routing, transport-aware refusals).

mcp:tool:transform(ctx: McpToolHookContext & { outputBytes: number; result: string | ToolResultContent[] }) => void

Fires before the MCP tool result is returned. Mutate ctx.result to rewrite it. outputBytes is the size before any mutation.

mcp:tools:filter(ctx: { server: string; tools: { description?: string | null; inputSchema?: unknown; name: string }[]; transport: 'sse' | 'stdio' | 'streamable-http' }) => void

Fires once per server after listTools() and after the config-side filters (enabledTools / disabledTools / toolFilter) have applied, but BEFORE tools are registered. Handlers may mutate ctx.tools in place — splicing, reordering, or replacing entries — to further narrow what the model sees.

Composes with config-side filters: config drops tools the host's static policy excludes; this hook is the runtime escape hatch for per-user, per- environment, or capability-driven decisions that the config can't express.

Items are upstream tool descriptors (NOT yet namespaced as mcp_<server>_<tool>).

mcp:tools:list(ctx: { name: string; tools: readonly McpToolSchema[]; transport: 'sse' | 'stdio' | 'streamable-http' }) => void

Fires once per successful bootstrap with the raw, pre-filter tool schemas the server advertised via tools/list (or that the host pre-seeded via McpServerConfig.cachedTools). Useful for hosts that want to:

  • Persist a per-server tool catalog to disk so a settings UI can show every available tool — including ones currently hidden by disabledTools — without re-bootstrapping.
  • Build per-tool toggle UIs that reflect the full server surface.
  • Diff successive bootstraps to detect newly-added / removed tools.

Read-only — handlers must NOT mutate tools. Shape the model-facing list via mcp:tools:filter instead (which fires immediately after this hook and IS the mutation surface).

Does NOT fire when the bootstrap fails. Does NOT fire on lazy-connect servers' deferred first-call (the schemas were already known from cachedTools at the agent-construction boundary).

mcp:warn(ctx: { message: string; name: string }) => void

Diagnostic line from a server. For fatal failures hook mcp:error.

model:fallback(ctx: StreamHookContext & { discardedThinking?: true; err: Error; from: { model: string; provider: string }; targetIndex: number; to: { model: string; provider: string } }) => void

Fires when a stream fails terminally on the active provider+model target and the loop advances to the next entry of the run's fallback chain (AgentRunOptions.model array / multi-provider fan-out) instead of surfacing the error. Fires once per advancement; the terminal failure of the LAST target still lands on stream:error as usual.

Fallback is run-sticky: after this fires, the rest of the run stays on to (no ping-pong back to the primary); the next run() starts from the primary target again.

  • from / to — provider name + model id of the exhausted and newly active targets.
  • targetIndex — 0-based index of to in the resolved target list.
  • err — the classified error that exhausted from (an AgentProviderError whose limit says when it was a rate limit, spent quota or overload); the raw provider error is its cause.
  • discardedThinking — true when the failed attempt streamed reasoning before it failed; same meaning as on stream:retry.

Observational — handlers cannot cancel the fallback. To disable fallback, configure a single provider+model target.

Attribution note: agent:start's providerName comes from the PRIMARY target and is not updated when a fallback advances — this hook (and per-turn TurnUsage.modelId) is the source of truth for which target actually served a given stretch of the run.

oauth:refresh(ctx: OAuthRefreshHookContext) => void

Fires when the provider refreshes its OAuth credentials during a run.

output(ctx: { output: Record<string, unknown>; schema: Record<string, unknown> }) => void

Fires with the structured output the model produced and the schema it was validated against.

pairing:repair(ctx: PairingRepair & { turnId?: string }) => void

Fires once per repair performed by the pre-send pairing pass (ensureToolResultPairing). The pass runs immediately before every provider call and patches over the wire-level corruption modes that Anthropic / OpenAI 400 on (orphan tool_use / tool_result, duplicate ids, compaction-stranded blocks).

Observational — handlers can mirror to Sentry/PostHog/etc. for postmortems on new corruption sources but cannot suppress the repair. The companion behavior.strictToolPairing flag is the consumer escape hatch for "throw instead of repair" (training-data collectors).

turnId lets handlers correlate a repair burst with the run-loop turn that observed it. Absent on repairs detected from the synthetic fallback path in agent.run()'s error catch (the orphan-tool_use fallback for crash recovery), where no turn is in flight.

provider:diagnostic(ctx: ProviderDiagnostic & { runId: string; turnId: string }) => void

Fires when the provider reports a diagnostic event for a turn.

provider:input-transformations(ctx: InputTransformationsHookContext) => void

Fires when the provider dropped replayed thinking blocks server-side before sampling (Anthropic preserved-thinking binding, reported via input_transformations). Nothing was mutated on our side — contrast 'history:repair'. A prefix_binding_mismatch entry means this host rewrote system / tools / an earlier message since the block was produced: the same edit 'cache:break' attributes, now also costing the model its reasoning. Observational. See InputTransformationsHookContext.

repeat-guard:exceeded(ctx: { action: 'steer' | 'block' | 'abort'; count: number; threshold: number; tool: string; turnId: string }) => void

Fires when the consecutive-identical AgentBehavior.repeatGuard escalates on a tracked tool. In chain mode, count is the number of back-to-back identical calls including the offending one; in window mode it is the number of occurrences inside the configured window. action reflects the escalation: 'block' refused this call via tool:gate; 'steer' let it execute and appended a steering message to the result; 'abort' additionally aborts the run via the agent's AbortController (the loop then fires agent:abort). The 'abort' event fires BEFORE the abort so consumers can distinguish a guard abort from a user-driven cancel.

replay:done(ctx: Record<string, never>) => void

Durable-execution replay finished — the invocation transitioned from journal replay to live execution (the first non-journaled provider stream or tool body just started).

The framework core never fires this: it is emitted by durable-runtime adapters (see createReplayTracker({ hooks }) in zidane/restate) once per handler invocation. Hosts that would otherwise infer liveness from side channels (counting live provider calls, watching stream hooks) should key on this instead. Fires at most once per invocation; never fires when the journal replays to completion without going live (nothing left to execute — and nothing left to abort).

resume:mismatch(ctx: { reason: string; resumeTurn: number; runId: string; sessionId: string; storedTurns: readonly number[] }) => void

Agent.resume refused a run whose stored step records are of another loop turn than the one its saved turns resume at (a history rewrite): asking for keys of the wrong turn would miss a pending unsafe call and run it again. Also fires when the run's saved turns leave a tool call unanswered before their last turn (they were rewritten after the loop saved them). The run is no longer resumable: its records are dropped, its dangling tool calls are reported interrupted (a spawned child's are left to the next top-level run), and resume() resolves null.

run:beforeEnd(ctx: { followUp: (message: string) => void; runId: string; session?: Session; turn: number }) => void | Promise<void>

Fires once per would-be final turn: the model answered with no tool call and nothing is queued. A handler that calls followUp(message) keeps the run going with that message as a user turn (the first message wins). Journaled as a loop decision, so a replay takes the same branch. Not fired after an abort or an error.

session:end(ctx: SessionHookContext & { runId: string; session?: Session; status: SessionEndStatus; turnRange: [number, number] }) => void

Fires when a run ends, with its status and the range of turn indexes it produced. session is the run's session (absent on hand-built calls).

session:meta(ctx: SessionHookContext & { key: string; value: unknown }) => void

Fires when a session metadata key is set.

session:save(ctx: SessionHookContext) => void

Fires when the session is saved.

session:start(ctx: SessionHookContext & { prompt: string; runId: string }) => void

Fires when a run starts on a session, with the run id and prompt label.

session:turns(ctx: SessionHookContext & { count: number; turns: SessionTurn[] }) => void

Fires when new turns are flushed to the session, with the turns and the resulting turn count.

skills:activate(ctx: { skill: SkillConfig; via: ActivationVia }) => void

Fires when a skill is activated. via says what triggered it.

skills:catalog(ctx: { catalog: string; skills: SkillConfig[] }) => void

Fires with the rendered skills catalog text and the skills it lists.

skills:deactivate(ctx: { reason: DeactivationReason; skill: SkillConfig }) => void

Fires when a skill is deactivated. reason says why.

skills:resolve(ctx: { skills: SkillConfig[] }) => void

Fires once skills have been resolved, with the resolved skill list.

spawn:before(ctx: SpawnHookContext) => void

Fires when a spawned subagent is about to start.

spawn:complete(ctx: ChildRunStats) => void

Fires when a spawned subagent finishes, with its run stats.

spawn:error(ctx: SpawnHookContext & { error: Error }) => void

Fires when a spawned subagent fails.

spawn:queued(ctx: SpawnQueuedContext) => void

A top-level spawn call found every slot taken and waits for one (it starts, with spawn:before, once a running child finishes). Never fires for a subagent's spawn: that one is refused at the cap instead.

steer:inject(ctx: { content: readonly PromptPart[]; id?: string; text: string }) => void

Fires when the loop adds a queued message to the conversation as a user turn: an Agent.steer or Agent.followUp message, or a message of the agent's own (a nudge, a warning). id is the id that the caller gave to steer or followUp. text joins the text parts.

stream:end(ctx: StreamHookContext & { text: string }) => void

Fires when the model's text stream for the turn ends, with the full text.

stream:error(ctx: StreamHookContext & { err: unknown; requestId?: string; statusCode?: number }) => void

Fires when the provider's stream rejects BEFORE stream:end — provider errors, network blips, malformed tools payloads (400 invalid_request_error).

err is the original thrown value, not the typed AgentProviderError the loop subsequently constructs; subscribe here to capture the native SDK exception (status codes, request ids) for telemetry without having to walk cause chains. Observational — the loop still wraps + throws after this hook resolves, so handlers cannot suppress the failure.

Best-effort convenience fields extracted from common SDK shapes (Anthropic, OpenAI, OpenRouter, Cerebras) when present on err:

  • statusCode — HTTP status (e.g. 400, 429, 500, 502, 529)
  • requestId — provider request id for grep-the-logs correlation

Both absent when the SDK doesn't expose them (network/early rejects, stream-internal protocol errors). Walk err.cause chains yourself for deeper diagnostics.

Does NOT fire on user-initiated aborts; those land on agent:abort instead. Use err instanceof Error && err.name === 'AbortError' to distinguish if you also subscribe here for raw error logging.

stream:progress(ctx: StreamProgressHookContext) => void

Fires (throttled, ~4/s) while the parent turn streams and once more, unthrottled, right before stream:end. Carries the tokens streamed so far (provider-counted when the wire reports mid-stream usage, else chars ÷ 4) and a running USD estimate for the turn. Hosts paint live counters from it; turn:after settles the figure. Not bubbled for subagents — their spend lands at child:turn:after. Observational and best-effort.

stream:retry(ctx: StreamHookContext & { attempt: number; delayMs: number; discardedText?: true; discardedThinking?: true; err: unknown; maxAttempts: number; nextAttempt: number; requestId?: string; statusCode?: number }) => void

Fires when the loop catches a retryable provider error and is about to sleep before re-issuing provider.stream(). Does NOT fire on the terminal failure (the attempt the loop gives up on) — that lands on stream:error instead.

Use this hook to surface "Anthropic overloaded — retrying in Ns" in the TUI, count retries in observability, or short-circuit further retry logic from an outer harness.

  • attempt — 1-indexed number of the attempt that just failed, counted in its retry budget: transport failures (a dropped socket) have their own, longer one, separate from behavior.retry.
  • nextAttempt — attempt + 1, the attempt that will run after the sleep.
  • delayMs — the wait the loop is about to take. Already accounts for any retry-after / retry-after-ms header the server returned and the configured maxDelayMs cap.
  • err — the raw thrown value (same as stream:error.err).
  • statusCode / requestId — extracted via the same helper as stream:error when available.

Observational — handlers cannot cancel the retry. To disable provider-classified retries, set behavior.retry.maxAttempts: 1.

stream:server_tool_result(ctx: StreamHookContext & { content: unknown; toolName: string; toolUseId: string }) => void

Pairs with ['stream:server_tool_use'] — fires when the provider closes the matching server-tool-result block. content is the raw block payload (either an error object or an array of result entries carrying encrypted_content / encrypted_index); consumers format it for display.

stream:server_tool_use(ctx: StreamHookContext & { id: string; input: Record<string, unknown>; name: string }) => void

Fires mid-stream when a provider that executes a tool server-side (currently: Anthropic's web_search_20250305) closes the server-tool-invocation content block. Symmetric with tool:before for function tools — surface the call into the transcript at the moment the model issued it, without waiting for the assistant message to finalize.

Shape is intentionally generic so future server tools reuse the same hook. id is the block's tool-use id (echoed back to the provider on follow-up turns); pair with stream:server_tool_result via that id.

Observational — handlers cannot block or substitute, since the call has already executed server-side by the time we see it.

stream:start(ctx: StreamHookContext & { startedAt: number }) => void

Fires immediately before the loop calls provider.stream(). Pairs with stream:end / stream:error; lets observers timestamp the moment the request leaves the client to compute wait-vs-generate split and TTFT relative to provider dispatch (not run start).

startedAt is Date.now() at fire time. Observational — handlers cannot mutate the in-flight StreamOptions (use turn:before / context:transform / system:transform for that).

stream:text(ctx: StreamHookContext & { delta: string; text: string }) => void

Fires for each text delta of the model response. delta is the new chunk and text the accumulated text.

stream:thinking(ctx: StreamHookContext & { delta: string; thinking: string }) => void

Fires for each thinking delta. delta is the new chunk and thinking the accumulated thinking text.

system:before(ctx: { system: string }) => void

Fires once per run, before the first turn, only when AgentRunOptions.system is set. ctx.system is that run-level string. Observational: the agent does not read ctx.system back, so use system:transform to rewrite the system prompt.

system:transform(ctx: { messages: readonly SessionMessage[]; session?: Session; system: string; turn: number; turnId: string }) => void

Fires per request, after context:transform and before the request goes out. Mutating ctx.system updates the system prompt the provider sends for this turn.

Anything written here must be byte-stable across turns

The system prompt sits ABOVE the whole conversation in the provider's cache prefix — Anthropic hashes tools → system → messages, cumulatively — so a one-byte change to ctx.system invalidates every message-level breakpoint and re-bills the entire transcript at the cache-write rate. Only tools and the static half of the system prompt survive, which is what makes this hard to spot: cacheRead stays large and non-zero (it becomes a CONSTANT equal to the scaffold) while cost climbs.

The classic way to get this wrong is a live tally: interpolating something like You have already read ${n} files… re-renders the prompt on most turns and quietly re-bills the transcript on each one.

Constant guidance strings, feature flags and run-stable facts are fine here. Live counters, timestamps, elapsed times, per-turn indices and anything derived from a growing tally are NOT — not even in the dynamic half below SYSTEM_PROMPT_BOUNDARY, which carries no cache_control of its own but is still inside the prefix that every message breakpoint hashes.

Genuinely per-turn state belongs in the transcript, persisted as immutable history so it cannot shift under the prefix on a later turn. A wire-only append to the newest message does NOT work either: that message re-renders without the append next turn and the prefix diverges there instead. Install installCacheBreakLogger while developing anything that writes here.

messages is read-only here; use context:transform for message surgery. session is undefined when the run is sessionless.

tool-batch:resumed(ctx: { done: readonly string[]; resuming: readonly string[]; runId: string; turn: number; turnId: string }) => void

Fires when a durable run resumes a tool batch a previous process left unfinished, before any call of it runs again. done calls were rebuilt from their step records (show them finished); resuming calls run again or are reported interrupted. Optional for hosts: one that ignores it shows the run as running and fills in the calls when the batch ends.

Checkpoint runtimes only: in replay mode the loop cannot tell which calls are done before it issues them, and the hook would fire on every replay.

tool-budget:exceeded(ctx: { count: number; max: number; mode: 'steer' | 'block'; tool: string; turnId: string }) => void

Fires when a per-tool budget configured via behavior.toolBudgets is exceeded for a specific tool. mode reflects how the framework reacted: 'steer' lets the call run and queues a post-turn nudge; 'block' refuses the call outright with Blocked: <message>.

count is the run-cumulative dispatched count just before this call. Use turnId to correlate with turn:after if you need the integer turn index. Distinct from budget:exceeded (byte-level) so consumers can subscribe specifically; both can fire in the same turn.

tool-results:after(ctx: { message: SessionTurn; results: readonly ToolResult[]; turn: number; turnId: string }) => void

Fires after a tool-results user turn is pushed onto the conversation, before any byte-budget enforcement or follow-up steering. Symmetric with turn:after but for the user-role tool_result turn that closes the round-trip.

Why it exists: persistence layers must write tool_use/tool_result turn pairs together — if only the assistant turn (carrying tool_use blocks) is durable, a process death between turns leaves the DB with an orphan tool_use that Anthropic rejects on resume. Subscribe here to persist the tool-results turn before the loop continues.

tool-state:commit-failed(ctx: { callId: string; error: Error; handled?: boolean; ops: readonly ToolStateOp[]; runId: string; toolName: string }) => void

Fires when a settled tool call's durable state (ToolContext.state) could NOT be committed — the backing AgentOptions.toolState store rejected the write.

The call itself is unaffected: the tool body already ran and its result is already on its way to the model, so failing the run over a bookkeeping write would be worse than losing it. But losing it silently is worse still — a dropped one-shot flag re-arms the exact bug durable tool state exists to prevent — so this is the seam for alerting on it. ops is the delta that did NOT land, in the order the tool staged it; replaying it by hand is a legitimate recovery.

Without a handler (or with one that leaves handled unset) the loop logs to stderr. Set handled = true when your handler owns the reporting and you don't want the duplicate log line.

tool:after(ctx: ToolHookContext & { coercions?: readonly string[]; isError: boolean; outcome?: ToolOutcome; outputBytes: number; result: string | ToolResultContent[]; runToolCounts: Readonly<Record<string, number>> }) => void

Fires after a tool body produced a result (the execute path) or a tool:gate listener substituted one via ctx.result (the cache-substitute path). Carries the post-tool:transform payload — what the model actually sees on the wire.

Does NOT fire on gate-block / unknown-tool / validation-reject paths: the loop synthesizes their tool_result text inline and sends it back to the model, but no tool body produced output and no tool:transform ran. Consumers wanting symmetric per-call notification across every dispatch path should listen to tool:dispatched instead (which fires once per call regardless of path, with an outcome discriminator).

This narrow contract is deliberate: tracing spans opened in tool:before only get closed where they were opened, byte budgets only count real tool output, and chat-layer transcript renderers keep their tool ↔ tool-result pairing without special-casing the synthesized paths.

tool:before(ctx: ToolHookContext & { coercions?: readonly string[]; priorContent?: string; runToolCounts: Readonly<Record<string, number>>; unknown?: boolean }) => void

Fires before a tool body runs. Mutate ctx.input to rewrite the arguments the body sees.

tool:cancelled(ctx: ToolHookContext & { reason: string; runToolCounts: Readonly<Record<string, number>> }) => void

Fires when a single in-flight tool call is cancelled mid-flight via agent.cancelTool(callId) — typically the TUI's "cancel this tool" affordance. Distinguished from tool:error (the tool body threw), agent:abort (whole run aborted), and the batch-layer INTERRUPT_MESSAGE_FOR_TOOL_USE shape (sibling drain on full abort) so consumers can split the four causes.

Semantics:

  • The tool's body has already received an abort signal (via the combined per-call + run signal); whether it actually unwound is up to the body's signal awareness.
  • The framework returns the canonical TOOL_USE_CANCELLED_MESSAGE to the model with isError: true so the assistant turn closes cleanly and the model can continue with the next batch call.
  • tool:after and tool:transform do not fire for a cancelled call — there's no real result the loop should account for.

runToolCounts mirrors the tool:gate / tool:before snapshot for this call (frozen, pre-increment) so a single per-call observer can subscribe to either hook without re-walking the run.

tool:dispatched(ctx: ToolHookContext & { outcome: 'execute' | 'gate-substitute' | 'gate-block' | 'unknown' | 'invalid-input'; reason?: string; runToolCounts: Readonly<Record<string, number>> }) => void

Symmetric notification fired ONCE per call the model dispatched, regardless of the path the call ultimately took through the loop. Every call that produces a tool_result closes with exactly one terminal event, so pair tool:dispatched with that terminal to reconstruct message history / close per-call spans:

  • outcome: 'execute' → closes with tool:after OR, if the call was cancelled in-flight via agent.cancelTool, with tool:cancelled (NOT tool:after). A span keyed on dispatched(execute)→after will leak on cancel — also listen for tool:cancelled.
  • outcome: 'gate-substitute' → closes with tool:after.
  • outcome: 'gate-block' | 'invalid-input' → no tool:after (the body never ran); the dispatch event itself carries the result.
  • outcome: 'unknown' → no tool:after; closes after tool:unknown (+ optional tool:error). The strict direction always holds: no tool:after fires without a preceding tool:dispatched.

Distinct from tool:before: tool:before fires when a tool's body is about to execute (the "I'm running this tool" notification) OR — as a special case — once with unknown: true for hallucinated tool names, so UI consumers rendering tool cards still see the model's attempt. It still skips gate-block / gate-substitute / validation-reject paths. tool:dispatched fires on all five paths with an outcome discriminator so live consumers never see a tool:after whose matching dispatch event was suppressed.

Strict ordering: tool:dispatched fires AFTER tool:gate / validation:reject / tool:unknown so the resolved outcome is always accurate. Fires BEFORE tool:before on the execute path so consumers binding both events see dispatched-then-before.

reason is set only when outcome === 'gate-block' and carries the gate's refusal text (so consumers can render denied-diff badges, etc. without a side channel).

tool:error(ctx: ToolHookContext & { error: Error; result?: string | ToolResultContent[] }) => void

Fires when a tool throws during execution. Mutate result to substitute a tool-output payload that gets sent back to the model in place of the default Tool error: <msg> string — useful for OSS-model error rewriting (collapse stack traces, hide internal paths, prepend recovery hints).

The post-hook value flows through tool:transform like a normal output, so downstream byte-budgeting and image-stripping still apply.

tool:gate(ctx: ToolHookContext & ToolGateResumeFields & { block: boolean; metadata: ToolResultMetadata; reason: string; result?: string | ToolResultContent[]; runToolCounts: Readonly<Record<string, number>> }) => void

Fires before validation, tool:before, and execute. Two ways to intercept:

  • Set block = true (with a reason) to refuse the call. The model sees a tool_result block with content = Blocked: <reason> and is_error: true. tool:before / tool:after do not fire; tool:dispatched does (with outcome: 'gate-block') so trace / audit consumers see the refusal. The exact wire payload is:

    Text
    {
      "type": "tool_result",
      "tool_use_id": "<call id>",
      "content": "Blocked: <reason>",   // verbatim, no wrapping XML
      "is_error": true
    }
    

    The Blocked: prefix is fixed (single space, no colon variations); the <reason> is whatever string the gate handler assigned to ctx.reason (defaults to 'Tool execution was blocked' if no handler set it). Pinned by test/per-run-hooks.test.ts so SDK consumers can safely string-match for telemetry / eval scoring.

  • Set result to substitute a successful tool_result and skip execution. The model sees the substitute as a normal tool_result; tool:before does not fire, but tool:after and tool:transform do — so byte budgets, telemetry, and post-mutation hooks see the substitute. Useful for cache hits, dedup, idempotency guards, plan-mode synthetic acks.

If multiple handlers along the chain set both block and result, block wins — refusal beats substitution, so a policy gate (skills allow-list, custom security) can always override an upstream consumer's cache substitute. Mirrors the writable-result shape on tool:unknown and tool:error so consumers learn one pattern.

runToolCounts — frozen pre-call snapshot of per-tool dispatched counts in this run. Use it to self-throttle, drive observability, or implement budget guards. Counts every call that passed gate, including dedup substitutes; excludes blocked calls.

Concurrent fleets see the same pre-batch snapshot on every member's tool:gate; the built-in budget / dedup middleware reserves per-call atomically so behavior.toolBudgets enforces correctly even when multiple tool:gate callbacks fire in the same microtask.

Gate a tool call before it runs. Handlers share ONE mutable ctx and run sequentially in registration order (the order they were hook()-ed). The framework reads the final state once all handlers (including async ones, which are awaited) have run. Built-in behavior gates install during run setup and self-defer when another gate already set block / result.

Precedence across the resolved state:

  • block beats result: if any handler left block === true, the call is refused (Blocked: <reason>, isError: true) and any result substitute is dropped. Security/refusal beats convenience/substitution.
  • Among handlers, it is effectively last-writer-wins per field (each just mutates the shared ctx). Cooperative built-ins self-defer — e.g. repeat-guard early-returns when ctx.block || ctx.result is already set, so it never double-penalizes a call another guard already broke. Host handlers composing with the built-ins should do the same (check ctx.block/ctx.result before acting) to keep precedence predictable.

Set block = true (+ reason) to refuse, or assign result to short-circuit with a synthetic successful tool_result (the body never runs; tool:after still fires). Mutating ctx.input rewrites the args the body and downstream hooks see. metadata rides the refused tool_result (read only when the call is blocked).

Resumed calls (resumed): a checkpoint run resumed after a process death dispatches again the calls whose step was recorded, which passed the gate in the process that died. Their gate runs again so handlers restore what they keep per call, without asking anyone: the framework ignores result for them, and block too unless the call's body runs again. What a handler writes to resumeState (JSON, under a key of its own) is stored with the call's step record and comes back on a resumed call.

tool:progress(ctx: ToolHookContext & { progress: ToolProgress }) => void

Live output of a running tool (ToolContext.reportProgress): a codemode script's printed items, a command's output chunks. Fires between tool:before and the call's terminal event, never on replay of a recorded call. Nothing here reaches the model or the session.

tool:transform(ctx: ToolHookContext & { coercions?: readonly string[]; isError: boolean; metadata: ToolResultMetadata; outputBytes: number; result: string | ToolResultContent[] }) => void

Fires after a tool produces output and before the result goes back to the model. Mutate ctx.result to rewrite it. outputBytes is the size before any mutation.

tool:unknown(ctx: ToolHookContext & { result?: string | ToolResultContent[]; suppressError: boolean }) => void

Fires before the generic "Unknown tool" error when the model invokes a tool that isn't registered (hallucinated names, dropped MCP servers, dangling aliases). Mutate result to substitute a friendly response or set suppressError: true to skip the companion tool:error emission.

Fires for any unknown tool name — including hallucinated MCP-style names (mcp_<server>_xxx); branch on name.startsWith('mcp_') to differentiate.

tracing:redact(ctx: { kind: 'system' | 'prompt' | 'tool-input' | 'tool-result' | 'mcp-tool-input' | 'mcp-tool-result' | 'stream-text' | 'stream-thinking' | 'decision-answers'; meta?: Readonly<Record<string, unknown>>; redacted: string; value: string }) => void

Privacy seam invoked by observability adapters (tracing, metrics, logging, persistence sinks) before attaching potentially-sensitive payloads as span attributes / log lines / external storage.

Handlers mutate ctx.redacted — the field starts equal to value and the final post-hook string is what consumers ship downstream. Default (no handlers) is no redaction. Multiple handlers compose left-to-right; the last writer wins.

kind lets a single handler do shape-aware scrubbing (system prompts vs. tool args vs. tool output have very different secret surfaces). meta carries context-specific identifiers (toolName, server, turnId, callId) the adapter knows at the call site — handlers use it for policy decisions ("skip redaction on internal read_file results"). Adapters that don't fire this hook simply attach raw values.

Synchronous-only by design: observability stays on the hot path. Heavy scrubbing (regex over MB of output) belongs in tool:transform instead.

turn:after(ctx: { aborted?: boolean; cumulativeUsage: Readonly<{ cacheCreation: number; cacheRead: number; cost?: number; input: number; output: number; turns: number }>; message: SessionTurn; toolCounts: { run: Readonly<Record<string, number>>; turn: Readonly<Record<string, number>> }; turn: number; turnId: string; usage: TurnUsage }) => void

Fires after each assistant turn (before its tool-result follow-up dispatches; the loop iterates back to a fresh turn:before once the tool results are produced).

toolCounts.turn — calls emitted by the model in this assistant turn, keyed by canonical tool name. Reflects what the model asked for, regardless of downstream gate outcome. Most useful for spotting per-turn spikes ("the model called todowrite 4 times in one turn").

toolCounts.run — cumulative running counter of dispatched calls scoped to this runId, captured at fire time. Excludes calls that were blocked by tool:gate handlers. Includes calls short-circuited via tool:gate result substitution (the model still asked, the framework just answered without the tool running). Resumed sessions start a fresh run with empty counts.

Both fields are frozen snapshots; mutate-safe.

aborted — runtime emissions always include this boolean; it is optional on the type only so tests/adapters that manually synthesize turn:after payloads do not break (treat absent as false). true when the run's AbortController was already tripped at the moment this turn:after fired. This is the case to branch on instead of usage.finishReason when emulating Stop-hook semantics: an aborted turn can still carry finishReason: 'stop' (e.g. a final text turn the user cancelled mid-stream), so finishReason alone cannot distinguish "model finished" from "we killed it".

Covers the streaming-abort path (cancel landed while the turn was streaming) and any abort that landed before this fired. NOTE: a tool:gate / repeat-guard abort trips DURING the tool batch, which runs after this turn:after — so the offending turn reports aborted: false here (its finishReason is 'tool-calls', never 'stop', so a 'stop'-keyed consumer won't misfire). That abort surfaces on the subsequent agent:abort. For the post-completion window (host queues a follow-up after a clean turn:after, then the run is aborted), rely on Agent.steer/Agent.followUp returning false once aborted. false on every normal turn boundary.

turn:before(ctx: { options: StreamOptions; turn: number; turnId: string }) => void

Fires immediately before each turn's provider.stream() call, after context:transform + pairing repair + system:transform. The options object IS mutable as a last-mile escape hatch (per-turn reminders, dynamic tags, audit wrapping), but prefer ['context:transform'] for any mutation that touches messages — it runs upstream of the defensive pairing pass and stays cheaper. Loop re-runs ensureToolResultPairing + ensureEndsWithUserMessage after this hook so a messages mutation here can't leak a tool_result must be preceded by a tool_call 400 to the wire, but you pay one extra walk for the privilege.

usage(ctx: { totalIn: number; totalOut: number; turn: number; turnId: string; usage: TurnUsage }) => void

Fires after each turn with that turn's token usage and the run's cumulative input and output tokens.

validation:coerce(ctx: ToolHookContext & { coercions: readonly string[]; schema: Record<string, unknown> }) => void

Fires when validateToolArgs successfully auto-coerced one or more input fields to satisfy the tool's inputSchema. Only fires when at least one coercion happened — never on perfectly-shaped inputs. Useful for counting model "wrongness rate" without re-running validation downstream.

coercions lists the field names that were coerced. The values landed in the input that the tool actually received; consumers wanting before/after comparison can re-run validateToolArgs(ctx.input, ctx.schema).

validation:reject(ctx: ToolHookContext & { reason: string; schema: Record<string, unknown> }) => void

Fires when validateToolArgs rejects an input that could not be auto-coerced to satisfy the tool's inputSchema. Observational — the tool call still surfaces a Validation error: … string back to the model. Useful for counting validation failures separately from runtime tool errors.

src/agent-hooks.ts:38

AgentOptionsInterface#

Options for createAgent. Only provider is required.

TypeScript
interface AgentOptions
AgentOptions properties
NameTypeDescription
approveToolCall?ToolCallApprover

Resolves a tool.call interceptor's 'ask' verdict through the host's approval flow. Without it, 'ask' blocks the call.

artifacts?boolean

Session artifacts master switch (docs/ARTIFACTS.md). false makes the loop ignore the reserved metadata.artifacts namespace entirely — no store writes, no artifact:recorded — regardless of which detectors or tools are composed. Hosts mirror Settings.artifacts here. Default: on.

behavior?AgentBehavior

Agent-level behavior defaults (overridden by run-level behavior)

behaviorTransforms?readonly ExtensionOrderedContribution<ExtensionBehaviorTransform>[]

Behavior transforms contributed by extensions, applied before each run.

clock?AgentClock

Time + UUID source for all journaled metadata (turn ids, createdAt stamps, runIds, hook payloads consumers may persist). Defaults to DEFAULT_AGENT_CLOCK (Date.now() / crypto.randomUUID()).

Override at the run level via AgentRunOptions.clock. Live-only measurements (TTFT, elapsed counters) keep Date.now() regardless.

Ignored on a replay-mode durable runtime (AgentOptions.durable): the run uses the runtime's clock, so replay regenerates byte-identical session metadata across attempts.

contextBreakdownSections?readonly ContextBreakdownContributor[]

Extension-owned context panel contributors. Hosts that already spread buildExtensionPreset(registry) can pass registry.contextBreakdownSections here without re-folding the whole registry and duplicating hooks/tools.

decisions?false | AgentDecisionsOptions

Decisions configuration for agent.decide / ctx.decide.

  • omitted: use the first provider in provider that implements decisions, with its default decision model.
  • an object: pin the provider, default model and/or timeout.
  • false: decisions are disabled; every call returns { ok: false, reason: 'unavailable' }.
durable?DurableRuntime | DurableOptions

Durable runtime every model call, tool call and loop decision goes through, so a run can resume after a process death (zidane/durable): the runtime itself, or DurableOptions to also say what run() does with a run a dead process left. Unset: the agent's memory runtime, and runs record no steps, so resume() rejects.

eager?boolean

Pre-connect MCP servers in the background as soon as createAgent returns, instead of deferring the bootstrap to the first agent.run().

Useful when MCP latency is the dominant cost of a cold start: callers that construct the agent early (e.g. at process init) can hide the bootstrap behind other setup work. If bootstrap fails while a run() / warmup() is awaiting the same in-flight promise, that caller sees the error. Otherwise the failure is dropped and the next run() / warmup() retries the connection.

Also starts skills resolution when skills are configured. No-op when there are no MCP servers and no skills. Default: false.

execution?ExecutionContext

Execution context: where tools run. Defaults to in-process.

extensionRuntime?AgentExtensionRuntime

The extension runtime's registries (ExtensionRegistry.runtime): the interceptor chains the agent runs at its call sites, and the tools catalog it replays per run. Folded from extensions.runtime when unset. Absent: no interceptor call site runs.

extensions?ExtensionRegistry

Pre-built ExtensionRegistry to fold into this agent, synchronously.

Build it first (async) via buildExtensionRegistry(defs.map(inlineLoadedExtension)), then pass the RESULT here — createAgent is sync, so it takes the merged registry, not raw definitions. Folded when the agent is created (see foldExtensionRegistry), with explicit AgentOptions winning on collision:

  • tools — merged by name; mcpServers — deduped by name (explicit wins on collision).
  • skills — last-wins: an explicit skills REPLACES the extension's wholesale (composePresets doesn't per-skill merge).
  • hooks — bucketed per event; handlers from BOTH the registry and hooks fire (registry first).
  • system fragments — appended AFTER system (blank-line separated).
  • subagents — applied to whichever spawn tool survives, via spawn.withSubagents.

Config is NOT folded: ctx.config.get reads at runtime through the registry's own captured readSettings getter (a registry built without one returns declared defaults). themes / agents / providers are host/picker concerns and intentionally stay out of the agent.

extensionScope?ExtensionCallScope

For a subagent: the session and spawning run its interceptor inputs name (ToolCall.sessionId, ToolCall.parentRunId). The spawn tool sets it with extensionRuntime; read only when extensionRuntime is set.

handle?ExecutionHandle | (() => Promise<ExecutionHandle>)

Pre-bound execution handle. When set, the agent uses it verbatim and does NOT call execution.spawn() — and does NOT destroy() it on teardown, since the host owns its lifecycle.

The durable-execution use case: a host that needs the spawn to be a recorded, replay-stable side effect drives execution.spawn() itself as a step of its durable runtime and hands the resulting handle here. On replay the recorded handle comes back identical, so the agent re-attaches to the same sandbox/container instead of minting a fresh one (which the internal lazy spawn, being unrecorded, cannot guarantee). Pair with a context that can re-attach by handle id (e.g. createSandboxContext, whose handle id IS the sandbox id) and a provider sandboxId for the underlying backend.

For host-observation without driving spawn yourself, see the execution:ready hook, which fires once with the bound handle.

A function form resolves the host-owned handle lazily, at the moment the agent would otherwise spawn (first run / ensureExecution()) — for hosts that build agents synchronously but keep one handle across rebuilds (chat/execution-cache.ts). Same ownership contract: never spawned, never destroyed by the agent.

hooks?Partial<{ 'agent:abort': ((ctx: { reason?: string; runId?: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { reason?: string; runId?: string } & DecideHookContext, ...rest: []) => void)[]; 'agent:done': ((ctx: AgentStats & DecideHookContext, ...rest: []) => void) | ((ctx: AgentStats & DecideHookContext, ...rest: []) => void)[]; 'agent:start': ((ctx: { agentName?: string; depth: number; parentRunId?: string; providerName?: string; runId: string; startedAt: number; tracingContext?: Readonly<Record<string, string>> } & DecideHookContext, ...rest: []) => void) | ((ctx: { agentName?: string; depth: number; parentRunId?: string; providerName?: string; runId: string; startedAt: number; tracingContext?: Readonly<Record<..., ...>> } & DecideHookContext, ...rest: []) => void)[]; 'artifact:recorded': ((ctx: { artifact: SessionArtifact; callId: string; name: string; runId: string; turnId: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { artifact: SessionArtifact; callId: string; name: string; runId: string; turnId: string } & DecideHookContext, ...rest: []) => void)[]; 'background:adopt': ((ctx: { command: string; cwd: string; outputPath: string; pid: number; startedAt: number; taskId: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { command: string; cwd: string; outputPath: string; pid: number; startedAt: number; taskId: string } & DecideHookContext, ...rest: []) => void)[]; 'background:exit': ((ctx: { command: string; durationMs: number; exitCode: number; outputPath: string; signal?: Signals; status: 'exited' | 'killed'; taskId: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { command: string; durationMs: number; exitCode: number; outputPath: string; signal?: Signals; status: 'exited' | 'killed'; taskId: string } & DecideHookContext, ...rest: []) => void)[]; 'background:reassign': ((ctx: { childId?: string; command: string; fromHandleId: string; outputPath: string; pid: number; startedAt: number; taskId: string; toHandleId: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { childId?: string; command: string; fromHandleId: string; outputPath: string; pid: number; startedAt: number; taskId: string; toHandleId: string } & DecideHookContext, ...rest: []) => void)[]; 'background:reconcile': ((ctx: { exits: TaskExitInfo[] } & DecideHookContext, ...rest: []) => void) | ((ctx: { exits: TaskExitInfo[] } & DecideHookContext, ...rest: []) => void)[]; 'background:stall': ((ctx: { bytesWritten: number; command: string; outputPath: string; stalledForMs: number; taskId: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { bytesWritten: number; command: string; outputPath: string; stalledForMs: number; taskId: string } & DecideHookContext, ...rest: []) => void)[]; 'background:start': ((ctx: { command: string; cwd: string; outputPath: string; pid: number; startedAt: number; taskId: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { command: string; cwd: string; outputPath: string; pid: number; startedAt: number; taskId: string } & DecideHookContext, ...rest: []) => void)[]; 'budget:exceeded': ((ctx: { budget: number; bytes: number; turn: number; turnId: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { budget: number; bytes: number; turn: number; turnId: string } & DecideHookContext, ...rest: []) => void)[]; 'cache:break': ((ctx: CacheBreakInfo & DecideHookContext, ...rest: []) => void | Promise<void>) | ((ctx: CacheBreakInfo & DecideHookContext, ...rest: []) => void | Promise<void>)[]; 'cache:drift': ((ctx: CacheDriftInfo & DecideHookContext, ...rest: []) => void | Promise<void>) | ((ctx: CacheDriftInfo & DecideHookContext, ...rest: []) => void | Promise<void>)[]; 'child:artifact:recorded': ((ctx: { artifact: SessionArtifact; callId: string; childId: string; depth: number; name: string; runId: string; turnId: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { artifact: SessionArtifact; callId: string; childId: string; depth: number; name: string; runId: string; turnId: string } & DecideHookContext, ...rest: []) => void)[]; 'child:background:exit': ((ctx: { childId: string; command: string; depth: number; durationMs: number; exitCode: number; outputPath: string; signal?: Signals; status: 'exited' | 'killed'; taskId: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { childId: string; command: string; depth: number; durationMs: number; exitCode: number; outputPath: string; signal?: Signals; status: 'exited' | 'killed'; taskId: string } & DecideHookContext, ...rest: []) => void)[]; 'child:background:reassign': ((ctx: { childId: string; command: string; depth: number; fromHandleId: string; outputPath: string; pid: number; startedAt: number; taskId: string; toHandleId: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { childId: string; command: string; depth: number; fromHandleId: string; outputPath: string; pid: number; startedAt: number; taskId: string; toHandleId: string } & DecideHookContext, ...rest: []) => void)[]; 'child:background:start': ((ctx: { childId: string; command: string; cwd: string; depth: number; outputPath: string; pid: number; startedAt: number; taskId: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { childId: string; command: string; cwd: string; depth: number; outputPath: string; pid: number; startedAt: number; taskId: string } & DecideHookContext, ...rest: []) => void)[]; 'child:mcp:tool:gate': ((ctx: McpToolHookContext & { block: boolean; childId: string; depth: number; reason: string; result?: string | ToolResultContent[] } & DecideHookContext, ...rest: []) => void) | ((ctx: McpToolHookContext & { block: boolean; childId: string; depth: number; reason: string; result?: string | ToolResultContent[] } & DecideHookContext, ...rest: []) => void)[]; 'child:repeat-guard:exceeded': ((ctx: { action: 'steer' | 'block' | 'abort'; childId: string; count: number; depth: number; threshold: number; tool: string; turnId: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { action: 'steer' | 'block' | 'abort'; childId: string; count: number; depth: number; threshold: number; tool: string; turnId: string } & DecideHookContext, ...rest: []) => void)[]; 'child:spawn:before': ((ctx: Omit<SpawnHookContext, 'tracingContext'> & ChildSpawnIdentity & DecideHookContext, ...rest: []) => void) | ((ctx: Omit<SpawnHookContext, 'tracingContext'> & ChildSpawnIdentity & DecideHookContext, ...rest: []) => void)[]; 'child:spawn:complete': ((ctx: ChildRunStats & ChildSpawnIdentity & DecideHookContext, ...rest: []) => void) | ((ctx: ChildRunStats & ChildSpawnIdentity & DecideHookContext, ...rest: []) => void)[]; 'child:spawn:error': ((ctx: Omit<SpawnHookContext, 'tracingContext'> & ChildSpawnIdentity & { error: Error } & DecideHookContext, ...rest: []) => void) | ((ctx: Omit<SpawnHookContext, 'tracingContext'> & ChildSpawnIdentity & { error: Error } & DecideHookContext, ...rest: []) => void)[]; 'child:stream:end': ((ctx: StreamHookContext & { childId: string; depth: number; text: string } & DecideHookContext, ...rest: []) => void) | ((ctx: StreamHookContext & { childId: string; depth: number; text: string } & DecideHookContext, ...rest: []) => void)[]; 'child:stream:error': ((ctx: StreamHookContext & { childId: string; depth: number; err: unknown } & DecideHookContext, ...rest: []) => void) | ((ctx: StreamHookContext & { childId: string; depth: number; err: unknown } & DecideHookContext, ...rest: []) => void)[]; 'child:stream:retry': ((ctx: StreamHookContext & { attempt: number; childId: string; delayMs: number; depth: number; discardedText?: true; discardedThinking?: true; err: unknown; maxAttempts: number; nextAttempt: number; requestId?: string; statusCode?: number } & DecideHookContext, ...rest: []) => void) | ((ctx: StreamHookContext & { attempt: number; childId: string; delayMs: number; depth: number; discardedText?: true; discardedThinking?: true; err: unknown; maxAttempts: number; nextAttempt: number; requestId?: string; statusCode?: number } & DecideHookContext, ...rest: []) => void)[]; 'child:stream:server_tool_result': ((ctx: StreamHookContext & { childId: string; content: unknown; depth: number; toolName: string; toolUseId: string } & DecideHookContext, ...rest: []) => void) | ((ctx: StreamHookContext & { childId: string; content: unknown; depth: number; toolName: string; toolUseId: string } & DecideHookContext, ...rest: []) => void)[]; 'child:stream:server_tool_use': ((ctx: StreamHookContext & { childId: string; depth: number; id: string; input: Record<string, unknown>; name: string } & DecideHookContext, ...rest: []) => void) | ((ctx: StreamHookContext & { childId: string; depth: number; id: string; input: Record<string, unknown>; name: string } & DecideHookContext, ...rest: []) => void)[]; 'child:stream:text': ((ctx: StreamHookContext & { childId: string; delta: string; depth: number; text: string } & DecideHookContext, ...rest: []) => void) | ((ctx: StreamHookContext & { childId: string; delta: string; depth: number; text: string } & DecideHookContext, ...rest: []) => void)[]; 'child:stream:thinking': ((ctx: StreamHookContext & { childId: string; delta: string; depth: number; thinking: string } & DecideHookContext, ...rest: []) => void) | ((ctx: StreamHookContext & { childId: string; delta: string; depth: number; thinking: string } & DecideHookContext, ...rest: []) => void)[]; 'child:tool:after': ((ctx: ToolHookContext & { childId: string; coercions?: readonly string[]; depth: number; isError?: boolean; outcome?: ToolOutcome; outputBytes: number; result: string | ToolResultContent[]; runToolCounts: Readonly<Record<string, number>> } & DecideHookContext, ...rest: []) => void) | ((ctx: ToolHookContext & { childId: string; coercions?: readonly string[]; depth: number; isError?: boolean; outcome?: ToolOutcome; outputBytes: number; result: string | ToolResultContent[]; runToolCounts: Readonly<Record<string, number>> } & DecideHookContext, ...rest: []) => void)[]; 'child:tool:before': ((ctx: ToolHookContext & { childId: string; coercions?: readonly string[]; depth: number; priorContent?: string; runToolCounts: Readonly<Record<string, number>> } & DecideHookContext, ...rest: []) => void) | ((ctx: ToolHookContext & { childId: string; coercions?: readonly string[]; depth: number; priorContent?: string; runToolCounts: Readonly<Record<string, number>> } & DecideHookContext, ...rest: []) => void)[]; 'child:tool:cancelled': ((ctx: ToolHookContext & { childId: string; depth: number; reason: string; runToolCounts: Readonly<Record<string, number>> } & DecideHookContext, ...rest: []) => void) | ((ctx: ToolHookContext & { childId: string; depth: number; reason: string; runToolCounts: Readonly<Record<string, number>> } & DecideHookContext, ...rest: []) => void)[]; 'child:tool:dispatched': ((ctx: ToolHookContext & { childId: string; depth: number; outcome: 'execute' | 'gate-substitute' | 'gate-block' | 'unknown' | 'invalid-input'; reason?: string; runToolCounts: Readonly<Record<string, number>> } & DecideHookContext, ...rest: []) => void) | ((ctx: ToolHookContext & { childId: string; depth: number; outcome: 'execute' | 'gate-substitute' | 'gate-block' | 'unknown' | 'invalid-input'; reason?: string; runToolCounts: Readonly<Record<string, number>> } & DecideHookContext, ...rest: []) => void)[]; 'child:tool:error': ((ctx: ToolHookContext & { childId: string; depth: number; error: Error } & DecideHookContext, ...rest: []) => void) | ((ctx: ToolHookContext & { childId: string; depth: number; error: Error } & DecideHookContext, ...rest: []) => void)[]; 'child:tool:gate': ((ctx: ToolHookContext & ToolGateResumeFields & { block: boolean; childId: string; depth: number; metadata: ToolResultMetadata; reason: string; result?: string | ToolResultContent[]; runToolCounts: Readonly<Record<string, number>> } & DecideHookContext, ...rest: []) => void) | ((ctx: ToolHookContext & ToolGateResumeFields & { block: boolean; childId: string; depth: number; metadata: ToolResultMetadata; reason: string; result?: string | ToolResultContent[]; runToolCounts: Readonly<Record<string, number>> } & DecideHookContext, ...rest: []) => void)[]; 'child:tool:progress': ((ctx: ToolHookContext & { childId: string; depth: number; progress: ToolProgress } & DecideHookContext, ...rest: []) => void) | ((ctx: ToolHookContext & { childId: string; depth: number; progress: ToolProgress } & DecideHookContext, ...rest: []) => void)[]; 'child:tool:transform': ((ctx: ToolHookContext & { childId: string; coercions?: readonly string[]; depth: number; isError: boolean; metadata: ToolResultMetadata; outputBytes: number; result: string | ToolResultContent[] } & DecideHookContext, ...rest: []) => void) | ((ctx: ToolHookContext & { childId: string; coercions?: readonly string[]; depth: number; isError: boolean; metadata: ToolResultMetadata; outputBytes: number; result: string | ToolResultContent[] } & DecideHookContext, ...rest: []) => void)[]; 'child:turn:after': ((ctx: { aborted?: boolean; childId: string; depth: number; message: SessionTurn; toolCounts: { run: Readonly<Record<string, number>>; turn: Readonly<Record<string, number>> }; turn: number; turnId: string; usage: TurnUsage } & DecideHookContext, ...rest: []) => void) | ((ctx: { aborted?: boolean; childId: string; depth: number; message: SessionTurn; toolCounts: { run: Readonly<Record<..., ...>>; turn: Readonly<Record<..., ...>> }; turn: number; turnId: string; usage: TurnUsage } & DecideHookContext, ...rest: []) => void)[]; 'child:validation:reject': ((ctx: ToolHookContext & { childId: string; depth: number; reason: string; schema: Record<string, unknown> } & DecideHookContext, ...rest: []) => void) | ((ctx: ToolHookContext & { childId: string; depth: number; reason: string; schema: Record<string, unknown> } & DecideHookContext, ...rest: []) => void)[]; 'compact:end': ((ctx: (CompactHookContext & { droppedCount: number; durationMs: number; effectiveTokens: number; replacedCount: number; restoredFiles: number; restoredSkills: number; status: 'success'; summaryModel: string } & DecideHookContext) | (CompactHookContext & { durationMs: number; error: unknown; status: 'error' } & DecideHookContext), ...rest: []) => void) | ((ctx: (CompactHookContext & { droppedCount: number; durationMs: number; effectiveTokens: number; replacedCount: number; restoredFiles: number; restoredSkills: number; status: 'success'; summaryModel: string } & DecideHookContext) | (CompactHookContext & { durationMs: number; error: unknown; status: 'error' } & DecideHookContext), ...rest: []) => void)[]; 'compact:progress': ((ctx: CompactProgressHookContext & DecideHookContext, ...rest: []) => void) | ((ctx: CompactProgressHookContext & DecideHookContext, ...rest: []) => void)[]; 'compact:restore': ((ctx: { add: (call: { input: Record<string, unknown>; name: string; output: string }) => void; session?: Session } & DecideHookContext, ...rest: []) => void | Promise<void>) | ((ctx: { add: (call: { input: Record<..., ...>; name: string; output: string }) => void; session?: Session } & DecideHookContext, ...rest: []) => void | Promise<void>)[]; 'compact:start': ((ctx: CompactStartHookContext & DecideHookContext, ...rest: []) => void) | ((ctx: CompactStartHookContext & DecideHookContext, ...rest: []) => void)[]; 'content-ref:resolve': ((ctx: { block: ResolveContentRefBlock; childId?: string; data?: string; depth?: number } & DecideHookContext, ...rest: []) => void) | ((ctx: { block: ResolveContentRefBlock; childId?: string; data?: string; depth?: number } & DecideHookContext, ...rest: []) => void)[]; 'context:overflow': ((ctx: StreamHookContext & { attempt: number; err: unknown } & DecideHookContext, ...rest: []) => void) | ((ctx: StreamHookContext & { attempt: number; err: unknown } & DecideHookContext, ...rest: []) => void)[]; 'context:transform': ((ctx: { messages: SessionMessage[] } & DecideHookContext, ...rest: []) => void) | ((ctx: { messages: SessionMessage[] } & DecideHookContext, ...rest: []) => void)[]; 'decision:end': ((ctx: DecisionEndHookContext) => void) | ((ctx: DecisionEndHookContext) => void)[]; 'decision:start': ((ctx: DecisionStartHookContext) => void) | ((ctx: DecisionStartHookContext) => void)[]; 'execution:ready': ((ctx: { handle: ExecutionHandle; spawned: boolean } & DecideHookContext, ...rest: []) => void) | ((ctx: { handle: ExecutionHandle; spawned: boolean } & DecideHookContext, ...rest: []) => void)[]; 'history:repair': ((ctx: HistoryRepairHookContext & DecideHookContext, ...rest: []) => void) | ((ctx: HistoryRepairHookContext & DecideHookContext, ...rest: []) => void)[]; 'mcp:auth:error': ((ctx: { error: Error; name: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { error: Error; name: string } & DecideHookContext, ...rest: []) => void)[]; 'mcp:auth:required': ((ctx: { name: string; reason: 'no-tokens' | 'auto-promoted'; transport: 'sse' | 'stdio' | 'streamable-http' } & DecideHookContext, ...rest: []) => void) | ((ctx: { name: string; reason: 'no-tokens' | 'auto-promoted'; transport: 'sse' | 'stdio' | 'streamable-http' } & DecideHookContext, ...rest: []) => void)[]; 'mcp:auth:success': ((ctx: { name: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { name: string } & DecideHookContext, ...rest: []) => void)[]; 'mcp:auth:url': ((ctx: { name: string; url: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { name: string; url: string } & DecideHookContext, ...rest: []) => void)[]; 'mcp:bootstrap:end': ((ctx: ({ durationMs: number; name: string; transport: string } & { cached?: boolean; lazy?: boolean; ok: true; toolCount: number; warnings?: string[] } & DecideHookContext) | ({ durationMs: number; name: string; transport: string } & { error: Error; ok: false } & DecideHookContext), ...rest: []) => void) | ((ctx: ({ durationMs: number; name: string; transport: string } & { cached?: boolean; lazy?: boolean; ok: true; toolCount: number; warnings?: ...[] } & DecideHookContext) | ({ durationMs: number; name: string; transport: string } & { error: Error; ok: false } & DecideHookContext), ...rest: []) => void)[]; 'mcp:bootstrap:settled': ((ctx: { results: readonly ({ durationMs: number; name: string; transport: string } & ({ lazy?: ...; ok: ...; toolCount: ... } | { error?: ...; ok: ...; skipped?: ... }))[] } & DecideHookContext, ...rest: []) => void) | ((ctx: { results: readonly ({ durationMs: ...; name: ...; transport: ... } & (... | ...))[] } & DecideHookContext, ...rest: []) => void)[]; 'mcp:bootstrap:start': ((ctx: { name: string; transport: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { name: string; transport: string } & DecideHookContext, ...rest: []) => void)[]; 'mcp:close': ((ctx: { name: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { name: string } & DecideHookContext, ...rest: []) => void)[]; 'mcp:connect': ((ctx: { lazy?: boolean; name: string; tools: string[]; transport: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { lazy?: boolean; name: string; tools: string[]; transport: string } & DecideHookContext, ...rest: []) => void)[]; 'mcp:error': ((ctx: { error: Error; name: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { error: Error; name: string } & DecideHookContext, ...rest: []) => void)[]; 'mcp:tool:after': ((ctx: McpToolHookContext & { outputBytes: number; result: string | ToolResultContent[] } & DecideHookContext, ...rest: []) => void) | ((ctx: McpToolHookContext & { outputBytes: number; result: string | ToolResultContent[] } & DecideHookContext, ...rest: []) => void)[]; 'mcp:tool:before': ((ctx: McpToolHookContext & DecideHookContext, ...rest: []) => void) | ((ctx: McpToolHookContext & DecideHookContext, ...rest: []) => void)[]; 'mcp:tool:error': ((ctx: McpToolHookContext & { error: Error } & DecideHookContext, ...rest: []) => void) | ((ctx: McpToolHookContext & { error: Error } & DecideHookContext, ...rest: []) => void)[]; 'mcp:tool:gate': ((ctx: McpToolHookContext & { block: boolean; reason: string; result?: string | ToolResultContent[] } & DecideHookContext, ...rest: []) => void) | ((ctx: McpToolHookContext & { block: boolean; reason: string; result?: string | ToolResultContent[] } & DecideHookContext, ...rest: []) => void)[]; 'mcp:tool:transform': ((ctx: McpToolHookContext & { outputBytes: number; result: string | ToolResultContent[] } & DecideHookContext, ...rest: []) => void) | ((ctx: McpToolHookContext & { outputBytes: number; result: string | ToolResultContent[] } & DecideHookContext, ...rest: []) => void)[]; 'mcp:tools:filter': ((ctx: { server: string; tools: { description?: string | null; inputSchema?: unknown; name: string }[]; transport: 'sse' | 'stdio' | 'streamable-http' } & DecideHookContext, ...rest: []) => void) | ((ctx: { server: string; tools: { description?: ... | ... | ...; inputSchema?: unknown; name: string }[]; transport: 'sse' | 'stdio' | 'streamable-http' } & DecideHookContext, ...rest: []) => void)[]; 'mcp:tools:list': ((ctx: { name: string; tools: readonly McpToolSchema[]; transport: 'sse' | 'stdio' | 'streamable-http' } & DecideHookContext, ...rest: []) => void) | ((ctx: { name: string; tools: readonly McpToolSchema[]; transport: 'sse' | 'stdio' | 'streamable-http' } & DecideHookContext, ...rest: []) => void)[]; 'mcp:warn': ((ctx: { message: string; name: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { message: string; name: string } & DecideHookContext, ...rest: []) => void)[]; 'model:fallback': ((ctx: StreamHookContext & { discardedThinking?: true; err: Error; from: { model: string; provider: string }; targetIndex: number; to: { model: string; provider: string } } & DecideHookContext, ...rest: []) => void) | ((ctx: StreamHookContext & { discardedThinking?: true; err: Error; from: { model: string; provider: string }; targetIndex: number; to: { model: string; provider: string } } & DecideHookContext, ...rest: []) => void)[]; 'oauth:refresh': ((ctx: OAuthRefreshHookContext & DecideHookContext, ...rest: []) => void) | ((ctx: OAuthRefreshHookContext & DecideHookContext, ...rest: []) => void)[]; output: ((ctx: { output: Record<string, unknown>; schema: Record<string, unknown> } & DecideHookContext, ...rest: []) => void) | ((ctx: { output: Record<string, unknown>; schema: Record<string, unknown> } & DecideHookContext, ...rest: []) => void)[]; 'pairing:repair': ((ctx: PairingRepair & { turnId?: string } & DecideHookContext, ...rest: []) => void) | ((ctx: PairingRepair & { turnId?: string } & DecideHookContext, ...rest: []) => void)[]; 'provider:diagnostic': ((ctx: ProviderDiagnostic & { runId: string; turnId: string } & DecideHookContext, ...rest: []) => void) | ((ctx: ProviderDiagnostic & { runId: string; turnId: string } & DecideHookContext, ...rest: []) => void)[]; 'provider:input-transformations': ((ctx: InputTransformationsHookContext & DecideHookContext, ...rest: []) => void) | ((ctx: InputTransformationsHookContext & DecideHookContext, ...rest: []) => void)[]; 'repeat-guard:exceeded': ((ctx: { action: 'steer' | 'block' | 'abort'; count: number; threshold: number; tool: string; turnId: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { action: 'steer' | 'block' | 'abort'; count: number; threshold: number; tool: string; turnId: string } & DecideHookContext, ...rest: []) => void)[]; 'replay:done': ((ctx: Record<string, never> & DecideHookContext, ...rest: []) => void) | ((ctx: Record<string, never> & DecideHookContext, ...rest: []) => void)[]; 'resume:mismatch': ((ctx: { reason: string; resumeTurn: number; runId: string; sessionId: string; storedTurns: readonly number[] } & DecideHookContext, ...rest: []) => void) | ((ctx: { reason: string; resumeTurn: number; runId: string; sessionId: string; storedTurns: readonly number[] } & DecideHookContext, ...rest: []) => void)[]; 'run:beforeEnd': ((ctx: { followUp: (message: string) => void; runId: string; session?: Session; turn: number } & DecideHookContext, ...rest: []) => void | Promise<void>) | ((ctx: { followUp: (message: string) => void; runId: string; session?: Session; turn: number } & DecideHookContext, ...rest: []) => void | Promise<void>)[]; 'session:end': ((ctx: SessionHookContext & { runId: string; session?: Session; status: SessionEndStatus; turnRange: [number, number] } & DecideHookContext, ...rest: []) => void) | ((ctx: SessionHookContext & { runId: string; session?: Session; status: SessionEndStatus; turnRange: [number, number] } & DecideHookContext, ...rest: []) => void)[]; 'session:meta': ((ctx: SessionHookContext & { key: string; value: unknown } & DecideHookContext, ...rest: []) => void) | ((ctx: SessionHookContext & { key: string; value: unknown } & DecideHookContext, ...rest: []) => void)[]; 'session:save': ((ctx: SessionHookContext & DecideHookContext, ...rest: []) => void) | ((ctx: SessionHookContext & DecideHookContext, ...rest: []) => void)[]; 'session:start': ((ctx: SessionHookContext & { prompt: string; runId: string } & DecideHookContext, ...rest: []) => void) | ((ctx: SessionHookContext & { prompt: string; runId: string } & DecideHookContext, ...rest: []) => void)[]; 'session:turns': ((ctx: SessionHookContext & { count: number; turns: SessionTurn[] } & DecideHookContext, ...rest: []) => void) | ((ctx: SessionHookContext & { count: number; turns: SessionTurn[] } & DecideHookContext, ...rest: []) => void)[]; 'skills:activate': ((ctx: { skill: SkillConfig; via: ActivationVia } & DecideHookContext, ...rest: []) => void) | ((ctx: { skill: SkillConfig; via: ActivationVia } & DecideHookContext, ...rest: []) => void)[]; 'skills:catalog': ((ctx: { catalog: string; skills: SkillConfig[] } & DecideHookContext, ...rest: []) => void) | ((ctx: { catalog: string; skills: SkillConfig[] } & DecideHookContext, ...rest: []) => void)[]; 'skills:deactivate': ((ctx: { reason: DeactivationReason; skill: SkillConfig } & DecideHookContext, ...rest: []) => void) | ((ctx: { reason: DeactivationReason; skill: SkillConfig } & DecideHookContext, ...rest: []) => void)[]; 'skills:resolve': ((ctx: { skills: SkillConfig[] } & DecideHookContext, ...rest: []) => void) | ((ctx: { skills: SkillConfig[] } & DecideHookContext, ...rest: []) => void)[]; 'spawn:before': ((ctx: SpawnHookContext & DecideHookContext, ...rest: []) => void) | ((ctx: SpawnHookContext & DecideHookContext, ...rest: []) => void)[]; 'spawn:complete': ((ctx: ChildRunStats & DecideHookContext, ...rest: []) => void) | ((ctx: ChildRunStats & DecideHookContext, ...rest: []) => void)[]; 'spawn:error': ((ctx: SpawnHookContext & { error: Error } & DecideHookContext, ...rest: []) => void) | ((ctx: SpawnHookContext & { error: Error } & DecideHookContext, ...rest: []) => void)[]; 'spawn:queued': ((ctx: SpawnQueuedContext & DecideHookContext, ...rest: []) => void) | ((ctx: SpawnQueuedContext & DecideHookContext, ...rest: []) => void)[]; 'steer:inject': ((ctx: { content: readonly PromptPart[]; id?: string; text: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { content: readonly PromptPart[]; id?: string; text: string } & DecideHookContext, ...rest: []) => void)[]; 'stream:end': ((ctx: StreamHookContext & { text: string } & DecideHookContext, ...rest: []) => void) | ((ctx: StreamHookContext & { text: string } & DecideHookContext, ...rest: []) => void)[]; 'stream:error': ((ctx: StreamHookContext & { err: unknown; requestId?: string; statusCode?: number } & DecideHookContext, ...rest: []) => void) | ((ctx: StreamHookContext & { err: unknown; requestId?: string; statusCode?: number } & DecideHookContext, ...rest: []) => void)[]; 'stream:progress': ((ctx: StreamProgressHookContext & DecideHookContext, ...rest: []) => void) | ((ctx: StreamProgressHookContext & DecideHookContext, ...rest: []) => void)[]; 'stream:retry': ((ctx: StreamHookContext & { attempt: number; delayMs: number; discardedText?: true; discardedThinking?: true; err: unknown; maxAttempts: number; nextAttempt: number; requestId?: string; statusCode?: number } & DecideHookContext, ...rest: []) => void) | ((ctx: StreamHookContext & { attempt: number; delayMs: number; discardedText?: true; discardedThinking?: true; err: unknown; maxAttempts: number; nextAttempt: number; requestId?: string; statusCode?: number } & DecideHookContext, ...rest: []) => void)[]; 'stream:server_tool_result': ((ctx: StreamHookContext & { content: unknown; toolName: string; toolUseId: string } & DecideHookContext, ...rest: []) => void) | ((ctx: StreamHookContext & { content: unknown; toolName: string; toolUseId: string } & DecideHookContext, ...rest: []) => void)[]; 'stream:server_tool_use': ((ctx: StreamHookContext & { id: string; input: Record<string, unknown>; name: string } & DecideHookContext, ...rest: []) => void) | ((ctx: StreamHookContext & { id: string; input: Record<string, unknown>; name: string } & DecideHookContext, ...rest: []) => void)[]; 'stream:start': ((ctx: StreamHookContext & { startedAt: number } & DecideHookContext, ...rest: []) => void) | ((ctx: StreamHookContext & { startedAt: number } & DecideHookContext, ...rest: []) => void)[]; 'stream:text': ((ctx: StreamHookContext & { delta: string; text: string } & DecideHookContext, ...rest: []) => void) | ((ctx: StreamHookContext & { delta: string; text: string } & DecideHookContext, ...rest: []) => void)[]; 'stream:thinking': ((ctx: StreamHookContext & { delta: string; thinking: string } & DecideHookContext, ...rest: []) => void) | ((ctx: StreamHookContext & { delta: string; thinking: string } & DecideHookContext, ...rest: []) => void)[]; 'system:before': ((ctx: { system: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { system: string } & DecideHookContext, ...rest: []) => void)[]; 'system:transform': ((ctx: { messages: readonly SessionMessage[]; session?: Session; system: string; turn: number; turnId: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { messages: readonly SessionMessage[]; session?: Session; system: string; turn: number; turnId: string } & DecideHookContext, ...rest: []) => void)[]; 'tool-batch:resumed': ((ctx: { done: readonly string[]; resuming: readonly string[]; runId: string; turn: number; turnId: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { done: readonly string[]; resuming: readonly string[]; runId: string; turn: number; turnId: string } & DecideHookContext, ...rest: []) => void)[]; 'tool-budget:exceeded': ((ctx: { count: number; max: number; mode: 'steer' | 'block'; tool: string; turnId: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { count: number; max: number; mode: 'steer' | 'block'; tool: string; turnId: string } & DecideHookContext, ...rest: []) => void)[]; 'tool-results:after': ((ctx: { message: SessionTurn; results: readonly ToolResult[]; turn: number; turnId: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { message: SessionTurn; results: readonly ToolResult[]; turn: number; turnId: string } & DecideHookContext, ...rest: []) => void)[]; 'tool-state:commit-failed': ((ctx: { callId: string; error: Error; handled?: boolean; ops: readonly ToolStateOp[]; runId: string; toolName: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { callId: string; error: Error; handled?: boolean; ops: readonly ToolStateOp[]; runId: string; toolName: string } & DecideHookContext, ...rest: []) => void)[]; 'tool:after': ((ctx: ToolHookContext & { coercions?: readonly string[]; isError: boolean; outcome?: ToolOutcome; outputBytes: number; result: string | ToolResultContent[]; runToolCounts: Readonly<Record<string, number>> } & DecideHookContext, ...rest: []) => void) | ((ctx: ToolHookContext & { coercions?: readonly string[]; isError: boolean; outcome?: ToolOutcome; outputBytes: number; result: string | ToolResultContent[]; runToolCounts: Readonly<Record<string, number>> } & DecideHookContext, ...rest: []) => void)[]; 'tool:before': ((ctx: ToolHookContext & { coercions?: readonly string[]; priorContent?: string; runToolCounts: Readonly<Record<string, number>>; unknown?: boolean } & DecideHookContext, ...rest: []) => void) | ((ctx: ToolHookContext & { coercions?: readonly string[]; priorContent?: string; runToolCounts: Readonly<Record<string, number>>; unknown?: boolean } & DecideHookContext, ...rest: []) => void)[]; 'tool:cancelled': ((ctx: ToolHookContext & { reason: string; runToolCounts: Readonly<Record<string, number>> } & DecideHookContext, ...rest: []) => void) | ((ctx: ToolHookContext & { reason: string; runToolCounts: Readonly<Record<string, number>> } & DecideHookContext, ...rest: []) => void)[]; 'tool:dispatched': ((ctx: ToolHookContext & { outcome: 'execute' | 'gate-substitute' | 'gate-block' | 'unknown' | 'invalid-input'; reason?: string; runToolCounts: Readonly<Record<string, number>> } & DecideHookContext, ...rest: []) => void) | ((ctx: ToolHookContext & { outcome: 'execute' | 'gate-substitute' | 'gate-block' | 'unknown' | 'invalid-input'; reason?: string; runToolCounts: Readonly<Record<string, number>> } & DecideHookContext, ...rest: []) => void)[]; 'tool:error': ((ctx: ToolHookContext & { error: Error; result?: string | ToolResultContent[] } & DecideHookContext, ...rest: []) => void) | ((ctx: ToolHookContext & { error: Error; result?: string | ToolResultContent[] } & DecideHookContext, ...rest: []) => void)[]; 'tool:gate': ((ctx: ToolHookContext & ToolGateResumeFields & { block: boolean; metadata: ToolResultMetadata; reason: string; result?: string | ToolResultContent[]; runToolCounts: Readonly<Record<string, number>> } & DecideHookContext, ...rest: []) => void) | ((ctx: ToolHookContext & ToolGateResumeFields & { block: boolean; metadata: ToolResultMetadata; reason: string; result?: string | ToolResultContent[]; runToolCounts: Readonly<Record<string, number>> } & DecideHookContext, ...rest: []) => void)[]; 'tool:progress': ((ctx: ToolHookContext & { progress: ToolProgress } & DecideHookContext, ...rest: []) => void) | ((ctx: ToolHookContext & { progress: ToolProgress } & DecideHookContext, ...rest: []) => void)[]; 'tool:transform': ((ctx: ToolHookContext & { coercions?: readonly string[]; isError: boolean; metadata: ToolResultMetadata; outputBytes: number; result: string | ToolResultContent[] } & DecideHookContext, ...rest: []) => void) | ((ctx: ToolHookContext & { coercions?: readonly string[]; isError: boolean; metadata: ToolResultMetadata; outputBytes: number; result: string | ToolResultContent[] } & DecideHookContext, ...rest: []) => void)[]; 'tool:unknown': ((ctx: ToolHookContext & { result?: string | ToolResultContent[]; suppressError: boolean } & DecideHookContext, ...rest: []) => void) | ((ctx: ToolHookContext & { result?: string | ToolResultContent[]; suppressError: boolean } & DecideHookContext, ...rest: []) => void)[]; 'tracing:redact': ((ctx: { kind: 'system' | 'prompt' | 'tool-input' | 'tool-result' | 'mcp-tool-input' | 'mcp-tool-result' | 'stream-text' | 'stream-thinking' | 'decision-answers'; meta?: Readonly<Record<string, unknown>>; redacted: string; value: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { kind: 'system' | 'prompt' | 'tool-input' | 'tool-result' | 'mcp-tool-input' | 'mcp-tool-result' | 'stream-text' | 'stream-thinking' | 'decision-answers'; meta?: Readonly<Record<..., ...>>; redacted: string; value: string } & DecideHookContext, ...rest: []) => void)[]; 'turn:after': ((ctx: { aborted?: boolean; cumulativeUsage: Readonly<{ cacheCreation: number; cacheRead: number; cost?: number; input: number; output: number; turns: number }>; message: SessionTurn; toolCounts: { run: Readonly<Record<string, number>>; turn: Readonly<Record<string, number>> }; turn: number; turnId: string; usage: TurnUsage } & DecideHookContext, ...rest: []) => void) | ((ctx: { aborted?: boolean; cumulativeUsage: Readonly<{ cacheCreation: number; cacheRead: number; cost?: number; input: number; output: number; turns: number }>; message: SessionTurn; toolCounts: { run: Readonly<Record<..., ...>>; turn: Readonly<Record<..., ...>> }; turn: number; turnId: string; usage: TurnUsage } & DecideHookContext, ...rest: []) => void)[]; 'turn:before': ((ctx: { options: StreamOptions; turn: number; turnId: string } & DecideHookContext, ...rest: []) => void) | ((ctx: { options: StreamOptions; turn: number; turnId: string } & DecideHookContext, ...rest: []) => void)[]; usage: ((ctx: { totalIn: number; totalOut: number; turn: number; turnId: string; usage: TurnUsage } & DecideHookContext, ...rest: []) => void) | ((ctx: { totalIn: number; totalOut: number; turn: number; turnId: string; usage: TurnUsage } & DecideHookContext, ...rest: []) => void)[]; 'validation:coerce': ((ctx: ToolHookContext & { coercions: readonly string[]; schema: Record<string, unknown> } & DecideHookContext, ...rest: []) => void) | ((ctx: ToolHookContext & { coercions: readonly string[]; schema: Record<string, unknown> } & DecideHookContext, ...rest: []) => void)[]; 'validation:reject': ((ctx: ToolHookContext & { reason: string; schema: Record<string, unknown> } & DecideHookContext, ...rest: []) => void) | ((ctx: ToolHookContext & { reason: string; schema: Record<string, unknown> } & DecideHookContext, ...rest: []) => void)[] }>

Agent-lifetime hook registrations. Registered once when createAgent returns; identical in effect to calling agent.hooks.hook(event, fn) for each entry. Use this to bake observability / policy hooks into a Preset so consumers get them automatically via ...spread.

Handlers may be a single function or an array — the array form is what composePresets() produces when multiple presets register handlers for the same event. Unknown event names throw at agent construction so typos never silently no-op.

For per-run handlers (auto-detached at run end) use AgentRunOptions.hooks.

mcpConfigTransforms?readonly ExtensionOrderedContribution<ExtensionMcpConfigTransform>[]

MCP config transforms contributed by extensions.

mcpConnector?McpConnector

Override how mcpServers are connected and their tools discovered — see McpConnector. The default connects via connectMcpServers; supply your own to inject a custom transport / auth provider. To journal discovered tools for a durable runtime, use mcpToolWrap instead — it composes with any connector.

Invoked lazily on the first run() / warmup(), with the agent's hooks. A connector that bypasses connectMcpServers also bypasses its config normalization, so prefer delegating to it.

mcpServers?McpServerConfig[]

MCP servers to connect and expose as tools

mcpToolWrap?McpToolWrap

Wrap every discovered MCP tool right after bootstrap, before it's registered into the agent's tool map — see McpToolWrap. This is the seam native tools have (you wrap them before createAgent) that discovered MCP tools lack: the agent owns their discovery, so it applies the wrap on your behalf, for both eager and lazily-connected servers and regardless of which mcpConnector produced them.

Not needed for durable execution: a replay-mode runtime records MCP tool calls as steps like native ones. Discovery, mcp_<server>_<tool> namespacing, per-server instructions, hooks, and OAuth all stay owned by the agent: the wrap only re-shapes execute.

name?string

Display name for the agent (used in traces/logs).

providerProvider | Provider[]

LLM provider(s) driving the agent.

A single Provider is the common case. Passing an array enables run-level fallback chains: AgentRunOptions.model entries reference providers by Provider.name, and bare-string model ids fan out across every provider in array order. Names must be unique within the array. The FIRST provider is the primary — agent-level surfaces (agent.meta, getContextBreakdown, the default model when model is omitted) read it.

readState?ReadStateMap

Explicit read-state map for read_file dedup + requireReadBeforeEdit tracking. When omitted, the read-state lives in a WeakMap<Session, …> keyed by the agent's session; providing an explicit map lets a parent agent share its tracking with a sessionless child (the canonical caller is spawn's shareReadState: true option). Tools resolve uniformly via resolveReadStateMap(ctx) so the explicit map (when present) wins over the session-keyed lookup.

runTransforms?readonly ExtensionOrderedContribution<ExtensionRunTransform>[]

Run option transforms contributed by extensions, applied before each run.

session?Session

Session for identity, turn persistence, and run tracking. With durable, prefer sessionId: createAgent throws when this session has a store other than durable.stores.session (a session with no store passes).

sessionId?string

Id of the session to run in, loaded from durable.stores.session, or created there when it does not exist yet. Needs durable; throws with session too. The session loads on the first run(), resume(), compact() or warmup(), or in the background at createAgent when eager is set and there are MCP servers or skills to warm. agent.session is null until then.

skillPolicies?readonly ExtensionOrderedContribution<ExtensionSkillPolicy>[]

Skill activation policies contributed by extensions.

skills?SkillsConfig

Skills configuration

system?string

Default system prompt injected when no system is provided at run time.

toolAliases?Record<string, string>

Map canonical tool names to LLM-facing (aliased) names.

Aliasing is LLM-boundary-only: the alias is what the provider's tool spec carries and what the model calls the tool; the canonical name is what lives in session.turns and what the agent uses to look up the tool implementation.

toolCatalogTransforms?readonly ExtensionOrderedContribution<ExtensionToolCatalogTransform>[]

Tool catalog transforms contributed by extensions.

toolDescriptions?ToolDescriptions

Override the LLM-facing description of any tool — native, host-provided, or MCP-discovered — keyed by canonical name (shell, mcp_<server>_<tool>; same keys as toolAliases, so aliases and MCP name sanitization never enter into it).

A string replaces the description; a function receives the current text (after every framework rewrite — e.g. shell's per-run background gate) and returns the new one, which is the form to use for amendments:

Text
toolDescriptions: {
  shell: cur => `${cur}\nNever invoke sudo.`,
  mcp_github_create_pull_request: 'Open a PR. Always target `develop`.',
}

Applied once per run, last, so it reaches every surface that reads spec.description: the wire tool list, the lazy <searchable_tools> catalog, tool_search results, and the context breakdown. Keys that match no registered tool are ignored (an MCP server may be offline for that run). Composes through presets (later wins per key) and is inherited by spawned subagents.

toolKill?{ defaultMessage?: string }

Tune Agent.cancelTool's per-call kill registry.

  • defaultMessage — reason stamped on the abort signal (and surfaced to the tool:cancelled hook) when cancelTool(callId) is called without an explicit reason. Defaults to 'user-cancelled-tool'.

The registry also handles the pre-registration race: a cancelTool issued for a call the loop hasn't dispatched yet (the host saw the id in the streamed turn) is parked and applied the moment the call registers, rather than dropped. No configuration needed for that — it's always on.

toolResultMetadata?readonly ExtensionOrderedContribution<ExtensionToolResultMetadataRegistration>[]

Tool result metadata registrations contributed by extensions.

tools?Record<string, ToolDef>

Tool definitions available to the agent. Defaults to no tools.

toolState?ToolStateStore

Durable backing for ToolContext.state — the key/value map tools use for information that must survive a resume (a one-shot payload already delivered, a cursor, a dedup marker).

Runtime-agnostic on purpose: with durable, a session or sessionId, and no store here, the runtime's stores.toolState(<session id>) is used, and any host can implement the same two methods against Redis / Postgres / a file. With no store configured ctx.state is still present and functional, backed by an in-memory map scoped to this agent instance — so tools written against it work identically on the local CLI path, just without durability.

The snapshot is loaded once per agent, before the first tool dispatch; writes are staged per call and committed when the call settles.

NOT inherited by subagents. A child you build yourself can be handed the same store, but createSpawnTool has no shareToolState counterpart to shareReadState, so tools inside a spawned subagent see an isolated in-memory map and their writes never reach this store.

src/agent-types.ts:82

CacheBreakInfoInterface#

Structured payload of the 'cache:break' agent hook, emitted by installCacheBreakLogger whenever a turn reads back materially less prefix than the previous turn established (see expectedReadAtLeast / expectedReadRatio on CacheBreakLoggerOptions).

changed names the request dimensions that differ from the previous turn — the culprit when the host rewrote something. An EMPTY changed is itself a signal: the request was byte-stable AT THIS WIRE and the provider still failed to serve the prefix. Three suspects, in the order to check them:

  1. TTL expiry — a long pause since the previous turn (compare wall-clock gaps; the default window is ~5 minutes).
  2. Provider-side eviction — no pause, no pattern; typically isolated.
  3. Downstream transformation churn — on architectures where a gateway CASTS this request into another provider's format (Anthropic → OpenAI Responses, Converse, …), these dimensions only cover the PRE-cast bytes. A transform that isn't prefix-monotonic (unstable id mapping, per-request injections, config-derived fields like reasoning effort) breaks the vendor's cache while reading as byte-stable here. Recurring empty-changed breaks on a cast route point at the transform, not at this agent — instrument the gateway's OUTPUT with the same positional hashing (hashMessages) to name the churn.
TypeScript
interface CacheBreakInfo
CacheBreakInfo properties
NameTypeDescription
cacheCreationnumber

Tokens written to the cache on the turn that broke.

cacheReadnumber

Tokens read from the cache on the turn that broke.

changedCacheDimensionName[]

Request dimensions that changed since the previous turn; empty ⇒ suspect TTL/eviction.

detailsPartial<Record<CacheDimensionName, string>>

Human-readable per-dimension diffs, keyed by the entries of changed.

expectedAtLeastnumber

The floor that tripped: max(expectedReadAtLeast, ratio-derived floor).

modelstring

Model id of the request that broke — pair with a provider key to price the break.

rebilledTokensnumber

Approximate tokens re-billed by this break: the prefix the previous turn had cached minus what this turn actually read back. That portion was re-written at the cache-write rate (~1.25x input) instead of read at the cache-read rate (~0.1x), so the extra spend is roughly 1.15 x rebilledTokens input-token equivalents. Approximate because the healthy-turn counterfactual (the genuine new-transcript delta) is folded into cacheCreation and cannot be separated out.

systemSections?CacheSystemSection[]

The parts of the system prompt that changed, when changed includes 'system' — see CacheDimensionDiff.systemSections.

turnnumber

1-based turn index as counted by the detector — monotonic across the INSTALLER'S lifetime, i.e. every agent.run() on this hook bus. It does NOT reset per run the way turn:after's ctx.turn does, so don't correlate the two on any prompt after the first; use turnId for that.

turnIdstring

Id of the turn that broke the cache.

src/cache-telemetry.ts:215

CacheBreakLoggerOptionsInterface#

Options for installCacheBreakLogger.

TypeScript
interface CacheBreakLoggerOptions
CacheBreakLoggerOptions properties
NameTypeDefaultDescription
expectedReadAtLeast?number

Absolute floor for "this turn missed cache". The logger reports when cacheRead falls below the threshold AND the dimensions differ from the previous turn.

Defaults to 1, which on its own only catches TOTAL cache loss. That is not the common failure: when the system prompt or the message prefix is rewritten, tools + the static system block still match, so cacheRead stays comfortably non-zero — pinned to a CONSTANT equal to that scaffold — while the entire conversation re-bills at the cache-write rate. An absolute-only threshold reports such a run as perfectly healthy.

CacheBreakLoggerOptions.expectedReadRatio is the fix and is on by default; this field remains as the absolute floor beneath it.

expectedReadRatio?number

Detect PARTIAL-prefix breaks by comparing this turn's cacheRead against the prefix the previous turn established (cacheRead + cacheCreation). The conversation only grows, so a healthy turn reads back at least what the one before it wrote; reading materially less means something at or before the last message changed.

Defaults to 0.8 — the slack absorbs tokenizer jitter and any uncached dynamic system block, which is re-billed by design. Set 0 to disable and detect only total loss through expectedReadAtLeast.

log?(line: string) => void

Sink for log lines. Defaults to console.error. Use a Logger or a test buffer here when you want the output to land somewhere other than stderr.

NOT every line is a dimension diff: unless onEmitError is provided, 'cache:break' handler errors are also routed here (prefixed cache-break: 'cache:break' handler threw:). A host that counts log invocations as "dimension changes" — the journal-mode wiring — should either pass onEmitError or count via the 'cache:break' hook instead.

onEmitError?(error: unknown) => void

Sink for errors thrown by 'cache:break' hook consumers. The emission is try/caught so a UI bug in a handler can't unwind into the loop and abort the run; this is where that error surfaces. Defaults to the log channel (never a louder one — hosts route log away from stderr for good reasons). Pass a dedicated sink when log invocations are being counted as metrics.

sessionBaseline?booleantrue

Share the comparison baseline with other agents bound to the same session. When on, the last request of a top-level run on a session is kept per session id at process level, and a newly built agent compares its first request against it, so a cache break caused by rebuilding the agent is reported instead of starting from a blank slate. Subagent runs never share. Set false to keep the baseline per logger.

verbose?boolean

When true, emit a log line on EVERY turn (not just cache breaks) with the per-dimension hashes. Useful for offline correlation; off by default to keep the logger zero-noise on happy paths.

src/cache-telemetry.ts:298

CacheDimensionDiffInterface#

Result of diffCacheDimensions.

TypeScript
interface CacheDimensionDiff
CacheDimensionDiff properties
NameTypeDescription
changedreadonly CacheDimensionName[]

Dimension names whose hashes differ between the two snapshots.

detailsReadonly<Partial<Record<CacheDimensionName, string>>>

Per-dimension details — only filled for changed dimensions.

messagePrefixDivergedAt?number

Message index where the prefixes diverged, when changed includes 'messagePrefix'. Chunk-aligned values (multiples of the compaction chunk) point at tail compaction / media age-out; non-aligned values point at stale-read stubbing or host-side history surgery.

systemSections?readonly CacheSystemSection[]

The parts of system that changed, when changed includes 'system' and both snapshots carry CacheDimensionSnapshot.systemSections. Empty when the change could not be traced to one part.

src/cache-telemetry.ts:154

CacheDimensionSnapshotInterface#

Snapshot of per-request dimensions that contribute to the provider-side prompt cache key. Hashed at turn:before and stashed for diffing on the next request.

Hashes are FNV-1a 32-bit (cheap, no allocations on the hot path, collision rate good enough for diff detection — we're not signing anything). For verbose output the helper also captures byte lengths so system: 1024 → 1031 reads like a real diff line.

TypeScript
interface CacheDimensionSnapshot
CacheDimensionSnapshot properties
NameTypeDescription
effortHintboolean

Whether the level-derived effort hint is emitted — true when absent.

messageCountnumber

Number of messages in the request.

messageHashesreadonly number[]

Per-message content hashes, in order. Compared POSITIONALLY against the previous turn so that appending messages — what every healthy turn does — is not mistaken for a rewrite. A single cumulative hash cannot make that distinction: it changes on plain growth too, and would name this dimension on literally every break, drowning the real culprit.

Tracked because the expensive real-world breaks live here, not in the request scaffold: compaction, stale-read elision, media age-out and history trimming all rewrite the transcript mid-conversation, and none of them touch system, tools, or the model id. A run can lose its entire transcript to a cache miss with every other dimension reading perfectly stable.

modelstring

Model id.

systemBytesnumber

Byte length of options.system.

systemHashnumber

Hash of options.system (with boundary marker preserved).

systemSections?Readonly<Partial<Record<CacheSystemSection, number>>>

Hash of each part of system the snapshot could find, keyed by CacheSystemSection. A section missing from the request has no key. The host's parts (hostStatic, hostDynamic) are hashed with the other sections removed, so a change in a catalog is not also blamed on the host. Absent on snapshots built before this field existed.

taskBudgetnumber

Whole-task thinking budget (Anthropic adaptive-only) — 0 when absent.

thinkingstring

Thinking level — 'off' when absent.

thinkingBudgetnumber

Exact thinking budget — 0 when absent.

toolsCountnumber

Number of tools in the array.

toolsHashnumber

Hash of the JSON-serialized options.tools array.

src/cache-telemetry.ts:73

CacheDriftInfoInterface#

Structured payload of the 'cache:drift' agent hook, emitted by installCacheBreakLogger at turn:before whenever the outgoing request DIVERGES from the previous completed turn's — any tracked dimension, including an in-place rewrite of the message prefix.

Drift is the PREDICTIVE counterpart of 'cache:break': it fires from the request bytes alone, before (and regardless of whether) the provider confirms a miss. That makes it the signal that works everywhere the confirmed-cost path cannot see — providers that report no cache usage, and format-casting gateways where this wire is the last observable point before the cast. A turn that drifts here and then reports a healthy cacheRead means the provider absorbed the change (e.g. only the tail moved); a turn that drifts AND breaks gives the break its attribution for free.

Intentional rewrites drift too, by design — that is the attribution story for planned invalidations: tail compaction and media age-out cut on chunk-aligned indices (divergedAt a multiple of the compaction chunk), stale-read stubbing rewrites a read's own position, host history surgery lands anywhere. details.messagePrefix carries the same hint in prose.

TypeScript
interface CacheDriftInfo
CacheDriftInfo properties
NameTypeDescription
changedCacheDimensionName[]

Dimensions that differ from the previous completed turn's request.

detailsPartial<Record<CacheDimensionName, string>>

Human-readable per-dimension diffs, keyed by the entries of changed.

divergedAt?number

Message index of an in-place prefix rewrite — see CacheDimensionDiff.messagePrefixDivergedAt.

messageCountnumber

Number of messages in the outgoing request.

modelstring

Model id of the OUTGOING request (post-fallback, pre-provider).

previousMessageCountnumber

Number of messages in the previous completed turn's request.

systemSections?CacheSystemSection[]

The parts of the system prompt that changed, when changed includes 'system' — see CacheDimensionDiff.systemSections.

turnnumber

1-based turn index, installer-lifetime monotonic — see CacheBreakInfo.turn.

turnIdstring

Id of the turn the outgoing request belongs to.

src/cache-telemetry.ts:272

CachePromptSectionsInterface#

Texts of the system prompt sections a snapshot can name: the sections the SDK appended and the host's rules block. Each must be a substring of the request's system to count; anything else is ignored.

TypeScript
interface CachePromptSections
CachePromptSections properties
NameTypeDescription
mcpInstructions?string

The MCP server instructions the SDK appended.

rules?string

The rules block the run passed as contextSections.rulesBlock.

skillsCatalog?string

The skills catalog the SDK appended.

toolsCatalog?string

The searchable tools catalog the SDK appended.

src/cache-telemetry.ts:142

McpToolMetaInterface#

Identity of a discovered MCP tool, passed to McpToolWrap.

TypeScript
interface McpToolMeta
McpToolMeta properties
NameTypeDescription
serverstring

Server name from mcpServers config (the <server> in mcp_<server>_<tool>).

toolNamestring

The server-side tool name, un-namespaced.

src/agent-types.ts:67

RunSummaryInterface#

Postmortem snapshot of one agent.run(). Strictly serializable — every field round-trips through JSON.stringify / JSON.parse without loss so a log aggregator can ingest it as-is.

TypeScript
interface RunSummary
RunSummary properties
NameTypeDescription
agentName?string

Name of the agent, when set.

blocksRunSummaryBlock[]

Tool calls that did not run.

budgetEventsRunSummaryBudget[]

Budget events recorded during the run.

byModelRunSummaryByModel[]

Totals broken down by model.

children?RunSummary[]

Postmortem snapshots of child runs that bubbled their stats up via spawn:complete. Only present when the run actually spawned.

depthnumber

Subagent depth. 0 is the top-level agent.

durationMsnumber

Wall-clock duration of the run.

endedAtnumber

Run end, in Unix milliseconds.

errorsRunSummaryError[]

Errors recorded during the run.

pairingRepairsRecord<string, number>

Counts of pairing repairs, keyed by repair mode.

parentRunId?string

Id of the parent run, for a subagent run.

repeatGuardEventsRunSummaryRepeatGuard[]

Repeat-guard escalations. An action: 'steer' entry means the tool still executed and got a steering message appended to its result. An action: 'abort' entry means the guard terminated the run via the agent's AbortController (the run finalizes as 'aborted').

runId?string

Id of the run.

startedAtnumber

Run start, in Unix milliseconds.

status'completed' | 'aborted'

How the run ended.

streamRetriesRunSummaryRetry[]

Provider-stream retries the loop absorbed. Empty on healthy runs; on incident runs the summed delayMs explains wall time that appears nowhere else (not in turns, not in tool durations).

totalsRunSummaryTokens

Token and cost totals, including child runs.

turnsnumber

Number of turns of the run's own loop.

validationRejectsRunSummaryValidation[]

Tool calls rejected by input validation.

src/run-summary.ts:168

RunSummaryBlockInterface#

One tool call that did not run: blocked by a gate, unknown, or rejected for invalid input.

TypeScript
interface RunSummaryBlock
RunSummaryBlock properties
NameTypeDescription
callIdstring

Id of the tool call.

outcome'gate-block' | 'unknown' | 'invalid-input'

Why the call did not run.

reason?string

Reason given by the gate, when there was one.

toolNamestring

Name of the tool the model called.

src/run-summary.ts:94

RunSummaryBudgetInterface#

One output-size or tool-count budget event.

TypeScript
interface RunSummaryBudget
RunSummaryBudget properties
NameTypeDescription
kind'bytes' | 'tool-count'

Budget type: a byte budget or a per-tool call budget.

limitnumber

Configured budget.

mode?'steer' | 'block'

mode for 'tool-count'; absent for byte budgets.

observednumber

Value that crossed the budget: bytes or call count.

toolName?string

Tool name (for 'tool-count'); absent for byte budgets.

turnId?string

Id of the turn the event happened in.

src/run-summary.ts:116

RunSummaryByModelInterface#

Token and cost totals for one model within a run.

TypeScript
interface RunSummaryByModel
RunSummaryByModel properties
NameTypeDescription
cacheCreationnumber

Tokens written to the prompt cache.

cacheReadnumber

Tokens read from the prompt cache.

costnumber

Cost in USD.

inputnumber

Input tokens, excluding cache tokens.

modelIdstring

Model id, or '(unknown)' when the provider did not report one.

outputnumber

Output tokens.

turnsnumber

Number of turns served by the model.

src/run-summary.ts:52

RunSummaryCollectorInterface#

Collector that builds a RunSummary from agent hooks. Created by createRunSummaryCollector.

TypeScript
interface RunSummaryCollector
RunSummaryCollector properties
NameTypeDescription
install(hooks: Hookable<AgentHooks>) => () => void

Install the collector's hook handlers. Returns an uninstall fn.

latest() => RunSummary | undefined

Most-recent summary; undefined until the first agent:done fires.

src/run-summary.ts:235

RunSummaryCollectorOptionsInterface#

Options for createRunSummaryCollector.

TypeScript
interface RunSummaryCollectorOptions
RunSummaryCollectorOptions properties
NameTypeDescription
onSummary?(summary: RunSummary) => void

Called with the assembled RunSummary on every agent:done. Synchronous — heavy I/O should be deferred (e.g. via setImmediate).

src/run-summary.ts:226

RunSummaryErrorInterface#

One error recorded during a run.

TypeScript
interface RunSummaryError
RunSummaryError properties
NameTypeDescription
callId?string

Id of the tool call, for tool errors.

childId?string

Spawn id of the child, for spawn errors.

errorType?string

Error class name, when known.

kind'spawn' | 'tool' | 'mcp' | 'stream' | 'mcp-tool'

Where the error came from: a provider stream, a tool, an MCP tool call, an MCP server, or a spawn.

messagestring

Error message.

requestId?string

Provider request id, when the provider reported one.

server?string

MCP server name, for MCP errors.

statusCode?number

HTTP status code, for provider errors.

toolName?string

Name of the tool, for tool errors.

turnId?string

Id of the turn the error happened in, when known.

src/run-summary.ts:70

RunSummaryRepeatGuardInterface#

One repeat-guard escalation.

TypeScript
interface RunSummaryRepeatGuard
RunSummaryRepeatGuard properties
NameTypeDescription
action'steer' | 'block' | 'abort'

What the guard did: blocked the call, steered the model, or aborted the run.

countnumber

Observed repeat count that tripped the guard.

thresholdnumber

Configured repeat threshold.

toolNamestring

Name of the repeated tool.

turnId?string

Id of the turn the event happened in.

src/run-summary.ts:132

RunSummaryTokensInterface#

Token and cost totals of a run.

TypeScript
interface RunSummaryTokens
RunSummaryTokens properties
NameTypeDescription
cacheCreationnumber

Tokens written to the prompt cache.

cacheReadnumber

Tokens read from the prompt cache.

cost?number

Cost in USD. Absent when no price was known.

inputnumber

Input tokens, excluding cache tokens.

outputnumber

Output tokens.

ttftMs?number

First observable byte from the provider, ms from run start.

src/run-summary.ts:36

RunSummaryValidationInterface#

One tool call rejected by input validation.

TypeScript
interface RunSummaryValidation
RunSummaryValidation properties
NameTypeDescription
callIdstring

Id of the tool call.

reasonstring

Validation failure message.

toolNamestring

Name of the tool the model called.

src/run-summary.ts:106

Types#

AgentHookMapType#

Strongly-typed hook registration map accepted on AgentOptions.hooks (and therefore on any Preset). Each entry is a single handler or an array of handlers — arrays are what composePresets produces when multiple presets register handlers for the same event.

Handlers registered here live for the lifetime of the agent. They fire across every run(), mirroring a manual agent.hooks.hook(event, fn) call made right after createAgent returns. For per-run handlers (auto-detached at run end) use AgentRunOptions.hooks instead.

TypeScript
type AgentHookMap = Partial<{ [K in keyof AgentHooks]: AgentHookHandlers[K] | AgentHookHandlers[K][] }>

src/agent-hooks.ts:1776

CacheDimensionNameType#

Cache-affecting dimension names.

TypeScript
type CacheDimensionName =
  | 'system'
  | 'tools'
  | 'model'
  | 'thinking'
  | 'thinkingBudget'
  | 'taskBudget'
  | 'effortHint'
  | 'messagePrefix'

src/cache-telemetry.ts:175

CacheSystemSectionType#

A part of the system prompt that a system change can be traced to.

  • skillsCatalog: the skills catalog the SDK appends.
  • toolsCatalog: the searchable tools catalog the SDK appends.
  • mcpInstructions: the MCP server instructions the SDK appends.
  • rules: the rules block the run passed as contextSections.rulesBlock.
  • hostStatic: the rest of the prompt above SYSTEM_PROMPT_BOUNDARY, or the whole rest of the prompt when it has no boundary.
  • hostDynamic: the rest of the prompt below SYSTEM_PROMPT_BOUNDARY.
TypeScript
type CacheSystemSection =
  | 'skillsCatalog'
  | 'toolsCatalog'
  | 'mcpInstructions'
  | 'rules'
  | 'hostStatic'
  | 'hostDynamic'

src/cache-telemetry.ts:129

McpConnectorType#

Custom MCP connector — the seam that owns "connect mcpServers, discover their tools, hand them back". Supply one via AgentOptions.mcpConnector to inject a custom transport / auth provider (e.g. OAuth via connectMcpServers(configs, undefined, hooks, { buildAuthProvider })).

To re-shape discovered tools, use the separate AgentOptions.mcpToolWrap hook: it composes with whatever connector is in play.

The agent passes its own hooks so a connector that delegates to connectMcpServers can forward them and keep every mcp:* event firing. A connector is responsible for whatever the default path does that it cares about (config normalization, namespacing, per-server instructions) — delegating to connectMcpServers preserves all of it.

The agent always passes extras.execution — a LAZY binding whose getHandle() spawns/resolves the execution handle only when a scope: 'context' stdio server actually builds its transport. Forward it untouched to connectMcpServers (as { execution }) or those servers fail bootstrap; a connector that never builds such a transport (e.g. one reusing a live parent connection) simply never triggers the spawn. There is no ordering guarantee beyond that — do not assume a handle exists before getHandle() resolves. Declaring only (configs, hooks) stays valid for host-only setups.

TypeScript
type McpConnector = (configs: McpServerConfig[], hooks: Hookable<AgentHooks>, extras?: { execution?: McpExecutionBinding }) => Promise<McpConnection>

src/agent-types.ts:60

McpToolWrapType#

Wrap each discovered MCP tool right after bootstrap, before it's registered into the agent's tool map — see AgentOptions.mcpToolWrap. The MCP counterpart to wrapping native tools yourself before createAgent.

TypeScript
type McpToolWrap = (tool: ToolDef, meta: McpToolMeta) => ToolDef

src/agent-types.ts:79

Re-exports#

These names are documented on the entry point that owns them.