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.
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.
- Get it on your machine. The next hour moves this program into your editor, recipe by recipe.
- See an agent stop and ask a human. The Bookings sample pauses mid-run and resumes from a stored session.
- Understand what you just ran. Five primitives and one ledger names every shape the runner executes.
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.
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.
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.
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.
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.
-
retryis aRetryConfig:{ maxAttempts, initialDelayMs, maxDelayMs, backoffMultiplier }, plus an optionalretryableErrors(error)predicate that bails out of retrying the errors it rejects. It re-runsexecutewith randomized exponential backoff. The retries are internal — the session ledger gets onetool_resultfor the call, not one per attempt. -
timeoutis 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 carriestimedOut: trueand an error naming the tool and the budget. -
An exception out of
executebecomes that same result event, witherrorset to its message. It is not swallowed and it is not thrown at yourapp.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.
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.
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.
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) |
SpawnHandle — wait(), 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 execute — StepContext 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.
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.
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.
book_slot.
yielded_tool.
bookings.db.
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.
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.
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 2
webSearch, and the seam under it
The model sees a tool called web_search that takes a query and, when
you allow it, a country code. The result is one field: results, an
array of rows in the provider's ranking order.
type SearchRow = {
title: string
url: string
snippet: string
position: number
date?: string
content?: string // only when autoFetch pulled the page in
}
Where those rows come from is a SearchProvider: a name and a
search(query, options). Pass one as provider and the tool is yours —
an internal index, a cached corpus, a different search API. Omit it and the tool builds a
SerperProvider, which reads SERPER_API_KEY
while you are building the tool. No key means no tool: it throws at wiring time, not
on the first model call.
The cell below runs the real web_search tool with a stand-in provider, so it
needs no key and makes no request. Only the provider is fake — the tool, its schema, the
agent, and the ledger are the shipped ones. Watch what the tool hands the provider: the config
you set becomes the provider's options.
import { openai } from '@animahealth/adk/openai'
import { findEventsByType, model, runTest, user } from '@animahealth/adk/testing'
// A stand-in for the search API. Real shape, invented rows — it echoes the options it was handed
// so you can see what the tool's config actually does.
const localIndex = {
name: 'stand-in',
search: async (query: string, options?: { numResults?: number; searchType?: string }) => [
{
title: `stand-in row for "${query}"`,
url: 'https://example.com/adk',
snippet: `asked for ${options?.numResults} ${options?.searchType} results`,
position: 1,
},
],
}
const searchTool = app.tools.webSearch({
numResults: 3,
searchType: 'news',
provider: localIndex,
// The country field leaves the schema the model is shown, and a country sent anyway is
// stripped before execute sees the arguments.
allowCountryOverride: false,
})
const researcher = app.agent({
name: 'researcher',
model: openai('gpt-5.6-luna'),
context: [app.context.system('Search before you answer.'), app.context.history()],
tools: [searchTool],
})
const searchRun = await runTest(researcher, [
user('What has the ADK shipped lately?'),
model({ toolCalls: [{ name: 'web_search', args: { query: 'anima adk release' } }] }),
model('Here is what the search turned up.'),
])
const searched = findEventsByType(searchRun.events, 'tool_result')[0]?.result
searched
The rest of the config is a short list.
-
numResults(default 10),searchType('web','news', or'images', default'web'), andcountryare passed to the provider, which decides what to do with them. -
allowCountryOverride(default true) decides whethercountryis in the schema at all. A configcountryis the fallback when the model does not send one. -
autoFetchfollows the topautoFetchTopresults (default 3) with the fetch machinery of the next section and copies each page's markdown onto that row'scontent.autoFetchRender,proxy, andpipelinessteer those fetches. It is one tool call and several page loads — budget for it.
Wiring the shipped provider is two lines, and the key can come from your own config rather than the environment.
import { SerperProvider, webSearch } from '@animahealth/adk/web'
// Reads SERPER_API_KEY when the argument is omitted; either way it throws here, not later.
const search = app.use(webSearch({ provider: new SerperProvider(process.env.SEARCH_KEY) }))
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.
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:
-
stdio —
command,args,cwd, andenv. Theenvrule is worth knowing: supply it and the child gets your wholeprocess.envwith 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 inenvrather than assuming inheritance. -
HTTP and SSE —
url,headers, andauthorization.authorizationis a convenience that sets one header; anything else (an API key header, a tenant id) goes inheaders. -
Both —
timeoutin 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.cacheResourcesListandcachePromptsListdo 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 onbeforeExit,SIGTERM, andSIGINT, so a Ctrl-C does not leave annpxbehind.
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.
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.
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.
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.
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:
-
beforeAgentreturning a string replaces the run's output. Use a braced body when you only meant to log. -
afterTurnfires only underapp.handler.turn, plus therestandaguihandlers that delegate to it. Never under a bareapp.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:
throwaborts the run with the original error.skiprecords the error and carries on.abortends the invocation cleanly.retry, with an optionaldelay, runs the same step again.fallbacksubstitutes a result as if the step had succeeded.passdeclines, 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, thenpass.-
rateLimitHandler— a retry handler that only fires on rate limit, 429, or too-many-requests messages. -
timeoutHandler— matchestimed out, thenfallbackif you gave it afallbackResult, otherwiseskip. 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.
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.
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.
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 Hook — beforeAgent,
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.
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.
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/adkno longer fails withERESOLVE— the optional peer ranges now co-resolve (reactwidened to^18 || ^19,inkpinned to the tested^5,ink-text-inputto^6), and@anthropic-ai/claude-agent-sdkleft the peer list (its published versions requirezod@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 requiresopenaiand@ag-ui/coreto 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/testinguses the SDK-freeopenaimodel 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 firstimportfailing. - Concurrent first operations on a lazily-opened store no longer construct two instances —
SQLiteStoreand 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. sqliteVecfiltered search no longer returns empty when every match ranks beyond its overfetch window — the KNN window now widens untiltopKmatches are found or the collection is exhausted.- Event dedup is now uniform across session stores:
InMemoryStoreskips already-stored event ids like the SQL stores, and the SQLite/Postgres stores no longer assign a duplicateidxwhen a committed batch overlaps stored events (which leftORDER BY idxunspecified). 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.loadScopedStateno longer crashes on string-valued state ('dark') — the JSONB driver already decodes values, and the redundantJSON.parsethrew 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.pgvectorreads (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:
DynamoDBStoreandPostgresStorerun the session-store suite andpgvectorthe vector-index suite against service containers in CI (the suite fixtures are now hygienic across tests on shared backends). Theqdrantindex deliberately does not run the vector suite — it is provisioning-based (collections and named-vector sets are fixed at creation viacollectionSpec()), where the suite encodes lazy creation. composeHooks,loggingHook,metricsHook, andcliHookare 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 withuser('hi'). Previously a string produced a response with no.text: the adapter emitted nothing and the run "passed" with the reply silently gone.OpenAIAdapteris now exported from@animahealth/adk/openai— the documentednew OpenAIAdapter(endpoints)+adk({ adapters: { openai } })seam for programmatic endpoint injection was previously unreachable (the class was not exported anywhere).OpenAIEndpointgainsdangerouslyAllowBrowser— 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-onlyclaudeCode/mockCodingAgentaliases — usecreateClaudeCodeAgent/createMockCodingAgentfrom@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), thesqliteIndex()vector provider, and thebetter-sqlite3/sqlite-vecoptional 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 assqliteVec, notsqliteIndex).
Added
- SQLite session store restored —
SQLiteStore/sqliteStore(dbPath)return at@animahealth/adk/stores/sqliteover the optionalbetter-sqlite3peer (>=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 formemory({ index })likeqdrant(…)/pgvector(…), over the optionalbetter-sqlite3+sqlite-vecpeers (vec0 virtual tables, cosine metric). Successor to the removedsqliteIndex(); where the old provider's no-variantscroll/countread only thedefaultvariant,sqliteVecfollows 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 andsqliteVecboth run it.
2026-08-20
0.5.27
Added
- OpenAI explicit prompt caching — configure
OpenAIModel.promptCacheand mark the stable prefix withapp.context.cacheableUser(...); Responses API cache reads and writes are exposed throughModelUsageandUsageSummary.
2026-06-08
0.5.25
Republish of 0.5.24 with a packaging fix — no API or runtime changes.
Fixed
- Published manifest —
@types/nodenow publishes as a concrete range (^22.19.19) instead of the raw pnpmcatalog:token. 0.5.24 shipped"@types/node": "catalog:", which brokepnpm pack/pnpm installfor consumers that vendor the ADK outside the Serenity workspace (e.g. the LiveKit voice agent deploy) withERR_PNPM_CATALOG_ENTRY_NOT_FOUND_FOR_SPEC.
Internal
- Publish pipeline —
adk-publish.ymlnow packs withpnpm pack(which resolvescatalog:/workspace:specifiers) and uploads the resulting tarball withnpm publish, so the published manifest no longer leaks workspace-only specifiers while keeping npm OIDC trusted publishing. Publishing the source directory withnpm publishshippedpackage.jsonverbatim, which is how thecatalog: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/workflow—runWorkflowFile()runs a CC-style workflow file throughapp.run, bindingagent()to a configurable node runner (defaultapp.ask; aCodingAgentover a provisioned workspace for build attractors).app.ask(prompt, opts)— terse, typed one-shot LLM call (no tools, fresh session); options typed asAskOpts.fanout(thunks, { limit })— capped isolated concurrency; a failed thunk resolves tonull.AnnotationEvent+ctx.note()— generic progress events (phase()/log()are sugar overctx.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-sessionDonepath ran. - Voice handler — fixed
shutdownProcessTimeoutunit 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 —
beforeAgentreturning 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()plusreply.waitForPlayout()from inside tool execution, including LiveKit awaitable speech handles.
Changed
- LiveKit voice peers — raised
@livekit/agentsand provider plugin peer floor to^1.4.4, and bumped@livekit/rtc-nodeto^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-compatibletoolChoice: "required". - Voice output completion telemetry — added
output_tool_completion_started,output_tool_completion_succeeded,output_tool_completion_failed,forced_tool_correction, andforced_tool_failurevoice events. - Voice diagnostics — eval reports now include forced-tool and output-completion timelines with timestamps relative to case start.
- Voice errors — exported
ForcedToolCallErrorandOutputToolCompletionErrorfrom@animahealth/adk/voice. - Durable intent — added
packages/adk/intent/voice-forced-tool-gating/spec.mdfor 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
beforeToolhooks, 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_calland 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 exposescontrol.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
onExpiryhooks 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=1in addition toVOYAGE_API_KEY, keeping default test and publish runs offline.
Internal
- Package publishing — Serenity
packages/adkis 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 soonEnterreplies 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; usecallTermination: falseto 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, andundefinedtool results stayundefinedinstead of being coerced to an empty string.
2026-03-26
0.5.16
Changed
waitForPlayout— added toToolExecutionContextandMockToolContext; removed broken session-levelVoiceSession.waitForPlayout(). Usectx.waitForPlayout?.()in tools,reply.waitForPlayout()in lifecycle hooks.
Fixed
dynamoStore— unusedExpressionAttributeNameson non-create commits causedValidationException.dynamoStore— scoped-state pk separator changed from_to#to prevent collisions whenscopeIdcontains underscores. (unused in production currently)
2026-03-26
0.5.15
Fixed
dynamoStore— support custom key schemas viapartitionKey/sortKeyconfig options (defaults topk/skfor backwards compat).
2026-03-24
0.5.14
Fixed
- Coercion parser — use
_def.typeNameinstead ofinstanceofso 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 toundefined.
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 —
resolveSessionusedagent.nameinstead of the app name;app.sessions.get()could never find handler-created sessions.HandlerConfig.appNameis now required (app.handler.*injects it automatically).
Removed
session.truncateAt()— brokecommitSession(cursor divergence). Usesession.forkAt()instead.
2026-03-20
0.5.11
Added
session.truncateAt(eventIndex)— truncate event history in-place; unlikeforkAt, 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 fielddata→inputto matchToolInputconsumed byapplyInput().
Added
RestResponse.state— rest handler returns session state whenresponse.stateis 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 upstreamcurrentGenerationthrows gracefully.
2026-03-16
0.5.8
Added
toolMocks— output tools (agent.output) are now intercepted the same as regular tools; unmocked output tools throwEvalToolErrorwhentoolMocksis 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,/qdrantinstead 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()→ asyncimport()so bundlers can tree-shake unused providers. sqliteIndex(dbPath)— now returnsPromise<VectorIndex>; callers mustawait.
2026-03-08
0.5.4
Added
app.hook.voice()— typed entry point for custom voice hooks; acceptsPartial<VoiceHook<S>>and returnsVoiceHook<S>. Mirrorsapp.hook()for standard hooks.VoiceHook.onTranscript— fires for each user or assistant transcript message with full context (session,state,voice,event) andctx.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. Usectx.voice.turnCount > 0to determine if the caller has engaged.- Output tool auto-trigger — when an agent has an
outputtool andturnCount > 0, the ADK automatically triggers it viagenerateReply({ toolChoice: 'required' })on lifecycle events (disconnect, inactivity, expiry). Hooks no longer need to manually force the output tool. ctx.end()— synchronous control signal (likectx.output()) that triggers the agent's output tool via the model. Return it from a tool'sexecuteto end the session with model-generated output:return ctx.end(). Voice mode triggersgenerateReply; text mode setsendInvocation.ctx.run()/ctx.spawn()/ctx.dispatch()in voice mode — voice tools can now run text-mode sub-agents inline. Backed byBaseRunner; 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 instateSchemaare 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 insetup(). Overrides handler-levelsound.noiseCancellationwhen present.app.evaluate.report(options?)— factory that returns(result) => string. Configure once, call with any eval result. Replacesapp.report(result, options).app.evaluate.voice.report(options?)— voice-specific report factory with narrowed types;renderCasereceivesVoiceEvalCaseResultwithrun.transcript,run.timing,run.recording.BaseEvalCaseResult/BaseEvalResult— shared base types for text and voice eval results;EvalCaseResultandVoiceEvalCaseResultboth extendBaseEvalCaseResult.ReportOptions<S, R>— now generic over result typeR;renderCase,sections, andfootercallbacks receive the correct result/case types.MetricResult.data— optionalRecord<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; seedssession,user,patient,practice,org,teamscopes in one call.EvalOptions.repeat/VoiceEvalOptions.repeat— run each case N times; results carryrepeatIndexandrepeatTotalmetadata. Reports auto-group repeated cases with pass-rate summaries.BaseEvalCaseResult.repeatIndex/.repeatTotal— present whenrepeat > 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>(andAgentConfig,OutputConfig,RunResult,AgentSpec);Agent<unknown, MySchema>simplifies toAgent<MySchema> ctx.output()in voice mode — output signal is ignored whenturnCount === 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 ofcompleted.SoundConfig.noiseCancellation—boolean | unknown→'general' | 'telephony'. Resolves toBackgroundVoiceCancellationorTelephonyBackgroundVoiceCancellationfrom@livekit/noise-cancellation-nodeinternally. Replacetruewith'general'.VoiceEvalOptions.room— now optional; defaults toLIVEKIT_URL/LIVEKIT_API_KEY/LIVEKIT_API_SECRETenv 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 typedsessionaccess.
Removed
app.report()— useapp.evaluate.report()insteadrepeatCases()— useEvalOptions.repeat/VoiceEvalOptions.repeatinsteadparseRepeatName()— 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
beforeEndcallback 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
onInactivityhooks, 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)— runsVoiceEvalCase[]through LiveKit rooms with real audio; returnsVoiceEvalResultwith per-case transcripts, timing, recordings, and metricsVoiceEvalCase— defines agent under test, simulateduserAgent, optionaltoolMocks, per-casemetrics,retries, andtimeoutVoiceEvalOptions— suite config:room(LiveKit URL + credentials),outputdirectory for per-case reports and recordings,concurrency,stopOnFirstFailure,onCaseprogress callback, and suite-levelmetricsandhooksVoiceRunResult— run output withtranscript: TranscriptEntry[],timing: VoiceTiming,recording: { path },events,session,usage, anddurationMsVoiceTiming— timing data:timeToFirstSpeechMs,responseTimes,silenceGaps,interruptions(count, byAgent, byUser),vadResolutionMsvoiceTimingMetric(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_countcreateSpeakerTracker()— tracks agent/user speech segments for transcript and timing computationTranscriptEntry—{ role, text, startMs?, endMs?, turnIndex }ToolContext.waitForPlayout— opt-in helper for tools that need to wait for pending agent speech to finishVoiceHook.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 noonEnterhook is defined
Changed
- Voice handler
onEnter— no longer auto-speaks when no hooks are defined; add an explicitonEnterhook to greet the caller VoiceEvalOptions.recording→VoiceEvalOptions.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; useonEnterfor ephemeral instructions
2026-03-02
0.5.2
Internal
- Fix ESM
require()shim — injectcreateRequirebanner in tsup ESM output so lazyrequire()calls resolve correctly (fixes"Dynamic require of X is not supported") - Remove
lodashdependency —isEqual→node: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 (withstt+tts) falls through to the standard adapter@animahealth/adk/voicesubpath — LiveKit voice handler;ctx.voice(VoiceSession) available in tools forgenerateReply,waitForPlayout,interrupt;app.handler.voice(config)on the handler namespaceOutputConfigaccepts aFunctionTool— passapp.tool()asagent.outputfor tool-injection output mode; framework injects the tool, validates args, runsexecute, captures output, and manages shutdownVoiceHook— extendsHookwithonInactivity,onExpiry,onDisconnectlifecycle callbacks; defined inside the unifiedhooksarray onVoiceHandlerConfig. Returnfalseto keep session alive,trueto explicitly end. Multiple hooks compose: anyfalsevetoes endVoiceHandlerConfig.timeouts— handler-level{ inactivity?, expiry? }defaults; per-agenttimeoutsoverride them after transfersVoiceSession.generateReply()accepts fullToolChoice— adds'required'and{ name: string }for forcing specific tool calls in lifecycle hooksAgent.yields— yield for user input after terminal model output instead of completing; defaults totruefor realtime models.Agent.maxTurnscaps yield/resume cycles (default: 100; producesstatus: 'max_turns')Agent.greeting— string or(ctx: GreetingContext) => string | Promise<string>; injected as system event on first activation only, not on resumeAgent.timeouts—{ inactivity?, expiry? }in ms; producesstatus: 'inactivity_timeout'orstatus: 'max_duration'ctx.output(value)onToolContext,ToolExecutionContext,StepContext— ends the invocation early; value becomesRunResult.output.valueadk({ store })— optionalSessionStoreon the app factory; defaults to in-memory.app.sessionsexposescreate,get,delete,list,commit,mergepre-bound to the app nameinMemoryStore(),dynamoStore(),sqliteStore(),postgresStore()— store factory functions; class constructors deprecatedUsageSummary.models— per-modelModelUsageEntry[]withcalls, token counts, and per-modelcost.ModelUsagegainsaudioInputTokens,audioOutputTokens,audioCachedTokensfor realtime modelsUserEvent.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(),CallResult→SubRunResult,CallResultTransfer→SubRunResultTransfer— old names are deprecated aliases until 0.6.0AgentTimeouts.maxDuration→AgentTimeouts.expiry(deprecated aliasmaxDurationstill works)ModelEndEvent.modelName→ModelUsage.modelName— model name now lives on the usage object; code readingevent.modelNameonmodel_endevents must switch toevent.usage?.modelNameHook.afterAgent— output parameter widenedstring→unknown; typed hooks with explicitstringsignatures need updatingHandlerConfig.sessionService— now optional; handlers auto-inherit the app's session servicecalculateCost(usage, modelName)→calculateCost(usage)— model name read fromusage.modelNameformatCost— sub-dollar amounts usetoFixedinstead oftoPrecisionRunStatus—'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 asRunStatus;InvocationEndReasonis now a strict subset ofRunStatusInvocationState— limit end reasons (max_steps,max_turns,max_duration,inactivity_timeout,disconnected,participant_left) map to'completed'instead of their raw reason stringBaseRunner—sessionServiceconstructor 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; thefindYieldedNodesscan already handles "nothing to resume"
Deprecated
AgentTimeouts.maxDuration— useexpiryinstead. Will be removed in 0.7.0.sessionService()factory — useadk({ store })andapp.sessionsInMemoryStore,DynamoDBStore,PostgresStore,SQLiteStoreclasses — useinMemoryStore(),dynamoStore(),sqliteStore(),postgresStore()app.session()— useapp.sessions.create()- Standalone
turn()— useapp.handler.turn()
Removed
ModelEndEvent.modelName— useModelUsage.modelNamegemini-2.5-flashhigh-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; returnsEvalResult<T, S>with app stateSand optional per-case contextTapp.report(result, options)— generates markdown fromEvalResult; optionaltitle,footer(string or function),sections, andrenderCasefor custom contentEvalCaseResult.run— fullRunResult(session, usage, output) preserved for reporting and metrics; convenience accessors.events,.usage,.turnsEvalOptions.onCase— progress callback(result, index, total) => voidcalled after each case completesEvalCase.retries— number of additional attempts for flaky cases; retries onfailed,error, ortimeoutEvalCase.timeout— per-case timeout in ms; producesstatus: 'timeout'on expiryTestOptions.maxTurns— replacesmaxIterationsfor consistency withSimulateOptionsRunResult.state— typedTypedState<S>accessor (same type asresult.session.state); symmetric withinput: { state }on the input sideAssistantEvent.media— optionalMediaPart[]field; eliminates the need to cast when accessing media on assistant eventsSession.boundState(invocationId)— returns invocation-scopedTypedState; useful in custom context renderersSession.onStateChange(callback)— registers a state change observerSession.getSpawnedTaskStatus(id),Session.getRunningSpawnedTasks(),Session.getAllSpawnedTasks(),Session.waitForSpawnedTask(id),Session.waitForAllSpawnedTasks(),Session.hasRunningSpawnedTasks()— spawned task observation from hooks and toolsVectorFilternested filters —must,should,must_notarrays now accept nestedVectorFilterobjects 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 typesSlicedSubset<TSlices>— type for the object returned bymem.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
partialused on parse failure when it passes schema EvalCase— composes shared fields withSimulateOptionsviaPick;initialState+firstMessagereplaced by standardinput(string or{ message, state })EvalCaseResult— nowEvalCaseResult<S>withrun: RunResult<unknown, S>;tokenUsage→usage(typeUsageSummary);eventsandturnspreserved as convenience accessorsMetric.evaluate— signature(events: Event[])→(run: RunResult); events available asrun.session.events; built-in metric factories updated- Eval error shape —
erroronEvalCaseResultis now{ message: string; stack?: string };EvalErrorinterface andphasefield removed - Eval status mapping —
abortedrun status now maps toEvalStatus: '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-levelhooksanderrorHandlers; handler-level config composes after app-level (app hooks run outer, handler hooks run inner)app.cli— now inherits app-levelhooks,errorHandlers, andappNamefor session creation; CLI-level hooks compose after app-level; respects user-providedrunneroverride- Untyped state scopes return
unknowninstead ofany— accessing properties on scopes without a schema (e.g.state.user.namewhen nouserschema is defined) now requires explicit narrowing mem.variantrenamed frommem.variants- Memory internal key separator
#→_— vector names (model#variant→model_variant), metadata prefixes (_variant#→_variant_,_slice#→_slice_); existing collections must be re-indexed ModelStartEvent—messages: ContextMessageSummary[]→messageCount: number;serializedSchemaremoved. The CLI reconstructs exact context on-demand viasession.forkAt()+buildContext()when the user expands a context block. Eliminates O(n²) storage of repeated context snapshots.SlicedMemory.slices→SlicedMemory.slice— singular accessor for per-slice operations (mem.slice.medication.search());slicesis now the subset methodeventCountMetric/eventSequenceMetric—filtercallback infers narrowed event type fromeventType; casts no longer needed.Metric<S>threads the app's state schema throughrun.state
Internal
- Voice lifecycle state machine (
idle → active → ending → ended) with atomictryEnd()— first caller wins, concurrent shutdown events are safely ignored - ADK-owned inactivity timer replaces LiveKit
userAwayTimeout— supports repeated firings andinactivityCountreset on user speech
Removed
Simulatortype — removed from main package andadk/evalexports (was the literal signature ofapp.simulate)llmJudge/LlmJudgeConfig— removed fromadk/eval; implement theMetricinterface directly with your own judge agent (see migration below)runEval/runEvalSuite/EvalSuiteConfig— useapp.evaluate(cases, options)EvalError— useerror?: { message: string; stack?: string }onEvalCaseResultEvalCaseResult.tokenUsage— useresult.usage(UsageSummary)EvalCase.initialState/EvalCase.firstMessage— use standardinput(stringor{ message, state })TestOptions.maxIterations→TestOptions.maxTurnstoolCallCountMetric— useeventCountMetricwitheventType: 'tool_call'and afilterdurationMetric— usetimingMetricwithmeasure: 'total_duration'modelLatencyMetric— usetimingMetricwithmeasure: 'model_latency_average'timeToFirstResponseMetric— usetimingMetricwithmeasure: 'time_to_first_assistant'
Migration from 0.4.x
EvalCase: initialState / firstMessage → input
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); returnsStreamResult<TurnResult>withinvocationIdon the stream; use for custom projections (Slack, cron, CLI) without duplicating persistenceHook.afterTurn— turn-level lifecycle hook that runs within thehandler.turncommit boundary (after run completes, beforecommitSession); state mutations are included in the commit atomically; receivesTurnContextwith session, result, and runnableTurnContext— context type forafterTurn; provides writable session,RunResult, and the runnableCommitStatus,TurnResult—TurnResultextendsRunResultwithsessionId,invocationId, optionalcommitStatus('committed' | 'merged' | 'skipped' | 'orphaned')RunConfig.invocationId— optional root invocation ID; when set, runner uses it instead of generating one (enables traceability with AG-UIrunId)RunConfig.errorHandlers— per-run error handlers, composed after runner and agent handlers (mirrorsRunConfig.hooks)sqliteIndex()— SQLite vector index provider viasqlite-vecwith auto-provisioningvoyage()sagemakeroption — SageMaker endpoint with automatic fallback to Voyage API; each path retries independentlyVectorCondition.range— string bounds for datetime range filtering across all providersVectorCondition.text— case-insensitive text matching on string metadata fields;containsacceptsstring | string[](array = OR)SearchOptions.contains— shorthand for text matching against stored contentCollectionSpec.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 viaFilterInputslicesconfig onmemory()— heterogeneous collections with per-slice typed metadata;records.slices.medication.search()returnsSearchResult<MedicationMeta>,records.search()returns a discriminated union withmatch.kindfor narrowing (renamed torecords.slicein 0.5.0)SlicedMemory,SliceAccessor,SlicedMatchUnion,SlicedSearchResult— types for sliced memoryCollectionSpec.payloadIndexes— auto-populated with_slice#kindwhen slices are declaredMatch.kind— optional; present when the document belongs to a slicebetter-sqlite3,sqlite-vec,@aws-sdk/client-sagemaker-runtime— optional peer dependencies
Changed
handler.agui— delegates toturn; events stream live (no buffering until commit); AG-UIrunIdis the turn’sinvocationId;RUN_FINISHEDresult payload includescommitStatusfor reconciliationhandler.rest— delegates toturninternally; external contract (buffered JSON response) unchangedresolveConflictreturn type —ConflictOutcome→CommitStatus(same values, adds'committed'for happy path)UpsertItem.content— now required; content is stored alongside vectors and returned asMatch.content- Upsert metadata — merge semantics across variants instead of replace
CollectionSpec.textIndexes— Qdrant users should provision text indexes from this field to enableSearchOptions.containsVoyageModel.dimensions— now required;voyage('voyage-4')→voyage('voyage-4', { dimensions: 1024 })MemoryConfig.variant→MemoryConfig.variants— singular string replaced by string array; omit for implicit['default']mem.variant('name')→mem.variants.name(changed tovariantin 0.5.0) — dynamic method replaced by upfront property mapcollectionSpec(config, variants)→collectionSpec(config)— variants now read fromconfig.variants- Internal metadata prefix
_content#→_variant#;_slice#reserved for slices
Removed
createRunId()— useturn(config, input).invocationId(or the root invocation ID from the stream) as AG-UIrunIdConflictOutcome— replaced byCommitStatus(import from handler or runtime types)vectorKey()— no longer public; usecollectionSpec()insteadEmbedResult,Point,VectorMatch,DistanceMatrixPair,DistanceMatrixResult— removed from top-level exports (importable from@animahealth/adk/memoryfor custom providers)MemoryContextConfig,MemoryToolConfig— removed aliases; use inlinemem.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-agnosticEmbedder/VectorIndexinterfacesvoyage()— Voyage AI embedding provider with batching (128/request), automaticinputTyperouting, retryqdrant()— Qdrant vector index provider with retrypgvector()— pgvector vector index provider (PostgreSQL) with auto-provisioning, HNSW indexing, retryinMemoryIndex()— in-memory vector index with real cosine similarity for testing and prototypingmem.context()— returnsContextRendererfor deterministic recall before reasoningmem.tool()— returnsFunctionToolfor agent-driven recall via tool callmem.search()— returns typedMatch<TMetadata>[]and computed embedding for downstream forwardingmem.upsert()— batch-aware write acceptingcontent(embeds) or pre-computedembedding; validates dimensionsmem.updateMetadata()— merge metadata without re-embedding;nulldeletes keysmem.variant()— named vector variants sharing collection, schema, and providersmem.sample()— representative sampling via density-weighted farthest-point selection; optional query-focused mode with gravityvectorKey()— exported so provisioning scripts, Terraform generators, and migration jobs can compute the samemodel#variantvector names the ADK uses internallycollectionSpec()— computes collection vector specifications from memory config for provisioningrepresentativeSample(),estimateDensity()— exported sampling utilities for custom workflowsEmbedder,EmbedResult,VectorIndex,Match,Point— exported types for custom provider implementationsvoyageai,@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 everyEvent/StreamEventmember SimulateYieldContextexported from main entry point
Changed
SimulateYieldContext,Transform,SimulateOptions— now generic overTArgs(defaults tounknown) soTransformcallbacks can typectx.argswithout casting
2026-02-11
0.4.3
Changed
- Eval, run, test, simulate, CLI, and handlers —
Runnable/Hookat orchestration boundaries widened toRunnable<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, typesSimulateOptions.maxTurns,.maxDuration,.stateMatches— flat termination fields replacingmaxIterationsRunStatus: 'terminated'withterminationReason: TerminationReasonon the resultSimulateOptions.userAgentis now optional — tool-only flows no longer need a stub
Changed
SimulateOptions.maxIterations→maxTurnsEvalSuiteConfig.parallel: boolean→concurrency: number(defaults toInfinity; use1for sequential)runEval/runEvalSuite— first arg is now aSimulatorfunction (passapp.simulate)
Removed
SimulateOptions.maxIterations— usemaxTurnsEvalSuiteConfig.parallel— useconcurrency
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, mirroringapp.context()createEventId/createCallId— exported from public API
Changed
ToolYieldEvent.preparedArgs→ToolYieldEvent.argsyieldedTools— returnsToolYieldEvent[]instead ofToolCallEvent[]- CLI — pending yields show enriched
tool_yieldargs instead of rawtool_callargs - 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 nowreadonly; context renderers must return new objects instead of mutatingRunResult.status—'yielded'→'yielded_tool','input_required'→'yielded_message'; each branch of the discriminated union carries only its relevant fieldsapp.run()/app.test()/app.simulate()/ctx.call()(renamed toctx.run()in 0.5.1) /ctx.spawn()— typedAgent<TOutput>overloads that preserve output type through to the resultImageSource/AudioSource/DocumentSource→MediaSource;MessageInput.images/.audio→media: MediaPart[]ToolInput/ResultInput→ToolInput { callId, input };session.input.tool()now handles both tool yields and tool call resultsTestOptions—messages→userHandler,tools→toolHandlers;SimulateOptions/EvalCase—simulator→userAgent,tools→toolAgentsHook | Hook[]→Hook[]— hooks options now only accept an array; wrap a single hook in[hook]CallOptions/SpawnOptions/DispatchOptions→HandoffOptionsFunctionToolHookContext→ToolExecutionContextRunResultOutput<T>→Output<T>— canonical output shape shared byRunResult,Session,CallResult(renamed toSubRunResultin 0.5.1),SpawnResultRunInput/BaseInput→Input— canonical input shape withmessage,tools, andstatefieldsHandlerInput,RunOptions,TestOptions,SimulateOptions— payload fields grouped underinputnamespace;HandlerInput.inputusesInputdirectly;toolInputs→input.tools;messagewidened tostring | MessageInputRestResponse.output— uses canonicalOutputtype; opt-inevents,usageviaHandlerConfig.responseRestResponse.yieldedTools— yielded tools promoted to top-level field (replacestoolCall/toolCalls)session.pendingYieldingCalls→session.yieldedTools;result.pendingCalls→result.yieldedTools;pendingCallIds→yieldedToolIds
Removed
result.response— useresult.output.valueorresult.output.textresult.awaitingInput— checkresult.status === 'yielded_message'ImageSource,AudioSource,DocumentSource,ImageInput,AudioInput— useMediaSource/MediaPart[]ResultInput,session.input.result()— useToolInput/session.input.tool()CallResultOutput,CallOptions,SpawnOptions,DispatchOptions,FunctionToolHookContext,ToolHookContextRunResultOutput— useOutputRunInput,BaseInput— useInputSessionOutputNamespace—session.outputnow returnsOutputdirectlyAdkRunConfig— useRunOptions
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
safeParsefailure — feed validation errors back astool_resultinstead 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
Hookinterface — unified observation (onEvent,onStep) + interception (before*/after*)app.run()acceptsRunOptionswithstateand call-sitehooksapp.test()— deterministic yield/resume testing (replacesscriptedUser())app.simulate()— LLM-powered eval loop (replacesagentUser())app.hook.logging(),app.hook.metrics()— built-in hook factories.app.handler.rest()/app.handler.agui()— protocol handlers withHandlerInput/HandlerConfigSessionStoreinterface withsessionService(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 duringcommitSession()
Changed
- **
runner.run()no longer commits sessions** — callers must callsessionService.commitSession()(built-in handlers do this automatically) Middleware/Hooks→ singleHookinterface;onStream→onEventAgent.middleware+Agent.hooks→Agent.hooks: Hook[];AdkConfig.middleware→AdkConfig.hookscomposeMiddleware()→composeHooks();loggingMiddleware()→loggingHook();cliMiddleware()→cliHook()session.versiontype:string→number;SessionStoreSnapshot→StoredSession
Removed
src/users/—scriptedUser(),humanUser(),agentUser(),Userinterface (useapp.test()/app.simulate())src/middleware/— replaced bysrc/hook/InMemorySessionService,LocalSessionService— usesessionService(new InMemoryStore())- Per-scope methods (
getUserState, etc.) — usegetScopedState/setScopedState HandlerInput.toolInput— usetoolInputsarray@animahealth/adk/persistencesubpath — 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()— transparentimport()wrapper so CJS consumers work unchanged extractCurrentThoughtBlock,buildInvocationBlocks→ exported from@animahealth/adk/cliinstead of main entryreact,ink,ink-spinner,ink-text-input— optional peer dependencies forapp.cli()consumers
Internal
tsup.config.tsnow includes an esbuild plugin that externalizes../cliin the CJS build, sodist/index.jsemitsrequire("./cli/index.js")instead of inlining the CLI module tree.scripts/postbuild-cli-cjs-wrapper.cjsgenerates a CJS→ESM wrapper atdist/cli/index.jsthat doesimport('./index.mjs')to load Ink in native ESM context.lodashis force-bundled (noExternal) to avoid Node ESM's "Named export not found" error when importing CJS-only packages.- Jest
moduleNameMappermocks added forinkandink-text-inputsince they are ESM-only and cannot berequire()'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 withnameandschemaapp.*methods for all runnables with automatic type inferenceapp.context.*namespace for context renderersapp.tools.*namespace for built-in tools: -webSearch()— web search via Serper API -fetchPage()— fetches web pages, PDFs, and images as markdown -takeScreenshot()— captures webpage screenshotsapp.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 serversserver.*instance API: -tools()— callableFunctionTool[]-toolDefinitions()— rawMCPToolInfo[]-resourceDefinitions()—MCPResourceInfo[]-promptDefinitions()—MCPPromptInfo[]-resource(uri)/prompt(name)— context rendererssession.input.*namespace for input operations: -message()— user messages (text and multimodal) -tool()— user input for yielding toolssession.output.*namespace for output operations: -text— last assistant text -items— all assistant events -tool()— provide tool results (superseded bysession.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.mediaand tool__mediareturn pattern MediaParttype for image, audio, and document attachmentsImageInputandAudioInputhelpers:{ 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 ofoutput: output(schema, 'key') - State API: method-based → property access - Session state is now the default scope:
ctx.state.mode(notctx.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 }) UserEventstructure simplified: -text: string— always the text message -media?: MediaPart[]— optional attachments (images, audio)
Removed
BaseRunner— useapp.run()instead- Standalone factories and context renderers — use
app.*methods - Method-based state API (
get,set,delete,toObject) initialStatefromCreateSessionOptions— usesession.state.update()session.addMessage()— usesession.input.message()insteadsession.addToolInput()— usesession.input.tool()insteadsession.addToolResult()— usesession.output.tool()(superseded bysession.input.result()in 0.3.3)session.append()— usesession.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.
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.
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.
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.
-
requiredis preserved exactly as written — a strict subset. Properties not listed become.optional(). It is never widened to every key, never emptied. -
additionalProperties: falsebecomes a strict object that rejects extra keys. -
enummembership is enforced whatever the declaredtype; a non-string enum becomes a union of literals. -
string,number,integer,boolean,null, and array element types are enforced, never collapsed toz.any(). Nested objects inside arrays keep their ownrequired. -
A node with no
typeand noenumstaysunknown— 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.
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.
{ path, dispose, isolation }. A failure here — or a backend that yields no
path — throws before any coding agent exists, so a bad path costs zero harness
calls and zero tokens.
factory.create({ workspace, signal }). The node body never
names a harness constructor, which is what makes the swap in step 2 real rather than
aspirational.
node.run — while the workspace still exists.
finally, exactly once, whether the coder returned, threw, or was
aborted. Guarded by a flag, so a retry path cannot double-dispose.
./eval metric receives { diff, commandResult } — and only
that. See step 5.
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.