![Zidane](https://github.com/Tahul/zidane/blob/main/zidane.jpeg?raw=true)

# Zidane

An agent that goes straight to the goal.

Minimal TypeScript agent loop built with [Bun](https://bun.sh), hookable via [hookable](https://github.com/unjs/hookable).

Built to be embedded.

## Features

Small, hookable core with sensible defaults. Three principles guide the design:

- ⚖️ **Token discipline** — cache, dedup, compaction, byte-accounting.
- 🩹 **Self-healing fault paths** — auto-coerce args, hallucinated-tool fallback, error rewriting.
- 🔁 **Provider parity** — server-side features on Anthropic, client-side equivalents elsewhere.

Everything below is in service of those:

- 🧠 **Providers** — Anthropic, OpenAI Codex, OpenRouter, Cerebras, Arcee, local LLMs, plus `openaiCompat` (Baseten, Fireworks, Groq). OAuth + API key with auto-refresh.
- 🪝 **Hookable turn loop** — every text/thinking delta, tool call, MCP, session, skill, spawn, OAuth, validation, and budget event is observable and (mostly) mutable.
- 🛠️ **First-class tools** — `shell`, `read_file`, `write_file`, `edit`, `multi_edit`, `glob`, `grep`, `spawn`, human-in-the-loop, plus any [MCP](https://modelcontextprotocol.io) server. Per-call gates, arg auto-coerce, hallucinated-tool fallback, error rewriting. Lazy MCP disclosure via `tool_search`. Optional `todowrite` / `todoread` for persistent task checkpointing across prompts.
- 🧮 **Per-tool concurrency** — every `ToolDef` carries `isConcurrencySafe` (default `false`); the dispatcher fans safe siblings out in parallel and barriers unsafe ones. Order is preserved at yield time. Cap via `behavior.maxConcurrentTools`.
- ✂️ **Token-aware** — paginated reads, tail-truncated `shell`, idempotent `write_file`; `outputBytes` everywhere. `toolOutputBudget`, `toolBudgets`, `thinkingDecay`.
- 🗜️ **Context discipline** — `cache_control` breakpoints; server-side compaction on Anthropic, client-side `compactStrategy: 'tail'` elsewhere. Per-session read dedup + `requireReadBeforeEdit`; generalized `dedupTools`.
- 🔁 **Loop prevention** — `repeatGuard` breaks consecutive-identical tool streaks (block then abort); pairs with `dedupTools`, `toolBudgets`, and `requireReadBeforeEdit`. On by default in the built-in chat profiles.
- 🎯 **Reasoning + structured output** — thinking levels with optional exact budgets; force final response to a JSON Schema (Zod v4 interop).
- 💾 **Sessions, skills, multimodal** — pluggable stores, incremental persistence; [Agent Skills](https://agentskills.io/specification) spec; images + documents via `PromptPart[]`.
- 🧵 **Sub-agents + execution contexts** — child events bubble to parent; run tools in-process, Docker, or any `SandboxProvider`.
- 🧭 **Typed errors + 1000+ tests** — `AgentContextExceededError` / `AgentProviderError` / `AgentAbortedError`. Suite under 2s with mocks.

## Quickstart

```bash
bun install
bun run auth                                    # Anthropic + OpenAI Codex OAuth (--openai / --anthropic to scope)
bun start --prompt "create a hello world app"
```

## Agent Setup

```ts
import { createAgent } from 'zidane'
import { basic } from 'zidane/presets'
import { anthropic } from 'zidane/providers'

const agent = createAgent({
  ...basic,
  provider: anthropic({ apiKey: 'sk-ant-...' }),
})

const stats = await agent.run({ prompt: 'build a REST API' })
console.log(`Done in ${stats.turns} turns`)
```

`createAgent` options:

```ts
createAgent({
  provider,                          // required
  name: 'basic',                     // display name (traces/logs)
  system: 'You are a helpful...',
  tools: { shell, readFile },        // default: {}
  toolAliases: { shell: 'Bash' },    // canonical → LLM-facing names
  session,
  behavior: {
    maxConcurrentTools: 10,          // cap on in-flight tools per turn; set 1 to force sequential
    maxTurns: 50,
    maxTokens: 16384,
    thinkingBudget: 10240,
    thinkingDecay: { afterTurn: 5, factor: 0.5, floor: 1024 },
    cache: true,                     // prompt-cache breakpoints
    toolOutputBudget: 32768,         // soft per-turn byte cap (off by default)
    dedupReads: true,                // dedup re-reads in `read_file`
    dedupTools: { execute_sql: i => typeof i.query === 'string' ? i.query.trim() : undefined },
    requireReadBeforeEdit: false,    // refuse edits against unread/stale files
    toolBudgets: { execute_sql: { max: 20, onExceed: 'steer' } },
    repeatGuard: { blockThreshold: 4, abortThreshold: 8 }, // block/abort consecutive-identical streaks
    compactStrategy: 'off',          // 'off' | 'tail' (non-Anthropic compaction)
    compactThreshold: 131_072,       // 128 KiB
    compactKeepTurns: 4,
    toolDisclosure: 'eager',         // 'eager' | 'lazy' (hide MCP schemas behind tool_search)
    toolSearch: { tool: true, limit: 20 },
  },
  execution: createProcessContext(),
  mcpServers: [],
  eager: true,                       // pre-warm MCP in background
  skills: {},
})
```

Presets are `Partial<AgentOptions>` — spread, override:

```ts
createAgent({ ...basic, provider, system: 'be concise' })
```

`agent.run()` options:

```ts
await agent.run({
  prompt: 'your task',       // optional when session has turns
  model: 'claude-opus-4-7',
  system: 'be concise',
  thinking: 'medium',        // off | minimal | low | medium | high
  behavior: { maxTurns: 10, maxTokens: 4096, thinkingBudget: 8192 },
  tools: {},                 // {} = no tools for this run
  images: [],
  signal: abortController.signal,
})
```

`prompt` is optional when the session already has turns — the agent resumes. Useful when the user message is persisted before the run (WebSocket → session → queue → agent).

Precedence: `run.behavior` > `agent.behavior` > defaults.

## CLI

```bash
bun start \
  --prompt "your task"    \   # required
  --model claude-opus-4-7 \   # model id
  --provider anthropic    \   # anthropic | openai | openrouter | cerebras | arcee | baseten | local | openai-compat
  --base-url http://localhost:8000/v1 \ # local/openai-compat endpoint
  --header "X-Route: dev" \ # extra header for local/openai-compat
  --preset basic          \   # preset name
  --system "be concise"   \   # system prompt
  --thinking off          \   # off | minimal | low | medium | high
  --context process       \   # process | docker
  --mcp '{"name":"fs","transport":"stdio","command":"npx","args":["-y","@modelcontextprotocol/server-filesystem","."]}'
```

For `local` / `openai-compat`, `--base-url`, `--api-key-env`, `--headers-env`, `--header`, `--vision`, `--image-in-tool-result`, `--temperature`, and `--seed` apply; hosted providers ignore them.

`bun start --restate` runs the same start flow through a local Restate endpoint for durable-execution demos and fixture testing. It starts the service, probes readiness, registers the deployment, invokes `zidane-start-agent`, and exposes `run` / `status` / `history` handlers. See [docs/RESTATE.md](./docs/RESTATE.md) for production wiring and the advanced ReState flags.

## Providers

All providers accept runtime credentials via params. Env vars are fallbacks.

### Anthropic

```ts
import { anthropic } from 'zidane/providers'

anthropic({ apiKey: 'sk-ant-...' })
anthropic({ access: 'sk-ant-oat-...', refresh: '...', expires: Date.now() + 3600_000 }) // OAuth + auto-refresh

// First-party betas + server-side compaction:
anthropic({
  apiKey: '...',
  extraBetas: [
    'context-management-2025-06-27',     // token-accurate compaction
    'token-efficient-tools-2026-03-28',  // ~4.5% output reduction
    'interleaved-thinking-2025-05-14',   // think between tool calls
  ],
  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', 'Glob'],
    }],
  },
})
```

Fallback: `params.apiKey` > `params.access` > `ANTHROPIC_API_KEY` env > `.credentials.json`. `extraBetas` merge with OAuth defaults and de-dupe. `contextManagement` is sent as `context_management`; pair with the matching beta. Non-Anthropic equivalent: `behavior.compactStrategy: 'tail'`. Declares `capabilities.nativeWebSearch`: when the `web_search` tool is registered, the provider swaps in Anthropic's server-side `web_search_20250305` and streams results via `onServerToolUse` / `onServerToolResult`.

`extraHeaders` merges request headers for proxies/gateways; reserved Anthropic headers still win on collision. `extraBodyParams` passes un-typed Messages API fields through (factory options win on collision). Use when Anthropic ships a beta before zidane has a knob. `openaiCompat` accepts the same field (e.g. `reasoning_effort`, `metadata`, OpenRouter `provider` routing).

### OpenRouter / OpenAI / Cerebras / Arcee / Baseten / Local

```ts
import { openrouter, openai, cerebras, arcee, baseten, local } from 'zidane/providers'

openrouter({ apiKey: 'sk-or-...', defaultModel: 'google/gemini-pro' })
openai()                                                                  // OpenAI Codex OAuth
openai({ access: 'eyJ...', refresh: '...', expires: Date.now() + 3600_000, accountId: 'acct_123' })
cerebras({ apiKey: 'csk-...', defaultModel: 'zai-glm-4.7' })
arcee({ apiKey: 'arcee-...', defaultModel: 'trinity-large-thinking' })
baseten({ apiKey: '...', defaultModel: 'zai-org/GLM-5.1' })               // Baseten Model APIs
local({ baseURL: 'http://localhost:8000/v1', defaultModel: 'qwen3-coder' })
```

Fallbacks: `params.apiKey` > `params.access` (Codex) > `<PROVIDER>_API_KEY` env > `.credentials.json` (Codex). `local` uses `LOCAL_LLM_BASE_URL`, `LOCAL_LLM_API_KEY`, and `LOCAL_LLM_DEFAULT_MODEL`. Pass full OAuth fields on `openai()` to auto-refresh without reading `.credentials.json`.

### OpenAI-compatible (custom endpoints)

Any OpenAI Chat Completions endpoint — Baseten, Fireworks, Groq, local LM servers, corporate proxies:

```ts
import { openaiCompat } from 'zidane/providers'

openaiCompat({
  name: 'baseten',
  apiKey: process.env.BASETEN_API_KEY!,
  baseURL: process.env.BASETEN_PROXY_URL!,
  authHeader: { name: 'Authorization', scheme: 'Api-Key' },  // vendor-specific
  capabilities: { vision: false, imageInToolResult: false },
  cacheBreakpoints: false,                                    // true only when endpoint honors `cache_control`
})
```

`openrouter`, `cerebras`, `arcee`, `baseten`, and `local` are thin wrappers with vendor/runtime defaults pinned. Use `openaiCompat` directly for new backends.

### Prompt caching

`behavior.cache` (default on). `cache_control: { type: 'ephemeral' }` is inserted on three stable prefixes (system, last tool, last message's final block). Hits + writes surface on `TurnUsage.cacheRead` / `cacheCreation` via the `usage` hook.

| Provider | Behavior |
|---|---|
| `anthropic` | Honored natively. |
| `openrouter` | Forwarded; Anthropic + Gemini honor; OpenAI / DeepSeek / Grok / Groq / Moonshot cache automatically and ignore the markers. |
| `arcee` / `local` / `openaiCompat` | Off by default for strict OpenAI-compatible schemas; use `openaiCompat({ cacheBreakpoints: true })` only for endpoints that honor `cache_control`. |
| `baseten` | Markers off — Baseten Model APIs cache every request automatically server-side (discounted `cacheRead` tokens surface in usage). |
| `cerebras` | Off. |
| `openai` (Codex) | Not affected (pi-ai wire format). |

## Presets

`basic` ships:

| Tool | Description |
|---|---|
| `shell` | Combined stdout+stderr tail-truncated at 8 KB. `maxOutputBytes: 0` disables. |
| `readFile` | Line range, default 1..2000, 64 KB 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 / not-found (with nearest-match preview). |
| `multiEdit` | Atomic edits to one file. All-or-nothing. |
| `listFiles` | Directory listing. |
| `spawn` | Sub-agent. |

Opt-in (via `import from 'zidane'`): `glob` (`node:fs/promises` glob; shells out in docker/sandbox), `grep` (ripgrep + `node:fs/promises` glob fallback; full Claude Code Grep semantics), `createInteractionTool` (HITL factory).

`skills_use` / `skills_read` / `skills_run_script` auto-inject when the skills catalog is non-empty.

```ts
import { basicTools, definePreset } from 'zidane/presets'

createAgent({ ...definePreset({ name: 'researcher', tools: basicTools }), provider })
createAgent({ provider })                            // no tools
await agent.run({ prompt: 'just chat', tools: {} }) // no tools for one run
```

## Thinking

Named levels or exact budgets. Traces persist as `{ type: 'thinking', text }` blocks and stream via `stream:thinking`. Supported by Anthropic (native) and OpenRouter/Arcee (`reasoning_content`/`reasoning` SSE fields).

| Level | Default budget |
|---|---|
| `off` | disabled |
| `minimal` | 1,024 |
| `low` | 4,096 |
| `medium` | 10,240 |
| `high` | 32,768 |
| `adaptive` | model self-budgets |

```ts
await agent.run({ prompt: '…', thinking: 'high' })
await agent.run({ prompt: '…', thinking: 'high', behavior: { thinkingBudget: 50000 } })  // exact
await agent.run({ prompt: '…', thinking: 'adaptive', behavior: { thinkingBudget: 32000 } })
```

`adaptive` is Anthropic-only (`thinking.type='adaptive'`, avoids the opus 4.6+ deprecation warning). Pairing it with `thinkingBudget` caps `max_tokens = min(maxTokens, thinkingBudget)` to bound runaway reasoning. Other providers fall back to no reasoning on `adaptive`.

## 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). See `docs/SKILL.md` for the full hook reference table.

### Practical examples

```ts
// Refuse or substitute a tool call.
agent.hooks.hook('tool:gate', (ctx) => {
  if (ctx.name === 'shell' && String(ctx.input.command).includes('rm -rf')) {
    ctx.block = true
    ctx.reason = 'dangerous command'
  }
  if (ctx.name === 'todowrite' && (ctx.runToolCounts.todowrite ?? 0) > 0)
    ctx.result = 'Already recorded; no-op.'  // `block` wins if both set
})

// Redact secrets before the model sees a tool result.
agent.hooks.hook('tool:transform', (ctx) => {
  if (typeof ctx.result === 'string')
    ctx.result = ctx.result.replace(/\b(API_KEY|TOKEN|PASSWORD)\s*=\s*\S+/gi, '$1=<redacted>')
})

// Substitute for hallucinated tool names instead of erroring.
agent.hooks.hook('tool:unknown', (ctx) => {
  if (ctx.name === 'EnterPlanMode') {
    ctx.result = 'EnterPlanMode is not available — use shell to draft a plan as comments.'
    ctx.suppressError = true
  }
})

// Per-turn observation.
agent.hooks.hook('turn:after', (ctx) => { /* ctx.turn, ctx.usage, ctx.message — always fires */ })
agent.hooks.hook('stream:text', (ctx) => { /* ctx.delta, ctx.text */ })
agent.hooks.hook('agent:done', (ctx) => { /* AgentStats — cumulative incl. children */ })

// Mutate messages / system before the provider call.
agent.hooks.hook('context:transform', (ctx) => {
  if (ctx.messages.length > 30) ctx.messages.splice(2, ctx.messages.length - 30)
})
agent.hooks.hook('system:transform', (ctx) => {
  if (ctx.session && ctx.turn > 1)
    ctx.system += `\n\n## Reminder: keep responses concise after turn ${ctx.turn}.`
})
```

Mutable hooks: `tool:gate` (`block` / `reason` / `result`), `tool:transform` (`result` / `isError`), `tool:error` + `tool:unknown` (`result`), `context:transform` (`messages`), `system:transform` + `system:before` (`system`), `skills:catalog` (`catalog`), `mcp:tool:gate` (`block` / `reason` / `result`), `mcp:tool:transform` (`result`). All tool hooks include `turnId` + `callId`. `outputBytes` is **pre-mutation** on `*:transform`, **post-mutation** on `*:after` — reproduce via `toolOutputByteLength()`. `ctx.coercions` is **omitted** when no coercion happened — guard with `if (ctx.coercions)`.

### Hook recipes

Three patterns the framework can't auto-infer. Copy-paste and tune.

```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 shell to draft a plan as comments.'
    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 the framework doesn't auto-handle. Most production agents end up with one of each.

### Per-turn output budget

`behavior.toolOutputBudget` injects a "summarize before continuing" message when a turn's combined post-`tool:transform` bytes exceed the cap. Off by default. Subscribe via `budget:exceeded` (byte) and `tool-budget:exceeded` (per-tool, fields: `tool, count, max, turnId, mode`).

### Client-side context compaction (non-Anthropic)

`behavior.compactStrategy: 'tail'` elides older `tool_result` blocks once their combined size exceeds `compactThreshold` (default 128 KiB); the newest `compactKeepTurns` (default 4) stay intact. Anthropic users should prefer the server-side `context-management-2025-06-27` beta via `anthropic({ extraBetas, contextManagement })` — token-accurate.

### Read dedup + read-before-edit guard

- `behavior.dedupReads` (default **on**) — `read_file` returns `"unchanged since the previous read"` on identical re-reads. Per-session content-hash.
- `behavior.requireReadBeforeEdit` (default **off** in the SDK core; **on** in the built-in `BUILD_AGENT` / `PLAN_AGENT` chat profiles) — `edit` / `multi_edit` reject when the file hasn't been read this session or has drifted. The refusal is a recoverable `tool_result` (tells the model to `read_file` first), and `write_file` seeds read-state so the write→edit flow needs no extra read.
- `behavior.repeatGuard` (default **off** in the SDK core; **on** in the chat profiles) — breaks consecutive-identical tool streaks: blocks the Nth back-to-back identical call (default 4) with a pivot suggestion, aborts the run at the Mth (default 8) via the agent's `AbortController`. Defaults track `shell` + `*_execute_sql`; configurable via `RepeatGuardConfig`.

Tracking and the dedup short-circuit are **independent** — turning `dedupReads` off does NOT disable `requireReadBeforeEdit`. Read-state keys are canonical absolute paths (resolved through `node:path`), so `read_file('src/x')` and `edit('./src/x', …)` resolve to the same entry. For subagent-shared tracking without shared turn history, use `createSpawnTool({ shareReadState: true })`.

### Per-tool concurrency

Every `ToolDef` carries an `isConcurrencySafe?: boolean | ((input) => boolean)` flag (default `false`). The unified dispatcher walks calls in submission order:

- **Safe siblings fan out** in parallel up to `behavior.maxConcurrentTools` (default `10`).
- **Unsafe calls act as barriers** — they wait for the in-flight fleet to drain, run alone, then unblock the queue.
- **Results are yielded in submission order** regardless of completion order, so the model sees a deterministic transcript.

Builtin annotations: `read_file`, `glob`, `grep`, `list_files`, `tool_search`, `todoread`, `spawn`, `skills_read` are `true`. `shell` is conditional — `ls`, `cat`, `git status`, `rg`, … fan out; `rm`, `npm install`, pipes, redirects, command chains all barrier. `edit`, `multi_edit`, `write_file`, `todowrite`, `interaction`, custom tools, and third-party MCP tools default to `false` (barrier).

A `shell` error in a concurrent fleet cascade-cancels its siblings (shell commands often have implicit dependency chains); other failures are isolated.

Set `behavior.maxConcurrentTools: 1` to force fully sequential dispatch for deterministic debugging.

### Generic per-tool dedup

`behavior.dedupTools` extends the pattern to arbitrary tools via a hasher keyed by canonical name. Requires a session. Hasher contract — **three returns, three meanings**:

| Return | Meaning |
|---|---|
| non-empty string | Cache key. Equal keys replay the prior result. |
| `undefined` | Skip dedup for this call. Tool runs normally. |
| `''` or non-string | Treated as `undefined` (defensive). |

```ts
behavior: {
  dedupTools: {
    todowrite: input => JSON.stringify(input),
    execute_sql: (input) => {
      const q = typeof input.query === 'string' ? input.query.trim().toLowerCase() : undefined
      if (!q || q.includes('now()') || q.includes('random()')) return undefined  // non-cacheable
      return q
    },
  },
}
```

Tools with side effects or non-determinism (network, time, randomness) **must not** be listed. For MCP tools, key by the namespaced wire name (`mcp_<server>_<tool>`).

### Per-tool call budgets

`behavior.toolBudgets` caps per-tool calls per run. `'steer'` lets the call run then nudges the model to commit (once per tool per run); `'block'` refuses with `Blocked: <reason>`.

```ts
behavior: {
  toolBudgets: {
    todowrite: { max: 6, onExceed: 'steer' },
    execute_sql: { max: 3, onExceed: 'block' },
  },
}
```

Pass a function for custom messages: `onExceed: ctx => ({ mode: 'steer', message: '...' })`. Counts include dedup hits — by design.

### Adaptive thinking budget

`behavior.thinkingDecay` tapers thinking across turns. Late turns are usually checkpoint work where reasoning rarely pays off.

```ts
behavior: {
  thinkingBudget: 8192,
  thinkingDecay: { afterTurn: 5, factor: 0.5, floor: 1024 },
  // turn 1-5 → 8192, turn 6 → 4096, turn 7 → 2048, turn 8+ → 1024
}
```

Pass a function for arbitrary curves: `thinkingDecay: (turn, base) => base / Math.sqrt(turn)`. No-op when `thinkingBudget` is unset.

## Steering and Follow-up

- `agent.steer(msg)` — inject mid-run, delivered between tool calls.
- `agent.followUp(msg)` — queue for after the run finishes.
- `agent.abort()` — cancel the whole run.
- `agent.cancelTool(callId, reason?)` — cancel a single in-flight tool call without aborting the rest of the run. The matching call's `ctx.signal` flips, `tool:cancelled` fires, and the wire result becomes the canonical `TOOL_USE_CANCELLED_MESSAGE` with `isError: true`. Other concurrent calls keep running and the assistant turn closes normally on the next batch boundary. Returns `true` when a live call was flipped, `false` otherwise (idempotent — repeats are no-ops).

```ts
agent.steer('focus only on the tests directory')
agent.followUp('now write tests for what you built')
agent.cancelTool('toolu_01abc…', 'taking too long')
```

## Background Tasks

The `shell` tool accepts an optional `run_in_background: true` flag that returns immediately with a `bash_<n>` task handle. Standard output streams to a log file the model can `read_file` at its leisure; the companion `shell_kill` tool terminates a running task. When the task exits, the framework injects a `<task-notification>` content block on the model's next user turn — no polling, no spinning.

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

const tasksDir = resolveTasksDir({ userDir: '~/.zidane', sessionId: session.id })
const agent = createAgent({
  provider,
  session,
  behavior: { tasksDir },    // host opt-in; without this the feature auto-disables
  // ...
})
```

**Disabling background mode.** The framework auto-rewrites the built-in `shell` tool to hide `run_in_background` from its schema + description when:

- `behavior.tasksDir` is **unset** (the host hasn't wired the log dir), or
- `behavior.disableBackgroundTasks: true` is set (the host opted out explicitly).

The model never sees the flag in either case — no wasted turns discovering it doesn't work. Hosts who register a custom shell-named tool keep ownership of their spec (the auto-rewrite only fires on the identity-equal framework `shell`). For explicit control, import `createShellTool({ allowBackground })` from `zidane`.

`agent.destroy()` SIGTERMs every still-running task's process group and awaits its log-stream flush before tearing down the execution handle. See [docs/SKILL.md "Background tasks"](docs/SKILL.md#background-tasks) for the full lifecycle (notification queue, suppression rules, replay).

## Sub-agent Spawning

`spawn` delegates to independent child agents. Children inherit the parent's preset (tools, system, aliases, MCP servers, skills, behavior) by default. Pass `preset` on `createSpawnTool()` to override per child.

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

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

## Interaction Tool

Pause the agent and request structured input. Not in any preset by default. `onRequest` may be async — the agent waits. Return a string or object.

```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) }),
})
```

## Sessions

Persistent turn history + run metadata across calls. Turns persist incrementally — a crash leaves history up to the last completed turn.

```ts
import { createAgent, createSession, createSqliteStore } from 'zidane'

const store = createSqliteStore({ path: './sessions.db' })
const session = await createSession({ store })

const agent = createAgent({ ...basic, provider, session })
await agent.run({ prompt: 'hello' })
await session.save()
```

Storage backends — `createMemoryStore()` (in-memory), `createSqliteStore({ path })` from `zidane/session/sqlite` (Bun-only subpath; WAL, per-turn flush), `createRemoteStore({ url })` (HTTP), `createFileMapStore(adapter)` (any `{ get, save, delete }` backend; `turns.jsonl` + `meta.json`).

Restore via `await loadSession(store, id)`. Session hooks: `session:start`, `session:turns`, `session:end` (always fires, carries `turnRange`).

**Building provider requests outside `agent.run()`** — use `session.toMessages({ provider })` instead of `session.turns`. Raw turns can carry orphan `tool_use` / `tool_result` blocks from interrupted runs, partial-result writes, or compaction boundaries — feeding them to a provider hits `tool_result must be preceded by a tool_call with the same toolCallId`. `toMessages()` projects through the same defensive pairing repair the loop runs at wire-send time and (with `provider`) enforces the user-tail invariant for prefill-rejecting models. Pure, idempotent, never mutates `session.turns`; do not write the result back via `setTurns` / `appendTurns` (synthetic placeholders would contaminate history). Standalone variant `toWireMessages(turns, opts?)` for callers holding raw `SessionTurn[]` from `store.getTurns()`.

## MCP Servers

Connect any MCP server. Tools are namespaced `mcp_{server}_{tool}`. Connections are lazy (first `run()`) and reused; all servers bootstrap in parallel.

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

Per-server `disclosure: 'lazy' | 'eager'` overrides `behavior.toolDisclosure` (see [Progressive tool disclosure](#progressive-tool-disclosure)).

### Hiding bootstrap latency

The first `run()` still waits on the slowest server. Two knobs:

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

`warmup()` fans MCP connect + skills resolution out in parallel — both idempotent and concurrency-safe. Failures surface on the next `warmup()` / `run()`, not on the eager kickoff. `agent.activateSkill(name)` also auto-resolves the catalog on its first call, so a TUI can wire `/slash`-style activation at submit time without ordering it against `run()`.

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
})
```

### OAuth 2.1

HTTP MCP servers can be tagged `auth: 'oauth'` to wire the SDK's OAuth 2.1 client (PKCE, RFC 7591 dynamic client registration, RFC 9728 resource metadata, token refresh). Zidane provides the storage + redirect halves:

```ts
import { createMemoryMcpCredentialStore, McpOAuthProvider, connectMcpServers, loginMcpServer } from 'zidane'
import { createFileMcpCredentialStore } from 'zidane/chat'

const store = createFileMcpCredentialStore('/path/to/data-dir')

const agent = createAgent({
  provider,
  mcpServers: [{ name: 'linear', transport: 'streamable-http', url: 'https://mcp.linear.app/mcp', auth: 'oauth' }],
  mcpConnector: configs => connectMcpServers(configs, undefined, agent.hooks, {
    // Non-interactive bootstrap: uses stored tokens, refreshes on expiry,
    // never opens a browser. Missing tokens fire `mcp:auth:required`.
    buildAuthProvider: cfg => new McpOAuthProvider({ name: cfg.name, store }),
  }),
})

agent.hooks.hook('mcp:auth:required', ({ name }) => {
  console.log(`${name} needs login — run loginMcpServer(...) when ready`)
})

// Interactive login (opens browser, waits on a loopback callback):
await loginMcpServer(linearConfig, { store, hooks: agent.hooks })
// Tokens persisted. Next `agent.run()` / `warmup()` connects Linear with the new token.
```

Auto-detect: a server without `auth: 'oauth'` that returns 401 + RFC 9728 metadata is promoted to OAuth IF no static `Authorization` header is set — the headers check stops the harness from second-guessing user-managed bearer tokens.

The TUI exposes this via the MCP picker (`l` to login, `o` to logout). State is driven by four hooks: `mcp:auth:required`, `mcp:auth:url`, `mcp:auth:success`, `mcp:auth:error`.

## Progressive tool disclosure

With hundreds of MCP tools, every turn ships every schema. `behavior.toolDisclosure: 'lazy'` flips MCP tools to a name-only catalog and auto-injects a `tool_search` native tool. Native + skill tools stay eager.

```ts
const agent = createAgent({
  ...basic,
  provider,
  mcpServers: [
    { name: 'github', transport: 'stdio', command: 'gh-mcp' },                    // 200+ tools
    { name: 'fs', transport: 'stdio', command: 'fs-mcp', disclosure: 'eager' },   // per-server override
  ],
  behavior: { toolDisclosure: 'lazy', toolSearch: { limit: 20 } },
})
```

System prompt gains `<searchable_tools>` with `name + description` per lazy tool. `tool_search` accepts `query` (substring), `names`, `server`, `limit` — matches unlock for the rest of the run. A `tool:gate` middleware refuses dispatch on un-surfaced lazy tools (covers custom/mock providers; production providers also refuse server-side). Catalog + search results show the **wire** name; the unlock set keys on canonical so dispatch and `session.turns` stay alias-stable.

Cost: one cache miss per discovery wave (the tool list grows); subsequent turns hit cache. Opt out via `behavior.toolSearch.tool: false` (catalog still emits, call-to-action drops). A pre-existing host tool named `tool_search` shadows the auto-injection.

## Skills

Reusable instruction packages following the [Agent Skills](https://agentskills.io/specification) standard.

```
my-skill/
  SKILL.md       # frontmatter + instructions
  scripts/       # optional
  references/    # optional
  assets/        # optional
```

```markdown
---
name: my-skill
description: When to activate this skill.
model: claude-opus-4-7
thinking: low
allowed-tools: Bash Read Write
paths: "src/**/*.ts, test/**/*.ts"
---

Full instructions the agent receives on activation.
```

`allowed-tools` accepts three authoring shapes — all normalize to `string[]`:

```yaml
allowed-tools: Read Bash(git:*)        # spec-canonical, whitespace-separated
allowed-tools: [Read, Bash(git:*)]     # YAML flow list
allowed-tools:                          # YAML block list
  - Read
  - Bash(git:*)
```

Once a skill with `allowed-tools` is active, a `tool:gate` middleware blocks every other tool — the model gets an `AgentToolNotAllowedError` whose `.message` ends with a recovery hint pointing at `skills_use({ mode: 'deactivate', name })` so it can release the skill itself instead of looping on probes.

Default scan paths (first found wins): `{cwd}/.agents/skills`, `{cwd}/.zidane/skills`, `~/.agents/skills`, `~/.zidane/skills`. Instructions support `!\`command\`` — runs during resolution; output replaces the placeholder.

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

createAgent({
  ...basic,
  provider,
  skills: {
    scan: ['./custom-skills'],
    write: [defineSkill({ name: 'review', description: 'Code review.', instructions: '...' })],
    exclude: ['deprecated-skill'],
    enabled: ['review', 'deploy'],
  },
})
```

## Execution Contexts

Where tools run. Defaults to in-process. Docker isolates; sandbox runs remotely (E2B, Rivet, custom).

```ts
import { createSandboxContext } from 'zidane'
// Docker lives behind its own subpath so `dockerode` (and the ssh2 →
// cpu-features native chain) only enters your bundle when you opt in.
import { createDockerContext } from 'zidane/contexts/docker'

createDockerContext({ image: 'node:22', cwd: '/workspace', limits: { memory: 512, cpu: '1.0' } })
createSandboxContext(myProvider)  // implement SandboxProvider
```

## State Management

```ts
agent.isRunning           // run in progress?
agent.turns               // SessionTurn[]
agent.abort()             // cancel current run
agent.reset()             // clear turns + queues
await agent.warmup()      // pre-connect MCP + resolve skills (idempotent)
await agent.destroy()     // clean up context + MCP
await agent.waitForIdle() // wait for run to complete
```

## Message Format

Canonical format. Providers convert to/from wire formats internally.

```ts
type SessionContentBlock =
  | { type: 'text', text: string }
  | { type: 'image', mediaType: string, data: string }
  | { type: 'tool_call', id: string, name: string, input: Record<string, unknown> }
  | { type: 'tool_result', callId: string, output: string | ToolResultContent[], isError?: boolean }
  | { type: 'thinking', text: string, signature?: string }
  | { type: 'server_tool_use', producer: 'anthropic', id: string, name: string, input: Record<string, unknown> }
  | { type: 'server_tool_result', producer: 'anthropic', toolUseId: string, toolName: string, content: unknown }

type ToolResultContent =
  | { type: 'text', text: string }
  | { type: 'image', mediaType: string, data: string }
```

Image-producing tools (MCP browsers, screenshots) return `ToolResultContent[]` — routed natively on providers with `imageInToolResult: true`, via companion user message elsewhere. Flatten with `toolResultToText(output)`.

External interop converters: `fromAnthropic`, `toAnthropic`, `fromOpenAI`, `toOpenAI`, `autoDetectAndConvert` (re-exported from `zidane`). Wire-safety helpers: `toWireMessages(turns, opts?)`, `ensureToolResultPairing(messages, opts?)`, `ensureEndsWithUserMessage(messages, provider)` — apply when sending outside `agent.run()` to avoid orphan `tool_use` 400s; see [Sessions](#sessions).

## Typed Errors

Provider failures are wrapped before leaving `agent.run()`. Match on `instanceof`, not strings. Every provider ships `classifyError(err)`; unrecognized errors fall through as `AgentProviderError`. Abort paths (`agent.abort()` / `AbortSignal`) always produce `AgentAbortedError`.

```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 AgentAbortedError) { /* user cancelled */ }
  else if (err instanceof AgentBudgetExceededError) {
    console.error(`Hit ${err.limit} cap: ${err.actualValue} > ${err.limitValue}`)
  }
  else if (err instanceof AgentProviderError) {
    console.error(`${err.provider}: ${err.message} (${err.providerCode})`)
  }
}
```

Set `behavior.maxCostUsd` and/or `behavior.maxTotalTokens` to trip the budget circuit breaker (post-turn check, soft cap). See [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) → run-level budget.

### Retry-with-backoff (new default)

Transient provider failures (429, 5xx, Anthropic `overloaded_error` mid-stream, OpenAI-compat stream truncation, pi-ai rate-limit / unavailable messages) now retry with exponential backoff + jitter **by default**. Default policy: **3 attempts, 1s → 2s → 4s with full jitter, capped at 30s per delay**, `retry-after` / `retry-after-ms` headers honored. Worst-case run latency on terminal failure: ~2× `maxDelayMs` (so ~60s) before the error surfaces.

```ts
await agent.run({
  prompt: '...',
  behavior: {
    retry: { maxAttempts: 3, initialDelayMs: 1000, maxDelayMs: 30_000 },
  },
})
```

Set `behavior.retry.maxAttempts: 1` to restore the pre-v5.7 fail-fast behavior. Retry skips when any output has already streamed this turn (replaying over partial text would corrupt the transcript). Observe via the `stream:retry` hook (`{ attempt, nextAttempt, delayMs, err, statusCode?, requestId? }`); `stream:error` only fires on the terminal failure.

## Structured Output

Force the final response to a JSON Schema via provider-level tool forcing. Lands on `stats.output` and fires the `output` hook (`ctx.output`, `ctx.schema`).

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

For Zod v4, normalize via `zodToJsonSchema(z.toJsonSchema(schema))` — strips `$schema` (some providers reject it).

## Usage Tracking

`stats.totalIn` / `stats.totalOut` / `stats.cost` are **cumulative** (parent + recursive children). `stats.turns` and `stats.turnUsage` cover the parent loop only. Use helpers for tree-wide breakdowns.

```ts
import { flattenTurns, statsByModel } from 'zidane'

stats.totalIn / stats.totalOut / stats.cost  // cumulative
stats.turnUsage                              // parent loop only
stats.children                               // ChildRunStats[] in completion order
stats.timeTillFirstTokenMs                   // ms to first stream/tool event

flattenTurns(stats)   // every TurnUsage in tree (DFS)
statsByModel(stats)   // Map<modelId, { input, output, cost, cacheRead, cacheCreation, turns }>
```

## Types & Helpers

Types from `zidane/types` (`Agent`, `SessionTurn`, `TurnUsage`, `Provider`, `ToolDef`, `ValidationResult`, hook contexts).

Helpers re-exported from `zidane`:
- `toolResultToText(content)` — flatten for logging.
- `toolOutputByteLength(content)` — same formula as `outputBytes`.
- `validateToolArgs(input, schema)` — the loop's validator.

## Testing & Benchmarks

```bash
bun test
```

1000+ tests with mock provider + execution context. Under 2 s; no API keys or Docker.

Benchmark harnesses live in [`benchmarks/`](./benchmarks). First integration: [Terminal-Bench](./benchmarks/terminal-bench).

## Docs

| Doc | What |
|---|---|
| [docs/ARCHITECTURE.md](./docs/ARCHITECTURE.md) | Mermaid diagrams of the agent loop, tool execution, hook firing order, dependency graph. |
| [docs/HEADLESS.md](./docs/HEADLESS.md) | `zidane/headless` — non-interactive `runHeadless()` + CLI for automation / RL rollouts. Structured result, JSONL transcript, `stream-json`, exit codes. |
| [docs/SKILL.md](./docs/SKILL.md) | Authoritative reference for `createAgent` + hooks + tools + sessions + skills + MCP. Long-form. |
| [docs/CHAT.md](./docs/CHAT.md) | `zidane/chat` — renderer-agnostic chat engine (auth, sessions, safe-mode, settings, theme). Consumed by the TUI and any future GUI. |
| [docs/TUI.md](./docs/TUI.md) | `zidane/tui` — terminal shell built on `zidane/chat`. `runTui()`, screens, modals, keyboard, theming. |
| [docs/RESTATE.md](./docs/RESTATE.md) | `zidane/restate` — optional durable-execution adapter (Restate). Journals LLM + completed tool calls, with strict defaults and opt-in durable parallel tools. |

## License

ISC
