---
name: zidane
description: Use when building agents, tools, presets, or extending the zidane framework. Covers createAgent, providers, hooks, sessions, skills, MCP, spawning, and execution contexts.
allowed-tools: Read Write Bash
paths: "src/**/*.ts, test/**/*.ts"
---

# Zidane Agent Framework

Minimal TypeScript agent loop built with Bun. Streams from an LLM, executes tools, loops until done.

## Core Concepts

- **Provider** — LLM backend (Anthropic, OpenAI Codex, OpenRouter, Cerebras, Arcee, local LLMs). Streaming, format conversion, tool wiring. Anthropic + Codex support OAuth.
- **Preset** — `Partial<AgentOptions>` (tools, system, aliases, mcpServers, skills, behavior, hooks). `basicTools` ships `shell`, `readFile`, `writeFile`, `listFiles`, `edit`, `multiEdit`; the `basic` preset adds `spawn`. Override `tools: {}` for untrusted prompts.
- **Agent** — `createAgent()` returns one. Turn loop: prompt → LLM → tool calls → tool results → … until done or `maxTurns`.
- **Session** — Optional persistent turn history. Written incrementally.

## Creating an Agent

```ts
import { anthropic, basic, createAgent } from 'zidane'

const agent = createAgent({
  ...basic,
  provider: anthropic({ apiKey: '...' }),
  behavior: {
    maxConcurrentTools: 10,          // cap on in-flight tools per turn (default: 10; set 1 for fully sequential)
    maxTurns: 50,
    maxTokens: 16384,
    thinkingBudget: 10240,
    thinkingDecay: { afterTurn: 5, factor: 0.5, floor: 1024 },
    cache: true,                     // prompt-cache breakpoints
    toolOutputBudget: 32768,         // off by default
    dedupReads: true,                // re-read dedup
    dedupTools: { execute_sql: i => typeof i.query === 'string' ? i.query.trim() : undefined },
    requireReadBeforeEdit: false,
    toolBudgets: { execute_sql: { max: 20, onExceed: 'steer' } },
    compactStrategy: 'off',          // 'off' | 'tail' (non-Anthropic compaction)
    compactThreshold: 131_072,       // 128 KiB
    compactKeepTurns: 4,
    toolDisclosure: 'eager',         // 'eager' | 'lazy'
    toolSearch: { limit: 20 },
  },
  eager: true,                       // pre-warm MCP in background
})

const stats = await agent.run({
  prompt: 'build a REST API',        // optional when session has turns
  model: 'claude-opus-4-7',
  system: 'be concise',
  thinking: 'medium',                // off | minimal | low | medium | high
  behavior: { maxTurns: 10 },
  tools: {},                         // {} = no tools for this run
  hooks: {                           // per-run, auto-detached
    'tool:gate': (ctx) => { if (ctx.name === 'shell') ctx.block = true },
  },
})

// stats.timeTillFirstTokenMs — first stream/tool event latency
// stats.turnUsage[n].finishReason / .modelId — normalized per-turn data
// agent.run({})  // promptless resume when session has the user turn
```

### Multimodal prompts

`prompt` accepts a string or a `PromptPart[]`:

```ts
await agent.run({
  prompt: [
    { type: 'text', text: 'What does this chart say?' },
    { type: 'image', mediaType: 'image/png', data: base64Png },
    { type: 'document', mediaType: 'text/markdown', data: readmeSource, encoding: 'text', name: 'README.md' },
  ],
})
```

Text-encoded documents inline with attachment headers (universal); base64 documents (PDFs) need native support (Anthropic). Unsupported part types throw a clear error.

Precedence: `run.behavior` > `agent.behavior` > defaults. `prompt` is required unless the session already has turns. See `docs/ARCHITECTURE.md`.

## Providers

All providers accept runtime keys via params. Env vars are fallbacks. OAuth credentials are read from `.credentials.json` and auto-refreshed (populate via `bun run auth`).

```ts
import { anthropic, openai, openrouter, cerebras, arcee, local, openaiCompat } from 'zidane'

anthropic({ apiKey: 'sk-ant-...', defaultModel: 'claude-opus-4-8' })
anthropic({ access: 'sk-ant-oat-...', refresh: '...', expires: 1234567890 })  // OAuth
anthropic({ apiKey: 'sk-ant-...', baseURL: 'https://gateway.corp/anthropic' }) // full proxy
anthropic({ apiKey: 'sk-ant-...', extraHeaders: { 'X-Route': 'claude-prod' } }) // proxy headers
anthropic({                                                                    // first-party betas
  apiKey: 'sk-ant-...',
  extraBetas: ['context-management-2025-06-27', 'token-efficient-tools-2026-03-28'],
  contextManagement: {
    edits: [{
      type: 'clear_tool_uses_20250919',
      trigger: { type: 'input_tokens', value: 180_000 },
      clear_at_least: { type: 'input_tokens', value: 140_000 },
      clear_tool_inputs: ['Read', 'Bash', 'Grep'],
    }],
  },
})
openai({ apiKey: 'sk-...', defaultModel: 'gpt-5.5' })                          // OpenAI Codex (OAuth too)
openrouter({ apiKey: 'sk-or-...', defaultModel: 'google/gemini-pro' })
cerebras({ apiKey: 'csk-...', defaultModel: 'zai-glm-4.7' })
arcee({ apiKey: 'arcee-...', defaultModel: 'trinity-large-thinking' })
local({ baseURL: 'http://localhost:8000/v1', defaultModel: 'qwen3-coder' })

// Public factory for any OpenAI-compatible endpoint:
openaiCompat({
  name: 'baseten',
  apiKey: process.env.BASETEN_API_KEY!,
  baseURL: process.env.BASETEN_PROXY_URL!,
  authHeader: { name: 'Authorization', scheme: 'Api-Key' },  // vendor-specific scheme
})
```

Provider params types: `AnthropicParams`, `OpenAIParams`, `OpenRouterParams`, `CerebrasParams`, `ArceeParams`, `LocalParams`, `OpenAICompatParams` — exported from `zidane`.

`openrouter`, `cerebras`, `arcee`, and `local` are thin wrappers over `openaiCompat` with vendor/runtime defaults pinned. For new OAI-compatible backends (Baseten, Fireworks, Groq), use `openaiCompat` directly.

### Vision + image tool results

`Provider.meta.capabilities` exposes `{ vision?, imageInToolResult? }`:
- `vision: false` — image blocks become `"[image omitted — model does not support vision]"` before reaching the provider.
- `imageInToolResult: false` on a vision-capable provider — the loop flattens tool results and emits a companion `user` message with image blocks right after.

Consumer providers can override: `openrouter({ capabilities: { vision: true, imageInToolResult: false } })`.

Tools can return structured content:

```ts
const screenshot: ToolDef = {
  spec: { name: 'screenshot', /* ... */ },
  async execute(input, ctx): Promise<ToolResultContent[]> {
    const { imageBase64 } = await takeScreenshot()
    return [
      { type: 'text', text: 'Screenshot captured.' },
      { type: 'image', mediaType: 'image/png', data: imageBase64 },
    ]
  },
}
```

Flatten via `toolResultToText(result)`. MCP image blocks are auto-normalized via `normalizeMcpBlocks` before hooks fire.

### Prompt caching

`behavior.cache` (default on). The provider inserts `cache_control: { type: 'ephemeral' }` on three stable prefixes — end of system, last tool, last content block of the last message — so shared prefixes hit cache across turns.

| Provider | Behavior |
|---|---|
| `anthropic` | Honored natively. |
| `openrouter` | Forwarded to Anthropic + Gemini routes; ignored by OpenAI / DeepSeek / Grok / Groq / Moonshot (those cache automatically). |
| `arcee` / `local` / `openaiCompat` | Off by default so strict-schema endpoints don't reject unknown fields. Use `openaiCompat({ cacheBreakpoints: true })` only for hosts that honor `cache_control`. |
| `cerebras` | Off. |
| `openai` (Codex) | Not affected (pi-ai wire format). |

Cache hits surface on `TurnUsage.cacheRead` / `cacheCreation`. Anthropic + OpenRouter populate them per turn.

### Provider error classification

Provider failures are wrapped via `toTypedError(err)` before leaving `agent.run()` — each provider implements `Provider.classifyError(err): ClassifiedError | null`; unrecognized errors fall back to `AgentProviderError`. Aborts always produce `AgentAbortedError`. Match on `instanceof`, not on `err.message`.

```ts
import { AgentAbortedError, AgentBudgetExceededError, AgentContextExceededError, AgentProviderError } from 'zidane'

try { await agent.run({ prompt }) }
catch (err) {
  if (err instanceof AgentContextExceededError) { /* prune history, retry */ }
  else if (err instanceof AgentBudgetExceededError) { /* err.limit, err.limitValue, err.actualValue */ }
  else if (err instanceof AgentProviderError) {
    console.error(`${err.provider}: ${err.message} (${err.providerCode})`)
  }
}
```

`AgentBudgetExceededError` trips when `behavior.maxCostUsd` or `behavior.maxTotalTokens` is set and the run's cumulative spend / token consumption crosses the cap (post-turn check, soft cap — may exceed by one turn's worth before tripping). The session run finalizes as `'aborted'` so persisted records distinguish operator-imposed stops from runtime failures.

## Presets and Tools

A preset is a reusable slice of `AgentOptions`. Spread it into `createAgent()`:

```ts
import { basicTools, definePreset, glob } from 'zidane'

const myPreset = definePreset({
  name: 'my-preset',
  system: 'You are a helpful assistant.',
  tools: { ...basicTools, glob },
  mcpServers: [],  // optional MCP servers
  skills: {},      // optional skills config
  toolAliases: {   // optional LLM-boundary-only renames
    shell: 'Bash',
    read_file: 'Read',
    write_file: 'Write',
  },
})

createAgent({ ...myPreset, provider })
```

Presets are plain `Partial<AgentOptions>` — inline literals work too, no `definePreset` required.

### Tool aliasing

`toolAliases` maps canonical → wire names. **LLM-boundary-only**:

- The alias is what the provider, model, and `displayName` see.
- The **canonical** name lives in `session.turns`, `ToolHookContext.name`, and dispatch lookup. Alias changes never desync persisted history.
- The factory rejects collisions (two canonicals → same alias).

Alias only when semantically equivalent. `shell → Bash` is safe; `list_files → Glob` is not. For distinct semantics, add a real implementation (e.g. the built-in `glob`).

### Built-in tools

| Tool | Purpose |
|---|---|
| `shell` | Combined stdout+stderr tail-truncated at 32 KiB. `maxOutputBytes: 0` disables. |
| `readFile` | Line range, default `offset=1, limit=2000`, 256 KiB cap. Paging footer; binary marker. |
| `writeFile` | Returns `Created` / `Updated` / `No change needed: …` for no-op detection. |
| `edit` | Surgical `old_string` → `new_string`. Clear errors on non-unique (unless `replace_all`) / not-found (with nearest-match preview). |
| `multiEdit` | Sequential edits to one file. Best-effort, per-hunk — each step runs against the file as left by the previous APPLIED step; a per-step rejection is recorded against that step alone, siblings still run. All-applied → legacy `Edited <path>: applied N edits (M replacements).`; any failure → header + per-failure lines + `<edit-outcomes>` block. Per-edit approval is a host concern: the chat layer's `tool:gate` rebinds `ctx.input.edits` to the user-approved subset before the body runs, then a `tool:transform` hook merges body + approval outcomes and appends the canonical `<edit-outcomes>` annotation (see `docs/CHAT.md`). |
| `listFiles` | Directory listing. |
| `glob` | `**`, `*`, `?` pattern matching via `node:fs/promises` glob; shells out in docker/sandbox. |
| `grep` | ripgrep + `node:fs/promises` glob fallback. Full Claude Code Grep semantics. `head_limit=250`, `offset` paginates. |
| `spawn` | Sub-agent delegation (via `createSpawnTool`). |
| `createInteractionTool` | HITL factory (not a tool — instantiate per use). |

`basicTools` (exported) contains `shell`, `readFile`, `writeFile`, `listFiles`, `edit`, `multiEdit`. The default `basic` preset adds `spawn`. `glob` / `grep` are opt-in.

Each tool is a `ToolDef`:

```ts
interface ToolDef {
  spec: { name: string, description: string, inputSchema: Record<string, unknown> }
  execute: (
    input: Record<string, unknown>,
    ctx: ToolContext,
  ) => Promise<string | ToolResultContent[]>
  /** Default `false`. See "Per-tool concurrency" below. */
  isConcurrencySafe?: boolean | ((input: Record<string, unknown>) => boolean)
}
```

`ToolContext` exposes `provider`, `signal`, `execution`, `handle`, `hooks`, `turnId`, `callId`, `runId?`, `parentRunId?`, `session?`, `readState?`, `depth?`, plus preset fields (`name`, `system`, `tools`, `toolAliases`, `mcpServers`, `skills`, `behavior`) so `spawn` can inherit.

Returns `string` (common) or `ToolResultContent[]` (images / mixed content). Routed natively via `tool_result.content` when supported, or via companion user message on plain Chat Completions. Flatten via `toolResultToText(result)`.

### Per-tool concurrency

The unified dispatcher walks tool calls in submission order and decides per-call whether to fan out:

- **Safe siblings fan out** — concurrent-safe tools form a fleet that runs in parallel up to `behavior.maxConcurrentTools` (default `10`).
- **Unsafe tools act as barriers** — the dispatcher drains the in-flight fleet, runs the unsafe call alone, then unblocks the queue. Subsequent safe calls re-form a new fleet behind it.
- **Results yield in submission order** regardless of completion order — the model sees a deterministic transcript.

Built-in annotations (mirrors the convention that reads parallelize, writes serialize):

| Concurrency-safe | Barrier (default) |
|---|---|
| `readFile`, `glob`, `grep`, `listFiles`, `tool_search`, `todoread`, `spawn`, `skills_read` | `edit`, `multiEdit`, `writeFile`, `todowrite`, `interaction`, custom tools, MCP tools |
| `shell` (conditional — see below) | |

`shell.isConcurrencySafe` parses `input.command`: `ls`, `cat`, `pwd`, `git status`, `rg`, `echo`, etc. fan out (full allow-list in `tools/shell.ts → SHELL_READ_ONLY_COMMANDS` + `GIT_READ_ONLY_SUBCOMMANDS`). Anything mutating, any redirect (`>`, `>>`, `|`), any command chain (`&&`, `||`, `;`), and any subshell (`$(…)`, `` `…` ``) barriers.

**Cascade-cancel**. A `shell` error inside a concurrent fleet cancels its in-flight siblings via the per-batch `siblingAbort` controller. Other tool errors are isolated (one read's failure doesn't kill another read). Cancelled siblings get a `Cancelled: a sibling \`shell\` call …` tool_result so the model can decide whether to retry independently.

**Authoring custom tools**. Default is fail-closed (`false`) — any custom tool you author serializes unless you opt in. Opt in via `isConcurrencySafe: true` when:

- The tool only reads from disk / network / catalog.
- The tool doesn't mutate any shared state (session metadata, files, in-memory caches your other tools read).
- Parallel invocations against different inputs produce the same outputs as sequential invocations.

Use `isConcurrencySafe: (input) => …` when safety depends on the call's arguments (the `shell` pattern).

### Tool argument validation (auto-coerce)

`validateToolArgs(input, schema)` runs between `tool:gate` and `tool:before`. Missing/null required fields fail. Top-level coercions normalize the common OSS-model mistakes (`"true"`/`"yes"`/`"1"` → `true`, numeric strings → numbers, JSON strings → arrays/objects). `execute` always receives the **coerced** input. `validation:reject` fires only when no type reconciles — distinct from `tool:error`.

Array recursion: properties declared `{ type: 'array', items: { ... }, maxItems?, minItems? }` walk each item against `items` (one level of nesting — required-field check + per-property coercion + enum). Items that can't be coerced are **dropped** rather than failing the whole call; the original indexes surface on `ValidationResult.droppedItems[fieldName]`. `maxItems` truncates and reports the discarded tail. The tool's `execute` receives the post-recursion array.

```ts
import { validateToolArgs } from 'zidane'
const { valid, coercedInput, coercions } = validateToolArgs(
  { count: '42', flag: 'yes' },
  { type: 'object', properties: { count: { type: 'number' }, flag: { type: 'boolean' } } },
)
// valid: true, coercedInput: { count: 42, flag: true }, coercions: ['count', 'flag']
```

## Hooks

Hooks fire at every lifecycle point via [hookable](https://github.com/unjs/hookable). Awaited in registration order; ctx is shared per firing (last-writer wins). Hooks marked **mutable** influence downstream behavior; others are observational. Framework gates (allowed-tools, tool-budgets, dedup, lazy-disclosure) install after consumer hooks and short-circuit on existing `block` / `result`, so a policy gate always beats a consumer cache substitute regardless of registration order.

### Hook reference

| Hook | Fires | Mutable | Key ctx fields |
|---|---|---|---|
| `session:start` | Run begins | — | `sessionId, runId, prompt` |
| `skills:resolve` | Once per agent, after discovery (lazy on first `warmup()` / `run()` / `activateSkill()`, eager when `eager: true`) | — | `skills` |
| `skills:catalog` | Once per agent, after catalog build (same timing as `skills:resolve`) | **yes** (`catalog`) | `catalog, skills` |
| `skills:activate` | Skill activated (via `skills_use` / `activateSkill` / session resume) | — | `skill, via` (`'model' \| 'explicit' \| 'resume'`) |
| `skills:deactivate` | Skill deactivated | — | `skill, reason` (`'run-end' \| 'explicit' \| 'reset' \| 'model'`) — `'model'` fires when the model calls `skills_use({ mode: 'deactivate' })` to release an `allowed-tools` restriction it ran into |
| `mcp:bootstrap:start` | Per MCP server, before connect + listTools | — | `name, transport` |
| `mcp:bootstrap:end` | Per MCP server, after connect attempt (always fires) | — | `name, transport, durationMs, ok` + (`toolCount, lazy?, cached?` \| `error`) |
| `mcp:connect` | Per MCP server after successful bootstrap (lazy-connect: after first call) | — | `name, transport, tools, lazy?` |
| `mcp:error`, `mcp:close` | MCP connection lifecycle | — | `name, error?` |
| `mcp:auth:required` | Server needs OAuth login (no tokens / auto-promoted on 401+RFC9728) | — | `name, transport, reason: 'no-tokens' \| 'auto-promoted'` |
| `mcp:auth:url`, `mcp:auth:success`, `mcp:auth:error` | Interactive OAuth flow lifecycle | — | `name` (+ `url` \| `error`) |
| `mcp:tools:filter` | Per-server, after config-side allow/deny/predicate, before registration | **yes** (`tools[]`) | `server, transport, tools: Array<{ name, description?, inputSchema? }>` |
| `mcp:tools:list` | Per-server, after `listTools()` (or `cachedTools` resolution), before filtering — observational tap for host-side caching / inspection | — | `name, transport, tools: McpToolSchema[]` |
| `system:before` | Run-level system prompt set (only fires when `run({ system })`); observational | — | `system` |
| `context:transform` | Before each LLM call | **yes** (`messages`) | `messages` |
| `system:transform` | Per-request system-prompt mutation (after `context:transform`) | **yes** (`system`) | `system, messages (readonly), turn, turnId, session?` |
| `turn:before` | Turn starts | — | `turn, turnId, options` |
| `oauth:refresh` | OAuth token refreshed | — | `provider, providerId, source, previousCredentials, credentials` |
| `stream:thinking` | Each reasoning chunk | — | `delta, thinking, turnId` |
| `stream:text` | Each text chunk | — | `delta, text, turnId` |
| `stream:end` | Text stream complete (only if text present) | — | `text, turnId` |
| `stream:server_tool_use` | Provider-executed tool invocation (e.g. Anthropic `web_search_20250305`) | — | `id, name, input, turnId` |
| `stream:server_tool_result` | Provider-executed tool returned | — | `toolUseId, toolName, content, turnId` |
| `turn:after` | Assistant turn complete (always fires, even on error) | — | `turn, turnId, usage, message, toolCounts: { turn, run }` |
| `tool-results:after` | After the user-role tool-results turn is pushed (persistence seam — write the pair together) | — | `turn, turnId, message, results: readonly ToolResult[]` |
| `tool:gate` | Before each tool call | **yes** (`block, reason, result?`) | `turnId, callId, name, displayName, input, runToolCounts` — set `block` to refuse with `tool_result.content = "Blocked: <reason>"` + `is_error: true` (pinned contract); set `result` to substitute and skip execute (fires `tool:transform` + `tool:after`); `block` wins over `result` |
| `tool:before` | Tool execution starts | — | `turnId, callId, name, displayName, input, runToolCounts, priorContent?` |
| `tool:unknown` | Model called an unregistered tool | **yes** (`result?, suppressError`) | `turnId, callId, name, displayName, input` |
| `validation:reject` | Tool args failed validation (after auto-coerce) | — | `+ reason: string, schema: Record<string, unknown>` |
| `validation:coerce` | Tool args were auto-healed (only fires when fields changed) | — | `+ coercions: readonly string[], schema: Record<string, unknown>` |
| `mcp:tool:gate` | Before each MCP call | **yes** (`block, reason, result?`) | `turnId, callId, server, tool, displayName, input` |
| `mcp:tool:before` | MCP execution starts | — | `turnId, callId, server, tool, displayName, input` |
| `mcp:tool:transform` | After MCP result | **yes** (`result`) | `+ result: string \| ToolResultContent[], outputBytes: number` |
| `mcp:tool:after` | MCP complete | — | `+ result: string \| ToolResultContent[], outputBytes: number` |
| `mcp:tool:error` | MCP error | — | `+ error` |
| `tool:transform` | After tool result | **yes** (`result`, `isError`) | `+ result: string \| ToolResultContent[], isError, outputBytes: number` |
| `tool:after` | Tool complete | — | `+ result: string \| ToolResultContent[], outputBytes: number, runToolCounts` |
| `tool:error` | Tool error | **yes** (`result?`) | `+ error` — mutate `result` to substitute the payload sent back to the model |
| `tool:cancelled` | Per-call cancel via `agent.cancelTool(callId, reason?)` (typically a TUI "cancel this tool" affordance) | — | `+ reason: string, runToolCounts` — wire result is the canonical `TOOL_USE_CANCELLED_MESSAGE` with `isError: true`; `tool:transform` + `tool:after` do **not** fire for the cancelled call |
| `background:start` | Background task spawned via `shell({ run_in_background: true })` | — | `taskId, pid, command, cwd, outputPath, startedAt` — observational; subscribe for telemetry / "task started" toasts |
| `background:exit` | Background task terminated (natural exit, `shell_kill`, or `agent.destroy()` teardown) | — | `taskId, status: 'exited' \| 'killed', exitCode, signal?, outputPath, durationMs, command` — fires at-most-once per task; the agent's internal listener queues a `<task-notification>` for injection on the next user-turn |
| `usage` | Per-turn usage | — | `turn, turnId, usage, totalIn, totalOut` |
| `output` | Structured output extracted | — | `output, schema` |
| `budget:exceeded` | Per-turn tool-output bytes over `behavior.toolOutputBudget` | — | `turn, turnId, bytes, budget` |
| `tool-budget:exceeded` | Per-tool call cap from `behavior.toolBudgets` tripped | — | `tool, count, max, turnId, mode: 'steer' \| 'block'` |
| `repeat-guard:exceeded` | Consecutive-identical streak from `behavior.repeatGuard` tripped | — | `tool, count, threshold, turnId, action: 'block' \| 'abort'` — `'abort'` fires before the run is aborted via the agent's `AbortController` |
| `steer:inject` | Steering/follow-up injected | — | `message` |
| `session:turns` | Turns persisted | — | `sessionId, turns, count` |
| `session:save` | Session store write | — | `sessionId` |
| `session:meta` | Session metadata write | — | `sessionId, key: string, value: unknown` |
| `spawn:before`, `spawn:complete`, `spawn:error` | Sub-agent lifecycle | — | `id, task, stats?, error?` |
| `session:start`, `session:end` | Run boundaries (always fire) | — | `sessionId, runId` + (`prompt` \| `status, turnRange`) |
| `agent:abort` | `agent.abort()` called | — | `{}` |
| `agent:done` | Run finished (all exit paths) | — | `AgentStats` |

### Mutation contract

Mutable ctx fields: `tool:gate` (`block`, `reason`, `result`), `tool:transform` (`result`, `isError`), `mcp:tool:transform` (`result`), `tool:error` + `tool:unknown` (`result`), `context:transform` (`messages`), `system:transform` (`system`), `skills:catalog` (`catalog`), `mcp:tools:filter` (`tools`). `system:before` is observational despite the name — `run({ system })` is read directly, ctx mutations are dropped.

`outputBytes` is the wire size — **pre-mutation** on `*:transform` (truncation handlers decide), **post-mutation** on `*:after` (what the model sees). Reproduce via `toolOutputByteLength(content)`. `ctx.coercions` is **omitted** on `tool:before` / `tool:after` / `tool:transform` when no coercion happened; guard with `if (ctx.coercions)`.

### Identity fields on tool hook contexts

Every `tool:*` and `mcp:tool:*` hook ctx additionally carries three optional identity fields (omitted from the per-row "key ctx fields" column above to keep the table readable):

- `runId?: string` — the `SessionRun.id` this call belongs to. Lets one listener disambiguate calls across parallel runs / subagent trees without subscribing to `child:tool:*`.
- `parentRunId?: string` — set when the call's agent is a subagent (mirrors `SessionRun.parentRunId`); absent on top-level runs. Drives UI grouping for subagent-scoped state (todowrite batches, edit groups, etc.) without resolving the run row.
- `depth?: number` — subagent depth, 0 = top-level. Mirrors `ToolContext.depth`.

Same three fields land on `ToolContext` (visible to the tool body via `ctx.runId` / `ctx.parentRunId` / `ctx.depth`). The loop centralizes their construction in `buildToolHookBase`, so a new tool-related hook event automatically inherits them.

### Child (sub-agent) bubble events

`spawn`'s stream/tool/turn events bubble to the parent with `childId` + `depth`:

```
child:stream:text / child:stream:thinking / child:stream:end
child:stream:server_tool_use / child:stream:server_tool_result
child:tool:gate / child:mcp:tool:gate    ← mutable: block/reason/result propagate to the child
child:tool:transform                     ← mutable: parent can rewrite the child's tool_result
child:tool:before / child:tool:after / child:tool:error / child:tool:cancelled
child:background:start / child:background:exit               ← bubbled from subagent's background tasks
child:turn:after
```

Render nested activity without listening on the child instance. The `child:*:gate` and `child:tool:transform` events are special: they share the same `ctx` reference the child's loop awaits on (see `BUBBLED_MUTABLE_EVENTS` in `src/tools/spawn.ts`), so a parent gate handler can refuse / substitute / annotate a child's tool call without registering on the child agent. The chat layer's per-edit annotation flow relies on `child:tool:transform` to append `<edit-outcomes>` blocks onto a subagent's `multi_edit` / `edit` / `write_file` results.

### Hook recipes

Three patterns the framework can't auto-infer:

```ts
// 1. Truncate MCP tool results — sizes vary too much for a default.
agent.hooks.hook('mcp:tool:transform', (ctx) => {
  if (ctx.outputBytes <= 8192 || typeof ctx.result !== 'string')
    return
  const tail = ctx.result.slice(-4096)
  ctx.result = `…(${ctx.outputBytes - tail.length} bytes truncated from head)…\n${tail}`
})

// 2. Substitute a friendly response when the model invents a tool name.
agent.hooks.hook('tool:unknown', (ctx) => {
  if (ctx.name === 'EnterPlanMode') {
    ctx.result = 'EnterPlanMode is not available — use the shell tool to draft a plan as comments instead.'
    ctx.suppressError = true
  }
})

// 3. Drop old turns past a soft cap.
agent.hooks.hook('context:transform', (ctx) => {
  const KEEP_RECENT = 30
  if (ctx.messages.length > KEEP_RECENT) {
    const trimmed = [ctx.messages[0], ...ctx.messages.slice(-KEEP_RECENT + 1)]
    ctx.messages.splice(0, ctx.messages.length, ...trimmed)
  }
})
```

`mcp:tool:transform`, `tool:unknown`, and `context:transform` are the highest-leverage entries for cases v3 doesn't auto-handle. Most production agents end up with one of each.

## Agent State and Control

```ts
agent.isRunning             // boolean
agent.turns                 // SessionTurn[]
agent.steer('new focus')    // mid-run, between tool calls
agent.followUp('next')      // queue after current run
agent.abort()               // cancel the whole run
agent.cancelTool(callId)    // cancel a single in-flight tool; siblings keep running
agent.reset()               // clear turns + queues
await agent.warmup()        // pre-connect MCP (idempotent)
await agent.waitForIdle()
await agent.destroy()       // clean up execution + MCP
```

`cancelTool` returns `true` when a live call was flipped, `false` otherwise (idempotent). The matching call's `ctx.signal` aborts, `tool:cancelled` fires, and the wire result becomes `TOOL_USE_CANCELLED_MESSAGE` with `isError: true` — other parallel calls in the same batch keep running, and the assistant turn closes normally on the next batch boundary. Distinguished from `abort()` (whole-run abort, batch-layer `INTERRUPT_MESSAGE_FOR_TOOL_USE`) and `TOOL_USE_SKIPPED_MESSAGE` (steered out) so consumers can split the three causes by string match.

Agents also implement `Symbol.asyncDispose` (aliased to `destroy`) so they work with TC39 explicit-resource-management:

```ts
await using agent = createAgent({ provider, mcpServers })
await agent.run({ prompt: 'go' })
// destroy() runs automatically at scope exit
```

Requires Node 20+ (or Bun). Idempotent, same semantics as calling `destroy()` directly.

## Background tasks

The `shell` tool accepts an optional `run_in_background: true` flag that returns immediately with a task handle (`bash_<n>`) instead of blocking the model's turn. The companion `shell_kill` tool terminates a running task, and `wait_task({ task_id, timeout_ms })` blocks until a task exits (or the timeout elapses) for the rare cases where the model's next step genuinely depends on the outcome. The framework owns the lifecycle:

```ts
import { createAgent, resolveTasksDir } from 'zidane'

const tasksDir = resolveTasksDir({ userDir: '~/.zidane', sessionId: session.id })
const agent = createAgent({
  provider,
  session,
  behavior: { tasksDir },              // host opt-in for background mode
  // ... rest of options
})
```

**Disabling background mode.** Background mode is auto-disabled at the schema level (the `run_in_background` field is dropped from the `shell` tool's input schema, and the background-mode paragraphs are dropped from its description) when either:

- `behavior.tasksDir` is **unset** — the host hasn't wired the log dir, so the model would only ever get an error if it tried. The auto-disable means the model never sees the flag in the first place; no wasted turns.
- `behavior.disableBackgroundTasks: true` is set — explicit opt-out for hosts who have `tasksDir` for some other reason (legacy fixture, integration test) but don't want the model spawning background work this run.
- `behavior.disableBackgroundTasks: 'non-durable'` is set and the execution context's `capabilities.detachedTasks` is below `'durable'` — the capability-aware form durable-execution hosts use (`restateBehavior()` defaults to it), so remote contexts whose tasks survive worker crashes keep background mode while host-local processes don't.
- the execution context's `capabilities.detachedTasks` is `'none'` — the context can't background at all, so the schema would advertise a dead flag.

The path is per-session by convention (`<userDir>/<sessionId>/tasks/`) so a session delete cleans up log files alongside other session state.

The auto-rewrite only fires for the identity-equal framework `shell` tool — hosts who register a custom shell-named tool keep ownership of their spec. For explicit control, hosts can import `createShellTool({ allowBackground })` from `zidane` and register the tailored variant directly.

**Task lifecycle.**

1. Model calls `shell({ command: 'npm run build', run_in_background: true })`.
2. The shell tool spawns a process group via `ExecutionContext.execBackground`, opens `<tasksDir>/<task-id>.<context-timestamp>.log` (append mode), pipes stdout+stderr into it, and returns immediately with the task id + pid + log path. `background:start` fires. The `<context-timestamp>` segment (`YYYYMMDD-HHMMSS-mmm`, UTC, shared across every task in the same `ExecutionContext` instance) keeps two contexts on the same session from ever resolving to the same log file — the model-facing task id (`bash_1`) stays short.
3. The process exits naturally OR via `shell_kill({ task_id })`. `background:exit` fires exactly once, with `status: 'exited' | 'killed'`.
4. The agent's internal `background:exit` listener queues a `TaskExitInfo` (keyed by `taskId` in a `Map`).
5. On the NEXT `agent.run()`, the agent **reconciles**: the push-fed queue is merged with a `listBackground` sweep for terminated tasks the model hasn't been told about (the pull side is what makes remote/durable contexts work — `onExit` is optional on the contract, since a remote context can't push from a timer into the host). The merged list passes through the mutable `background:reconcile` hook (durable hosts journal it there), then each entry becomes a leading `text` content block on the seeded user turn carrying `<task-notification>…</task-notification>` XML — the model reads task completions inline with its prompt, no polling needed. A per-agent `notified` latch keeps the pull sweep from re-notifying the same terminated task on later runs.

**Suppression.** The agent's `tool:after` listener watches for `shell_kill` (deletes from the queue by `task_id`), `wait_task` (deletes by `task_id` when the wait returned exit info — timeouts don't suppress, the eventual exit is still news), and `read_file` / `read` (deletes by matching the file's `path` against any pending entry's `outputPath`). This keeps the model from receiving a redundant `<task-notification>` for an exit it already learned about through other means.

**Guardrails.** Two opt-in behavior knobs protect long unattended jobs:

- `behavior.backgroundOutputCap` (bytes) — past the cap the task keeps running but further output is dropped; a `<output-truncated bytes-dropped="N"/>` marker is appended to the log when the task settles.
- `behavior.backgroundStallWatchdogMs` — when a running task produces no output for the window, a `background:stall` hook fires and a one-shot `<task-stall>` notification is queued for the model's next turn (re-arms after fresh output). The process is NOT killed — the model decides, typically by reading the log tail and `shell_kill`ing a task stuck at an interactive prompt.

**Output reading.** The model reads task output via the normal `read_file` tool — no special API. The file lives at the absolute path returned in the spawn result; offset + limit work for tail-watching. The file stays around until the session is deleted.

**Shutdown.** Two paths:

1. **Clean shutdown (`agent.destroy()`)** — SIGTERMs every still-running task (entire process groups, so no orphans), awaits each log stream's flush, then tears down the execution handle. Notification queue cleared. The TUI invokes this via its session-teardown flow.
2. **Process-exit safety net (Ctrl+C, crash)** — `ProcessContext` lazy-registers a `process.on('exit')` handler on the first spawn that synchronously SIGTERMs every still-running task's process group. Output streams may not flush their last buffer (Node's `'exit'` listeners can't await), but the children DO die — no orphan `npm run dev` outliving a Ctrl+C'd TUI. The handler is deregistered in `destroy()` so repeated session-swap cycles don't pile listeners onto the global event emitter.

**Replay.** Persisted `<task-notification>` text blocks survive in the session's turn history; `eventsFromTurns` re-synthesizes them as `'task-notification'` `StreamEvent`s on load so the TUI's banner renderer paints them the same way live + replayed.

**Subagents.** A `spawn`'d subagent can call `shell({ run_in_background: true })` — but it has no "next user-turn" of its own to receive the `<task-notification>` on (a subagent's `agent.run()` ends as soon as the model returns `done: true`). The plumbing handles this transparently:

1. Subagent starts the task → registered under the subagent's `ExecutionHandle`.
2. Subagent's `agent.run` finishes (the model SHOULD return a brief summary that includes the `task_id` and end its turn — the shell tool's success-result is depth-aware and tells the model exactly this).
3. `spawn.ts`'s `finally` calls `ExecutionContext.reassignBackgroundTasks(childHandle, parentHandle, newOnExit)` BEFORE `agent.destroy()`. The task's `handleId` is rewritten to the parent's, and the task's natural-exit callback is rewired from the (about-to-be-destroyed) subagent's hook bus to the parent's. `background:reassign` fires on the parent's bus.
4. Subagent's `destroy()` walks its registry and finds no surviving tasks (they've all been promoted), so it kills nothing.
5. When the task eventually exits, `background:exit` fires on the PARENT's bus → enqueued → drained on the parent's next user-turn.

For nested subagents (grandchild spawns a background task while running inside a child that itself was spawned by the parent), the reassign chain runs at each spawn finally: grandchild → child at grandchild-finish, child → parent at child-finish. The task ends up owned by the top-level parent. `child:background:reassign` bubbles through so observability hooks see the full chain.

**Context support.** Background mode is OPTIONAL on `ExecutionContext` — the methods (`execBackground`, `killBackground`, `listBackground`, `waitBackground`) are declared as `?:` so existing contexts compile unchanged. The `ProcessContext` (in-process default) implements all of them; remote sandboxes that don't support detached spawns can leave them undefined and the shell tool surfaces a clean error. Contexts declare their task-lifetime guarantee via `capabilities.detachedTasks` (`'none' | 'process-lifetime' | 'durable'`; `resolveDetachedTasksCapability` infers `'process-lifetime'` for legacy contexts that implement `execBackground` without the field). The same is true for `agent.destroy()`'s teardown — it walks the registry only if the context exposes one.

## Sessions

```ts
import { createMemoryStore, createSession, loadSession } from 'zidane'

const store = createMemoryStore()
const session = await createSession({ store })
const agent = createAgent({ ...basic, provider, session })

await agent.run({ prompt: 'hello' })
await session.save()
const loaded = await loadSession(store, session.id)
```

| Store | Import | Durability | Concurrency |
|---|---|---|---|
| `createMemoryStore()` | `zidane` | Lost on exit | Single process |
| `createSqliteStore({ path })` | **`zidane/session/sqlite`** (Bun-only) | Per-turn flush | Same-process safe; WAL for multi-process. Don't attach two `Agent`s to one session. |
| `createRemoteStore({ url })` | `zidane` | Server-side | Delegated |
| `createFileMapStore(adapter)` | `zidane` | `meta.json` + `turns.jsonl` | Single session, auto-creates |

Turns persist incrementally via `session:turns` — crashes leave partial-but-valid history. Call `session.save()` only when your store batches writes. `session.isEmpty` is a "first prompt" signal.

`createFileMapStore(adapter)` wraps any `{ get, save, delete }` backend. Format is `turns.jsonl` (one `SessionTurn` per line) + `meta.json` — JSONL is debuggable and survives partial corruption (parse up to the first bad line).

### SQLite schema migrations (v3 → v4)

`createSqliteStore` migrates older DBs in place on first open. Before any destructive change, the migration writes a point-in-time snapshot to `<db>.zidane-pre-v4.bak` (WAL checkpointed first so the copy is consistent). The snapshot is created once and never overwritten on subsequent opens — re-runs of the migration reuse it. A one-line stderr notice on first migration tells you the path and the rollback procedure.

**Rollback procedure.** If a session loads incorrectly after migration:

1. Quit every zidane process holding the DB.
2. Restore the snapshot and clear the now-stale WAL sidecars:

   ```sh
   DB=~/.zidane/sessions.db   # adjust to your path
   cp "$DB.zidane-pre-v4.bak" "$DB"
   rm -f "$DB-wal" "$DB-shm"
   ```

3. Either pin the previous zidane version (it reads v3 directly) or upgrade to a fixed release (the new migration is idempotent — already-correct rows are skipped via a `turns` row-count check; bad rows are re-attempted). Either path leaves the `.bak` untouched so you can roll back again.
4. Restart zidane.

**Per-session recovery** (don't want to roll back the whole DB): the `.bak` is a fully valid v3 SQLite file. Open it with `sqlite3` (`SELECT data FROM sessions WHERE id = ?`) or an older zidane build, extract the JSON blob, hand-import the turns you need.

Delete the `.bak` once you've confirmed every session loads correctly; it can be reclaimed once you're satisfied.

## Sub-agent Spawning

`spawn` delegates to child agents. Children inherit the parent's preset fields by default — including `shell` and MCP servers. Pass `createSpawnTool({ preset })` to restrict.

```ts
import { basicTools, createSpawnTool, definePreset } from 'zidane'

definePreset({
  name: 'orchestrator',
  tools: {
    ...basicTools,
    spawn: createSpawnTool({
      maxConcurrent: 5,
      model: 'claude-haiku-4-5-20251001',
      preset: { tools: {} },  // restrict children
    }),
  },
})
```

### Parent/child boundaries — `persist` and `shareReadState`

Two **independent** flags control what crosses the parent/child boundary:

| Flag | Default | What it shares |
|---|---|---|
| `persist` | `false` | The parent's `Session`. Child turns get appended to the same `session.turns` stream with the child's `runId`; the child's `SessionRun` carries `parentRunId` so the tree is reconstructible. As a side-effect the read-state map (Session-keyed) is also shared. |
| `shareReadState` | `false` | Only the read-state map. The child gets `AgentOptions.readState` populated with the parent's resolved map but **no session** — `read_file` populations land in the parent's tracking, but child turns stay in-memory. |

Use `shareReadState: true` (without `persist`) when you want the parent's `requireReadBeforeEdit` gate to honor reads the child performed, without polluting the parent's session with child turns. The gate's error message now hints at this when the entry is missing — re-read in the calling context OR opt into one of the sharing flags.

### Subagent type registry

When configured, the spawn tool's input schema gains a `subagent_type` enum the model uses to pick a per-type preset. Mirrors the Claude Code SDK surface — trained models routinely emit `subagent_type: 'Explore' | 'Plan' | 'Verification' | 'general-purpose'` and the registry routes that decision into the framework instead of forcing every host to invent its own dispatch layer.

```ts
createSpawnTool({
  subagents: {
    Explore: { description: 'read-only research', readonly: true },
    Plan: { description: 'design changes', system: 'You are the planning subagent. Do not edit.' },
    Verification: { description: 'run tests', tools: ['shell', 'read_file'] },
  },
})
```

Per-type overlay fields: `system` (preset system prompt — per-call `input.system` still wins), `tools: string[]` (canonical-name allow-list, strict subset of the parent's tools), `readonly: true` (convenience for `['read_file', 'grep', 'glob', 'list_files']`), `description` (rendered into the schema for model self-selection). Unknown type names degrade to `'general-purpose'` (no overlay, no error) so models emitting types the host didn't wire keep working. Empty / unset registry drops the schema field entirely.

## Interaction Tool

Lets the agent request structured input. Not in any preset by default. `onRequest` can be async — the agent waits.

```ts
import { createInteractionTool } from 'zidane'

const askUser = createInteractionTool({
  name: 'ask_user',
  schema: { type: 'object', properties: { question: { type: 'string' } }, required: ['question'] },
  onRequest: async ({ question }) => ({ answer: await promptUser(question) }),
})
// tools: { ...basicTools, ask_user: askUser }
```

## MCP Servers

MCP connects lazily on first run, bootstrapped in parallel — the first `run()` waits on the slowest. Tools namespaced `mcp_{server}_{tool}`. Partial-failure tolerant — only all-fail throws.

```ts
const agent = createAgent({
  ...basic,
  provider,
  mcpServers: [
    { name: 'fs', transport: 'stdio', command: 'npx', args: ['-y', '@modelcontextprotocol/server-filesystem', '.'] },
    { name: 'api', transport: 'streamable-http', url: 'http://localhost:3002/mcp', bootstrapTimeout: 5_000, toolTimeout: 30_000 },
  ],
})
```

Per-server knobs: `bootstrapTimeout` (default **10 s**, caps `connect + listTools` — critical for flaky remotes), `toolTimeout` (default **30 s**, caps `callTool`), `closeTimeout` (default **5 s**, caps each server's shutdown inside `McpConnection.close()`; on timeout a stdio child is SIGKILLed so `destroy()` never hangs or leaks a subprocess), `strictEnv` (stdio only; pass `env` verbatim, off by default so `env: {}` doesn't strip `PATH`).

### Hiding bootstrap latency

```ts
await Promise.all([agent.warmup(), authenticate(), loadConfig()])    // pre-warm
const agent = createAgent({ provider, mcpServers, eager: true })     // or kick off automatically
```

`warmup()` is idempotent + concurrency-safe — callers share one promise, rejections clear for retry, post-`destroy()` is a no-op.

Two hooks fire per bootstrap regardless of outcome — attribute cold-start latency per server:

```ts
agent.hooks.hook('mcp:bootstrap:end', (ctx) => {
  // ctx.name, ctx.transport, ctx.durationMs, ctx.ok
  // ok ? ctx.toolCount : ctx.error
})
```

`normalizeMcpServers(input)` accepts host-SDK shapes (native array, record-indexed maps, `type` vs `transport`, `httpUrl` vs `url`) → canonical `McpServerConfig[]`.

### Stdio env handling

For `transport: 'stdio'`, `env` **merges** on top of the MCP SDK's inherited-env whitelist (POSIX: `HOME`, `LOGNAME`, `PATH`, `SHELL`, `TERM`, `USER`; Win32: `APPDATA`, `PATH`, …) — `env: { MY_TOKEN: '…' }` no longer strips `PATH` and trips `spawn ENOENT`. Use `strictEnv: true` only for fully-isolated child envs (tight sandboxes).

### Per-server tool filtering

`enabledTools` / `disabledTools` / `toolFilter` apply at config time (before registration); the `mcp:tools:filter` hook is the runtime escape hatch when host-side policy depends on the user / environment. Config filters compose: allow-list first, then deny-list, then predicate, then hook. `cachedTools` skips the `tools/list` round-trip when the host can supply the schemas itself; `lazyConnect: true` (requires `cachedTools`, incompatible with `auth: 'oauth'`) defers `client.connect()` to the first `tools/call`.

### Server instructions

Servers can return an `instructions` payload on the MCP `initialize` handshake — free-form text telling the model how to use the server's tools ("project is provisioned, use `apply_migration` directly"). Zidane captures these via the SDK's `getInstructions()` and renders them into the system prompt as a `# MCP Server Instructions` section, grouped by server name in `mcpServers` config order. Lives on the static (cached) half of the system prompt, so it rides the prefix cache.

Opt out with `behavior.surfaceMcpInstructions: false` (default `true`). Only eager-connect servers contribute — `lazyConnect: true` servers would have to force a connection to fetch the field, which defeats the deferral.

### Tool naming

Canonical wire form is `mcp_<server>_<tool>` (single underscore). Models trained on the Claude Code SDK may emit the double-underscore form (`mcp__<server>__<tool>`); zidane auto-recognises that as an inbound alias for every MCP tool, so a model calling `mcp__supabase__apply_migration` against a server registered as `mcp_supabase_apply_migration` resolves correctly. Inbound-only — the wire still advertises the single-underscore canonical, so the provider tool-list bytes don't change.

## Progressive tool disclosure

`behavior.toolDisclosure: 'lazy'` swaps MCP tools for a name-only catalog and auto-injects a `tool_search` native tool. Native + skill tools stay eager.

```ts
createAgent({
  ...basic,
  provider,
  behavior: { toolDisclosure: 'lazy' },
  mcpServers: [
    { name: 'github', transport: 'stdio', command: 'gh-mcp' },
    { name: 'fs', transport: 'stdio', command: 'fs-mcp', disclosure: 'eager' },  // per-server override
  ],
})
```

The system prompt gains a `<searchable_tools>` block. `tool_search({ query, names, server, limit })` returns matching schemas and unlocks them for the rest of the run. A `tool:gate` middleware refuses un-surfaced lazy tools (production providers also enforce this server-side). Catalog + search results show the **wire** name; the unlock set keys on canonical so dispatch / `session.turns` stay alias-stable.

`behavior.toolSearch` knobs (no-op in eager mode): `tool: false` opts out of auto-injection (catalog still emits, call-to-action drops); `limit: 20` caps default results. Per-server `disclosure: 'eager' | 'lazy'` overrides the agent default; run-level overrides agent-level.

Cost: one cache miss per discovery wave (tool list grows); subsequent turns hit cache normally.

## Skills

Spec-aligned client for the [Agent Skills specification](https://agentskills.io/specification).

```
pdf-processing/
├── SKILL.md          # Required: frontmatter + instructions
├── scripts/          # Optional: executable code
├── references/       # Optional: documentation
└── assets/           # Optional: templates, data
```

```markdown
---
name: pdf-processing
description: Extract PDF text, fill forms, merge files. Use when handling PDFs.
license: Apache-2.0
compatibility: Requires Python 3.14+ and pypdf
allowed-tools: Bash(git:*) Read
metadata:
  author: example-org
  version: "1.0"
  zidane.model: claude-opus-4-7    # zidane.* prefix for client hints
  zidane.thinking: medium
---

# PDF Processing
Instructions here…
```

### Loading + enabling

```ts
createAgent({
  ...basic,
  provider,
  skills: {
    scan: ['./my-skills'],
    write: [defineSkill({ name: 'review', description: 'Code review.', instructions: '...' })],
    exclude: ['old-skill'],
    enabled: ['review', 'pdf-processing'],
    maxActive: 5,              // default: unlimited
    scriptTimeoutMs: 60_000,
    trustProjectSkills: true,  // false skips project-scoped
    tool: true,                // auto-inject skills_use / skills_read / skills_run_script
  },
})
```

Default scan paths: `{cwd}/.agents/skills`, `{cwd}/.zidane/skills`, `~/.agents/skills`, `~/.zidane/skills`. Project beats user on collision.

### Activation

When the catalog is non-empty and `tool !== false`, three tools auto-inject:

| Tool | Purpose |
|---|---|
| `skills_use` | Activate or deactivate a skill by name. `mode: 'activate'` (default, spec-mandated) loads the skill body and wraps it in `<skill_content spec_version="0.1">`; `mode: 'deactivate'` (zidane extension) releases an active skill so its `allowed-tools` restrictions stop applying — fires `skills:deactivate` with `reason: 'model'`. Idempotent in both directions. |
| `skills_read` | Read a bundled resource (`references/`, `assets/`) of an active skill. |
| `skills_run_script` | Run `scripts/*.sh` / `*.py` with args. Returns `{ exitCode, stdout, stderr }`. |

Shell interpolation (`` !`cmd` ``) runs **per activation** — `` !`gh pr diff` `` reflects the current PR each time.

Programmatic activation:

```ts
await agent.activateSkill('pdf-processing')   // skills:activate, via: 'explicit'
agent.activeSkills                            // readonly ActiveSkill[]
await agent.deactivateSkill('pdf-processing') // skills:deactivate, reason: 'explicit'
```

`activateSkill` auto-resolves the catalog on its first call, so it's safe to invoke before `run()` (e.g. a TUI activating a `/slash` skill at submit time). Pre-warm via `await agent.warmup()` or `eager: true` to pay the resolution cost up front. Throws `Cannot activate skill "<name>" — skills are disabled for this agent` only when `skills.enabled === false` / `enabled: []`.

### Enforcement + resume

When any active skill declares `allowed-tools`, a `tool:gate` middleware blocks calls outside the union (the three skills tools are always implicitly allowed). Matching uses `displayName`, supports exact names (`Read`) and patterns (`Bash(git:*)`). Blocked calls throw `AgentToolNotAllowedError`, whose `.message` ends with a recovery hint pointing the model at `skills_use({ mode: 'deactivate', name })` — without this the model tends to invent folk theories about "sticky restrictions" instead of releasing the skill itself.

`allowed-tools` accepts three authoring shapes (all normalize to `string[]`): spec-canonical space-separated string (`allowed-tools: Read Bash(git:*)`), YAML block list (`- Read` lines), and YAML flow list (`[Read, Bash(git:*)]`). Earlier zidane builds only handled the first; the others silently mangled the union and opened the gate. The lenient parser is in `src/skills/discovery.ts` — malformed shapes drop the field with a diagnostic instead of coercing to garbage.

The parser also tolerates **`allowedTools`** (camelCase) and **`allowed_tools`** (snake_case) as alt-spellings of the canonical `allowed-tools` key — the value is migrated to the canonical slot and a `frontmatter-key-alt-spelling` diagnostic is attached. This is a courtesy for authors used to JS / Python idioms; the spec mandates kebab-case and the diagnostic is the nudge to rename. The canonical key wins when both are present.

On session resume, `skills_use` calls in the turn history rebuild the active set with **last-mode-wins per skill**: a trailing `mode: 'deactivate'` block correctly STAYS inactive on the next run (without this, the rehydrator would silently re-activate skills the model just released, defeating the recovery path). Activations fire with `via: 'resume'`; `skills:deactivate` does NOT fire during rehydration — the state started empty so there's nothing to deactivate.

Programmatic host-side activations (`agent.activateSkill(name)`) do NOT produce a `skills_use` block in history, so they don't survive the run-end deactivate-all pass automatically. Hosts that want persistence across runs (TUI slash-commands, for example) must re-activate before each `agent.run()`; the TUI does this via a pinned-skills set persisted in `session.metadata['zidane.activeSkills']` — see `docs/TUI.md`.

### Lenient load, strict authoring

Third-party skills with minor deviations load anyway via `parseSkillFile` (warnings in `SkillConfig.diagnostics`). `defineSkill` / `writeSkillToDisk` run `validateSkillForWrite` — spec-strict.

Hooks: `skills:resolve` (`{ skills }`), `skills:catalog` (`{ catalog, skills }`), `skills:activate` (`{ skill, via: 'model'|'explicit'|'resume' }`), `skills:deactivate` (`{ skill, reason: 'run-end'|'explicit'|'reset'|'model' }`).

## Structured Output

Force the final response to a JSON Schema. A synthetic `__output__` tool is injected with `toolChoice` on the **final turn only** (after the model signals done); mid-run tool calls are unaffected. Result lands on `stats.output` and fires the `output` hook.

```ts
const stats = await agent.run({
  prompt: 'Extract entities',
  behavior: {
    schema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] },
  },
})
console.log(stats.output) // { name: 'Alice' }
```

For Zod v4: `zodToJsonSchema(z.toJsonSchema(schema))` (strips `$schema` — some providers reject it). Zod is an optional peer dep; `zodToJsonSchema` takes JSON Schema, not a Zod type.

## Tracing

`createTracingHooks({ startSpan })` bundles turn/tool/MCP hooks into spans. Tracer-agnostic. `install()` returns an idempotent `uninstall()` that detaches handlers and closes open spans (defensive against abort).

```ts
const tracing = createTracingHooks({
  startSpan: (name, attrs) => Sentry.startInactiveSpan({ name, attributes: attrs }),
  namespace: 'agent',
})

const uninstall = tracing.install(agent.hooks)
try { await agent.run({ prompt }) }
finally { uninstall() }
```

## Execution Contexts

Where tools run. Defaults to in-process.

```ts
import { createSandboxContext } from 'zidane'
// Docker is opt-in via its own subpath — keeps `dockerode` (and its native
// ssh2 → cpu-features chain) out of bundles that never use it.
import { createDockerContext } from 'zidane/contexts/docker'

createDockerContext({ image: 'node:22', cwd: '/workspace' })
createSandboxContext(myProvider)  // implement SandboxProvider (E2B, Rivet, …)
```

## Key Types

| Type | Description |
|---|---|
| `AgentOptions` | `createAgent()` config (+ `eager?` to pre-warm MCP). |
| `AgentRunOptions` | `agent.run()` config. |
| `AgentBehavior` | All `behavior.*` fields. |
| `AgentStats` | Run result. `totalIn` / `totalOut` / `totalCacheRead` / `totalCacheCreation` / `cost` are cumulative (parent + children); `turns` / `turnUsage` parent-only. Plus `elapsed`, `output`, `children?`, `timeTillFirstTokenMs`. |
| `Agent` | Instance (run, abort, steer, hooks, …). |
| `Preset` | `Omit<Partial<AgentOptions>, 'provider' \| 'execution' \| 'session' \| 'mcpConnector'>`. |
| `ToolDef` | `{ spec, execute }`. `execute` returns `string \| ToolResultContent[]`. |
| `ToolContext` | Runtime context passed to tools. |
| `ToolResult` | `{ id, content: string \| ToolResultContent[] }`. |
| `ToolResultContent` | `{ type: 'text', text } \| { type: 'image', mediaType, data }`. |
| `Provider` | LLM interface (+ optional `classifyError`, `promptMessage`, `meta.capabilities`). |
| `ProviderCapabilities` | `{ vision?, imageInToolResult? }`. |
| `McpServerConfig` | `{ name, transport, command?, args?, env?, strictEnv?, url?, headers?, auth?, bootstrapTimeout?, toolTimeout?, closeTimeout?, enabledTools?, disabledTools?, toolFilter?, disclosure?, cachedTools?, lazyConnect? }`. |
| `McpToolSchema` | `{ name, description?, inputSchema? }` — the slim listTools shape consumed by `cachedTools`. |
| `LazyToolEntry` | `{ name, canonicalName, description, inputSchema, server? }` — consumed by `createToolSearchTool`. Exported from `zidane/tools`. |
| `SessionTurn` | `{ id, runId?, role, content, usage?, createdAt }`. |
| `TurnUsage` | `{ input, output, cacheRead?, cacheCreation?, thinking?, cost?, finishReason?, modelId? }`. |
| `TurnFinishReason` | `stop \| tool-calls \| length \| content-filter \| pause \| error \| other`. |
| `SkillConfig` / `SkillsConfig` | Parsed skill definition / config (`scan, write, exclude, enabled`). |
| `InteractionToolOptions` | `createInteractionTool` config (`schema, onRequest`). |
| `PromptPart` | `{ type: 'text' \| 'image' \| 'document', ... }`. |
| `RunHookMap` | `run({ hooks })` shape. |
| `AgentBehavior.maxConcurrentTools` | Cap on in-flight tools per turn (default `10`, clamped at `1`). See "Per-tool concurrency" above. |
| `ToolDef.isConcurrencySafe` | `boolean \| ((input) => boolean)`. Defaults to `false`. See "Per-tool concurrency" above. |
| `ToolHookContext` / `McpToolHookContext` / `SessionHookContext` / `StreamHookContext` / `SpawnHookContext` | Hook ctx base types. `ToolHookContext` / `McpToolHookContext` carry `runId? / parentRunId? / depth?` identity fields on every `tool:*` / `mcp:tool:*` event — see Identity fields above. |
| `ReadStateMap` / `ReadStateEntry` | Per-file read-tracking map (`Map<canonicalAbsPath, { contentHash, offset, limit, maxBytes, lineNumbers?, mtimeMs }>`). Construct one manually for `AgentOptions.readState` to share across an agent tree; or get the session-bound one via `getReadState(session)` / the lookup-aware `resolveReadStateMap(ctx)`. Canonical keys come from `readStateKey(cwd, path)`. |
| `ClassifiedError` | `{ kind, providerCode?, message? }` from `Provider.classifyError`. |
| `AgentContextExceededError` / `AgentProviderError` / `AgentAbortedError` / `AgentToolNotAllowedError` / `AgentBudgetExceededError` | Typed errors. `toTypedError(err)` wraps native errors. |
| `FileMapAdapter` | `{ get, save, delete }` — host-SDK seam for `createFileMapStore`. |
| `TracingHooksOptions` | `createTracingHooks` config (`startSpan`, `namespace?`). |
| `OpenAICompatParams` | `openaiCompat()` config (apiKey, baseURL, authHeader, extraHeaders, capabilities, cacheBreakpoints). |
| `ArceeParams` / `LocalParams` | OpenAI-compatible wrapper configs for Arcee Platform and self-hosted local endpoints. |
| `ValidationResult` | `{ valid, error?, coercedInput?, coercions?, droppedItems? }` — `validateToolArgs` output. `droppedItems` is keyed by field name and reports the original indexes of items dropped during recursive array validation. |
| `AgentHookMap` | `{ [event]: handler \| handler[] }` — agent-lifetime hooks on `AgentOptions.hooks`. |
| `RunHookMap` | Same shape, per-run scope (`agent.run({ hooks })`); handlers auto-detach. |
| `toolOutputByteLength(content)` | Reproduces the loop's `outputBytes` formula. |
| `toolResultToText(result)` | Flatten `string \| ToolResultContent[]` to a plain string. |

## File Layout

```
src/
  agent.ts             createAgent, orchestration, hooks
  loop.ts              Turn loop, tool execution
  loop-persistence.ts  Disk persistence of oversize tool results (`<persisted-output>` stubs)
  aliasing.ts          Tool-alias lookup + rewrite helpers
  atomic-write.ts      tmp + rename helper (shared by sessions, credentials, state)
  dedup-tools.ts       behavior.dedupTools gate
  tool-budgets.ts      behavior.toolBudgets gate
  errors.ts            Typed errors + toTypedError dispatcher
  prompt.ts            PromptPart canonicalizer + default builder
  tracing.ts           createTracingHooks
  stats.ts             flattenTurns, statsByModel
  types.ts             Shared types
  types-export.ts      Public type surface for `zidane/types`
  xml.ts               Minimal escapers
  index.ts             Public entry
  zod.ts               zodToJsonSchema (v4)
  auth.ts              OAuth login CLI
  start.ts             CLI entrypoint
  providers/           anthropic, openai (Codex), openrouter, cerebras, arcee, local, openaiCompat + oauth helpers
  presets/             basic, basicTools, definePreset, composePresets
  tools/               Built-ins + skills tools + validation (auto-coerce)
  session/             memory, sqlite, remote, file-map + converters
  skills/              Discovery, resolution, catalog, writer, interpolation, activation
  mcp/                 Connection + normalizeMcpServers + OAuth provider
  contexts/            process, docker, sandbox
  compact/             compactConversation + restoration + prompt builders
  chat/                Renderer-agnostic chat engine (see docs/CHAT.md)
  tui/                 OpenTUI terminal shell (see docs/TUI.md)
```

Types: `import type { Agent, SessionTurn, TurnUsage, Provider, ToolDef } from 'zidane/types'`

## Related

- `docs/ARCHITECTURE.md` — execution-flow diagrams, hook firing order, gate precedence.
- `docs/CHAT.md` — renderer-agnostic chat surface (auth, sessions, safe-mode, theme, edit-approval).
- `docs/TUI.md` — OpenTUI terminal shell built on top.
- `docs/INTERACTIONS.md` — `present_plan` / `ask_user` protocol.
