ZenZip logozenzip

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#

support.ts
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.

mcp.ts
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.

mcp-server.ts
// 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#

approval.ts
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#

sessions.ts
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.

memory.ts
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#

handoff.ts
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.

network.ts
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 summarizes

network.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:

agent.ts
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:

breaker.ts
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#

classifier.ts
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().

evals.ts
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#

providers.ts
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#

OptionTypeDefaultDescription
providerLlmProvideranthropic() / openaiCompatible() / mockProvider() / yours.
modelstringPassed through to the provider.
instructionsstringSystem prompt (cached on Anthropic).
toolsAgentTool[]Available tools, incl. handoffTool().
maxIterationsnumber10Hard cap on model round-trips; exceeding fails the run.
maxTotalTokensnumberToken budget across the run; exceeding fails the run.
maxTokensnumber4096Per-call output cap.
historyWindownumber20Session messages kept.
approvalTimeoutDuration"1h"Approval gates resolve as denied after this.
outputStandardSchemaV1Validate the final answer as JSON (one corrective round).
stepRetriesnumber2Retries per step (tools, model calls).
leaseDuration"5m"Crash-redelivery horizon — covers slow model calls.
circuitBreakerCircuitBreakerOptionsFail-fast guard around model calls: failureThreshold, resetTimeout, halfOpenMax, maxConcurrent.
Run APIWhat 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 / denyresolve a pending approval gate
agent.getRun / cancelinspect / 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.