Agent Development Kit · Start · start here

Build an agent, run it in this page

The ADK is a TypeScript framework for production multi-agent systems: schema-first, event-sourced, provider-agnostic. The cells below are live editors running the shipped runtime. Paste your OpenAI key, press Run, edit anything, run again. Steps 1 to 3 go to OpenAI; the testing aside runs the same code with the model scripted. No key handy? The cells read as plain code, and everything they print is described beside them.

Audience · engineers building agents Needs · an OpenAI API key (stays in this browser) Package · @animahealth/adk (MIT)

Your key

Paste a key, run everything

This site is static — no backend, no proxy. Your key lives in this browser's localStorage and requests go straight to api.openai.com, exactly as they would from your own machine.

Step 1

An agent with a tool

The whole program: one tool with a Zod schema, one agent that carries it, one run. The model reads the tool's description and decides to call it. Your execute computes the answer in this page. This is the same code you would run locally after npm install @animahealth/adk. Building costs nothing and needs no key: this cell prints the agent's name.

import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { z } from 'zod'

const app = adk()

const calculator = app.tool({
  name: 'calculate',
  description: 'Evaluate a mathematical expression',
  schema: z.object({ expression: z.string() }),
  execute: (ctx) => {
    const sanitized = ctx.args.expression.replace(/[^\d\s+*/().-]/g, '')
    return { result: Function(`"use strict"; return (${sanitized})`)() }
  },
})

const assistant = app.agent({
  name: 'math_assistant',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('Use the calculator for arithmetic.'), app.context.history()],
  tools: [calculator],
})

assistant.name

Now run it. The model chooses to call the calculator, and your code does the arithmetic. What prints is one short assistant sentence carrying the computed number. Edit the question and run again.

const run = await app.run(assistant, 'What is 731 * 268, minus 17?')

run.output.text

Step 2

What it cost

Nothing about a run is opaque. Tokens, model calls, and cost come straight off the run's model_end events. This cell reads the run bound in step 1, so that cell has to have been pressed, with a key. It prints a usage object: input and output token counts, two model calls, and an estimated cost.

run.usage

Step 3

The ledger the run left behind

Everything the agent did is an append-only event history. It belongs to the session, not to the run: a run is one pass over it. So the full ledger is run.session.events, and run.stepEvents is this run's slice of it. This cell reads the same run from step 1. Note the tool_call and tool_result pair: the model decided, your code executed. Each model_start/model_end pair brackets one model call. A tool-using turn makes two round-trips — one to decide the call, one to answer with its result — which is why run.usage reports two model calls. On a reasoning model, the summary lands as a thought event.

run.session.events.map((event) => event.type)

Aside

Testing the same agent, no key

The test kit replaces only the model with scripted turns. The tool still executes and the ledger still accrues. So agent tests run deterministically in CI, with no credentials. This cell needs the build cell from step 1 pressed once, and no key at all. It prints the tool results the scripted run produced. Skip it if you only came to build.

import { getToolResults, runTest, user, model } from '@animahealth/adk/testing'

// The assistant from step 1, with the model's turns scripted: the script decides THAT the
// calculator is called; the calculator itself really runs.
const test = await runTest(assistant, [
  user('What is 134 divided by 4?'),
  model({ toolCalls: [{ name: 'calculate', args: { expression: '134 / 4' } }] }),
  model('134 divided by 4 is 33.5.'),
])

getToolResults(test.events)

Where to go next

Three doors out of here

Every chapter behind this one teaches the same way: by running the code.

Agent Development Kit · Start · after the quickstart

Five primitives and one ledger

The quickstart ran one agent. A program is usually several — a classifier, a worker, a check, a retry — and the ADK gives you exactly five runnable kinds to compose them: agent, step, sequence, parallel, loop. They all write to one append-only event ledger, held by one session. Every cell below runs the shipped runtime with the model scripted, so nothing here needs a key.

Cells · scripted model, no key required Source · src/types/runnables.ts Reads after · Quickstart

The vocabulary

Everything the runner executes is one of five shapes

Runnable is a union of five interfaces. Each carries a kind and a name, and the runner switches on kind — there is no sixth case and no escape hatch. The factories on your app (app.agent, app.step, app.sequence, app.parallel, app.loop) return plain objects. A runnable is data: you can build it, nest it, and pass it around before anything runs.

Kind Carries What the runner does with it
agent model, context, tools Calls the model in a loop until it stops asking for tools. The only kind that talks to a provider.
step execute(ctx) Runs your TypeScript. No model, no prompt. May return another runnable to delegate to.
sequence runnables Runs them in order on the same session, stopping early on error or yield.
parallel runnables, merge, minSuccessful Runs branches concurrently on cloned sessions, then merges their new events back.
loop runnable, while, maxIterations Repeats one runnable while the condition holds, capped by the iteration limit.

Any of them may hold any of them: a loop over a sequence of agents, a parallel of loops, a step that returns whichever agent the data calls for.

Step 1

A sequence of two agents, sharing one session

mockAgent(name) is an agent whose model is scripted — it lets a page like this one show composition without a key. The scripted replies are handed out in the order the model is actually called. So a sequence of two agents consumes two model() steps: one turn each.

import { adk, isModelEndEvent } from '@animahealth/adk'
import { mockAgent, runTest, user, model, getLastAssistantText } from '@animahealth/adk/testing'

const app = adk()

const triage = mockAgent('triage')
const reply = mockAgent('reply')

const intake = app.sequence({ name: 'intake', runnables: [triage, reply] })

const intakeRun = await runTest(intake, [
  user('My laptop will not boot.'),
  model('Category: hardware.'),
  model('Hold the power button for ten seconds, then plug in the charger.'),
])

const intakeSummary = {
  status: intakeRun.status,
  modelCalls: intakeRun.events.filter(isModelEndEvent).length,
  lastReply: getLastAssistantText(intakeRun.events),
}

intakeSummary

Two model calls, one status, and the last reply is the second agent's. Nothing was passed from triage to reply by hand. They share a session, and the second agent reads the first agent's answer out of it — which is what the next section is about.

Step 2

One ledger, and both agents are in it

A session is an append-only list of events. Print it with the agent that wrote each one and the structure of the run falls out. The sequence opens an invocation, each child agent opens its own inside it, and the model calls and replies land between the brackets.

intakeRun.events.map((e) => [e.type, e.agentName].filter(Boolean).join(' · '))

Note what is not there: no per-agent history, no message bus, no copy of the conversation handed from one agent to the next. There is one list. Every event carries the invocationId that produced it and the agentName that wrote it, so the nesting is recoverable from the flat list alone. Session state is computed from these events rather than stored beside them.

Where to reach for it. The ledger belongs to the session: run.session.events. run.stepEvents is this run's slice of it, and the test kit's TestResult.events is an alias for session.events — which is what the cells on this page print. There is no run.events. The same test result also carries TestResult.result: the RunResult you get by awaiting app.run, with its output, usage, status and session.

Step 3

A step is your code, in the same session

Not every node needs a model. A step receives a context with the session, typed state, ctx.note() for annotations, ctx.output() to record the step's own output value, and the signals ctx.skip(), ctx.fail(message) and ctx.respond(text). Return a runnable from execute and the step delegates to it — that is how routing is written here.

import { isAnnotationEvent, isAssistantEvent } from '@animahealth/adk'

const audit = app.step({
  name: 'audit',
  execute: (ctx) => {
    const authors = ctx.session.events.filter(isAssistantEvent).map((e) => e.agentName)
    ctx.note(`${authors.length} replies from ${authors.join(', ')}`, { kind: 'mark' })
  },
})

const audited = app.sequence({ name: 'audited_intake', runnables: [triage, reply, audit] })

const auditedRun = await runTest(audited, [
  user('My laptop will not boot.'),
  model('Category: hardware.'),
  model('Hold the power button for ten seconds.'),
])

auditedRun.events.filter(isAnnotationEvent).map((e) => e.message)

The step read the two agents' replies straight off the ledger, then appended an annotation event of its own. Narrowing the union is a filter, because the package exports a type guard per event type. The sequence now has three children but still consumes two scripted replies: a step never calls a model, so it emits no model_start/model_end pair. Deterministic work — parsing, gating, fetching, scoring — belongs in a step, where it costs no tokens and cannot hallucinate.

Step 4

Repeat one, or run several at once

A loop takes a single runnable, a while predicate over { iteration, lastResult, state, session }, and a required maxIterations so a bad predicate cannot run forever. A parallel takes several runnables, clones the session for each branch, runs them concurrently, and merges each branch's new events back into the parent ledger. failFast, branchTimeout, minSuccessful, and a custom merge are there when the default concatenation is not what you want.

const drafter = mockAgent('drafter')

const revise = app.loop({
  name: 'revise',
  runnable: drafter,
  maxIterations: 3,
  while: (ctx) => ctx.iteration < 3,
})

const loopRun = await runTest(revise, [
  user('Draft a subject line.'),
  model('Draft 1'),
  model('Draft 2'),
  model('Draft 3'),
])

const sentiment = mockAgent('sentiment')
const wordcount = mockAgent('wordcount')

const analysis = app.parallel({ name: 'analysis', runnables: [sentiment, wordcount] })

const fanRun = await runTest(analysis, [
  user('Two reviews came in.'),
  model('positive'),
  model('nine words'),
])

const branching = {
  drafts: loopRun.events.filter(isAssistantEvent).map((e) => e.text),
  branches: fanRun.events.filter(isAssistantEvent).map((e) => `${e.agentName}: ${e.text}`),
}

branching

Three iterations, three drafts, one session. Two branches, two replies, still one session — the branch sessions are clones, and only the events they produced come back. The merge goes into the same append-only list, so a parallel is not a special result type you have to unpack. Whatever the branches did is simply in the ledger afterwards.

Step 5

The ledger is history — the prompt is rendered from it

An agent's context is an array of renderers applied in order. The render context they receive starts with an empty event list. Nothing reaches the model until a renderer puts it there. app.context.system(…) adds an instruction, and app.context.history() pulls events out of the ledger — its scope decides which ones. This runs again before every model call. Put a recorder of your own in the array and you can watch it happen.

import { openai } from '@animahealth/adk/openai'

const rendered: { at: string; sees: string[] }[] = []
const recordAs = (at: string) =>
  app.context((ctx) => {
    rendered.push({ at, sees: ctx.events.map((e) => e.type) })
    return ctx
  })

const scribe = app.agent({
  name: 'scribe',
  model: openai('gpt-5.6-luna'),
  context: [
    recordAs('renderers start'),
    app.context.system('Answer in one sentence.'),
    app.context.history(),
    recordAs('history added'),
  ],
})

const twice = app.loop({
  name: 'twice',
  runnable: scribe,
  maxIterations: 2,
  while: (ctx) => ctx.iteration < 2,
})

const scribeRun = await runTest(twice, [
  user('What is the ledger for?'),
  model('It is the append-only history of everything that happened.'),
  model('Every runnable in the program writes to it.'),
])

const seam = { renderedPerModelCall: rendered, ledgerLength: scribeRun.events.length }

seam

Four entries, two per model call: empty at the start of the renderer chain, populated after history(). The second call's context contains the first call's reply, because by then it was in the ledger. The agent's model config never changed; the input to it was rebuilt from scratch, twice.

The ledger records what happened in full, and the prompt is a projection of it chosen per agent and per turn — a renderer decision, taken apart in Context: what the model sees. What that ledger buys you once it is persisted belongs to Sessions and state.

Agent Development Kit · Start

The next hour, recipe by recipe

You have run an agent in this page. These six recipes move it onto your machine, clone the sample that already does the hard part, give the agent the web, put it behind a browser UI and a terminal UI, and name the errors you will actually hit. Every shell command here is real and every quoted error is the string the package throws.

Audience · engineers 15 minutes into a hackathon Needs · Node ≥ 22; a key only for the live steps Package · @animahealth/adk (MIT)

Recipe 1

From this page to a project on your machine

Five commands. The ADK needs Node 22 or newer, and the code below uses top-level await, so the project is ESM.

mkdir math-agent
cd math-agent
npm init -y
npm pkg set type=module
npm install @animahealth/adk zod
npm install -D tsx typescript @types/node

zod is the ADK's one required peer, so your package manager installs it anyway — naming it makes your own import { z } from 'zod' honest. You do not need the OpenAI SDK to run: @animahealth/adk/openai ships it inside that subpath's bundle, which is why the cells on this site reach api.openai.com with nothing else installed. A TypeScript project does need it, because that subpath's type declarations reference the SDK's types — so either npm install -D openai or set skipLibCheck: true (the shipped sample does). The one path that loads the real SDK at runtime is the deprecated core-entry re-export, import { openai } from '@animahealth/adk'; the subpath is the supported import.

Then the key. It is read from the environment at the first model call, not at import — the adapter builds its endpoint list right before it needs one.

export OPENAI_API_KEY=sk-...

Now the program. This is the body of agent.ts, and it is the same agent this site's quickstart ran: one tool with a Zod schema, one agent that carries it. Press Run — the cell builds it here too.

import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { z } from 'zod'

const app = adk()

const calculator = app.tool({
  name: 'calculate',
  description: 'Evaluate a mathematical expression',
  schema: z.object({ expression: z.string() }),
  execute: (ctx) => {
    const sanitized = ctx.args.expression.replace(/[^\d\s+*/().-]/g, '')
    return { result: Function(`"use strict"; return (${sanitized})`)() }
  },
})

const assistant = app.agent({
  name: 'math_assistant',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('Use the calculator for arithmetic.'), app.context.history()],
  tools: [calculator],
})

assistant.name

A file needs two more lines than a cell does — the cell prints its last expression, your program has to say so:

const run = await app.run(assistant, 'What is 731 * 268, minus 17?')
console.log(run.output.text)
npx tsx agent.ts

That is the whole loop: no server, no config file, no framework directory. The last thing worth adding before you build on it is a test that needs no key — npm install -D vitest, then npx vitest run, with the model's turns scripted while your tools really execute. The testing chapter has the whole kit.

Recipe 2

Clone the Bookings sample and run it

Bookings is a slot-booking assistant that offers an appointment and then stops: booking is a yielding tool, so the session becomes a row in a SQLite file and a later command supplies the approval. It lives under sample/ and depends on the ADK beside it ("@animahealth/adk": "file:.."), so the repository root is built once first:

git clone https://github.com/mycontinuum-com/adk.git
cd adk
pnpm install && pnpm run build
cd sample
npm install
npx tsx src/cli.ts ask "I need physio on Tuesday afternoon, for Alex Doe"

ask calls a model, so export OPENAI_API_KEY first — then walk the three sources, the resume commands, and the suite that proves the pause with no key in the Bookings sample.

Recipe 3

Give the agent the web

Three web tools ship in the core entry as factories on app.tools. Each returns an ordinary tool, so it goes in the same tools: array your own tools do. The model sees them as web_search, fetch_page and take_screenshot.

const researcher = app.agent({
  name: 'researcher',
  model: openai('gpt-5.6-luna'),
  context: [
    app.context.system('Search before you answer. Fetch a page when the snippet is not enough.'),
    app.context.history(),
  ],
  tools: [
    app.tools.webSearch({ numResults: 5, searchType: 'web', country: 'GB' }),
    app.tools.fetchPage({ render: true }),
  ],
})

Search goes through Serper, and its key comes from SERPER_API_KEY. The provider is constructed when you build the tool, so a missing key throws while the agent is being assembled — before any model call, which is the good time to find out. Pass one explicitly instead with app.tools.webSearch({ provider: new SerperProvider(key) }), importing SerperProvider from @animahealth/adk/web.

fetch_page takes one URL or an array, and returns web pages as markdown, PDFs as documents and images as images — the media rides back with the tool result, so a vision model can read it. Its markdown conversion is done by three optional peers you have to install: npm install jsdom @mozilla/readability turndown. render: true and take_screenshot additionally need playwright plus npx playwright install, and screenshots need sharp. Install nothing and the failure is quiet — see recipe 6. The web tools chapter covers the configs; for tools someone else wrote, the ADK speaks MCP.

Recipe 4

Stream it into a browser

app.handler.agui({ agent }) returns a function from one request to an async iterable of AG-UI events — the protocol the off-the-shelf chat frontends already speak. Write each event down an SSE response and a browser has a streaming UI without you inventing a message format. Install the protocol's types first: npm install @ag-ui/core (an optional peer, pinned to one exact version in the package's peerDependencies).

import { createServer } from 'node:http'

// One handler, built once and called per request. The app's store and hooks come with it.
const stream = app.handler.agui({ agent: assistant })

createServer(async (request, response) => {
  const url = new URL(request.url ?? '/', 'http://localhost')

  response.writeHead(200, {
    'content-type': 'text/event-stream',
    'cache-control': 'no-cache',
    connection: 'keep-alive',
  })

  for await (const event of stream({
    // No session id starts a new session; passing one back continues that conversation.
    sessionId: url.searchParams.get('session') ?? undefined,
    input: { message: url.searchParams.get('q') ?? '' },
  })) {
    response.write(`data: ${JSON.stringify(event)}\n\n`)
  }

  response.end()
}).listen(3000)

The browser end is three lines, because EventSource is doing the work:

const events = new EventSource(`/agent?q=${encodeURIComponent(question)}`)

events.onmessage = (message) => render(JSON.parse(message.data))

The event vocabulary is catalogued in serving it; the part that matters here is the pause — a run that stops at a yielding tool sends a CUSTOM event named RUN_INTERRUPTED, carrying reason: 'tool_yield' with the tool's name and arguments (or reason: 'input_required' when the agent is waiting for the next message), and then emits RUN_FINISHED twice, only the second of which carries result.

Two siblings, same config object. app.handler.rest({ agent }) awaits the whole turn and returns one JSON object — sessionId, status, output, yieldedTools when it paused, and optionally events, state and usage. app.handler.turn({ agent }) hands you the raw ADK event stream if you would rather define your own wire format. All three commit the session after every turn, so a run that pauses is durable the moment the request ends.

Recipe 5

Drive it from a terminal

The ADK ships an interactive terminal UI: app.cli(runnable), optionally with a first message. It is not a command you install — it is a call your program makes, so the agent you run in it is the one you ship.

// chat.ts — the agent from recipe 1, with a terminal in front of it.
app.cli(assistant, {
  input: 'What is 731 * 268, minus 17?',
  // Default is false: the UI stays open to be read after the run.
  options: { exitOnComplete: true },
})

Its React/Ink dependencies are optional peers, so install them beside the ADK:

npm install ink ink-text-input react
npx tsx chat.ts

It takes over the alternate screen and offers three views, switched by a single key: debug [d] — every event as it happens, the default; content [c] — just the conversation; logs [l] — anything the program wrote to the console, captured rather than smeared over the UI. Arrow keys and PageUp/PageDown move through the trace; in the debug view Enter or space opens the selected event's detail pane (r for its raw payload, c for the readable one); and Ctrl+C leaves.

The reason to reach for it over console.log is the pause. When a yielding tool stops the run, i opens an input built from that tool's yieldSchema — you answer the agent in the terminal and the run resumes, which is the sample's approve without the second process. The call returns a handle you can await for the RunResult, and it carries the runner and session it used. The whole surface is the terminal UI.

Recipe 6

The errors you will actually see

Three of these account for most first-hour dead ends. Each message below is the package's own text, verbatim.

No key. Thrown from app.run at the first model call — not at import, and not when the agent is built. The adapter reads the environment when it needs an endpoint, so exporting the key in the same shell after the process started will not help; the process has to start with it.

No OpenAI API key configured.

Set one of these environment variables:
  - OPENAI_API_KEY                                  (Standard OpenAI)
  - OPENAI_EU_API_KEY                               (OpenAI EU region)
  - AZURE_OPENAI_ENDPOINT + AZURE_OPENAI_API_KEY    (Azure OpenAI)

A missing optional peer. The core entry pulls in no provider and no backend, which is the point — and the cost is that each surface you reach for may want an install. Some say so in the ADK's own words; several do not, and surface as Node's ordinary module-resolution failure instead. The map:

What you used What to install What a miss looks like
@animahealth/adk/openai, /gemini, /claude nothing to run — each subpath bundles its provider SDK; a TypeScript project installs it anyway, or sets skipLibCheck types only — the subpath's declarations reference the SDK's types, so tsc without skipLibCheck wants openai or @google/genai. The deprecated core-entry factories (import { openai } from '@animahealth/adk') are the one path that loads the real SDK at runtime, and they need it installed.
@animahealth/adk/cli or app.cli() ink ink-text-input react a module-resolution error at import — no ADK message
app.handler.agui() @ag-ui/core a module-resolution error on the first request, when the adapter loads
@animahealth/adk/stores/sqlite better-sqlite3 a module-resolution error on the first read or write, not at import
app.tools.fetchPage() jsdom @mozilla/readability turndown, plus playwright for render: true quiet: the tool returns { success: false, error: 'network_error' } and the model reads that as a broken page
app.mcp (tools from an MCP server) @modelcontextprotocol/sdk MCP SDK not found. Install it with: npm install @modelcontextprotocol/sdk

A model name that does not exist. The ADK does not keep a list of model names: openai('…') is a descriptor, and the string goes to the provider as you typed it. So a typo is not caught locally — it comes back as the provider's own error at the first call. The adapter's only opinion about it is where to go next: an error whose message contains model not found or deployment not found is treated as worth retrying at the next configured endpoint, alongside rate limits, timeouts and 5xx. With one endpoint configured there is no next one, so the provider's error is what you see. On Azure the name you write is mapped to a deployment name, so check the modelMapping before you blame the model — the models chapter covers that mapping.

Two more with their own messages, both thrown early rather than mid-run. Building a web-search tool with no Serper key fails while the agent is being assembled:

No Serper API key configured.

Set the environment variable:
  - SERPER_API_KEY    (Get one at https://serper.dev)

Or pass directly to webSearch:
  webSearch({ provider: new SerperProvider('your-api-key') })

And the Bookings sample refuses to call a model without a key, pointing at the flow that needs none: OPENAI_API_KEY is not set. Run `npm test` for the same flow, scripted.

Where to go next

The chapters behind these recipes

Each recipe here is the shortest honest version of a chapter. Stopping to ask is the pause the sample is built on; where a sleeping agent lives is the store you swap SQLite for; serving it is the handler surface behind recipe 4; streaming and cost is what those events cost; testing agents and measuring agents are how the thing you built at hour one survives hour ten. When something behaves in a way no chapter explains, the package's source is the answer — it is the same code this page just ran.

Agent Development Kit · Build · the model seam

Choosing a model, and swapping it

An agent's model: is a descriptor — a small data object naming a provider, a model and its options. It never talks to anyone. The runner resolves an adapter for that provider when a call actually happens, and that seam is why the same agent runs against OpenAI, against Vertex, or against a scripted test with no key at all.

Runs here · scripted cells need no key Live cells · your own OpenAI key Providers · openai · gemini · claude

Step 1

A model is a value

openai(name, options?) returns an object, not a client. It holds provider, name, and whatever options you passed — nothing else you can see. The adapter that will eventually make the HTTP call rides along on a non-enumerable symbol, so a descriptor stays inspectable, comparable and safe to store.

The first three cells below run with no key. The two live cells further down call OpenAI directly from this page, so paste a key when you get there.

import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'

const app = adk()

// Two descriptors. No network, no client, no key — just data.
const luna = openai('gpt-5.6-luna')
const tuned = openai('gpt-5.6-terra', { temperature: 0.2, maxTokens: 300 })
const descriptors = { luna, tuned }

descriptors

Every provider factory takes the same shape: a model name, then an options object typed for that provider. temperature and maxTokens are the two options all three share; everything else is provider-specific and covered in step 3. The factories also carry a realtime variant (openai.realtime, gemini.realtime) that wraps a descriptor for voice — that wrapper belongs to the voice runtime, not to this chapter.

Step 2

Descriptor, adapter, runner

A descriptor names a model; an adapter is the thing that speaks the provider's wire protocol. The runner picks one per model call, in a fixed order. An adapter registered on the app — adk({ adapters: { openai: myAdapter } }) — wins outright. Otherwise the runner uses the factory the descriptor carries, which is what importing @animahealth/adk/openai attaches. Failing both, it imports the provider's adapter dynamically; a provider it has no case for throws, naming the subpath you should have imported.

That order is the whole trick. Below, runTest registers a scripted adapter, so the descriptor still says which model this agent is for while no provider client is ever built — no key, no SDK, no network.

import { runTest, user, model, getLastAssistantText } from '@animahealth/adk/testing'

const triage = app.agent({
  name: 'triage',
  model: luna,
  context: [app.context.system('Answer in one short sentence.'), app.context.history()],
})

const scriptedOpenAI = await runTest(triage, [user('Ping'), model('Pong, from a scripted turn.')])

getLastAssistantText(scriptedOpenAI.events)

Now point the same agent at a different provider. A descriptor is plain data, so you can write one out by hand instead of calling a factory — useful when the model comes from configuration. This agent names Gemini, and it runs exactly as the OpenAI one did, because runTest registers its scripted adapter for both providers.

const triageOnGemini = app.agent({
  name: 'triage_gemini',
  model: { provider: 'gemini', name: 'gemini-3-flash' },
  context: [app.context.system('Answer in one short sentence.'), app.context.history()],
})

const scriptedGemini = await runTest(triageOnGemini, [user('Ping'), model('Pong, again.')])
const swapped = {
  named: triageOnGemini.model,
  reply: getLastAssistantText(scriptedGemini.events),
}

swapped

One limit, worth knowing before it bites: runTest registers its scripted adapter for openai and gemini only. An agent whose descriptor says claude falls through to the real Vertex adapter and will ask for Google credentials. Pass your own adapter through adk({ adapters: { claude: … } }) for that case.

This page is itself an instance of rule one. The adk() it serves has an OpenAI adapter registered on it — one that reads the key from the box above at call time, and sets the browser flag from step 4. Your openai(…) descriptors are honoured for which model to call; the registered adapter decides how.

Step 3

Options that change the call

Provider options live on the descriptor, so changing one is a one-line edit with no plumbing behind it. OpenAI takes reasoning: { effort, summary? }; effort is minimal, low, medium or high. Run this, then change the effort and run it again — reasoningTokens moves, and so does the cost.

One rule to know: when a descriptor carries reasoning, the adapter does not send temperature at all, because reasoning models reject a non-default value. Setting both is not an error; the temperature is simply dropped.

const scout = app.agent({
  name: 'scout',
  model: openai('gpt-5.6-luna', { reasoning: { effort: 'minimal' } }),
  context: [app.context.system('Answer in one short sentence.'), app.context.history()],
})

const scouted = await app.run(scout, 'Why is the sky blue?')

scouted.usage

The modelName in that summary is the name you wrote, not whatever the endpoint resolved it to, and the cost estimate is looked up from it. That matters in step 4, where a deployment can be called something else entirely.

Gemini and Claude take their own thinking controls. Their SDKs are optional peers behind @animahealth/adk/gemini and @animahealth/adk/claude, which this page does not serve — so these two are static, checked against the same types the cells above compile against.

import { gemini } from '@animahealth/adk/gemini'
import { claude } from '@animahealth/adk/claude'

// Gemini: a thinking budget in tokens, or a level; thoughts can be returned.
const flash = gemini('gemini-3-flash', {
  temperature: 0.3,
  thinkingConfig: { thinkingLevel: 'low', includeThoughts: true },
})

// Claude runs through Google Vertex, so `vertex` is required — there is no second argument
// without it. Credentials fall back to GOOGLE_APPLICATION_CREDENTIALS when the path is omitted.
const sonnet = claude('claude-sonnet-4-20250514', {
  vertex: { project: 'my-project', location: 'us-east5' },
  thinking: { budgetTokens: 4000 },
})

All three providers also accept retry on the descriptor, applied around the model stream. It takes the same RetryConfig as a tool's — see Tools.

Step 4

Endpoints, Azure and the browser

Where the call goes is the adapter's business, not the descriptor's. The OpenAI adapter takes an ordered list of endpoints and tries them in turn, falling forward only on failures worth retrying elsewhere: rate limits, timeouts, connection errors, 500/502/503, and a missing model or deployment. Any other error is thrown from the first endpoint that raises it.

import { adk } from '@animahealth/adk'
import { OpenAIAdapter } from '@animahealth/adk/openai'

const openAIAdapter = new OpenAIAdapter([
  {
    type: 'azure',
    baseUrl: 'https://my-resource.openai.azure.com',
    apiVersion: '2025-01-01-preview',
    apiKey: process.env.AZURE_OPENAI_API_KEY,
    // The name your code writes, mapped to the name your Azure resource deploys it under.
    modelMapping: { 'gpt-5.6-luna': 'gpt-5-6-luna-2026-04' },
  },
  // Second choice: OpenAI's EU region, by base URL. Any OpenAI-compatible host works here.
  { type: 'openai', baseUrl: 'https://eu.api.openai.com/v1', apiKey: process.env.OPENAI_EU_API_KEY },
  // Last: plain api.openai.com.
  { type: 'openai', apiKey: process.env.OPENAI_API_KEY },
])

const configuredApp = adk({ adapters: { openai: openAIAdapter } })

For an azure endpoint the adapter builds the client against {baseUrl}/openai/deployments/{deployment} with your apiVersion; modelMapping is what turns the logical name on the descriptor into that deployment. Usage and cost still report the logical name, so the accounting does not change when you move a model behind an alias.

Pass no endpoints at all and the adapter builds the list from the environment, in this order: AZURE_OPENAI_ENDPOINT with AZURE_OPENAI_API_KEY, then OPENAI_EU_API_KEY, then OPENAI_API_KEY. With none of them set it throws, naming all three.

The last endpoint field is a safety catch. The OpenAI client refuses to run in a browser, because the usual reason it is there is a server key baked into shipped JavaScript. dangerouslyAllowBrowser: true opts out of that guard, for the one case it was meant to allow: the end user typed the key themselves. This page is that case — it is exactly how the cells above reach api.openai.com with no backend in between. Never set it with a key your user did not type.

// A page where the reader supplies the key, as this one does: `keyTheReaderTyped` came from an
// input on the page, never from a build-time constant.
const browserAdapter = new OpenAIAdapter([
  { type: 'openai', apiKey: keyTheReaderTyped, dangerouslyAllowBrowser: true },
])

The other two providers put their connection on the descriptor instead of on an endpoint list: Gemini takes an optional vertex: { project, location, credentials? } (or an API key given to new GeminiAdapter({ apiKey })), and Claude requires vertex, as step 3 showed.

Step 5

Prompt caching

Caching is opt-in per descriptor, and the two providers spell it differently because their APIs do. OpenAI supports explicit breakpoints only: give a stable key (1 to 64 characters — the adapter throws outside that), the mode explicit, and the 30m sliding window that refreshes on reuse.

The key alone does nothing. Some context message must also be tagged as cacheable, marking where the reusable prefix ends. app.context.cacheableUser(text) is that tag; if nothing in the rendered context carries it, the adapter throws rather than quietly paying full price.

const briefer = app.agent({
  name: 'briefer',
  model: openai('gpt-5.6-luna', {
    promptCache: { key: 'clinic-handbook-v3', mode: 'explicit', ttl: '30m' },
  }),
  context: [
    app.context.system('You answer questions about the handbook.'),
    // The long, stable prefix — everything before this point is the cacheable span.
    app.context.cacheableUser(handbookText),
    app.context.history(),
  ],
})

Claude's caching is on by default for Vertex models and is configured by disabling or tuning it: enabled, a ttl of 5m or 1h, and system choosing whether all system blocks are cacheable or only tagged ones. A 1h cache write costs more than a 5m one.

const cachedSonnet = claude('claude-sonnet-4-20250514', {
  vertex: { project: 'my-project', location: 'us-east5' },
  promptCache: { enabled: true, ttl: '1h', system: 'tagged' },
})

Either way, the result shows up in the run's usage as totalCachedTokens and totalCacheWriteTokens, priced separately from fresh input.

Step 6

Images and audio going in

Multimodal input is not a model option — it is a property of the message. A user message carries media, a list of parts, each an image, audio or document with a source that is either base64 with a mimeType, or a url. The adapter for whichever provider you named translates the parts.

This is live, and it needs a model that can see. The image below is a 32-pixel square inlined as base64 — swap the data or the question and run it again.

const redSquarePng =
  'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAAKklEQVR42mO4o6ZGU8QwasGoBaMWjFowasGoBaMWjFowasGoBaMWDBULAIjyoD2JhwFtAAAAAElFTkSuQmCC'

const looker = app.agent({
  name: 'looker',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('Answer with one word.'), app.context.history()],
})

const seen = await app.run(looker, {
  input: {
    message: {
      text: 'What colour is this square?',
      media: [
        {
          type: 'image',
          source: { type: 'base64', mimeType: 'image/png', data: redSquarePng },
        },
      ],
    },
  },
})

seen.output.text

Coverage is per adapter and per position, and it is not uniform. On a user message, image reaches all three providers and audio reaches OpenAI and Gemini; Claude's user serializer has no audio branch. document parts are dropped from a user message by all three — they are serialized only when they arrive on a tool result, and then only for Gemini and Claude. Nothing throws when a part is dropped, so check the part type against the provider you are actually calling.

That is the model seam whole: a descriptor names who answers, and an adapter decides how the call is made. The other half of an agent's config — what it can actually do — is Tools, the next chapter.

Agent Development Kit · Build

Tools: the code a model is allowed to run

A tool is a name, a description, a Zod schema, and a function. The model reads the name and description and decides; the schema is the border it has to cross; your function does the work. This chapter runs every part of that — the execute context, the prepare and finalize steps around it, timeouts and retries, and what the model sees when the whole thing fails.

Builds on · the quickstart's one-tool agent Most cells · no key needed Surface · app.tool

Step 1

The whole config, in one object

app.tool takes one object and returns a tool. Three fields are the contract the model sees — name, description, schema — and execute is the work. The rest are optional and covered below. Every cell here but the last runs with no key: the model's turns are scripted and the tools themselves really execute. The last cell asks a real model to choose, so paste a key if you want it — it stays in this browser.

import { adk } from '@animahealth/adk'
import { z } from 'zod'

const app = adk()

const WAREHOUSE: Record<string, number> = { 'ADK-1': 12, 'ADK-2': 0 }

const checkStock = app.tool({
  name: 'check_stock',
  description: 'Look up how many units of a SKU the warehouse holds',
  schema: z.object({
    sku: z.string().describe('Warehouse SKU, e.g. ADK-1'),
    quantity: z.number().int().positive().describe('Units the customer wants'),
  }),
  execute: (ctx) => {
    const onHand = WAREHOUSE[ctx.args.sku] ?? 0
    return { sku: ctx.args.sku, onHand, canFulfil: onHand >= ctx.args.quantity }
  },
})

// Every key app.tool reads. There is no tenth.
Object.keys(checkStock)

name and description are prompt: they are the only reason the model reaches for this tool rather than another, so write the description for a reader who cannot see your code. schema is a Zod schema — it becomes the JSON Schema the provider is given, so .describe() on a field is prompt too. yieldSchema belongs to tools that stop and ask a human, at the end of this page. app.tool throws at build time if a config has neither execute nor yieldSchema: a tool that can do nothing is a mistake, not a runtime surprise. Everything here is a function tool: besides these and MCP servers, an agent's tools may hold a provider tool such as { type: 'web_search' }, which runs inside the model provider and has no execute of yours.

Step 2

The schema is the border, not a suggestion

Arguments arrive as JSON a language model wrote, so they are wrong often enough to design for. Each call is coerced toward the schema first — a stringy number becomes a number — and then parsed by Zod. execute only ever sees arguments that parsed. When they do not parse, the run does not throw: the call becomes a tool_result whose error reads Invalid arguments: …, the model reads it on the next round-trip, and it can correct itself.

Three calls below: one clean, one where quantity arrives as the string '5', one missing quantity entirely. Only the third is an error, and the agent survives it.

import { openai } from '@animahealth/adk/openai'
import { runTest, user, model, findEventsByType } from '@animahealth/adk/testing'

const shopkeeper = app.agent({
  name: 'shopkeeper',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('Check stock before promising anything.'), app.context.history()],
  tools: [checkStock],
})

const stockRun = await runTest(shopkeeper, [
  user('Can I have 3 of ADK-1 and 5 of ADK-2?'),
  model({ toolCalls: [{ name: 'check_stock', args: { sku: 'ADK-1', quantity: 3 } }] }),
  model({ toolCalls: [{ name: 'check_stock', args: { sku: 'ADK-2', quantity: '5' } }] }),
  model({ toolCalls: [{ name: 'check_stock', args: { sku: 'ADK-1' } }] }),
  model('ADK-1 has 12 on hand; ADK-2 is out of stock.'),
])

findEventsByType(stockRun.events, 'tool_result').map((event) =>
  event.error
    ? { name: event.name, error: `${event.error.slice(0, 44)}…` }
    : { name: event.name, result: event.result },
)

Section 5 shows the same mechanism catching an exception your own code threw.

Step 3

What ctx carries

execute takes exactly one argument. ctx.args is the parsed input, and the rest of the context is the run around it: toolName and callId identify this call, invocationId and runnable identify the turn and the agent, session is the ledger, state reads and writes it, and signal is the run's AbortSignal to pass to your own fetch. Rather than list them, ask the context itself.

const inspect = app.tool({
  name: 'inspect_context',
  description: 'Report what a tool can see while it runs',
  schema: z.object({ note: z.string() }),
  execute: (ctx) => {
    // State writes are events too: this lands on the session ledger, not on a local variable.
    ctx.state.update({ lastNote: ctx.args.note })
    return {
      carries: Object.keys(ctx).sort(),
      toolName: ctx.toolName,
      agent: ctx.runnable.name,
      session: ctx.session.id,
      aborted: ctx.signal?.aborted ?? false,
      lastNote: ctx.state.lastNote,
    }
  },
})

const inspector = app.agent({
  name: 'inspector',
  model: openai('gpt-5.6-luna'),
  context: [app.context.history()],
  tools: [inspect],
})

const inspectRun = await runTest(inspector, [
  user('Write down that the shelf is dusty.'),
  model({ toolCalls: [{ name: 'inspect_context', args: { note: 'shelf is dusty' } }] }),
  model('Noted.'),
])

findEventsByType(inspectRun.events, 'tool_result')[0]?.result

Four of those keys — output, run, spawn, and dispatch — change the shape of the run rather than just answering it, and they belong to Many agents. (call in that list is the former name of run, kept deprecated — use run.)

Step 4

prepare and finalize: the two optional halves

prepare runs after the arguments parse and before execute. Return a value and it replaces the arguments execute will see; return nothing and they pass through. It is where normalisation, defaulting, and authorization lookups belong, so execute can be about the work.

finalize runs after execute returns, with the output on ctx.result. Return a value and it replaces the result the model is shown; return nothing and the output stands. It is where redaction, truncation, and logging belong. It runs only on a plain return — a control signal such as ctx.output(), or a thrown error, skips it.

type Booking = { room: string; minutes: number; reference: string }

const trace: string[] = []

const bookRoom = app.tool({
  name: 'book_room',
  description: 'Reserve a meeting room for a number of minutes',
  schema: z.object({ room: z.string(), minutes: z.number() }),
  prepare: (ctx) => {
    trace.push(`prepare(${JSON.stringify(ctx.args)})`)
    // Normalise once, here — execute never has to wonder about casing or absurd durations.
    return { room: ctx.args.room.trim().toUpperCase(), minutes: Math.min(ctx.args.minutes, 60) }
  },
  execute: (ctx): Booking => {
    trace.push(`execute(${JSON.stringify(ctx.args)})`)
    return { room: ctx.args.room, minutes: ctx.args.minutes, reference: 'REF-8891-KLM' }
  },
  finalize: (ctx): Booking => {
    trace.push(`finalize(${String(ctx.result?.reference)})`)
    // The model gets a booking it can talk about, not the reference it could leak.
    return { room: ctx.result!.room, minutes: ctx.result!.minutes, reference: 'REF-•••' }
  },
})

const receptionist = app.agent({
  name: 'receptionist',
  model: openai('gpt-5.6-luna'),
  context: [app.context.history()],
  tools: [bookRoom],
})

const bookingRun = await runTest(receptionist, [
  user('Book me  orion  for four hours.'),
  model({ toolCalls: [{ name: 'book_room', args: { room: '  orion  ', minutes: 240 } }] }),
  model('Booked ORION for 60 minutes.'),
])

const lifecycle = {
  trace,
  modelSaw: findEventsByType(bookingRun.events, 'tool_result')[0]?.result,
}

lifecycle

Read the trace top to bottom: the model asked for ' orion ' and 240 minutes, prepare handed execute 'ORION' and 60, and the model was told a reference that is not the real one. Three functions, one call, and the only thing the model ever learns is what finalize allowed.

Step 5

Timeouts, retries, and what the model is told

Two optional fields make a tool survivable, and one rule makes it honest.

  • retry is a RetryConfig: { maxAttempts, initialDelayMs, maxDelayMs, backoffMultiplier }, plus an optional retryableErrors(error) predicate that bails out of retrying the errors it rejects. It re-runs execute with randomized exponential backoff. The retries are internal — the session ledger gets one tool_result for the call, not one per attempt.
  • timeout is milliseconds, and it wraps the retrying execution as a whole — it is a budget for the call, not for one attempt. On expiry the result event carries timedOut: true and an error naming the tool and the budget.
  • An exception out of execute becomes that same result event, with error set to its message. It is not swallowed and it is not thrown at your app.run: it is handed to the model as the outcome of the call, which is the only place that can do something about it. Whatever your error message says, the model reads. Guardrails covers what a throw does in full.

One agent, three tools, three failures — a flaky call that recovers on its third attempt, a slow call that blows a 25ms budget, and a call that simply throws.

let upstreamCalls = 0

const fetchRate = app.tool({
  name: 'fetch_rate',
  description: 'Fetch a currency exchange rate from the upstream feed',
  schema: z.object({ pair: z.string() }),
  retry: { maxAttempts: 3, initialDelayMs: 1, maxDelayMs: 8, backoffMultiplier: 2 },
  execute: async (ctx) => {
    upstreamCalls++
    if (upstreamCalls < 3) throw new Error(`upstream 503 (attempt ${upstreamCalls})`)
    return { pair: ctx.args.pair, rate: 1.27, attempts: upstreamCalls }
  },
})

const slowReport = app.tool({
  name: 'slow_report',
  description: 'Build the quarterly report',
  schema: z.object({}),
  timeout: 25,
  execute: async (): Promise<{ report: string }> => {
    await new Promise((resolve) => setTimeout(resolve, 400))
    return { report: 'never arrives' }
  },
})

const chargeCard = app.tool({
  name: 'charge_card',
  description: 'Charge the customer their balance',
  schema: z.object({ amount: z.number() }),
  execute: (ctx): { charged: number } => {
    // Say something the model can act on: it is the audience for this string.
    throw new Error(`Card declined for ${ctx.args.amount} — ask for another payment method`)
  },
})

const teller = app.agent({
  name: 'teller',
  model: openai('gpt-5.6-luna'),
  context: [app.context.history()],
  tools: [fetchRate, slowReport, chargeCard],
})

const failureRun = await runTest(teller, [
  user('Get me the GBPUSD rate, run the report, and charge 40.'),
  model({ toolCalls: [{ name: 'fetch_rate', args: { pair: 'GBPUSD' } }] }),
  model({ toolCalls: [{ name: 'slow_report', args: {} }] }),
  model({ toolCalls: [{ name: 'charge_card', args: { amount: 40 } }] }),
  model('Rate is 1.27. The report timed out and the card was declined.'),
])

findEventsByType(failureRun.events, 'tool_result').map((event) => ({
  name: event.name,
  result: event.result,
  error: event.error,
  timedOut: event.timedOut,
}))

fetch_rate reports attempts: 3 — proof that retry re-ran execute, and that only the surviving attempt reached the ledger. The other two failed, the run did not, and the agent still produced its answer. That is the whole failure contract: a tool can fail, and the model finds out.

Step 6

A real model choosing between two tools

Everything above scripted the decision so the tools could be exercised without a key. The decision itself is the model's, and it is made from the names and descriptions alone. Paste a key in the box at the top, then run this: two tools, one question that only one of them answers. Change the question and watch the choice change.

const ZONES: Record<string, string> = { Tokyo: 'Asia/Tokyo', London: 'Europe/London' }

const localTime = app.tool({
  name: 'local_time',
  description: 'Get the current wall-clock time in a named city',
  schema: z.object({ city: z.string().describe('City name, e.g. Tokyo') }),
  execute: (ctx) => {
    const zone = ZONES[ctx.args.city]
    if (!zone) throw new Error(`No timezone on file for ${ctx.args.city} — try Tokyo or London`)
    return { city: ctx.args.city, time: new Date().toLocaleTimeString('en-GB', { timeZone: zone }) }
  },
})

const currentWeather = app.tool({
  name: 'current_weather',
  description: 'Get the current temperature and conditions in a named city',
  schema: z.object({ city: z.string().describe('City name, e.g. Tokyo') }),
  // A stub, so the cell needs no second API key: only the description above decides whether the
  // model reaches for this tool or the one beside it.
  execute: (ctx) => ({ city: ctx.args.city, celsius: 19, conditions: 'light rain' }),
})

const concierge = app.agent({
  name: 'concierge',
  model: openai('gpt-5.6-luna'),
  context: [
    app.context.system('Answer with the tools. Never guess a time or a temperature.'),
    app.context.history(),
  ],
  tools: [localTime, currentWeather],
})

const conciergeRun = await app.run(concierge, 'What time is it in Tokyo right now?')

const chosen = {
  called: findEventsByType(conciergeRun.session.events, 'tool_call').map((event) => event.name),
  answer: conciergeRun.output.text,
}

chosen

The only thing separating the two tools is a sentence each: the description is what decides whether your tool gets called at all.

Next

Tools that stop and ask

A tool with a yieldSchema and no execute does not compute an answer — it suspends the run until a human supplies one, and the agent sleeps as a database row until they do. That is Stopping to ask.

Agent Development Kit · Build

Context: what the model actually sees

An agent's context is a list of functions, not a string. Before every model call the ADK runs that list over the session ledger and hands the result to the provider. The ledger itself is never sent. This chapter makes the difference inspectable: the cells below record each render as it happens and print it, with no API key.

Audience · engineers tuning prompts and token cost Needs · nothing (every cell is scripted) Source · src/context/

Step 1

A context is a pipeline of renderers

A context renderer takes a RenderContext and returns one. The agent's context array is applied in order, each renderer seeing what the previous one produced. app.context.system(…) appends a system event; app.context.history() appends the session's events; a filter drops some again. Because a renderer is an ordinary function, one that returns its input unchanged is a tap — a way to see the finished render. That is the whole trick this page uses.

import { adk, type Agent } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { z } from 'zod'

const app = adk()

// One entry per model call, in the order the calls happen.
const renders: Array<{ messages: string[]; tools: string[] }> = []

// A renderer that changes nothing and records everything: the exact list the provider is about
// to serialize. `app.context(fn)` is the escape hatch — raw RenderContext in, RenderContext out.
const tap = app.context((ctx) => {
  renders.push({
    messages: ctx.events.flatMap((e) =>
      e.type === 'system' || e.type === 'user' || e.type === 'assistant' || e.type === 'thought'
        ? [`${e.type}: ${e.text}`]
        : e.type === 'tool_call'
          ? [`tool_call: ${e.name}(${JSON.stringify(e.args)})`]
          : e.type === 'tool_result'
            ? [`tool_result: ${JSON.stringify(e.result)}`]
            : [],
    ),
    tools: ctx.functionTools.map((tool) => tool.name),
  })
  return ctx
})

const allergies = app.tool({
  name: 'lookup_allergies',
  description: 'Look up the recorded allergies for a patient',
  schema: z.object({ patient: z.string() }),
  execute: (ctx) => ({ patient: ctx.args.patient, allergies: ['penicillin'] }),
})

const triage = app.agent({
  name: 'triage',
  model: openai('gpt-5.6-luna'),
  context: [
    app.context.system('You are a triage nurse. Check the allergy record before advising.'),
    app.context.history(),
    tap,
  ],
  tools: [allergies],
})

triage.context.length

Three renderers, and the tap is the last one, so it sees the final result. Drop app.context.history() from that array and the agent sees no conversation at all — the session would still be recording every word. Nothing reaches the model except through this list.

Step 2

The ledger is stored; the context is rendered

The session ledger is append-only and permanent. The context is neither — it is rebuilt from scratch before every model call, so a tool-using turn renders twice. Run the same agent with a scripted model and compare the three things below: what accrued in the ledger, what the two renders contained, and what each model_start event recorded.

import { isModelStartEvent } from '@animahealth/adk'
import { runTest, user, model } from '@animahealth/adk/testing'

renders.length = 0 // so this cell prints this run, however many times you press Run

const consult = await runTest(triage, [
  user('Can I take amoxicillin?'),
  model({
    thought: 'Check the record first.',
    toolCalls: [{ name: 'lookup_allergies', args: { patient: 'ada' } }],
  }),
  model('Your record lists penicillin, and amoxicillin is a penicillin. Avoid it.'),
])

const seam = {
  ledger: consult.events.map((e) => e.type),
  modelStarts: consult.events.filter(isModelStartEvent).map((e) => ({
    step: e.stepIndex,
    messageCount: e.messageCount,
    tools: e.tools.map((t) => t.name),
  })),
  firstRender: renders[0],
  secondRender: renders[1],
}

seam

The first render is the system message and the question. The second render is that plus the thought, the tool call, and the tool's real result — the model gets its own decision back as input, which is how a tool-using turn resolves. Neither render is stored anywhere; both were derived from the ledger and thrown away.

model_start is a receipt, not a transcript. It records stepIndex, messageCount, a tools list of names and descriptions, and the outputSchema name — the shape of the render, not its text. That is deliberate: prompts carry the sensitive payload and the ledger already holds every message event separately. When you need the exact text, tap the render as the cells here do.

Note also what app.context.history() copies: the session slice whole, bookkeeping events included. Only message-shaped events — system, user, assistant, thought, tool_call, tool_result — are serialized into a prompt, and messageCount counts exactly those. That is why the ledger list above is longer than the message counts beside it.

Step 3

Scopes and filters: paying for less

history() takes a scope that decides which invocations are visible — it matters once an agent hands off to another. Filters then run after history and shrink what it produced, so order in the array is the whole meaning: pruneReasoning() before history() would prune an empty list.

history({ scope }) Includes
'direct' (default) This invocation, plus every root invocation — the ordinary conversation. An invocation reached by an isolated handoff sees only itself.
'all' Every event in the session, unfiltered.
'invocation' Only this invocation's events.
'ancestors' This invocation, its parent chain, and the root invocations.
'agent' That same lineage, narrowed to user events, this agent's own events, and assistant events from the agents named in agents: […].

Now the filters. This agent prunes reasoning and keeps only the two most recent prompt events. Watch what falls out of the window on the second call.

const terse: string[][] = []

const briefTriage = app.agent({
  name: 'brief_triage',
  model: openai('gpt-5.6-luna'),
  context: [
    app.context.system('Be brief.'),
    app.context.history(),
    app.context.pruneReasoning(),
    app.context.selectRecent(2),
    app.context((ctx) => {
      terse.push(ctx.events.map((e) => e.type))
      return ctx
    }),
  ],
  tools: [allergies],
})

const trimmed = await runTest(briefTriage, [
  user('Can I take amoxicillin?'),
  model({
    thought: 'Check the record first.',
    toolCalls: [{ name: 'lookup_allergies', args: { patient: 'ada' } }],
  }),
  model('Avoid it.'),
])

const filtered = { ledger: trimmed.events.map((e) => e.type), rendered: terse }

filtered

On the second call the user's actual question has fallen out of the window and the model is answering from a tool result alone. That is the cost of a small selectRecent, and it is why the filter counts only prompt events and re-attaches a tool_call whose tool_result survived the cut — a result without its call is a request providers reject outright. It also lifts system events to the front, so instructions are never the thing that gets trimmed.

Step 4

Which tools the model may pick, this turn

Tools belong to the agent, but availability is per-render. limitTools(names) sets allowedTools on the context and toolChoice(choice) overrides the agent's default — both for this call only, and both are ordinary renderers, so they can be conditional on state.

const gates: Array<{ tools: string[]; allowedTools: string[]; toolChoice: string }> = []

const escalate = app.tool({
  name: 'escalate_to_gp',
  description: 'Escalate the question to a GP',
  schema: z.object({ reason: z.string() }),
  execute: (ctx) => ({ escalated: true, reason: ctx.args.reason }),
})

const gatedTriage = app.agent({
  name: 'gated_triage',
  model: openai('gpt-5.6-luna'),
  context: [
    app.context.system('Look up the allergy record before doing anything else.'),
    app.context.history(),
    app.context.limitTools(['lookup_allergies']),
    app.context.toolChoice('required'),
    app.context((ctx) => {
      gates.push({
        tools: ctx.functionTools.map((t) => t.name),
        allowedTools: [...(ctx.allowedTools ?? [])],
        toolChoice: JSON.stringify(ctx.toolChoice),
      })
      return ctx
    }),
  ],
  tools: [allergies, escalate],
})

const gatedRun = await runTest(gatedTriage, [
  user('Can I take amoxicillin?'),
  model({ toolCalls: [{ name: 'lookup_allergies', args: { patient: 'ada' } }] }),
  model('Avoid it.'),
])

const gateView = {
  rendered: gates[0],
  modelStartTools: gatedRun.events.filter(isModelStartEvent).map((e) => e.tools.map((t) => t.name)),
}

gateView

Read that result carefully, because it is the one thing about limitTools that surprises people: both tool definitions are still in functionTools, and both still appear in model_start. limitTools does not delete a tool — it narrows the choice. The OpenAI adapter turns allowedTools into a tool_choice of type allowed_tools, folding in toolChoice as the mode:

// what serializeToolChoice(ctx.toolChoice, ctx.allowedTools) produces for the render above
{
  "type": "allowed_tools",
  "mode": "required",
  "tools": [{ "type": "function", "name": "lookup_allergies" }]
}

Other adapters express the same constraint in their own dialect. If you need a tool to be genuinely invisible — absent from the token count as well as the choice — leave it off the agent, or give the agent a narrower peer.

Step 5

Typed prompt fragments

Pass a function instead of a string to system or user and it is rendered per call against the app's state schema. The argument carries state, typed from that schema — session keys are readable at the top level and by scope — and outputSchema, the agent's output type already rendered as text you can drop into a prompt. A typo in a state key is a compile error, not a blank in a prompt.

const clinic = adk({
  schema: {
    session: {
      patientName: z.string(),
      lastVisit: z.string(),
    },
  },
})

const systemTexts: string[] = []

const briefing = clinic.agent({
  name: 'briefing',
  model: openai('gpt-5.6-luna'),
  context: [
    clinic.context.system(
      (c) => `Brief the clinician on ${c.state.patientName}, last seen ${c.state.lastVisit}.`,
    ),
    clinic.context.history(),
    clinic.context((c) => {
      for (const e of c.events) {
        if (e.type === 'system') systemTexts.push(e.text)
      }
      return c
    }),
  ],
})

// One pre-1.0 rough edge: `runTest` declares its runnable with the erased state schema, so a
// schema-typed agent needs a widening cast to reach it. The run itself is unchanged.
const briefed = await runTest(
  briefing as unknown as Agent,
  [user('Summarise the last visit.'), model('Ada was seen in March for a repeat prescription.')],
  { initialState: { session: { patientName: 'Ada Lovelace', lastVisit: '2026-03-02' } } },
)

const typedView = { systemTexts, status: briefed.status }

typedView

The state was seeded on the session, and the system message was written at render time from the values that were true then. Change the state mid-run and the next turn's system message changes with it — the prompt is a projection of state, the same way the message list is a projection of the ledger.

Reference

The whole vocabulary

Everything app.context offers. Each returns a renderer; put them in the agent's context array in the order you want them applied.

Renderer Effect on the render
app.context(fn) Your own function over the RenderContext. Everything below is built from this.
.system(text | fn) Append a system event. A function is rendered per call against typed state.
.user(text | fn) Append a user event the reader never typed — a preamble, a retrieved document.
.cacheableUser(text) The same, tagged for provider prompt caching. The tag alone enables nothing; the model config must also turn caching on.
.history(options) Append the session's events for the given scope. See the table above.
.transform(fn, options) Rewrite the text of user events in place. options.at chooses which state a state-aware transform sees: 'message', 'invocation', or 'current'.
.pruneUserMessages(name) Drop user events tagged with an agent's name; 'self' means this agent's own.
.selectRecent(n) Keep the n most recent prompt events, plus every system event and any tool_call a surviving tool_result needs.
.pruneReasoning() Drop thought events — usually the cheapest token saving available.
.limitTools(names) Narrow which of the agent's tools the model may choose this call.
.toolChoice(choice) 'auto', 'none', 'required', or { name }, overriding the agent's setting for this call.

Text agents render synchronously. A renderer that returns a promise — an embedding lookup, a database read — throws when the agent's context is built. Fetch outside the render and pass the result in as state, or do the retrieval in a tool.

Where this goes next: the ledger these renders read from is the subject of Sessions and state, the outputSchema a typed prompt can quote comes from Structured output, and the scripted model driving every cell on this page is Testing agents without a model.

Agent Development Kit · Build

Sessions and state

A session is an append-only list of events. State is not stored beside that list — it is replayed from it, key by key, so every value an agent holds has an event that put it there. This chapter builds a schema, writes state from a tool, reads the audit trail back, and rewinds the session to an earlier event.

Runs · no key (the model is scripted) Builds on · tools (ctx.state) and the quickstart's ledger Package · @animahealth/adk (MIT)

Step 1

A schema, and its scopes

State is declared on the app, as Zod types grouped by scope. The scopes are session (this conversation), the shared scopes user, patient, practice, org and team (each keyed by an id the session carries, so several sessions can address the same one), and temp (one invocation, never recorded). Declare only the scopes you use.

The schema is a compile-time contract and a defaults table. It types every read and write below, and its .default(…) values are filled in when state is seeded through it — which step 3 does.

Step 2

Writing state from a tool

A tool's ctx.state is the session scope at the top level, with the shared scopes and temp hanging off it: ctx.state.stage, ctx.state.user.displayName, ctx.state.temp.scratch. There are two spellings: assign a key, or hand ctx.state.update(changes) a batch of them. Both follow the same rule — every key that actually changes appends its own state_change event, a deep-equal value records nothing, and undefined deletes the key.

import { adk } from '@animahealth/adk'
import { MockAdapter } from '@animahealth/adk/testing'
import { z } from 'zod'

const schema = {
  session: {
    stage: z.enum(['intake', 'collecting', 'triaged']).default('intake'),
    concerns: z.array(z.string()).default([]),
  },
  user: {
    displayName: z.string().default('friend'),
  },
}

// The model on this page is a scripted stand-in, so every cell runs with no key. Step 3 writes
// the script; everything else is the shipped runtime.
const scriptedModel = new MockAdapter()

const app = adk({ name: 'triage', schema, adapters: { openai: scriptedModel } })

const noteConcern = app.tool({
  name: 'note_concern',
  description: 'Record one concern the patient reports.',
  schema: z.object({ concern: z.string() }),
  execute: (ctx) => {
    ctx.state.concerns = [...ctx.state.concerns, ctx.args.concern]
    ctx.state.stage = 'collecting'
    return { concerns: ctx.state.concerns }
  },
})

const triage = app.agent({
  name: 'triage',
  model: { provider: 'openai', name: 'gpt-5.6-luna' },
  context: [app.context.system('Log every concern with note_concern.'), app.context.history()],
  tools: [noteConcern],
})

const built = { scopes: Object.keys(app.schema), agent: triage.name, tool: noteConcern.name }

built

One thing the scope list above leaves out: temp is scratch space held in memory for the invocation, and reading or writing it outside an invocation throws.

Step 3

Run it, then read state

setResponses hands the scripted model this run's two turns: one that calls the tool, one that answers. Everything else is the ordinary run path — the tool really executes, and no key or network is involved. input.initialState seeds the session before the first turn, scope by scope, and the app's schema fills the rest in from .default(…) — which is why stage starts at 'intake' and concerns at [] without either being named.

scriptedModel.setResponses([
  { toolCalls: [{ name: 'note_concern', args: { concern: 'sore throat' } }] },
  { text: 'Noted a sore throat. Anything else?' },
])

const run = await app.run(triage, {
  input: {
    message: 'I have a sore throat.',
    initialState: { session: {}, user: { displayName: 'Ada' } },
  },
})

const afterRun = { stage: run.state.stage, concerns: run.state.concerns }

afterRun

The run is over; the session is not. run.session is the same session object the tool wrote to, and its state is recomputed from the events on every read. Nothing was saved to a state table — replay is the storage.

Step 4

The audit trail

Every write left a state_change event carrying its scope, its source, and a changes list of { key, oldValue, newValue }. That is the audit: any value in the state can be traced to the event that set it, what it replaced, and which invocation was running.

run.session.events.flatMap((event) =>
  event.type === 'state_change'
    ? [{ scope: event.scope, source: event.source, changes: event.changes }]
    : [],
)

Three sources tell you who moved the value. direct is a write from outside an invocation — the seeding above. mutation is a write from inside one — the tool. observation is not a write at all: it records the value an invocation read from a shared scope, whose storage is bound to the session from outside and can move between reads. Without that binding — as on this page — the seeded user value sits in the ledger, but a tool reading ctx.state.user.displayName finds nothing.

Step 5

Snapshots and time travel

Because state is derived, it can be derived at any point in the past. session.stateAt(index) replays the events up to that index and returns a snapshot: sessionState, scopedStates for the shared scopes, plus the status, currentAgentName, yieldedTools and invocationTree as they stood then. session.eventIndexOf(eventId) maps an event id to its index; session.forkAt(index) returns a new session carrying the events up to that point, so an alternate history can be run without disturbing this one.

const timeline = run.session.events.flatMap((event, index) =>
  event.type === 'state_change'
    ? [{ index, stage: run.session.stateAt(index).sessionState.stage }]
    : [],
)

const forked = run.session.forkAt(run.session.events.findIndex((e) => e.type === 'tool_call'))

const timeTravel = {
  timeline,
  fork: { events: forked.events.length, state: { ...forked.state }, id: forked.id },
}

timeTravel

The fork holds the state as it was when the model asked for the tool, before the tool ran, and it has its own session id. Same mechanism as the audit trail, read forwards instead of backwards. Where those events outlive the process is a separate choice — adk({ store }), covered in Where a sleeping agent lives.

The seam

The ledger is not the context

The ledger is the durable record; what the model sees is re-rendered from it before every model call by the agent's context renderers, so no state_change reaches the prompt unless a renderer puts it there. Which events become messages, and how state is injected into them, belongs to What the model actually sees — write to the session, render for the model.

Agent Development Kit · Build · after Five primitives

Many agents, two ways to hand off

A container decides the shape before the run starts. A verb decides it while the run is happening. This chapter is the second half of composition: nesting the five kinds, the in-run verbs run, spawn, dispatch and transfer, the gated and cached wrappers, a runnable that is not bound to an app yet, and the one-shot call that skips the graph entirely. Every cell but the last runs with the model scripted.

Reads after · Five primitives Needs · a key for the last cell only Source · src/core/orchestration.ts

The axis

Two moments at which one agent can hand work to another

Five primitives gave you the vocabulary: agent, step, sequence, parallel, loop. Those are the first moment — you write the graph, and the runner walks it. The second moment is inside a running step or tool, where the code that decides already knows what the data looks like. Both write to the same session, and one field separates them: a container's child carries a parentInvocationId and nothing else, while a handed-off child also carries a handoffOrigin naming the verb.

Decided Written as You get back The caller
before the run app.sequence · app.parallel · app.loop a runnable — plain data is the container
during the run ctx.run(agent) Promise<SubRunResult> waits
during the run ctx.spawn(agent) SpawnHandlewait(), abort() carries on
during the run ctx.dispatch(agent) DispatchHandle — two ids, no result carries on
during the run return someRunnable nothing ends; the target takes over

The three verbs live on the context object, so they are available in any step and in any tool's executeStepContext and ToolContext both extend OrchestrationContext. Every cell below but the last scripts the model with the test kit and needs no key. The last one asks a real model, so paste a key if you want it; it stays in this browser.

Step 1

Composition is data, and the result is a projection

Containers nest without ceremony: a parallel is a legal child of a sequence, and a step between them sees everything both wrote. Nothing is passed along a wire. Below, two agents run concurrently, a step counts what they said, and a third agent writes the summary.

import { adk, isAnnotationEvent, isAssistantEvent, isModelEndEvent } from '@animahealth/adk'
import { mockAgent, runTest, user, model } from '@animahealth/adk/testing'

const app = adk()

const scan = mockAgent('scan')
const risk = mockAgent('risk')
const writeUp = mockAgent('write_up')

// A parallel nested inside a sequence, with your own code in between.
const checks = app.parallel({ name: 'checks', runnables: [scan, risk] })

const tally = app.step({
  name: 'tally',
  execute: (ctx) => {
    const replies = ctx.session.events.filter(isAssistantEvent).length
    ctx.note(`${replies} replies before the write-up`, { kind: 'mark' })
    ctx.output({ repliesSoFar: replies })
  },
})

const review = app.sequence({ name: 'review', runnables: [checks, tally, writeUp] })

const reviewRun = await runTest(review, [
  user('Review this pull request.'),
  model('No secrets in the diff.'),
  model('Two risky migrations.'),
  model('Ship it once the migrations are split.'),
])

const composition = {
  status: reviewRun.status,
  modelCalls: reviewRun.events.filter(isModelEndEvent).length,
  outputText: reviewRun.result.output.text,
  outputValue: reviewRun.result.output.value,
  outputItems: reviewRun.result.output.items.length,
  notes: reviewRun.events.filter(isAnnotationEvent).map((e) => e.message),
}

composition

Three agents, three scripted replies, three model calls — the step costs none. Read the output fields carefully, because this is where composition surprises people. A container does not return its last child's return value. output.text is the last assistant event in the session and output.items is every assistant event in it, both computed from the ledger when the run finishes.

output.value is undefined here, and that is not a bug in the cell. ctx.output(value) records the value on the step's own result. A sequence builds its terminal result from the session, and never copies a child's value into it — so a step's output value survives only when that step is what you ran. To move a value between children, put it in ctx.state, which every runnable in the session can read.

The stopping rule follows the same logic. A sequence abandons the rest of its children when a child comes back error, aborted, max_steps, or yielded_tool; anything else and the next child runs. A step that calls ctx.skip() therefore skips itself, not the sequence. A loop applies exactly the same four-way test to each iteration, and re-evaluates its while predicate before every one.

Step 2

run, spawn, dispatch: the same handoff, three waits

The three verbs differ only in how the caller waits. ctx.run awaits a SubRunResult. ctx.spawn starts the agent and hands you a SpawnHandle you can wait() on later or abort(). ctx.dispatch starts it and returns only its name and invocation id — there is no result to collect. All three take the agent plus a message, or { input, timeout } when you want more: input.state seeds the child's temp state, and timeout is honoured by run and by a handle's wait()dispatch, having nothing to wait on, ignores it. All three run on the session you are already in.

import { isInvocationStartEvent } from '@animahealth/adk'

const grader = mockAgent('grader')
const summariser = mockAgent('summariser')
const auditor = mockAgent('auditor')

const triage = app.step({
  name: 'triage',
  execute: async (ctx) => {
    const graded = await ctx.run(grader, 'Grade this ticket.') // wait here
    const pending = ctx.spawn(summariser, 'Summarise the thread.') // wait later
    const sent = ctx.dispatch(auditor, 'Write the audit line.') // never wait
    const summary = await pending.wait()
    ctx.output({
      ranInline: graded.output.text,
      spawned: `${pending.agentName} · ${summary.status}`,
      dispatched: sent.agentName,
    })
  },
})

const triageRun = await runTest(triage, [
  user('Ticket 41 came in.'),
  model('P2'),
  model('The customer cannot log in.'),
  model('audit ok'),
])

const handoffs = {
  value: triageRun.result.output.value,
  origins: triageRun.events
    .filter(isInvocationStartEvent)
    .map((e) => `${e.agentName} ← ${e.handoffOrigin?.type ?? 'root'}`),
}

handoffs

Four invocations, and each one says how it was reached. That stamp is the reason the flat ledger stays readable: handoffOrigin carries the type and the parent's invocation id, so a reviewer can rebuild the call tree from rows alone. A step run at the top has no origin at all.

Note the auditor. Nothing awaited it, and it is in the ledger anyway — because fire-and-forget describes the caller, not the run. A spawned or dispatched agent is registered as a producer on the run's event channel, and the run's promise resolves only when every producer has finished. The parent's own invocation closes first; the work outlives the step, not the run. To outlive the run, you need a store and a second call, not a background task.

One refusal is worth knowing before you reach for it: ctx.run rejects an agent that yields. A delegate stopping to ask a human has nowhere to put the question, so it throws and points you at yielding tools in the parent agent instead.

Concurrent handoffs share the ledger, and the ledger is where output.text comes from. Two agents running at once on one session both resolve output.text to the same thing — whichever assistant event landed last. Read a concurrent branch's own words from its events, or give it an output schema, rather than trusting output.text to be its reply. Sequential ctx.run is unaffected.

Step 3

Transfer: the agent that replaces the one that called it

The fourth handoff has no verb. Return a runnable from a tool's execute, or from a step's, and the runtime treats it as a transfer: the current agent's invocation ends and the target's begins in its place. There is no nesting and no return trip. This is how a front desk hands a caller to a specialist.

import { openai } from '@animahealth/adk/openai'
import { z } from 'zod'

const billing = mockAgent('billing')

const handOver = app.tool({
  name: 'hand_over',
  description: 'Give the conversation to the billing specialist',
  schema: z.object({ reason: z.string() }),
  // Returning a runnable is the transfer. Nothing else about the tool changes.
  execute: () => billing,
})

const frontDesk = app.agent({
  name: 'front_desk',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('Hand billing questions over.'), app.context.history()],
  tools: [handOver],
})

const handedOver = await runTest(frontDesk, [
  user('My invoice is wrong.'),
  model({ toolCalls: [{ name: 'hand_over', args: { reason: 'invoice dispute' } }] }),
  model('I can see it — the VAT line is duplicated.'),
])

const transferred = {
  status: handedOver.status,
  finalText: handedOver.result.output.text,
  origins: handedOver.events
    .filter(isInvocationStartEvent)
    .map((e) => `${e.agentName} ← ${e.handoffOrigin?.type ?? 'root'}`),
  ledger: handedOver.events.map((e) => e.type),
}

transferred

Read the ledger tail: front_desk reaches invocation_end before billing opens its invocation_start. That is the difference from ctx.run, which nests the callee inside the caller. The run status is completed, not something transfer-specific — the caller is gone, so the target's outcome is the run's outcome. Temp state follows the transfer; the rest of the session was never separate.

A transfer that happens inside a ctx.run is resolved before that promise settles, so the calling step sees the final agent's result and never has to chase a chain. One restriction: an afterTool hook cannot transfer, because the tool result has already been written — see guardrails for where each hook can and cannot intervene.

Step 4

Two wrappers you would otherwise write twice

gated(runnable, check) puts a step in front of a runnable. The check receives a StepContext; return nothing and the runnable runs, call ctx.skip() and it does not. cached(runnable, { key, scope, ttlMs }) is that same gate with the check written for you: if the state key already holds a value, skip. Both return a Sequence carrying the wrapped runnable's own name, so wrapping does not rename anything in the ledger.

Both read state, and state lives in scopes on the session — session is the one every runnable in the run shares, which is why the runs below seed it through initialState. Sessions and state is the chapter.

import { cached, gated } from '@animahealth/adk'

const enrich = mockAgent('enrich')
const translate = mockAgent('translate')

// Run the translator unless the ticket is already in English.
const guarded = gated(translate, (ctx) => {
  if (ctx.state.locale === 'en') ctx.skip()
})

// Run the enricher only when session state has no profile yet.
const memoised = cached(enrich, { key: 'profile', scope: 'session' })

const pipeline = app.sequence({ name: 'pipeline', runnables: [memoised, guarded] })

const cold = await runTest(pipeline, [user('Hallo'), model('profile built'), model('Hello')])

const warm = await runTest(pipeline, [user('Hallo'), model('Hello')], {
  initialState: { session: { profile: { name: 'Ada' }, locale: 'de' } },
})

const english = await runTest(pipeline, [user('Hello'), model('profile built')], {
  initialState: { session: { locale: 'en' } },
})

const patterns = {
  cold: cold.events.filter(isAssistantEvent).map((e) => e.agentName),
  warmCacheHit: warm.events.filter(isAssistantEvent).map((e) => e.agentName),
  alreadyEnglish: english.events.filter(isAssistantEvent).map((e) => e.agentName),
  whatCachedBuilt: `${memoised.kind} '${memoised.name}' wrapping ${memoised.runnables.map((r) => r.name).join(', ')}`,
}

patterns

Cold, both run. Warm, the cached agent is skipped and only the translator speaks. Already in English, the gate skips the translator instead. The last field shows what cached actually built: a sequence named enrich whose single child is a step named enrich_gate, which returns the wrapped agent when the check passes. Nothing is hidden — it is the routing step from Five primitives, written once.

scope picks which state scope the key is read from and defaults to session; ttlMs adds an age test, measured from the state_change event that last wrote the key. An expired or absent key runs the runnable again. Neither wrapper ever writes the key; the wrapped runnable does, through its output config or a step of its own.

Step 5

A runnable that has not chosen an app yet

app.agent and friends bake in one app: its schema, its hooks, its error handlers. That is what you want inside a program and wrong for a library. spec defers the binding. spec.sequence()(fn) returns a function of an app, and app.use(thatSpec) calls it — so the same definition yields a different runnable per app, with each app's own configuration already applied.

import { spec } from '@animahealth/adk'

const other = adk({ name: 'other-app' })

// No app in sight — `boundApp` arrives when someone uses it.
const triageSpec = spec.sequence()((boundApp) => ({
  name: 'triage_flow',
  runnables: [
    boundApp.step({
      name: 'stamp',
      execute: (ctx) => {
        ctx.state.stamped = true
      },
    }),
    boundApp.agent({
      name: 'classifier',
      model: openai('gpt-5.6-luna'),
      context: [boundApp.context.system('Answer in one word.'), boundApp.context.history()],
    }),
  ],
}))

const here = app.use(triageSpec)
const there = other.use(triageSpec)

const specRun = await runTest(here, [user('My card was declined.'), model('billing')])

const reuse = {
  distinctRunnables: here !== there,
  built: `${here.kind} '${here.name}': ${here.runnables.map((r) => `${r.kind}:${r.name}`).join(' → ')}`,
  said: specRun.events.filter(isAssistantEvent).map((e) => e.text),
}

reuse

One definition, two runnables, and the cell ran the one bound to app. There is a spec factory per kind — tool, step, context, agent, sequence, parallel, loop — each taking an optional schema so the callback's ctx is typed before any app exists. This is how a package ships an agent that a consuming app finishes configuring, and it is the only reason to reach past app.*.

Step 6

Below the graph: one-shot calls and bounded concurrency

Some work is not an agent. Judging an answer, extracting a field, scoring a candidate — no tools, no memory of the conversation, no reason to appear in the ledger. app.ask(prompt, opts) is that call: a no-tools agent on a fresh session, so nothing it does touches yours. Pass a Zod schema and the return type is the parsed value rather than text; pass system, signal, or retries to shape the attempt. The model comes from opts.model or the app's defaultModel, and if neither is set the call throws rather than guessing.

fanout(thunks, { limit }) is the other half: run zero-argument async functions with at most limit in flight. It returns results in input order, turns a rejected thunk into null instead of failing the batch, and never rejects. It knows nothing about agents — it is the concurrency primitive the workflow runtime uses, exported because fanning out judgements is the common case.

This cell calls OpenAI with the key from the box above. Three tickets, two at a time, each answered as a typed object.

import { fanout } from '@animahealth/adk'

const live = adk({ defaultModel: openai('gpt-5.6-luna') })

const Verdict = z.object({
  severity: z.enum(['low', 'medium', 'high']),
  team: z.string(),
})

const tickets = [
  'The invoice VAT line is duplicated.',
  'The app crashes on every login attempt.',
  'Please send a copy of last month’s receipt.',
]

// fanout takes zero-argument thunks, so each ask is wrapped rather than started.
const askVerdict = (ticket: string) => () =>
  live.ask(`Route this support ticket. Ticket: ${ticket}`, { schema: Verdict })

const verdicts = await fanout(tickets.map(askVerdict), { limit: 2 })

verdicts

Three verdicts, in the order the tickets were written, whatever order they finished in. Only OutputParseError is retried — see Structured output — so a provider error surfaces immediately instead of being spent on re-asks. If you need tools, state, or a resumable pause, you have outgrown ask: build an agent and put it in the graph.

That is the whole handoff surface. Sessions and state covers the state these agents share, what the model actually sees covers what each one is shown, and dynamic workflows covers the experimental loader that builds a graph like this one from a file.

Agent Development Kit · Build

Yielding: stopping to ask, then sleeping as a row

Some tools cannot finish alone. A refund needs an approval; a booking needs the date the caller never gave. A yielding tool lets the agent stop mid-turn, hand the question out, and leave nothing running — the paused agent is a few rows in an event log, not a process holding a socket. Every cell below runs the shipped runtime with the model scripted, so none of them needs a key.

Audience · engineers building agents Needs · nothing (all cells are scripted) Package · @animahealth/adk (MIT)

Step 1

A tool that yields

A tool yields by declaring a yieldSchema: the shape of the answer it needs from outside. That is the only new field. app.tool requires an execute or a yieldSchema, so a yielding tool may have no execute at all — then the supplied input becomes the tool result.

Three optional hooks bracket the pause. prepare runs before it and can rewrite the arguments or record state. execute and finalize run after it, with ctx.input typed by the yieldSchema; otherwise they behave as Tools describes.

import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { z } from 'zod'

const app = adk()

const requestApproval = app.tool({
  name: 'request_approval',
  description: 'Ask a human to approve an action before performing it',
  schema: z.object({ action: z.string() }),
  // The answer this tool waits for. Its presence is what makes the tool yield.
  yieldSchema: z.object({ approved: z.boolean(), note: z.string().optional() }),
  prepare: (ctx) => {
    // Runs before the pause: park what a human (or another service) needs to see.
    ctx.state.pendingAction = ctx.args.action
    return ctx.args
  },
  execute: (ctx) => {
    // Runs after the pause, with the supplied answer already validated.
    const decision = ctx.input
    if (!decision?.approved) return { action: ctx.args.action, status: 'declined' }
    return { action: ctx.args.action, status: 'performed', note: decision.note }
  },
  finalize: (ctx) => {
    ctx.state.pendingAction = undefined
  },
})

const operator = app.agent({
  name: 'operator',
  model: openai('gpt-5.6-luna'),
  context: [
    app.context.system('Ask for approval before any destructive action.'),
    app.context.history(),
  ],
  tools: [requestApproval],
})

operator.name

Step 2

The run that stops

Script the model, supply no answer, and the run does not complete: it comes back yielded_tool, carrying the calls it is waiting on. Each is a tool_yield event with a callId — the handle you answer it by — plus the tool's name and the arguments prepare returned. The session says awaiting_input, and session.yieldedTools is the still-unanswered subset. State written in prepare is already durable.

import { runTest, user, model } from '@animahealth/adk/testing'

const paused = await runTest(operator, [
  user('Delete the 2019 archive'),
  model({
    toolCalls: [{ name: 'request_approval', args: { action: 'delete the 2019 archive' } }],
  }),
])

const pausedRun = paused.result

const waiting = {
  runStatus: pausedRun.status,
  sessionStatus: paused.session.status,
  askedFor:
    pausedRun.status === 'yielded_tool'
      ? pausedRun.yieldedTools.map((y) => ({ name: y.name, callId: y.callId, args: y.args }))
      : [],
  stateWhileAsleep: paused.session.state.pendingAction,
}

waiting

Before resuming anything, you can ask the ledger whether it is answerable. validateResumeState reads the events and returns one entry per yield that has no input yet; assertReadyToResume is the same check that throws. Both take events, not a live object, so a resume can be vetted anywhere the rows can be read.

import { validateResumeState, assertReadyToResume } from '@animahealth/adk'

let guard
try {
  assertReadyToResume(paused.events)
  guard = 'ready to resume'
} catch (error) {
  guard = error instanceof Error ? error.message : String(error)
}

const readiness = { unresolved: validateResumeState(paused.events), guard }

readiness

Run it again without answering and it will not resume. The runtime builds a resume context only when every pending yield has its input; with one missing it treats the call as a fresh start — a second invocation_start, a new invocation, and the pending yield still pending. Nothing throws. That is why the guard above exists: check the events, then resume.

Step 3

What a sleeping agent actually is

This is the whole of the paused agent. No process, no timer, no held connection — an append-only list of events that stops at invocation_yield. Note what is missing: there is no invocation_end. An unterminated invocation whose last event is a yield is the definition of a sleeping agent, and it costs exactly what those rows cost to store.

paused.events.map((event) => event.type)

Those rows outlive the process only if the app has a store, which is Stores, and what else the session around them carries is Sessions and state.

Step 4

Supplying the input

Answering is one call: session.input.tool({ callId, input }). It appends a tool_input event, which makes the yield resolved; the next run picks it up. The test kit has a step for exactly this — input(...), keyed by tool name, placed after the model turn that yielded. So the whole pause-and-resume fits in one script.

import { input, getToolResults } from '@animahealth/adk/testing'

const resumed = await runTest(operator, [
  user('Delete the 2019 archive'),
  model({
    toolCalls: [{ name: 'request_approval', args: { action: 'delete the 2019 archive' } }],
  }),
  // The answer a human would give — supplied here as session.input.tool would supply it.
  input({ request_approval: { approved: true, note: 'Owner signed off' } }),
  model('Archive deleted.'),
])

const settled = {
  status: resumed.status,
  unresolved: validateResumeState(resumed.events).length,
  toolResults: getToolResults(resumed.events),
  pendingStateClearedByFinalize: resumed.session.state.pendingAction === undefined,
  ledger: resumed.events.map((event) => event.type),
}

settled

The input is parsed against the yieldSchema before execute sees it. A mismatched answer does not throw and does not hang: it becomes a tool_result carrying an Invalid input error, which the model reads on the next turn like any other tool failure.

Read the tail of that ledger: tool_yield and invocation_yield close the first run, tool_input is the answer arriving, invocation_resume reopens the same invocation, and only then does tool_result appear. The agent was never running in between.

Step 5

Yielding for a message, not a tool

The other stop is conversational. An agent configured yields: true parks after each terminal reply instead of completing, waiting for the next user message — the status is yielded_message and the result carries a yieldedInvocationId rather than tool calls. Realtime models default to this. Answering is session.input.message(...); in the test kit it is another user(...) step.

const intake = app.agent({
  name: 'intake',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('Ask one question at a time.'), app.context.history()],
  tools: [],
  yields: true,
})

const asked = await runTest(intake, [user('I want to book a room'), model('Which date?')])

const answered = await runTest(intake, [
  user('I want to book a room'),
  model('Which date?'),
  user('Next Tuesday'),
  model('Booked for next Tuesday.'),
])

const conversation = {
  afterOneTurn: asked.status,
  afterTwoTurns: answered.status,
  said: answered.events.flatMap((event) => (event.type === 'assistant' ? [event.text] : [])),
  ledger: answered.events.map((event) => event.type),
}

conversation

Both runs end yielded_message: an agent that yields never completes on its own, it parks. The second ledger shows the shape of a conversation under this rule — invocation_yield, then user, then invocation_resume into the same invocation. A chat that has been idle for a month and one that replied a second ago are the same rows.

Run status The run is waiting for The result carries You answer with
yielded_tool an input for one or more tool calls yieldedTools (each with callId) session.input.tool({ callId, input })
yielded_message the next message from the user yieldedInvocationId session.input.message(...)

Step 6

The same protocol in a server

Nothing above changes when the pause spans a real request boundary: because the wait is stored rather than held, the process that asks the question and the process that receives the answer need not be the same one, or alive at the same time. The Bookings sample puts that loop behind a running app, and the request that resumes a row — the input.tools you post — belongs to Serving.

Agent Development Kit · Build

The Bookings sample — the agent that stops and asks

A slot-booking assistant in three files. It offers an appointment, calls a yielding tool, and the process exits — the session is now a row in a SQLite file. A later command supplies the approval and the run continues from exactly where it stopped. This chapter walks that code: the demo first, then the three sources in the order they are worth reading, then the test suite that proves the pause with no credentials at all.

Audience · engineers building agents Needs · nothing (the cells are scripted) Reads · src/clinic.ts, src/bookings.ts, src/cli.ts

Step 1

The demo, end to end

Four commands — ask, pending, approve, deny. The first one starts a run and deliberately does not finish it.

ask The model searches slots, offers one, and calls book_slot.
yield The call suspends instead of executing. The run returns yielded_tool.
exit The process ends. What is left is a row in bookings.db.
approve A new process answers the call. execute runs, once, and books it.

Start it:

$ npx tsx src/cli.ts ask "I need physio on Tuesday afternoon, for Alex Doe"
session session_8c1f…

agent  Tuesday 14:30 with R. Ellis is open — shall I book it?

paused book_slot {"slotId":"slot-02","bookedFor":"Alex Doe"}
       npx tsx src/cli.ts approve session_8c1f…
       npx tsx src/cli.ts deny session_8c1f… "why not"

The paused line is the yield. The model asked for book_slot; the runtime recorded the request and stopped the run rather than executing it. Then the process exited. No held connection — the session is rows on disk, and the pause is one of them.

Come back tomorrow, from another terminal. pending asks the store what is still unanswered; approve answers it.

$ npx tsx src/cli.ts pending
session_8c1f…  book_slot  {"slotId":"slot-02","bookedFor":"Alex Doe"}

$ npx tsx src/cli.ts approve session_8c1f…

agent  Booked — Tuesday 14:30 with R. Ellis.
booked CLINIC-001 — physiotherapy with R. Ellis, Tuesday 14:30, for Alex Doe

approve read the session out of SQLite, handed { approved: true } to the suspended call, and the tool's execute ran for the first time — in a process that did not exist when the model decided to call it. The booked line is typed state: session.state.confirmation, declared in the app's schema and read back intact after a round trip through the file.

Declining takes the same path, and the reason reaches the model:

$ npx tsx src/cli.ts deny session_8c1f… "Alex cannot do afternoons"

agent  Understood — 09:15 on Monday is the other physio slot. Shall I take it?

The agent's lines are the model's; the printed lines are not. A live run will not match these transcripts word for word. The session, paused and booked lines are printed by src/cli.ts, and those are exact.

Step 2

The world it books against — src/clinic.ts

The clinic is a constant and a Map. That is deliberate: the sample needs no database, no container and no network, and swapping these functions for real queries changes nothing above them. The agent only ever sees tools.

/** The week, as `[id, service, clinician, day, time]`. Weekday names never go stale. */
const TIMETABLE = [
  ['slot-01', 'physiotherapy', 'R. Ellis', 'Monday', '09:15'],
  ['slot-02', 'physiotherapy', 'R. Ellis', 'Tuesday', '14:30'],
  ['slot-03', 'physiotherapy', 'J. Okafor', 'Tuesday', '16:00'],
  ['slot-04', 'dental-hygiene', 'M. Haas', 'Tuesday', '11:00'],
  ['slot-05', 'dental-hygiene', 'M. Haas', 'Thursday', '15:45'],
  ['slot-06', 'eye-test', 'S. Vance', 'Wednesday', '10:30'],
  ['slot-07', 'eye-test', 'S. Vance', 'Friday', '13:00'],
] as const satisfies ReadonlyArray<readonly [string, Service, string, string, string]>

const SLOTS: readonly Slot[] = TIMETABLE.map(([id, service, clinician, day, time]) => ({
  id,
  service,
  clinician,
  day,
  time,
}))

Three readers come off that list. openSlots(filter) returns the unbooked slots matching an optional service and weekday; findSlot and isBooked are the two lookups the booking tool needs. One function writes — bookSlot — and it is the side effect a human approves.

Step 3

The whole program — src/bookings.ts

One app, one state schema, two tools, one agent. Everything the ADK contributes to this sample is in this file. Start here; the rest is scaffolding around it.

import { fileURLToPath } from 'node:url'
import { z } from 'zod'

import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { sqliteStore } from '@animahealth/adk/stores/sqlite'

import { SERVICES, bookSlot, findSlot, isBooked, openSlots } from './clinic.js'

Three imports off the package: the core, the OpenAI model factory, the SQLite store. Providers and stores are subpath exports and optional peers — importing the core pulls in neither.

/**
 * What a completed booking looks like. Declaring it in the app schema is what makes
 * `session.state.confirmation` typed everywhere downstream, including after it is read back out of
 * SQLite in a different process.
 */
const confirmation = z.object({
  reference: z.string(),
  slotId: z.string(),
  service: z.string(),
  clinician: z.string(),
  day: z.string(),
  time: z.string(),
  bookedFor: z.string(),
})

export type Confirmation = z.infer<typeof confirmation>

export const app = adk({
  name: 'bookings',
  schema: { session: { confirmation: confirmation.optional() } },
  store: sqliteStore(DB_PATH),
})

schema.session is what makes state typed downstream. confirmation is optional because a session has none until a booking is approved. store decides where the session sleeps: one file, created on first use, safe to delete.

The deterministic tool. The model picks the arguments; this code decides the answer.

/** Deterministic. The model chooses the arguments; this code decides the answer. */
const searchSlots = app.tool({
  name: 'search_slots',
  description: 'List the open appointment slots at the clinic, optionally filtered.',
  schema: z.object({
    service: z.enum(SERVICES).optional().describe('Only slots for this service'),
    day: z.string().optional().describe('Only slots on this weekday, e.g. "Tuesday"'),
  }),
  execute: (ctx) => ({ slots: openSlots(ctx.args) }),
})

And the yielding one — the only structural difference is yieldSchema.

/**
 * The yielding tool. `yieldSchema` is the contract for the human's answer; because it is present,
 * ADK suspends the run at the call instead of running `execute`. `execute` runs later, once, with
 * that answer in `ctx.input`.
 */
const bookSlotTool = app.tool({
  name: 'book_slot',
  description:
    'Book one open slot. A human reviews every call to this tool before it takes effect, so propose a single slot and call it once.',
  schema: z.object({
    slotId: z.string().describe('The id of an open slot, from search_slots'),
    bookedFor: z.string().describe('The name the appointment is under'),
  }),
  yieldSchema: z.object({
    approved: z.boolean().describe('true to book the slot, false to decline it'),
    note: z.string().optional().describe('Why it was declined — the agent reads this'),
  }),
  execute: (ctx) => {
    const slot = findSlot(ctx.args.slotId)
    if (!slot) {
      return { booked: false as const, reason: `There is no slot called ${ctx.args.slotId}.` }
    }
    if (isBooked(slot.id)) {
      return { booked: false as const, reason: `${slot.id} has already been booked.` }
    }
    if (!ctx.input?.approved) {
      return {
        booked: false as const,
        reason: ctx.input?.note ?? 'A human declined this booking. Offer a different slot.',
      }
    }

    const booking = bookSlot(slot, ctx.args.bookedFor)
    // Typed, and durable: this write becomes a state_change event, committed to SQLite with the
    // rest of the session, and readable as `session.state.confirmation` forever after.
    ctx.state.confirmation = booking
    return { booked: true as const, ...booking }
  },
})

ctx.args is what the model asked for; ctx.input is what the human answered, already validated against yieldSchema.

The agent is the rest of the file.

export const bookingAgent = app.agent({
  name: 'bookings',
  model: openai('gpt-5.6-luna'),
  context: [
    app.context.system(
      [
        'You book appointments for a small clinic.',
        'Call search_slots before you offer anything. Never invent a slot or a time.',
        'Offer one slot at a time, then call book_slot for it. That call pauses for a human.',
        'If a booking is declined, read the note and offer the next best open slot.',
        'Answer in one or two sentences.',
      ].join('\n'),
    ),
    app.context.history(),
  ],
  tools: [searchSlots, bookSlotTool],
})

The system prompt does the work a schema cannot: it tells the model to search before it offers, and to propose one slot at a time — because every book_slot call spends a human's attention. The mechanism itself, in isolation, is Stopping to ask: prepare, finalize, the resume guard, and yielding for a message rather than a tool.

Step 4

Four commands, two processes — src/cli.ts

Nothing here is special machinery. ask is the whole lifecycle: create a session, put a message in, run, commit, print.

async function ask(text: string): Promise<void> {
  requireKey()
  const session = await app.sessions.create()
  session.input.message(text)
  const result = await app.run(bookingAgent, { session })
  await app.sessions.commit(session)

  console.log(`session ${session.id}`)
  report(session, result.output.text)
}

app.run returns when the run stops — completed or yielded, it is the same call either way, and the status says which. The commit is what makes a pause durable: events buffer in memory until it is called.

Printing is where the pause becomes visible.

/** Print what the agent said, and what it is now waiting for. */
function report(session: Session<typeof app.schema>, text: string | undefined): void {
  if (text) console.log(`\nagent  ${text}`)

  const confirmation = session.state.confirmation
  if (confirmation) {
    console.log(
      `booked ${confirmation.reference} — ${confirmation.service} with ${confirmation.clinician}, ` +
        `${confirmation.day} ${confirmation.time}, for ${confirmation.bookedFor}`,
    )
  }

  const [waiting] = session.yieldedTools
  if (waiting) {
    console.log(`\npaused ${waiting.name} ${JSON.stringify(waiting.args)}`)
    console.log(`       npx tsx src/cli.ts approve ${session.id}`)
    console.log(`       npx tsx src/cli.ts deny ${session.id} "why not"`)
  }
}

session.yieldedTools is the unanswered set — empty after a completed run, one entry after this one. session.state.confirmation is typed by the app schema, so the fields interpolated into the booked line are checked at compile time, not hoped for at runtime.

The queue of things waiting for a human is therefore a query, not a broker:

async function pending(): Promise<void> {
  const sessions = await app.sessions.list()
  let found = 0

  for (const { id } of sessions) {
    const session = await app.sessions.get(id)
    const [waiting] = session?.yieldedTools ?? []
    if (!session || !waiting) continue
    found++
    console.log(`${session.id}  ${waiting.name}  ${JSON.stringify(waiting.args)}`)
  }

  if (found === 0) console.log(`nothing is waiting for a human (${DB_PATH})`)
}

And the resume is one line of protocol wrapped in the same run-and-commit:

async function decide(sessionId: string, approved: boolean, note?: string): Promise<void> {
  requireKey()
  const session = await app.sessions.get(sessionId)
  if (!session) {
    throw new Error(`No session ${sessionId}. Try: npx tsx src/cli.ts pending`)
  }

  const [waiting] = session.yieldedTools
  if (!waiting) {
    throw new Error(`Session ${sessionId} is not waiting on anything.`)
  }

  // The resume. `callId` ties the answer to the exact suspended call; `input` is validated
  // against the tool's yieldSchema before execute() ever sees it.
  session.input.tool({ callId: waiting.callId, input: { approved, note } })
  const result = await app.run(bookingAgent, { session })
  await app.sessions.commit(session)

  report(session, result.output.text)
}

callId ties the answer to the exact suspended call. After that it is ask again — run, commit, print — except this run starts inside a tool call that a previous process left open, and it is approve that pays for the booking's side effect.

Step 5

The same arc, with no key — test/bookings.test.ts

runTest replaces exactly one thing: the model. The tools still run, the ledger still accrues, the yield still happens. So the assertion worth making is available on any fork, with no credentials.

import { beforeEach, describe, expect, test } from 'vitest'

import type { Runnable } from '@animahealth/adk'
import { getToolCalls, getToolResults, input, model, runTest, user } from '@animahealth/adk/testing'

import { bookingAgent } from '../src/bookings.js'
import { isBooked, openSlots, resetClinic } from '../src/clinic.js'

/**
 * `runTest` is typed against the schema-erased `Runnable`, so an agent built on an app that
 * declares a state schema needs this cast. Types only — at runtime it is the same object
 * `app.run()` takes.
 */
const agent = bookingAgent as unknown as Runnable

beforeEach(resetClinic)

First the pause. Script the model into calling book_slot, then stop.

describe('book_slot yields', () => {
  test('the run stops at the call and nothing is booked', async () => {
    const run = await runTest(agent, [
      user('Book slot-02 for Alex Doe.'),
      model({
        toolCalls: [{ name: 'book_slot', args: { slotId: 'slot-02', bookedFor: 'Alex Doe' } }],
      }),
    ])

    expect(run.status).toBe('yielded_tool')
    expect(run.session.yieldedTools).toHaveLength(1)
    expect(run.session.yieldedTools[0]?.name).toBe('book_slot')

    // The pause is real: execute() has not run, so the world is untouched.
    expect(isBooked('slot-02')).toBe(false)
    expect(run.session.state.confirmation).toBeUndefined()
  })
})

Status yielded_tool, one entry waiting, the slot still open and no confirmation in state. That is the pause asserted rather than asserted-about.

Then the two resume cases. input(...) stands in for what a human sends hours later from another process — in a script it is one line in the right place.

  test('approval executes the booking and returns a typed confirmation', async () => {
    const run = await runTest(agent, [
      user('Book slot-02 for Alex Doe.'),
      model({
        toolCalls: [{ name: 'book_slot', args: { slotId: 'slot-02', bookedFor: 'Alex Doe' } }],
      }),
      // The human's answer, shaped by the tool's yieldSchema. In the CLI this arrives from a
      // separate process, minutes or days later.
      input({ book_slot: { approved: true } }),
      model('Booked — Tuesday 14:30 with R. Ellis.'),
    ])

    expect(run.status).toBe('completed')
    expect(isBooked('slot-02')).toBe(true)
    expect(run.session.state.confirmation).toEqual({
      reference: 'CLINIC-001',
      slotId: 'slot-02',
      service: 'physiotherapy',
      clinician: 'R. Ellis',
      day: 'Tuesday',
      time: '14:30',
      bookedFor: 'Alex Doe',
    })
  })

And the demo's other branch, where the reason travels back to the model:

  test('a refusal leaves the slot open and hands the reason back to the agent', async () => {
    const run = await runTest(agent, [
      user('Book slot-02 for Alex Doe.'),
      model({
        toolCalls: [{ name: 'book_slot', args: { slotId: 'slot-02', bookedFor: 'Alex Doe' } }],
      }),
      input({ book_slot: { approved: false, note: 'Alex cannot do afternoons.' } }),
      model('Understood — 09:15 on Monday is the other physio slot. Shall I take it?'),
    ])

    expect(run.status).toBe('completed')
    expect(isBooked('slot-02')).toBe(false)
    expect(run.session.state.confirmation).toBeUndefined()
    expect(getToolResults(run.events)[0]?.result).toEqual({
      booked: false,
      reason: 'Alex cannot do afternoons.',
    })
  })

The whole file runs under:

npm test

The same script, running here

The cells below are that test, adapted only as far as this page requires: the substrate serves the ADK, its test kit and zod, so the clinic module is inlined and the SQLite store is dropped. The schema, both tools, the prompt and the agent are the sample's own. Persistence is the one thing a cell cannot demonstrate here, so src/cli.ts above stands as the evidence for it.

import { adk } from '@animahealth/adk'
import type { Runnable } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { z } from 'zod'

// src/clinic.ts and src/bookings.ts, joined and trimmed to what this page can serve: the clinic
// module is inlined, the SQLite store is dropped, two of the seven slots are kept. The schema,
// both tools and the agent are the sample's.
const confirmation = z.object({
  reference: z.string(),
  slotId: z.string(),
  service: z.string(),
  clinician: z.string(),
  day: z.string(),
  time: z.string(),
  bookedFor: z.string(),
})

const SLOTS = [
  { id: 'slot-01', service: 'physiotherapy', clinician: 'R. Ellis', day: 'Monday', time: '09:15' },
  { id: 'slot-02', service: 'physiotherapy', clinician: 'R. Ellis', day: 'Tuesday', time: '14:30' },
]

const ledger = new Map<string, z.infer<typeof confirmation>>()
let nextReference = 1

const openSlots = (filter: { service?: string; day?: string }) =>
  SLOTS.filter(
    (slot) =>
      !ledger.has(slot.id) &&
      (filter.service === undefined || slot.service === filter.service) &&
      (filter.day === undefined || slot.day.toLowerCase() === filter.day.toLowerCase()),
  )

const findSlot = (slotId: string) => SLOTS.find((slot) => slot.id === slotId)
const isBooked = (slotId: string) => ledger.has(slotId)
const resetClinic = () => {
  ledger.clear()
  nextReference = 1
}

const bookings = adk({
  name: 'bookings',
  schema: { session: { confirmation: confirmation.optional() } },
})

const searchSlots = bookings.tool({
  name: 'search_slots',
  description: 'List the open appointment slots at the clinic, optionally filtered.',
  schema: z.object({
    service: z.string().optional().describe('Only slots for this service'),
    day: z.string().optional().describe('Only slots on this weekday, e.g. "Tuesday"'),
  }),
  execute: (ctx) => ({ slots: openSlots(ctx.args) }),
})

const bookSlotTool = bookings.tool({
  name: 'book_slot',
  description:
    'Book one open slot. A human reviews every call to this tool before it takes effect, so propose a single slot and call it once.',
  schema: z.object({
    slotId: z.string().describe('The id of an open slot, from search_slots'),
    bookedFor: z.string().describe('The name the appointment is under'),
  }),
  yieldSchema: z.object({
    approved: z.boolean().describe('true to book the slot, false to decline it'),
    note: z.string().optional().describe('Why it was declined — the agent reads this'),
  }),
  execute: (ctx) => {
    const slot = findSlot(ctx.args.slotId)
    if (!slot) {
      return { booked: false as const, reason: `There is no slot called ${ctx.args.slotId}.` }
    }
    if (isBooked(slot.id)) {
      return { booked: false as const, reason: `${slot.id} has already been booked.` }
    }
    if (!ctx.input?.approved) {
      return {
        booked: false as const,
        reason: ctx.input?.note ?? 'A human declined this booking. Offer a different slot.',
      }
    }

    const booking = {
      reference: `CLINIC-${String(nextReference++).padStart(3, '0')}`,
      slotId: slot.id,
      service: slot.service,
      clinician: slot.clinician,
      day: slot.day,
      time: slot.time,
      bookedFor: ctx.args.bookedFor,
    }
    ledger.set(slot.id, booking)
    ctx.state.confirmation = booking
    return { booked: true as const, ...booking }
  },
})

const bookingAgent = bookings.agent({
  name: 'bookings',
  model: openai('gpt-5.6-luna'),
  context: [
    bookings.context.system(
      [
        'You book appointments for a small clinic.',
        'Call search_slots before you offer anything. Never invent a slot or a time.',
        'Offer one slot at a time, then call book_slot for it. That call pauses for a human.',
        'If a booking is declined, read the note and offer the next best open slot.',
        'Answer in one or two sentences.',
      ].join('\n'),
    ),
    bookings.context.history(),
  ],
  tools: [searchSlots, bookSlotTool],
})

// The sample's test file makes this same cast: runTest takes the schema-erased Runnable.
const agent = bookingAgent as unknown as Runnable

bookingAgent.name

Now the first run, with nothing supplied. It should stop at the call and leave the world untouched.

import { model, runTest, user } from '@animahealth/adk/testing'

resetClinic()

const paused = await runTest(agent, [
  user('Book slot-02 for Alex Doe.'),
  model({ toolCalls: [{ name: 'book_slot', args: { slotId: 'slot-02', bookedFor: 'Alex Doe' } }] }),
])

const stopped = {
  status: paused.status,
  waitingOn: paused.session.yieldedTools.map((y) => ({ name: y.name, args: y.args })),
  slotStillOpen: !isBooked('slot-02'),
  confirmation: paused.session.state.confirmation,
}

stopped

Add the human's answer and the same script completes. The printed ledger carries the whole pause: the yield, the answer arriving, the invocation reopening, and only then the tool result. Between those events, in the sample, is a process boundary.

import { input } from '@animahealth/adk/testing'

resetClinic()

const resumed = await runTest(agent, [
  user('Book slot-02 for Alex Doe.'),
  model({ toolCalls: [{ name: 'book_slot', args: { slotId: 'slot-02', bookedFor: 'Alex Doe' } }] }),
  // What a human sends, hours later, from anywhere. In the sample it arrives from another process.
  input({ book_slot: { approved: true } }),
  model('Booked — Tuesday 14:30 with R. Ellis.'),
])

const settled = {
  status: resumed.status,
  confirmation: resumed.session.state.confirmation,
  ledger: resumed.events.map((event) => event.type),
}

settled

Edit the script and run again — decline it, change the slot id, book a slot that does not exist. The tool's real branches answer, because the tool is real. The kit itself is Testing agents without a model.

Step 6

Clone and run it

Four steps. The first is the one a fresh clone cannot skip: the sample depends on the ADK by path — "@animahealth/adk": "file:.." — so the package in the directory above sample/ has to be built before the sample can resolve it.

pnpm install && pnpm run build

The other three happen inside sample/:

cd sample
npm install
export OPENAI_API_KEY=...            # only the live run needs this; the tests do not
npx tsx src/cli.ts ask "I need physio on Tuesday afternoon, for Alex Doe"

The key is only for the three commands that call a model: ask, approve, deny. pending reads the store and needs nothing, and neither does npm test. The only thing written to disk is bookings.db; delete it to start over.

SQLite here; Postgres and DynamoDB implement the same interface, compared in Where a sleeping agent lives. And Serving it puts this same resume behind an HTTP handler instead of a CLI.

Agent Development Kit · Build

Structured output: prose in, a typed object out

One field turns an agent's reply from a paragraph into a value your code can branch on. Behind that field is a parser built for what models actually emit — fenced, prefaced, single-quoted, trailing-comma'd, sometimes truncated. The cells below run the shipped parser on that mess and show what it repairs, what it coerces, and what it refuses.

Owns · output, the JSON parser, parse failure Cells · ten need no key, one needs yours Package · @animahealth/adk (MIT)

Your key

Ten cells run without one

Everything up to the last cell scripts the model with the test kit, so it runs here with no credentials. The final cell asks a real model for a typed object; that one needs a key. It lives in this browser's localStorage and goes only to api.openai.com.

Step 1

A schema on output

An agent's output field accepts a Zod schema — { schema, key?, mode? } — or the name of a key in the app's session schema. Give it a schema and the run hands back the parsed, validated object alongside the text it came from. Build the agent first.

import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { z } from 'zod'

const app = adk()

const Triage = z.object({
  urgency: z.enum(['routine', 'urgent', 'emergency']),
  symptoms: z.array(z.string()),
  followUpDays: z.number(),
})

const triager = app.agent({
  name: 'triager',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('Triage the patient message.'), app.context.history()],
  output: { schema: Triage },
})

triager.name

Now script the model's reply — deliberately the kind of thing a model returns when nobody is constraining it. Prose on both sides, an unquoted key, single quotes, a comma-separated string where an array belongs, a number sent as text. The test kit replaces only the model, so the real output path runs on it.

import { runTest, user, model } from '@animahealth/adk/testing'

const scripted = `Reading it back: { urgency: 'URGENT', symptoms: "chest tightness, shortness of breath", followUpDays: "2" } — book them in.`

const triaged = await runTest(triager, [
  user('chest tightness since this morning and I cannot get a full breath'),
  model(scripted),
])

const shape = {
  text: triaged.result.output.text,
  value: triaged.output,
  assistantEvents: triaged.result.output.items.length,
  status: triaged.status,
}

shape

A run result carries an Output: text, value, items (every assistant event), and media. The raw sentence is still there in text — the schema adds value, it does not replace anything. The test kit's result is the same RunResult app.run returns, and its shorthand .output is that result's output.value.

Add a key and the parsed object is also written into session state, so the next agent in a sequence reads it as data rather than re-reading the transcript. When the app declares a session schema, naming that key alone — output: 'triage' — is shorthand for the same { key, schema, mode: 'native' }, with the schema taken from the declaration.

const recorder = app.agent({
  name: 'recorder',
  model: openai('gpt-5.6-luna'),
  context: [app.context.history()],
  output: { key: 'triage', schema: Triage },
})

const recorded = await runTest(recorder, [
  user('sore throat for two days, no fever'),
  model(`{ urgency: 'Routine', symptoms: "sore throat", followUpDays: 7 }`),
])

recorded.result.state

Step 2

The parser under it

Nothing about that was agent-specific. The output path calls the package's parser, and the parser is exported from the core entry: parse, parsePartial, createParser, parseJsonish, coerce. Run it directly on the same string and you get the same object.

A ParseResult is a union — value on the success branch, partial and errors on the failure branch — and both branches carry corrections and totalScore. A correction is a receipt: the path it touched, what it found, what it produced, and why. The score is the cost of getting there, so a high score is a signal to look at your prompt, not a failure.

import { parse } from '@animahealth/adk'

const parsed = parse(scripted, Triage)

const receipts = {
  parsed: parsed.success ? parsed.value : parsed.errors,
  score: parsed.totalScore,
  corrections: parsed.corrections.map((c) => ({
    path: c.path.join('.'),
    type: c.type,
    from: c.from,
    to: c.to,
  })),
}

receipts

One layer below is parseJsonish: text to a plain value, no schema involved. It strips code fences, finds JSON embedded in a sentence, closes unterminated strings and brackets, and accepts single quotes, unquoted keys and trailing commas. It does not throw and it does not report failure — given a sentence with no JSON in it, it hands the sentence back as a string. That is why a schema, not this layer, is what rejects bad output.

import { parseJsonish } from '@animahealth/adk'

const messyInputs = [
  '{"urgency": "urgent"}',
  '```json\n{"urgency": "urgent"}\n```',
  "{urgency: 'urgent', followUpDays: 2,}",
  'The answer is {"urgency": "urgent"} — hope that helps.',
  '{"urgency": "urgent", "symptoms": ["chest',
  'I would rather just chat.',
]

messyInputs.map((text) => {
  const repaired = parseJsonish(text)
  return { input: text, value: repaired.value, got: typeof repaired.value }
})

Step 3

Coercion, and switching it off

Valid JSON with the wrong types is the common case, so the schema stage coerces before it validates. Strings become numbers, booleans and dates; 'yes' is true; enum members match case-insensitively and across underscores and spaces; a comma-separated string becomes an array; a lone value becomes a one-element array; defaults fill absent keys. Every one of those lands as a correction.

import { coerce } from '@animahealth/adk'

const Reading = z.object({
  status: z.enum(['ok', 'high', 'low']),
  systolic: z.number(),
  tags: z.array(z.string()),
  reviewed: z.boolean(),
  notes: z.string().default('none'),
})

const coerced = coerce(
  { status: 'HIGH', systolic: '142', tags: 'urgent, recheck', reviewed: 'yes' },
  Reading,
)

coerced.success ? coerced.value : coerced.errors

Coercion and the text extraction above it are both configuration, and both default to on: createParser(schema, { coerceTypes, extractFromMarkdown }). Turn them off and the parser becomes JSON.parse plus schema.safeParse — strict, and useful when you would rather see the model's sloppiness than absorb it. Note the failure's stage: json when the text never parsed, coercion or validation when it parsed but did not fit.

import { createParser } from '@animahealth/adk'

const strict = createParser(Triage, { coerceTypes: false, extractFromMarkdown: false })

const refused = {
  onProse: strict.parse(scripted).errors,
  onCleanJsonWrongTypes: strict.parse('{"urgency":"urgent","symptoms":["cough"],"followUpDays":"2"}')
    .errors,
}

refused

Step 4

When nothing valid comes back

If the parser cannot produce a value the schema accepts — and cannot rescue a partial object that does — the run throws OutputParseError rather than handing you an output.value you would have to re-check. Script a model that simply refuses to answer in JSON and catch it.

let caught: unknown

try {
  await runTest(triager, [
    user('how are you today?'),
    model('I would rather just chat, thanks.'),
  ])
} catch (error) {
  caught = error
}

caught instanceof Error ? { name: caught.name, message: caught.message } : caught

Match on error.name, not instanceof. The OutputParseError class is exported from the core entry and carries rawOutput, schema, parseErrors, partial and corrections where it is thrown — but an error that crosses a run's event channel is reconstructed on the far side with its name and message preserved and its own fields gone. The package's own retry logic checks both, for exactly this reason.

That name is what app.ask(prompt, { schema }) retries on: a re-run budget that defaults to two when a schema is set and zero when it is not. Only a parse error is retried — a provider or transport error surfaces immediately, because re-asking cannot fix it.

Step 5

Native, prompt, and a real model

mode decides where the schema is enforced, and defaults to 'native': the adapter sends the schema to the provider as a response format, so the model is constrained before it writes a token. mode: 'prompt' withholds it, leaving the schema to your own prompt — which is where ctx.outputSchema comes in. It is the schema rendered as compact text for a system message. This cell asks for it, then reads back the system event the renderer produced, so you can see exactly what the model was told.

let rendered: string[] = []

const narrator = app.agent({
  name: 'narrator',
  model: openai('gpt-5.6-luna'),
  context: [
    app.context.system((ctx) => `Reply with JSON matching:\n${ctx.outputSchema}`),
    app.context((ctx) => {
      rendered = ctx.events.flatMap((e) => (e.type === 'system' ? [e.text] : []))
      return ctx
    }),
    app.context.history(),
  ],
  output: { schema: Triage, mode: 'prompt' },
})

await runTest(narrator, [user('chest tightness since this morning'), model(scripted)])

rendered

Now the real thing. Same agent as step 1, same schema, an actual model on the other end — and because its mode is the default, the schema goes to the provider as a response format. The parser still runs on whatever comes back; native mode makes its job easy rather than unnecessary. Edit the message and run it again.

const live = await app.run(
  triager,
  'Since last night: fever of 39, a stiff neck, and the light is hurting my eyes.',
)

live.output.value

Native mode wants an object schema: a top-level array or scalar is still parsed and validated on the way out, it just is not sent to the provider as a format. Either way the choice is recorded — a model_start event names the output schema its call was rendered with, so an audit of a past run can tell a free-text turn from a structured one without replaying it. This last cell reads that back off the scripted run from step 1, so it needs no key.

triaged.events.flatMap((event) =>
  event.type === 'model_start'
    ? [{ agent: event.agentName, outputSchema: event.outputSchema }]
    : [],
)

Where this goes next: a typed value is what an assertion should read, so Testing agents without a model checks a run's output rather than its prose. And the same field is what makes app.ask(prompt, { schema }) hand back a parsed object instead of text — see Many agents.

Agent Development Kit · Build

Web tools: search, read, and look at a page

Three tools ship built: web_search finds pages, fetch_page turns one into markdown (or hands over a PDF or an image), and take_screenshot photographs it. They are ordinary function tools — your process makes the request, your process pays for it — so what this chapter really teaches is the two seams you plug your own code into, and the convention that carries pixels back to the model.

Builds on · Tools Cells · no key needed Surface · app.tools, @animahealth/adk/web

Step 1

They are already on the app

app.tools carries the three factories, so the common case needs no import beyond the one you already have. Each takes a config object and returns a tool you put in an agent's tools array — the same FunctionTool app.tool returns. Building one touches no network and starts no browser: everything happens when the model calls it.

Every cell on this page runs with no key. The model's turns are scripted by the test kit, and the web tools themselves really execute — against stand-ins you can read, so nothing here depends on a live site being up.

import { adk } from '@animahealth/adk'

const app = adk()

const pageFetcher = app.tools.fetchPage()
const screenshotter = app.tools.takeScreenshot({ maxTargets: 4 })

// A description is prompt, and config writes part of it: maxTargets above lands in the sentence
// the model reads before deciding.
const contracts = [pageFetcher, screenshotter].map((tool) => ({
  name: tool.name,
  description: tool.description,
}))

contracts

The @animahealth/adk/web subpath exports the same three factories in their ToolSpec form, which builds the same tool; reach for the subpath when you also need what only it exports: the SerperProvider class, the pipeline and result types, and the standalone helpers (fetchPagesBatch, screenshotPage, screenshotPages, closeBrowser).

One thing these are not: a provider's own hosted search. That runs inside the model provider and has no execute of yours; the ADK models it separately as a provider tool. Everything on this page is a function tool your process runs.

Step 3

fetchPage, and the seam under it

One tool, three kinds of thing at the other end of a URL. An HTML page is reduced to article markdown — Readability picks the content, Turndown renders it. A PDF comes back as a document the model can look at, truncated to maxPdfPages (default 10). An image comes back as an image, scaled to fit 1280px. The kind is decided by the response's content-type, falling back to the URL's extension.

The model passes urls — one string or an array, capped at maxUrls (default 20) — and optionally includeSelectors, which annotates the markdown with [@selector] markers naming elements that can be screenshotted later. You get one entry per URL, in order.

type FetchedPage = {
  success: boolean
  url: string
  title?: string
  content?: string // markdown, for HTML pages
  wordCount?: number
  httpStatus?: number
  error?: 'timeout' | 'blocked' | 'not_found' | 'network_error' | 'api_error'
  pipeline?: string // set by the pipeline that answered, if one did
  raw?: unknown // a pipeline's structured payload, if it supplied one
  mediaIndex?: number // this entry's PDF or image, by position — see the next section
}

A failure is never an exception here: the entry comes back with success: false and an error from that union, and the model reads it alongside the entries that worked. The rest of the config is timeout (default 30s per URL), concurrency (default 5), render, proxy, includeSelectors, and pipelines.

render: true is the escape hatch for pages that are empty without JavaScript: the fetch goes through a real browser instead of fetch. It runs on the shared browser of step 5, and it is the only fetch path a proxy applies to.

A FetchPipeline is the seam: a name, the patterns it claims, and a fetch. Pipelines are consulted before the network, in order, and the first one whose pattern matches and whose result has success: true wins. A pipeline that returns success: false is treated as having declined, and the ordinary fetch runs anyway — so a pipeline that exists to refuse a URL must succeed at refusing it.

import { fetchPage } from '@animahealth/adk/web'

const noInternalHosts = {
  name: 'internal-hosts',
  patterns: [/\.internal\.example\.com/],
  // success: true — this is the answer, not a failed attempt. Returning false would fall through
  // to the real fetch and defeat the point.
  async fetch(url: string) {
    return {
      success: true,
      url,
      title: 'Blocked',
      content: 'Internal hosts are not readable from here. Ask the platform team instead.',
    }
  },
}

const guardedFetch = app.use(fetchPage({ pipelines: [noInternalHosts] }))

Step 4

How pixels reach the model

A tool result is JSON, and a PDF is not. The ADK's convention for that is one reserved key: a tool may return __media alongside its result, and the runtime lifts it off before the result is recorded. The result the model reads never contains it; the tool_result event carries it in a sibling field, media, and the provider serializer attaches those parts to the tool's answer — an image part for an image, a file part for a document.

fetch_page and take_screenshot both use it, which is why an entry names a mediaIndex rather than carrying bytes. The cell below runs the real fetch_page tool with a pipeline that answers locally, so the only invented thing is the payload the pipeline hands back.

// A pipeline that claims one URL pattern and answers it from memory: no network, no jsdom, no
// browser. Where `media` sits here, the real tool puts the bytes it downloaded.
const localPipeline = {
  name: 'stand-in',
  patterns: [/example\.com\/report/],
  fetch: async (url: string) => ({
    success: true,
    url,
    title: 'Q3 report',
    content: '# Q3 report\n\nRevenue held flat.',
    wordCount: 6,
    pipeline: 'stand-in',
    media: { type: 'document' as const, mimeType: 'application/pdf', data: 'JVBERi0xLjQK' },
  }),
}

const reportFetcher = app.tools.fetchPage({ pipelines: [localPipeline] })

const reader = app.agent({
  name: 'reader',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('Read the page before summarising it.'), app.context.history()],
  tools: [reportFetcher],
})

const fetchRun = await runTest(reader, [
  user('Summarise https://example.com/report'),
  model({ toolCalls: [{ name: 'fetch_page', args: { urls: 'https://example.com/report' } }] }),
  model('Revenue held flat in Q3.'),
])

const fetched = findEventsByType(fetchRun.events, 'tool_result')[0]

const carried = {
  // What the model reads as the call's result: no bytes, just a pointer.
  result: fetched?.result,
  // What travels beside it. Printed as a summary — the data is base64.
  media: fetched?.media?.map((part) => ({
    kind: part.type,
    describes: part.source.type === 'base64' ? part.source.mimeType : part.source.url,
  })),
}

carried

The rule this demonstrates. __media is stripped from the result and lands on event.media. A result that quietly grew a __media key — from a finalize, from your own tool — is treated the same way, so do not use that key for anything else.

Step 5

takeScreenshot and its one browser

Each target is a url and an optional CSS selector; the model may pass one target or an array of up to maxTargets (default 10). A selector captures that element — and if it matches nothing visible, the capture falls back to the viewport rather than failing. Without a selector, fullPage (default true) decides between the whole scrollable page and the viewport. Images are capped at 1280px on both axes, whatever maxWidth and maxHeight ask for.

Results follow the same split as fetch_page: a small entry per target, and the pictures on media.

type Shot = {
  success: boolean
  url: string // where the page ended up, after redirects
  selector?: string
  title?: string
  width?: number
  height?: number
  error?: string
}

maxTargets is not a suggestion checked in your code — it is in the schema, so an over-long request is rejected at the border and the model is told why. That is worth seeing rather than believing: the tool built in step 1 allows four.

const target = (n: number) => ({ url: `https://example.com/${n}` })

const border = {
  one: screenshotter.schema.safeParse({ targets: target(1) }).success,
  four: screenshotter.schema.safeParse({ targets: [1, 2, 3, 4].map(target) }).success,
  five: screenshotter.schema.safeParse({ targets: [1, 2, 3, 4, 5].map(target) }).success,
  withSelector: screenshotter.schema.safeParse({
    targets: { url: 'https://example.com', selector: 'main.report' },
  }).success,
}

border

Two operational facts about the browser underneath. It is a process-wide singleton: the first launch wins, so a proxy or a headless flag on a later call is ignored until the browser is closed. And it keeps the process alive — a script that screenshots and exits should call closeBrowser(). At most five browser operations run at once, across every tool sharing that browser; a call with more targets than that starts them all and they queue.

import { closeBrowser, screenshotPage } from '@animahealth/adk/web'

// The same capture the tool performs, callable directly — useful in a script or a test.
const shot = await screenshotPage('https://example.com', { selector: 'main', timeout: 15000 })

await closeBrowser() // otherwise the process does not exit

URLs that end in a download extension (.pdf, .zip, and friends) are refused before a page is opened, with an error saying so. Send those to fetch_page, which turns a PDF into something the model can read.

Step 6

Keys, packages, proxies

These tools are the ADK's only paid, installed, network-facing surface. Everything they need is an optional peer dependency, imported the moment it is needed and not before — so a dependency you skipped costs you nothing until a model calls the path that wants it, and then it costs you that call.

Path Needs Without it
webSearch(), default provider SERPER_API_KEY Throws while building the tool, with the variable name and where to get a key.
fetch_page on an HTML page @mozilla/readability, jsdom, turndown The entry comes back success: false with error: 'network_error' — a missing package is reported as a network fault, so check the console before blaming the site.
fetch_page on a PDF pdf-lib A console warning, and the whole PDF goes to the model — maxPdfPages stops applying.
fetch_page on an image sharp The original bytes are sent unresized, with no width or height.
fetch_page({ render: true }) playwright and its browsers Installed package, missing browsers: the call throws, naming npx playwright install.
take_screenshot playwright and its browsers, sharp Throws with the install command for whichever is missing.
Proxied browsing PROXY_HOST, PROXY_PORT, optionally PROXY_USERNAME and PROXY_PASSWORD Direct connections. The environment proxy and the proxy config both apply to browser launches only — plain fetch_page ignores them.

A throw out of a web tool is not a crash. It is the tool failure contract from Tools: the exception becomes the tool_result event's error, the model reads it, and the run continues. That is why these errors are written as instructions — the audience for "run npx playwright install" is a language model deciding what to do next, and it will tell your user.

Next

Tools you didn't write

Search, fetch, and screenshot are three tools someone else wrote and you configured. The general case of that is MCP: a server hands your agent tools it discovers at connect time, with the same schemas and the same ledger. That is Tools you didn't write.

Agent Development Kit · Build

MCP: tools you didn't write

An MCP server is a bag of tools someone else already shipped — a filesystem, a GitHub, your own internal service. app.mcp.server(config) declares one; putting it in an agent's tools turns every tool it advertises into an ADK tool the model can call. This chapter runs the declaration, the filtering, and the lifecycle here in the page, and is exact about the one part a browser tab cannot do: talk to a real server.

Builds on · the Tools chapter Needs · nothing (no API key) Surface · app.mcp

Step 1

Declaring a server spawns nothing

app.mcp.server(config) registers a server on the app and hands you a handle. It does not spawn a process, open a socket, or list a single tool. Most of this page runs here precisely because declaring is inert — the connection is deferred until an agent actually needs the tools, and section 6 is where that deferral comes due.

import { adk } from '@animahealth/adk'

const app = adk({ name: 'mcp-tour' })

// stdio: the ADK spawns this command and speaks MCP over its stdin/stdout.
const files = app.mcp.server({
  name: 'files',
  command: 'npx',
  args: ['-y', '@modelcontextprotocol/server-filesystem', '/workspace'],
  cacheToolsList: true,
})

// HTTP: no child process, a URL. `authorization` is the raw token — the client sends the
// `Authorization: Bearer <token>` header for you, so do not write the word Bearer yourself.
const issues = app.mcp.server({
  name: 'issues',
  url: 'https://example.com/mcp',
  authorization: 'a-token-that-is-not-real',
  transport: 'http',
})

const declared = {
  kind: files.kind,
  name: files.name,
  connected: files.isConnected(),
  state: files.getState(),
  registered: app.mcp.servers().map((server) => server.name),
  sameHandleTwice: app.mcp.get('issues') === issues,
}

declared

Three things to take from that object. A server's kind is 'mcp_server', which is what lets it sit in an agent's tools array beside your own functions. Its state starts disconnected with no toolCount, because nothing has been listed yet. And registration is keyed by name: declaring the same name twice returns the handle you already have, so module-level declaration in two files does not give you two child processes.

A config must carry exactly one of command and url. Neither or both is a build-time throw, not a surprise at first use.

const rejected = ['neither', 'both'].map((shape) => {
  try {
    app.mcp.server(
      shape === 'neither'
        ? { name: `bad-${shape}` }
        : { name: `bad-${shape}`, command: 'npx', url: 'https://example.com/mcp' },
    )
    return { shape, error: null }
  } catch (error) {
    return { shape, error: error instanceof Error ? error.message : String(error) }
  }
})

rejected

Step 2

stdio, HTTP, SSE — and how one is chosen

The transport is decided by the config, not by a switch you flip. command means stdio, always. url means a network transport, and then transport picks between 'http' (streamable HTTP) and 'sse'. Omit transport with a url and the client tries HTTP first, falls back to SSE, and — if both fail — reports the HTTP error, because that is the one you probably meant to succeed.

The fields each side reads:

  • stdiocommand, args, cwd, and env. The env rule is worth knowing: supply it and the child gets your whole process.env with your entries merged over it; omit it and the ADK passes nothing, leaving the transport to pick its own default environment. If a server needs a token, put the token in env rather than assuming inheritance.
  • HTTP and SSEurl, headers, and authorization. authorization is a convenience that sets one header; anything else (an API key header, a tenant id) goes in headers.
  • Bothtimeout in milliseconds, defaulting to 30 000. It is per operation, not per server: connecting, listing tools, and each tool call get their own budget.

Step 3

What the model ends up seeing

An MCP server goes in tools, exactly where a function tool goes. It stays a server there — the array holds one entry, not thirty — and it is expanded into real tools once per turn, before the first model call.

import { isMCPTool, isFunctionTool } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { z } from 'zod'

const recordDecision = app.tool({
  name: 'record_decision',
  description: 'Write a decision to the audit log',
  schema: z.object({ note: z.string() }),
  execute: (ctx) => ({ recorded: ctx.args.note }),
})

const librarian = app.agent({
  name: 'librarian',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('Read the files before answering.'), app.context.history()],
  tools: [files, recordDecision],
})

// Three kinds can sit in `tools`, and the ADK tells them apart by shape, not by registration.
const declaredTools = librarian.tools.map((tool) => {
  if (isMCPTool(tool)) return { from: 'mcp server', name: tool.name }
  if (isFunctionTool(tool)) return { from: 'app.tool', name: tool.name }
  return { from: 'provider', name: tool.type }
})

declaredTools

At expansion time each tool the server advertises becomes an ordinary FunctionTool, and three transformations happen to it. Its name is prefixed mcp_<server>_<tool> — which is what keeps two servers' identically named tools apart, and the reason to keep the server name short, since it rides on every tool name the model reads. Its description passes through unchanged, or becomes MCP tool: <name> if the server offered none. And its JSON Schema is converted to a Zod schema: required properties stay required, optional ones become nullable and optional, description survives as .describe(), and the object is .strict() so an invented argument is rejected at the border.

So a server advertising this — the MCP wire format, not ADK —

{
  name: 'read_file',
  description: 'Read the complete contents of a file',
  inputSchema: {
    type: 'object',
    properties: {
      path: { type: 'string', description: 'Absolute path to the file' },
      encoding: { type: 'string' },
    },
    required: ['path'],
  },
}

reaches the model as this:

{
  name: 'mcp_files_read_file',
  description: 'Read the complete contents of a file',
  schema: z
    .object({
      path: z.string().describe('Absolute path to the file'),
      encoding: z.string().nullable().optional(),
    })
    .strict(),
}

When the model calls it, the arguments cross that schema, the client calls the server, and the reply is unwrapped before the model sees it. A single text block is parsed as JSON when it parses and handed over as a string when it does not; a single non-text block is passed through as-is; several blocks arrive as an array; none at all becomes { success: true, message: 'Operation completed with no output' }. If the server answers with isError, the tool throws with the server's own text — and from there it is the ordinary tool failure contract: a tool_result carrying an error, read by the model, run intact.

Step 4

Only these, never those

A general-purpose MCP server hands you everything it has, including the destructive half. Two filters trim it, and they compose: server.only([…]) keeps a list, server.exclude([…]) drops one. Both return a new server sharing the original's connection, so filtering costs no extra process. The same lists can be given up front as includeTools and excludeTools in the config.

const readOnly = files.only(['read_file', 'list_directory', 'search_files'])
const noDelete = files.exclude(['delete_file', 'move_file'])

const filtering = {
  derivedAreNewHandles: readOnly !== files && noDelete !== files,
  registryUnchanged: app.mcp.servers().map((server) => server.name),
  configIsTheOriginalObject: readOnly.config === files.config,
  configIncludeTools: readOnly.config.includeTools ?? null,
}

filtering

Note the last two lines: a derived server keeps the config object you passed to app.mcp.server, so .config.includeTools is not where .only() writes its list. Read the filter from the code that built the handle, not from the config. The rules themselves are short — .only() replaces the keep-list, .exclude() adds to the drop-list, and exclusion is applied last, so a name in both lists is dropped.

Filtering is client-side: the server is still asked for its full inventory and the trimming happens before the tools are built. It shapes what the model can reach, and it is the right first move on any server you did not write. It is not a permission boundary — the credentials you handed the server still are. For a genuine confirmation step, wrap the risky operation in your own tool with a yieldSchema and let a human answer; that is Stopping to ask.

Step 5

Resources and prompts are context, not tools

MCP servers also publish resources (documents addressed by URI) and prompts (named, parameterised message templates). Neither is something a model calls. Both become context renderers, so they go in an agent's context array alongside app.context.system and app.context.history.

const readme = files.resource('file:///workspace/README.md')
const houseStyle = files.prompt('code-review', { language: 'typescript' })

const reviewer = app.agent({
  name: 'reviewer',
  model: openai('gpt-5.6-luna'),
  context: [
    app.context.system('Review the diff against the project README.'),
    readme,
    houseStyle,
    app.context.history(),
  ],
  tools: [readOnly],
})

const contextShape = {
  renderers: reviewer.context.length,
  resourceIsARenderer: typeof readme === 'function',
  promptIsARenderer: typeof houseStyle === 'function',
}

contextShape

What they render is worth knowing exactly. A resource is fetched and appended as one system event reading [Resource: <uri>] then the text — so it is text or nothing: a binary resource has no text and contributes nothing. A prompt is fetched and its messages are appended as user and assistant events, which is how a server ships a worked example rather than an instruction.

And both fail open. If the fetch throws, the renderer logs a warning and returns the context untouched; the turn proceeds without that document. This is the opposite of the tools path, which fails the turn — the asymmetry in the next section — and it means a missing resource degrades an agent quietly. If a document is load-bearing, read it yourself with await server.readResource(uri) and decide what to do when it is not there.

The lower-level pair sits underneath: server.readResource(uri) returns { uri, mimeType, text, data } (data being a decoded Buffer for a blob), and server.getPrompt(name, args) returns { messages }. server.resourceDefinitions() and server.promptDefinitions() list what a server offers, the way server.toolDefinitions() lists its tools.

Step 6

Connect, fail, reconnect, close

Connection is lazy: the first turn of an agent carrying an MCP server connects it, because expanding the server into tools has to list them. await app.mcp.connect() moves that cost to startup, warming every registered server at once — and it never throws. Failures are collected, warned about, and swallowed, so one dead server cannot stop your process from booting. The state is where you find out.

Run the cell. It takes a few seconds — a connection is attempted three times with randomized backoff before it is given up on — and then both servers report the same thing, because this page is a browser tab: there is no process to spawn npx in and no @modelcontextprotocol/sdk to load. That is the honest answer here, and it is the exact shape you would read on your own machine when a server is misconfigured.

await app.mcp.connect()

const afterWarmup = app.mcp.servers().map((server) => ({
  name: server.name,
  ...server.getState(),
}))

afterWarmup

Now the asymmetry that matters in production. app.mcp.connect() swallowing a failure does not make the failure harmless — it defers it. The tools are needed at the top of a turn, and if the server cannot be reached then, the connection error is thrown out of the run. Not a tool_result with an error; a rejected promise from app.run. The same agent, the same dead server — and the same few seconds, because the failed warm-up left nothing cached to reuse:

import { runTest, user, model } from '@animahealth/adk/testing'

let turnFailure = ''
try {
  await runTest(librarian, [user('What is in the README?'), model('It is a project readme.')])
} catch (error) {
  turnFailure = error instanceof Error ? error.message : String(error)
}

turnFailure

So: a warm-up call tells you whether a server is reachable, and server.getState() or await server.healthCheck() tells you which one is not. Check them at boot. An agent whose MCP server is down does not degrade — it stops.

Once connected, the client keeps itself alive without your help:

  • Reconnect. An operation that fails with a connection-shaped error — EPIPE, ECONNRESET, ECONNREFUSED, or a message about being closed, disconnected, or not connected — reconnects and retries once. Other errors are yours to see.
  • Caching. Tools are listed once per turn unless you set cacheToolsList: true, which reuses the first listing for the life of the connection. On a stdio server that is a round-trip to a child process on every turn, so set it — and know it is a real cache: a server that gains a tool while connected will not be noticed until it reconnects. cacheResourcesList and cachePromptsList do the same for the other two listings.
  • Shutdown. await app.mcp.disconnect() closes every registered server and clears its caches; await app.close() does that and closes the session store. Child processes are also cleaned up on beforeExit, SIGTERM, and SIGINT, so a Ctrl-C does not leave an npx behind.

Step 7

The whole thing, on your machine

Everything above ran here. This last piece cannot: it needs a Node process, an OpenAI key, and one peer dependency — npm install @animahealth/adk @modelcontextprotocol/sdk. The MCP SDK is an optional peer, which is why importing the ADK does not drag it in, and why every failure in this page's cells says it is missing. Copy this into a file and run it.

import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'

const app = adk({ name: 'file-reader' })

const files = app.mcp.server({
  name: 'files',
  command: 'npx',
  args: ['-y', '@modelcontextprotocol/server-filesystem', process.cwd()],
  cacheToolsList: true,
})

const agent = app.agent({
  name: 'librarian',
  model: openai('gpt-5-mini'),
  context: [
    app.context.system('Answer from the files. Read before you answer.'),
    app.context.history(),
  ],
  tools: [files.only(['read_file', 'list_directory', 'search_files'])],
})

// Warm up first, so a broken server is a startup error and not a mid-conversation one.
await app.mcp.connect()
console.log(files.getState())
console.log((await files.toolDefinitions()).map((tool) => tool.name))

const run = await app.run(agent, 'What does this project do? Read the README.')
console.log(run.output.text)

await app.close()

That is the whole surface. Declare a server, filter it down to the tools you want the model reaching for, warm it at boot, close it on the way out — and the rest of the ADK cannot tell the difference between those tools and the ones you wrote by hand.

Agent Development Kit · Build

Using memory

memory() composes two things an agent needs to recall anything: an embedder that turns text into a vector, and an index that stores vectors and finds the near ones. Everything else on this page — filters, variants, slices, the search tool you hand an agent — is that one pair, addressed different ways. Every cell runs here, with no key and no database.

Runs · no key (the embedder and the index are local) Assumes · the quickstart's tool section Source · src/memory/

Step 1

An embedder and an index

A memory needs a model (anything satisfying Embedder: a dimensions count, an optional modelName, and an embed(texts) that returns one vector per text), an index, and a collection name. The optional metadata Zod schema types every read and write below and validates on the way in.

The embedder here is a stand-in built in the page: it hashes each word into one of 64 buckets and counts them, so a vector is a pure function of its text — no key, no network, identical output on every run. That makes it honest rather than good. It measures word overlap, not meaning: flu and influenza land in unrelated buckets. In production you pass voyage(name, { dimensions }) from @animahealth/adk/voyage, or any other object satisfying Embedder, and nothing else on this page changes.

import { memory, inMemoryIndex } from '@animahealth/adk'
import type { Embedder } from '@animahealth/adk'
import { z } from 'zod'

const DIMENSIONS = 64

// A real embedder calls a model. This one hashes words into buckets — deterministic, offline,
// and lexical: it can only see the words two texts share.
const hashEmbedder: Embedder = {
  dimensions: DIMENSIONS,
  modelName: 'hash-64',
  async embed(input) {
    return {
      model: 'hash-64',
      embeddings: input.map((text) => {
        const vector = Array.from({ length: DIMENSIONS }, () => 0)
        for (const token of text.toLowerCase().match(/[a-z0-9]+/g) ?? []) {
          let bucket = 0
          for (const char of token) bucket = (bucket * 31 + char.charCodeAt(0)) % DIMENSIONS
          vector[bucket] += 1
        }
        return vector
      }),
    }
  },
}

const notes = memory({
  model: hashEmbedder,
  index: inMemoryIndex(),
  collection: 'clinic_notes',
  metadata: z.object({
    patient: z.string(),
    topic: z.enum(['symptom', 'medication']),
    day: z.number(),
  }),
})

const built = { collection: notes.collection, dimensions: hashEmbedder.dimensions }

built

inMemoryIndex() is a real VectorIndex that keeps its points in a Map — the same interface sqlite-vec, pgvector and Qdrant implement, so the code below is the code you keep when you swap it. Pass model: { index, query } instead of one embedder for asymmetric retrieval; the factory throws at construction if the two dimensions disagree.

Step 2

Write, then search

upsert takes one item or an array of { id, content, metadata }. It embeds the content for you, in batches of 128, and stores the text alongside the vector — so a match carries its content back and you need no second lookup in another database. Pass embedding yourself to skip the embed call for that item.

await notes.upsert([
  { id: 'n1', content: 'Sore throat and fever for three days.',
    metadata: { patient: 'ada', topic: 'symptom', day: 3 } },
  { id: 'n2', content: 'Throat pain worse when swallowing.',
    metadata: { patient: 'ada', topic: 'symptom', day: 4 } },
  { id: 'n3', content: 'Prescribed amoxicillin, 500mg three times daily.',
    metadata: { patient: 'ada', topic: 'medication', day: 4 } },
  { id: 'n4', content: 'Ankle sprain after a fall while running.',
    metadata: { patient: 'sam', topic: 'symptom', day: 1 } },
  { id: 'n5', content: 'Ibuprofen for the ankle swelling.',
    metadata: { patient: 'sam', topic: 'medication', day: 2 } },
])

const found = await notes.search('sore throat', { topK: 3 })

const ranked = {
  stored: await notes.count(),
  matches: found.matches.map((match) => ({
    id: match.id,
    score: Number(match.score.toFixed(2)),
    topic: match.metadata.topic,
    content: match.content,
  })),
}

ranked

A search returns { matches, embedding }: the matches, and the query vector it computed — hand that to a second index instead of paying to embed the same string twice. Each match is { id, score, content, metadata }, cosine-similarity ordered, and the metadata is typed by the schema from step 1 (match.metadata.topic is 'symptom' | 'medication', and a typo is a compile error).

Look at the third result. It shares no word with the query and still scores. Cosine similarity returns a number for every point in the collection, so a nearest-neighbour search always returns something — which is what the next section's minScore is for. The write path continues past upsert — reading by id, merging metadata, deleting, and paging without a query — and the typed interface behind all of it is Vector backends.

Step 3

Narrowing the search

Four options shape a search, and they compose. topK caps the results — every shipped backend defaults to 10. filter restricts which points are eligible. minScore drops weak matches. contains demands a literal substring of the stored content, case-insensitively — a keyword gate bolted onto the vector search, not a second query.

A filter is either shorthand — an object of key/value pairs, every one of which must match — or the structured form with must, should and must_not arrays of conditions. A condition tests one metadata key with match (equality), text.contains (substring), or range (gt/gte/lt/lte, numeric or lexicographic). normalizeFilter is the exported function that turns the first form into the second, and every backend receives the second.

const idsOf = (result: { matches: { id: string }[] }) =>
  result.matches.map((match) => match.id)

// Shorthand: every key must match.
const forSam = await notes.search('pain', { filter: { patient: 'sam' } })

// Structured: day 4 or later, and not a medication note.
const recentSymptoms = await notes.search('pain', {
  filter: {
    must: [{ key: 'day', range: { gte: 4 } }],
    must_not: [{ key: 'topic', match: { value: 'medication' } }],
  },
})

// A literal substring of the stored content, whatever the query vector says.
const mentioningAnkle = await notes.search('anything at all', { contains: 'ankle' })

// Same query as step 2, with the weak match cut off.
const confident = await notes.search('sore throat', { minScore: 0.3 })

const narrowed = {
  forSam: idsOf(forSam),
  recentSymptoms: idsOf(recentSymptoms),
  mentioningAnkle: idsOf(mentioningAnkle),
  confident: idsOf(confident),
}

narrowed

The same filter shape works on count, scroll and deleteByFilter. It is also where tenancy belongs: put the org id in the metadata and filter every search on it, so no query can reach another tenant's rows.

Step 4

Handing the search to an agent

notes.tool(config) returns an ordinary FunctionTool, so the agent gets recall the same way it gets any other capability. The tool's schema is fixed at one query string — the model writes the search, your config sets the policy: topK, minScore, a filter (or a function of state that returns one), and a render that formats the matches. The name defaults to memory_search. Only description is required — it is the whole instruction the model reads before deciding to call.

import { adk } from '@animahealth/adk'
import { runTest, user, model, getToolResults } from '@animahealth/adk/testing'

const app = adk({ name: 'clinic' })

const recall = notes.tool({
  name: 'recall_notes',
  description: 'Search this patient\'s past clinic notes.',
  topK: 2,
  filter: { patient: 'ada' },
})

const assistant = app.agent({
  name: 'clinic_assistant',
  model: { provider: 'openai', name: 'gpt-5.6-luna' },
  context: [app.context.system('Search the notes before answering.'), app.context.history()],
  tools: [recall],
})

// The script decides THAT recall_notes is called; the search itself really runs.
const test = await runTest(assistant, [
  user('What did Ada come in with?'),
  model({ toolCalls: [{ name: 'recall_notes', args: { query: 'sore throat' } }] }),
  model('Ada reported a sore throat and fever.'),
])

getToolResults(test.events)

The result is a string, because that is what a model reads. The default rendering tags each match with its id — [memory] (id="n1") — and separates them with a rule; pass render to produce your own, with the matches typed by your metadata schema. Note what the filter did: the model asked for "sore throat" and could not have reached Sam's notes whatever it asked, because the filter is config, not prompt.

memory.context() needs the async context path. The same config also produces a ContextRenderer — recall injected before every model call instead of waiting for the model to ask. It searches, so it is asynchronous, and the reasoning loop builds context synchronously (buildContext in src/context/build.ts throws on a renderer that returns a promise; only buildContextAsync, which the voice runtime uses, awaits one). Until those meet, reach for the tool above, or search before the run and inject the result with app.context.system(…).

Step 5

Two views of one record

One record often has more than one useful text: a clean summary to search, the raw transcript to read. variants gives a point one named vector per view — same id, same metadata, different embedding and different stored content. The first name in the array is the default, and memory.variant.<name> addresses the others.

That splits searching from returning. returning(name) keeps searching the current variant's vectors but hands back another variant's content — search the tidy summary, show the model the transcript. returning throws on a variant you did not declare; variant is a plain record, so the same typo there is undefined.

const cases = memory({
  model: hashEmbedder,
  index: inMemoryIndex(),
  collection: 'case_notes',
  variants: ['summary', 'transcript'],
})

// The memory itself is its first variant; the others hang off `.variant`.
await cases.upsert({ id: 'c1', content: 'Sore throat, fever, likely viral.' })
await cases.upsert({ id: 'c2', content: 'Ankle sprain, rest and ibuprofen.' })
await cases.variant.transcript.upsert({
  id: 'c1',
  content: 'It started Friday. My throat hurts and I had a fever on Saturday night.',
})
await cases.variant.transcript.upsert({
  id: 'c2',
  content: 'I rolled my ankle running on Sunday.',
})

const contentOf = (result: { matches: { id: string; content: string }[] }) =>
  result.matches.map((m) => [m.id, m.content])

const views = {
  summaries: contentOf(await cases.search('fever')),
  summarySearchTranscriptContent: contentOf(await cases.returning('transcript').search('fever')),
  transcriptSearch: contentOf(await cases.variant.transcript.search('rolled my ankle')),
}

views

The first two searches rank identically — same vectors, same query — and differ only in the text they carry back. The third searches the transcript vectors instead, and reorders: "rolled my ankle" is a phrase from a transcript, not from a summary. Writes are per-variant, and every backend's compliance suite pins the consequence: a search over one variant sees only the points written for it.

Step 6

Several kinds in one collection

Variants are views of one record. Slices are different kinds of record sharing one collection — problems, medications, letters — each with its own metadata schema, all searchable at once. Pass slices instead of metadata and the shape of the memory changes: writes go through memory.slice.<name>, which is where the typed metadata lives, and there is no top-level upsert to write an unlabelled point with.

const chart = memory({
  model: hashEmbedder,
  index: inMemoryIndex(),
  collection: 'chart',
  slices: {
    problem: { metadata: z.object({ onset: z.string() }) },
    medication: { metadata: z.object({ dose: z.string() }) },
  },
})

await chart.slice.problem.upsert({
  id: 'p1',
  content: 'Acute sore throat',
  metadata: { onset: '2026-03-01' },
})
await chart.slice.medication.upsert({
  id: 'm1',
  content: 'Amoxicillin for the throat infection',
  metadata: { dose: '500mg' },
})
await chart.slice.medication.upsert({
  id: 'm2',
  content: 'Ibuprofen as needed',
  metadata: { dose: '400mg' },
})

const everything = await chart.search('throat')
const medsOnly = await chart.slices(['medication']).search('throat')

const sliced = {
  everything: everything.matches.map((m) => ({ id: m.id, kind: m.kind, metadata: m.metadata })),
  medsOnly: medsOnly.matches.map((m) => m.id),
}

sliced

A cross-slice search returns one ranked list, and every match carries the kind it came from — a discriminated union, so narrowing on match.kind narrows match.metadata to that slice's schema. chart.slices(['medication']) takes a subset and keeps the union typed to it; chart.slice.medication searches exactly one. The kind also becomes the tag in the default tool rendering from step 4, so a model reading cross-slice results can tell a medication from a problem.

The seam

Where the vectors actually live

Everything above ran against inMemoryIndex() and vanishes when this tab closes. Nothing above mentions a backend, and that is the point: VectorIndex is nine methods and an optional close, and memory() is the only thing your code talks to. collectionSpec computes what a backend has to provision for a given config — without connecting to anything.

import { collectionSpec } from '@animahealth/adk'

collectionSpec({
  model: hashEmbedder,
  collection: 'case_notes',
  variants: ['summary', 'transcript'],
})

Which backend to pick, what that spec provisions, what each one costs, and how their filtered searches differ is Vector backends.

Agent Development Kit · Prove & ship

Testing agents without a model

An agent test that calls a real model is slow, costs money, and disagrees with itself. The test kit replaces one thing — the model — with turns you write down. Your tools still execute, your context still renders, the ledger still accrues. Every cell on this page runs here, with no key and no network.

Audience · engineers writing agent tests Needs · nothing (no API key) Import · @animahealth/adk/testing

Step 1

A scripted turn

First, the agent under test. It is an ordinary agent — nothing about it knows it is being tested — with one tool that computes a price and one that stops to ask the guest a question. Build it once; every cell below runs it.

import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { z } from 'zod'

const app = adk()

const quoteNights = app.tool({
  name: 'quote_nights',
  description: 'Quote the price of a stay',
  schema: z.object({ nights: z.number(), guests: z.number() }),
  execute: (ctx) => ({ total: ctx.args.nights * ctx.args.guests * 40 }),
})

// A tool with a `yieldSchema` and no `execute` stops the run and waits for an answer.
const askNight = app.tool({
  name: 'ask_night',
  description: 'Ask the guest which night they want',
  schema: z.object({ question: z.string() }),
  yieldSchema: z.object({ night: z.string() }),
  finalize: (ctx) => ({ night: ctx.input?.night ?? 'unknown' }),
})

const concierge = app.agent({
  name: 'concierge',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('Quote stays with the tools.'), app.context.history()],
  tools: [quoteNights, askNight],
})

concierge.name

Note the model: line. It names a real provider, and this page has no key — yet the next cell runs. runTest builds its own runner with a mock adapter registered for the provider, and a registered adapter beats the one the agent's model config would resolve. Nothing reaches the network, so you never have to keep a separate "test agent" in sync with the one you ship.

A test is the agent plus a list of steps. user(text) is a message in; model(text) is the reply the model would have produced. Edit the script and run again — the reply is whatever you say it is.

import { getLastAssistantText, model, runTest, user } from '@animahealth/adk/testing'

const greeting = await runTest(concierge, [
  user('Hi, are you open in March?'),
  model('We are — how many nights were you thinking?'),
])

getLastAssistantText(greeting.events)

model('plain text') is shorthand for model({ text: 'plain text' }), symmetric with user. The config form is what you reach for when a turn is more than words: a tool decision, a reasoning summary, a thrown error, a delay, a chunked stream. Section 4 lists every field.

Step 2

The mock replaces the model, not your code

This is the load-bearing idea of the whole kit. Scripting { toolCalls: [{ name, args }] } decides that the tool is called and with what — it does not decide the answer. Your execute runs for real, against the arguments the script chose, and the number below is computed in this page by the tool you wrote in step 1.

import { getToolResults } from '@animahealth/adk/testing'

const quoted = await runTest(concierge, [
  user('Two nights for three of us — what does that cost?'),
  model({ toolCalls: [{ name: 'quote_nights', args: { nights: 2, guests: 3 } }] }),
  model('Two nights for three guests comes to 240.'),
])

getToolResults(quoted.events)

Two model steps for one question, because a tool-using turn is two model round-trips: one to decide the call, one to answer with its result. Change the arithmetic in the tool and this cell changes with it; change the second model line and it does not. That is the seam — the script owns the model's judgement, your code owns the answer.

Failure travels the same path: a tool that throws lands in the ledger as a tool_result carrying error, and the run continues — see guardrails and recovery.

Step 3

What the run left behind

Assertions read the event history, not a transcript. TestResult.events is an alias for the session ledger — see five primitives and one ledger.

import { getToolCalls } from '@animahealth/adk/testing'

const ledger = {
  status: quoted.status,
  iterations: quoted.iterations,
  calls: getToolCalls(quoted.events),
  events: quoted.events.map((event) => event.type),
  streamed: quoted.streamEvents.map((event) => event.type),
}

ledger

Read it left to right: the user's message, the invocation opening, a model_start/model_end pair for the decision, the tool_call and the tool_result your code produced, a second model pair for the reply, then the assistant text. iterations counts those two model steps. Everything a real run records, the scripted run records too.

events is the durable ledger; streamEvents is what a subscriber saw while the run was in flight — the same events without what the session already held, plus the deltas a chunked turn emits and the ledger never keeps. The one thing a scripted run cannot report is cost. There were no tokens, so quoted.result.usage is undefined. Measure cost against a real model, on the streaming and cost page.

A TestResult also carries result — the whole untouched RunResult, for anything the aliases do not cover.

Four helpers cover most assertions, and each takes an event list: getLastAssistantText, getToolCalls, getToolResults, and findEventsByType(events, 'thought') for anything else. findStreamEventsByType is their counterpart over streamEvents, and collectStream drains a live stream into events plus result.

Step 4

The step vocabulary

Four step builders, and the runner walks them in order. user and model you have seen. input and result exist for the other direction: when the agent stops and waits, they are how the test answers it.

Step What it does
user(text) A message from the user. The first one starts the run; later ones answer a run that yielded for a message.
model(text | config) One model call. A string is { text }; the config form is below.
input({ toolName: value }) The value a yielded tool was waiting for, matched by tool name. Applies to the yield raised by the model step it follows.
result({ toolName: value }) Its sibling, for supplying a yielded call's result. Both resolve the yielded call the same way, through the session's tool input; prefer input.

Here is the pair in motion. ask_night yields, the run stops, the input step hands back a night, and finalize turns it into the tool's result — all inside one runTest call.

import { input } from '@animahealth/adk/testing'

const resumed = await runTest(concierge, [
  user('Book me a room'),
  model({ toolCalls: [{ name: 'ask_night', args: { question: 'Which night?' } }] }),
  input({ ask_night: { night: 'Friday' } }),
  model('Booked for Friday.'),
])

const waited = {
  status: resumed.status,
  events: resumed.events.map((event) => event.type),
  results: getToolResults(resumed.events),
}

waited

tool_yield, invocation_yield, tool_input, invocation_resume — the whole pause is in the ledger, which is what lets a sleeping agent be a database row. Delete the input line and run again: the test comes back with status yielded_tool, still waiting. Yielding tools have their own chapter, stopping to ask.

A model turn's config is a small, closed set:

Field Scripts
text An assistant reply.
thought A reasoning summary, landing as a thought event.
toolCalls { name, args } pairs — one tool_call each.
error An Error the model call throws instead of answering.
delayMs A pause before the turn — for asserting on timeouts.
streamChunks, chunkSize Emit the text and thought as deltas, sliced to chunkSize characters (default 10), so a streaming consumer has something to consume.

Step 5

Stand-ins, failures, and the adapter underneath

Not every test needs a real agent. mockAgent(name, config?) — positional name first — returns an agent with no tools and a history renderer, useful as the neighbour in a multi-agent test or as the thing a router hands off to. Its config.responses are consumed before the script's model steps.

And failure is a step like any other. model({ error }) throws from inside the model call, so the runTest promise rejects — catch it, and assert on what your error handlers did.

import { mockAgent } from '@animahealth/adk/testing'

const triage = mockAgent('triage', { responses: [{ text: 'Escalating to a human.' }] })
const stubbed = await runTest(triage, [user('My booking vanished')])

let failure = 'the run did not fail'
try {
  await runTest(mockAgent('flaky'), [
    user('hello'),
    model({ error: new Error('429 rate limited') }),
  ])
} catch (thrown) {
  failure = thrown instanceof Error ? thrown.message : String(thrown)
}

const stubs = { stubbed: getLastAssistantText(stubbed.events), failure }

stubs

Underneath both is MockAdapter, and you can hold it yourself. An app built with adk({ adapters: { openai: mockAdapter } }) runs the ordinary app.run path — sessions, hooks, stores, everything the quickstart showed — with the model replaced. That is the way to test the code around the agent rather than the agent's turns.

import { MockAdapter } from '@animahealth/adk/testing'

const scripted = new MockAdapter({
  responses: [
    { toolCalls: [{ name: 'quote_nights', args: { nights: 3, guests: 2 } }] },
    { text: 'Three nights for two is 240.' },
  ],
  defaultResponse: { text: 'the script ran out' },
})

const offline = adk({ adapters: { openai: scripted } })

const first = await offline.run(concierge, 'Three nights for two?')
const second = await offline.run(concierge, 'And a fourth night?')

const adapterRuns = { first: first.output.text, second: second.output.text }

adapterRuns

The second run prints the fallback: one adapter holds one cursor, and it keeps advancing across runs. When the responses are exhausted, defaultResponse answers — and its own default is the string Mock response, which is how an under-scripted test passes while asserting nothing. Set defaultResponse to something you would notice, or call scripted.reset() between runs.

Step 6

In your repo, with no credentials

Everything above is a plain async function call, so it drops into any runner. Nothing reads an environment variable, opens a socket, or asks for a key — which means agent tests pass on a fork, on a pull request from outside your org, and on a laptop with no secrets. This block imports vitest, which this page's substrate does not serve, so it is shown rather than run.

import { describe, expect, it } from 'vitest'
import { getToolResults, model, runTest, user } from '@animahealth/adk/testing'

import { concierge } from './concierge'

describe('concierge', () => {
  it('quotes from the tool, not from the model', async () => {
    const outcome = await runTest(concierge, [
      user('Two nights for three of us?'),
      model({ toolCalls: [{ name: 'quote_nights', args: { nights: 2, guests: 3 } }] }),
      model('That comes to 240.'),
    ])

    expect(outcome.status).toBe('completed')
    expect(getToolResults(outcome.events)).toEqual([
      { name: 'quote_nights', result: { total: 240 } },
    ])
  })
})

The kit also ships vitest matchers — toHaveAssistantText, toHaveToolCall, toHaveToolResult, toHaveEventSequence, toHaveStatus, toHaveState, toHaveEvent, toBeUuid — registered by await setupAdkMatchers(). One caveat: their type declaration is not re-exported from /testing, so TypeScript sees them only once your project picks it up. The helper-based assertions above need no setup at all.

One more piece of the kit these cells did not need: createTestContext(agent). Scripted turns prove behaviour; to score quality against a real model, go on to measuring agents.

Agent Development Kit · Prove & ship

Evals: measuring agents you did not script

A test asserts on turns you wrote down. An eval asks a question about turns you did not: did it call the refund tool, in that order, inside that budget — over a suite, at a pass rate you can watch move. app.evaluate runs the cases and scores them; app.simulate is the loop underneath that keeps a conversation going. Everything here runs in this page.

Runs here · six scripted cells, no key Live cell · your own OpenAI key Assumes · the testing chapter

Step 1

A case, and the tools it must not really call

The agent under test is ordinary: one tool that reads, one tool that spends money. Nothing about it knows it is being evaluated. Build it once — every cell below scores this agent. Only the last cell on the page calls a real model, so the key box can stay empty until then.

import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { z } from 'zod'

const app = adk()

const orderStatus = app.tool({
  name: 'order_status',
  description: 'Look up an order and how late it arrived',
  schema: z.object({ orderId: z.string() }),
  execute: (ctx) => ({ orderId: ctx.args.orderId, daysLate: ctx.args.orderId === 'A-1' ? 7 : 0 }),
})

// The dangerous one. Its execute is deliberately unreachable, so a run that reaches it is loud.
const issueRefund = app.tool({
  name: 'issue_refund',
  description: 'Refund an order. Moves real money.',
  schema: z.object({ orderId: z.string(), amount: z.number() }),
  execute: () => {
    throw new Error('the real refund gateway was called')
  },
})

const support = app.agent({
  name: 'support',
  model: openai('gpt-5.6-luna'),
  context: [
    app.context.system('Refund an order only if it arrived more than three days late. Check it first.'),
    app.context.history(),
  ],
  tools: [orderStatus, issueRefund],
})

support.name

Testing replaced the model and let your tools run. An eval moves the other seam too: toolMocks replaces a tool's execute, so a suite can exercise the refund path a thousand times without a thousand refunds. A mock is { execute } — but note the signature, which is not a tool's: execute(args, ctx) takes the arguments first, where a real tool takes one ctx and reads ctx.args. Passing the real tool as the value instead lets it through untouched, which is what you want for a lookup with no side effects.

One case is one run plus the mocks it is allowed. app.evaluate takes a single case or an array; either way it answers with a suite result.

import { MockAdapter, type MockAdapterConfig } from '@animahealth/adk/testing'

// A fresh adapter per suite. One MockAdapter holds one cursor, so reusing it would make the
// second press of Run disagree with the first.
const offline = (responses: MockAdapterConfig['responses']) =>
  adk({
    adapters: {
      openai: new MockAdapter({ responses, defaultResponse: { text: 'the script ran out' } }),
    },
  })

const refundScript = [
  { toolCalls: [{ name: 'order_status', args: { orderId: 'A-1' } }] },
  { toolCalls: [{ name: 'issue_refund', args: { orderId: 'A-1', amount: 40 } }] },
  { text: 'Order A-1 was seven days late, so I have refunded 40.' },
]

const mocks = {
  order_status: orderStatus, // real: it only reads
  issue_refund: {
    execute: (args: unknown) => ({
      refunded: true,
      reference: `mock-${(args as { orderId: string }).orderId}`,
    }),
  },
}

const first = await offline(refundScript).evaluate({
  name: 'refunds a late order',
  runnable: support,
  input: 'Order A-1 turned up a week late. I want my money back.',
  toolMocks: mocks,
})

const outcome = {
  summary: first.summary,
  status: first.results[0].status,
  turns: first.results[0].turns,
  tools: first.results[0].events.flatMap((event) =>
    event.type === 'tool_result' ? [{ name: event.name, result: event.result }] : [],
  ),
}

outcome

The refund reference is mock-A-1, so the mock ran and the gateway did not. Delete the issue_refund entry and press Run again: toolMocks is strict, so a called tool with no entry throws inside the tool. That is an ordinary tool-phase error, and the default handler records it rather than propagating it — it lands in the ledger as a tool_result carrying an error that names the tool and prints the mock you should have written. Nothing escapes evaluate: the run answers the customer anyway, and the case still comes back passed.

Which is the problem: this case asked nothing. A case with no metrics passes whenever the run did not error, abort, or terminate — status is the run's outcome first, then every metric agreeing, and no metrics agree vacuously. A green suite without metrics is not evidence. That is the next section's job.

Step 2

The metric is the question

A metric is a name and a function over the finished run: { name, evaluate(run) }, returning passed and optionally score, evidence, and data. It reads the ledger — run.session.events and run.session.state — because that is where everything the agent did already is. app.evaluate.metric is an identity helper that types the callback; it changes nothing at runtime.

import type { Event } from '@animahealth/adk'

const toolsCalled = (events: readonly Event[]) =>
  events.flatMap((event) => (event.type === 'tool_call' ? [event.name] : []))

const refundIssued = app.evaluate.metric({
  name: 'refund_issued',
  evaluate: (run) => {
    const called = toolsCalled(run.session.events)
    const passed = called.includes('issue_refund')
    return { passed, score: passed ? 1 : 0, evidence: [`called: ${called.join(' → ') || 'nothing'}`] }
  },
})

const refundDeclined = app.evaluate.metric({
  name: 'refund_declined',
  evaluate: (run) => {
    const refunds = toolsCalled(run.session.events).filter((name) => name === 'issue_refund')
    return { passed: refunds.length === 0, evidence: [`issue_refund calls: ${refunds.length}`] }
  },
})

// The invariant both cases owe: never refund without looking the order up first.
const checkedFirst = app.evaluate.metric({
  name: 'checked_first',
  evaluate: (run) => {
    const called = toolsCalled(run.session.events)
    const refundAt = called.indexOf('issue_refund')
    const lookupAt = called.indexOf('order_status')
    return {
      passed: refundAt === -1 || (lookupAt !== -1 && lookupAt < refundAt),
      evidence: [`order: ${called.join(' → ') || 'no tools'}`],
    }
  },
})

const questions = [refundIssued, refundDeclined, checkedFirst].map((metric) => metric.name)

questions

A metric that throws does not crash the suite: the runner catches it and records passed: false with the error as its evidence. Scores between 0 and 1 are averaged into the report; anything outside that range is carried but not averaged.

Four metric builders ship for the shapes you write most often. They live on the @animahealth/adk/eval subpath, which this page's substrate does not serve, so this block is shown rather than run — the cells above are the runnable form of the same contract.

import {
  eventCountMetric,
  eventSequenceMetric,
  stateMetric,
  timingMetric,
} from '@animahealth/adk/eval'

// How many events of a type match a filter?
eventCountMetric({
  name: 'refunded_once',
  eventType: 'tool_call',
  filter: (event) => event.name === 'issue_refund',
  assertion: (count) => count === 1,
})

// Did these events happen, in this order? (Other events in between are allowed.)
eventSequenceMetric({
  name: 'lookup_then_refund',
  sequence: [
    { eventType: 'tool_call', filter: (event) => event.name === 'order_status' },
    { eventType: 'tool_call', filter: (event) => event.name === 'issue_refund' },
  ],
})

// What did a state key end up as? (Replayed from the run's state_change events.)
stateMetric({
  name: 'case_closed',
  scope: 'session',
  key: 'closed',
  assertion: (value) => value === true,
})

// Was it fast enough?
timingMetric({
  name: 'answered_quickly',
  measure: 'time_to_first_assistant',
  assertion: (ms) => ms < 4000,
})

timingMetric's measure is a closed set: total_duration, time_to_first_assistant, time_to_first_tool_call, model_latency_total, model_latency_average, tool_execution_total, tool_execution_average. A measure it cannot compute — no matching events, missing timestamps — fails with that as its evidence rather than passing vacuously. The same subpath also ships codingDeltaMetric, for scoring a coding agent on the diff it produced rather than on its summary of the diff.

Step 3

A suite, and where its verdict comes from

Metrics attach in two places. Suite-level metrics in the options apply to every case — the invariants. Case-level metrics are added on top — the thing that case is actually about. Here one case must refund and one must not, and both owe the ordering invariant.

const declineScript = [
  { toolCalls: [{ name: 'order_status', args: { orderId: 'B-2' } }] },
  { text: 'Order B-2 arrived on time, so there is nothing to refund.' },
]

const refundCases = app.evaluate.cases([
  {
    name: 'refunds a late order',
    runnable: support,
    input: 'Order A-1 turned up a week late. I want my money back.',
    toolMocks: mocks,
    metrics: [refundIssued],
  },
  {
    name: 'leaves an on-time order alone',
    runnable: support,
    input: 'Order B-2 arrived on Tuesday as promised. Refund me anyway?',
    toolMocks: mocks,
    metrics: [refundDeclined],
  },
])

const suite = await offline([...refundScript, ...declineScript]).evaluate(refundCases, {
  metrics: [checkedFirst],
  concurrency: 1,
})

const scored = {
  summary: suite.summary,
  cases: suite.results.map((result) => ({
    name: result.name,
    status: result.status,
    metrics: result.metrics,
  })),
}

scored

app.evaluate.cases exists so a case array written in its own module is checked against the app's state schema at the point you write it, not the point you run it.

concurrency: 1 is load-bearing here only: one scripted cursor serves both cases. Against a real model the default of 64 applies.

A case's status is the run's outcome first, metrics second. If the run ended terminated, error, or aborted, that is the status and the metrics are decoration. Otherwise it is passed when every metric passed and failed when one did not — so a run that ends parked on a yield still passes, if what you asked about is true.

Case field What it sets
name, description The name is the report's key and the retry's label. Make it identify the sample.
runnable The agent, sequence, or loop under test — required.
input A string, or { message, state, initialState } to seed the session before the first turn.
toolMocks Per tool name: a { execute } mock, or the real tool for passthrough. Strict — a called tool with no entry throws inside the tool, and the default handler records that as a tool-phase error on the tool_result.
metrics This case's questions, on top of the suite's.
retries, timeout Re-run a failed, errored, or timed-out case up to retries more times; attempts on the result records how many it took.
userAgent, toolAgents, transform, maxTurns, maxDuration, stateMatches Passed straight through to simulate — section 5.
Suite option What it does
metrics Applied to every case. A case metric of the same name shadows it, and the runner warns.
concurrency Cases in flight at once. Default 64; 1 runs them in order.
repeat Run every case N times. Results carry repeatIndex/repeatTotal and the report groups them — this is how you measure flakiness rather than luck.
stopOnFirstFailure Stop the suite at the first failed or errored case.
onCase (result, completed, total) as each case lands — a progress bar, or a streamed log.
hooks Hooks installed on every run in the suite.

Step 4

The report

app.evaluate.report(options?) returns a function from a suite result to markdown: pass rate, per-metric rates with a mean of any 0–1 scores, then a Cases section that lists only the failures and their evidence. It is a pure function of the result — no rerun, no second pass over the model.

const markdown = app.evaluate.report({
  title: 'Refund suite',
  footer: (result) => `${result.results.length} cases in ${result.durationMs}ms`,
})(suite)

markdown

sections and renderCase customise the output when the audience is a reviewer rather than CI.

Each case result carries more than the report prints. run is the whole RunResult and events is its session ledger, so anything the test kit's helpers read, a failing case can be debugged with. turns counts the user messages in the run — one for a single-shot case, more once a simulated user is answering.

Case status Means
passed / failed The run finished and every metric agreed, or one did not.
terminated A simulate stop condition fired — terminationReason is maxTurns, maxDuration, or stateMatches.
error The run errored, or the case threw. error.message carries it.
timeout The case exceeded its own timeout.
aborted The run was aborted.

summary counts each of those across the suite as total, passed, failed, errors, terminated, aborted, timedOut. Only passed is good news; a suite that is 40% errors is not 60% healthy.

Step 5

Simulating the other side

Every case so far was one message in. Real behaviour needs a conversation, and a conversation needs someone on the other end. app.simulate is the loop app.evaluate runs each case through: run the agent, and while it is parked, answer it and run it again. A userAgent answers a message yield; a toolAgents entry, keyed by tool name, answers a yielding tool's question.

const chatSupport = app.agent({
  name: 'chat_support',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('Ask one question at a time.'), app.context.history()],
  tools: [],
  yields: true,
})

const customer = app.agent({
  name: 'customer',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('You are a customer chasing a refund. Answer briefly.'), app.context.history()],
  tools: [],
})

// Two agents interleaving: route by name so neither can eat the other's script.
const conversationAdapter = new MockAdapter({ defaultResponse: { text: 'the script ran out' } })
conversationAdapter.addResponses('agent:chat_support', [
  { text: 'Sorry about that — what is the order number?' },
  { text: 'Thanks. Was it more than three days late?' },
  { text: 'Then I can refund A-1 for you now.' },
])
conversationAdapter.addResponses('agent:customer', [{ text: 'A-1.' }, { text: 'Seven days late.' }])

const conversation = await adk({ adapters: { openai: conversationAdapter } }).simulate(chatSupport, {
  input: 'I want a refund.',
  userAgent: customer,
  maxTurns: 2,
})

const transcript = conversation.session.events.flatMap((event) => {
  if (event.type === 'user') return [`customer: ${event.text}`]
  if (event.type === 'assistant') return [`support: ${event.text}`]
  return []
})

const simulated = {
  status: conversation.status,
  stoppedBecause: conversation.status === 'terminated' ? conversation.terminationReason : undefined,
  transcript,
}

simulated

The customer's own turns are missing from that ledger on purpose: the simulated user runs in its own session, and only the message it produced crosses into the agent's. That is the whole trick — the agent under test cannot tell a simulated user from a real one, because it sees the same rows either way.

An agent with yields: true never completes on its own, so the loop needs a stop condition. maxTurns caps the exchanges, maxDuration caps the clock, and stateMatches stops as soon as the session state says the thing you were waiting for happened — { session: { refunded: true } }, with $exists, $eq, and $ne available for a key you want to test rather than match. Whichever fires, the result comes back terminated with the reason attached, and a case in a suite reports that status verbatim.

Two customisations sit either side of the loop. transform.prepareInput rewrites what the simulated user or tool agent is asked — by default the agent's last reply for a message yield, and the tool call as JSON for a tool yield. transform.processOutput rewrites what comes back, which is how a scripted answer table replaces a model entirely. A tool yield with no matching toolAgents entry also throws by name — but unlike a missing toolMocks entry, nothing records it. The throw escapes the loop, so simulate rejects and a case in a suite comes back error.

Step 6

Against a real model

Nothing above needed a key, and nothing above measured a model — a scripted adapter can only confirm the plumbing. Point the same cases at a real model and the suite starts answering the question it exists for: does this model, on this prompt, do the right thing? The mocks stay, so the refund gateway is still never called. The model is the only thing that changes. Paste a key into the box at the top of the page and press Run.

const live = await app.evaluate(refundCases, {
  metrics: [checkedFirst],
  onCase: (result, done, total) => console.log(`${done}/${total} ${result.name}: ${result.status}`),
})

app.evaluate.report({ title: 'Refund suite · live' })(live)

Now the numbers mean something, including the money ones: each case result carries usage, and the report totals tokens and cost across the suite when the provider reported them. Edit the system prompt in step 1, press Run again, and watch the pass rate and the bill move together. Run it with repeat: 5 and the report groups the runs per case — a metric that passes four times out of five is the fact a single green run was hiding.

Step 7

In your repo

An eval suite is an async function call and a number, so it needs no runner of its own. This block imports node:process, which this page's substrate does not serve, so it is shown rather than run.

import process from 'node:process'

import { app } from './app'
import { refundCases } from './cases'
import { checkedFirst } from './metrics'

const result = await app.evaluate(refundCases, {
  metrics: [checkedFirst],
  repeat: 5,
  onCase: (caseResult, done, total) =>
    process.stdout.write(`\r${done}/${total} ${caseResult.name}`),
})

process.stdout.write(`\n${app.evaluate.report({ title: 'Refund suite' })(result)}\n`)

// The gate is yours: `evaluate` reports, it does not exit. A CI suite that never fails the
// build is a dashboard, not a gate.
const bad = result.summary.failed + result.summary.errors + result.summary.timedOut
process.exit(bad > 0 ? 1 : 0)

That last line is the one teams forget. app.evaluate returns a result and never throws on a failed metric — deliberately, because a local exploration run wants the report more than it wants an exception. Anything blocking a merge has to read summary and decide.

Two boundaries worth naming. Deterministic behaviour belongs in scripted tests, not here: an eval that a script could have answered is just a slow, expensive test. And spoken conversations have their own surface: app.evaluate.voice, with recordings, transcripts, and turn timings — see voice agents.

Where this goes next: guardrails and recovery for the failures a suite keeps finding, and streaming and cost for the usage numbers the live report totalled.

Agent Development Kit · Prove & ship

Guardrails and recovery

A hook is one object that both watches a run and can interrupt it. An error handler decides what a failure means. Together they are the layer where you cap a refund, redact an argument, retry a flaky provider, and keep a run alive when a dependency is not. This chapter runs all of it against a scripted model, so every cell works with no key.

Runs · no key (the model is scripted) Assumes · the quickstart's ledger section Package · @animahealth/adk (MIT)

Step 1

One hook, nine lifecycle points

A Hook is a plain object with optional methods: onEvent and onStep observe, beforeAgent/afterAgent, beforeModel/afterModel and beforeTool/afterTool wrap each phase, and afterTurn runs inside the commit boundary. It is one interface rather than a split observer and interceptor because the concerns that need both — rate limits, budget caps, redaction — need them in the same place. Implement the methods you want; the rest are undefined and cost nothing.

First the setup. runTest from the test kit scripts the model but exposes no hooks option, so build the app yourself and hand it a MockAdapter — see testing agents without a model.

import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { MockAdapter } from '@animahealth/adk/testing'
import { z } from 'zod'

// Each turn the adapter serves the next scripted response; every cell below re-scripts it, so
// cells stay deterministic however often you press Run.
const scripted = new MockAdapter({ responses: [] })

const app = adk({ name: 'guardrails', adapters: { openai: scripted } })

// The tool records what it actually did — that is how we prove a guardrail stopped it.
const issued: number[] = []

const refund = app.tool({
  name: 'refund',
  description: 'Refund a payment, in pence',
  schema: z.object({ amount: z.number() }),
  execute: (ctx) => {
    issued.push(ctx.args.amount)
    return { refunded: ctx.args.amount }
  },
})

const support = app.agent({
  name: 'support',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('Refund what the customer asks for.'), app.context.history()],
  tools: [refund],
})

support.name

Now a hook that only watches. onEvent sees the stream as it happens — the same events that land in run.session.events — while the phase methods bracket the work. Note the ordering in the output: beforeTool fires before the tool runs, and afterTool receives the finished tool_result event, result payload and all.

const seen: string[] = []

scripted.setResponses([
  { toolCalls: [{ name: 'refund', args: { amount: 4000 } }] },
  { text: 'Refunded 4000. Anything else?' },
])

const traced = await app.run(support, {
  input: 'Refund my last order.',
  hooks: [
    {
      name: 'trace',
      onEvent: (event) => {
        seen.push(`event ${event.type}`)
      },
      beforeAgent: (ctx) => {
        seen.push(`beforeAgent ${ctx.runnable.name}`)
      },
      beforeTool: (ctx, call) => {
        seen.push(`beforeTool ${call.name}`)
      },
      afterTool: (ctx, result) => {
        seen.push(`afterTool ${result.name} -> ${JSON.stringify(result.result)}`)
      },
      afterAgent: (ctx, output) => {
        seen.push(`afterAgent ${typeof output}`)
      },
    },
  ],
})

seen

Every method here returned undefined, which is what makes it an observer. The moment one returns a value it becomes an interceptor — section 3. Two warnings the types will not give you:

  • beforeAgent returning a string replaces the run's output. Use a braced body when you only meant to log.
  • afterTurn fires only under app.handler.turn, plus the rest and agui handlers that delegate to it. Never under a bare app.run.

Step 2

Composition order

Hooks attach at three places: adk({ hooks }) for the app, app.agent({ hooks }) for one agent, and app.run(…, { hooks }) for one call. The runner flattens all three into a single hook — app outermost, then agent, then call site — and the flattening rule is the interesting part. Before hooks run outer to inner and the first non-undefined return wins, so an outer layer can pre-empt an inner one. After hooks run inner to outer, each free to rewrite what the previous returned, so an outer layer gets the last word on the result. Observation is different again: onEvent and onStep fan out to every hook, and their errors are swallowed — watching must never abort a run, while intercepting is control flow and throws.

composeHooks is that flattening, exported so you can build a bundle yourself and pass it anywhere a single hook goes. The built-ins are ordinary hooks too: loggingHook logs agent and tool boundaries, metricsHook turns events into counter callbacks, and cliHook renders a streaming run to stdout. That last one writes ANSI to process.stdout, so it belongs in a terminal, not this page. Each is also reachable as app.hook.logging(), app.hook.metrics() and app.hook.cli().

import { composeHooks, metricsHook } from '@animahealth/adk'
import type { Hook } from '@animahealth/adk'

const unwound: string[] = []
const measured: string[] = []

const stamp = (label: string): Hook => ({
  name: label,
  beforeTool: () => {
    unwound.push(`before ${label}`)
  },
  afterTool: () => {
    unwound.push(`after ${label}`)
  },
})

// One hook out of three: a built-in and two hand-written layers.
const bundle = composeHooks([
  metricsHook({
    onToolResult: (name, durationMs, error) => {
      measured.push(`${name} ${error ? 'failed' : 'ok'}`)
    },
  }),
  stamp('outer'),
  stamp('inner'),
])

scripted.setResponses([
  { toolCalls: [{ name: 'refund', args: { amount: 250 } }] },
  { text: 'Refunded 250.' },
])

await app.run(support, { input: 'Refund the delivery fee.', hooks: [bundle] })

const composition = { unwound, measured }

composition

Read unwound: before goes outer then inner, after comes back inner then outer. That is why a redaction hook belongs outside a logging hook — the logger sees whatever the redactor already rewrote.

Step 3

A hook that says no

Interception is the return value. beforeTool returning a ToolResultEvent means this is the result: the tool never executes, the event is appended to the session, and the model reads it on the next turn and explains itself. It fires before the arguments are parsed and before the tool is even resolved, so the hook sees exactly what the model asked for — the right place for a cap, an allow-list, or a permission check.

The symmetric returns elsewhere: beforeModel returning a ModelStepResult skips the provider call, afterModel and afterTool returning a value replace the result, and beforeModel/afterModel may also return a Runnable to hand the invocation to a different agent. beforeTool and afterTool deliberately cannot transfer — a tool-level handoff has no coherent meaning.

issued.length = 0 // rerunnable: forget what the earlier cells refunded

const refundCap: Hook = {
  name: 'refund_cap',
  beforeTool: (ctx, call) => {
    const amount = Number(call.args.amount)
    if (call.name === 'refund' && amount > 5000) {
      return {
        id: call.id,
        type: 'tool_result',
        createdAt: Date.now(),
        invocationId: call.invocationId,
        agentName: call.agentName,
        callId: call.callId,
        name: call.name,
        error: `Refunds over 5000 need a human. The model asked for ${amount}.`,
      }
    }
  },
}

scripted.setResponses([
  { toolCalls: [{ name: 'refund', args: { amount: 12000 } }] },
  { text: 'That refund is over my limit — a manager has to approve it.' },
])

const capped = await app.run(support, { input: 'Refund my £120 order.', hooks: [refundCap] })

const cappedReport = { executed: issued, text: capped.output.text }

cappedReport

executed is empty: the model asked, the hook answered, your money stayed put. One asymmetry to know — a vetoed call skips afterTool entirely, because there was no tool result to post-process. Put audit logging in beforeTool or onEvent if it must see the calls that were refused.

Step 4

What a throw actually does

A tool that throws does not fail the run. Every failure becomes an ErrorContext — the invocation, the phase (model, tool, callback or render), the attempt number, the error — and is offered to the error handlers. With no handler registered the default is deliberate and asymmetric. A tool error is skipped, recorded as the error field of its tool_result so the model can see it and react. A model error is thrown, because there is nothing left to reason with.

import { isToolResultEvent } from '@animahealth/adk'

const lookupOrder = app.tool({
  name: 'lookup_order',
  description: 'Look up an order by id',
  schema: z.object({ id: z.string() }),
  timeout: 50,
  execute: async (ctx) => {
    if (ctx.args.id === 'A-42') throw new Error('order service unreachable')
    await new Promise((resolve) => setTimeout(resolve, 2000)) // slower than the 50ms timeout
    return { id: ctx.args.id, status: 'shipped' }
  },
})

const orderDesk = app.agent({
  name: 'order_desk',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('Look the order up before answering.'), app.context.history()],
  tools: [lookupOrder],
})

scripted.setResponses([
  { toolCalls: [{ name: 'lookup_order', args: { id: 'A-42' } }] },
  { text: 'The order service is down, so I could not check A-42.' },
])

const survived = await app.run(orderDesk, 'Where is order A-42?')

const survivedReport = {
  status: survived.status,
  toolResults: survived.session.events
    .filter(isToolResultEvent)
    .map((event) => ({ name: event.name, error: event.error })),
  text: survived.output.text,
}

survivedReport

The run is completed, not error. That is the shape you want for a flaky dependency and the shape you must override for a dependency whose failure is not survivable — which is what section 5 is for.

Step 5

Six recovery actions

An error handler is { canHandle?, handle }, and handle returns one of six verdicts:

  • throw aborts the run with the original error.
  • skip records the error and carries on.
  • abort ends the invocation cleanly.
  • retry, with an optional delay, runs the same step again.
  • fallback substitutes a result as if the step had succeeded.
  • pass declines, handing the decision to the next handler.

Handlers are consulted in order — runner, then agent, then call site. The first verdict that is not pass wins. If every handler passes, the phase default from section 4 applies.

Five handlers ship:

  • retryHandler — bounded exponential backoff, then pass.
  • rateLimitHandler — a retry handler that only fires on rate limit, 429, or too-many-requests messages.
  • timeoutHandler — matches timed out, then fallback if you gave it a fallbackResult, otherwise skip.
  • loggingHandler — logs and always passes.
  • defaultHandler — the phase default, made explicit.

The cell below stacks three of them against a run that fails twice, in two different phases.

import { loggingHandler, retryHandler, timeoutHandler } from '@animahealth/adk'

const failures: string[] = []

scripted.setResponses([
  { error: new Error('503 upstream unavailable') }, // model call fails
  { toolCalls: [{ name: 'lookup_order', args: { id: 'B-7' } }] }, // retry: this one calls the slow tool
  { text: 'B-7 is unconfirmed — the live lookup timed out, so this is the cached status.' },
])

const recovered = await app.run(orderDesk, {
  input: 'Where is order B-7?',
  errorHandlers: [
    // Observes every failure and defers — `pass` keeps the chain moving.
    loggingHandler({
      onError: (ctx) => {
        failures.push(`${ctx.phase} · attempt ${ctx.attempt} · ${ctx.error.message}`)
      },
    }),
    // Ordered before the retry handler: a timeout should degrade, not hammer the dependency.
    timeoutHandler({ fallbackResult: { status: 'unknown', source: 'cache' } }),
    // `retryable` narrows this one to model failures; without it, it would retry everything.
    retryHandler({ maxAttempts: 3, baseDelay: 0, retryable: (ctx) => ctx.phase === 'model' }),
  ],
})

const recoveredReport = {
  failures,
  toolResults: recovered.session.events
    .filter(isToolResultEvent)
    .map((event) => ({ name: event.name, result: event.result })),
  text: recovered.output.text,
}

recoveredReport

Two failures, two different verdicts, one completed run. The model's 503 was retried and the second attempt called the tool; the tool's timeout was substituted with a cached-looking result that the model then wrote its answer from. Order is the whole design here: had retryHandler come first without its retryable predicate, it would have claimed the timeout too and retried a dependency that was already too slow.

Step 6

Timeouts and caps

Deadlines exist at three scopes, and they do different things. A tool's own timeout (milliseconds) races that one execution and raises Tool 'name' timed out after Nms — an ordinary tool-phase error, which is why timeoutHandler can catch it and why the tool_result is flagged timedOut. A tool's retry takes a RetryConfig — spelled out on the tools page — and retries inside the tool, before any error handler is consulted. And app.run(…, { timeout }) bounds the whole run: it races the event stream and rejects with Timeout after Nms, so it is a hard stop, not a recoverable phase error.

Two caps stop a loop that never settles. An agent's maxSteps (default 25) bounds reasoning iterations in one invocation and ends the run with status max_steps rather than an error; maxTurns (default 100) bounds yield-and-resume cycles the same way. A third cap is not configurable: an error handler that keeps answering retry for the same tool call is cut off after ten attempts, so a handler bug degrades into a recorded failure instead of an infinite loop.

Every guardrail decision ends as an event in the session, so it is already in the audit trail you would read anyway.

Which raises the question of where that session lives once the process ends. Next: stores and the sleeping agent.

Agent Development Kit · Prove & ship

Stores: where a sleeping agent actually lives

A paused agent is rows in a table — that is the whole claim, and a store is what makes it true. One interface, four implementations, one shared conformance suite. The cells below run the shipped runtime against a real store in this page, with the model scripted, so none of them needs a key.

Audience · engineers deploying agents Needs · nothing (all cells are scripted) Package · @animahealth/adk (MIT)

Step 1

The contract a store implements

SessionStore is seven methods and no cleverness. It persists two things: a session's metadata row plus its events, and scoped state — the values shared by every session bound to one user, patient, practice, org, or team. Everything else the ADK does with sessions (binding scopes, buffering events, tracking what is dirty) sits above the store, in the session service, so a store never has to think about it.

interface SessionStore {
  load(
    appName: string,
    sessionId: string,
  ): Promise<{ session: StoredSession; events: Event[] } | null>

  commit(
    session: StoredSession,
    newEvents: Event[],
    expectedVersion: number,
    scopedChanges?: ScopedStateChange[],
  ): Promise<CommitResult>

  delete(appName: string, sessionId: string): Promise<void>

  loadScopedState(appName: string, scope: string, scopeId: string): Promise<Record<string, unknown>>

  saveScopedState(
    appName: string,
    scope: string,
    scopeId: string,
    state: Record<string, unknown>,
  ): Promise<void>

  close(): Promise<void>

  list(appName: string): Promise<Array<{ id: string; updatedAt: number }>>
}

Note what StoredSession does not carry: the events. It is id, appName, version, scopes, and createdAt. Sessions routinely reach several megabytes of event data, so a store that kept events inside the metadata row would rewrite the entire history on every turn — and would hit DynamoDB's 400KB item limit as a hard ceiling on conversation length.

inMemoryStore() is exported from the package root, so the contract is exercisable right here. Commit takes an expectedVersion; 0 means create this session. Commits are append-only and idempotent by event id, so the second commit below — a retried batch that overlaps the stored history — cannot double an event, and its genuinely new one still lands at the end in order.

import { inMemoryStore } from '@animahealth/adk'

const store = inMemoryStore()

// The metadata row — no events in it, by design.
const meta = {
  id: 'session_demo',
  appName: 'bookings',
  version: 0,
  scopes: {},
  createdAt: Date.now(),
}

const makeEvent = (n: number) => ({
  id: `evt-${n}`,
  type: 'user' as const,
  createdAt: Date.now() + n,
  text: `message ${n}`,
})

const created = await store.commit(meta, [makeEvent(1), makeEvent(2)], 0)

// The same batch again, one event further on: event 2 is already stored, event 3 is not.
const appended = await store.commit({ ...meta, version: 1 }, [makeEvent(2), makeEvent(3)], 1)

const reread = await store.load('bookings', 'session_demo')

const ledger = {
  created,
  appended,
  events: reread?.events.map((e) => e.id),
  version: reread?.session.version,
}

ledger

Concurrency is optimistic, not locked. Two runtimes can hold the same session; the second one to commit finds the version moved and is told so, with the version it actually needs. Nothing is written on a rejected commit — not the events, not the scoped-state changes that rode along with it.

// A writer that loaded at version 1, while someone else has already moved the row to 2.
const stale = await store.commit({ ...meta, version: 1 }, [makeEvent(4)], 1)

const afterStale = await store.load('bookings', 'session_demo')

const conflict = {
  stale,
  eventsAfterTheRejectedCommit: afterStale?.events.length,
}

conflict

Why optimistic concurrency rather than a lock or a per-session queue: an agent turn runs for five to sixty seconds. That is far too long to hold a distributed lease — a crashed runtime would strand it — and a per-session queue would park every incoming message behind whatever slow run is in flight.

Scoped state is the store's other surface, and its save is a merge: keys you do not mention are untouched, and a key set to undefined is deleted.

await store.saveScopedState('bookings', 'user', 'u-1', { theme: 'dark', locale: 'en-GB' })
await store.saveScopedState('bookings', 'user', 'u-1', { theme: 'light', locale: undefined })

const scoped = await store.loadScopedState('bookings', 'user', 'u-1')

scoped

Step 2

The row is real only after a commit

A bare app.run does not persist anything, store or no store. Events accrue on the in-memory session; the store is not touched until something commits. app.handler.rest() and app.handler.turn() commit after every turn, which is why serving an agent needs no persistence code. Driving app.run yourself, the commit is yours: await app.sessions.commit(session). Skip it and the sleeping agent is not asleep — it is gone when the process exits.

Below is a real app on a real store, with the model scripted so the page needs no key (see Testing agents without a model). Everything under the model — session, ledger, store — is the shipped runtime. app.sessions.create() writes the metadata row immediately: version 1, zero events.

import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { MockAdapter } from '@animahealth/adk/testing'

const app = adk({
  name: 'bookings',
  store: inMemoryStore(),
  adapters: { openai: new MockAdapter({ responses: [{ text: 'Tuesday 14:30 is open.' }] }) },
})

const concierge = app.agent({
  name: 'concierge',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('Be brief.'), app.context.history()],
  tools: [],
})

const session = await app.sessions.create()

const run = await app.run(concierge, { session, input: { message: 'Anything on Tuesday?' } })

// Read the row back from the store while the run's events are still only in memory.
const onDisk = await app.sessions.get(session.id)

const afterRun = {
  runStatus: run.status,
  said: run.output.text,
  eventsInMemory: session.events.length,
  eventsInTheStore: onDisk?.events.length,
}

afterRun

The run completed, the agent replied, and the store holds nothing. Now commit. The version advances, the whole ledger lands, and the reloaded row answers for the agent — the reply comes back out of storage, not out of the object you still have in scope.

const committed = await app.sessions.commit(session)

const reloaded = await app.sessions.get(session.id)

const afterCommit = {
  committed,
  eventsInTheStore: reloaded?.events.length,
  versionInTheStore: reloaded?.version,
  saidByTheReloadedRow: reloaded?.output.text,
  ledger: reloaded?.events.map((e) => e.type),
}

afterCommit

Pass no session at all and app.run builds one for the duration of the call. It is never registered with the store, so it is absent from app.sessions.list() — the listing that a "pending work" screen or a sweeper reads. It is not lost, though: nothing wrote it, and committing it afterwards creates the row.

const orphan = await app.run(concierge, 'Anything on Wednesday?')

const listedBefore = await app.sessions.list()
const rescued = await app.sessions.commit(orphan.session)
const listedAfter = await app.sessions.list()

const listing = {
  orphanId: orphan.session.id,
  listedBefore: listedBefore.map((s) => s.id),
  rescued,
  listedAfter: listedAfter.map((s) => s.id),
}

listing

When the handlers commit for you they resolve conflicts too, and report which way it went in commitStatus; the four words it can hold are tabled in serving. When you are done with the app, app.close() closes the store, so a CLI or a worker exits instead of hanging on a live pool.

Step 3

The four stores

Which store is a deployment decision, not an agent one. The agent, the tools, and the session code are identical across all four; only the line that builds the app changes. The three backed stores sit behind subpath exports, with their drivers as optional peers.

In-memory

The default. adk() with no store is already using it. Maps in the process; everything is gone when the process exits. It holds the same append-by-id dedup and the same OCC semantics as the SQL stores, which is what makes it a legitimate stand-in for them in tests.

import { adk, inMemoryStore } from '@animahealth/adk'

const app = adk({ name: 'bookings', store: inMemoryStore() })
// identical to: adk({ name: 'bookings' })

SQLite

Zero infrastructure and genuinely durable — a file. This is the right store for local development, for a CLI, and for a single-process deployment. Parent directories are created for you, and the connection runs in WAL mode. Pass ':memory:' for an ephemeral database that still exercises the real SQL path.

import { adk } from '@animahealth/adk'
import { sqliteStore } from '@animahealth/adk/stores/sqlite'

const app = adk({ name: 'bookings', store: sqliteStore('./data/bookings.db') })

// npm install better-sqlite3   (optional peer, >=11)

A commit — the version bump, the event appends, and the scoped-state writes — is one transaction, so a rejected commit writes nothing at all.

Postgres

The multi-process store: several web nodes and workers sharing one session table, with OCC as the referee between them. Either hand it a connection string and let it build a pool, or hand it a pool you already manage — the store leaves an injected pool entirely alone, including its error handling and shutdown. It throws at construction if given neither.

import { adk } from '@animahealth/adk'
import { postgresStore } from '@animahealth/adk/stores/postgres'

const app = adk({
  name: 'bookings',
  store: postgresStore({ connectionString: process.env.DATABASE_URL }),
})

// or bring your own pool — the store will not close or re-configure it:
import { Pool } from 'pg'

const shared = new Pool({ connectionString: process.env.DATABASE_URL, max: 20 })
const store = postgresStore({ pool: shared })

// npm install pg   (optional peer, >=8)

Every commit runs inside BEGIN/COMMIT on one dedicated client from the pool, so a version conflict rolls the whole thing back — there is no window in which the version advanced but the events did not.

DynamoDB

Single-table, serverless, and the one store with no transaction across items. The table needs a string partition key and a string sort key; their names default to pk and sk and are configurable if your table already uses others.

import { adk } from '@animahealth/adk'
import { dynamoStore } from '@animahealth/adk/stores/dynamodb'

const app = adk({
  name: 'bookings',
  store: dynamoStore({
    tableName: 'adk-sessions',
    client: { region: 'eu-west-2' }, // any DynamoDBClientConfig
    partitionKey: 'pk', // default
    sortKey: 'sk', // default
  }),
})

// npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb   (optional peers, >=3)

Two honest caveats, both in the source. First, atomicity: the metadata PutItem is the OCC gate and runs first; events and scoped state follow as batched writes. If the gate succeeds and a batch then fails, the version has advanced without its events. Second, list() is a table Scan — the key layout has no app-level partition — so put a GSI over a dedicated app-name attribute in front of it before leaning on it at scale.

Store Import Peer Commit atomicity Reach for it when
inMemoryStore() @animahealth/adk none process-local, all-or-nothing tests, demos, anything disposable
sqliteStore(path) /stores/sqlite better-sqlite3 one transaction local dev, CLIs, one process
postgresStore(config) /stores/postgres pg one transaction many processes, one database
dynamoStore(config) /stores/dynamodb AWS SDK v3 OCC gate, then batched writes serverless, no database to run

Step 4

What lands on disk, and who creates it

Three tables, the same shape in SQLite and Postgres: the session row, the event log keyed by event id and ordered by idx, and scoped state. This is the whole footprint of a sleeping agent.

-- Postgres. SQLite is the same three tables with TEXT/INTEGER in place of JSONB/BIGINT.
CREATE TABLE IF NOT EXISTS sessions (
  app_name TEXT NOT NULL,
  id TEXT NOT NULL,
  version INTEGER NOT NULL DEFAULT 0,
  scopes JSONB NOT NULL DEFAULT '{}',
  created_at BIGINT NOT NULL,
  updated_at BIGINT NOT NULL,
  PRIMARY KEY (app_name, id)
);

CREATE TABLE IF NOT EXISTS events (
  app_name TEXT NOT NULL,
  session_id TEXT NOT NULL,
  event_id TEXT NOT NULL,
  idx INTEGER NOT NULL,
  data JSONB NOT NULL,
  PRIMARY KEY (app_name, session_id, event_id)
);

CREATE INDEX IF NOT EXISTS idx_events_order
  ON events (app_name, session_id, idx);

CREATE TABLE IF NOT EXISTS scoped_state (
  app_name TEXT NOT NULL,
  scope TEXT NOT NULL,
  scope_id TEXT NOT NULL,
  key TEXT NOT NULL,
  value JSONB NOT NULL,
  PRIMARY KEY (app_name, scope, scope_id, key)
);

There is no migration step to run. Both SQL stores issue that DDL themselves — SQLite when it opens the file, Postgres once per store instance before its first query. It is IF NOT EXISTS throughout, so it is safe to run from every process on every boot. The practical consequence is a permissions one: the role your Postgres store connects as needs table-creation rights the first time it runs against a fresh database. A SELECT/INSERT-only role will fail on that first statement, not on the first commit.

The tables are unqualified, so they land in the connection's current schema — point the store at a dedicated database or set the search_path if you want them somewhere specific.

DynamoDB has no DDL to issue, so its one piece of provisioning is yours: create the table with a string HASH key and a string RANGE key. Everything the store writes — the meta item, one item per event, one item per scoped-state key — is keyed inside that pair.

// The table this store expects. Events sort by (v, seq): `v` is the session version the OCC gate
// just granted, `seq` the 0-based position within that commit's batch — both known at write time,
// with no read-before-write and no counter to race on.
await client.send(
  new CreateTableCommand({
    TableName: 'adk-sessions',
    AttributeDefinitions: [
      { AttributeName: 'pk', AttributeType: 'S' },
      { AttributeName: 'sk', AttributeType: 'S' },
    ],
    KeySchema: [
      { AttributeName: 'pk', KeyType: 'HASH' },
      { AttributeName: 'sk', KeyType: 'RANGE' },
    ],
    BillingMode: 'PAY_PER_REQUEST',
  }),
)

Step 5

One suite, four stores, in public CI

The suite is the contract for a store you write yourself: implement the seven methods, register it with runSessionStoreTests(name, createStore, cleanup), and any gap between your store and the shipped ones surfaces as a failure in the same assertions. It is what backs "they implement the same interface" for the shipped stores too — four stores, five registrations in one file, because SQLite runs twice, file and ':memory:'.

// src/session/compliance.test.ts — every store registers against the same suite.
runSessionStoreTests('InMemoryStore', () => new InMemoryStore())
runSessionStoreTests('SQLiteStore', /* a throwaway file database */)
runSessionStoreTests('SQLiteStore (:memory:)', /* the advertised ephemeral mode */)
runSessionStoreTests('PostgresStore', /* when TEST_DATABASE_URL is set */)
runSessionStoreTests('DynamoDBStore', /* when TEST_DYNAMODB_ENDPOINT is set */)

The suite covers load, commit, delete, list, and scoped state — including the behaviours that are easy to get subtly wrong and impossible to notice: event order preserved within a batch and across batches, a re-committed event id neither duplicated nor allowed to disturb later ordering, scoped state written atomically with the commit and not written on a conflict, a conflict returned for a commit against a deleted session, and batches larger than DynamoDB's 25-item write limit. The second SQLite registration earns its place: ':memory:' has its own failure shape, where a second connection is a second, empty database.

The backed stores are not skipped in the public repository's CI. A second job runs the compliance file against service containers — stock Postgres (via the pgvector image, which also backs the vector-index suites) and amazon/dynamodb-local — wired in by environment variable. Neither job needs a secret, so both run on fork pull requests too.

# .github/workflows/ci.yml — the backend-compliance job
services:
  postgres:
    image: pgvector/pgvector:pg17
    ports: ['5432:5432']
  dynamodb:
    image: amazon/dynamodb-local
    ports: ['8000:8000']

env:
  TEST_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/adk
  TEST_DYNAMODB_ENDPOINT: http://127.0.0.1:8000

steps:
  - run: pnpm run test -- src/session/compliance.test.ts …

Run it locally the same way: set those two variables and the skipped registrations wake up. With neither set, the suite still runs in full against the in-memory and SQLite stores, and the other two report themselves skipped rather than passing quietly.

Step 6

A sleeping agent you can actually run

The Bookings sample in the package is this chapter with a filesystem attached: the session becomes a row in bookings.db, the process exits, and a later command resumes the run from exactly where it paused. The store is one line of the app, and every command that runs the agent also commits it.

// sample/src/bookings.ts
export const app = adk({
  name: 'bookings',
  schema: { session: { confirmation: confirmation.optional() } },
  store: sqliteStore(DB_PATH),
})
// sample/src/cli.ts
const session = await app.sessions.create()
const result = await app.run(bookingAgent, { session })
await app.sessions.commit(session)

Agent Development Kit · Prove & ship

Serving it: one turn per request

A handler is the seam between a request and an agent. It loads the session, applies whatever arrived, runs one turn, and commits — so the next request can be served by a different process, or a different machine, or next week. The agent never sees the request. Every cell below runs the shipped handlers with no key.

Audience · engineers deploying agents Needs · no key (a hook answers for the model) Package · @animahealth/adk (MIT)

Step 1

One request, one turn

app.handler carries turn, rest, agui and voice. The first three are one thing wearing three coats: turn is the atom — session in, one run, commit, result out — and it returns a stream. rest drains that stream and returns a JSON body; agui translates it into the AG-UI protocol. Both call turn.

Each takes a config once and hands back a function you call per request. The config is the deployment's business — which agent, which store, which hooks, how much to report. The per-request part is two fields: a sessionId and an input.

interface HandlerInput {
  sessionId?: string
  input: Input // { message?, tools?, state?, initialState? }
}

// app.handler.* fills in the app's name, schema and store, and prepends the app's own
// hooks and error handlers to these.
interface HandlerConfig {
  agent: Runnable
  schema?: StateSchema
  sessionService?: SessionService
  hooks?: Hook[]
  errorHandlers?: ErrorHandler[]
  timeout?: number
  response?: { events?: boolean; usage?: boolean; state?: boolean }
}

Omit the sessionId and the handler mints one. Supply it and the handler loads that row, or starts it if there is nothing there yet. Ids are normalised on the way in, so thread-42 and session_thread-42 are the same thread.

Here is the agent the rest of the page serves. The model is stood down by a hook — beforeAgent returning a string replaces the run's output before any model is reached (see Guardrails and recovery). Everything under it is the shipped request path.

import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { z } from 'zod'

const app = adk({ name: 'bookings' })

const lookupStay = app.tool({
  name: 'lookup_stay',
  description: 'Look up a booking by its reference',
  schema: z.object({ ref: z.string() }),
  execute: (ctx) => ({ ref: ctx.args.ref, nights: 3 }),
})

const concierge = app.agent({
  name: 'concierge',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('Answer booking questions.'), app.context.history()],
  tools: [lookupStay],
})

// The model, stood down: this hook answers before the model call, so every handler on this page
// runs with no key. Delete it and the same code talks to OpenAI.
const scripted = (text: string) => [{ beforeAgent: () => text }]

concierge.name

Step 2

The REST handler

app.handler.rest(config) returns (input) => Promise<RestResponse>. It runs the turn to completion and hands back a plain object you can serialise. That is the whole surface: no framework, no router, no middleware contract.

const askConcierge = app.handler.rest({
  agent: concierge,
  hooks: scripted('Three nights, checking out Friday.'),
  response: { state: true },
})

const reply = await askConcierge({
  sessionId: 'thread-42',
  input: { message: 'How long is my stay?', state: { channel: 'sms' } },
})

reply

Three fields are always there — sessionId, status, output — and the rest are earned. yieldedTools appears when the turn yielded, error when it failed, warning when the commit was messy. The other three are opt-in, because a chat client and an audit endpoint want different amounts of the turn:

response flag Adds Which is
state state a copy of the session state as the turn left it
usage usage the turn's UsageSummary — absent if it called no model
events events every event the turn streamed, deltas included

One sharp edge on the last of those: events is typed Event[], but a stream also carries assistant_delta and thought_delta, which are not ledger events. Narrow on type before you trust an entry, or read the ledger from the session instead.

Now the reason this is worth anything. Call the same handler again on the same sessionId: the second turn resumes a conversation nobody kept in memory, because the first one committed before it returned.

const secondReply = await askConcierge({
  sessionId: 'thread-42',
  input: { message: 'And the checkout time?' },
})

const thread = await app.sessions.get('thread-42')

const durable = {
  turns: [reply.status, secondReply.status],
  eventsOnDisk: thread?.events.length,
  version: thread?.version,
  ledger: thread?.events.map((event) => event.type),
}

durable

handler.rest and handler.turn commit after every turn, so serving an agent needs no persistence code of your own — which store the rows land in, and what a bare app.run does instead, are Where a sleeping agent lives.

Behind an HTTP server it stays this small. The handler is built once, at boot; the request body supplies the two fields.

import { createServer } from 'node:http'

import { adk } from '@animahealth/adk'
import { postgresStore } from '@animahealth/adk/stores/postgres'

const bookings = adk({
  name: 'bookings',
  store: postgresStore({ connectionString: process.env.DATABASE_URL }),
})

const ask = bookings.handler.rest({ agent: concierge, response: { usage: true } })

createServer(async (req, res) => {
  const chunks: Buffer[] = []
  for await (const chunk of req) chunks.push(chunk as Buffer)
  const body = JSON.parse(Buffer.concat(chunks).toString())

  const turn = await ask({ sessionId: body.sessionId, input: { message: body.message } })

  res.writeHead(turn.status === 'error' ? 500 : 200, { 'content-type': 'application/json' })
  res.end(JSON.stringify(turn))
}).listen(3000)

Step 3

A turn that stops

A yielding tool crossing a request boundary needs no extra protocol. The turn comes back yielded_tool with the calls it is waiting on; a later request carries the answers in input.tools. That is the pause-and-resume of Stopping to ask, with HTTP in the gap instead of a runTest step.

// POST /turns { "sessionId": "y1", "message": "book me in" }
const paused = await ask({ sessionId: 'y1', input: { message: 'book me in' } })

// paused.status       → 'yielded_tool'
// paused.output.items → []                       (nothing was said; the turn stopped)
// paused.yieldedTools → [{ callId: 'call_125f…', name: 'ask_guest', args: { q: 'Which night?' } }]

// POST /turns { "sessionId": "y1", "tools": [{ "callId": "call_125f…", "input": { … } }] }
if (paused.status === 'yielded_tool' && paused.yieldedTools) {
  const [pending] = paused.yieldedTools

  const resumed = await ask({
    sessionId: paused.sessionId,
    input: { tools: [{ callId: pending.callId, input: { answer: 'Friday' } }] },
  })

  // resumed.status      → 'completed'
  // resumed.output.text → 'Booked for Friday.'
}

Note the guard. RunResult is a discriminated union, so status === 'yielded_tool' is enough there. RestResponse is a flat object with optional fields, so check yieldedTools yourself.

Between those two requests the agent is rows. Nothing is held open, so the process that asked the question and the process that receives the answer need not be the same one.

Step 4

The stream underneath

app.handler.turn(config) returns (input) => StreamResult<TurnResult> — an async iterable of stream events that returns the turn result. Take it when you want the tokens as they arrive: a server-sent-event endpoint, a websocket, a CLI.

Two things to know before you iterate. The stream is lazy — it starts when something awaits or iterates it, not when you call the handler. And the TurnResult is the generator's return value, which a for await loop throws away, so drive the iterator by hand when you need it.

const streamTurn = app.handler.turn({
  agent: concierge,
  hooks: scripted('Checkout is at eleven.'),
})

const live = streamTurn({ sessionId: 'thread-42', input: { message: 'Remind me again?' } })

const streamedTypes: string[] = []
const iterator = live[Symbol.asyncIterator]()
let step = await iterator.next()
while (!step.done) {
  streamedTypes.push(step.value.type)
  step = await iterator.next()
}

const outcome = {
  invocationId: live.invocationId,
  streamed: streamedTypes,
  status: step.value.status,
  commitStatus: step.value.commitStatus,
  sessionId: step.value.sessionId,
}

outcome

Sparse, because the model on this page never runs: a real turn also streams model_start, assistant_delta, tool_call, model_end. What matters is the pair around them — invocation_start opens the turn, and after invocation_end the result carries a commitStatus the stream never mentioned. That word is the next section.

live.abort() stops the run. An aborted turn does not commit at all: its commitStatus is undefined and its events stay where they were. So does a turn that threw — the result comes back status: 'error' with the message, and nothing is written.

Step 5

The AG-UI handler

AG-UI is a protocol for agent front-ends: a fixed vocabulary of events a generic client can render without knowing what framework produced them. app.handler.agui(config) returns (input) => AsyncIterable<AGUIEvent>, ready to write down an SSE connection.

import { createServer } from 'node:http'

const ui = bookings.handler.agui({ agent: concierge })

createServer(async (req, res) => {
  const url = new URL(req.url ?? '/', 'http://localhost')
  res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })

  for await (const event of ui({
    sessionId: url.searchParams.get('session') ?? undefined,
    input: { message: url.searchParams.get('message') ?? '' },
  })) {
    res.write(`data: ${JSON.stringify(event)}\n\n`)
  }

  res.end()
}).listen(3001)

Every stream is bracketed the same way. RUN_STARTED carries the session as threadId and the invocation as runId. Then a STATE_SNAPSHOT, which is empty — state reaches the client as STATE_DELTA patches as it changes, not as an opening picture. Then the turn's events, translated one by one:

ADK stream event AG-UI events
assistant_delta TEXT_MESSAGE_START on the first delta, then TEXT_MESSAGE_CONTENT
assistant TEXT_MESSAGE_END
thought_delta REASONING_START, REASONING_MESSAGE_START, then REASONING_MESSAGE_CONTENT
thought REASONING_MESSAGE_END, REASONING_END
tool_call TOOL_CALL_START, TOOL_CALL_ARGS, and TOOL_CALL_END unless the call yields
tool_result TOOL_CALL_RESULT
tool_yield CUSTOM named TOOL_YIELD
state_change STATE_DELTA — JSON-Patch ops at /<scope>/<key>

A text message therefore exists only if the model streamed deltas. The end of the run says which way it went: RUN_FINISHED carrying result: { commitStatus } when the turn finished, a CUSTOM named RUN_INTERRUPTED when it yielded (the pending callId and tool name ride in its value), and RUN_ERROR — with no RUN_FINISHED — when it failed or was aborted.

An interrupted turn emits RUN_FINISHED twice. runInterrupted appends one, and the handler appends another after it. Only the second carries result, so a client that closes on the first sees no commit status.

handler.agui is a thin loop over handler.turn, and the translator it uses is exported. AgUIAdapter takes options the handler does not forward — step events, reasoning off, raw events attached, a transformer per yielding tool — so when you want those, or a protocol that is not AG-UI at all, write the loop yourself.

import { AgUIAdapter } from '@animahealth/adk/agui'

const runTurn = bookings.handler.turn({ agent: concierge })

async function* protocol(sessionId: string, message: string) {
  const live = runTurn({ sessionId, input: { message } })
  const adapter = new AgUIAdapter(sessionId, live.invocationId, { includeSteps: true })

  yield adapter.runStarted()

  const iterator = live[Symbol.asyncIterator]()
  let step = await iterator.next()
  while (!step.done) {
    yield* adapter.transform(step.value)
    step = await iterator.next()
  }

  yield adapter.runFinished({ commitStatus: step.value.commitStatus })
}

Step 6

Four ways a turn ends up on disk

Sessions are guarded by a version, not a lock — that is the store's bargain, argued in Where a sleeping agent lives — so a handler must decide what to do when a commit is refused, and commitStatus on the turn result reports which way it decided:

commitStatus What happened What the REST response shows
committed the version was clean the turn, as it ran
merged the row had moved, and this turn's events were appended to the new head the turn, as it ran
skipped a newer user message arrived mid-turn, so this reply is stale and is dropped status: 'skipped' and an empty output
orphaned the merge failed too; nothing was written the turn, plus a warning

Underneath are the two calls the handler makes for you, and you can make them yourself. Load one session twice — two requests, two processes, same row — and watch the second commit be refused.

await app.sessions.create({ sessionId: 'thread-shared' })

const replicaOne = await app.sessions.get('thread-shared')
const replicaTwo = await app.sessions.get('thread-shared')
if (!replicaOne || !replicaTwo) throw new Error('no session thread-shared')

const loadedAt = [replicaOne.version, replicaTwo.version]

replicaOne.input.message('from the first request')
replicaTwo.input.message('from the second request')

const won = await app.sessions.commit(replicaOne)
const lost = await app.sessions.commit(replicaTwo)
const rescued = await app.sessions.merge(replicaTwo)
const settled = await app.sessions.get('thread-shared')

const conflict = {
  loadedAt,
  won,
  lost,
  rescued,
  version: settled?.version,
  ledger: settled?.events.map((event) => (event.type === 'user' ? event.text : event.type)),
}

conflict

The refusal is data, not an exception: { ok: false, conflict: true, currentVersion }, and it tells you the version you actually needed. Nothing was written — not the events, not the scoped state riding with them. merge then re-reads the head and appends this writer's new events onto it, so both messages survive. Losing a race costs an extra round trip, not a turn.

Step 7

Two turns, one session

Now the same collision through the handlers, with no conflict simulated. Two turns are started against one sessionId. In the first pair a slow turn is overtaken by a message that arrives while it is still thinking. In the second, both arrive at once.

const slowConcierge = app.handler.turn({
  agent: concierge,
  hooks: [
    {
      beforeAgent: async () => {
        await new Promise((resolve) => setTimeout(resolve, 150))
        return 'the slow answer'
      },
    },
  ],
})
const fastConcierge = app.handler.turn({ agent: concierge, hooks: scripted('the fast answer') })

// Promise.resolve starts a lazy turn stream, so the second request lands mid-flight.
const slowTurn = Promise.resolve(
  slowConcierge({ sessionId: 'race', input: { message: 'first question' } }),
)
await new Promise((resolve) => setTimeout(resolve, 30))
const fastTurn = Promise.resolve(
  fastConcierge({ sessionId: 'race', input: { message: 'second question' } }),
)

const [slowOutcome, fastOutcome] = await Promise.all([slowTurn, fastTurn])
const raced = await app.sessions.get('race')

const together = app.handler.turn({ agent: concierge, hooks: scripted('either answer') })
const [firstOutcome, secondOutcome] = await Promise.all([
  Promise.resolve(together({ sessionId: 'tie', input: { message: 'A' } })),
  Promise.resolve(together({ sessionId: 'tie', input: { message: 'B' } })),
])
const tied = await app.sessions.get('tie')

const said = (session: typeof raced) =>
  session?.events.map((event) => (event.type === 'user' ? event.text : event.type))

const race = {
  overtaken: {
    slow: [slowOutcome.status, slowOutcome.commitStatus],
    fast: [fastOutcome.status, fastOutcome.commitStatus],
    ledger: said(raced),
  },
  together: {
    first: [firstOutcome.status, firstOutcome.commitStatus],
    second: [secondOutcome.status, secondOutcome.commitStatus],
    ledger: said(tied),
  },
}

race

Read the first ledger. The slow turn ran to completed and produced an answer, and none of it is there — not the reply, not even the question that started it. The rule is a timestamp comparison: on a rejected commit the handler re-reads the row, and if it holds a user message newer than this turn's own, the whole uncommitted slice is dropped. The guest moved on, so the answer to the old question would arrive out of order.

The second ledger is the other outcome: nothing overtook anything, so the loser merged and both exchanges are on the row, in commit order. One question separates the two — did a newer user message land while this turn was running.

A skipped turn is invisible to the ledger, not to the world. The run completed — tools ran, payments moved, emails went out. Only the record is discarded, so the next turn has no idea it happened. Effects you cannot repeat need an idempotency key of their own; the ledger will not remember this turn for you.

Agent Development Kit · Prove & ship

Voice agents: the same agent, on the phone

The concierge that answered a request answers a call. Same tools, same session, same ledger — what changes is the handler and the model. A voice agent needs a LiveKit deployment and a realtime model, so nothing on this page runs in the browser: every block is code to take away, checked against the package's own types.

Audience · engineers putting an agent on a call Needs · a LiveKit deployment and a realtime model key Package · @animahealth/adk (MIT)

Step 1

What actually changes

app.handler carries turn, rest, agui and voice. The first three are the request shape of Serving it: a config in, a function you call per request out. voice is the odd one. There is no request. A call arrives at a LiveKit room, and what the handler hands back is a worker.

handler.rest handler.voice
Returns (input) => Promise<RestResponse> VoiceHandlerHandle{ entry, prewarm?, start }
Unit of work one turn one call, start to hangup
Session id the request supplies it setup(participant) derives it, or the room's name
Model any provider model a realtime config — openai or gemini only
Commits after every turn once, when the call is over
Tools, hooks, state, ledger the same objects, unchanged

Here is a whole voice program. One tool, one agent, one handler — and the last two lines, which are the part that is not like anything else in the package.

import { fileURLToPath } from 'node:url'

import { z } from 'zod'

import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'

const app = adk({ name: 'bookings' })

const lookupStay = app.tool({
  name: 'lookup_stay',
  description: 'Look up a booking by its reference',
  schema: z.object({ ref: z.string() }),
  execute: (ctx) => ({ ref: ctx.args.ref, nights: 3 }),
})

const concierge = app.agent({
  name: 'concierge',
  model: openai.realtime('gpt-realtime-1.5', { voice: 'ballad' }),
  context: [app.context.system('Answer booking questions.'), app.context.history()],
  tools: [lookupStay],
  timeouts: { inactivity: 15_000, expiry: 300_000 },
})

const handler = app.handler.voice({ agent: concierge })

export default handler
handler.start(fileURLToPath(import.meta.url))

That file is both the worker and the thing that launches it. start(entryFile) boots a LiveKit agent server and tells it which module to load per job. LiveKit spawns a subprocess per call and reads that module's default export, which must carry an entry function and either a prewarm function or none. The handle is exactly that shape, so export default handler is the whole wiring. Inside a spawned subprocess start returns immediately instead of booting a second server. That is why one file can be both.

Run it the way LiveKit runs a worker — the subcommand is read from process.argv:

npx tsx call.ts dev      # reload on change, for local iteration
npx tsx call.ts start    # production
npx tsx call.ts connect  # join one named room and exit

Your worker will not pick up calls by itself. start always registers an agent name — config.name, else the agent's name, else adk-voice — and LiveKit treats a named worker as explicit dispatch: rooms are not routed to it automatically. Name the agent in the caller's access token, or create a dispatch with LiveKit's AgentDispatch API. A worker that looks healthy and never rings is almost always this.

Two constructor-time checks are worth knowing before you deploy. app.handler.voice requires @livekit/agents to be installed and throws when it is not — the core package never imports it, and the import happens the moment you call voice(). And the runnable must be an agent carrying a realtime model config; a sequence, a step, or an agent on an ordinary chat model is rejected with a message naming openai.realtime() and gemini.realtime().

Step 2

What has to be running

The ADK does not carry the media stack. LiveKit does: a room server that the caller and your worker both connect to, and — for a real phone number — a SIP trunk in front of it. Self-host it or use LiveKit Cloud; either way the worker finds it through three environment variables, read by the LiveKit CLI at boot.

LIVEKIT_URL=wss://your-project.livekit.cloud
LIVEKIT_API_KEY=…
LIVEKIT_API_SECRET=…
OPENAI_API_KEY=…            # or GOOGLE_API_KEY for a Gemini realtime model

The peers are opt-in, and each one buys a specific feature. Install the first two (or the Google plugin instead) and you have a working call; the rest are for what you add later.

Package Needed for
@livekit/agents everything — the worker, rooms, the agent session
@livekit/agents-plugin-openai an openai realtime model
@livekit/agents-plugin-google a gemini realtime model
@livekit/noise-cancellation-node sound.noiseCancellation
livekit-server-sdk egress recording, and hanging up the call at the end
@livekit/rtc-node local track recording (recording.dir)
npm install @livekit/agents @livekit/agents-plugin-openai

The last two rows arrive on their own: @livekit/agents depends on livekit-server-sdk and declares @livekit/rtc-node as its own peer. They are listed because the ADK loads them lazily, by name. A broken install therefore shows up as a missing feature, not a missing module: egress recording that logs and returns, local capture that warns and skips.

Step 3

The realtime model

A realtime config is a wrapper, not a model. RealtimeModelConfig holds realtime: true and the inner provider model. Around it go the audio-side settings the inner model has no vocabulary for: which voice, how turns are detected, how speech is transcribed, how input noise is handled. Three shapes come out of that wrapper. Which one you get is decided entirely by whether you pass stt and tts.

import { gemini } from '@animahealth/adk/gemini'
import { openai } from '@animahealth/adk/openai'
import { realtime } from '@animahealth/adk/voice'

// Speech in, speech out, one model.
const speechToSpeech = openai.realtime('gpt-realtime-1.5', {
  voice: 'ballad',
  turnDetection: { type: 'semantic', silenceDurationMs: 500 },
  inputTranscription: { model: 'gpt-4o-mini-transcribe' },
  noiseReduction: { type: 'near_field' },
})

const geminiLive = gemini.realtime('gemini-live-2.5-flash-native-audio', { voice: 'Puck' })

// Half-cascade: the realtime model reasons in text, your TTS speaks.
const halfCascade = openai.realtime('gpt-realtime-1.5', { tts: elevenLabsTTS })

// Full pipeline: an ordinary chat model between an STT and a TTS.
const pipeline = realtime({ model: openai('gpt-5.6-luna'), stt: deepgramSTT, tts: elevenLabsTTS })
You pass You get Where the audio settings go
neither the provider's realtime model, speech to speech onto the realtime model
tts only the realtime model restricted to text output, plus your TTS onto the realtime model
both a plain LLM, plus your STT and TTS ignored — the pipeline owns turn-taking
stt only an error, at construction

stt and tts are LiveKit plugin instances, typed unknown by the ADK so the core carries no dependency on them. Anything else the provider accepts goes through providerOptions, which is merged last and therefore wins over everything the ADK computed.

Voice mode speaks to two providers. A realtime config on any other provider is rejected when the call starts, with the provider named. Gemini's Vertex settings ride on the inner model — gemini.realtime(name, { vertex: { project, location } }) — and are translated into the plugin's own Vertex options.

A realtime agent still runs in text. The runner recognises a realtime config and loads a text adapter for it, so app.run, handler.rest and the test kit work on the same agent object — which is how you keep the tool logic of Testing agents without a model under test without a phone. The exception is the full pipeline: with both stt and tts set, the runner treats the inner model as an ordinary chat model.

One environment note. Voice resolves its OpenAI connection separately from text mode and understands one override: OPENAI_EU_API_KEY routes the realtime connection at the EU endpoint. Otherwise it passes nothing and the LiveKit plugin reads OPENAI_API_KEY itself. Azure endpoints configured for text mode are not carried into a call.

Step 4

Which session the call joins

A request carries its own sessionId. A call carries a room and a participant, so the handler asks you. setup runs once, after the worker has connected to the room and the caller has joined, and its return value decides which session row this call appends to. Omit it and the room's name is the session id.

const app = adk({
  name: 'bookings',
  schema: {
    session: { caller: z.string().default('unknown'), channel: z.string().default('voice') },
  },
})

const handler = app.handler.voice({
  agent: concierge,
  name: 'concierge-voice',
  setup: (participant) => ({
    sessionId: participant.attributes?.['booking_ref'] ?? `call-${Date.now()}`,
    state: { caller: participant.identity ?? 'unknown', channel: 'voice' },
  }),
  sound: { noiseCancellation: 'telephony' },
  timeouts: { inactivity: 15_000, expiry: 600_000 },
})

participant is deliberately thin — identity and attributes, the two fields a SIP trunk or a token can populate. That is enough to route a caller back to the thread they were on last week. Return that thread's id and the agent picks up a history nobody had to hand it — the ledger was already there.

The rest of the config is the deployment's business, once, at boot:

interface VoiceHandlerConfig {
  agent: Runnable                       // must be an agent on a realtime model
  schema?: StateSchema
  setup?: (participant: VoiceParticipant) => SessionSetup | Promise<SessionSetup>
  sound?: SoundConfig                   // noise cancellation, thinking sound
  recording?: RecordingConfig           // { dir?, egress? }
  callTermination?: false | CallTerminationConfig
  hooks?: VoiceHook[]
  errorHandlers?: ErrorHandler[]
  timeouts?: AgentTimeouts              // { inactivity?, expiry? }
  name?: string                         // the name registered with LiveKit
  worker?: Record<string, unknown>      // extra LiveKit ServerOptions
  prewarm?: (proc: unknown) => void | Promise<void>
}

interface SessionSetup {
  sessionId: string
  scopes?: Partial<Record<SharedScope, string>>
  state?: Record<string, unknown>
  initialState?: StateChanges
  recordingKey?: string                 // overrides the egress object key
  noiseCancellation?: NoiseCancellationType
}

Four of those are worth a sentence each. timeouts.inactivity is measured from the last thing that happened, not the last thing the caller said, and it re-arms after every silence. An agent's own timeouts override the handler's, so a transfer can change the clock. sound.noiseCancellation is 'telephony' or 'general', set per handler or per session. recording has two independent halves. dir writes raw tracks locally and mixes them into a WAV on hangup, which is the development one; egress starts a LiveKit room-composite egress straight to S3, which is the production one. And callTermination is on by default: when the call completes the ADK deletes the room, or removes the participant under strategy: 'removeParticipant'. Set it to false when something else owns the hangup.

prewarm runs once per worker subprocess before any call reaches it — the place for tracing setup or loading something heavy, so the first caller does not wait for it.

Step 5

Tools during a call

A tool is the same object in both modes. The voice bridge converts each one into a LiveKit tool carrying the same JSON schema. The executor around it then does what the text runner does: parse the args, run prepare, call execute, run finalize, write a tool_call and a tool_result. Errors go to your error handlers with the same retry, fallback and throw vocabulary. Arguments that fail the schema come back to the model as text rather than throwing, so a bad call is a turn the model can correct.

// Same tool object, either handler. `ctx.voice` is the only voice-only field, and it is
// `undefined` in text mode.
const lookupStay = app.tool({
  name: 'lookup_stay',
  description: 'Look up a booking by its reference',
  schema: z.object({ ref: z.string() }),
  execute: async (ctx) => {
    await ctx.voice?.say('Let me pull that up.')
    return { ref: ctx.args.ref, nights: 3 }
  },
})

// Return a Runnable and the call transfers: LiveKit swaps the agent in place, mid-call.
const toBilling = app.tool({
  name: 'transfer_to_billing',
  description: 'Hand the caller to the billing agent',
  schema: z.object({}),
  execute: () => billing,
})

// The output tool. Calling it ends the call and becomes the run's output.
const endCall = app.tool({
  name: 'end_call',
  description: 'End the call once the caller is done',
  schema: z.object({ summary: z.string(), resolved: z.boolean() }),
  execute: (ctx) => ctx.output({ summary: ctx.args.summary, resolved: ctx.args.resolved }),
})

const concierge = app.agent({
  name: 'concierge',
  model: openai.realtime('gpt-realtime-1.5'),
  context: [app.context.system('Answer booking questions.'), app.context.history()],
  tools: [lookupStay, toBilling],
  output: endCall,
})

ctx.voice is the seam to the audio. It is a small interface on purpose — no LiveKit types cross it:

Member Does
generateReply(options?) trigger a model turn, optionally with instructions, userInput or a toolChoice; interrupts current speech
say(text, options?) speak text through TTS and add it to the conversation, or play audio you already have
playSound(source, options?) play on a separate background track, outside the conversation; returns undefined when no background player could be started
interrupt() stop the current speech; the model keeps listening
turnCount how many times the caller has spoken

Both generateReply and say return a VoiceReply, and its one method is the one that matters on a phone call: await reply.waitForPlayout() resolves when the caller has actually heard it. Without it your next line runs while the sentence is still in the air.

Two control signals end a tool differently from a return value. ctx.output(value) makes the value the call's output and starts the hangup — that is what an output tool is, and it is what output: endCall declares. ctx.end() asks to end the call without supplying the value, and requires an output tool to exist, because the handler then runs one more forced generation to collect it. Returning a Runnable transfers instead. The outgoing agent's invocation closes with reason transferred and the incoming agent gets its own invocation_start. Its tools and instructions are rebuilt, and LiveKit swaps the agent inside the live call. The caller hears no seam.

Say something before you go quiet. A tool that takes three seconds is three seconds of silence to a caller — there is no spinner. ctx.voice.say() before the slow part, or sound.backgroundAudio.thinking for a sound the handler starts and stops for you, are the two ways out. ctx.waitForPlayout, when the runtime supplies it, lets a tool hold until the model has finished its current sentence.

One capability of text mode does not survive the crossing: a tool cannot yield. There is nobody to pause for — the caller is on the line — so Stopping to ask has no voice equivalent. Ask the caller instead.

Step 6

What the ledger records

A call leaves the same kind of event list a text run does, and you read it the same way. Nothing about voice is a special table.

const call = await app.sessions.get('call-4471')

const ledger = call?.events.map((event) => event.type)

// Voice writes the same `user` and `assistant` events a typed turn does, tagged with where the
// text came from.
const transcript = call?.events
  .filter((event) => event.type === 'user' || event.type === 'assistant')
  .map((event) => ({ who: event.type, said: event.text, from: event.source ?? 'text' }))

// Audio tokens ride on the same `model_end` usage a text turn reports.
const audio = call?.events
  .filter((event) => event.type === 'model_end')
  .map((event) => event.usage?.audioInputTokens)

// How the call ended: 'completed' | 'transferred' | 'inactivity_timeout' | 'max_duration' |
// 'disconnected' | 'participant_left'.
const ending = call?.events.findLast((event) => event.type === 'invocation_end')?.reason

The differences are small and worth knowing precisely:

Event On a call
user, assistant carry source: 'transcript' — the text is what the model heard or said, not what anyone typed
model_start written when the agent starts thinking; its tools list is real, its messageCount is always 0 (the realtime session owns the context)
model_end written from the provider's realtime metrics, so usage also carries audioInputTokens, audioOutputTokens and audioCachedTokens; finishReason is always 'stop'
tool_call, tool_result identical to text mode
invocation_start, invocation_end one pair per agent, not per turn — a call with two transfers holds three pairs
deltas, thought, invocation_yield never appear

There is no per-turn commit. Events accumulate on the session in memory as the call runs, and the handler writes them once, at the end — after the final hooks, inside LiveKit's shutdown barrier so a hangup cannot outrun it. That is the right trade for a call: a turn is a fuzzy unit here, and the caller is present throughout. It has one consequence.

A worker that dies mid-call loses the call's ledger. Nothing was written yet. The commit is also best-effort — a store that refuses it is swallowed, not raised — so treat the durable record of a call as arriving at the end or not at all. Effects you need regardless (a booking made, a message sent) belong in the tool that made them, not in a later reading of the ledger.

Step 7

Hooks along the call

A VoiceHook is an ordinary HookbeforeAgent, beforeTool, afterTool, onEvent, all of Guardrails and recovery — plus five callbacks that only a call has. Build one with app.hook.voice and put it in the same hooks array.

const callLifecycle = app.hook.voice({
  // Replaces the default auto-speak on entry and after every transfer.
  onEnter: async (ctx) => {
    const reply = await ctx.voice.generateReply({ instructions: 'Greet the caller by name.' })
    await reply.waitForPlayout()
  },

  // Every transcript line, on its own queue — never blocks the audio pipeline.
  onTranscript: (ctx) => {
    if (ctx.event.type === 'user') ctx.state.update({ lastHeard: ctx.event.text })
  },

  // Return false to keep the call alive, true to end it, nothing for the default end.
  onInactivity: async (ctx) => {
    if (ctx.inactivityCount >= 2) return
    const reply = await ctx.voice.generateReply({ instructions: 'Ask if they are still there.' })
    await reply.waitForPlayout()
    return false
  },

  onExpiry: async (ctx) => {
    const reply = await ctx.voice.generateReply({ instructions: 'Apologise, then wrap up.' })
    await reply.waitForPlayout()
  },

  onDisconnect: () => undefined,

  // Ephemeral telemetry. Never written to the ledger.
  onVoiceEvent: (event) => {
    if (event.type === 'forced_tool_correction' || event.type === 'voice_error') {
      console.warn('[voice]', event)
    }
  },
})

onEnter fires when an agent becomes active — at the start of the call and again after every transfer. Defining it replaces the default greeting, so the first thing the caller hears is yours. onInactivity, onExpiry and onDisconnect share one contract: return false to keep the call alive, true to end it, and nothing at all for the default. When several hooks define the same callback they all run, and any one of them returning false wins — a veto, not a vote. ctx.inactivityCount counts consecutive silences and resets the moment the caller speaks, which is what turns three prompts into an escalation instead of a loop.

onTranscript runs on its own queue, one line at a time, so a slow hook delays the lines behind it and never the audio. Its context is richer than the others'. Alongside session, state, voice and the event, it carries run(agent, input?) — so a background agent can read the conversation as it happens. A live summary, a check on what was promised, a state update the main agent never has to be told about. State written here is drained before the commit.

onVoiceEvent is the telemetry channel: state transitions, activity markers, lifecycle-hook outcomes, forced-tool corrections, errors. None of it is persisted, which is the point — it is how the call is behaving, not what happened on it. app.hook.voiceLogging({ level: 'debug' }) is a ready-made hook that formats these as structured log lines.

Step 8

Forcing a tool before speech

Sometimes one specific tool must run before the model is allowed to answer: check the policy before quoting it, record the outcome before hanging up. In text mode that is toolChoice: { name } and the provider honours it. Realtime sessions do not reliably honour a named tool choice. The obvious workaround — hide every other tool for one turn — mutates the tool list, which invalidates the provider's prompt cache. Every turn after that costs more.

So the ADK layers its own guarantee on top of the one thing providers do honour, toolChoice: 'required'. Pass a named tool choice to ctx.voice.generateReply and a gate opens for that generation:

import { ForcedToolCallError } from '@animahealth/adk/voice'

const beforeQuoting = app.hook.voice({
  onTranscript: async (ctx) => {
    if (ctx.event.type !== 'user') return
    if (!/\bcancel\b/i.test(ctx.event.text)) return

    try {
      // The gate: this generation may not end in speech until `check_policy` has run.
      await ctx.voice.generateReply({
        toolChoice: { name: 'check_policy' },
        instructions: 'Check the cancellation policy before answering.',
      })
    } catch (error) {
      if (!(error instanceof ForcedToolCallError)) throw error

      // reason: 'active_gate' | 'exhausted' | 'timeout' | 'generation_failed'
      console.warn(
        `[voice] ${error.intendedToolName} not called (${error.reason})`,
        error.incorrectToolName,
        `${error.attempts}/${error.maxAttempts}`,
      )
      await ctx.voice.say('Let me put you through to someone who can check that.')
    }
  },
})

What the gate does, in order. The named choice is downgraded to 'required' on the way to the provider — the tool list is untouched, so the cache survives. If the intended tool is called, it executes normally and the gate resolves when it finishes. If a different tool is called, that tool never executes: the call is intercepted before beforeTool and before execute, so nothing with a side effect has happened yet. The model gets a synthetic result, and a correction turn goes back naming both the tool it wrongly called and the tool it must call. If the model speaks instead of calling anything, that counts as a wrong answer too; so does calling nothing at all, after a short wait. Each of those consumes an attempt.

When the attempts run out, or the gate times out, or the generation itself fails, the gate clears and rejects. The ForcedToolCallError carries intendedToolName, incorrectToolName, attempts, maxAttempts, source and reason. The same failure is also announced on onVoiceEvent as forced_tool_failure, with forced_tool_correction for each retry along the way.

Three properties are deliberate and worth relying on. The gate is ephemeral — it lives in the voice session for one generation and is never written to session or application state, so nothing to clean up when a call drops. It is exclusive — opening a second gate while one is pending throws immediately with reason: 'active_gate' rather than interleaving two forced turns. And the correction instructions are generic: the ADK will tell the model which tool it must call and will not invent a domain-specific recovery. What to say to the caller when the tool never lands is yours, in the catch.

The same machinery runs on your behalf at the end of a call. Something asks the call to finish: ctx.end(), an inactivity or expiry hook, a caller who hung up. If the agent declares an output tool, the handler triggers one last generation forced onto it, so the structured result is collected before the room closes.

Step 9

Measuring it

A call is hard to assert on: the transcript is nondeterministic and the timing is half the product. The package's answer is app.evaluate.voice, a sibling of the runner in Measuring agents. It puts a second agent on the other end of a real LiveKit room, plays the conversation out, and scores the ledger and the timings it produced. .case, .cases and .report are shaped like their text counterparts, and a control handle can disconnect the simulated caller mid-call. It needs the same LiveKit credentials this chapter's worker does, and it is a chapter's worth of surface on its own. Start from app.evaluate.voice and the @animahealth/adk/eval exports.

Agent Development Kit · Prove & ship

CLI: the terminal UI

app.cli(runnable) renders your agent into a full-screen terminal app: the transcript, every event as it streams, the exact context each model call saw, and a form for answering a tool that stopped to ask. It is a call your program makes, not a binary you install — the agent you drive in it is the agent you ship.

Needs · a real terminal, plus three optional peers No cells here · a TTY is not something a page can offer

Step 1

A call, not a command

The UI is built with ink and React. Both, plus ink-text-input, are optional peer dependencies: the core never pulls them, so install them beside the ADK when you want the terminal.

npm install ink ink-text-input react

The declared ranges are ink 5, ink-text-input 6, and React 18 or 19. Miss one and there is no friendly message — the module is loaded lazily at the moment you call app.cli, so Node's own resolver reports it, one package at a time:

Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'ink' imported from
  node_modules/@animahealth/adk/dist/cli/index.mjs

Then the program. app.cli takes the runnable and, optionally, a first message — it is sent the moment the UI mounts.

// chat.ts
import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'

const app = adk({ name: 'chat' })

const assistant = app.agent({
  name: 'assistant',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('Be brief.'), app.context.history()],
})

app.cli(assistant, 'What is 731 * 268, minus 17?')
npx tsx chat.ts

The third overload takes a config object instead of the message. Everything the UI does is configured there:

const result = await app.cli(assistant, {
  input: 'What is 731 * 268, minus 17?',
  options: { exitOnComplete: true, showIds: true },
})

process.stdout.write(result.output.text + '\n')
Config field Default What it does
input First message, sent as the UI mounts. Omit it and the CLI opens on a prompt.
session a fresh in-memory session The session the run appends to. See step 4.
sessionService the app's own Only used to build the default runner.
runner built from the app Run with your own Runner, carrying its own adapters and hooks.
options Display and lifecycle, below.
Option Default What it does
defaultMode 'debug' Which of the three views opens first: content, debug, or logging.
exitOnComplete false Leave the UI once the run finishes, instead of staying to be read.
showDurations true Closes each block with [1.9s]. Turn it off and the block closes with its state, [completed].
showIds false Prints the invocation id beside the agent's name: ┌─ assistant (inv_fa0d1df5…).
logBufferSize 1000 How many captured log lines the logs view keeps.
hooks Extra hooks for this run, after the app's own. Ignored if you pass your own runner.

The call returns a handle, not a promise: await it for the RunResult, or read runner, session and runnable off it. The first two are populated once the UI has loaded, so read them after the await — synchronously they are still undefined.

Two things the terminal takes over. It needs a real TTY: pipe the program's output and ink refuses with Raw mode is not supported on the current process.stdin. And from the moment app.cli is called, console.log and its siblings are captured into the logs view rather than printed — for the rest of the process, including after the UI exits. Write to process.stdout directly when you want something on the terminal afterwards.

Step 2

What the screen shows

One invocation is one bracketed block: the agent's name on top, its events indented inside, the duration on the bottom. Sub-agents nest as inner blocks. Each model call gets its own model bracket, so a tool-using turn shows two of them.

● debug [d] ○ content [c] ○ logs [l]
 ┌─ booker ◆ agent
  ▸├─ user     book tuesday 3pm
   ┌─ model
     ├─ call     book_slot {"slot":"tuesday 3pm"}
     ├─ yield    book_slot {"slot":"tuesday 3pm"}
     ├─ input    book_slot {"approved":true}
     ├─ result   book_slot → {"booked":true,"slot":"tuesday 3pm"}
   └─ 2ms
   ├─ yield    awaiting 1 call
   ├─ resume
   ┌─ model
     ├─ output   Booked tuesday 3pm.
   └─ 1ms
 └─ 1.9s
scroll [↑↓] • jump [←→] • open [Enter] • close [Esc] • exit [Ctrl+C]

Every line is one event from the session's ledger, under a short label: user, think for reasoning, output for the assistant, then call, yield, input, result and state. Assistant and reasoning text stream in as deltas, with a spinner on the line still being written. marks the selection, and the footer always names the keys that apply right now.

Three views share that screen, each one key away. debug is everything above. content keeps only the conversation — user, assistant, reasoning, tool calls and the answers you typed. logs is the console output the UI captured, with a timestamp and level per line, so a chatty tool cannot smear the trace.

○ debug [d] ○ content [c] ● logs [l]
  18:08:42.438 LOG   a log line from the tool
▸ 18:08:42.439 WARN  and a warning
scroll [↑↓] • page [←→] • open [Enter] • exit [Ctrl+C]
Key Browsing the trace In the logs view
Move the selection — or scroll the open detail pane. Select a log line.
Jump to the start / end of the current block. Page through the lines.
PageUp PageDown Page the trace, or the open detail pane.
Enter Space Open the selected event's detail. On a model bracket, expand the context that call was given. Open the selected line.
r c Inside the detail pane: the raw event, or the readable rendering.
Esc Close the detail, collapse an expanded context, or leave the prompt to browse. Close the open line.
d c l Switch view: debug, content, logs.
i Answer a yielded tool — or take the prompt back after Esc.
Ctrl+C Leave.

One exception governs the whole table: while the prompt has focus, every key is text. Esc hands focus back to the trace, and i returns it to the prompt.

Step 3

Answering a yielded tool

This is the reason to reach for the terminal over console.log. A tool declared yields: true stops the run and waits for a human. The CLI is that human: the top bar turns up input [i] in yellow, and i opens a form built from the tool's yieldSchema.

Event • yield • ○ clean [c] ○ raw [r] ● input [i]
book_slot yielded
args: {
  "slot": "tuesday 3pm"
}

{
 "approved": ● true ○ false,
 "note": "" ?
}
[↑↓] field • [←→] value • submit [Enter] • cancel [Esc] • exit [Ctrl+C]

↑↓ moves between fields, ←→ toggles a boolean or cycles an enum, strings and numbers are typed, and Enter submits. The answer is delivered to the tool's execute as ctx.input, the run resumes in place, and the ledger keeps yield, input and result as three separate events — the same three the yielding chapter resumes over HTTP.

The form is generated only for a schema the terminal can lay out: at most five fields, each a string, number, boolean, enum or literal — or an array of objects whose fields are those. Anything deeper falls back to one line — Enter result (JSON or string) — parsed as JSON, or taken verbatim if it is not JSON. Optional fields left empty are dropped; required ones fall back to the schema's default.

Step 4

One launch, one conversation

The prompt appears before the first run and after a yielded message — not after a completed one. So a launch is one invocation, plus however many pauses it takes to finish it; when the run completes the UI stays open for reading, and Ctrl+C ends it. Pass exitOnComplete: true to skip the reading.

The CLI never commits. Like a bare app.run, it accrues events on the session in memory and touches no store. Give it a session you loaded and commit that session yourself, and the conversation outlives the process.

import { adk } from '@animahealth/adk'
import { sqliteStore } from '@animahealth/adk/stores/sqlite'

const app = adk({ name: 'chat', store: sqliteStore('./chat.db') })

const id = 'kitchen-table'
const session = (await app.sessions.get(id)) ?? (await app.sessions.create({ sessionId: id }))

await app.cli(assistant, { session, input: 'where were we?', options: { exitOnComplete: true } })

await app.sessions.commit(session)
await app.close()

Run that twice and the second launch opens on the first launch's history, because app.context.history() reads the session it was handed. Without the commit, nothing is written and every launch starts empty.

Agent Development Kit · Prove & ship

Streaming and cost

A run has two surfaces. The stream is everything that happens, delivered the moment it happens, half-finished sentences included. The ledger is what the run decided to keep. They are different types, they carry different events, and the difference is the whole chapter: you render from the stream, you bill and audit from the ledger.

Runs here · five scripted cells, no key Live cell · your own OpenAI key Assumes · the quickstart's ledger section

Step 1

Two surfaces, one run

Event is a ledger entry. StreamEvent is everything an Event can be, plus two kinds that only ever exist in flight: assistant_delta and thought_delta. Each carries the delta just produced and the text accumulated so far, so a renderer can append or replace, whichever it prefers.

Deltas are never appended to the session. The runner forwards them from the adapter straight to the observer and drops them; the committed assistant event is written once, after the model call finishes. That is why replaying a session gives you the answer and not the typing.

Observation is a hook. onEvent receives every StreamEvent of a run, in order, and returns nothing — guardrails covers the rest of the interface, including the methods that can interrupt. Below, a registered MockAdapter (testing) stands in for the provider the descriptor names (choosing a model), with streamChunks making it emit the reply in pieces.

import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { MockAdapter } from '@animahealth/adk/testing'

// Every cell below re-scripts this adapter, so cells stay deterministic however often you press Run.
const scripted = new MockAdapter({ responses: [] })

const app = adk({ name: 'streaming', adapters: { openai: scripted } })

const narrator = app.agent({
  name: 'narrator',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('Answer in one sentence.'), app.context.history()],
})

narrator.name

Now watch one run twice: once through onEvent as it happens, once through run.session.events after it is over.

const streamed: string[] = []
const pieces: string[] = []

// `delayMs` buys the run enough wall-clock to be worth measuring in step 2.
scripted.setResponses([
  { text: 'Chunked on the way out.', streamChunks: true, chunkSize: 6, delayMs: 40 },
])

const run = await app.run(narrator, {
  input: 'Say something.',
  hooks: [
    {
      name: 'watch',
      onEvent: (event) => {
        streamed.push(event.type)
        if (event.type === 'assistant_delta') pieces.push(event.delta)
      },
    },
  ],
})

const surfaces = {
  streamed,
  committed: run.session.events.map((event) => event.type),
  pieces,
  reassembled: pieces.join(''),
  finalText: run.output.text,
}

surfaces

Three things to read off that. The deltas are in streamed and absent from committed. They arrive before model_end, while the committed assistant event lands after it — the ledger writes the answer once the call is accounted for. And committed opens with a user event that streamed never saw: the message was appended to the session before the run began, so it is history, not this run's stream.

Reassembly is pieces.join('') here, but you rarely need it. Each delta already carries text, the accumulation so far, so a renderer can set its buffer to the latest text and never track state of its own.

Step 2

What a model call cost

One model_start/model_end pair is one round-trip to the provider. model_end is where the money is: stepIndex, durationMs, finishReason, and an optional usage with inputTokens, outputTokens, cachedTokens, cacheWriteTokens and reasoningTokens.

The optionality is the important part, and this cell shows it honestly. durationMs is the runner's own stopwatch around the call, so it is always a number. usage and finishReason come off the provider's response, so a scripted model reports neither — and run.usage, which is rolled up from the model_end events that have usage, is undefined for the whole run. Deterministic tests can assert on latency and call counts; they cannot assert on cost. For cost you need step 5.

import { isModelEndEvent } from '@animahealth/adk'

const perCall = run.session.events.filter(isModelEndEvent).map((event) => ({
  stepIndex: event.stepIndex,
  durationMs: event.durationMs,
  finishReason: event.finishReason,
  usage: event.usage,
}))

const accounting = { runUsage: run.usage, perCall }

accounting

When usage is present, run.usage is the roll-up: modelCalls, the token totals, a models array broken down by model name, and an optional cost of { inputCost, outputCost, totalCost, currency: 'USD' }. The model name is the one you wrote in the descriptor, not whatever the endpoint resolved it to.

No provider sends you a price. The ADK derives one from the token counts and a pricing table internal to the package, so a model the table does not know has no cost field and its token counts stand alone — and a price you negotiated yourself is your own multiplication over those totals.

For the streaming case there is a built-in that does the bookkeeping: app.hook.metrics({ onModelResult }) fires per model_end with the agent name, model name, duration and token counts — one callback into whatever your metrics backend is, with no event filtering of your own.

Step 3

Usage is per session, not per run

This one costs people money, so read it before you build a budget on it. run.usage and run.stepEvents are both computed from the session's entire event list, not from the slice this run appended. Give two runs the same session and the second result reports the first one's model calls as well as its own.

const shared = await app.sessions.create()

scripted.setResponses([{ text: 'First answer.' }, { text: 'Second answer.' }])

const firstRun = await app.run(narrator, { session: shared, input: 'One?' })
const secondRun = await app.run(narrator, { session: shared, input: 'Two?' })

const scope = {
  firstRunStepEvents: firstRun.stepEvents.length,
  secondRunStepEvents: secondRun.stepEvents.length,
  sessionEvents: shared.events.length,
  modelEndsSeenBySecondRun: secondRun.stepEvents.filter(isModelEndEvent).length,
}

scope

secondRunStepEvents equals sessionEvents, and the second run counts two model calls after making one. A fresh session per run — which is what app.run creates when you pass a bare string instead of a session — makes the two readings identical, which is why the trap stays hidden until you keep a conversation going. To attribute cost to one turn of a long conversation, diff the session's event list around the run, or accumulate from onEvent: a model_end observed during the run is unambiguously the run's own.

Step 4

Stopping a run

app.run does not take an AbortSignal. It returns a handle — a StreamResult, which is at once awaitable and iterable — and that handle has abort(). Calling it aborts the controller the runner made for this run: the signal is already threaded into the model adapter, so an in-flight provider request is cancelled rather than merely ignored, and the event channel is torn down. Awaiting an aborted run does not return a result. It rejects, with Aborted.

An AbortSignal you already own connects with one line — signal.addEventListener('abort', () => stream.abort()) — which is exactly what app.ask does with its own signal option, the one place in the API where a signal goes in directly. The cell proves both paths, and both time out well inside the three seconds the scripted model was told to take.

const observed: string[] = []

scripted.setResponses([{ text: 'A reply that never arrives.', delayMs: 3000 }])

const inflight = app.run(narrator, {
  input: 'Take your time.',
  hooks: [{ name: 'watch-abort', onEvent: (event) => observed.push(event.type) }],
})

setTimeout(() => inflight.abort(), 50)

let stopped = 'the run finished first'
try {
  await inflight
} catch (error) {
  stopped = (error as Error).message
}

// The same stop, reached through a signal you own.
scripted.setResponses([{ text: 'Also never arrives.', delayMs: 3000 }])

const controller = new AbortController()
setTimeout(() => controller.abort(), 50)

let asked = 'the ask finished first'
try {
  await app.ask('Anything.', { model: openai('gpt-5.6-luna'), signal: controller.signal })
} catch (error) {
  asked = (error as Error).message
}

const stopping = { stopped, asked, observed }

stopping

observed is where the run got to before it was cut off — the events that had already been produced, and nothing after. Abort is not the only stop, and the others are not interchangeable. app.run(…, { timeout }) races the whole stream and rejects with Timeout after Nms; an agent's maxSteps ends the run with status max_steps instead of throwing; a tool's own timeout is a recoverable phase error. Guardrails is where those live.

Step 5

Real tokens, real money

Everything above ran against a script, which is why no number in it was a price. This cell calls OpenAI with the key from the box at the top of the page, on a fresh app so the scripted adapter is out of the way. It times the first delta — the number your users actually feel — and then reads the usage the provider reported.

const liveApp = adk({ name: 'streaming-live' })

const scribe = liveApp.agent({
  name: 'scribe',
  model: openai('gpt-5.6-luna'),
  context: [liveApp.context.system('Answer in two short sentences.'), liveApp.context.history()],
})

const firstDelta: number[] = []
const startedAt = Date.now()

const answered = await liveApp.run(scribe, {
  input: 'What does a hospital ward do at night?',
  hooks: [
    {
      name: 'time-to-first-token',
      onEvent: (event) => {
        if (event.type === 'assistant_delta' && firstDelta.length === 0) {
          firstDelta.push(Date.now() - startedAt)
        }
      },
    },
  ],
})

const measured = {
  msToFirstDelta: firstDelta[0],
  text: answered.output.text,
  usage: answered.usage,
}

measured

msToFirstDelta against durationMs on the model_end event is the streaming argument in two numbers: the reader starts reading at the first, the run ends at the second. The OpenAI adapter always streams, so those deltas arrive whether or not you asked for them — streaming is not a mode you switch on, it is what the transport does, and onEvent is where you decide to care.

Edit the question and run it again to watch the tokens and the cost move. A longer question moves inputTokens; a longer answer moves outputTokens, which on this model is priced six times higher.

Step 6

Getting the stream off the box

A hook is in-process. To reach a browser, the run has to be iterated, and app.handler.turn is the surface for it: it returns a handle that is both an async iterable of stream events and a promise of the committed TurnResult. Driving that handle by hand — and the trap in not doing so — belongs to serving; what is left here is one line down the wire.

// Inside the loop over the turn's stream events.
if (event.type === 'assistant_delta') {
  response.write(`data: ${JSON.stringify({ delta: event.delta })}\n\n`)
}

For a front end that speaks the AG-UI protocol, app.handler.agui is the same run translated: it hands back an AsyncIterable of AG-UI events, mapping deltas to text-message content frames and tool calls to their protocol equivalents, so a compatible client renders the stream without knowing the ADK exists. Both handlers, and the sessions they commit into, belong to serving.

Next: The next hour — get this on your machine.

Agent Development Kit · Reference

Changelog

What changed, release by release — rendered from the package's own CHANGELOG.md, so this page cannot disagree with it.

release

Unreleased

Fixed

  • npm install @animahealth/adk no longer fails with ERESOLVE — the optional peer ranges now co-resolve (react widened to ^18 || ^19, ink pinned to the tested ^5, ink-text-input to ^6), and @anthropic-ai/claude-agent-sdk left the peer list (its published versions require zod@4; the SDK is bundled into @animahealth/adk/agents/coding/claude-code, so nothing needs it installed).
  • Importing the ESM main entry (or /testing) no longer requires openai and @ag-ui/core to be installed — the build now code-splits, so lazy provider imports stay lazy instead of hoisting their SDKs' static imports to the entry's top level, and /testing uses the SDK-free openai model descriptor. A packaging gate (verify-package-exports.cjs) now walks each key-free entry's static import graph so this class cannot ship again.
  • Provider SDKs the core loads lazily (openai, @google/genai, @anthropic-ai/vertex-sdk, ws) are now declared as optional peers, so package managers surface them instead of the first import failing.
  • Concurrent first operations on a lazily-opened store no longer construct two instances — SQLiteStore and lazy vector providers (sqliteVec, qdrant, voyage) memoize the in-flight open, closing the window where a ':memory:' database could silently drop one side's committed writes.
  • sqliteVec filtered search no longer returns empty when every match ranks beyond its overfetch window — the KNN window now widens until topK matches are found or the collection is exhausted.
  • Event dedup is now uniform across session stores: InMemoryStore skips already-stored event ids like the SQL stores, and the SQLite/Postgres stores no longer assign a duplicate idx when a committed batch overlaps stored events (which left ORDER BY idx unspecified).
  • memory(...).close() on a never-used lazy index is a no-op instead of instantiating the provider (and creating its database file) just to close it.
  • PostgresStore.loadScopedState no longer crashes on string-valued state ('dark') — the JSONB driver already decodes values, and the redundant JSON.parse threw on any bare string.
  • DynamoDBStore.list() now lists sessions (it was a stub returning []); Scan-based — see its doc comment before using it on a large table.
  • pgvector reads (scroll/count/distanceMatrix/get) no longer create the collection's table as a side effect — a missing collection reads as empty instead of failing on a dimension-less table whose index cannot build. Mutations on a missing collection are no-ops.
  • Every documented store and vector backend now actually runs its shared compliance suite: DynamoDBStore and PostgresStore run the session-store suite and pgvector the vector-index suite against service containers in CI (the suite fixtures are now hygienic across tests on shared backends). The qdrant index deliberately does not run the vector suite — it is provisioning-based (collections and named-vector sets are fixed at creation via collectionSpec()), where the suite encodes lazy creation.
  • composeHooks, loggingHook, metricsHook, and cliHook are now exported from the Core entry — only their option types were previously reachable, so a consumer could not construct any built-in hook at all.

Changed

  • model() in the test kit accepts a plain string as a text reply — model('hello'), symmetric with user('hi'). Previously a string produced a response with no .text: the adapter emitted nothing and the run "passed" with the reply silently gone.
  • OpenAIAdapter is now exported from @animahealth/adk/openai — the documented new OpenAIAdapter(endpoints) + adk({ adapters: { openai } }) seam for programmatic endpoint injection was previously unreachable (the class was not exported anywhere).
  • OpenAIEndpoint gains dangerouslyAllowBrowser — passed through to the OpenAI/Azure client so a page where the END USER supplies their own key can construct the adapter in a browser. Never set it with a key the user did not type themselves.

Removed

  • Experimental surfaces no longer reach the Core entry — import { DockerExecutor } from '@animahealth/adk' and friends are gone. /executors (all exports), /agents/coding (all exports, including the root-only claudeCode/mockCodingAgent aliases — use createClaudeCodeAgent/createMockCodingAgent from @animahealth/adk/agents/coding), and the knowledge module (provisionClaudeProtocol, renderClaudeMd, renderRule, renderSettings — now internal, no replacement) left the main barrel. A gate (src/index.tier-boundary.test.ts) keeps every fenced module's vocabulary out of the Core entry.
  • The gateway/process-store surface (createGateway, GatewayImpl, inMemoryProcessStore, postgresProcessStore, createInProcessExecutor, and their types), the artifact services (InMemoryArtifactService, postgresArtifactService, inferMimeType, createArtifactsProxy), and channels (InMemoryChannel, EventChannel) are internal — removed from the main barrel with no subpath. They are proposals-stage machinery, not public SDK.
  • SQLite backends — SQLiteStore / sqliteStore() (/stores/sqlite), the sqliteIndex() vector provider, and the better-sqlite3 / sqlite-vec optional peers were dropped during the 0.5.20–0.5.27 line without a changelog entry; documenting here. Both surfaces are restored below (the vector provider returns as sqliteVec, not sqliteIndex).

Added

  • SQLite session store restored — SQLiteStore / sqliteStore(dbPath) return at @animahealth/adk/stores/sqlite over the optional better-sqlite3 peer (>=11): zero-infrastructure durable sessions for local development, CLIs, and single-process deployments, passing the same store compliance suite as the in-memory and Postgres stores. ':memory:' gives an ephemeral store.
  • SQLite vector memory restored as sqliteVec({ path }) — a config for memory({ index }) like qdrant(…) / pgvector(…), over the optional better-sqlite3 + sqlite-vec peers (vec0 virtual tables, cosine metric). Successor to the removed sqliteIndex(); where the old provider's no-variant scroll/count read only the default variant, sqliteVec follows the in-memory reference (each id is one logical point).
  • VectorIndex compliance suite — runVectorIndexTests (src/memory/providers/index-compliance.test.ts) now proves every index provider against one contract; the in-memory reference and sqliteVec both run it.

2026-08-20

0.5.27

Added

  • OpenAI explicit prompt caching — configure OpenAIModel.promptCache and mark the stable prefix with app.context.cacheableUser(...); Responses API cache reads and writes are exposed through ModelUsage and UsageSummary.

2026-06-08

0.5.25

Republish of 0.5.24 with a packaging fix — no API or runtime changes.

Fixed

  • Published manifest — @types/node now publishes as a concrete range (^22.19.19) instead of the raw pnpm catalog: token. 0.5.24 shipped "@types/node": "catalog:", which broke pnpm pack / pnpm install for consumers that vendor the ADK outside the Serenity workspace (e.g. the LiveKit voice agent deploy) with ERR_PNPM_CATALOG_ENTRY_NOT_FOUND_FOR_SPEC.

Internal

  • Publish pipeline — adk-publish.yml now packs with pnpm pack (which resolves catalog: / workspace: specifiers) and uploads the resulting tarball with npm publish, so the published manifest no longer leaks workspace-only specifiers while keeping npm OIDC trusted publishing. Publishing the source directory with npm publish shipped package.json verbatim, which is how the catalog: token reached 0.5.24.

2026-06-04

0.5.24

Workflows: author Claude Code-style .workflow.js files and run them through the ADK, plus a few general additions used to express them.

Added

  • @animahealth/adk/workflowrunWorkflowFile() runs a CC-style workflow file through app.run, binding agent() to a configurable node runner (default app.ask; a CodingAgent over a provisioned workspace for build attractors).
  • app.ask(prompt, opts) — terse, typed one-shot LLM call (no tools, fresh session); options typed as AskOpts.
  • fanout(thunks, { limit }) — capped isolated concurrency; a failed thunk resolves to null.
  • AnnotationEvent + ctx.note() — generic progress events (phase() / log() are sugar over ctx.note()).

Changed

  • Voice handler — end-of-invocation hooks (afterAgent/afterTurn) now run inside LiveKit's shutdown barrier, so completion side effects (e.g. completeCall) run exactly once before the worker exits — even on abnormal teardown (caller disconnect, human transfer, drop). Previously they were skipped if the job was killed before the post-sessionDone path ran.
  • Voice handler — fixed shutdownProcessTimeout unit bug (60ms → 60_000ms / 60s, matching LiveKit's default); the worker was force-killing job processes ~60ms into shutdown, before finalization could complete.
  • Voice handler — beforeAgent returning a string now finalizes through the same shared path as any other call (completion hooks run), replacing a separate early-exit lifecycle.

2026-05-26

0.5.23

Added

  • Voice lifecycle diagnostics — added typed voice activity and lifecycle hook events to production/eval voice handlers, voiceLoggingHook, and voice eval reports.
  • Voice playout tests — covered ctx.voice.generateReply() plus reply.waitForPlayout() from inside tool execution, including LiveKit awaitable speech handles.

Changed

  • LiveKit voice peers — raised @livekit/agents and provider plugin peer floor to ^1.4.4, and bumped @livekit/rtc-node to ^0.13.28, verified with child speech-handle playout waits inside tool execution.

Fixed

  • Voice output tools — model-initiated output-tool completion now stores the structured output internally without returning it to the realtime model, preventing final summaries from being spoken as a trailing assistant response.

2026-05-20

0.5.22

Voice output completion is now a visible, typed lifecycle step. Named voice tool forcing is handled inside the ADK without mutating the realtime tool list, so voice agents can reliably collect final structured output while preserving provider prompt caches.

Added

  • Voice forced-tool gate — ctx.voice.generateReply({ toolChoice: { name } }) now enforces the named tool internally while sending provider-compatible toolChoice: "required".
  • Voice output completion telemetry — added output_tool_completion_started, output_tool_completion_succeeded, output_tool_completion_failed, forced_tool_correction, and forced_tool_failure voice events.
  • Voice diagnostics — eval reports now include forced-tool and output-completion timelines with timestamps relative to case start.
  • Voice errors — exported ForcedToolCallError and OutputToolCompletionError from @animahealth/adk/voice.
  • Durable intent — added packages/adk/intent/voice-forced-tool-gating/spec.md for cache-stable named tool forcing.

Changed

  • Voice ctx.end() — ending tools now return their tool result before ADK forces the configured output tool and then shuts down the voice lifecycle.
  • Voice output tools — output-tool completion timeout/failure is no longer treated as silent success; eval paths surface typed failures and production emits diagnostic voice events.
  • Voice eval cleanup — teardown now uses bounded waits for tracker flush, LiveKit session close, recorder stop/disconnect, room disconnect, and room deletion.

Fixed

  • Voice forced tools — wrong tools are intercepted before tool execution and before app beforeTool hooks, then corrected after the synthetic wrong-tool result is returned to the provider.
  • Voice forced tools — required generations that produce no tool call now retry with a generic correction naming no_tool_call and the intended tool.
  • Voice shutdown — after-turn hooks, session commit, and call termination now run in a stable order after output finalization.

Migration from 0.5.21

Voice output completion failures

Voice evals can now fail when the configured output tool is not actually completed. This is intentional: missing final structured output is now observable instead of being treated as best effort success. Production cleanup still runs after output completion failure.

Named voice tool choices

Applications no longer need app-level generic "wrong tool redirect" state for voice toolChoice: { name }. Keep domain-specific fallback logic in the application, but let the ADK own generic named-tool enforcement.

2026-05-19

0.5.21

Added

  • Voice evals — app.evaluate.voice.case((control) => case) now exposes control.disconnectUser(), letting eval code orchestrate caller disconnects from hooks, tool mocks, or other TypeScript code.

Fixed

  • Voice handlers — participant disconnect, inactivity, expiry, and ctx.end() paths now wait for the output tool to complete before room termination.
  • Voice evals — transcript hooks now run in the voice harness, and participant-left cases can pass/fail on metrics after cleanup instead of always reporting as terminated.

2026-05-17

0.5.20

Fixed

  • Voice handlers — lifecycle hooks (onInactivity, onExpiry, onDisconnect) now run the active agent hooks together with handler hooks, matching voice eval behavior and allowing agent-owned inactivity prompts in production.
  • Voice evals — expiry timeouts now run onExpiry hooks before ending the case, matching production timeout behavior.
  • Artifact sync — file-watch artifact watchers now perform the documented final sweep on stop(), so missed filesystem watch events are still collected.
  • Memory evals — the network-backed Voyage embedding eval now requires ADK_RUN_VOYAGE_EVALS=1 in addition to VOYAGE_API_KEY, keeping default test and publish runs offline.

Internal

  • Package publishing — Serenity packages/adk is now the source for publishing @animahealth/adk, with package metadata pointing at the Serenity monorepo.

2026-05-08

0.5.19

Fixed

  • Voice handlers — forced output-tool replies now wait for speech playout before ending the LiveKit room.

2026-05-08

0.5.18

Fixed

  • Voice generateReply() — preserves the entry-reply scheduling yield after capturing LiveKit's synchronous speech handle so onEnter replies do not race realtime session instruction updates.

2026-05-05

0.5.17

Fixed

  • Voice handlers — ctx.end() from a voice tool now waits for the model-triggered output tool and current playout to finish, then deletes the LiveKit room by default; use callTermination: false to leave hangup to the deployment.
  • Voice generateReply() — LiveKit speech handles are now captured synchronously, named tool choices use LiveKit's { type: 'function', function: { name } } shape, and undefined tool results stay undefined instead of being coerced to an empty string.

2026-03-26

0.5.16

Changed

  • waitForPlayout — added to ToolExecutionContext and MockToolContext; removed broken session-level VoiceSession.waitForPlayout(). Use ctx.waitForPlayout?.() in tools, reply.waitForPlayout() in lifecycle hooks.

Fixed

  • dynamoStore — unused ExpressionAttributeNames on non-create commits caused ValidationException.
  • dynamoStore — scoped-state pk separator changed from _ to # to prevent collisions when scopeId contains underscores. (unused in production currently)

2026-03-26

0.5.15

Fixed

  • dynamoStore — support custom key schemas via partitionKey / sortKey config options (defaults to pk / sk for backwards compat).

2026-03-24

0.5.14

Fixed

  • Coercion parser — use _def.typeName instead of instanceof so coercion works across Zod instances.
  • Voice tool bridge — pass JSON Schema to LiveKit; validate with coercion in the ADK executor.
  • {} on optional primitive fields now coerces to undefined.

2026-03-24

0.5.13

Fixed

  • Tool arg validation — run args through the coercion parser before safeParse, so malformed values are coerced instead of silently failing. Applies to all agent tool calls, yielding tools, and the LiveKit voice bridge.

2026-03-21

0.5.12

Fixed

  • Handler session key — resolveSession used agent.name instead of the app name; app.sessions.get() could never find handler-created sessions. HandlerConfig.appName is now required (app.handler.* injects it automatically).

Removed

  • session.truncateAt() — broke commitSession (cursor divergence). Use session.forkAt() instead.

2026-03-20

0.5.11

Added

  • session.truncateAt(eventIndex) — truncate event history in-place; unlike forkAt, mutates the same session.
  • Memory sample() — large candidate pools now bypass the server-side distance matrix and compute diversity locally from raw vectors.

2026-03-20

0.5.10

Fixed

  • toolInputsSchema() — generated schema field datainput to match ToolInput consumed by applyInput().

Added

  • RestResponse.state — rest handler returns session state when response.state is enabled.

2026-03-17

0.5.9

Added

  • Voice eval reports now include per-model-call cost and token breakdown.

Fixed

  • A single voice eval worker crash no longer aborts the entire suite.
  • ctx.end() from the output tool no longer loops indefinitely.
  • Orphaned eval workers handle EPIPE, closed IPC, and upstream currentGeneration throws gracefully.

2026-03-16

0.5.8

Added

  • toolMocks — output tools (agent.output) are now intercepted the same as regular tools; unmocked output tools throw EvalToolError when toolMocks is provided.

2026-03-16

0.5.7

Fixed

  • Voice eval — requireLiveKit() await import()require() to fix dual-package hazard that silently prevented conversations from starting.
  • Voice eval report — stale speech end timestamps from previous segments no longer produce backwards time ranges.

2026-03-15

0.5.6

Changed

  • openai, gemini, claude, voyage, qdrant — import from @animahealth/adk/openai, /gemini, /claude, /voyage, /qdrant instead of the main entry.

```typescript

// Before

import { adk, openai, voyage, qdrant } from '@animahealth/adk'

// After

import { adk } from '@animahealth/adk'

import { openai } from '@animahealth/adk/openai'

import { voyage } from '@animahealth/adk/voyage'

import { qdrant } from '@animahealth/adk/qdrant'

```

2026-03-14

0.5.5

Changed

  • All optional dependencies (openai, @google/genai, @anthropic-ai/vertex-sdk, pg, @qdrant/js-client-rest, voyageai, ws, etc.) — require() → async import() so bundlers can tree-shake unused providers.
  • sqliteIndex(dbPath) — now returns Promise<VectorIndex>; callers must await.

2026-03-08

0.5.4

Added

  • app.hook.voice() — typed entry point for custom voice hooks; accepts Partial<VoiceHook<S>> and returns VoiceHook<S>. Mirrors app.hook() for standard hooks.
  • VoiceHook.onTranscript — fires for each user or assistant transcript message with full context (session, state, voice, event) and ctx.run() for text-mode sub-agent orchestration. Runs in a dedicated queue that never blocks the voice pipeline; drained before session commit so in-flight state mutations are preserved.
  • VoiceSession.turnCount — number of user speech segments (blocks of continuous user speech). Incremented each time the user starts speaking. Use ctx.voice.turnCount > 0 to determine if the caller has engaged.
  • Output tool auto-trigger — when an agent has an output tool and turnCount > 0, the ADK automatically triggers it via generateReply({ toolChoice: 'required' }) on lifecycle events (disconnect, inactivity, expiry). Hooks no longer need to manually force the output tool.
  • ctx.end() — synchronous control signal (like ctx.output()) that triggers the agent's output tool via the model. Return it from a tool's execute to end the session with model-generated output: return ctx.end(). Voice mode triggers generateReply; text mode sets endInvocation.
  • ctx.run() / ctx.spawn() / ctx.dispatch() in voice mode — voice tools can now run text-mode sub-agents inline. Backed by BaseRunner; the sub-agent runs in the same session while the voice session continues.
  • VoiceHandlerConfig.prewarm — optional per-subprocess init callback, called by LiveKit before any job runs (e.g. Sentry, OpenTelemetry setup)
  • Schema defaults — Zod .default() values declared in stateSchema are now applied to initial state across all entry points (voice setup, REST/AG-UI handlers, app.run(), test runner, voice evals). Setup functions no longer need to manually mirror defaults.
  • SessionSetup.noiseCancellation — per-session noise cancellation profile set in setup(). Overrides handler-level sound.noiseCancellation when present.
  • app.evaluate.report(options?) — factory that returns (result) => string. Configure once, call with any eval result. Replaces app.report(result, options).
  • app.evaluate.voice.report(options?) — voice-specific report factory with narrowed types; renderCase receives VoiceEvalCaseResult with run.transcript, run.timing, run.recording.
  • BaseEvalCaseResult / BaseEvalResult — shared base types for text and voice eval results; EvalCaseResult and VoiceEvalCaseResult both extend BaseEvalCaseResult.
  • ReportOptions<S, R> — now generic over result type R; renderCase, sections, and footer callbacks receive the correct result/case types.
  • MetricResult.data — optional Record<string, unknown> for attaching arbitrary structured data (computed values, intermediate measurements, debug info) to metric results.
  • app.evaluate.case() / .cases() / .metric() — identity helpers for type-safe eval config definition; provides schema inference without runtime overhead.
  • app.evaluate.voice.case() / .cases() — identity helpers for voice eval cases.
  • app.initialState() — identity helper for typed multi-scope initial state config.
  • MockToolContext.voice, .output(), .end(), .run() — expanded mock context surface; tool mocks can now test voice state, output signals, end signals, and sub-agent handoffs.
  • Input.initialState / SessionSetup.initialState — multi-scope state seeding via handler input and voice setup; seeds session, user, patient, practice, org, team scopes in one call.
  • EvalOptions.repeat / VoiceEvalOptions.repeat — run each case N times; results carry repeatIndex and repeatTotal metadata. Reports auto-group repeated cases with pass-rate summaries.
  • BaseEvalCaseResult.repeatIndex / .repeatTotal — present when repeat > 1; structured repeat metadata replaces name-mangled [i/n] suffixes.
  • Voice eval process isolation — when concurrency > 1, each voice eval case auto-forks into its own child process with an independent event loop and native thread pool. Eliminates WebRTC contention at high concurrency.

Changed

  • Generic parameter order — Agent<TOutput, S>Agent<S, TOutput> (and AgentConfig, OutputConfig, RunResult, AgentSpec); Agent<unknown, MySchema> simplifies to Agent<MySchema>
  • ctx.output() in voice mode — output signal is ignored when turnCount === 0 (no user engagement). A voice session with no user turn cannot produce meaningful output; the session ends through the lifecycle event (disconnect, inactivity) instead of completed.
  • SoundConfig.noiseCancellationboolean | unknown'general' | 'telephony'. Resolves to BackgroundVoiceCancellation or TelephonyBackgroundVoiceCancellation from @livekit/noise-cancellation-node internally. Replace true with 'general'.
  • VoiceEvalOptions.room — now optional; defaults to LIVEKIT_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRET env vars.
  • Voice eval default timeout — 480s → 300s (5 min).
  • StateChanges<S> — now generic over state schema for typed multi-scope seeding.
  • MetricRun<S> — now generic over state schema; Metric<MetricRun<S>> provides typed session access.

Removed

  • app.report() — use app.evaluate.report() instead
  • repeatCases() — use EvalOptions.repeat / VoiceEvalOptions.repeat instead
  • parseRepeatName() — repeat metadata is now structured (repeatIndex, repeatTotal) on result objects

Fixed

  • Voice thinking sounds — pre-decoded into memory at session start; eliminates ffmpeg subprocess spawn and stutter on first play
  • Voice handler cleanup — cancel speech wait and stop thinking sound on session end; prevents dangling timers after disconnect.
  • Voice handler lifecycle — state guard before beforeEnd callback prevents double-end race when multiple lifecycle events fire concurrently.
  • Voice handler transcript drain — 10s timeout cap prevents hanging when the transcript queue stalls on disconnect.
  • Voice eval inactivity timer — now resets on agent activity and fires onInactivity hooks, mirroring production handler behavior.

Migration from 0.5.3

Generic parameter order: <TOutput, S><S, TOutput>

```typescript

// Before

Agent<unknown, typeof schema>

Agent<string, typeof schema>

// After

Agent<typeof schema>

Agent<typeof schema, string>

```

app.report()app.evaluate.report()

Report is now a factory — configure options up front, call the returned function with a result. This lets you define reports in a separate file without having the result available.

```typescript

// Before

const md = app.report(result, { title: 'My Eval' })

// After

const report = app.evaluate.report({ title: 'My Eval' })

const md = report(result)

// Voice — renderCase receives VoiceEvalCaseResult with full inference

const voiceReport = app.evaluate.voice.report({

renderCase: (r) => r.run.transcript.map((t) => t.text).join('\n'),

})

```

2026-03-05

0.5.3

Voice evaluation framework — run voice agents through real LiveKit rooms with a simulated user agent, collect transcripts and timing data, and measure outcomes with voice-specific metrics.

Added

  • evaluateVoice(cases, options) — runs VoiceEvalCase[] through LiveKit rooms with real audio; returns VoiceEvalResult with per-case transcripts, timing, recordings, and metrics
  • VoiceEvalCase — defines agent under test, simulated userAgent, optional toolMocks, per-case metrics, retries, and timeout
  • VoiceEvalOptions — suite config: room (LiveKit URL + credentials), output directory for per-case reports and recordings, concurrency, stopOnFirstFailure, onCase progress callback, and suite-level metrics and hooks
  • VoiceRunResult — run output with transcript: TranscriptEntry[], timing: VoiceTiming, recording: { path }, events, session, usage, and durationMs
  • VoiceTiming — timing data: timeToFirstSpeechMs, responseTimes, silenceGaps, interruptions (count, byAgent, byUser), vadResolutionMs
  • voiceTimingMetric(config) — metric factory for voice timing measures: time_to_first_speech, response_latency_p50, response_latency_p95, response_latency_max, silence_gap_max, silence_gap_total, interruption_count
  • createSpeakerTracker() — tracks agent/user speech segments for transcript and timing computation
  • TranscriptEntry{ role, text, startMs?, endMs?, turnIndex }
  • ToolContext.waitForPlayout — opt-in helper for tools that need to wait for pending agent speech to finish
  • VoiceHook.onEnter — fires when an agent becomes active (initial entry and after transfer); replaces default auto-speak when defined
  • Voice handler auto-speak — calls generateReply({ toolChoice: 'none' }) on agent activation when no onEnter hook is defined

Changed

  • Voice handler onEnter — no longer auto-speaks when no hooks are defined; add an explicit onEnter hook to greet the caller
  • VoiceEvalOptions.recordingVoiceEvalOptions.output

Fixed

  • Voice thinking sounds — deferred until agent speech playout completes; tool execution itself is not blocked
  • Voice inactivity timer — resets on actual speech start instead of transcript arrival; no longer fires while agent is thinking or speaking

Removed

  • Agent.greeting, GreetingContext — move greeting content into system prompt; use onEnter for ephemeral instructions

2026-03-02

0.5.2

Internal

  • Fix ESM require() shim — inject createRequire banner in tsup ESM output so lazy require() calls resolve correctly (fixes "Dynamic require of X is not supported")
  • Remove lodash dependency — isEqualnode:util.isDeepStrictEqual

2026-03-01

0.5.1

Realtime voice agents — OpenAI and Gemini realtime adapters, LiveKit voice handler, native agent yield/resume loops with greeting and timeout support, structured session completion via output tools, and app-owned session management.

Added

  • realtime() / openai.realtime() / gemini.realtime() — wraps a provider model for realtime use; text-mode agents use native WebSocket adapters, full audio pipeline (with stt + tts) falls through to the standard adapter
  • @animahealth/adk/voice subpath — LiveKit voice handler; ctx.voice (VoiceSession) available in tools for generateReply, waitForPlayout, interrupt; app.handler.voice(config) on the handler namespace
  • OutputConfig accepts a FunctionTool — pass app.tool() as agent.output for tool-injection output mode; framework injects the tool, validates args, runs execute, captures output, and manages shutdown
  • VoiceHook — extends Hook with onInactivity, onExpiry, onDisconnect lifecycle callbacks; defined inside the unified hooks array on VoiceHandlerConfig. Return false to keep session alive, true to explicitly end. Multiple hooks compose: any false vetoes end
  • VoiceHandlerConfig.timeouts — handler-level { inactivity?, expiry? } defaults; per-agent timeouts override them after transfers
  • VoiceSession.generateReply() accepts full ToolChoice — adds 'required' and { name: string } for forcing specific tool calls in lifecycle hooks
  • Agent.yields — yield for user input after terminal model output instead of completing; defaults to true for realtime models. Agent.maxTurns caps yield/resume cycles (default: 100; produces status: 'max_turns')
  • Agent.greeting — string or (ctx: GreetingContext) => string | Promise<string>; injected as system event on first activation only, not on resume
  • Agent.timeouts{ inactivity?, expiry? } in ms; produces status: 'inactivity_timeout' or status: 'max_duration'
  • ctx.output(value) on ToolContext, ToolExecutionContext, StepContext — ends the invocation early; value becomes RunResult.output.value
  • adk({ store }) — optional SessionStore on the app factory; defaults to in-memory. app.sessions exposes create, get, delete, list, commit, merge pre-bound to the app name
  • inMemoryStore(), dynamoStore(), sqliteStore(), postgresStore() — store factory functions; class constructors deprecated
  • UsageSummary.models — per-model ModelUsageEntry[] with calls, token counts, and per-model cost. ModelUsage gains audioInputTokens, audioOutputTokens, audioCachedTokens for realtime models
  • UserEvent.source, AssistantEvent.source'text' | 'transcript' distinguishes direct text from audio transcription
  • Realtime model pricing — gpt-realtime-*, gpt-4o-realtime, gpt-4o-mini-realtime, gemini-*-native-audio, gemini-live-*
  • @livekit/agents, @livekit/agents-plugin-openai, @livekit/agents-plugin-google — optional peer dependencies

Changed

  • ctx.call()ctx.run(), CallResultSubRunResult, CallResultTransferSubRunResultTransfer — old names are deprecated aliases until 0.6.0
  • AgentTimeouts.maxDurationAgentTimeouts.expiry (deprecated alias maxDuration still works)
  • ModelEndEvent.modelNameModelUsage.modelName — model name now lives on the usage object; code reading event.modelName on model_end events must switch to event.usage?.modelName
  • Hook.afterAgent — output parameter widened stringunknown; typed hooks with explicit string signatures need updating
  • HandlerConfig.sessionService — now optional; handlers auto-inherit the app's session service
  • calculateCost(usage, modelName)calculateCost(usage) — model name read from usage.modelName
  • formatCost — sub-dollar amounts use toFixed instead of toPrecision
  • RunStatus'inactivity-timeout''inactivity_timeout', 'max-duration''max_duration', 'participant-left''participant_left'; added 'disconnected' and 'participant_left' as first-class statuses (previously collapsed to 'aborted')
  • InvocationEndReason — same underscore renames as RunStatus; InvocationEndReason is now a strict subset of RunStatus
  • InvocationState — limit end reasons (max_steps, max_turns, max_duration, inactivity_timeout, disconnected, participant_left) map to 'completed' instead of their raw reason string
  • BaseRunnersessionService constructor option is now optional (defaults to in-memory)
  • computeResumeContext() — multi-turn resume no longer bails early when a prior turn's root invocation is terminal; the findYieldedNodes scan already handles "nothing to resume"

Deprecated

  • AgentTimeouts.maxDuration — use expiry instead. Will be removed in 0.7.0.
  • sessionService() factory — use adk({ store }) and app.sessions
  • InMemoryStore, DynamoDBStore, PostgresStore, SQLiteStore classes — use inMemoryStore(), dynamoStore(), sqliteStore(), postgresStore()
  • app.session() — use app.sessions.create()
  • Standalone turn() — use app.handler.turn()

Removed

  • ModelEndEvent.modelName — use ModelUsage.modelName
  • gemini-2.5-flash high-tier pricing bracket (price changed)

2026-02-23

0.5.0

Eval API moves onto the app (app.evaluate, app.report), with full RunResult preserved per case and markdown report generation for persistent, agent readable, git-checkable records.

Added

  • app.evaluate(cases, options) — runs eval cases (single or array) with tool mocking and metrics; returns EvalResult<T, S> with app state S and optional per-case context T
  • app.report(result, options) — generates markdown from EvalResult; optional title, footer (string or function), sections, and renderCase for custom content
  • EvalCaseResult.run — full RunResult (session, usage, output) preserved for reporting and metrics; convenience accessors .events, .usage, .turns
  • EvalOptions.onCase — progress callback (result, index, total) => void called after each case completes
  • EvalCase.retries — number of additional attempts for flaky cases; retries on failed, error, or timeout
  • EvalCase.timeout — per-case timeout in ms; produces status: 'timeout' on expiry
  • TestOptions.maxTurns — replaces maxIterations for consistency with SimulateOptions
  • RunResult.state — typed TypedState<S> accessor (same type as result.session.state); symmetric with input: { state } on the input side
  • AssistantEvent.media — optional MediaPart[] field; eliminates the need to cast when accessing media on assistant events
  • Session.boundState(invocationId) — returns invocation-scoped TypedState; useful in custom context renderers
  • Session.onStateChange(callback) — registers a state change observer
  • Session.getSpawnedTaskStatus(id), Session.getRunningSpawnedTasks(), Session.getAllSpawnedTasks(), Session.waitForSpawnedTask(id), Session.waitForAllSpawnedTasks(), Session.hasRunningSpawnedTasks() — spawned task observation from hooks and tools
  • VectorFilter nested filters — must, should, must_not arrays now accept nested VectorFilter objects for compound boolean logic (e.g. AND-within-OR for per-slice filtering)
  • mem.slices(names) — subset accessor for searching, sampling, and filtering across a selected set of slices in one call; return type narrows to only the selected slice types
  • SlicedSubset<TSlices> — type for the object returned by mem.slices()
  • mem.variant.summary.returning('detailed') — cross-variant content: search using one variant's embeddings, return another variant's content

Changed

  • Parser structured output — path-scoped visited key (no false circular ref); valid partial used on parse failure when it passes schema
  • EvalCase — composes shared fields with SimulateOptions via Pick; initialState + firstMessage replaced by standard input (string or { message, state })
  • EvalCaseResult — now EvalCaseResult<S> with run: RunResult<unknown, S>; tokenUsageusage (type UsageSummary); events and turns preserved as convenience accessors
  • Metric.evaluate — signature (events: Event[])(run: RunResult); events available as run.session.events; built-in metric factories updated
  • Eval error shape — error on EvalCaseResult is now { message: string; stack?: string }; EvalError interface and phase field removed
  • Eval status mapping — aborted run status now maps to EvalStatus: 'aborted' instead of falling through to pass/fail; metric name collisions between suite and case level emit a console warning
  • app.handler.rest / app.handler.agui / app.handler.turn — now inherit app-level hooks and errorHandlers; handler-level config composes after app-level (app hooks run outer, handler hooks run inner)
  • app.cli — now inherits app-level hooks, errorHandlers, and appName for session creation; CLI-level hooks compose after app-level; respects user-provided runner override
  • Untyped state scopes return unknown instead of any — accessing properties on scopes without a schema (e.g. state.user.name when no user schema is defined) now requires explicit narrowing
  • mem.variant renamed from mem.variants
  • Memory internal key separator #_ — vector names (model#variantmodel_variant), metadata prefixes (_variant#_variant_, _slice#_slice_); existing collections must be re-indexed
  • ModelStartEventmessages: ContextMessageSummary[]messageCount: number; serializedSchema removed. The CLI reconstructs exact context on-demand via session.forkAt() + buildContext() when the user expands a context block. Eliminates O(n²) storage of repeated context snapshots.
  • SlicedMemory.slicesSlicedMemory.slice — singular accessor for per-slice operations (mem.slice.medication.search()); slices is now the subset method
  • eventCountMetric / eventSequenceMetricfilter callback infers narrowed event type from eventType; casts no longer needed. Metric<S> threads the app's state schema through run.state

Internal

  • Voice lifecycle state machine (idle → active → ending → ended) with atomic tryEnd() — first caller wins, concurrent shutdown events are safely ignored
  • ADK-owned inactivity timer replaces LiveKit userAwayTimeout — supports repeated firings and inactivityCount reset on user speech

Removed

  • Simulator type — removed from main package and adk/eval exports (was the literal signature of app.simulate)
  • llmJudge / LlmJudgeConfig — removed from adk/eval; implement the Metric interface directly with your own judge agent (see migration below)
  • runEval / runEvalSuite / EvalSuiteConfig — use app.evaluate(cases, options)
  • EvalError — use error?: { message: string; stack?: string } on EvalCaseResult
  • EvalCaseResult.tokenUsage — use result.usage (UsageSummary)
  • EvalCase.initialState / EvalCase.firstMessage — use standard input (string or { message, state })
  • TestOptions.maxIterationsTestOptions.maxTurns
  • toolCallCountMetric — use eventCountMetric with eventType: 'tool_call' and a filter
  • durationMetric — use timingMetric with measure: 'total_duration'
  • modelLatencyMetric — use timingMetric with measure: 'model_latency_average'
  • timeToFirstResponseMetric — use timingMetric with measure: 'time_to_first_assistant'

Migration from 0.4.x

EvalCase: initialState / firstMessageinput

EvalCase now uses the standard input field (same as app.run and app.simulate) instead of separate initialState and firstMessage fields.

```typescript

// Before

{ initialState: { session: { orgId: 'org-1' } }, firstMessage: 'Hello' }

// After

{ input: { message: 'Hello', state: { orgId: 'org-1' } } }

// or just: { input: 'Hello' }

```

Custom metrics: (events)(run)

Metrics receive the full run; events are on run.session.events.

```typescript

// Before

const metric = {

name: 'my_metric',

evaluate: (events: Event[]) => {

/* ... */

},

}

// After

const metric = {

name: 'my_metric',

evaluate: (run: RunResult) => {

const events = [...run.session.events]

// ... same logic, or use run.usage, run.output, run.session.state

},

}

```

llmJudge → custom Metric

llmJudge assumed a fixed transcript format and generic pass/fail schema. Implement Metric directly with your judge agent; the metric now receives RunResult so you can pass session or output into the judge.

```typescript

// Before

import { llmJudge } from '@animahealth/adk/eval'

const metric = llmJudge({

name: 'quality',

prompt: '...',

model: openai('gpt-5-mini'),

passingScore: 0.8,

})

// After

import type { Metric, MetricResult } from '@animahealth/adk/eval'

return {

name: 'quality',

evaluate: async (run): Promise<MetricResult> => {

// build input from run.session.events or run.output, then run judge agent

const { output } = await app.run(judgeAgent, { input: judgeInput })

return {

passed: output.value!.score >= 0.8,

score: output.value!.score,

evidence: [output.value!.reasoning],

}

},

}

```

2026-02-20

0.4.6

Added

  • handler.turn — shared streaming lifecycle (resolve session, run, stream events, commit, resolve conflict); returns StreamResult<TurnResult> with invocationId on the stream; use for custom projections (Slack, cron, CLI) without duplicating persistence
  • Hook.afterTurn — turn-level lifecycle hook that runs within the handler.turn commit boundary (after run completes, before commitSession); state mutations are included in the commit atomically; receives TurnContext with session, result, and runnable
  • TurnContext — context type for afterTurn; provides writable session, RunResult, and the runnable
  • CommitStatus, TurnResultTurnResult extends RunResult with sessionId, invocationId, optional commitStatus ('committed' | 'merged' | 'skipped' | 'orphaned')
  • RunConfig.invocationId — optional root invocation ID; when set, runner uses it instead of generating one (enables traceability with AG-UI runId)
  • RunConfig.errorHandlers — per-run error handlers, composed after runner and agent handlers (mirrors RunConfig.hooks)
  • sqliteIndex() — SQLite vector index provider via sqlite-vec with auto-provisioning
  • voyage() sagemaker option — SageMaker endpoint with automatic fallback to Voyage API; each path retries independently
  • VectorCondition.range — string bounds for datetime range filtering across all providers
  • VectorCondition.text — case-insensitive text matching on string metadata fields; contains accepts string | string[] (array = OR)
  • SearchOptions.contains — shorthand for text matching against stored content
  • CollectionSpec.textIndexes — payload field names that need a text index for content search (Qdrant)
  • normalizeFilter() — filter shorthand: { org: 'acme' } expands to { must: [{ key: 'org', match: { value: 'acme' } }] }; all filter-accepting methods (search, context, tool, sample, scroll, count) accept the shorthand via FilterInput
  • slices config on memory() — heterogeneous collections with per-slice typed metadata; records.slices.medication.search() returns SearchResult<MedicationMeta>, records.search() returns a discriminated union with match.kind for narrowing (renamed to records.slice in 0.5.0)
  • SlicedMemory, SliceAccessor, SlicedMatchUnion, SlicedSearchResult — types for sliced memory
  • CollectionSpec.payloadIndexes — auto-populated with _slice#kind when slices are declared
  • Match.kind — optional; present when the document belongs to a slice
  • better-sqlite3, sqlite-vec, @aws-sdk/client-sagemaker-runtime — optional peer dependencies

Changed

  • handler.agui — delegates to turn; events stream live (no buffering until commit); AG-UI runId is the turn’s invocationId; RUN_FINISHED result payload includes commitStatus for reconciliation
  • handler.rest — delegates to turn internally; external contract (buffered JSON response) unchanged
  • resolveConflict return type — ConflictOutcomeCommitStatus (same values, adds 'committed' for happy path)
  • UpsertItem.content — now required; content is stored alongside vectors and returned as Match.content
  • Upsert metadata — merge semantics across variants instead of replace
  • CollectionSpec.textIndexes — Qdrant users should provision text indexes from this field to enable SearchOptions.contains
  • VoyageModel.dimensions — now required; voyage('voyage-4')voyage('voyage-4', { dimensions: 1024 })
  • MemoryConfig.variantMemoryConfig.variants — singular string replaced by string array; omit for implicit ['default']
  • mem.variant('name')mem.variants.name (changed to variant in 0.5.0) — dynamic method replaced by upfront property map
  • collectionSpec(config, variants)collectionSpec(config) — variants now read from config.variants
  • Internal metadata prefix _content#_variant#; _slice# reserved for slices

Removed

  • createRunId() — use turn(config, input).invocationId (or the root invocation ID from the stream) as AG-UI runId
  • ConflictOutcome — replaced by CommitStatus (import from handler or runtime types)
  • vectorKey() — no longer public; use collectionSpec() instead
  • EmbedResult, Point, VectorMatch, DistanceMatrixPair, DistanceMatrixResult — removed from top-level exports (importable from @animahealth/adk/memory for custom providers)
  • MemoryContextConfig, MemoryToolConfig — removed aliases; use inline mem.context() / mem.tool() config

Migration from 0.4.5

Memory variant API

```typescript

// Before

const mem = memory({ ..., variant: 'questionnaire' });

const full = mem.variant('full');

// After

const mem = memory({ ..., variants: ['questionnaire', 'full'] });

const full = mem.variants.full; // (changed to variant in 0.5.0)

```

Memory collectionSpec signature

```typescript

// Before

collectionSpec({ model, collection }, ['questionnaire', 'full'])

// After

collectionSpec({ model, collection, variants: ['questionnaire', 'full'] })

```

2026-02-15

0.4.5

Added

  • memory() — composable vector memory; typed metadata via Zod schema, provider-agnostic Embedder / VectorIndex interfaces
  • voyage() — Voyage AI embedding provider with batching (128/request), automatic inputType routing, retry
  • qdrant() — Qdrant vector index provider with retry
  • pgvector() — pgvector vector index provider (PostgreSQL) with auto-provisioning, HNSW indexing, retry
  • inMemoryIndex() — in-memory vector index with real cosine similarity for testing and prototyping
  • mem.context() — returns ContextRenderer for deterministic recall before reasoning
  • mem.tool() — returns FunctionTool for agent-driven recall via tool call
  • mem.search() — returns typed Match<TMetadata>[] and computed embedding for downstream forwarding
  • mem.upsert() — batch-aware write accepting content (embeds) or pre-computed embedding; validates dimensions
  • mem.updateMetadata() — merge metadata without re-embedding; null deletes keys
  • mem.variant() — named vector variants sharing collection, schema, and providers
  • mem.sample() — representative sampling via density-weighted farthest-point selection; optional query-focused mode with gravity
  • vectorKey() — exported so provisioning scripts, Terraform generators, and migration jobs can compute the same model#variant vector names the ADK uses internally
  • collectionSpec() — computes collection vector specifications from memory config for provisioning
  • representativeSample(), estimateDensity() — exported sampling utilities for custom workflows
  • Embedder, EmbedResult, VectorIndex, Match, Point — exported types for custom provider implementations
  • voyageai, @qdrant/js-client-rest, pg — optional peer dependencies

2026-02-11

0.4.4

Added

  • Event type guards — isToolCallEvent, isToolYieldEvent, isToolInputEvent, isToolResultEvent, isAssistantEvent, and 10 more for every Event/StreamEvent member
  • SimulateYieldContext exported from main entry point

Changed

  • SimulateYieldContext, Transform, SimulateOptions — now generic over TArgs (defaults to unknown) so Transform callbacks can type ctx.args without casting

2026-02-11

0.4.3

Changed

  • Eval, run, test, simulate, CLI, and handlers — Runnable/Hook at orchestration boundaries widened to Runnable<any> / Hook<any>[] so typed agents and hooks work without casts

2026-02-10

0.4.2

Exports the eval framework as @animahealth/adk/eval and moves simulation termination into the core run loop.

Added

  • @animahealth/adk/eval — subpath export: runEval, runEvalSuite, interceptTools, metric factories, types
  • SimulateOptions.maxTurns, .maxDuration, .stateMatches — flat termination fields replacing maxIterations
  • RunStatus: 'terminated' with terminationReason: TerminationReason on the result
  • SimulateOptions.userAgent is now optional — tool-only flows no longer need a stub

Changed

  • SimulateOptions.maxIterationsmaxTurns
  • EvalSuiteConfig.parallel: booleanconcurrency: number (defaults to Infinity; use 1 for sequential)
  • runEval / runEvalSuite — first arg is now a Simulator function (pass app.simulate)

Removed

  • SimulateOptions.maxIterations — use maxTurns
  • EvalSuiteConfig.parallel — use concurrency

Migration from 0.4.1

Max-iterations status change

maxIterations exceeded previously returned status: 'error'. It now returns status: 'terminated' with terminationReason: 'maxTurns'. Code that checked result.status === 'error' for iteration limits must check 'terminated' instead.

2026-02-09

0.4.1

Added

  • app.hook() — callable hook namespace for typed custom hooks, mirroring app.context()
  • createEventId / createCallId — exported from public API

Changed

  • ToolYieldEvent.preparedArgsToolYieldEvent.args
  • yieldedTools — returns ToolYieldEvent[] instead of ToolCallEvent[]
  • CLI — pending yields show enriched tool_yield args instead of raw tool_call args
  • OpenAI — synthetic call IDs normalized to fc_ prefix at serialization boundary

Migration from 0.4.0

yieldedTools returns ToolYieldEvent[] instead of ToolCallEvent[]

session.yieldedTools, RunResult.yieldedTools, and RestResponse.yieldedTools now return the enriched ToolYieldEvent (with prepare args) instead of the raw ToolCallEvent. Code that accessed yieldedTools[n].args continues to work — the args are now the enriched version from prepare.

2026-02-09

0.4.0

Consolidates the public API with a canonical Input/Output pair — descriptive yield statuses, unified media and tool, namespaced handler payloads, fewer redundant types.

Changed

  • RenderContext — all fields are now readonly; context renderers must return new objects instead of mutating
  • RunResult.status'yielded''yielded_tool', 'input_required''yielded_message'; each branch of the discriminated union carries only its relevant fields
  • app.run() / app.test() / app.simulate() / ctx.call() (renamed to ctx.run() in 0.5.1) / ctx.spawn() — typed Agent<TOutput> overloads that preserve output type through to the result
  • ImageSource / AudioSource / DocumentSourceMediaSource; MessageInput.images / .audiomedia: MediaPart[]
  • ToolInput / ResultInputToolInput { callId, input }; session.input.tool() now handles both tool yields and tool call results
  • TestOptionsmessagesuserHandler, toolstoolHandlers; SimulateOptions / EvalCasesimulatoruserAgent, toolstoolAgents
  • Hook | Hook[]Hook[] — hooks options now only accept an array; wrap a single hook in [hook]
  • CallOptions / SpawnOptions / DispatchOptionsHandoffOptions
  • FunctionToolHookContextToolExecutionContext
  • RunResultOutput<T>Output<T> — canonical output shape shared by RunResult, Session, CallResult (renamed to SubRunResult in 0.5.1), SpawnResult
  • RunInput / BaseInputInput — canonical input shape with message, tools, and state fields
  • HandlerInput, RunOptions, TestOptions, SimulateOptions — payload fields grouped under input namespace; HandlerInput.input uses Input directly; toolInputsinput.tools; message widened to string | MessageInput
  • RestResponse.output — uses canonical Output type; opt-in events, usage via HandlerConfig.response
  • RestResponse.yieldedTools — yielded tools promoted to top-level field (replaces toolCall / toolCalls)
  • session.pendingYieldingCallssession.yieldedTools; result.pendingCallsresult.yieldedTools; pendingCallIdsyieldedToolIds

Removed

  • result.response — use result.output.value or result.output.text
  • result.awaitingInput — check result.status === 'yielded_message'
  • ImageSource, AudioSource, DocumentSource, ImageInput, AudioInput — use MediaSource / MediaPart[]
  • ResultInput, session.input.result() — use ToolInput / session.input.tool()
  • CallResultOutput, CallOptions, SpawnOptions, DispatchOptions, FunctionToolHookContext, ToolHookContext
  • RunResultOutput — use Output
  • RunInput, BaseInput — use Input
  • SessionOutputNamespacesession.output now returns Output directly
  • AdkRunConfig — use RunOptions

Migration from 0.3.x

Immutable RenderContext

```typescript

// Before

app.context((ctx) => {

ctx.events.push(systemEvent)

ctx.allowedTools = ['search']

return ctx

})

// After

app.context((ctx) => ({

...ctx,

events: [...ctx.events, systemEvent],

allowedTools: ['search'],

}))

```

Yield statuses

```typescript

// Before

if (result.status === 'yielded') {

if (result.awaitingInput) {

/* loop */

} else {

/* tool */

}

}

// After

if (result.status === 'yielded_tool') {

session.input.tool({ callId: result.yieldedTools[0].callId, input: data })

}

if (result.status === 'yielded_message') {

session.input.message({ text, invocationId: result.yieldedInvocationId })

}

```

Tool input

```typescript

// Before

session.input.tool({ callId, data: value })

// After

session.input.tool({ callId, input: value })

```

Media input

```typescript

// Before

session.input.message({ text, images: [{ url }], audio: [{ mimeType, data }] })

// After

session.input.message({

text,

media: [

{ type: 'image', source: { type: 'url', url } },

{ type: 'audio', source: { type: 'base64', mimeType, data } },

],

})

```

Orchestration options

```typescript

// Before (ctx.call renamed to ctx.run in 0.5.1)

ctx.call(agent, { message: 'hello', tempState: { key: 'val' } })

// After (ctx.call renamed to ctx.run in 0.5.1)

ctx.call(agent, { input: { message: 'hello', state: { key: 'val' } } })

```

Input / Output types

```typescript

// Before

import type { RunInput, BaseInput, RunResultOutput } from '@animahealth/adk'

const input: RunInput = { message: 'Hello' }

const output: RunResultOutput = result.output

// After

import type { Input, Output } from '@animahealth/adk'

const input: Input = { message: 'Hello' }

const output: Output = result.output

```

Handler Input

```typescript

// Before

handler({ sessionId: 'abc', message: 'Hello', state: { mode: 'debug' } })

handler({

sessionId: 'abc',

toolInputs: [{ callId: 'c1', data: { ok: true } }],

})

// After

handler({

sessionId: 'abc',

input: { message: 'Hello', state: { mode: 'debug' } },

})

handler({

sessionId: 'abc',

input: { tools: [{ callId: 'c1', input: { ok: true } }] },

})

```

Handler Output

```typescript

// Before

response.output // string

response.toolCall // { callId, name, args }

response.toolCalls // Array<{ callId, name, args }>

// After

response.output.text // string

response.yieldedTools // Array<{ callId, name, args }>

```

RunOptions / TestOptions

```typescript

// Before

app.run(agent, { input: 'Hello', state: { mode: 'debug' } })

app.test(agent, { input: 'Start', tools: { ask: [{ answer: 'Blue' }] } })

// After

app.run(agent, { input: { message: 'Hello', state: { mode: 'debug' } } })

app.test(agent, {

input: { message: 'Start', tools: { ask: [{ answer: 'Blue' }] } },

})

```

app.run(agent, 'Hello') string shorthand is unchanged.

Yield Renames

```typescript

// Before

session.pendingYieldingCalls

result.pendingCalls

event.pendingCallIds

// After

session.yieldedTools

result.yieldedTools

event.yieldedToolIds

```

2026-02-07

0.3.1

Fixed

  • Yielding tool safeParse failure — feed validation errors back as tool_result instead of silently hanging
  • Consistent ID prefixes for forked sessions (session_) and AG-UI runs (run_)

2026-02-07

0.3.0

Introduces the Hook system, pluggable session persistence, protocol handlers, and deterministic testing — replaces middleware, standalone runners, and user primitives.

Added

  • Hook interface — unified observation (onEvent, onStep) + interception (before*/after*)
  • app.run() accepts RunOptions with state and call-site hooks
  • app.test() — deterministic yield/resume testing (replaces scriptedUser())
  • app.simulate() — LLM-powered eval loop (replaces agentUser())
  • app.hook.logging(), app.hook.metrics() — built-in hook factories.
  • app.handler.rest() / app.handler.agui() — protocol handlers with HandlerInput / HandlerConfig
  • SessionStore interface with sessionService(store) factory — pluggable persistence
  • Stores: InMemoryStore (main entry), SQLiteStore (/stores/sqlite), DynamoDBStore (/stores/dynamodb), PostgresStore (/stores/postgres)
  • runSessionStoreTests() — shared compliance suite for custom stores
  • Scoped shared state via session.scopes, getScopedState() / setScopedState()
  • ConflictError — thrown on OCC version conflict during commitSession()

Changed

  • **runner.run() no longer commits sessions** — callers must call sessionService.commitSession() (built-in handlers do this automatically)
  • Middleware / Hooks → single Hook interface; onStreamonEvent
  • Agent.middleware + Agent.hooksAgent.hooks: Hook[]; AdkConfig.middlewareAdkConfig.hooks
  • composeMiddleware()composeHooks(); loggingMiddleware()loggingHook(); cliMiddleware()cliHook()
  • session.version type: stringnumber; SessionStoreSnapshotStoredSession

Removed

  • src/users/scriptedUser(), humanUser(), agentUser(), User interface (use app.test() / app.simulate())
  • src/middleware/ — replaced by src/hook/
  • InMemorySessionService, LocalSessionService — use sessionService(new InMemoryStore())
  • Per-scope methods (getUserState, etc.) — use getScopedState / setScopedState
  • HandlerInput.toolInput — use toolInputs array
  • @animahealth/adk/persistence subpath — use main entry or store subpaths

Migration from 0.2.x

Session Commit (runner.run callers only)

```typescript

// Before

const result = await runner.run(agent, session)

// After

const result = await runner.run(agent, session)

await sessionService.commitSession(session)

```

Built-in handlers and app.run() commit automatically — no change needed.

Middleware → Hooks

```typescript

// Before

const app = adk({ middleware: [loggingMiddleware()] })

const agent = app.agent({ middleware: [myMw], hooks: { beforeAgent: fn } })

// After

const app = adk({ hooks: [loggingHook()] })

const agent = app.agent({ hooks: [myHook, { beforeAgent: fn }] })

```

Session Stores

```typescript

// Before

import { sessionService, SQLiteStore } from '@animahealth/adk'

// After

import { sessionService, InMemoryStore } from '@animahealth/adk'

import { SQLiteStore } from '@animahealth/adk/stores/sqlite'

import { DynamoDBStore } from '@animahealth/adk/stores/dynamodb'

import { PostgresStore } from '@animahealth/adk/stores/postgres'

```

User Primitives → app.test / app.simulate

```typescript

// Before

await runner.runWithUser(agent, session, {

user: scriptedUser({ tools: { approve: [{ ok: true }] } }),

})

await runner.runWithUser(agent, session, {

user: agentUser({ loop: simAgent, tools: { ask: answerAgent } }),

})

// After

await app.test(agent, { input: 'Start', tools: { approve: [{ ok: true }] } })

await app.simulate(agent, {

input: 'Start',

simulator: simAgent,

tools: { ask: answerAgent },

})

```

2026-02-06

0.2.1

Changed

  • Ink 3 / React 17 → Ink 5 / React 18 for CLI terminal UI
  • CJS→ESM bridge for app.cli() — transparent import() wrapper so CJS consumers work unchanged
  • extractCurrentThoughtBlock, buildInvocationBlocks → exported from @animahealth/adk/cli instead of main entry
  • react, ink, ink-spinner, ink-text-input — optional peer dependencies for app.cli() consumers

Internal

  • tsup.config.ts now includes an esbuild plugin that externalizes ../cli in the CJS build, so dist/index.js emits require("./cli/index.js") instead of inlining the CLI module tree.
  • scripts/postbuild-cli-cjs-wrapper.cjs generates a CJS→ESM wrapper at dist/cli/index.js that does import('./index.mjs') to load Ink in native ESM context.
  • lodash is force-bundled (noExternal) to avoid Node ESM's "Named export not found" error when importing CJS-only packages.
  • Jest moduleNameMapper mocks added for ink and ink-text-input since they are ESM-only and cannot be require()'d in test.

Migration from 0.2.0

**CLI utility imports** — if you import extractCurrentThoughtBlock or buildInvocationBlocks, update the import path:

```typescript

// Before

import { extractCurrentThoughtBlock, buildInvocationBlocks } from '@animahealth/adk'

// After

import { extractCurrentThoughtBlock, buildInvocationBlocks } from '@animahealth/adk/cli'

```

2026-02-04

0.2.0

Introduces the adk() factory — a typed app instance with namespaced methods for agents, tools, context, sessions, and MCP, replacing standalone factories and adding multimodal input/output.

Added

  • adk() factory — creates typed app instance with name and schema
  • app.* methods for all runnables with automatic type inference
  • app.context.* namespace for context renderers
  • app.tools.* namespace for built-in tools: - webSearch() — web search via Serper API - fetchPage() — fetches web pages, PDFs, and images as markdown - takeScreenshot() — captures webpage screenshots
  • app.mcp.* namespace for MCP server management: - server() — create/get MCP server instance - tools() — aggregated callable tools from all servers - toolDefinitions() — aggregated tool metadata from all servers - resourceDefinitions() — aggregated resource metadata from all servers - promptDefinitions() — aggregated prompt metadata from all servers
  • server.* instance API: - tools() — callable FunctionTool[] - toolDefinitions() — raw MCPToolInfo[] - resourceDefinitions()MCPResourceInfo[] - promptDefinitions()MCPPromptInfo[] - resource(uri) / prompt(name) — context renderers
  • session.input.* namespace for input operations: - message() — user messages (text and multimodal) - tool() — user input for yielding tools
  • session.output.* namespace for output operations: - text — last assistant text - items — all assistant events - tool() — provide tool results (superseded by session.input.result() in 0.3.3)
  • result.output.* namespace with convenient accessors: - text — last assistant message text - value — structured output (if schema configured) - items — all assistant events - media — generated media (images, audio)
  • Multimodal input via session.input.message({ text, images, audio, media })
  • Multimodal output via result.output.media and tool __media return pattern
  • MediaPart type for image, audio, and document attachments
  • ImageInput and AudioInput helpers: { url } or { mimeType, data } (base64)
  • Provider support: Claude, OpenAI, and Gemini handle media in user messages and tool results
  • spec.* namespace for cross-app reusable specs

Changed

  • Standalone factories → app.* methods (agent()app.agent(), etc.)
  • Standalone context renderers → app.context.* (injectSystemMessage()app.context.system(), etc.)
  • Model providers remain standalone: openai(), gemini(), claude()
  • Output config simplified: output: 'key' instead of output: output(schema, 'key')
  • State API: method-based → property access - Session state is now the default scope: ctx.state.mode (not ctx.state.session.mode) - Other scopes remain explicit: ctx.state.user.theme, ctx.state.patient.id
  • Session input: addMessage()session.input.message()
  • Session input: addToolInput()session.input.tool({ callId, data })
  • UserEvent structure simplified: - text: string — always the text message - media?: MediaPart[] — optional attachments (images, audio)

Removed

  • BaseRunner — use app.run() instead
  • Standalone factories and context renderers — use app.* methods
  • Method-based state API (get, set, delete, toObject)
  • initialState from CreateSessionOptions — use session.state.update()
  • session.addMessage() — use session.input.message() instead
  • session.addToolInput() — use session.input.tool() instead
  • session.addToolResult() — use session.output.tool() (superseded by session.input.result() in 0.3.3)
  • session.append() — use session.pushEvent() if needed (internal)

Migration from 0.1.0

App Factory Pattern

```typescript

// Before

import { agent, tool, openai, injectSystemMessage, includeHistory, BaseRunner } from '@animahealth/adk';

const myTool = tool({ name: 'greet', schema: z.object({ name: z.string() }), ... });

const assistant = agent({

name: 'assistant',

model: openai('gpt-4o-mini'),

context: [injectSystemMessage('You are helpful'), includeHistory()],

tools: [myTool],

});

await BaseRunner.run(assistant, 'Hello');

// After

import { adk, openai } from '@animahealth/adk';

const app = adk({ schema: { session: { mode: z.string() } } });

const myTool = app.tool({ name: 'greet', schema: z.object({ name: z.string() }), ... });

const assistant = app.agent({

name: 'assistant',

model: openai('gpt-4o-mini'),

context: [app.context.system('You are helpful'), app.context.history()],

tools: [myTool],

});

await app.run(assistant, 'Hello');

```

State API

```typescript

// Before

ctx.state.get('mode')

ctx.state.set('mode', 'triage')

// After

ctx.state.mode

ctx.state.mode = 'triage'

ctx.state.update({ mode: 'triage', count: 42 })

```

Session Input

```typescript

// Before

session.addMessage('Hello')

session.addToolInput(callId, input)

// After

session.input.message('Hello')

session.input.message({ text, invocationId }) // For resuming loops

session.input.tool({ callId, data: input })

```

Output Access

```typescript

// Before

const lastEvent = result.session.events.findLast((e) => e.type === 'assistant')

if (lastEvent && lastEvent.type === 'assistant') {

console.log(lastEvent.text)

}

// After

console.log(result.output.text)

// Structured output

const output = result.output.value

// Provide tool results (superseded by session.input.result() in 0.3.3)

session.output.tool({ callId, result: data })

```

Reusable Specs (Advanced)

```typescript

// For runnables shared across multiple apps:

import { spec } from '@animahealth/adk';

// Stateless (no schema)

const calc = spec.tool()({ name: 'calc', schema: z.object({ expr: z.string() }), ... });

// Stateful (with schema constraint)

const counter = spec.tool({ session: { count: z.number() } })({ name: 'inc', ... });

// Bind to any schema compatible app

const boundTool = app.use(calc);

```

2026-01-21

0.1.0

Added

  • Initial release as standalone package ported from anima-service.

Migration from anima-service

```typescript

// Before

import { agent, tool } from '../../../modules/adk'

// After

import { agent, tool } from '@animahealth/adk'

```

For PersistentSessionService (DynamoDB), continue importing from anima-service until that is extracted.

Agent Development Kit · Reference

Glossary

One definition per word, and a link to the chapter that shows it running. Nothing here is new — every entry is the vocabulary the other chapters already use, written down once so a sentence four pages away still parses. The last section is the honest part: the words the package spends twice.

Reads · in any order, at any time Needs · nothing (no cells on this page) Package · @animahealth/adk (MIT)

Vocabulary

What you build

Nine words cover everything you hand the runner. Five of them are the closed set of runnable kinds; the rest are what those kinds are made of.

Term What it means here
runnable Anything the runner executes. A union of five kinds — agent, step, sequence, parallel, loop — each a plain object carrying a kind and a name. There is no sixth case and no escape hatch. The five kinds.
agent The only runnable kind that talks to a provider. It carries model, context and tools, and calls the model in a loop until the model stops asking for tools. The five kinds.
step Your TypeScript as a runnable, in the same session. No model, no prompt. Returning a runnable from its execute delegates to that runnable, which is how routing is written. (The runner also counts model calls in something it calls steps — see One word, two jobs.) A step is your code.
sequence Runs its runnables in order on one session, stopping early on error or yield. A sequence of two agents.
parallel Runs its branches concurrently on cloned sessions, then merges each branch's new events back into the parent ledger. Loop and parallel.
loop Repeats one runnable while a while predicate holds, capped by a required maxIterations. Loop and parallel.
app What adk(config) returns. It holds the state schema, the store, the registered adapters, app-level hooks and error handlers, and the factories that build everything else: app.agent, app.tool, app.context, app.step, app.run. An agent with a tool.
tool A name, a description, a Zod schema and a function. The name and description are prompt — they are the only reason a model reaches for this tool rather than another. The whole config.
yielding tool A tool that declares a yieldSchema. Instead of computing an answer it suspends the run until one is supplied from outside; with no execute, the supplied input becomes the result. A tool that yields.

Vocabulary

One run, and how it ends

Four nested units of work: a turn contains a run, a run contains invocations, an invocation contains model calls. They are easy to conflate, and the caps are set on different ones.

Term What it means here
run One call of app.run(runnable, input): the runnable executes against a session until it completes, yields, or fails. The quickstart.
run result What a run hands back: output, usage, status, iterations, stepEvents, session, state and runnable, plus per-status extras. There is no run.events. What the run left behind.
invocation One execution of one runnable, opened by invocation_start and closed by invocation_end. Nested runnables nest invocations, and every event carries the invocationId that produced it. One ledger, interleaved.
model call One request to a provider, bracketed by a model_start and a model_end. A tool-using turn makes two: one to decide the call, one to answer with its result. The ledger.
iteration One model call plus the tools it asked for — the unit run.iterations counts, maxSteps caps at 25, and model_start.stepIndex numbers. Timeouts and caps.
turn One input applied to a session, run to a stopping point, then committed. app.handler.turn is that unit — and the only place the afterTurn hook fires. One hook.
yield A run stopping to wait for something from outside rather than finishing. The invocation stays open — an invocation_yield with no matching invocation_end — which is precisely what a sleeping agent is. What a sleeping agent is.
run status How the run ended: completed, yielded_tool, yielded_message, error, max_steps, max_turns, aborted, transferred and the rest. Yielding for a message.
session status A different axis, describing the session rather than the run over it: active, awaiting_input, completed or error. The run that stops.
output run.output: the text of the reply, the parsed value when an output schema is set, items (every assistant event) and media. A schema on output.
usage Tokens, model calls and an estimated cost, summed from the run's model_end events. A scripted run has none — there were no tokens. What it cost.
handoff Giving work to another agent from inside a tool or step. ctx.run awaits it, ctx.spawn backgrounds it with a handle, ctx.dispatch fires and forgets, and returning a runnable transfers to it outright. What ctx carries.

Vocabulary

The ledger, and what is derived from it

One append-only list is the whole storage model. State, status, the invocation tree and a fork of the past are all computed from it rather than kept beside it.

Term What it means here
session An append-only list of events plus everything derived from it. One per program, not one per agent, so every runnable reads the same history. One ledger, interleaved.
ledger That event list read as a record: run.session.events. run.stepEvents is this run's slice of it, and the test kit's TestResult.events is an alias for the whole thing. One ledger, interleaved.
event One durable row. Every event carries id, type, createdAt, invocationId and agentName, and the package exports a type guard per type, so narrowing the union is a filter. One ledger, interleaved.
stream event What a subscriber sees while a run is in flight: the same events, minus what the session already held, plus the assistant_delta and thought_delta chunks the ledger never keeps. What the run left behind.
prompt event The six message-shaped types a provider serializes: system, user, assistant, thought, tool_call, tool_result. Everything else in the ledger is bookkeeping, and messageCount counts exactly these. Rendered every turn.
annotation An annotation event written by ctx.note(message, opts) — a phase marker, a log line, a checkpoint. It is not a prompt event, so no model sees it unless a renderer puts it there. A step is your code.
state The values an agent holds, replayed from state_change events on every read. Nothing is saved to a state table; replay is the storage. Run it, then read state.
state scope Which bucket a state key lives in: session, the shared scopes user, patient, practice, org and team, and temp. Declare only the ones you use. A schema, and its scopes.
shared scope A scope keyed by an id the session carries, so several sessions address the same values. Its values can move between reads, which is why a read is recorded as an observation state change. The audit trail.
temp The one scope never written to the ledger: scratch space for a single invocation, held in memory. Reading it outside an invocation throws. Writing state from a tool.
state schema The Zod types declared per scope on adk({ schema }). It is a compile-time contract over every read and write, and a defaults table for seeding. A schema, and its scopes.
fork session.forkAt(index) — a new session carrying the events up to that point, so an alternate history runs without disturbing this one. stateAt(index) is the read-only version of the same replay. Snapshots and time travel.
store Where the events live between runs. adk({ store }) takes one, and Postgres, SQLite, DynamoDB and the default in-memory store all implement the same SessionStore contract — so which one is a deployment decision, not an agent one. What a sleeping agent is.
commit Writing buffered events to the store. The turn, rest and agui handlers commit after every turn; driving app.run yourself, you call sessions.commit yourself. What a sleeping agent is.

Vocabulary

What the model sees

The ledger is history; the prompt is a projection of it, rebuilt before every model call. These are the words for that projection.

Term What it means here
context An agent's context: array. Not a string — a list of renderers applied in order before every model call. A context is a pipeline.
renderer A function from a RenderContext to a RenderContext. The chain starts with an empty event list, so nothing reaches the model unless a renderer puts it there. A context is a pipeline.
tap A renderer that returns its input unchanged so it can record the finished render. The only way to see the exact text a call was built from — model_start stores the shape, not the words. A context is a pipeline.
history scope history({ scope }): which invocations are visible to this render — direct, all, invocation, ancestors or agent. Unrelated to a state scope. Scopes and filters.
tool choice Which of an agent's tools the model may pick on this call. limitTools(names) narrows the choice and toolChoice sets the mode; neither removes a tool from the definitions the provider is sent. Which tools it may pick.
prompt caching Reusing a stable prompt prefix at the provider. Opt in per descriptor, and mark where the reusable prefix ends with app.context.cacheableUser(text). Prompt caching.
output schema The Zod schema on an agent's output. A renderer can read it as ctx.outputSchema, already rendered as prompt text, and every model_start records which one its call used. Native, prompt, and a real model.
output mode Where that schema is enforced. native (the default) sends it to the provider as a response format; prompt withholds it and leaves the job to your own prompt. Native, prompt, and a real model.
correction A receipt from the output parser: the path it touched, what it found, what it produced, and why. Their totalScore is what the repair cost, so a high score is a signal about your prompt. The parser under it.

Vocabulary

The model seam

Naming a model and calling one are different jobs held by different objects. That separation is what lets the same agent run against a provider or against a script.

Term What it means here
descriptor An agent's model: — a plain data object holding provider, name and options, which never talks to anyone. openai('gpt-5.6-luna') returns one; you can also write one by hand. A model is a value.
adapter The object that speaks a provider's wire protocol. Resolved per model call, in a fixed order: an adapter registered on the app wins, then the factory the descriptor carries, then a dynamic import. Descriptor, adapter, runner.
provider openai, gemini or claude — named on the descriptor, and the key an adapter is registered under. Descriptor, adapter, runner.
test kit @animahealth/adk/testing: runTest, the user/model/input/result step builders, mockAgent, MockAdapter, the assertion helpers and the vitest matchers. A scripted turn.
scripted model A MockAdapter standing in for a provider, so a run needs no key and no network. It replaces exactly one thing — the model's turns. Tools, context, state and the ledger are the real ones. The mock replaces the model.

Vocabulary

Guardrails and recovery

Two layers, deliberately separate: hooks decide what is allowed to happen, error handlers decide what a failure means.

Term What it means here
hook One object with optional lifecycle methods: onEvent, onStep, beforeAgent/afterAgent, beforeModel/afterModel, beforeTool/afterTool, and afterTurn. Attach it at the app, the agent, or one call. One hook.
interception A hook method returning a value instead of undefined. A beforeTool that returns a tool_result means the tool never executes and the model reads that result instead. A hook that says no.
error handler { canHandle?, handle }, where handle returns one of six verdicts — throw, skip, abort, retry, fallback, pass — and the first that is not pass wins. Six recovery actions.
phase Where a failure happened: model, tool, callback or render. With no handler the defaults are asymmetric — a tool error is skipped, a model error is thrown. What a throw actually does.
retry config { maxAttempts, initialDelayMs, maxDelayMs, backoffMultiplier, retryableErrors? }, set on a tool or on a descriptor. Retries are internal: the ledger records one result per call, not one per attempt. Timeouts, retries, failure.
maxSteps · maxTurns An agent's two caps: 25 iterations inside one invocation, and 100 yield-and-resume cycles. Both end the run with a status of their own name rather than an error. Timeouts and caps.

Vocabulary

Words this site uses

These are about the documentation rather than the package. They matter because a chapter tells you which of its code blocks you can press Run on, and why the others you cannot.

Term What it means here
cell A code block on these pages you can edit and press Run. It executes the shipped package in your browser, and all the cells on one page share a scope in document order. Paste a key, run everything.
mock cell A cell whose model is scripted by the test kit. It needs no key and no network, and its tools really execute. Most cells on this site are mock cells. A scripted turn.
live cell A cell that calls OpenAI with the key you paste into the box on that page. The key lives in your browser's localStorage and the request goes straight to api.openai.com. Paste a key, run everything.
static block A code block with no Run button, for code the substrate cannot serve — another provider's SDK, vitest, a server route. It is checked by hand against the same types the cells compile against. In your repo.
substrate What a page's cells are allowed to import: @animahealth/adk, @animahealth/adk/testing, @animahealth/adk/openai, and zod. Anything beyond that is a static block. In your repo.
tier The stability promise on an entry point. Core is the main entry and the subpaths beside it; Experimental/workflow, /agents/coding, /executors — may change or vanish in any release, and is reachable only through its own subpath, never the main entry.

Read this one twice

One word, two jobs

Twelve words in this package name more than one thing. The ambiguity is in the API itself, not only in the prose about it, so the fix is to read the surrounding member rather than the word. Each row is a real collision you will meet.

Word One job The other
step A runnable kind — app.step({ execute }), your code in the graph. One model call and its tools — run.iterations, maxSteps, model_start.stepIndex, run.stepEvents, the hook onStep. Nothing to do with app.step.
turn One input, run, and commit — app.handler.turn, the hook afterTurn, and maxTurns counting yield-and-resume cycles. Loosely, one model call: a scripted model(...) step is one of those, and a tool-using exchange with the user is two.
scope A state scope — session, user, temp and the rest of the buckets state lives in. A history scope — history({ scope: 'direct' }), choosing which invocations a render can see.
schema A tool's argument schema (and its yieldSchema) — the border a model's JSON has to cross. The app's state schema (adk({ schema })) and an agent's output schema (output: { schema }). Three different Zod objects, three different jobs.
status A run status — completed, yielded_tool, max_steps: how one run ended. A session status — active, awaiting_input: what the session is now. A yielded run leaves a session awaiting_input.
output The agent's output: config — the schema or state key its reply is parsed into. run.output, the value a run produced; and ctx.output(value), the signal that ends an invocation with one.
context An agent's context: renderers — the list that builds the prompt. The ctx a tool, step or hook receives — args, session, state, signal. Also RenderContext, ErrorContext, TurnContext.
agent A runnable kind — app.agent({ model, tools }), the thing the runner executes. Five primitives. A coding agent — a CodingAgent handle on a harness like Claude Code. It is a tool and a runner of its own, not one of the five kinds. Coding agents.
model The model: descriptor on an agent — a value naming a provider and a model, resolved to an adapter at run time. A model is a value. model(…), the test-kit script step standing in for one reply (the step vocabulary) — and, in prose, the LLM itself.
store A SessionStore — where the ledger and scoped state are committed. Stores: the sleeping agent. Never the vector side: qdrant, pgvector, sqliteVec and inMemoryIndex are indexes on this site, and they hold no session. Vector backends.
tool · tools app.tool({ … }) builds one function tool, and an agent's tools: array is what it may call. Tools. app.tools.* is a different thing entirely: the namespace holding the built-in web-tool factories. Web tools.
result What a run produced — run.output, and the test kit's TestResult.result, which is the whole RunResult the kit ran. What the run left behind. result({ toolName: value }), the script step supplying a yielded call's result (the step vocabulary); and tool_result, the ledger event a tool's return becomes (the ledger).

The rule that resolves most of them. Ask whether the word is naming something you built or something the runner counted. app.step is yours; stepIndex is the runner's. output: is yours; run.output is the runner's. The two never appear in the same expression.

Agent Development Kit · Reference · the storage seam

Vector backends behind one interface

memory() implements neither half of what it needs: something that turns text into vectors, and something that stores them. Both are interfaces. Four index implementations ship, and a fifth can be your own object. This chapter is a reference, not a notebook — every backend here needs a native module or a running server, so its code is read rather than run.

Audience · engineers wiring memory to an index Runs here · nothing (no cells on this page) Imports · @animahealth/adk · /qdrant

Step 1

Two contracts, one seam

A memory is a config: model, index, collection. The model is an Embedder; the index is a VectorIndex. Everything else on the memory chapter — search, slices, variants, the context renderer, the tool — is built on those two methodsets and nothing more.

The embedder is the smaller of the two.

interface Embedder {
  readonly dimensions: number
  readonly modelName?: string
  embed(input: string[], options?: { inputType?: 'query' | 'document' }): Promise<EmbedResult>
}

interface EmbedResult {
  embeddings: number[][]
  model: string
  usage?: { totalTokens: number }
}

dimensions is load-bearing: it sizes the column, the virtual table, or the named vector the index creates. So is modelName — it prefixes the vector name that memory() reads and writes, as <modelName>_<variant>. Rename the model and you are addressing a different vector, in the same collection, holding nothing.

The index is the larger contract. Nine methods, and an optional tenth.

interface VectorIndex {
  search(
    collection: string,
    embedding: number[],
    options?: {
      topK?: number
      filter?: VectorFilter
      variant?: string
      minScore?: number
    },
  ): Promise<VectorMatch[]>
  upsert(collection: string, points: Point[], options?: { variant?: string }): Promise<void>
  delete(collection: string, ids: string[]): Promise<void>
  deleteByFilter(collection: string, filter: VectorFilter): Promise<void>
  updateMetadata(collection: string, id: string, metadata: Record<string, unknown>): Promise<void>
  distanceMatrix(
    collection: string,
    options?: {
      sample?: number
      limit?: number
      filter?: VectorFilter
      variant?: string
    },
  ): Promise<DistanceMatrixResult>
  get(
    collection: string,
    ids: string[],
    options?: { variant?: string },
  ): Promise<Array<{ id: string; metadata: Record<string, unknown> }>>
  scroll(
    collection: string,
    options?: {
      filter?: VectorFilter
      limit?: number
      offset?: string
      variant?: string
      includeVectors?: boolean
    },
  ): Promise<ScrollResult>
  count(
    collection: string,
    options?: {
      filter?: VectorFilter
      variant?: string
    },
  ): Promise<number>
  close?(): Promise<void>
}

Three shapes recur. A collection is a namespace, passed on every call rather than bound at construction — one index instance serves many. A variant is a second vector for the same id: the same note embedded as a summary, or by a second model. A filter is the must/should/must_not tree of VectorFilter; the served backends translate it into their own query language, and the file-backed ones evaluate it with the same predicate the reference index uses.

memory() always names the variant it reads and writes, even the default one. The contract's latitude about which variant an unnamed search returns therefore only matters when you hold a VectorIndex yourself.

Descriptors, not clients. sqliteVec(), pgvector() and qdrant() return plain data — { provider: 'sqlite-vec', path } and friends — so a config stays inspectable and serializable. memory() resolves it to a live index on use. The sqlite-vec and Qdrant descriptors resolve lazily: their native module or client is imported on the first call that needs it, never at import time. Pass an object that already implements VectorIndex and it is used as-is, with no registration step.

Step 2

What ships

Four implementations, one interface. The first three are in the main entry; Qdrant sits behind its own subpath so importing the ADK never pulls its client in.

Backend Import Also install Where points live Collection creation
inMemoryIndex() @animahealth/adk nothing one process's heap on first write
sqliteVec({ path }) @animahealth/adk better-sqlite3, sqlite-vec a local file, or ':memory:' on first write
pgvector({ connectionString }) @animahealth/adk pg, a Postgres with vector one table per collection on first write
qdrant({ url }) @animahealth/adk/qdrant @qdrant/js-client-rest, a server a Qdrant collection yours, before first write

Every "also install" entry is an optional peer dependency. The ADK declares them and imports them dynamically, so a consumer who never touches a backend never installs it. That last column is the deepest difference between the backends, and section 5 is about it.

inMemoryIndex() is the reference implementation, not a placeholder: it is the index the contract is defined against, and the yardstick the other three are held to. It keeps nothing across a process restart.

Step 3

sqlite-vec — durable, with no server

A file on disk, two native modules, no infrastructure. This is the backend for local development, CLIs, desktop apps, and single-process deployments.

import { memory, sqliteVec } from '@animahealth/adk'

const notes = memory({
  model: myEmbedder,
  index: sqliteVec({ path: './data/notes.db' }),
  collection: 'notes',
})

await notes.upsert({
  id: 'n1',
  content: 'the boiler was serviced in March',
  metadata: { room: 'plant' },
})

const { matches } = await notes.search('boiler service', { topK: 3 })

path: ':memory:' gives an ephemeral index with the same code path; any other path has its parent directory created for it, and the database opens in WAL mode. Each collection becomes two tables: an ordinary one holding the metadata and the variants, and a vec0 virtual table holding the vectors under a cosine metric.

CREATE TABLE IF NOT EXISTS "<collection>" (
  _rowid INTEGER PRIMARY KEY AUTOINCREMENT,
  id TEXT NOT NULL,
  variant TEXT NOT NULL,
  metadata TEXT NOT NULL DEFAULT '{}',
  embedding BLOB NOT NULL,
  UNIQUE(id, variant)
)

CREATE VIRTUAL TABLE IF NOT EXISTS "<collection>_vec"
  USING vec0(embedding float[<dimensions>] distance_metric=cosine)

Three consequences worth knowing before you ship it. The virtual table's width is fixed by the first vector the collection ever sees, so one collection serves exactly one embedder. Collection names are interpolated into that DDL and are therefore held to [A-Za-z0-9_-]+. And a filtered search cannot filter inside the KNN query: the index over-fetches — ten times topK, at least a hundred rows — filters the page, and widens the window tenfold again until it has topK matches, exhausts the index, or drops below minScore. Correct, at the cost of extra reads on a filter that matches almost nothing.

Step 4

pgvector — the database you already run

If the rest of the system is on Postgres, so is this. The provider needs the vector extension available and a role that may create it, plus pg as a peer.

import { memory, pgvector } from '@animahealth/adk'

const notes = memory({
  model: myEmbedder,
  index: pgvector({
    connectionString: process.env.DATABASE_URL!,
    schema: 'memory',
  }),
  collection: 'notes',
})

The index opens its own pool and, on close(), ends it. To share the pool your application already has, pass it — anything with a query(text, values?) method satisfies the PgPool shape. An injected pool is never ended for you, and connectionString stays required even when you pass one.

import { Pool } from 'pg'

const pool = new Pool({ connectionString: process.env.DATABASE_URL })

const index = pgvector({
  connectionString: process.env.DATABASE_URL!,
  pool,
  batchSize: 500,
})

Each collection is one table in schema (default public), plus an HNSW index.

CREATE TABLE IF NOT EXISTS "<schema>"."<collection>" (
  id TEXT NOT NULL,
  variant TEXT NOT NULL,
  embedding vector(<dimensions>),
  metadata JSONB DEFAULT '{}',
  PRIMARY KEY (id, variant)
)

CREATE INDEX IF NOT EXISTS "<schema>_<collection>_hnsw_idx"
  ON "<schema>"."<collection>" USING hnsw (embedding vector_cosine_ops)

Filters compile to SQL against the metadata column, so scoring and filtering happen in one statement and there is no over-fetch window to widen: a match becomes metadata->>key = $n, a text.contains becomes a lowercased LIKE, and a range casts the field to numeric — or to timestamptz when the bound you passed is a string. The score is 1 - (embedding <=> $1::vector), cosine similarity, matching every other backend.

Identifiers — the schema and every collection name — must match [a-zA-Z_][a-zA-Z0-9_]*. That is stricter than sqlite-vec: a collection named user-notes works on one backend and throws on the other. Search, scroll, count and upsert address the 'default' variant when none is named. Every operation retries with exponential backoff — three attempts, 500 ms, doubling, capped at 30 s — unless you pass your own retry.

Step 5

Qdrant — provisioned, not created

Qdrant is the one backend that will not build its own collection. Its provider never issues a create call: a collection's named vectors are fixed when the collection is created, and Qdrant cannot add a named vector to a collection that already exists. So provisioning is a step you run, in the shape the ADK will later address.

import { memory } from '@animahealth/adk'
import { qdrant } from '@animahealth/adk/qdrant'

const notes = memory({
  model: myEmbedder,
  index: qdrant({ url: process.env.QDRANT_URL!, apiKey: process.env.QDRANT_API_KEY }),
  collection: 'notes',
  variants: ['default', 'summary'],
})

Import qdrant from @animahealth/adk/qdrant. The main entry re-exports it for compatibility and marks it deprecated; the subpath is the one that bundles the client.

collectionSpec() computes what to provision from the same config, so the vector names are derived rather than retyped. It creates nothing.

import { collectionSpec } from '@animahealth/adk'

const spec = collectionSpec({
  model: myEmbedder,
  collection: 'notes',
  variants: ['default', 'summary'],
})

// spec.collection   'notes'
// spec.vectors      { 'my-encoder_default': { dimensions: 256, distance: 'Cosine' },
//                     'my-encoder_summary': { dimensions: 256, distance: 'Cosine' } }
// spec.textIndexes  ['_variant_default', '_variant_summary']

Those keys are exactly what the running memory addresses: one named vector per variant, prefixed by the embedder's modelName, and one payload key per variant holding the text that was embedded. spec.payloadIndexes appears only when the config has slices, and carries the key their kind is stored under.

Applying the spec is a dozen lines you own — a script, a migration job, a Terraform generator. Note the one translation: the spec says dimensions, Qdrant's client says size.

import { QdrantClient } from '@qdrant/js-client-rest'

const client = new QdrantClient({ url: process.env.QDRANT_URL! })

await client.createCollection(spec.collection, {
  vectors: Object.fromEntries(
    Object.entries(spec.vectors).map(([name, v]) => [
      name,
      { size: v.dimensions, distance: v.distance },
    ]),
  ),
})

for (const field of spec.textIndexes) {
  await client.createPayloadIndex(spec.collection, { field_name: field, field_schema: 'text' })
}

Adding a variant later means adding it to variants, re-running the spec against a new collection, and re-indexing into it. That is the cost the other backends do not charge, and the reason to reach for Qdrant deliberately: a served index with payload indexes, tenant keys, and a server-side distance matrix, in exchange for a provisioning step.

One more thing to expect in the dashboard. Qdrant point ids must be unsigned integers or UUIDs, so any other id — 'note-17', a slug, a composite key — is hashed to a deterministic UUIDv5 and the original is kept in the point's _original_id payload key. The ADK maps it back on every read and strips the key from the metadata you see. Your ids round-trip; the ids in Qdrant's own UI are UUIDs.

Step 6

Your own index, and your own embedder

Both seams are structural. An object with the right methods is an Embedder; an object with the other nine is a VectorIndex. Neither needs a factory, a registration call, or a provider tag.

import type { Embedder } from '@animahealth/adk'

// `encode` is yours: a local model, an HTTP call, anything returning `dimensions` numbers.
const myEmbedder: Embedder = {
  dimensions: 256,
  modelName: 'my-encoder',
  async embed(input) {
    return { embeddings: await Promise.all(input.map(encode)), model: 'my-encoder' }
  },
}

An index is the same move with more surface. Pass the object as index and memory() uses it directly. Read inMemoryIndex in the package source first — it is the shortest complete implementation of the contract, and the one the others are checked against.

Then hold your implementation to the same suite. It is written as a function over a factory, so registering a backend is three lines.

// src/memory/providers/index-compliance.test.ts
export function runVectorIndexTests(
  name: string,
  createIndex: () => Promise<VectorIndex>,
  cleanup?: () => Promise<void> | void,
)

That file is package source, not a published entry point: @animahealth/adk/testing does not export it. To run it against an index of your own today, copy it out of the repository. The next section is what it proves for the backends that ship.

Step 7

What is actually proven

One suite defines the contract, and the in-memory index is its reference implementation. It asserts shared behaviour only, and stays deliberately quiet where the contract leaves latitude: which variant an unnamed search reads is provider business, and scroll tokens are paged through opaquely rather than parsed.

Every method is pinned down, and most of them from several directions:

Area What it pins down
search Cosine ranking, topK, minScore, metadata filters, one row per id when no variant is named, and a named variant searching its own vectors. One case buries the filter's only match behind 120 closer points — a backend that KNN-fetches a fixed window and filters afterwards comes back empty and fails here.
upsert Re-upserting an id replaces its embedding; metadata merges across writes, and a null value deletes a key.
delete · deleteByFilter The named ids only, and the matching points only.
updateMetadata · get Updates merge into existing metadata; get returns an empty metadata object for an id that does not exist, rather than throwing or dropping the row.
scroll Every point is reachable by following nextOffset, filters apply, and includeVectors round-trips the embedding it stored.
count · distanceMatrix Counts respect filters and count only the ids carrying a named variant; the matrix returns one pair per unordered pair, with sample and limit bounding it. Empty collections answer empty everywhere.

Where it runs is a property of the public repository's CI workflow: a unit job with no services, and a backend-compliance job against service containers. Stores: the sleeping agent has both job definitions.

Backend Registered in the suite Unit job Backend-compliance job
inMemoryIndex yes runs runs
sqliteVec yes — against a throwaway file database runs runs
pgvector yes — when TEST_PGVECTOR_URL is set skipped, no endpoint runs, against the container
qdrant no — see below

SQLite needs no environment, which is why it gets the full run everywhere. The pgvector registration wipes and recreates the public schema before it starts: the suite reuses fixed collection names, so point it only at a dedicated test database.

Qdrant does not run the shared suite, and this is deliberate. The suite encodes the lazy-creation semantics the in-memory reference, sqlite-vec and pgvector share: write to a collection that does not exist and it appears. Qdrant is provisioning-based, as section 5 describes, so those cases cannot be true of it. Whether the VectorIndex contract should grow a provisioning seam — so Qdrant can run the suite too — is an open design question in the package. Until it is settled, any conformance claim for Qdrant must read provisioned, does not run the shared suite.

An unexercised provider is an undocumented deviation waiting to be found by a user rather than by CI, so here are the three the source shows today, none of them caught by anything. get() throws for an id that is not in the collection, where the contract returns an empty metadata object — so pass ids you already know exist, or catch. get() and count() both ignore the variant option: a count is a count of points, not of a named vector. Everything else in the provider tracks the contract, but "tracks" here means read, not proven.

Agent Development Kit · Experimental

Dynamic workflows, and a file Claude Code already runs

A workflow here is not a new runtime. It is an ordinary step, run by app.run, returning an ordinary result. On top of that sits one optional loader: @animahealth/adk/workflow takes a .workflow.js file written for the Claude Code Workflow tool and runs its body on this runtime, unchanged.

Status · experimental subpath Cells · no key required Source · src/workflow/

Read this first

Experimental: this subpath can change without notice

@animahealth/adk/workflow is the only subpath in the package that carries foreign vocabulary — runWorkflowFile, TierModelMap, NodeRunner. Names, option shapes, and error types here can change in any release, without a deprecation window. Nothing in the ADK core imports it, and it is not part of the surface the package promises to hold still. What pre-1.0 means, and which surfaces carry which promise, is owned by the package README’s Stability section — read that before you build something load-bearing on this page.

Two consequences for this chapter. The loader reads files from disk, so it is Node-only and cannot run in this page: every block that mentions runWorkflowFile is static code, checked by hand against src/workflow/index.ts. What is runnable here is the piece that lives in the core — ctx.note — and those cells need no key, because a step never calls a model.

Step 1

A workflow is a step, and nothing else

There is no app.workflow, no WorkflowResult, no runWorkflow. A workflow is an app.step whose body orchestrates other runs with plain async/await; you run it with app.run and get a RunResult. The three additions that make that comfortable — app.ask, fanout, and ctx.note — are general core primitives, useful in any step. Many agents covers the first two; this chapter owns ctx.note and the file loader.

Here is a step that emits the three annotation shapes the loader emits, reads them back, and returns a value. No model is involved, so this runs on nothing but the shipped runtime.

import { adk, isAnnotationEvent } from '@animahealth/adk'

const app = adk()

const build = app.step({
  name: 'build',
  execute: (ctx) => {
    ctx.note('Plan', { kind: 'phase' })
    ctx.note('node:impl:renderer', {
      kind: 'mark',
      label: 'impl:renderer',
      data: { phase: 'Implement' },
    })
    ctx.note('plan returned 3 files')
    ctx.output({ files: ['renderer.ts'] })
  },
})

const buildRun = await app.run(build, 'go')

// Reading them back is a filter over the same ledger — there is no second channel.
const annotations = buildRun.session.events.filter(isAnnotationEvent).map((e) => ({
  kind: e.kind,
  message: e.message,
  label: e.label,
  phase: e.data?.phase,
}))

const ledger = { events: buildRun.session.events.map((e) => e.type), annotations }

ledger

Read events first. The input landed as a user event, an invocation opened, three annotations followed, the invocation closed. No model_start, because a step never calls a model — which is why this cell needs no key. Then annotations: the same ledger, narrowed with isAnnotationEvent, with the three shapes the step emitted. The step's ctx.output(value) is what RunResult.output.value carries — which is also how a workflow file's return reaches the caller.

Step 2

ctx.note is the whole progress surface

Long orchestrations need to say where they are without inventing a logging channel. ctx.note(message, opts?) appends one AnnotationEvent to the same append-only ledger everything else writes to. It is the only event kind these workflows added, it is general (any step may call it), and it streams with every other StreamEvent — so a live UI, a CI log, and a stored session all read the same thing.

Field Type Meaning
type 'annotation' Narrow the union with isAnnotationEvent.
kind 'phase' | 'log' | 'mark' Defaults to 'log' when opts.kind is omitted.
message string? The first argument to note.
label string? A short identifier — a phase title, a node id.
data Record<string, unknown>? Structured payload for consumers that render more than a line of text.

Timestamp and invocation id are stamped by the ledger, not by you. Pulling annotations back out is the filter in the cell above — events.filter(isAnnotationEvent), the same way you pull anything else out of a session.

Three kinds, three uses. phase marks a boundary a reader can scan for; log is a line of narration; mark is a checkpoint carrying structure in data. The loader in the next sections emits exactly these three shapes and invents nothing else.

Step 3

What a workflow file looks like

A workflow file is the authoring surface Claude Code's Workflow tool already uses: one .js file, a meta export, then a body that calls four globals it never imports — agent, parallel, phase, log. The loader binds those globals before the body's first statement runs, so the top-level phase('Plan') below resolves rather than throwing.

// build-renderer.workflow.js
export const meta = {
  name: 'build-renderer',
  description: 'Plan, implement in parallel, then verify the renderer.',
  whenToUse: 'After the contract changes and the renderer must be rebuilt.',
  phases: [
    { title: 'Plan', detail: 'Agree the module layout', model: 'opus' },
    { title: 'Implement', detail: 'One node per module' },
    { title: 'Verify', detail: 'Build and test' },
  ],
}

const PLAN_SCHEMA = {
  type: 'object',
  additionalProperties: false,
  properties: {
    modules: { type: 'array', items: { type: 'string' } },
    risks: { type: 'array', items: { type: 'string' } },
  },
  required: ['modules'],
}

const FILES_SCHEMA = {
  type: 'object',
  additionalProperties: false,
  properties: { files: { type: 'array', items: { type: 'string' } } },
  required: ['files'],
}

phase('Plan')
const plan = await agent('Plan the renderer modules against the contract.', {
  label: 'plan',
  model: 'opus',
  schema: PLAN_SCHEMA,
})
if (!plan) return { aborted: 'planning produced no verdict' }
log(`Plan: ${plan.modules.length} modules`)

phase('Implement')
const impls = await parallel(
  plan.modules.map((module) => () =>
    agent(`Implement ${module}.`, {
      label: `impl:${module}`,
      phase: 'Implement',
      model: 'sonnet',
      schema: FILES_SCHEMA,
    }),
  ),
)

const files = impls.filter(Boolean).flatMap((r) => r.files)
log(`Implemented ${files.length} files`)

return { modules: plan.modules, files }

Four things in that file are load-bearing, and each is a contract the loader keeps. meta is a pure object literal. Top-level await and top-level return both work. A failed agent() resolves to null rather than throwing, which is why if (!plan) and .filter(Boolean) are the idioms. And model is a tier string — the file never names a provider.

Step 4

Running one: runWorkflowFile

The file is not passed to app.run. runWorkflowFile is the bridge: it reads the source, parses meta, wraps the body in an app.step named meta.name, and runs it. The tier map is required, and it is where the deployment decision lives — the file says 'opus', you say what 'opus' is.

import { adk } from '@animahealth/adk'
import { claude } from '@animahealth/adk/claude'
import { openai } from '@animahealth/adk/openai'
import { runWorkflowFile } from '@animahealth/adk/workflow'

const app = adk()

const result = await runWorkflowFile('workflows/build-renderer.workflow.js', {
  app,
  models: {
    default: openai('gpt-5.6-luna'),                 // used when agent() omits model
    byTier: {
      sonnet: openai('gpt-5.6-luna'),
      opus: claude('claude-opus-4-5', {}),           // claude() takes a required config object
    },
  },
  // node: a NodeRunner — see below. Omitted, agent() is a no-tools app.ask.
})

result.status          // 'completed'
result.output?.value   // { modules: [...], files: [...] } — the file's return value
In the file Binds to Result contract
agent(prompt, opts?) the node runner — by default app.ask The schema-validated object (or text), unwrapped — not a RunResult. Any failure becomes null; it never throws out of the body.
parallel(thunks) fanout(thunks) Results in input order; a failed thunk is null; the call never rejects. Concurrency is fanout's default cap, min(16, cores - 2) and never below one — the loader passes no limit of its own.
phase(title) ctx.note(title, { kind: 'phase' }) One annotation with kind: 'phase' and the title as its message.
log(message) ctx.note(message) One annotation with the default kind: 'log'.
label / phase on agent() ctx.note('node:<label>', { kind: 'mark', label, data }) A node-level mark carrying label and data.phase. It does not emit a second phase marker: the count of phase events equals the count of phase() calls.

Four failures stop the run before any node executes: a computed (non-literal) meta; a tier a file names that the map does not define; a deferred feature; and the v2 options resume, background, runId. There is no substitution anywhere in that list — an unmapped tier does not silently fall back to default. An omitted model uses default; a present but unmapped one is an UnmappedTierError naming both the tier and the tiers you did define.

Because meta is required to be a pure literal, a host can read it without running anything — parseWorkflowMeta(source) evaluates only the object literal, in an empty VM context, and returns { name, description, whenToUse?, phases? }. That is how a workflow file can be listed, indexed, or shown in a picker before anyone agrees to execute it. Unknown extra keys in meta are ignored, not rejected.

Step 5

Exactly where the compatibility stops

The headline is real but bounded: a .workflow.js written for Claude Code runs on this runtime with zero body edits, provided it stays inside the subset below. That subset was not guessed — it is what the Serenity build attractors actually use. Everything outside it raises UnsupportedCCFeatureError, whose message names the specific feature. Nothing is silently ignored, and nothing is approximated.

Supported Rejected, by name
meta with name, description, whenToUse?, phases?: [{ title, detail?, model? }] a meta that is not a pure object literal
agent, parallel, phase, log pipeline, nested workflow(), args, budget
agent() options label, phase, model, schema — and only those four isolation, agentType, retries, timeoutMs, and any option key the loader does not recognize
tier strings resolved through your map, plus a default a tier the map does not define
JSON Schema literals on agent({ schema }), top-level await, top-level return resume, background, runId on the loader itself (durable resume and detached execution are not built)

budget deserves its own sentence, because approximating it would be worse than refusing it: cross-run token accounting does not exist here, so budget.remaining throws rather than returning a number a workflow would then trust.

Three limits that are not features. They follow from how the loader executes a file, and a workflow file that trips them will not run.

The file is text, not a module. The loader reads the source, removes the export const meta = … declaration, strips remaining export keywords, and compiles the rest as an async function body whose parameters are the four globals. That is what makes top-level await and top-level return work — and it is also why a static import statement in a workflow file cannot work. None of the repo's workflow files use one.

Only meta is sandboxed. The literal is evaluated in an empty VM context. The body is not: it runs in-process with the loader's own globals and full Node access. Run a workflow file the way you would run any script you are about to execute — this is not a sandbox for untrusted code.

The node runner gets no signal. NodeRunner declares a third signal parameter, and the loader passes undefined for it on every call. Aborting the run still settles it, but a node runner that needs to cancel in-flight work must arrange its own signal.

Step 6

The node seam: one agent() call, two kinds of agent

Claude Code has one agent(). The ADK deliberately separates a no-tools LLM call from a coding agent that edits files and runs commands. The node option is where that difference is made explicit — one config, and the file body stays identical.

type NodeRunner = (
  prompt: string,
  opts: CCAgentOpts,        // { label?, phase?, model?, schema? }
  signal?: AbortSignal,     // always undefined from this loader
) => Promise<unknown | null>

Omit node and every agent() call becomes app.ask: a one-shot call on a fresh session, with no tools, returning the schema-validated value — or null if it fails, including after its schema retries are exhausted. That is the right node for planning, judging, extraction, and verdicts.

Supply a node and the same call goes wherever you send it. A build workflow sends it to a coding agent over a provisioned workspace, so the file's agent('implement X') mutates a workspace rather than returning prose about one. That runner, its workspace lifecycle, and how its work is scored on the environment delta belong to Coding agents. Returning null from your runner is how you signal a per-node failure without breaking the file's filter(Boolean) idiom.

Step 7

JSON Schema in the file, Zod at the boundary

A workflow file cannot import Zod, so agent({ schema }) carries a JSON Schema object literal. The loader converts it statically, and the conversion is deliberately narrow: widening a constraint would let a node return a shape the file then dereferences.

  • required is preserved exactly as written — a strict subset. Properties not listed become .optional(). It is never widened to every key, never emptied.
  • additionalProperties: false becomes a strict object that rejects extra keys.
  • enum membership is enforced whatever the declared type; a non-string enum becomes a union of literals.
  • string, number, integer, boolean, null, and array element types are enforced, never collapsed to z.any(). Nested objects inside arrays keep their own required.
  • A node with no type and no enum stays unknown — the converter does not fabricate a constraint it was not given.

What reaches the file is the parsed value, unwrapped. A validation failure the node cannot recover from is a null, which the file is expected to guard — the same contract every other node failure has. Structured output covers how schema-shaped output behaves on the native surface, where you write the Zod yourself.

Agent Development Kit · Experimental

Coding agents, whichever harness you bring

A coding agent is an ordinary ADK agent whose work is filesystem state. This chapter is the contract around one: the provider-neutral CodingAgent interface, the CodingAgentFactory seam that swaps the harness underneath it, the provision → dispose lifecycle that keeps a run from leaking a workspace, and the rule that decides whether the run passed — the diff, never the summary.

Audience · engineers wiring a coding harness into a pipeline Needs · a real workspace and a harness — nothing runs in this page Source · src/agents/coding/, src/executors/

Read this first

Every surface on this page is Experimental

Experimental means no deprecation cycle. The coding-agent and executor surfaces ship only behind their own subpaths — @animahealth/adk/agents/coding, @animahealth/adk/agents/coding/claude-code, and @animahealth/adk/executors — and any release may change or remove them. A build gate keeps them out of the main entry precisely so that nothing here can reach you wearing a Core badge. Pin your version. The package README’s Stability section says what the two tiers promise.

The other reason this chapter reads instead of runs: every block below needs a real filesystem, a real repository, and a coding harness process. The cells elsewhere on this site execute the ADK inside your browser, and none of that exists there. So the code here is checked against the package source by hand rather than by pressing Run — treat it as a transcription of src/agents/coding/ and src/executors/, and read those when something surprises you.

Step 1

A coding agent is a runnable and a tool

Claude Code, Codex, and their kin are already agents: they plan, call tools, edit files, and stop. The ADK does not re-implement that loop — it wraps one in an interface, so the harness becomes a thing you can hand to an orchestrator, drop into a sequence, or run on its own. CodingAgent is that interface, and it is deliberately two things at once.

// src/agents/coding/types.ts
interface CodingAgent<S extends StateSchema = StateSchema>
  extends FunctionTool<CodingToolInput, CodingResult, StreamEvent, S> {
  name: string
  description: string
  schema: z.ZodType<CodingToolInput>

  /** Standalone: returns a handle you can stream AND await. */
  run(task: string | CodingTask): CodingHandle

  /** Tool form: what the ADK calls when the agent sits in a `tools:` array. */
  execute(
    ctx: ToolExecutionContext<CodingToolInput, StreamEvent, unknown, S>,
  ): Promise<CodingResult>

  /** Only needed to rename or re-describe the tool form. */
  asTool(options?: CodingToolOptions): FunctionTool<CodingToolInput, CodingResult, StreamEvent, S>
}

Because execute is there, a coding agent needs no adapter to be a tool: put it in tools: [coder] and an orchestrating model can delegate to it. Its input schema is two fields — task and an optional sessionId — so the model's job is to write a brief, not to drive an editor.

// src/agents/coding/types.ts
interface CodingTask {
  task: string
  sessionId?: string   // resume a previous harness session
  signal?: AbortSignal
}

/** Streams events AND resolves the result — off the same object. */
interface CodingHandle extends AsyncIterable<StreamEvent>, PromiseLike<CodingResult> {
  send(input: CodingInput): void
  abort(): void
}

type CodingInput =
  | { type: 'message'; text: string }
  | { type: 'tool_response'; callId: string; approved: boolean }
  | { type: 'abort' }

The handle is the ergonomic part. Iterate it to watch the harness work, then await the same object for the verdict — awaiting on its own consumes the stream for you, so you never have to drain it by hand.

import { createClaudeCodeAgent } from '@animahealth/adk/agents/coding'

const coder = createClaudeCodeAgent({
  workspace: '/path/to/repo',
  config: { permissionMode: 'acceptEdits', maxTurns: 50 },
})

const handle = coder.run('Fix the failing test in auth.test.ts, then run the unit tests.')

for await (const event of handle) {
  console.log(event.type)   // assistant, thought, tool_call, tool_result, system, deltas
}

const result = await handle   // the same handle, now a CodingResult

The events are ADK StreamEvents, not harness messages: the Claude Code adapter maps assistant text, thinking blocks, tool calls, and tool results onto the same vocabulary the rest of this site uses, so a coding agent's stream renders in whatever already renders an agent's stream. What resolves at the end is aligned with a normal run result, with two coding-specific additions.

// src/agents/coding/types.ts
interface CodingResult {
  status: 'completed' | 'error' | 'aborted' | 'max_turns' | 'max_duration'
  sessionId: string                 // always present — the resume handle
  output: Output<CodingOutput>      // output.text = the agent's summary
  usage?: UsageSummary              // ADK's usage shape: tokens, modelCalls, cost
  error?: CodingError
  durationMs?: number
}

interface CodingOutput {
  modifiedFiles: string[]           // provenance, gathered from the run's write/edit tool calls
  metadata?: Record<string, unknown>
}

interface CodingError {
  message: string
  code: 'rate_limited' | 'context_exhausted' | 'sdk_error' | 'aborted' | 'timeout' | 'unknown'
  retryAfter?: number               // seconds, when code is 'rate_limited'
}

sessionId is the durable part. Pass it back as coder.run({ task, sessionId }) and the harness resumes its own conversation — the ADK stores nothing itself. modifiedFiles is provenance, not proof: it is collected by watching the run's write and edit tool calls, so it records what the coding agent said it was doing. Proof comes from the workspace, in step 5.

Step 2

The factory seam: one node body, any harness

An interface alone does not make a harness swappable. The thing that forks a codebase is the constructor: every call site that says createClaudeCodeAgent(…) is a place a second harness has to be threaded through. So construction goes behind one seam. CodingAgentFactory.create takes a workspace and hands back a CodingNode — a coding agent already bound to that directory.

// src/agents/coding/factory.ts
interface CodingAgentFactory {
  create(opts: { workspace: string; signal?: AbortSignal }): CodingNode
}

interface CodingNode {
  readonly workspace: string
  run(task: string | CodingTask): Promise<CodingNodeOutcome>
}

interface CodingNodeOutcome {
  workspace: string
  task: string
  delta: EnvironmentDelta   // { diff, commandResult } — the scoring surface
  summary?: string          // the agent's own words. Display only; never scored.
  result: CodingResult      // the raw result, kept for provenance
}

Note what create does not do: it does not provision. The workspace path is an input, materialized by someone else before the call. That ordering is the seam's whole contract — provision, then construct, then run — and it is what lets the same node body drive a local git worktree and a remote sandbox without knowing which it got.

Two factories ship. The first wraps the Claude Code agent:

import { createClaudeCodeFactory } from '@animahealth/adk/agents/coding'

const factory = createClaudeCodeFactory({
  claudeCode: { config: { permissionMode: 'acceptEdits' } },
  delta: async (result, ctx) => ({
    diff: await git(ctx.workspace, 'diff'),
    commandResult: await sh(ctx.workspace, 'npm test'),
  }),
})

const node = factory.create({ workspace: '/repo/.worktrees/run-17', signal })
const outcome = await node.run('Implement the failing requirement; run the unit tests.')

The second takes any CodingAgent you can build. This is the bring-your-own-harness door: implement the interface over Codex, over an in-house coding agent, over a shell script that shells out to something entirely different, and pass a build function. Nothing downstream changes — not the node, not the lifecycle, not the scoring.

import { createCodingAgentFactory, codingToolInputSchema } from '@animahealth/adk/agents/coding'
import type { CodingAgent, CodingHandle, CodingResult } from '@animahealth/adk/agents/coding'

// Your harness, wearing the ADK's interface. `startHarness` returns a CodingHandle:
// an AsyncIterable of StreamEvents that is also a PromiseLike of a CodingResult.
function myCoder({ workspace }: { workspace: string }): CodingAgent {
  const agent: CodingAgent = {
    name: 'my-coder',
    description: 'Runs the in-house coding harness in a provisioned workspace.',
    schema: codingToolInputSchema,
    run: (task): CodingHandle => startHarness(workspace, task),
    execute: (ctx): Promise<CodingResult> => Promise.resolve(startHarness(workspace, ctx.args)),
    asTool: (options) => ({
      name: options?.name ?? agent.name,
      description: options?.description ?? agent.description,
      schema: agent.schema,
      execute: agent.execute,
    }),
  }
  return agent
}

const factory = createCodingAgentFactory({
  build: myCoder,
  delta: async (result, ctx) => ({
    diff: await git(ctx.workspace, 'diff'),
    commandResult: await sh(ctx.workspace, 'npm test'),
  }),
})

build is required and create validates. createCodingAgentFactory({}) throws on the missing build — use createClaudeCodeFactory() if the shipped coding agent is what you wanted. And create({ workspace: '' }) throws rather than quietly running the harness in whatever directory the process happens to be in. Both are refusals by design: a coding agent pointed at the wrong tree is the expensive kind of mistake.

One subtlety in how the signal flows. create({ workspace, signal }) stores the signal and threads it into each node.run(task) — unless the task carries its own, which wins. Construction is never cancelled; the in-flight run is.

Step 3

Provision, construct, run, dispose — then score

Those four steps in that order, with disposal guaranteed, are easy to write and easy to get subtly wrong: dispose in the happy path only and an aborted run leaks a worktree; construct before provisioning and a bad path costs you a harness session before it fails. runCodingNode is that sequence, written once.

import { runCodingNode } from '@animahealth/adk/agents/coding'
import { createWorkspaceProvisioner } from '@animahealth/adk/executors'

const { outcome, score } = await runCodingNode({
  factory,                                    // CodingAgentFactory
  provisioner: createWorkspaceProvisioner(),  // WorkspaceProvisioner
  base: '/repo',                              // repo root or parent directory
  isolation: 'worktree',                      // 'session' | 'worktree' | 'sandbox'
  task: 'Implement the failing requirement; run the unit tests.',
  signal: ctx.signal,                         // optional
  metric: myMetric,                           // optional; defaults to codingDeltaMetric()
})

score.passed        // reflects outcome.delta — never outcome.summary
outcome.workspace   // the path that existed during the run; it is gone by now
outcome.result      // the raw CodingResult: status, sessionId, usage, modifiedFiles

Read the ordering once more, because it has a consequence people trip over: the workspace is already disposed by the time the metric runs. A metric that wants to look at files cannot — that is what the delta probe is for, and the probe fires inside node.run, before the finally. Gather your evidence in the probe; score it afterwards.

Failures propagate honestly. A coder that throws throws out of runCodingNode — after disposal. A provisioning failure surfaces as WorkspaceProvisionError or UnknownIsolationStrategyError, unchanged, because those errors already name the offending strategy and base path. An abort mid-run cancels the coder, skips the remaining work, and still disposes.

Step 4

Workspace isolation is a seam, not a policy

Concurrent coding agents sharing a working tree is not a race you want to debug. The WorkspaceProvisioner gives each run its own directory, and it is deliberately the smallest interface that can: one method in, a disposable handle out.

// src/executors/workspace-provisioner.ts
interface WorkspaceProvisioner {
  provision(base: string, isolation: string): Promise<ProvisionedWorkspace>
}

interface ProvisionedWorkspace {
  path: string                              // the coder's working directory
  dispose: () => Promise<void> | void       // called exactly once, after the run
  isolation: 'session' | 'worktree' | 'sandbox'
}
Strategy What a host is expected to materialize
'session' An ephemeral directory under the base path, thrown away after the run.
'worktree' A git worktree of the base repository — the usual choice when the base is a repo and the diff is the deliverable.
'sandbox' An isolated sandbox from a provider. This is where a container or a remote microVM plugs in.

The strategy string is validated before any backend runs, and an unknown one is a hard error naming both the bad value and the valid set. There is no fallback to a shared directory — the failure mode that silently lets two coding agents edit the same tree is simply not reachable.

import {
  createWorkspaceProvisioner,
  ISOLATION_STRATEGIES,        // ['session', 'worktree', 'sandbox']
  isIsolationStrategy,
  UnknownIsolationStrategyError,
  WorkspaceProvisionError,
} from '@animahealth/adk/executors'

// Supply real materialization per strategy. Anything you omit falls back to the in-process
// default: a distinct, kind-tagged path under `base` with a no-op dispose.
const provisioner = createWorkspaceProvisioner({
  worktree: async (base) => {
    const path = `${base}/.worktrees/${crypto.randomUUID()}`
    await sh(base, `git worktree add ${path}`)
    return {
      path,
      isolation: 'worktree',
      dispose: () => sh(base, `git worktree remove --force ${path}`),
    }
  },
})

await provisioner.provision('/repo', 'container')
// throws UnknownIsolationStrategyError:
//   unknown isolation strategy: 'container'. Valid strategies: session, worktree, sandbox

Step 5

Scored on what the workspace shows

A coding agent's most confident sentence is "I've fixed the bug and all tests pass." It is also the sentence least worth believing, because the coding agent that wrote it is the one being judged. So the score never sees it. The scoring surface is the EnvironmentDelta: the workspace diff, plus the output of whatever command verifies it.

// src/agents/coding/factory.ts
interface EnvironmentDelta {
  diff: string            // the workspace diff after the run; '' when nothing changed
  commandResult: string   // the verification command / test output
}

type DeltaProbe = (
  result: CodingResult,
  ctx: { workspace: string; task: string; signal?: AbortSignal },
) => Promise<EnvironmentDelta> | EnvironmentDelta

The invariant is structural rather than advisory. The metric's input type is { diff, commandResult } — there is no summary field to read, so no metric, custom ones included, can re-couple the score to the coding agent's claims. A run that reports success over an empty diff scores as a failure, and no amount of eloquence changes that.

import { codingDeltaMetric } from '@animahealth/adk/eval'

// The default metric. `passed` is true when the diff is non-empty AND the command result
// carries no failure token.
const metric = codingDeltaMetric()

// A custom one is the same two fields, plus a numeric score if you want one.
const strict = codingDeltaMetric({
  name: 'green-tests-only',
  passed: (delta) => delta.diff.trim() !== '' && delta.commandResult.includes('0 failed'),
  score: (delta) => delta.diff.length,
})

The other seam

Executors: where a turn's environment comes from

@animahealth/adk/executors holds two different seams, and telling them apart is most of understanding the module. The WorkspaceProvisioner above is per-run: it exists for the length of one coding node. An Executor is per-turn and belongs to the ADK's long-running process machinery — it prepares the environment an agent's turn executes in, and hands back the events that turn produced.

// src/gateway/gateway-types.ts
interface Executor {
  readonly name: string
  // request: { process, session, agent, messages, signal }
  // result:  { status, nextWakeAt?, error?, events, executorConfig? }
  execute(
    request: ExecutionRequest,
    onEvent: (event: StreamEvent) => void,
  ): Promise<ExecutionResult>

  cleanup?(processId: string): Promise<void>
  getPreviewUrl?(processId: string): Promise<string | null>
  listWorkspaceFiles?(processId: string): Promise<string[] | null>
}

Two implementations ship: createDockerExecutor runs turns in local containers against a bind-mounted repository, and createModalExecutor runs them in remote sandboxes cloned from a git URL. Both take a session store, because a turn's events have to land somewhere durable, and both need real infrastructure — a Docker daemon, or a Modal account and its tokens. Neither is something app.run() reaches on its own — and the Executor type above is not itself exported, so today you consume the two factories rather than write a third implementation.

import { createDockerExecutor, createModalExecutor } from '@animahealth/adk/executors'

const local = createDockerExecutor({
  sessionStore,
  repoPath: '/path/to/repo',      // bind-mounted into each container
  hooks: {
    afterCreate: async (ctx) => { await ctx.exec('npm install') },
    afterRun: async (ctx) => { ctx.log(`turn done in ${ctx.workspace.path}`) },
  },
})

const remote = createModalExecutor({
  sessionStore,
  defaultWorkspace: { repoUrl: 'https://github.com/org/repo.git', baseBranch: 'main' },
})

Those hooks are the extension point worth knowing: afterCreate, beforeRun, afterRun, and beforeDestroy, each either a shell string or a function receiving { workspace, process, session, log, exec }. Dependency installation, artifact collection, and branch pushes hang off those four points rather than off the executor's internals.

Workspace tools: an ordinary agent, scoped to a directory

The most immediately useful export in the module needs no infrastructure at all. workspaceTools returns file tools bound to a root — the ingredients for a coding agent you build yourself out of ADK primitives, rather than one you bring from outside.

import { workspaceTools, DEFAULT_BLOCKED_COMMANDS } from '@animahealth/adk/executors'

// Returns: [read, write, edit, grep, glob, shell]
const tools = workspaceTools({
  root: '/workspace/my-project',
  sandboxed: false,       // default. Mutating tools carry requiresApproval: true.
  allowShell: true,       // default. Set false to drop the shell tool entirely.
  maxFileSize: 10 * 1024 * 1024,
  shellTimeout: 30_000,
})

const editor = app.agent({ name: 'editor', model, tools })

Two behaviors carry the safety of that list. Every path is resolved against the root and a path that escapes it throws — no ../.. reaches outside the workspace. And while sandboxed is false, write, edit, and shell are marked requiresApproval, so the runner pauses and asks before each one; see Stopping to ask for what that pause looks like. Set sandboxed: true only when a container is the thing containing the damage.

The shell tool's blocklist is a seatbelt, not a sandbox. DEFAULT_BLOCKED_COMMANDS is a list of regexes covering the obvious hazards — sudo, rm -rf /, pipe-to-shell downloads, container escapes — and it is exported so you can extend or replace it. It stops a careless command, not a determined one. Real isolation comes from the environment the tools run in.

Aside

Testing the pipeline without a harness

Everything above is orchestration, and orchestration deserves tests that do not cost tokens or need a subprocess. Two stand-ins implement CodingAgent with no harness behind them: coding.mock replays a script, and coding.noop returns a completed result immediately.

import { coding } from '@animahealth/adk/agents/coding'

const coder = coding.mock({
  responses: [
    { type: 'assistant', text: 'Reading the failing test...' },
    { type: 'tool_call', name: 'write', args: { path: 'src/auth.ts', content: '...' } },
    { type: 'tool_result', name: 'write', result: 'File written' },
    { type: 'assistant', text: 'Fixed.' },
  ],
  artifacts: [{ name: 'summary.md', content: '# Summary\nDone.' }],
  delayMs: 0,
})

const result = await coder.run('Fix the failing test')
result.status                       // 'completed'
result.output.value?.modifiedFiles  // ['src/auth.ts'] — derived from the scripted tool calls

The mock is faithful where it matters for a lifecycle test: it streams real StreamEvents, honors abort(), send({ type: 'abort' }) and an AbortSignal, and can be told to fail on cue.

const flaky = coding.mock({
  responses: [{ type: 'assistant', text: 'Starting...' }],
  simulateError: { after: 1, message: 'harness died', code: 'sdk_error' },
})
// Iterating the handle throws; awaiting it yields status 'error' with that CodingError.

const stub = coding.noop({ name: 'code' })
// A FunctionTool that returns a completed CodingResult with no modified files.

Pair either with a stub WorkspaceProvisioner and you can assert the whole lifecycle — provisioned before constructed, disposed exactly once on the throwing path, scored on the delta — with no filesystem and no model. The rest of the test kit is Testing agents without a model.

Before you build on it

Edges to know about

Experimental is a promise about churn, but these are specific and current. Each is a place the surface reads more complete than it is.

Edge What actually happens
Mid-run steering CodingHandle.send accepts message and tool_response, but the Claude Code agent implements only abort — the others log a warning and do nothing. Treat send as an abort channel until that changes.
Codex The seam is provider-neutral and the interface is the whole contract, but no Codex adapter ships. Bringing one is the build function in step 2, not a fork.
The harness dependency @anthropic-ai/claude-agent-sdk is not declared in the package's dependencies at all, and nothing imports it statically — createClaudeCodeAgent loads it on the first run, so importing the subpath or building a factory costs nothing, and a run without it fails with one clear error naming the package. That lazy import also means npm install will not fetch it for you: install it explicitly alongside the ADK to run a Claude Code node.
Provisioner defaults With no ProvisionerBackends supplied, provision returns an in-process placeholder: a path like /repo/.adk-worktrees/<stamp> that nothing has created, and a dispose that does nothing. Enough to exercise the lifecycle in a test; not enough to isolate a real run. Supply real backends before trusting the word "isolation".
The default delta probe Synthesized from the result's modifiedFiles, not from the filesystem: the diff becomes modified <file> lines and the command result becomes status: completed, so the default score reduces to "did the coding agent claim to touch a file?" — the question the delta exists to stop asking. The default passed predicate is blunt on the other side too: any case-insensitive FAIL in the command result fails the run, so a reporter line reading failures: 0 scores as a failure. Pass a probe, and a predicate that matches your reporter's vocabulary.
Errors from the tool form run() resolves an error-status CodingResult when a run fails, but execute() — the tool form — drains the stream first, so a failing run rejects there instead. Handle both shapes if a coding agent sits in an orchestrator's tools: array.

Where this goes next: the orchestration that uses coding nodes as steps is Dynamic workflows, the metric vocabulary the delta score plugs into is Measuring agents, and the interface a coding agent satisfies to be a tool at all is Tools. If none of that is what you came for, Quickstart builds an ordinary agent in one page.