Agents
Durable LLM agents: every model call and tool execution is a journaled workflow step — crash recovery, retries without re-prompting, human approval gates, and dashboard traces for free.
Agents are workflows#
An agent loop — model call → tool calls → model call → answer — is a durable workflow whose steps are generated dynamically. That single design decision is where every guarantee comes from:
// What the journal looks like for one agent run:
//
// session (kind: run) history snapshot
// llm-0 (kind: run) full model response, journaled
// tool-0-tu_abc (kind: run) tool result, retried w/ backoff on throw
// llm-1 (kind: run) next round-trip
// save-session (kind: run)
//
// Crash anywhere → resume fast-forwards through recorded steps.
// A flaky tool retries WITHOUT re-calling the model: llm-0 is already
// journaled. Verified by test: tool fails twice, provider.calls === 2.- Tool failures never re-prompt. The model response is journaled before tools run; retries replay it.
- Crashes resume. A deploy mid-conversation continues at the exact step durability last advanced.
- Everything is observable. Agent runs appear in the dashboard as
agent:<name>workflows — every prompt, tool call, and token count in the step detail.
Defining an agent#
import { anthropic, tool, zenzip } from "zenzipjs";
const app = zenzip();
const searchDocs = tool({
name: "search_docs",
description: "Search the help center.",
parameters: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
execute: async ({ query }) => helpCenter.search(query),
});
const support = app.agent("support", {
provider: anthropic(), // ANTHROPIC_API_KEY from env
model: "claude-sonnet-4-6",
instructions: "You are a support agent. Use the tools.",
tools: [searchDocs],
maxIterations: 8,
});
await app.start();
const result = await support.run("My order is late!", { sessionId: "cus_42" });
// → { text, usage: { totalTokens }, iterations, toolCalls }Tools#
tool() takes JSON-Schema parameters (what the model sees) and an optional Standard Schema schema (runtime validation of the model's arguments — invalid input throws into the retry path). Results are JSON-serialized back to the model. Throwing marks the step failed: retried with backoff per stepRetries, then the run fails with a precise error.
If a tool fetches a model- or user-supplied URL, guard it with assertPublicUrl(url) before fetching — it resolve-then-validates the host and rejects private / loopback / link-local / cloud-metadata targets (SSRF). The built-in mcp(url) takes ssrf: true to apply the same guard.
MCP tools#
mcp(url)connects to a Model Context Protocol server over Streamable HTTP, lists its tools, and returns them as ZenZip agent tools. Each call runs inside the agent's journaled tool step — so an MCP call is durable and retried like any other step. Connecting is async; spread the result into tools.
import { mcp } from "zenzipjs";
// CONSUME — connect to an MCP server and use its tools. Connecting is
// async, so spread the result into the agent's tools:
const app = zenzip();
app.agent("research", {
model: anthropic(),
tools: [
...(await mcp("https://mcp.example.com/")), // every MCP tool
...(await mcp({ url: "https://other/", prefix: "x_", headers: { authorization }, ssrf: true })),
searchDocs, // local tools alongside
],
});The reverse direction works too: app.mcpServer() exposes your own workflows and agents as an MCP server, so other agents can call them durably — a workflow tool triggers a run, an agent tool runs the agent.
// AUTHOR — expose this app's workflows + agents AS an MCP server,
// so other agents can call them durably.
await app.start();
await app.mcpServer({ port: 4200, token: process.env.MCP_TOKEN });
// workflows: true | ["only", "these"] — what to expose (default: all)
// agents: true | ["only", "these"]
// wait: "60s" | false — triggerAndWait, or return { runId }
// Or embed the endpoint in an existing server instead of a standalone one:
http.createServer(app.mcpHandler()).listen(4200);Human-in-the-loop#
const sendRefund = tool({
name: "send_refund",
description: "Refund an order.",
parameters: { type: "object", properties: { orderId: { type: "string" } } },
requiresApproval: true, // ← durable pause before every call
execute: async ({ orderId }) => payments.refund(orderId),
});
// The run suspends in waitForEvent — zero resources, survives restarts.
// From your admin panel / Slack bot / REPL:
support.approve(runId, toolUseId);
support.deny(runId, toolUseId, "amount too high");
// Timeout (default 1h) resolves as denied; the model is told either way.The pause is a waitForEvent with a match predicate on (runId, toolUseId) — it holds across restarts and deploys, and the operator decision arrives as a normal event.
Session memory#
await support.run("My name is Ada.", { sessionId: "cus_7" });
await support.run("What's my name?", { sessionId: "cus_7" });
// → "Your name is Ada." — history persisted in the store (windowed)
const transcript = await support.session("cus_7"); // LlmMessage[]Sessions persist in the embedded store (historyWindow most-recent messages, default 20). The history snapshot is itself a journaled step, so resumed runs see the conversation exactly as it was when the run started.
Tiered memory#
Session memory is the recent window. Tiered memory adds two more tiers, opt-in via memory: semantic recall (embed + retrieve the most relevant past facts and inject them into the prompt) and working memory(compress old turns into a summary so long sessions keep fitting the context window). Recall + remember run inside durable steps, so they're journaled and never re-run on replay.
import { AgentMemory, openaiEmbeddings } from "zenzipjs";
const memory = new AgentMemory({
embeddings: openaiEmbeddings({ apiKey: process.env.OPENAI_API_KEY }),
// store: new PgVectorStore(...), // default is in-memory (process-local)
topK: 4,
provider, model, // enables working-memory compression
});
const agent = app.agent("support", { provider, model, memory });Durable recall needs a shared store
The default InMemoryVectorStore is process-local and lost on restart. For production durable, cross-node recall, implement the MemoryStore interface over pgvector or a vector database.
Multi-agent handoff#
import { handoffTool } from "zenzipjs";
const researcher = app.agent("researcher", { provider, model, tools: [webSearch] });
const planner = app.agent("planner", {
provider,
model,
tools: [handoffTool(researcher)], // exposes ask_researcher to the model
});
// The child runs as a durable child workflow (step.invoke): own journal,
// own retries; cancelling the parent cancels it.Multi-agent networks#
handoffTool is 1:1 delegation. A network is the 1:N generalization: a coordinator that routes each request to the right specialist — and may consult several in sequence — then composes the final answer. The coordinator is itself a durable agent whose tools are handoffs to every member, so each delegation runs as a child workflow with its own journal, retries, and cancellation propagation.
import { zenzip } from "zenzipjs";
const app = zenzip();
const billing = app.agent("billing", { provider, model, instructions: "Refunds + invoices." });
const tech = app.agent("tech", { provider, model, instructions: "Troubleshooting." });
const support = app.network("support", {
provider, model,
agents: [billing, tech], // or { agent, description } for richer routing
maxHandoffs: 4, // routing hops before it must answer
});
await app.start();
const res = await support.run("I was double-charged and the app crashes");
// → coordinator delegates to billing (and/or tech), then summarizesnetwork.run() / network.trigger()behave like an agent's. Routing is one-way (coordinator → specialist); the per-run iteration cap bounds hops, so a misrouting can't loop forever.
Circuit breakers#
When an LLM provider (or any external dependency) starts failing, retrying just piles load onto a service that is already down. A circuit breaker opens after a threshold of failures and makes subsequent calls fail fast, then probes for recovery (half-open → closed). Set one on an agent to guard its model calls:
const agent = app.agent("support", {
provider, model,
circuitBreaker: {
failureThreshold: 5, // consecutive failures trip it open
resetTimeout: "30s", // then allow a probe (half-open)
maxConcurrent: 20, // bulkhead: cap in-flight calls
},
});The breaker is process-local and shared across the agent's runs — it protects the live process, so it is deliberately not journaled (a retried step re-evaluates the live circuit). The same primitive is exported for wrapping your external calls — third-party HTTP in a tool, a webhook fan-out, anything:
import { circuitBreaker, CircuitOpenError } from "zenzipjs";
const payments = circuitBreaker({ failureThreshold: 3, resetTimeout: "10s" });
try {
const res = await payments.run(() => fetch("https://payments.example/charge"));
} catch (e) {
if (e instanceof CircuitOpenError) {
// fail fast — Stripe is down, don't hammer it
}
}Structured output#
import { z } from "zod";
const classifier = app.agent("classifier", {
provider,
model: "claude-sonnet-4-6",
output: z.object({ sentiment: z.enum(["pos", "neg"]), score: z.number() }),
});
const { output } = await classifier.run("Classify: great product!");
// output is parsed + validated; one corrective round on invalid JSON,
// then the run fails with the validation error.Evals#
Score outputs to gate deploys and regression-test prompts the way you unit-test code. Evaluators are pure functions over a sample — rule-based (contains, matches, equals, jsonValid), statistical (similarity), or model-graded (llmJudge, a separate LLM scores against a rubric). Aggregate one sample with evaluate() or a whole suite with runEvals().
import { runEvals, contains, jsonValid, llmJudge, anthropic } from "zenzipjs";
const judge = llmJudge({
provider: anthropic(), model: "claude-sonnet-4-6",
rubric: "Answers the question accurately and politely.",
threshold: 0.7,
});
const report = await runEvals(
cases.map((c) => ({ input: c.q, output: await agent.run(c.q).then((r) => r.text) })),
[contains("sorry", { ignoreCase: true }), jsonValid(), judge],
);
if (!report.passed) throw new Error(`eval gate failed: ${report.passRate * 100}% passed`);Providers#
import { anthropic, openaiCompatible, mockProvider, mockText, mockToolUse } from "zenzipjs";
anthropic({ apiKey }) // Messages API, tool use, SSE
// streaming, prompt caching on
// system + tools
openaiCompatible({ baseUrl, apiKey }) // OpenAI / Ollama / vLLM /
// OpenRouter — function calling
googleGemini({ apiKey }) // Gemini generateContent +
// function calling
bedrock({ region, accessKeyId, // Anthropic Claude on AWS Bedrock,
secretAccessKey }) // SigV4-signed
mockProvider([ // deterministic tests, offline dev
mockToolUse("search", { q: "x" }, { id: "tu_1" }),
mockText("final answer"),
])Providers are ~150 lines of fetch each — the durability machinery lives below them, so adding one means mapping messages, not rebuilding reliability. Streaming: pass onToken to agent.run(); live runs stream (Anthropic SSE), memoized replays never re-stream.
Cost accounting: every agent.run() result carries usage (token counts) and costUsd — an estimate from a built-in per-model price table. Prices drift; override with registerPricing("model-prefix", { input, output }) (USD per 1M tokens), or compute directly via costOf(model, usage).
Options#
| Option | Type | Default | Description |
|---|---|---|---|
| provider | LlmProvider | — | anthropic() / openaiCompatible() / mockProvider() / yours. |
| model | string | — | Passed through to the provider. |
| instructions | string | — | System prompt (cached on Anthropic). |
| tools | AgentTool[] | — | Available tools, incl. handoffTool(). |
| maxIterations | number | 10 | Hard cap on model round-trips; exceeding fails the run. |
| maxTotalTokens | number | — | Token budget across the run; exceeding fails the run. |
| maxTokens | number | 4096 | Per-call output cap. |
| historyWindow | number | 20 | Session messages kept. |
| approvalTimeout | Duration | "1h" | Approval gates resolve as denied after this. |
| output | StandardSchemaV1 | — | Validate the final answer as JSON (one corrective round). |
| stepRetries | number | 2 | Retries per step (tools, model calls). |
| lease | Duration | "5m" | Crash-redelivery horizon — covers slow model calls. |
| circuitBreaker | CircuitBreakerOptions | — | Fail-fast guard around model calls: failureThreshold, resetTimeout, halfOpenMax, maxConcurrent. |
| Run API | What it does |
|---|---|
agent.run(msg, opts) | trigger + wait; throws on failure; onToken streams |
agent.trigger(msg, opts) | fire-and-forget durable run (idempotencyKey supported) |
agent.approve / deny | resolve a pending approval gate |
agent.getRun / cancel | inspect / cancel (children included) |
agent.session(id) | stored conversation |
Try it offline
examples/support-agent runs the full search → approval → refund flow with a scripted mock provider — no API key — and shows it live in the dashboard. Set ANTHROPIC_API_KEY to run the same demo on Claude.