zidane
v8.1.0
Published
an agent that goes straight to the goal
Readme

Zidane
An agent that goes straight to the goal.
Features
Zidane has a small, hookable core with sensible defaults.
Three principles guide the design:
- Token discipline. Caching, deduplication, compaction and byte accounting.
- Self-healing fault paths. Automatic argument coercion, a fallback for hallucinated tools, and error rewriting.
- Provider parity. Server-side features on Anthropic, client-side equivalents on other providers.
Everything below serves those principles:
- Providers. Anthropic, OpenAI Codex, OpenRouter, Cerebras, xAI Grok, Google Gemini and Vertex AI, Arcee, Z.ai, local LLMs, plus
openaiCompat(Baseten, Fireworks, Groq). OAuth and API key both work, with automatic refresh. For every provider, you can override the model metadata (output ceiling, pricing, context window) withbehavior.modelCatalogorsetModelCatalog(). Use this for gateway aliases, private deployments, and models newer than the bundled registry. - Hookable turn loop. Hooks can observe every text delta, thinking delta and tool call. They can also observe MCP, session, skill, spawn, OAuth, validation and budget events. Most hooks can also change the event.
- Tools.
shell,read_file,write_file,edit,multi_edit,glob,grep,spawn, human-in-the-loop, plus any MCP server. Tools have per-call gates, automatic argument coercion, a hallucinated-tool fallback and error rewriting. MCP tools are disclosed lazily throughtool_search. The optionaltodowriteandtodoreadtools save task checkpoints across prompts. - ACP v1 adapter. Run Zidane from ACP clients such as Zed with
zidane-acp, or embed the protocol server withzidane/acp. - Per-tool concurrency. Every
ToolDefhasisConcurrencySafe(defaultfalse). The dispatcher runs safe sibling calls in parallel and makes unsafe ones wait. Results keep their original order when yielded. Set a cap withbehavior.maxConcurrentTools. - Token-aware. Paginated reads, tail-truncated
shell, idempotentwrite_file, andoutputByteseverywhere. Related options aretoolOutputBudget,toolBudgetsandthinkingDecay. - Context discipline.
cache_controlbreakpoints. Server-side compaction on Anthropic, and client-sidecompactStrategy: 'tail'on other providers. Per-session read deduplication andrequireReadBeforeEdit, and the generaldedupTools. - Loop prevention.
repeatGuardbreaks streaks of identical consecutive tool calls (it blocks, then aborts). It pairs withdedupTools,toolBudgetsandrequireReadBeforeEdit. It is on by default in the built-in chat profiles. - Reasoning and structured output. Thinking levels with optional exact budgets. You can force the final response to match a JSON Schema (Zod v4 interop).
- Sessions, skills, multimodal. Pluggable stores and incremental persistence. The Agent Skills spec. Images and documents through
PromptPart[]. - Sub-agents and execution contexts. Child events bubble up to the parent. Run tools in-process, in Docker, or in any
SandboxProvider. - Typed errors and 1000+ tests.
AgentContextExceededError,AgentProviderErrorandAgentAbortedError. The suite runs in under 2s with mocks.
Quickstart
bun install
bun run auth # Anthropic + OpenAI Codex OAuth (--openai / --anthropic to scope)
bun start --prompt "create a hello world app"To run the chat host on a server, use zidane-server. TUI and desktop windows attach to it over WebSocket. Get it as the image ghcr.io/tahul/zidane-server (linux/amd64, linux/arm64) or as the binaries attached to each GitHub release. See SERVER.md.
docker run -d --name zidane -v zidane-data:/data -p 127.0.0.1:4280:4280 ghcr.io/tahul/zidane-server:7.2Agent setup
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:
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
toolDescriptions: { // override any tool's description (native or MCP)
shell: cur => `${cur}\nNever invoke sudo.`,
mcp_github_create_pull_request: 'Open a PR. Always target `develop`.',
},
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, 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: { run_query: i => typeof i.query === 'string' ? i.query.trim() : undefined },
requireReadBeforeEdit: false, // refuse edits against unread/stale files
toolBudgets: { run_query: { 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,
mediaKeepTurns: undefined, // age image/document base64 off the wire after N messages; wins over mediaBudget
mediaBudget: undefined, // keep media inline until near the provider's caps; chat profiles use { maxItems: 80, maxBytes: 20 MiB }
maxImageEdge: undefined, // shrink oversize PNGs once, at entry; chat profiles use 2000
autoCompact: false, // loop-native LLM summary at N% of the window; chat profiles enable it
toolDisclosure: 'eager', // 'eager' | 'lazy' (hide MCP schemas behind tool_search)
toolSearch: { tool: true, limit: 20, mode: 'unlock' }, // 'dispatch' = stable tool list via call_tool
},
execution: createProcessContext(),
mcpServers: [],
eager: true, // pre-warm MCP in background
skills: {},
})Presets are Partial<AgentOptions> objects. Spread one and override what you need:
createAgent({ ...basic, provider, system: 'be concise' })agent.run() options:
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 then resumes the session. This helps when the user message is saved before the run (WebSocket, then session, then queue, then agent).
Precedence: run.behavior > agent.behavior > defaults.
Provider/model fallback
provider also accepts an array, and model an ordered fallback list:
const agent = createAgent({
provider: [anthropic({ ... }), openrouter({ ... })], // names must be unique
})
await agent.run({
prompt: '…',
model: [
{ provider: 'anthropic', model: 'claude-sonnet-4-5' }, // primary
{ provider: 'openrouter', model: 'anthropic/claude-sonnet-4-5' },
'claude-haiku-4-5', // bare string fans out across ALL providers, in order
],
})The run resolves this into an ordered target list (resolveModelTargets). The loop moves to the next target and re-issues the request when two things are true. First, nothing has streamed yet. Second, a stream failed terminally on the active target. A terminal failure is one of these:
- The retry budget is exhausted.
- A non-retryable provider error, such as an auth or billing error.
- A retryable error whose server-declared
retry-afterexceeds 15s. This is quota or usage exhaustion, and waiting is pointless while a sibling target is available.
The loop reformats tools for the new provider's wire shape. Messages are canonical, so they need no translation. context_exceeded never falls back. It keeps its own overflow-recovery path.
Fallback is run-sticky. Once a run advances, it stays on that target. The next run() starts from the primary again. Each advancement fires the model:fallback hook (from, to, targetIndex, err). Only the last target's terminal failure reaches stream:error. A plain model: 'id' with multiple providers means "try this model on every provider". If you omit model, fallback goes across each provider's meta.defaultModel. TurnUsage.modelId keeps the per-turn attribution. The providerName in the session run record keeps the primary's name.
Two sizing notes:
maxTokensand the thinking budget are not re-derived on fallback. They carry over from the primary, so size them for the smallest model in the chain. A target that rejects the carried budget uses up one advancement.- In multi-provider setups, every provider needs a non-empty, unique
name.
The auto-compact window does re-derive on fallback. Object entries can have a contextWindow (your catalog's value for that model). It re-bases even an explicit autoCompact.contextWindow. Without it, windows from meta re-resolve from the new provider's meta.contextWindow, and explicit windows stay as set.
CLI
bun start \
--prompt "your task" \ # required
--model claude-opus-4-7 \ # model id
--provider anthropic \ # anthropic | openai | openrouter | cerebras | xai | google | google-vertex | 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 and 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 and invokes zidane-start-agent. It also exposes run, status and history handlers. See docs/RESTATE.md for production wiring and the advanced Restate flags.
ACP
Zidane includes an optional Agent Client Protocol v1 adapter for editors and IDEs. Install the package and point your ACP client at:
zidane-acpBy default, zidane-acp advertises a model picker for providers configured in Zidane's credential store or environment. It also saves ACP sessions to ~/.zidane/acp-sessions.db, so clients such as Zed can reopen threads after a restart.
For a curated or single-provider setup, use --models a,b (one provider), the repeatable --model-option provider:model[:label], or --provider and --model. Use --session-db <path> only to override the default ACP session database.
By default, credentials come from Zidane's app credential store (~/.zidane/credentials.json), so the editor config needs no API keys. Explicit env vars still win. ACP does not let editors forward their own provider keys. Configure Zidane credentials once to keep secrets out of settings.json.
For Zed config, model selection, stdio framing details, session persistence and in-process use through zidane/acp, see docs/ACP.md.
Vim / Neovim
editors/vim is a plugin for Vim 9 and Neovim 0.9+, built on zidane-acp. It gives you a quick panel that opens over any window (buffers, NERDTree, netrw and others). It has editor tools, so the agent can inspect your setup, read :help and run commands once you approve them.
Plug 'Tahul/zidane', { 'rtp': 'editors/vim' }<Leader>z or :Zidane [prompt] opens it. A visual selection goes to the agent as context. See :help zidane.
Providers
All providers accept runtime credentials through params. Env vars are the fallback.
Anthropic
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'],
}],
},
})Credentials resolve from params.apiKey first, then params.access, then the ANTHROPIC_API_KEY env var, then .credentials.json. extraBetas merge with the OAuth defaults, and duplicates are removed. The provider sends contextManagement as context_management. Pair it with the matching beta. The equivalent for other providers is behavior.compactStrategy: 'tail'.
The provider declares capabilities.nativeWebSearch. When you register the web_search tool, the provider swaps in Anthropic's server-side web_search_20250305. It streams results through onServerToolUse and onServerToolResult.
extraHeaders merges request headers for proxies and gateways. Reserved Anthropic headers still win on collision. extraBodyParams passes untyped Messages API fields through, and factory options win on collision. Use it when Anthropic ships a beta before Zidane has a knob for it. openaiCompat accepts the same field (for example reasoning_effort, metadata and OpenRouter provider routing).
OpenRouter / OpenAI / Cerebras / xAI / Arcee / Z.ai / Mistral / Baseten / Local
import { openrouter, openai, cerebras, xai, arcee, zai, mistral, 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' })
xai({ apiKey: 'xai-...' }) // or OAuth (SuperGrok / X Premium+) via the auth screen
arcee({ apiKey: 'arcee-...', defaultModel: 'trinity-large-thinking' })
zai({ apiKey: '...', defaultModel: 'glm-5.3-flash' }) // Z.ai (Zhipu) GLM Open Platform
mistral({ apiKey: '...', defaultModel: 'mistral-medium-latest' }) // Mistral La Plateforme — thinking via `reasoning_effort` (small / medium-3.5 / magistral)
baseten({ apiKey: '...', defaultModel: 'zai-org/GLM-5.3' }) // Baseten Model APIs
local({ baseURL: 'http://localhost:8000/v1', defaultModel: 'qwen3-coder' })Credentials resolve from params.apiKey first, then params.access (Codex), then the <PROVIDER>_API_KEY env var, then .credentials.json (Codex). local uses LOCAL_LLM_BASE_URL, LOCAL_LLM_API_KEY and LOCAL_LLM_DEFAULT_MODEL. Pass the full OAuth fields to openai() to refresh automatically without reading .credentials.json. xai accepts both a static XAI_API_KEY and the SuperGrok or X Premium+ OAuth login (bun run auth --xai). It refreshes the stored OAuth token automatically, like Anthropic and Codex.
Google Gemini / Vertex AI
import { google, googleVertex } from 'zidane/providers/google'
google({ apiKey: 'AIza...' }) // Gemini API (Google AI Studio); defaults to GEMINI_API_KEY, then GOOGLE_API_KEY
googleVertex({ project: 'my-project' }) // Vertex AI with Google Cloud credentials (`gcloud auth application-default login`)
googleVertex({ project: 'my-project', location: 'us-central1', credentialsFile: './sa.json' })google takes an API key. googleVertex takes a Vertex API key (GOOGLE_CLOUD_API_KEY) when one is set. Otherwise it takes Google Cloud credentials for GOOGLE_CLOUD_PROJECT in GOOGLE_CLOUD_LOCATION (default global).
In the TUI and desktop app, Vertex AI also offers "Sign in with Google". This needs ZIDANE_GOOGLE_CLIENT_ID and ZIDANE_GOOGLE_CLIENT_SECRET to name a Desktop-app OAuth client from the Google Cloud console. From the CLI, run bun run auth --google. The sign-in picks the project to bill and refreshes itself.
Google AI Pro and Ultra plans include monthly Google Cloud credits. These credits pay for either provider on a billing-enabled project. The built-in providers do not use the plans' consumer sign-ins (Antigravity, Gemini CLI), because Google bans their use from third-party tools. The unofficial extensions/antigravity extension is opt-in. It does sign in to Antigravity's quota. Read its risk notice first, because Google can restrict the account.
The session keeps Gemini's thought signatures and replays them to the route that issued them. Gemini 3 requires this for multi-step tool use. Models come from pi-ai's registry, plus Gemini 4 Argon (gemini-4-argon) until pi-ai bundles it.
OpenAI-compatible (custom endpoints)
This works with any OpenAI Chat Completions endpoint, such as Baseten, Fireworks, Groq, local LM servers and corporate proxies:
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 that pin vendor or runtime defaults. For new backends, use openaiCompat directly.
Prompt caching
behavior.cache is on by default. It inserts cache_control: { type: 'ephemeral' } on three stable prefixes (system, last tool, last message's final block). Cache hits and writes show up on TurnUsage.cacheRead and cacheCreation through the usage hook.
behavior.cacheTtl: '1h' opts into Anthropic's extended cache. It sets cache_control.ttl and sends the extended-cache-ttl-2025-04-11 beta header. Writes cost 2× instead of 1.25×. Reads stay at about 0.1×. The extended cache pays off for interactive sessions, where a single pause over 5 minutes otherwise loses the entire cached prefix. For headless back-to-back runs, keep the default '5m'.
The wire shape is verified, but measure actual retention on your account before you count the savings. Our 2026-07 measurement saw a 1h entry evicted within 6 minutes (the gated test uses INFERENCE_SLOW=1). See docs/ARCHITECTURE.md § Cache-stable configuration for the full cache-aware settings table.
| Provider | Behavior |
|---|---|
| anthropic | Honored natively. |
| openrouter | Forwarded. Anthropic and Gemini honor the markers. OpenAI, DeepSeek, Grok, Groq and Moonshot cache automatically and ignore them. |
| arcee, zai, mistral, 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 on the server, and discounted cacheRead tokens show up in usage. |
| google, google-vertex | Markers off. Gemini caches repeated prefixes implicitly, and cached tokens show up as cacheRead. |
| cerebras | Off. |
| openai (Codex) | Not affected (pi-ai wire format). |
Presets
basic ships:
| Tool | Description |
|---|---|
| shell | Combined stdout and stderr, tail-truncated at 8 KB. maxOutputBytes: 0 disables the limit. |
| readFile | Line range, default 1..2000, 64 KB cap. It adds a paging footer and a binary marker. |
| writeFile | Returns Created, Updated or No change needed: … for no-op detection. |
| edit | Replaces old_string with new_string. Gives clear errors on non-unique or not-found matches (with a nearest-match preview). |
| multiEdit | Atomic edits to one file. All-or-nothing. |
| listFiles | Directory listing. |
| spawn | Sub-agent. |
These tools are opt-in, through import from 'zidane'. They are:
glob(node:fs/promisesglob, which shells out in docker and sandbox contexts)grep(ripgrep with anode:fs/promisesglob fallback, and full Claude Code Grep semantics)createInteractionTool(the human-in-the-loop factory)
skills_use, skills_read and skills_run_script are injected automatically when the skills catalog is non-empty.
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 runThinking
Use named levels or exact budgets. Traces persist as { type: 'thinking', text } blocks and stream through stream:thinking. Anthropic supports thinking natively. OpenRouter, Arcee, Z.ai and Baseten support it through the reasoning_content and reasoning SSE fields.
| Level | Default budget |
|---|---|
| off | disabled |
| minimal | 1,024 |
| low | 4,096 |
| medium | 10,240 |
| high | 32,768 |
| adaptive | model self-budgets |
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 works on Anthropic only. It uses thinking.type='adaptive', which 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 through hookable. Hookable awaits them in registration order. All hooks share one ctx per firing, and the last writer wins. See docs/SKILL.md for the full hook reference table.
Practical examples
// 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}.`
})These hooks are mutable. The fields you can change are in parentheses:
tool:gate(block,reason,result)tool:transform(result,isError)tool:errorandtool:unknown(result)context:transform(messages)system:transformandsystem:before(system)skills:catalog(catalog)mcp:tool:gate(block,reason,result)mcp:tool:transform(result)
All tool hooks include turnId and callId. outputBytes is pre-mutation on *:transform and post-mutation on *:after. Reproduce it with toolOutputByteLength(). ctx.coercions is omitted when no coercion happened. Guard against that with if (ctx.coercions).
Hook recipes
The framework cannot infer these three patterns automatically. Copy them and tune them.
// 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-impact hooks that the framework does not handle automatically. Most production agents end up with one of each.
Decisions
ctx.decide (in every hook) and agent.decide send a typed decision request to a provider's Decisions endpoint (OpenRouter, or OpenAI with a platform sk- key). Each question gets a probability, a choice or a score. A failure never throws. It returns { ok: false, reason }.
agent.hooks.hook('tool:gate', async (ctx) => {
const r = await ctx.decide({
input: JSON.stringify(ctx.input),
questions: { destructive: { type: 'predicate', instructions: 'Does this delete or overwrite data?' } },
})
if (r.ok && r.answers.destructive.type === 'predicate' && r.answers.destructive.probability > 0.8)
ctx.block = true
})By default, Zidane uses the first provider in provider that supports decisions and has the credentials for them. If none has credentials, it uses the first provider that supports decisions, and that provider reports why it is unavailable. Pin one with createAgent({ decisions: { provider, model, timeoutMs } }), or pass decisions: false to disable them. decision:start and decision:end fire for every call. See Decisions.
Per-turn output budget
behavior.toolOutputBudget injects a "summarize before continuing" message when a turn's combined post-tool:transform bytes exceed the cap. It is off by default. Subscribe with budget:exceeded (byte budget) and tool-budget:exceeded (per-tool budget). The per-tool event has the 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. On Anthropic, we recommend the server-side context-management-2025-06-27 beta through anthropic({ extraBetas, contextManagement }). It is token-accurate.
Media has its own controls. Aged media becomes an attachment://<hash> marker on the wire. read_file path="attachment://..." can re-inline it, and session.turns keeps the bytes.
behavior.mediaBudgetkeeps inline images and documents on the wire until a request nears the provider's hard limits (maxItemsblocks ormaxBytesof base64). Then it ages out the oldest media in steps of half the limit. Each age-out rewrites an earlier message and resets the prompt cache. Batching the age-outs keeps long image-heavy sessions cheap.maxItemsdrops to the provider'scapabilities.maxMediaPerRequestwhen that is lower (Bedrock is 20). Built-in chat profiles,runHeadlessandrestateBehavior()use{ maxItems: 80, maxBytes: 20 MiB }.behavior.maxImageEdgeshrinks PNGs larger than N px on their longest edge. It does this once, when they enter the session, with a built-innode:zlibcodec (no native dependency). Those defaults use2000. Past that size, a request with more than 20 images is rejected.behavior.mediaKeepTurnsis the older positional policy. Media older than N messages ages out. It takes precedence overmediaBudgetwhen set, and only runs whenread_fileis registered.
All three are off in the SDK by default.
behavior.autoCompact is loop-native auto-compaction, and it is the heavy lever. Once a turn's effective input crosses threshold (default 0.85) of the context window, the loop runs a real LLM summary over the older turns. It appends a compact-summary marker and file and skill restoration to the session, then continues. This happens mid-run and at the end of a run. The window comes from autoCompact.contextWindow, then provider.meta.contextWindow. Without either, auto-compaction is inert. Observe it with the compact:start and compact:end hooks. It is off by default in the SDK, and chat profiles enable it. It differs from compactStrategy (a per-turn, wire-only tool_result trim) and from the always-on reactive context_exceeded recovery.
Stale-read policy
behavior.elideStaleReads decides what happens when an edit, multi_edit or write_file succeeds on a file the model read earlier in the run:
'notice'is append-only. The mutating tool's own result stores a persisted<system-reminder>that names the stale path. It never rewrites cached prefix bytes, so the prompt cache is untouched.'stub'(or legacytrue) replaces the stale read's output on the wire with a short stub. It reclaims the stale bytes from context. But each newly stale read re-bills the conversation at the cache-write rate. Use it only under real context-window pressure.falseis off. It is the default, and the built-in chat profiles ship it too. Detection is identical in both modes. It uses success markers only, so failed edits and no-op writes stale nothing. Deduplication honesty around mutations holds in every mode. See below.
See docs/ARCHITECTURE.md § Cache-stable configuration for the measured economics.
Read dedup + read-before-edit guard
behavior.dedupReadsis on by default.read_filereturns"unchanged since the previous read"on identical re-reads. It uses a per-session content hash. Mutations are honest by construction. After anedit,multi_editorwrite_filetouches a file, the next re-read returns the full fresh body instead of the stub. Then deduplication resumes.behavior.requireReadBeforeEditis off by default, including in the built-in chat profiles.editandmulti_editreject when the session has not read the file or the file has drifted. The refusal is a recoverabletool_resultthat tells the model toread_filefirst.write_fileseeds read-state, so the write-then-edit flow needs no extra read.behavior.trackShellReadsis off by default. With the gate on, plain shell reads (cat,head,tail,nl,sed -n '<range>p') also count as reads. So the gate stops refusing files the model inspected throughshell.behavior.shellToolNudgesis off by default and on in the chat profiles. When ashellcall does a pure read, a pure search or a scripted file edit, it appends one line. The line points atread_file,greporedit. It does this at mostmaxPerKind(2) times per kind per session.behavior.repeatGuardis off by default in the SDK core and on in the chat profiles. It breaks streaks of identical consecutive tool calls. It blocks the Nth back-to-back identical call (default 4) with a pivot suggestion. It aborts the run at the Mth (default 8) through the agent'sAbortController. The defaults track the built-inshellandread_filetools. Configure it withRepeatGuardConfig.
Tracking and the deduplication 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. To share tracking with subagents without sharing 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(default10). - Unsafe calls act as barriers. They wait for the in-flight calls 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.
The built-in tools read_file, glob, grep, list_files, tool_search, todoread, spawn and skills_read are annotated true. shell is conditional. Commands such as ls, cat, git status and rg fan out. Commands such as rm, npm install, pipes, redirects and command chains all act as barriers. edit, multi_edit, write_file, todowrite, interaction, custom tools and third-party MCP tools default to false (barrier).
A shell error in a concurrent batch cancels its siblings, because 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 through a hasher keyed by canonical name. It requires a session. The hasher contract has three returns with three meanings:
| Return | Meaning |
|---|---|
| non-empty string | Cache key. Equal keys replay the prior result. |
| undefined | Skip deduplication for this call. Tool runs normally. |
| '' or non-string | Treated as undefined (defensive). |
behavior: {
dedupTools: {
todowrite: input => JSON.stringify(input),
run_query: (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>.
behavior: {
toolBudgets: {
todowrite: { max: 6, onExceed: 'steer' },
run_query: { max: 3, onExceed: 'block' },
},
}Pass a function for custom messages: onExceed: ctx => ({ mode: 'steer', message: '...' }). Counts include deduplication hits by design.
Adaptive thinking budget
behavior.thinkingDecay tapers thinking across turns. Late turns are usually checkpoint work where reasoning rarely pays off.
behavior: {
thinkingBudget: 8192,
thinkingDecay: { afterTurn: 5, floor: 1024 }, // step: turns 1-5 → 8192, 6+ → 1024
}When prompt caching is on, use the step form (without factor). thinking.budget_tokens is part of Anthropic's cache key. A request with a different budget reads nothing back and re-bills the entire prefix. We measured this and pinned it in the live suite.
Each distinct value a schedule emits is one full re-bill. The step form costs exactly one per run. The geometric form { afterTurn: 5, factor: 0.5, floor: 1024 } costs one per halving (8192 to 4096 to 2048 to 1024, three in all). A per-turn function like (turn, base) => base / Math.sqrt(turn) silently disables caching for the whole run. Decay is a no-op when thinkingBudget is unset.
Steering and follow-up
agent.steer(msg)injects a message mid-run. The agent delivers it between tool calls.agent.followUp(msg)queues a message for after the run finishes.agent.abort()cancels the whole run.agent.cancelTool(callId, reason?)cancels a single in-flight tool call without aborting the rest of the run. The matching call'sctx.signalflips,tool:cancelledfires, and the wire result becomes the canonicalTOOL_USE_CANCELLED_MESSAGEwithisError: true. Other concurrent calls keep running, and the assistant turn closes normally on the next batch boundary. It returnstruewhen it flipped a live call andfalseotherwise. It is idempotent, so repeats are no-ops.
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. The call returns immediately with a bash_<n> task handle. Standard output streams to a log file that the model can read_file when it wants. 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. The model needs no polling.
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
// ...
})Background mode can be disabled. The framework automatically rewrites the built-in shell tool to hide run_in_background from its schema and description when:
behavior.tasksDiris unset (the host has not wired the log dir), orbehavior.disableBackgroundTasks: trueis set (the host opted out explicitly).
The model never sees the flag in either case, so it wastes no turns discovering that the flag does not work. Hosts that register a custom shell-named tool keep ownership of their spec. The automatic rewrite only fires on the identity-equal framework shell. For explicit control, import createShellTool({ allowBackground }) from zidane.
agent.destroy() sends SIGTERM to the process group of every task that is still running. It awaits the log-stream flush before it tears down the execution handle. See 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.
import { basicTools, definePreset, createSpawnTool } from 'zidane'
definePreset({
name: 'orchestrator',
tools: {
...basicTools,
spawn: createSpawnTool({ maxConcurrent: 5, model: 'claude-haiku-4-5-20251001', thinking: 'low' }),
},
})Pass models (or spawnTool.withModels(catalog) at agent-build time) to let the model pick a model for each spawn. The schema gains a model field. The tool routes entries to the provider that serves them. Large catalogs defer to createModelSearchTool(catalog) instead of bloating the schema. The model for a spawn comes from per-call input.model first, then subagent-type model, then createSpawnTool({ model }). If none is set, the child inherits the parent's. See docs/ARCHITECTURE.md for details.
Interaction tool
Pause the agent and request structured input. No preset includes this tool by default. onRequest can be async, and the agent waits. Return a string or object.
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
Sessions keep turn history and run metadata across calls. Turns persist incrementally, so a crash leaves history up to the last completed turn.
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 })fromzidane/session/sqlite(Bun-only subpath, WAL, per-turn flush)createRemoteStore({ url })(HTTP)createFileMapStore(adapter)(any{ get, save, delete }backend, withturns.jsonlandmeta.json)
Restore a session with await loadSession(store, id). The session hooks are session:start, session:turns and session:end. The end hook always fires and carries turnRange.
To build provider requests outside agent.run(), use session.toMessages({ provider }) instead of session.turns. Raw turns can carry orphan tool_use and tool_result blocks from interrupted runs, partial-result writes or compaction boundaries. If you feed them to a provider, it fails with tool_result must be preceded by a tool_call with the same toolCallId.
toMessages() projects through the same defensive pairing repair that the loop runs at wire-send time. With provider, it also enforces the user-tail invariant for prefill-rejecting models. It is pure and idempotent, and it never mutates session.turns. Do not write the result back through setTurns or appendTurns, because synthetic placeholders contaminate history. Callers that hold raw SessionTurn[] from store.getTurns() can use the standalone variant toWireMessages(turns, opts?).
MCP servers
Connect any MCP server. Tools are namespaced mcp_{server}_{tool}. Connections are lazy (first run()) and reused. All servers bootstrap in parallel.
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).
Hiding bootstrap latency
The first run() still waits on the slowest server. Two controls reduce the wait:
await Promise.all([agent.warmup(), authenticate(), loadConfig()]) // pre-warm manually
const agent = createAgent({ provider, mcpServers, eager: true }) // or kick off automaticallywarmup() runs the MCP connection and skills resolution in parallel. Both are idempotent and concurrency-safe. Failures surface on the next warmup() or run(), not on the eager kickoff. agent.activateSkill(name) also resolves the catalog automatically 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. Use them to attribute cold-start latency per server:
agent.hooks.hook('mcp:bootstrap:end', (ctx) => {
// ctx.name, ctx.transport, ctx.durationMs, ctx.ok
// ok ? ctx.toolCount : ctx.error
})OAuth 2.1
Tag an HTTP MCP server with auth: 'oauth' to use the SDK's OAuth 2.1 client. The client covers PKCE, RFC 7591 dynamic client registration, RFC 9728 resource metadata and token refresh. Zidane provides the storage and redirect halves:
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.Zidane detects OAuth automatically. A server without auth: 'oauth' that returns 401 with 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 through the MCP picker (l to log in, o to log out). Four hooks drive the state: mcp:auth:required, mcp:auth:url, mcp:auth:success, mcp:auth:error.
Progressive tool disclosure
behavior.toolDisclosure: 'lazy' switches MCP tools to a name-only catalog and injects a tool_search native tool automatically. Native and skill tools stay eager. With hundreds of MCP tools, every turn otherwise ships every schema.
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 } },
})The system prompt gains <searchable_tools> with name + description for each lazy tool. tool_search accepts query (substring), names, server and limit. Matches unlock for the rest of the run. A tool:gate middleware refuses dispatch on lazy tools that have not surfaced. This covers custom and mock providers. Production providers also refuse on the server side. The catalog and search results show the wire name. The unlock set keys on the canonical name, so dispatch and session.turns stay alias-stable.
In the default toolSearch.mode: 'unlock', each discovery wave costs one full prompt-cache rewrite. Providers hash tools → system → messages, so growing the tool list (even at the tail) re-bills the system prompt and every message.
toolSearch.mode: 'dispatch' removes that cost. In this mode, surfaced tools are never added to the tool list. Instead, the loop adds a fixed native call_tool { name, arguments } to the eager set. tool_search still returns the schema into the transcript. The loop unwraps the envelope before dispatch. So gating, argument validation against the inner tool's schema, hooks, telemetry and transcript events all see the inner tool as they see a direct call. The persisted assistant turn keeps the wire-valid envelope. Only lazy catalog tools use the envelope. Eager tools are always called directly. In the chat hosts, this is Settings.stableToolList.
To opt out of discovery, set behavior.toolSearch.tool: false. The catalog still emits, and the call-to-action drops. A pre-existing host tool named tool_search shadows the automatic injection. A pre-existing call_tool disables dispatch mode.
Skills
Skills are reusable instruction packages that follow the Agent Skills standard.
my-skill/
SKILL.md # frontmatter + instructions
scripts/ # optional
references/ # optional
assets/ # optional---
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[]:
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. Its .message ends with a recovery hint that points at skills_use({ mode: 'deactivate', name }). The model can then release the skill itself instead of looping on probes.
The default scan paths are {cwd}/.agents/skills, {cwd}/.zidane/skills, ~/.agents/skills and ~/.zidane/skills. The first one found wins. Instructions support !\command``. It runs during resolution, and its output replaces the placeholder.
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
An execution context sets where tools run. It defaults to in-process. Docker isolates. A sandbox runs remotely (E2B, Rivet, custom).
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 SandboxProviderOS sandbox for the process context
The in-process context can wrap the model's shell commands in the OS sandbox
(macOS Seatbelt via sandbox-exec, Linux bubblewrap). Claude Code and Codex
use the same boundary. Writes are confined to cwd, temp dirs, a worktree's
git dir and any extra roots. ~/.ssh, ~/.aws and credential files are
unreadable. Network is allow-all or deny-all. write_file and edit honor the
same roots in-process. The sandbox fails closed when the backend is missing.
import { createProcessContext, resolveWritableRoots } from 'zidane'
createProcessContext({ cwd, isolation: { writableRoots: resolveWritableRoots(cwd), network: 'allow' } })In the TUI and GUI, go to Settings, then Agent. The settings are Sandbox shell
commands, Sandbox network and Sandbox writable paths. See
docs/EXECUTION_CONTEXT.md for scope and limits.
E2B
Zidane ships a ready-made SandboxProvider backed by E2B
behind its own subpath. The e2b SDK only enters your bundle when you opt in
(bun add e2b, peer e2b@^2).
import { createSandboxContext } from 'zidane'
import { createE2BProvider } from 'zidane/contexts/e2b'
// apiKey/domain fall back to E2B_API_KEY / E2B_DOMAIN env vars.
// Point `domain` at a self-hosted / on-prem cluster to use your own E2B.
const execution = createSandboxContext(createE2BProvider({
apiKey: process.env.E2B_API_KEY,
domain: 'e2b.my-company.internal', // omit for E2B's hosted API
template: 'base',
// onDestroy: 'kill' (default) | 'pause' | 'leave'
// 'pause' preserves state; reconnect later with sandboxId: '<id>'
}))From the CLI, --context e2b wires the same provider. It reads E2B_API_KEY and
E2B_DOMAIN from the environment, and --cwd sets the sandbox working directory:
bun start --prompt "..." --context e2b --cwd /home/user/project
# Pause the sandbox at the end of the run so you can reconnect to it later:
bun start --prompt "..." --context e2b --sandbox-on-destroy pause
# Next session: bun start --prompt "..." --context e2b --sandbox <id>State management
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 completeMessage format
Zidane uses one canonical message format. Providers convert it to and from wire formats internally.
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[]. Providers with imageInToolResult: true receive it natively. Other providers receive it through a companion user message. Flatten it with toolResultToText(output).
The external interop converters are fromAnthropic, toAnthropic, fromOpenAI, toOpenAI and autoDetectAndConvert (re-exported from zidane). The wire-safety helpers are toWireMessages(turns, opts?), ensureToolResultPairing(messages, opts?) and ensureEndsWithUserMessage(messages, provider). Apply them when you send outside agent.run(), to avoid orphan tool_use 400s. See Sessions.
Typed errors
Zidane wraps provider failures before they leave agent.run(). Match on instanceof, not strings. Every provider ships classifyError(err). Unrecognized errors fall through as AgentProviderError. Abort paths (agent.abort() or AbortSignal) always produce AgentAbortedError.
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, behavior.maxTotalTokens or both to trip the budget circuit breaker (a post-turn check and a soft cap). See docs/ARCHITECTURE.md § run-level budget.
Retry-with-backoff (new default)
Transient provider failures retry with exponential backoff and jitter by default. These failures are 429, 5xx, Anthropic overloaded_error mid-stream, OpenAI-compat stream truncation, and pi-ai rate-limit or unavailable messages. The default policy is 3 attempts, with delays of 1s, 2s and 4s, full jitter, and a cap of 30s per delay. Retries honor the retry-after and retry-after-ms headers. On a terminal failure, the worst-case run latency is about 2× maxDelayMs (so about 60s) before the error surfaces.
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 is skipped when any output has already streamed this turn, because replaying over partial text corrupts the transcript. Observe retries with 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 through provider-level tool forcing. The result lands on stats.output and fires the output hook (ctx.output, ctx.schema).
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 with zodToJsonSchema(z.toJsonSchema(schema)). This strips $schema, which some providers reject.
Usage tracking
stats.totalIn, stats.totalOut and stats.cost are cumulative (parent and recursive children). stats.turns and stats.turnUsage cover the parent loop only. Use the helpers for tree-wide breakdowns.
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)flattens content for logging.toolOutputByteLength(content)uses the same formula asoutputBytes.validateToolArgs(input, schema)is the loop's validator.
Testing & benchmarks
bun run test # the core suite (test/): every file in parallel, throwaway HOME
bun run test:all # plus extensions/
bun run test:timings # refresh test/timings.json, the per-file durations that order and shard runs
bun run typecheck # bun check (TypeScript 7 rules); typecheck:tsc runs the TypeScript 6 compiler
bun run lint # ESLint, cached per eslint.config.ts + bun.lockThe suite has 9,000+ tests with a mock provider and execution context. It takes about 30 s on a laptop and needs no API keys or Docker. Each file runs in a fresh global, so nothing one file leaves behind reaches another. Plain bun test is the serial, single-global run for debugging.
Benchmark harnesses live in benchmarks/. The first integration is Terminal-Bench.
Docs
The full index, with a "where does X live" map, is in docs/README.md.
| Area | Doc | What |
|---|---|---|
| Core SDK | SKILL.md | createAgent, providers, the complete hooks table, tools, sessions, skills, MCP, spawning. The API reference. |
| Core SDK | ARCHITECTURE.md | Agent loop internals: lifecycle, turn and tool flow, hook order, built-in tool defaults and cache-stable config. |
| Core SDK | EXECUTION_CONTEXT.md | --context process\|docker\|e2b\|daytona sets where shell and file operations run. The doc also covers what each context isolates. |
| Core SDK | RUN_IN_BACKGROUND.md | Background shell tasks, including run_in_background, wait_task, notifications, teardown and subagent reassignment. |
| Core SDK | INTERACTIONS.md | present_plan and ask_user pause the run for user input. |
| Core SDK | HEADLESS.md | zidane/headless offers one-shot runHeadless() and a CLI for automation and RL. |
| Chat and surfaces | CHAT.md | zidane/chat is the renderer-agnostic engine. It covers config, sessions, settings, safe mode, todos, commands and completion. |
| Chat and surfaces | TUI.md | zidane/tui is the terminal shell. It covers screens, keybindings, modals, theming and file layout. |
| Chat and surfaces | IPC.md | zidane/ipc is the shared chat host and daemon. It covers every RPC and signal, and TUI and GUI parity. |
| Chat and surfaces | SERVER.md | zidane-server runs the chat host on a remote machine. It covers the GHCR image and release binaries, Coolify, TLS and proxy, tokens, security and operations. |
| Chat and surfaces | MACHINES.md | Machines. A computer lends a host its folders and shell (local@laptop, zidane machine share, "share this computer"). Or a server lends itself to a local host (local@server). It also covers the protocol, offline semantics, project files, sandbox and security. |
| Chat and surfaces | ARTIFACTS.md | Session artifacts are what a session produced (PRs, branches, commits, files). It covers the model, producers, surfaces and IPC. |
| Chat and surfaces | EXTENSIONS.md | User extensions. It covers defineExtension, the full ctx API, discovery, installing and authoring patterns. |
| Integrations | PLUGINS.md | zidane/plugins is the Agent Plugins 1.0.0 loader (skills and MCP servers as portable packages). |
| Integrations | ACP.md | zidane/acp is the Agent Client Protocol adapter for editors (Zed and others). |
| Integrations | RESTATE.md | zidane/restate provides durable execution. It journals LLM and tool calls and has strict defaults. |
License
MIT
