Deterministic tests
Did the tool wait for approval?
Script the model's turns while your tools, context renderers, and state updates execute for real. Assert on the resulting events and values without a model API key.
Anima Agent Development Kit
Define your state schema once. Choose what each agent sees and which decisions belong in code. Let work pause for a human and continue in a later request. Anima ADK brings these choices together in a TypeScript library for text and voice agents.
Run a live agent in your browser with your OpenAI API key.
One application, different perspectives
A booking assistant and an availability specialist need the same proposed slot. They do not necessarily need the same conversation. In ADK, application state and model context are separate: agents work with a common data contract, and each agent has its own context renderers.
Application state · defined once
const app = adk({
schema: {
session: {
slot: z.string().default(''),
status: z.enum(['proposed', 'booked', 'declined']),
},
},
})
Tools write typed results. Agents and renderers read the same declared fields.
Availability specialist sees
The requested time and its own lookup results.
context: [instructions, ownHistory]
Booking assistant sees
The proposed slot, booking instructions, and its own conversation.
context: [slotFromState, ownHistory]
The app infers state types across tools, agents, steps, and renderers. Context starts empty before each model call; your renderers add instructions, selected history, and state. Keep a specialist focused, or deliberately share the wider session history.
Inspect both agents' context · Explore typed state · Compose context renderers
Control where you use AI
Use a model to interpret a request, reason through ambiguity, or hold a conversation. Use TypeScript to enforce a rule, select the next action, or decide what enters the model's context. You choose that balance for each part of your application.
Compose agents with steps, sequences, parallel branches, and loops. A step can execute an action directly or choose the next agent without a model call. A context renderer can select exact state fields and session events before each call. You can also let an agent choose among the tools you provide when the task needs that flexibility.
Hooks give you the same choice inside an agent's loop. A beforeModel hook can
supply tool calls directly, bypassing the model while the runtime still validates arguments
and executes the tools. Let other turns use the model as usual. You can make a specific action
deterministic without turning the whole agent into a fixed sequence.
At Anima, this lets us put known decision rules in code and use models where interpretation is useful. An approval requirement can be a check that blocks execution, rather than an instruction we rely on the model to follow. As a workflow becomes better understood, more of its decisions can move into code while using the same state and session.
Run a deterministic check and router · Use hooks to run tools without a model call · Choose model context in code · Combine code and agent delegation
Work that continues
A tool can ask for approval or missing information before it executes. The run returns the question, and a handler persists the session. A later request supplies the answer and continues the work, even in a different process or Lambda invocation.
You choose the persistent store and deployment. ADK records the pending work; it does not require a hosted orchestration service. The same session ledger records tool results and tracked state changes, so you can inspect how the application reached its current state.
Run the Bookings sample across two processes · Serve one turn per request
Text and voice
A voice agent can use the same booking lookup, state schema, and session ledger as a text agent. It can call text specialists during the conversation. A LiveKit handler and a supported realtime model provide the voice runtime, including speech and call lifecycle hooks.
Text agents can use OpenAI, Gemini, or Claude through provider-specific imports. Realtime voice supports OpenAI and Gemini. Change model configuration while keeping your application tools and state; provider capabilities and setup remain explicit.
Evidence from the same application
Deterministic tests
Script the model's turns while your tools, context renderers, and state updates execute for real. Assert on the resulting events and values without a model API key.
Text and voice evaluations
Run conversations with simulated users, control tool effects, and score outcomes. Voice evaluations add recordings, response latency, silence, and interruption measurements.
Why we build on it
We build clinical and operational applications where several agents contribute to the same work. They need structured information, different context, and a way to wait for people. We choose where models make decisions and where code enforces the rules. We also need to test application logic and evaluate conversations over text and voice. These requirements shape the ADK.
ADK fits TypeScript teams that want to own their application logic, runtime, and storage. Context selection controls model input; your application still owns access control. Durable pauses need a persistent store, and active voice calls need a LiveKit worker.
The package is pre-1.0. Core changes are documented in the changelog; experimental workflow and coding-agent APIs can change without a deprecation cycle. See the stability policy.
Agent Development Kit · Start
A live model reads your request, decides whether to look up a booking, and answers with the result. Your tool updates typed application state. Run it, change the question, and inspect the state and tool calls it produced.
Save an OpenAI API key, then run the cells in order or press Run all. Requests go directly
from this browser to api.openai.com and incur provider usage. The key stays in
this browser's localStorage until you clear it. The booking data is fictional; the model calls
are live.
Explore the runtime without a key using the separate scripted test example.
Step 1
One schema types every ctx.state read and write. The tool looks up a booking and
records its reference and slot. Building these definitions does not call a model.
import { adk } from '@animahealth/adk'
import { z } from 'zod'
const app = adk({
schema: {
session: {
reference: z.string().default(''),
slot: z.string().nullable().default(null),
},
},
})
const bookings: Record<string, string> = {
'B-42': 'Tuesday at 14:30',
'B-73': 'Friday at 09:00',
}
const lookupBooking = app.tool({
name: 'lookup_booking',
description: 'Look up an appointment by its booking reference.',
schema: z.object({ reference: z.string() }),
execute: (ctx) => {
ctx.state.reference = ctx.args.reference
ctx.state.slot = bookings[ctx.args.reference] ?? null
return { reference: ctx.state.reference, slot: ctx.state.slot }
},
})
lookupBooking.name
Step 2
The agent gets instructions, the last lookup from typed state, and conversation history. Context is rebuilt before each model call, so the next call sees state written by the tool.
import { openai } from '@animahealth/adk/openai'
const assistant = app.agent({
name: 'booking-assistant',
model: openai('gpt-5.6-luna'),
context: [
app.context.system(({ state }) =>
'Help with booking questions. Look up references before giving appointment times. ' +
'If the reference is missing, ask for it. A null slot means no booking was found. ' +
`Last lookup: ${state.reference || 'none'}. Slot: ${state.slot ?? 'none'}.`,
),
app.context.history(),
],
tools: [lookupBooking],
})
assistant.name
Step 3
Run this cell to make a live request. The agent should call lookup_booking
for B-42 and answer with Tuesday at 14:30. Its wording can vary. Nothing in the script
supplies a model response or tells the runner which tool to call.
const question = 'What time is booking B-42?'
const run = await app.run(assistant, {
input: { message: question, initialState: { session: {} } },
})
run.output.text
Change B-42 to B-73 for a different appointment. Try B-99 for a missing booking, or “When is my appointment?” to see whether the agent asks for a reference. Each run starts a new session, so you can compare the choices without earlier conversation influencing them.
Step 4
For B-42, expect reference: "B-42" and slot: "Tuesday at 14:30".
Those values come from your tool. The session events show which arguments the model chose and
what the tool returned. A request that needs clarification may finish without a tool call.
const outcome = {
status: run.status,
state: { reference: run.state.reference, slot: run.state.slot },
tools: run.session.events.filter((event) =>
event.type === 'tool_call' || event.type === 'tool_result',
),
usage: run.usage,
}
outcome
You have a live agent, structured application state, and explicit model context. The Context guide shows how to select and filter history when several agents work in one session.
Agent Development Kit · Start · after the quickstart
Choose which parts of your workflow need a model and which should follow rules in code. Compose agents, steps, sequences, parallel branches, and loops around the same session. The examples below combine model calls with deterministic checks, routing, and context selection. Model replies are scripted so you can inspect the behavior without an API key.
src/types/runnables.ts
Reads after · Quickstart
The vocabulary
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,
yields
|
Repeats one runnable while the condition holds, capped by the iteration limit. With
yields, it stops for input between passes — a conversation.
|
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
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 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
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(' · '))
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, and
run.session.events is the way to read it — one list, accumulating across every
run on that session. 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
Put a known decision rule in a step. Here, code checks for an open ticket before calling a model, then routes the work using the session's severity. Neither decision depends on the model following an instruction.
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 escalation = mockAgent('escalation')
// A gate. `ctx.fail` ends the run; `ctx.skip` and `ctx.respond` are the other two signals.
const gate = app.step({
name: 'gate',
execute: (ctx) => {
if (!ctx.state.openTicket) ctx.fail('No open ticket on this session.')
},
})
// A router. Return a runnable and the step delegates to it — no model, no prompt.
const route = app.step({
name: 'route',
execute: (ctx) => {
const authors = ctx.session.events.filter(isAssistantEvent).map((e) => e.agentName)
ctx.note(`routing after ${authors.join(', ')}`, { kind: 'mark' })
return ctx.state.severity === 'high' ? escalation : reply
},
})
const desk = app.sequence({ name: 'desk', runnables: [gate, triage, route] })
const refused = await runTest(desk, [user('My laptop will not boot.')])
const refusedRun = refused.result
const escalated = await runTest(
desk,
[
user('My laptop will not boot.'),
model('Category: hardware.'),
model('An engineer will call you back.'),
],
{ initialState: { session: { openTicket: true, severity: 'high' } } },
)
const stepped = {
gateStopped: refusedRun.status,
gateSaid: refusedRun.status === 'error' ? refusedRun.error : null,
routedTo: escalated.events.filter(isAssistantEvent).map((e) => e.agentName),
notes: escalated.events.filter(isAnnotationEvent).map((e) => e.message),
}
stepped
The gate failed before any model was called. The sequence stopped, and the run came back
error carrying the gate's own message. With an open ticket the sequence ran
triage, and the router then chose escalation over
reply — a step that returns a runnable is the routing. On the way it
read the ledger and appended an annotation event of its own; narrowing the union
is a filter, because the package exports a type guard per event type.
Neither step called a model, so neither emitted a
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
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 in the ledger afterwards.
Step 5
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
Run a live agent on your machine. You need Node 22 or newer and an OpenAI API key.
Step 1
In your project directory, install the ADK and Zod.
mkdir my-agent
cd my-agent
npm init -y
npm install @animahealth/adk zod@^3.25
npm install -D tsx
tsx runs the TypeScript file below. If your project already runs TypeScript, use
your existing runner.
Step 2
Save this as agent.mts.
import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
const app = adk()
const assistant = app.agent({
name: 'assistant',
model: openai('gpt-5.6-luna'),
context: [
app.context.system('Answer concisely.'),
app.context.history(),
],
})
const run = await app.run(assistant, 'Suggest a name for a booking assistant.')
console.log(run.output.text)
The .mts extension lets you use top-level await without changing
your project's module configuration.
Step 3
In your terminal, replace the placeholder with your OpenAI API key.
export OPENAI_API_KEY="your-api-key"
npx tsx agent.mts
The agent calls OpenAI and prints its suggestion in your terminal. The wording varies with each run. To use another provider, see Models and providers.
Setup is complete. Add tools and typed state with the Quickstart.
When your application needs more, follow the relevant guide.
Agent Development Kit · Build · live model
Another live example after the booking quickstart: let a model decide when to call a calculator tool. The cells run the shipped ADK 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. Without a key, the cells read as plain code, and everything they print is described beside them.
Your key
This site is static — no backend, no proxy. Your key lives in this browser's localStorage and
requests go straight to api.openai.com, as they would from your own machine.
Step 1
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
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
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 ledger is
run.session.events, and it accumulates across every run on that session.
run.usage is scoped the same way: run a second time on the same session and its
result reports both runs' cost. This cell reads the same run from step 1. 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
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. 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
Every chapter behind this one teaches the same way: by running the code.
Agent Development Kit · Build · the model seam
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 happens, and that seam is why the same agent runs against OpenAI,
against Vertex, or against a scripted test with no key.
Step 1
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
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.
The order is what makes the swap possible. Below, runTest registers a scripted
adapter, so the descriptor still says which model this agent is for while no provider client
is ever built and nothing reaches the 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 the same way 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 to watch for: 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
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 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.
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
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 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 case it was meant
to allow: the end user typed the key themselves. This page is that case — it is 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
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
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 calling.
A descriptor names who answers; the adapter decides how the call is made. The other half of an agent's config — what it can do — is Tools, the next chapter.
Agent Development Kit · Build
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
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
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. 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
ctx carries
execute takes 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 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. Redaction, truncation, and logging belong here.
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
Two optional fields make a tool survivable, and one rule decides what the model hears.
retry is a RetryConfig:
{ maxAttempts, initialDelayMs, maxDelayMs, backoffMultiplier }, plus an
optional retryableErrors(error) predicate that bails out of retrying the errors
it rejects. It re-runs execute with randomized exponential backoff. The retries
are internal — the session ledger gets one tool_result for the call, not one
per attempt.
timeout is milliseconds, and it wraps the retrying execution as a whole — it is
a budget for the call, not for one attempt. On expiry the result event carries
timedOut: true and an error naming the tool and the budget.
execute becomes that same result event, with
error set to its message. It is not swallowed and it is not thrown at your
app.run: it is handed to the model as the outcome of the call, which is the
only place that can do something about it. Whatever your error message says, the model
reads. Guardrails covers what a throw does in full.
One agent, three tools, three failures — a flaky call that recovers on its third attempt, a slow call that blows a 25ms budget, and a call that 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
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.
Next
A tool with a yieldSchema and no execute computes nothing. It
suspends the run, and the agent sleeps as a database row until a human supplies the answer.
That is Stopping to ask.
Agent Development Kit · Build
Give a specialist the information it needs without handing it every other agent's conversation. Each agent's context renderers select instructions, state, and history before a model call. The session keeps the work; the model receives the view you choose. Run the cells below to inspect that choice, then change it.
src/context/
Step 1
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. Every cell below uses one.
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 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. 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.
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
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 decides the outcome:
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 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. It also lifts system events to the front, so instructions are never what gets trimmed.
Step 4
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. 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 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
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
}),
],
})
// `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
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
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
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
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 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
temp is scratch space held in memory for the invocation, and reading or writing
it outside an invocation throws.
Step 3
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
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
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 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.
Agent Development Kit · Build · after Five primitives
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
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
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 the same
four-way test to each iteration, and re-evaluates its while predicate before
every one.
examples/staticFlow.ts
is this composition at full size: parallel research, a planning sequence, a revision loop that
stops on the editor's score, then a yield for sign-off.
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.
Nothing awaited the auditor, 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, while the run stays open until the work finishes. To outlive the run, you need a store and a second call, not a background task.
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.
output.text is the session's last assistant event, so two agents
running at once on one session both resolve it to the same words. When you want one branch's
own words, read them from its events, or
give it an output schema. Sequential
ctx.run is unaffected.
Step 3
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.
The cells above call the verbs from your own code, which is where a deterministic handoff
belongs. For all four wired instead as tools a model picks between, read
examples/dynamicFlow.ts.
Step 4
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 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
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
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
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 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 — 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
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
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. The
absent event is the point: 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
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 it — 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
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
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
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 where it stopped. This chapter walks that code: the demo first, then source excerpts in reading order, then runnable cells that prove the pause with no credentials. The CLI transcript illustrates the example; use the cells below to run it.
Step 1
Four commands — ask, pending, approve,
deny. The first one starts a run and 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
src/clinic.ts
The clinic is a constant and a Map, so 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
src/bookings.tsOne 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. A session has no
confirmation until a booking is approved, so the field is optional.
store decides where the session sleeps: one file, created on first use, safe to
delete.
The deterministic tool comes first.
/** 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
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)
}
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
test/bookings.test.ts
runTest replaces exactly one thing: the model. Everything else still runs — the
tools, the ledger, the yield. 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.
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 cells below exercise the pause and resume behavior directly in this page.
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 what 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 own branches answer, because the tool is real. The kit itself is Testing agents without a model.
Step 6
Press Run all to execute the Bookings flow with a scripted provider. The
cells use an in-memory session, so they need no credentials and do not create
bookings.db.
For a local application, follow the Node project setup and install the
published @animahealth/adk package. The snippets above explain the agent, store,
and approval flow; your application supplies its CLI entrypoint and configuration.
Agent Development Kit · Build
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 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
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
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 — 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
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
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. The failure's
stage says which: 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
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 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 that 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
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 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
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 teaches is the two seams you plug your own code into, and the
convention that carries pixels back to the model.
Step 1
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 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 the first time it searches, reading SERPER_API_KEY
then. Building the tool needs no key; running it does — so a missing key surfaces on the first
search, not at wiring time.
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'), and
country are passed to the provider, which decides what to do with them.
allowCountryOverride (default true) decides whether country is in
the schema at all. A config country is the fallback when the model does not
send one.
autoFetch follows the top autoFetchTop results (default 3) with
the fetch machinery of the next section and copies each page's markdown onto that row's
content. autoFetchRender, proxy, and
pipelines steer 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'
// Constructing the provider yourself reads SERPER_API_KEY when the argument is omitted, and a
// missing key throws on this line rather than on the first search.
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 is truncated to
maxPdfPages (default 10) and handed over as a document the model can look at. 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' | 'missing_dependency'
errorMessage?: string // detail for the failure, e.g. which packages to install
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
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 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 lives in the schema rather than in a check inside your code, so an
over-long request is rejected at the border and the model is told why. 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
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 on the first search, 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: 'missing_dependency', and errorMessage carries the
npm install line for the packages it wanted.
|
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
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
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
clear about the part a browser tab cannot do: talk to a real server.
app.mcp
Step 1
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
because declaring is inert — the connection is deferred until an agent 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
The transport is decided by the config. 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:
command, args, cwd, and
env. Supply env and the child gets your whole
process.env with your entries merged over it; omit it and the ADK
passes nothing, leaving the transport to pick its own default environment. If a server needs
a token, put the token in env rather than assuming inheritance.
url, headers, and
authorization. authorization is a convenience that sets one
header; anything else (an API key header, a tenant id) goes in headers.
timeout in milliseconds, defaulting to 30 000. It is
per operation, not per server: connecting, listing tools, and each tool call get their own
budget.
Step 3
An MCP server goes in tools, 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
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
The last two lines matter: 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 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
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
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 rather than stopping it. If a document must be there, read
it yourself with await server.readResource(uri) and decide what to do when it is
not.
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
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 what you get here, and it is the same
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:
EPIPE, ECONNRESET, ECONNREFUSED, or a message about
being closed, disconnected, or not connected — reconnects and retries once. Other errors are
yours to see.
cacheToolsList: true, which reuses the first listing for the life of the
connection. On a stdio server that is a round-trip to a child process on every turn, so set
it — and know it is a real cache: a server that gains a tool while connected will not be
noticed until it reconnects. cacheResourcesList and
cachePromptsList do the same for the other two listings.
await app.mcp.disconnect() closes every registered
server and clears its caches; await app.close() does that and closes the
session store. Child processes are also cleaned up on beforeExit,
SIGTERM, and SIGINT, so a Ctrl-C does not leave an
npx behind.
Step 7
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 — and once a server is declared, filtered, and warmed, the rest of the ADK cannot tell the difference between its tools and the ones you wrote by hand.
Agent Development Kit · Build
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
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 reproducible 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
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
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
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. The
filter did its work here: 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() belongs to 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 only
the async context builder awaits a renderer. The voice runtime uses that one; a text agent's
reasoning loop builds context synchronously and throws on a renderer that returns a promise.
So in a text agent, reach for the tool above, or search before the run and inject the result
with app.context.system(…).
Step 5
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
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
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
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.
For a complete example with typed state, two agents, and approval/resume, test a workflow without model calls.
Step 1
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
That model: line 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
This is the idea the kit rests on. 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
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. A scripted run cannot report 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
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.
|
Now 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
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. Use it 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
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 — so agent tests pass 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(). 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
Build a workflow that shares typed state, gives each agent its own context, and pauses before booking. Run the cells in order, or press Run all. The model responses are scripted; the tools, state updates, context rendering, and pause/resume use the real ADK.
Step 1
The availability tool proposes a slot. The booking tool waits for a typed approval before
changing the booking state. Both tools use the schema declared on the app; their
ctx.state fields are inferred from it. This example records a booking as a
counter in memory, so it cannot book a real appointment.
import { adk } from '@animahealth/adk'
import { MockAdapter } from '@animahealth/adk/testing'
import { z } from 'zod'
const scripted = new MockAdapter()
const bookingApp = adk({
name: 'booking-demo',
schema: {
session: {
request: z.string().default('Please find an appointment on Tuesday.'),
slot: z.string().default(''),
status: z.enum(['searching', 'proposed', 'booked', 'declined']).default('searching'),
bookings: z.number().default(0),
},
},
adapters: { openai: scripted },
})
const findSlot = bookingApp.tool({
name: 'find_slot',
description: 'Find an available appointment slot.',
schema: z.object({}),
execute: (ctx) => {
ctx.state.slot = 'Tuesday 14:30'
ctx.state.status = 'proposed'
return { slot: ctx.state.slot }
},
})
const bookSlot = bookingApp.tool({
name: 'book_slot',
description: 'Ask for approval to book the proposed slot.',
schema: z.object({}),
yieldSchema: z.object({ approved: z.boolean() }),
execute: (ctx) => {
if (!ctx.input?.approved) {
ctx.state.status = 'declined'
return { booked: false }
}
ctx.state.bookings += 1
ctx.state.status = 'booked'
return { booked: true, slot: ctx.state.slot }
},
})
const toolNames = [findSlot.name, bookSlot.name]
toolNames
The cell prints the two tool names. Neither tool has executed yet.
Step 2
The specialist reads the request from state and sees its own history. The booking assistant receives the proposed slot through typed state, plus its own history. It does not receive the specialist's conversation. A sequence runs the specialist first, then the booking assistant.
const availability = bookingApp.agent({
name: 'availability',
model: { provider: 'openai', name: 'scripted' },
context: [
bookingApp.context.system(({ state }) => `Find a slot for this request: ${state.request}`),
bookingApp.context.history({ scope: 'invocation' }),
],
tools: [findSlot],
})
const coordinator = bookingApp.agent({
name: 'coordinator',
model: { provider: 'openai', name: 'scripted' },
context: [
bookingApp.context.system(({ state }) =>
`Offer ${state.slot}. Use book_slot to request approval. Booking status: ${state.status}.`,
),
bookingApp.context.history({ scope: 'invocation' }),
],
tools: [bookSlot],
})
const bookingFlow = bookingApp.sequence({
name: 'booking',
runnables: [availability, coordinator],
})
bookingFlow.name
Step 3
The script supplies the model's decisions. The runtime executes find_slot, writes
its state, and yields when the coordinator calls book_slot. Expect
yielded_tool, a proposed slot, and zero bookings.
scripted.reset()
scripted.setResponses([
{ toolCalls: [{ name: 'find_slot', args: {} }] },
{ text: 'The availability check found Tuesday 14:30.' },
{ toolCalls: [{ name: 'book_slot', args: {} }] },
])
const pausedBooking = await bookingApp.run(bookingFlow, {
input: {
message: 'Please find an appointment on Tuesday.',
initialState: { session: {} },
},
})
const waitingBooking = {
status: pausedBooking.status,
slot: pausedBooking.state.slot,
bookings: pausedBooking.state.bookings,
waitingFor: pausedBooking.session.yieldedTools.map((tool) => tool.name),
}
waitingBooking
Step 4
The scripted adapter records the context it receives. Read the messages below: the coordinator knows the slot through its system message, without receiving the specialist's reply. The full session still records both agents' work.
const modelInputs = scripted.stepCalls.map(({ ctx }) => ({
agent: ctx.agent.name,
messages: ctx.events.flatMap((event) =>
event.type === 'system' || event.type === 'user' || event.type === 'assistant'
? [`${event.type}: ${event.text}`]
: [],
),
tools: ctx.functionTools.map((tool) => tool.name),
}))
modelInputs
This view shows text messages and available tool names. The Context chapter also shows tool-call and tool-result messages, history scopes, and filters. Context selection controls model input, not access permissions.
Step 5
Keep approved true to book. To try rejection, rerun step 3, change
approved to false, and run this cell again. Approval produces one booking;
rejection leaves zero. The tool checks the decision in code.
const approved = true
const pendingBooking = pausedBooking.session.yieldedTools[0]
if (!pendingBooking) throw new Error('Run step 3 again to create a pending booking.')
pausedBooking.session.input.tool({
callId: pendingBooking.callId,
input: { approved },
})
scripted.setResponses([{ text: 'Your decision has been recorded.' }])
const finishedBooking = await bookingApp.run(bookingFlow, { session: pausedBooking.session })
const bookingOutcome = {
status: finishedBooking.state.status,
bookings: finishedBooking.state.bookings,
changes: finishedBooking.session.events.flatMap((event) =>
event.type === 'state_change' ? event.changes : [],
),
}
bookingOutcome
The model's closing sentence is scripted. The booking count and state changes come from the tool that actually executed. Tests can assert on those outcomes; live evaluations measure whether a model makes the right decisions.
Continue building
This example keeps its session in browser memory. To survive a process exit, use a persistent store and commit the session. The Bookings sample demonstrates that boundary with SQLite and separate commands for asking and approving.
Agent Development Kit · Prove & ship
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
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 }, and its signature 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.
A case with no metrics asserts nothing. It 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. That is why the case above stays green with its mock deleted. A green suite without metrics is not evidence — the next section adds the questions.
Step 2
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
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 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 matters only here: 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
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
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
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; only the model 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
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)
Teams forget that last line. app.evaluate returns a result and never throws on a
failed metric, 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. Deterministic behaviour belongs in scripted tests, not
here: an eval that a script could have answered is 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
Hooks let your code observe and control an agent's execution. Bypass a model call, choose a tool call in code, check an action before it runs, or replace a result. Error handlers control retries and recovery. These examples run without an API key.
Step 1
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.
The setup uses a MockAdapter to script provider responses and record model calls.
Tools and hooks execute in the real ADK runtime. See Testing for the
test utilities.
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. In the output, beforeTool fires before the tool runs, and
afterTool receives the finished tool_result event, result payload
and all.
const seen: string[] = []
scripted.setResponses([
{ toolCalls: [{ name: 'refund', args: { amount: 4000 } }] },
{ text: 'Refunded 4000. Anything else?' },
])
const traced = await app.run(support, {
input: 'Refund my last order.',
hooks: [
{
name: 'trace',
onEvent: (event) => {
seen.push(`event ${event.type}`)
},
beforeAgent: (ctx) => {
seen.push(`beforeAgent ${ctx.runnable.name}`)
},
beforeTool: (ctx, call) => {
seen.push(`beforeTool ${call.name}`)
},
afterTool: (ctx, result) => {
seen.push(`afterTool ${result.name} -> ${JSON.stringify(result.result)}`)
},
afterAgent: (ctx, output) => {
seen.push(`afterAgent ${typeof output}`)
},
},
],
})
seen
Every method here returned undefined, which is what makes it an observer. The
moment one returns a value it becomes an interceptor — section 3. Two warnings the types will
not give you:
beforeAgent returning a string replaces the run's output. Use a braced body
when you only meant to log.
afterTurn fires only under app.handler.turn, plus the
rest and agui handlers that delegate to it. Never under a bare
app.run.
Step 2
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 consoleHook 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.console().
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.
Deterministic tool calls
Return a ModelStepResult from beforeModel to bypass that provider
call. Include a tool call in both stepEvents and toolCalls, with
terminal: false. The runtime records the call and runs it through the normal tool
pipeline, including argument validation and tool hooks.
This example chooses a fixed refund in code. The refund tool above only records an amount in an array. On the next iteration, the hook sees the tool result and ends the run.
import type { Hook } from '@animahealth/adk'
import type { ToolCallEvent } from '@animahealth/adk'
issued.length = 0
scripted.reset()
const fixedRefund: Hook = {
name: 'fixed_refund',
beforeModel: (ctx, renderCtx) => {
const refunded = ctx.session.events.some((event) =>
event.type === 'tool_result' &&
event.invocationId === ctx.invocationId &&
event.name === 'refund',
)
if (refunded) {
return { stepEvents: [], toolCalls: [], terminal: true }
}
const call: ToolCallEvent = {
id: crypto.randomUUID(),
callId: crypto.randomUUID(),
type: 'tool_call',
createdAt: Date.now(),
invocationId: ctx.invocationId,
agentName: renderCtx.agentName,
name: 'refund',
args: { amount: 250 },
}
return { stepEvents: [call], toolCalls: [call], terminal: false }
},
}
const forced = await app.run(support, {
input: 'Apply the fixed delivery refund.',
hooks: [fixedRefund],
})
const bypassResult = {
status: forced.status,
modelCalls: scripted.stepCalls.length,
refunds: [...issued],
toolResults: forced.session.events.filter((event) => event.type === 'tool_result'),
}
bypassResult
The result shows modelCalls: 0 and refunds: [250], with a recorded
tool result. The hook chose the call; the tool still executed. To let the model handle other
cases or explain a tool result, return undefined instead. The next model call
proceeds normally.
This control sits inside an agent's loop. Use a
step when the decision belongs between workflow
stages. Use a beforeModel hook when you want to replace a particular model
decision while keeping the agent's tools and context.
Step 3
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 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 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: 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
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
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 — the subject of section 5.
Step 5
An error handler is { canHandle?, handle }, and handle returns one
of six verdicts:
throw aborts the run with the original error.skip records the error and carries on.abort ends the invocation cleanly.retry, with an optional delay, runs the same step again.fallback substitutes a result as if the step had succeeded.pass declines, handing the decision to the next handler.
Handlers are consulted in order — runner, then agent, then call site. The first verdict that
is not pass wins. If every handler passes, the phase default from section 4
applies.
Five handlers ship:
retryHandler — bounded exponential backoff, then pass.rateLimitHandler — a retry handler that only fires on rate limit, 429, or
too-many-requests messages.
timeoutHandler — matches timed out, then fallback if
you gave it a fallbackResult, otherwise skip.
loggingHandler — logs and always passes.defaultHandler — the phase default, made explicit.The cell below stacks three of them against a run that fails twice, in two different phases.
import { loggingHandler, retryHandler, timeoutHandler } from '@animahealth/adk'
const failures: string[] = []
scripted.setResponses([
{ error: new Error('503 upstream unavailable') }, // model call fails
{ toolCalls: [{ name: 'lookup_order', args: { id: 'B-7' } }] }, // retry: this one calls the slow tool
{ text: 'B-7 is unconfirmed — the live lookup timed out, so this is the cached status.' },
])
const recovered = await app.run(orderDesk, {
input: 'Where is order B-7?',
errorHandlers: [
// Observes every failure and defers — `pass` keeps the chain moving.
loggingHandler({
onError: (ctx) => {
failures.push(`${ctx.phase} · attempt ${ctx.attempt} · ${ctx.error.message}`)
},
}),
// Ordered before the retry handler: a timeout should degrade, not hammer the dependency.
timeoutHandler({ fallbackResult: { status: 'unknown', source: 'cache' } }),
// `retryable` narrows this one to model failures; without it, it would retry everything.
retryHandler({ maxAttempts: 3, baseDelay: 0, retryable: (ctx) => ctx.phase === 'model' }),
],
})
const recoveredReport = {
failures,
toolResults: recovered.session.events
.filter(isToolResultEvent)
.map((event) => ({ name: event.name, result: event.result })),
text: recovered.output.text,
}
recoveredReport
Two failures, two different verdicts, one completed run. The model's 503 was retried and the
second attempt called the tool; the tool's timeout was substituted with a cached-looking
result that the model then wrote its answer from. The ordering is the 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
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
A paused agent is rows in a table, and a store is what puts them there. 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
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 }>>
}
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 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 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
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
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.
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' })
Zero infrastructure and 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.
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.
Single-table, serverless, and the only 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 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
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. Nothing else of a sleeping agent
lands on disk.
-- 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
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, and SQLite
registers twice — as a file and as ':memory:'.
// 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', /* against a live Postgres */)
runSessionStoreTests('DynamoDBStore', /* against a live DynamoDB endpoint */)
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 is not redundant: ':memory:' has its own
failure shape, where a second connection is a second, empty database.
The in-memory and SQLite registrations run anywhere. The Postgres and DynamoDB ones need a live endpoint to point at; without one they report themselves skipped rather than passing.
Step 6
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 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
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
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
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 depend on the turn. yieldedTools appears when
it 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 caveat 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.
Call the same handler again on the same sessionId and 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 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.'
}
Hence 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
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.
The stream carries state. It includes a state_change event for
every write, in every scope, patient and practice included. Before an event leaves the
process, pass it to mayLeaveProcess from @animahealth/adk and drop
it when that returns false. The REST and AG-UI handlers already do.
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
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)
Any writer that can frame SSE will do. Behind AWS Lambda the same iterable goes to a streamed
response, which is
examples/lambda-agui.ts.
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 always empty. State never crosses this edge, because
state_change events carry every scope, patient and practice included. 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 |
Nothing. State changes stay in the process. |
Any event from a run started with ctx.run, spawn or
dispatch
|
Nothing, except the step events for its invocation_start and
invocation_end when steps are on. The chat shows the tool that started
the run, through its call and result.
|
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 — that result field
is where a client reads whether the turn was written — 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.
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
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
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
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
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
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 two closing lines unlike 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
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
A named worker needs an explicit dispatch. start always
registers an agent name — config.name, else the agent's name, else
adk-voice — and LiveKit does not route rooms to a named worker automatically.
Name the agent in the caller's access token, or create a dispatch with LiveKit's
AgentDispatch API. Without one, the worker runs and never rings.
Two things are checked at construction, 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
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
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, and whether
you pass stt and tts decides which one you get.
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.
Voice resolves its OpenAI connection separately from text mode and understands one environment
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
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 thin by design — 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 need explaining. 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
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 matters on a phone call: await reply.waitForPlayout() resolves when
the caller has 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
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:
| 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.
The record of a call arrives at the end, or not at all. The single commit is best-effort, and nothing is on disk before it. 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
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
when the agent answers the caller, which is what turns three prompts into an escalation
instead of a loop. A sound the agent does not answer, such as a click, keeps the escalation
going.
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: 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
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.
The gate has three properties you can build on. It 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
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. Follow
Evaluate voice agents to build a complete case, assert on tool
behavior and response latency, and review the recording.
Agent Development Kit · Prove & ship
Put a simulated caller on the other end of your voice agent. Check which tools ran, measure response latency, and review the transcript and recording. This guide runs locally with LiveKit and a realtime model provider; it does not run in the browser.
Step 1
Use Node 22 or later and a LiveKit server. Set LIVEKIT_URL,
LIVEKIT_API_KEY, LIVEKIT_API_SECRET, and
OPENAI_API_KEY in your local environment. Both sides of the call use a live
model, so running the evaluation incurs provider usage.
npm install @animahealth/adk zod@3 @livekit/agents @livekit/agents-plugin-openai @livekit/rtc-node livekit-server-sdk
npm install --save-dev tsx
Create booking-eval.mts and add the following blocks in order. The
voice setup guide explains the runtime dependencies.
Step 2
This lookup returns synthetic booking data. The same tool could serve a text agent; the realtime model configuration makes this agent speak. Its output tool ends the call.
import { z } from 'zod'
import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { voiceTimingMetric } from '@animahealth/adk/eval'
const app = adk()
const lookupBooking = app.tool({
name: 'lookup_booking',
description: 'Look up a booking by reference.',
schema: z.object({ reference: z.string() }),
execute: (ctx) => ({ reference: ctx.args.reference, slot: 'Tuesday 14:30' }),
})
const endCall = app.tool({
name: 'end_call',
description: 'End the call after answering the booking question.',
schema: z.object({ summary: z.string() }),
execute: (ctx) => ctx.end(),
})
const agent = app.agent({
name: 'booking-support',
model: openai.realtime('gpt-realtime-1.5', { voice: 'ballad' }),
context: [
app.context.system('Ask for the booking reference, look it up, and tell the caller the slot. Use end_call when finished.'),
app.context.history(),
],
tools: [lookupBooking],
output: endCall,
})
const caller = app.agent({
name: 'booking-caller',
model: openai.realtime('gpt-realtime-1.5', { voice: 'shimmer' }),
context: [app.context.system('You are calling to check booking B-42. Ask what time it is. When answered, say thank you and goodbye.')],
tools: [],
})
Step 3
Supply an explicit tool mock so the evaluation controls the lookup result. Let the real output tool end the call. The first metric checks that the agent looked up the requested reference and received the expected result. The second checks the 95th percentile of response latency against an example three-second budget. Choose a budget for your application.
const bookingCase = app.evaluate.voice.case({
name: 'find-booking-time',
agent,
userAgent: caller,
timeout: 120_000,
toolMocks: {
lookup_booking: {
execute: (args) => ({ reference: args.reference, slot: 'Tuesday 14:30' }),
},
end_call: endCall,
},
metrics: [
{
name: 'looked-up-requested-booking',
evaluate: (run) => ({
passed: run.events.some((event) =>
event.type === 'tool_call' && event.name === 'lookup_booking' &&
event.args.reference === 'B-42' &&
run.events.some((result) => result.type === 'tool_result' &&
result.callId === event.callId && !result.error &&
JSON.stringify(result.result) === JSON.stringify({ reference: 'B-42', slot: 'Tuesday 14:30' })),
),
}),
},
voiceTimingMetric({
name: 'response-within-budget',
measure: 'response_latency_p95',
assertion: (ms) => ms < 3000,
}),
],
})
The lookup metric verifies tool behavior. It does not establish that the caller heard a correct answer. Review the recording and transcript, and add an outcome metric for the conversational requirement you need to enforce. A timing metric fails when its required measurements are missing.
Step 4
const suite = await app.evaluate.voice([bookingCase], {
concurrency: 1,
output: './booking-eval-results',
hooks: [{
onEnter: async (ctx) => {
const reply = await ctx.voice.generateReply({ toolChoice: 'none' })
await reply.waitForPlayout()
},
}],
})
console.log(app.evaluate.voice.report()(suite))
process.exitCode = suite.summary.passed === suite.summary.total ? 0 : 1
npx tsx booking-eval.mts
The evaluation creates a LiveKit room and connects the agent and simulated caller. The console
report lists case and metric results. Each case's output directory contains a
report.md and recording.wav. The returned case result also includes
session events, voice events, a transcript, timing, and usage when available.
Expect the wording and timing to vary between runs. Inspect a failed metric's evidence, then use the recording and events to determine whether the problem was the conversation, a tool, or the connection. A timeout or errored call is not a passing evaluation.
Broaden the cases
repeat to run each case several times and inspect variation.time_to_first_speech, silence_gap_max, or
interruption_count metrics where they answer a product question.
app.evaluate.voice.case((control) => ...) with
control.disconnectUser() to exercise caller disconnection.
Start with the same business outcomes you check in text evaluations, then add audio and lifecycle requirements. Use deterministic tests for tool and state behavior that does not require a live conversation.
Agent Development Kit · Prove & ship
app.terminal(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
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. The UI is
loaded lazily, at the moment you call app.terminal, so a missing one surfaces
there — as
Terminal dependencies not found. Install them with: npm install ink ink-text-input
react, not as a resolver stack trace.
Then the program. app.terminal 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.terminal(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.terminal(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 terminal 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.
The terminal takes over two things. 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.terminal 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
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 drops everything but 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
This is the reason to reach for the terminal over console.log. A tool that
declares a yieldSchema stops the run and waits for a
human. The terminal 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
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.
To get the prompt back after every reply, hand app.terminal a loop that yields —
app.loop({ runnable: assistant, yields: true, … }). That is the shape behind
examples/assistant.ts, which ends the chat from its while predicate.
The terminal holds the session in memory. Like a bare
app.run, it accrues events on the session and touches no store. To persist across runs, hand it a
session you loaded and commit that session yourself when the launch ends.
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.terminal(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
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
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
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
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. 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
Read this before you build a budget on it. The ledger belongs to the session, and
run.usage is computed from that whole event list — not from the slice this run
appended. That is deliberate: a session accrues its cost and its history as it goes. Give two
runs the same session and the second result accounts for both.
const shared = await app.sessions.create()
scripted.setResponses([{ text: 'First answer.' }, { text: 'Second answer.' }])
const firstRun = await app.run(narrator, { session: shared, input: 'One?' })
// The ledger is live, not a snapshot, so measure it now — the second run appends to this array.
const afterFirstRun = firstRun.session.events.length
const secondRun = await app.run(narrator, { session: shared, input: 'Two?' })
const scope = {
afterFirstRun,
afterSecondRun: secondRun.session.events.length,
sessionEvents: shared.events.length,
modelEndsInSession: secondRun.session.events.filter(isModelEndEvent).length,
}
scope
afterSecondRun equals sessionEvents, and the ledger read from the
second run's result holds two model calls after that run made one of them. That total is the
conversation's, which is usually what you bill it for. output.text follows the
same rule: it is the session's last assistant event. A fresh session per run —
which is what app.run creates when you pass a bare string instead of a
session — makes every reading a single turn's, so the accumulation only shows
once you keep a conversation going. To attribute cost to one turn inside a longer one, 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
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 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 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
Everything above ran against a script, so 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 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
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`)
}
Forwarding only the deltas is deliberate. The same stream carries a
state_change event for every state write, patient and practice scopes included.
To send more than text, drop each event for which mayLeaveProcess(event) returns
false before it goes down the wire.
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, live in serving.
Next: Install and setup — get this on your machine.
Agent Development Kit · Reference
What changed, release by release — rendered from the package's own
CHANGELOG.md, so this page cannot disagree with it.
2026-10-14
VoyageModel.timeoutMs and QdrantConfig.timeoutMs — the limit for each provider HTTP request, including reading the response; a whole number of milliseconds from 1 to 3600000, default 30000. A request that runs out fails, and retry decides whether to try again. Any other value throws a RangeError when the provider is created. See vector memory.qdrant() — requests time out after 30s by default instead of the client's 300s.voyage() — requests time out after 30s by default instead of the SDK's 60s, which stopped at the response headers, and fail with Voyage request timed out after <n>ms. The SDK no longer retries a 408, 409, 429 or 5xx response itself; retry.maxAttempts is the number of requests.2026-10-12
A suite's scorecard is one path, recorded with the commit it measured, so the scorecards of several
repositories can be kept in one place. The scorecard declaration from 0.6.6 changes shape in this
patch release: one suite had adopted it, and it moves with this release.
provenance record judges — each distinct model the cases' metrics reported calling, such as the judge metric's model.@animahealth/adk/eval exports parseScorecard and the Scorecard type — the reader every tool shares.measures — aggregates that are not one case's pass count, each { passed, runs } or { value, n } — with cases or in place of them. The eval CLI writes cases only and rejects a scorecard that has none.OpenAIModel.serviceTier: 'flex' — sends OpenAI flex processing requests; a flex request that fails before any output is sent again at the default tier on the same endpoint. Azure endpoints get the default request. See OpenAI.MixedEvalOptions.scorecard: { path, fingerprint } → a path from the top of the outermost repository that holds the working directory — a suite in a submodule records into the repository around it.repo (the owner/name the origin remote ends in) and commit replace fingerprint; a reader compares the paths it cares about at that commit.Scorecard.cases is optional, and the reader rejects a key the format does not name.run --record needs the scorecard's directory to exist, an origin remote and the whole checkout committed, submodules aside, before any case runs, and records nothing when HEAD moves or the checkout changes during the run.provenance.fingerprint is removed; provenance.dirty covers the whole checkout, submodules aside, for every suite.Replace scorecard: { path, fingerprint } with scorecard: '<path>', from the top of the outermost
repository. A version 1 scorecard at that path is rejected before any case runs, so --record
cannot replace it. Delete the file, commit the deletion and record again with --record; or replace
its fingerprint with repo and the commit whose ids for those paths are the recorded ones, and
set version to 2.
2026-10-08
MixedEvalOptions.scorecard: { path, fingerprint } declares a committed eval CLI baseline with per-case pass counts, Git file IDs, runner, and models. Keep it in the same repository as its fingerprint paths, but outside them. See Eval scorecards.run --record replaces the declared scorecard with exactly the selected cases. It requires clean fingerprint paths and preserves the scorecard if the run stops early, a case errors, times out or aborts, or fingerprinted files change during the run. result.json reports record: { file, written }; exit code 0 means the scorecard was written.run compares with an existing declared scorecard unless --baseline is supplied, reporting its recording date and whether fingerprint paths still match. Scorecards require a cases field and contain no tool results, so comparison.toolResults is absent for these comparisons.--kind text|voice filters cases alongside --case. An empty selection exits with code 2.result.json includes provenance: runner, models, available Git commit and dirty state, and declared fingerprints. Suites without scorecards still run when Git is unavailable.2026-10-08
Hook.duringModel(ctx, renderCtx, signal) runs alongside the model call. A hook answer that wins the race replaces the model's step and cancels the call. If the hook declines, rejects, or answers late, the model's step stands. The hook's signal aborts when its answer can no longer be used. Model events are buffered until the hook settles or the model step completes. See Hooks.2026-10-07
app.decide(input, { questions, model?, signal? }) answers closed questions (predicate, choice, score) on the model's decisions endpoint, with probabilities. See One-shot calls.DecisionsUnavailableError rejects it for a model no decisions endpoint serves; today only OpenAI gpt-6-luna is served. There is no fallback to a model call.ModelAdapter.decide is the optional adapter method behind it; MockAdapter scripts it with decisions and fails an unscripted question.--baseline <run> (repeatable, pooled) compares a run with earlier ones — each case is regressed, improved, unchanged or unclear by a two-sided Fisher exact test on its pass counts, and tool results that one side returned and the other never did are listed. The comparison follows the summary in report.md and is comparison in result.json.--case may repeat.case-<n>.json has modelInputs and instructions — the system messages, toolChoice and allowedTools each model call was given.2026-10-06
LiveVoiceHook.onVoiceActivity reports speaking, transcript, backend-run and silence events. Its context exposes sinceRun and runBackend() to process caller speech not yet given to a run. The trigger returns false when a run is active, no speech is pending, or the call has ended.backendTimeoutMs; a run started during that wait replaces the silence response. Other activity events do not wait for hook promises.sound.backgroundAudio.thinking while delegations are pending, pausing for completed answers and resuming for remaining work. Playback failure leaves the call running without sound. Voice eval callers still hear only the agent's microphone track, not the thinking sound.LiveVoiceDelegation.startedBy says what started a GPT Live backend run: voice for GPT Live's delegation, handler for the silence rule, app for a hook's runBackend(). Hooks read it on ctx.delegation and on run_started and run_settled activity, so an app can tell its own trigger's run from the silence rule's without parsing the run's ID.runBackend(); its behaviour is unchanged.Code that builds a LiveVoiceDelegation itself, such as a test double given to a hook, must set startedBy.
2026-09-30
LiveVoiceControls.untilQuiet() waits for non-interruptible commentary to finish or reach its playout bound. Use it before a transfer that must follow a notice; it does not confirm the caller heard the notice.recording and setup().recordingKey, and caller noise cancellation through sound.noiseCancellation.app.replyTool and ReplyToolConfig let a tool call serve as the agent's reply without forcing an exit.OpenAIModel.parallelToolCalls: false disables parallel tool calls on the Responses API.LiveContentFilterError, exported from /voice, reports moderation stops to GPT Live onError hooks after reconnection. It is recoverable and has no delegation.liveTranscript retains raw fragments.atMs; simulated callers can delegate without failing the case.OpenAIRealtimeTextAdapter uses the GA Realtime API and no longer sends temperature. Optional ModelAdapter.endInvocation releases resources when an invocation ends, including on a tool call.tools list.error event as Live provider error with its type and code only, never its message, so moderation stops and other provider failures are visible.Custom implementations and mocks of LiveVoiceControls must implement untilQuiet(): Promise<void>. ADK-provided controls already implement it. Realtime consumers must use GA-compatible models and must not rely on temperature taking effect.
2026-09-28
Adds GPT Live, richer evaluations, self-hosted model adapters and Zod 4 support. Review the migration notes before upgrading from 0.6.0.
openai.live('gpt-live-1', options) connects voice agents to ADK backends, with tools, persisted state, transcripts, lifecycle hooks and cost tracking. Commentary supports interruption control, not guaranteed verbatim speech.app.evaluate.cli runs mixed text/voice suites with simulated callers, JSON evidence and pass/fail exit codes; app.evaluate.judge adds [model-judged metrics](docs/judge-metric.md) with separate judge costs. /eval exports liveTranscriptTurns for speaker-turn grouping./chat-completions and /eurouter support self-hosted endpoints, streaming, tools and structured output.run.settled — await execution and cleanup before committing or closing a session store.^3.25.76 and Zod 4 Classic ^4.6.5.1.4.4; GPT Live requires Agents and the OpenAI plugin 1.9 or later. OpenAI requires ^6.0.0, Google GenAI ^1.52.0, and @livekit/rtc-node is an explicit optional peer.app.terminal, /terminal, Terminal*, app.hook.console, consoleHook and ConsoleHookOptions. All previous CLI names, imports and types remain deprecated aliases.configurePricing(options | false) overrides or disables it. Cost-summary helpers accept a catalog, and unavailable prices leave costs unknown.run, and voice configuration moves under options.voice. Use returned evidence paths or index.md.user_speech_ended; see the changed inactivity-count semantics below.reasoning.effort: 'none' with temperature, and parse wrapped primitive outputs correctly.ctx.note API.Run and turn-handler streams now include state_change for every scope. Before forwarding events outside the process, filter with mayLeaveProcess; REST and AG-UI already do so. Annotations are forwarded, so avoid patient data in ctx.note.
Hooks receive parallel events when branches settle and only state changes owned by their run's invocations. They continue observing unfinished spawned/dispatched work after a parent yields or fails. Use the new session.addStateChangeListener for direct session writes or other runs; custom Session implementations must implement it.
REST consumers should group nested events by invocationId and parentInvocationId. AG-UI consumers should use tool results rather than expecting nested agents' text in the conversation.
InvocationContext.onStream and ToolContext.onStream are removed. Return tool data or use ctx.note for annotations.
Return passed: false for a product failure; thrown metric errors now produce status error, not failed. Treat missing cost as unknown, not zero; configure a reachable price map or disable estimation.
inactivityCount resets when the agent replies, not on caller sounds or silence prompts. GPT Live requires a separate audio burst, with an 800 ms pause, to count a new reply. If an inactivity hook keeps the call open without speaking, the next reply counts as its prompt unless the handler skipped that hook. Review fixed-count hangup hooks against these semantics; see [GPT Live](docs/gpt-live.md).
2026-09-01
The first public release. It removes untested experimental APIs and restores the supported local storage integrations.
SQLiteStore, sqliteStore(), and vector memory through sqliteVec().composeHooks, loggingHook, metricsHook, and cliHook from the main entry.zod — raised the peer floor to ^3.25.0 and rejected Zod 4 schemas with a clear error.adk({ adapters }) to run, handler, voice, and CLI entry points.pgvector operations.DynamoDBStore.list()./testing matcher types and allowed model('text') replies.RunResult.stepEvents and FunctionTool.requiresApproval. Read events from run.session.events and use yielding tools for approval.2026-08-20
OpenAIModel.promptCache and mark the stable prefix with app.context.cacheableUser(...); Responses API cache reads and writes are exposed through ModelUsage and UsageSummary.2026-06-08
Republish of 0.5.24 with a packaging fix — no API or runtime changes.
@types/node now publishes as a concrete range (^22.19.19) instead of the raw pnpm catalog: token. 0.5.24 shipped "@types/node": "catalog:", which broke pnpm pack / pnpm install for anyone consuming the package outside the workspace it is published from, with ERR_PNPM_CATALOG_ENTRY_NOT_FOUND_FOR_SPEC.adk-publish.yml now packs with pnpm pack (which resolves catalog: / workspace: specifiers) and uploads the resulting tarball with npm publish, so the published manifest no longer leaks workspace-only specifiers while keeping npm OIDC trusted publishing. Publishing the source directory with npm publish shipped package.json verbatim, which is how the catalog: token reached 0.5.24.2026-06-04
Workflows: author Claude Code-style .workflow.js files and run them through the ADK, plus a few general additions used to express them.
@animahealth/adk/workflow — runWorkflowFile() runs a CC-style workflow file through app.run, binding agent() to a configurable node runner (default app.ask; a CodingAgent over a provisioned workspace for build attractors).app.ask(prompt, opts) — terse, typed one-shot LLM call (no tools, fresh session); options typed as AskOpts.fanout(thunks, { limit }) — capped isolated concurrency; a failed thunk resolves to null.AnnotationEvent + ctx.note() — generic progress events (phase() / log() are sugar over ctx.note()).afterAgent/afterTurn) now run inside LiveKit's shutdown barrier, so completion side effects (e.g. completeCall) run exactly once before the worker exits — even on abnormal teardown (caller disconnect, human transfer, drop). Previously they were skipped if the job was killed before the post-sessionDone path ran.shutdownProcessTimeout unit bug (60ms → 60_000ms / 60s, matching LiveKit's default); the worker was force-killing job processes ~60ms into shutdown, before finalization could complete.beforeAgent returning a string now finalizes through the same shared path as any other call (completion hooks run), replacing a separate early-exit lifecycle.2026-05-26
voiceLoggingHook, and voice eval reports.ctx.voice.generateReply() plus reply.waitForPlayout() from inside tool execution, including LiveKit awaitable speech handles.@livekit/agents and provider plugin peer floor to ^1.4.4, and bumped @livekit/rtc-node to ^0.13.28, verified with child speech-handle playout waits inside tool execution.2026-05-20
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.
ctx.voice.generateReply({ toolChoice: { name } }) now enforces the named tool internally while sending provider-compatible toolChoice: "required".output_tool_completion_started, output_tool_completion_succeeded, output_tool_completion_failed, forced_tool_correction, and forced_tool_failure voice events.ForcedToolCallError and OutputToolCompletionError from @animahealth/adk/voice.ctx.end() — ending tools now return their tool result before ADK forces the configured output tool and then shuts down the voice lifecycle.beforeTool hooks, then corrected after the synthetic wrong-tool result is returned to the provider.no_tool_call and the intended tool.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.
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
app.evaluate.voice.case((control) => case) now exposes control.disconnectUser(), letting eval code orchestrate caller disconnects from hooks, tool mocks, or other TypeScript code.ctx.end() paths now wait for the output tool to complete before room termination.2026-05-17
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.onExpiry hooks before ending the case, matching production timeout behavior.stop(), so missed filesystem watch events are still collected.ADK_RUN_VOYAGE_EVALS=1 in addition to VOYAGE_API_KEY, keeping default test and publish runs offline.@animahealth/adk now publishes from this package's own source tree, with repository metadata to match.2026-05-08
2026-05-08
generateReply() — preserves the entry-reply scheduling yield after capturing LiveKit's synchronous speech handle so onEnter replies do not race realtime session instruction updates.2026-05-05
ctx.end() from a voice tool now waits for the model-triggered output tool and current playout to finish, then deletes the LiveKit room by default; use callTermination: false to leave hangup to the deployment.generateReply() — LiveKit speech handles are now captured synchronously, named tool choices use LiveKit's { type: 'function', function: { name } } shape, and undefined tool results stay undefined instead of being coerced to an empty string.2026-03-26
waitForPlayout — added to ToolExecutionContext and MockToolContext; removed broken session-level VoiceSession.waitForPlayout(). Use ctx.waitForPlayout?.() in tools, reply.waitForPlayout() in lifecycle hooks.dynamoStore — unused ExpressionAttributeNames on non-create commits caused ValidationException.dynamoStore — scoped-state pk separator changed from _ to # to prevent collisions when scopeId contains underscores. (unused in production currently)2026-03-26
dynamoStore — support custom key schemas via partitionKey / sortKey config options (defaults to pk / sk for backwards compat).2026-03-24
_def.typeName instead of instanceof so coercion works across Zod instances.{} on optional primitive fields now coerces to undefined.2026-03-24
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
resolveSession used agent.name instead of the app name; app.sessions.get() could never find handler-created sessions. HandlerConfig.appName is now required (app.handler.* injects it automatically).session.truncateAt() — broke commitSession (cursor divergence). Use session.forkAt() instead.2026-03-20
session.truncateAt(eventIndex) — truncate event history in-place; unlike forkAt, mutates the same session.sample() — large candidate pools now bypass the server-side distance matrix and compute diversity locally from raw vectors.2026-03-20
toolInputsSchema() — generated schema field data → input to match ToolInput consumed by applyInput().RestResponse.state — rest handler returns session state when response.state is enabled.2026-03-17
ctx.end() from the output tool no longer loops indefinitely.EPIPE, closed IPC, and upstream currentGeneration throws gracefully.2026-03-16
toolMocks — output tools (agent.output) are now intercepted the same as regular tools; unmocked output tools throw EvalToolError when toolMocks is provided.2026-03-16
requireLiveKit() await import() → require() to fix dual-package hazard that silently prevented conversations from starting.2026-03-15
openai, gemini, claude, voyage, qdrant — import from @animahealth/adk/openai, /gemini, /claude, /voyage, /qdrant instead of the main entry.```typescript
// Before
import { adk, openai, voyage, qdrant } from '@animahealth/adk'
// After
import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { voyage } from '@animahealth/adk/voyage'
import { qdrant } from '@animahealth/adk/qdrant'
```
2026-03-14
openai, @google/genai, @anthropic-ai/vertex-sdk, pg, @qdrant/js-client-rest, voyageai, ws, etc.) — require() → async import() so bundlers can tree-shake unused providers.sqliteIndex(dbPath) — now returns Promise<VectorIndex>; callers must await.2026-03-08
app.hook.voice() — typed entry point for custom voice hooks; accepts Partial<VoiceHook<S>> and returns VoiceHook<S>. Mirrors app.hook() for standard hooks.VoiceHook.onTranscript — fires for each user or assistant transcript message with full context (session, state, voice, event) and ctx.run() for text-mode sub-agent orchestration. Runs in a dedicated queue that never blocks the voice pipeline; drained before session commit so in-flight state mutations are preserved.VoiceSession.turnCount — number of user speech segments (blocks of continuous user speech). Incremented each time the user starts speaking. Use ctx.voice.turnCount > 0 to determine if the caller has engaged.output tool and turnCount > 0, the ADK automatically triggers it via generateReply({ toolChoice: 'required' }) on lifecycle events (disconnect, inactivity, expiry). Hooks no longer need to manually force the output tool.ctx.end() — synchronous control signal (like ctx.output()) that triggers the agent's output tool via the model. Return it from a tool's execute to end the session with model-generated output: return ctx.end(). Voice mode triggers generateReply; text mode sets endInvocation.ctx.run() / ctx.spawn() / ctx.dispatch() in voice mode — voice tools can now run text-mode sub-agents inline. Backed by BaseRunner; the sub-agent runs in the same session while the voice session continues.VoiceHandlerConfig.prewarm — optional per-subprocess init callback, called by LiveKit before any job runs (e.g. Sentry, OpenTelemetry setup).default() values declared in stateSchema are now applied to initial state across all entry points (voice setup, REST/AG-UI handlers, app.run(), test runner, voice evals). Setup functions no longer need to manually mirror defaults.SessionSetup.noiseCancellation — per-session noise cancellation profile set in setup(). Overrides handler-level sound.noiseCancellation when present.app.evaluate.report(options?) — factory that returns (result) => string. Configure once, call with any eval result. Replaces app.report(result, options).app.evaluate.voice.report(options?) — voice-specific report factory with narrowed types; renderCase receives VoiceEvalCaseResult with run.transcript, run.timing, run.recording.BaseEvalCaseResult / BaseEvalResult — shared base types for text and voice eval results; EvalCaseResult and VoiceEvalCaseResult both extend BaseEvalCaseResult.ReportOptions<S, R> — now generic over result type R; renderCase, sections, and footer callbacks receive the correct result/case types.MetricResult.data — optional Record<string, unknown> for attaching arbitrary structured data (computed values, intermediate measurements, debug info) to metric results.app.evaluate.case() / .cases() / .metric() — identity helpers for type-safe eval config definition; provides schema inference without runtime overhead.app.evaluate.voice.case() / .cases() — identity helpers for voice eval cases.app.initialState() — identity helper for typed multi-scope initial state config.MockToolContext.voice, .output(), .end(), .run() — expanded mock context surface; tool mocks can now test voice state, output signals, end signals, and sub-agent handoffs.Input.initialState / SessionSetup.initialState — multi-scope state seeding via handler input and voice setup; seeds session, user, patient, practice, org, team scopes in one call.EvalOptions.repeat / VoiceEvalOptions.repeat — run each case N times; results carry repeatIndex and repeatTotal metadata. Reports auto-group repeated cases with pass-rate summaries.BaseEvalCaseResult.repeatIndex / .repeatTotal — present when repeat > 1; structured repeat metadata replaces name-mangled [i/n] suffixes.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.Agent<TOutput, S> → Agent<S, TOutput> (and AgentConfig, OutputConfig, RunResult, AgentSpec); Agent<unknown, MySchema> simplifies to Agent<MySchema>ctx.output() in voice mode — output signal is ignored when turnCount === 0 (no user engagement). A voice session with no user turn cannot produce meaningful output; the session ends through the lifecycle event (disconnect, inactivity) instead of completed.SoundConfig.noiseCancellation — boolean | unknown → 'general' | 'telephony'. Resolves to BackgroundVoiceCancellation or TelephonyBackgroundVoiceCancellation from @livekit/noise-cancellation-node internally. Replace true with 'general'.VoiceEvalOptions.room — now optional; defaults to LIVEKIT_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRET env vars.StateChanges<S> — now generic over state schema for typed multi-scope seeding.MetricRun<S> — now generic over state schema; Metric<MetricRun<S>> provides typed session access.app.report() — use app.evaluate.report() insteadrepeatCases() — use EvalOptions.repeat / VoiceEvalOptions.repeat insteadparseRepeatName() — repeat metadata is now structured (repeatIndex, repeatTotal) on result objectsbeforeEnd callback prevents double-end race when multiple lifecycle events fire concurrently.onInactivity hooks, mirroring production handler behavior.<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
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.
evaluateVoice(cases, options) — runs VoiceEvalCase[] through LiveKit rooms with real audio; returns VoiceEvalResult with per-case transcripts, timing, recordings, and metricsVoiceEvalCase — defines agent under test, simulated userAgent, optional toolMocks, per-case metrics, retries, and timeoutVoiceEvalOptions — suite config: room (LiveKit URL + credentials), output directory for per-case reports and recordings, concurrency, stopOnFirstFailure, onCase progress callback, and suite-level metrics and hooksVoiceRunResult — run output with transcript: TranscriptEntry[], timing: VoiceTiming, recording: { path }, events, session, usage, and durationMsVoiceTiming — 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 definedgenerateReply({ toolChoice: 'none' }) on agent activation when no onEnter hook is definedonEnter — no longer auto-speaks when no hooks are defined; add an explicit onEnter hook to greet the callerVoiceEvalOptions.recording → VoiceEvalOptions.outputAgent.greeting, GreetingContext — move greeting content into system prompt; use onEnter for ephemeral instructions2026-03-02
require() shim — inject createRequire banner in tsup ESM output so lazy require() calls resolve correctly (fixes "Dynamic require of X is not supported")lodash dependency — isEqual → node:util.isDeepStrictEqual2026-03-01
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.
realtime() / openai.realtime() / gemini.realtime() — wraps a provider model for realtime use; text-mode agents use native WebSocket adapters, full audio pipeline (with stt + tts) falls through to the standard adapter@animahealth/adk/voice subpath — LiveKit voice handler; ctx.voice (VoiceSession) available in tools for generateReply, waitForPlayout, interrupt; app.handler.voice(config) on the handler namespaceOutputConfig accepts a FunctionTool — pass app.tool() as agent.output for tool-injection output mode; framework injects the tool, validates args, runs execute, captures output, and manages shutdownVoiceHook — extends Hook with onInactivity, onExpiry, onDisconnect lifecycle callbacks; defined inside the unified hooks array on VoiceHandlerConfig. Return false to keep session alive, true to explicitly end. Multiple hooks compose: any false vetoes endVoiceHandlerConfig.timeouts — handler-level { inactivity?, expiry? } defaults; per-agent timeouts override them after transfersVoiceSession.generateReply() accepts full ToolChoice — 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 to true for realtime models. Agent.maxTurns caps yield/resume cycles (default: 100; produces status: 'max_turns')Agent.greeting — string or (ctx: GreetingContext) => string | Promise<string>; injected as system event on first activation only, not on resumeAgent.timeouts — { inactivity?, expiry? } in ms; produces status: 'inactivity_timeout' or status: 'max_duration'ctx.output(value) on ToolContext, ToolExecutionContext, StepContext — ends the invocation early; value becomes RunResult.output.valueadk({ store }) — optional SessionStore on the app factory; defaults to in-memory. app.sessions exposes create, get, delete, list, commit, merge pre-bound to the app nameinMemoryStore(), dynamoStore(), sqliteStore(), postgresStore() — store factory functions; class constructors deprecatedUsageSummary.models — per-model ModelUsageEntry[] with calls, token counts, and per-model cost. ModelUsage gains audioInputTokens, audioOutputTokens, audioCachedTokens for realtime modelsUserEvent.source, AssistantEvent.source — 'text' | 'transcript' distinguishes direct text from audio transcriptiongpt-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 dependenciesctx.call() → ctx.run(), CallResult → SubRunResult, CallResultTransfer → SubRunResultTransfer — old names are deprecated aliases until 0.6.0AgentTimeouts.maxDuration → AgentTimeouts.expiry (deprecated alias maxDuration still works)ModelEndEvent.modelName → ModelUsage.modelName — model name now lives on the usage object; code reading event.modelName on model_end events must switch to event.usage?.modelNameHook.afterAgent — output parameter widened string → unknown; typed hooks with explicit string signatures need updatingHandlerConfig.sessionService — now optional; handlers auto-inherit the app's session servicecalculateCost(usage, modelName) → calculateCost(usage) — model name read from usage.modelNameformatCost — sub-dollar amounts use toFixed instead of toPrecisionRunStatus — 'inactivity-timeout' → 'inactivity_timeout', 'max-duration' → 'max_duration', 'participant-left' → 'participant_left'; added 'disconnected' and 'participant_left' as first-class statuses (previously collapsed to 'aborted')InvocationEndReason — same underscore renames as RunStatus; InvocationEndReason is now a strict subset of RunStatusInvocationState — limit end reasons (max_steps, max_turns, max_duration, inactivity_timeout, disconnected, participant_left) map to 'completed' instead of their raw reason stringBaseRunner — sessionService constructor option is now optional (defaults to in-memory)computeResumeContext() — multi-turn resume no longer bails early when a prior turn's root invocation is terminal; the findYieldedNodes scan already handles "nothing to resume"AgentTimeouts.maxDuration — use expiry instead. Will be removed in 0.7.0.sessionService() factory — use adk({ store }) and app.sessionsInMemoryStore, DynamoDBStore, PostgresStore, SQLiteStore classes — use inMemoryStore(), dynamoStore(), sqliteStore(), postgresStore()app.session() — use app.sessions.create()turn() — use app.handler.turn()ModelEndEvent.modelName — use ModelUsage.modelNamegemini-2.5-flash high-tier pricing bracket (price changed)2026-02-23
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.
app.evaluate(cases, options) — runs eval cases (single or array) with tool mocking and metrics; returns EvalResult<T, S> with app state S and optional per-case context Tapp.report(result, options) — generates markdown from EvalResult; optional title, footer (string or function), sections, and renderCase for custom contentEvalCaseResult.run — full RunResult (session, usage, output) preserved for reporting and metrics; convenience accessors .events, .usage, .turnsEvalOptions.onCase — progress callback (result, index, total) => void called after each case completesEvalCase.retries — number of additional attempts for flaky cases; retries on failed, error, or timeoutEvalCase.timeout — per-case timeout in ms; produces status: 'timeout' on expiryTestOptions.maxTurns — replaces maxIterations for consistency with SimulateOptionsRunResult.state — typed TypedState<S> accessor (same type as result.session.state); symmetric with input: { state } on the input sideAssistantEvent.media — optional MediaPart[] field; eliminates the need to cast when accessing media on assistant eventsSession.boundState(invocationId) — returns invocation-scoped TypedState; 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 toolsVectorFilter nested filters — must, should, must_not arrays now accept nested VectorFilter objects for compound boolean logic (e.g. AND-within-OR for per-slice filtering)mem.slices(names) — subset accessor for searching, sampling, and filtering across a selected set of slices in one call; return type narrows to only the selected slice typesSlicedSubset<TSlices> — type for the object returned by mem.slices()mem.variant.summary.returning('detailed') — cross-variant content: search using one variant's embeddings, return another variant's contentpartial used on parse failure when it passes schemaEvalCase — composes shared fields with SimulateOptions via Pick; initialState + firstMessage replaced by standard input (string or { message, state })EvalCaseResult — now EvalCaseResult<S> with run: RunResult<unknown, S>; tokenUsage → usage (type UsageSummary); events and turns preserved as convenience accessorsMetric.evaluate — signature (events: Event[]) → (run: RunResult); events available as run.session.events; built-in metric factories updatederror on EvalCaseResult is now { message: string; stack?: string }; EvalError interface and phase field removedaborted run status now maps to EvalStatus: 'aborted' instead of falling through to pass/fail; metric name collisions between suite and case level emit a console warningapp.handler.rest / app.handler.agui / app.handler.turn — now inherit app-level hooks and errorHandlers; handler-level config composes after app-level (app hooks run outer, handler hooks run inner)app.cli — now inherits app-level hooks, errorHandlers, and appName for session creation; CLI-level hooks compose after app-level; respects user-provided runner overrideunknown instead of any — accessing properties on scopes without a schema (e.g. state.user.name when no user schema is defined) now requires explicit narrowingmem.variant renamed from mem.variants# → _ — vector names (model#variant → model_variant), metadata prefixes (_variant# → _variant_, _slice# → _slice_); existing collections must be re-indexedModelStartEvent — messages: ContextMessageSummary[] → messageCount: number; serializedSchema removed. The CLI reconstructs exact context on-demand via session.forkAt() + buildContext() when the user expands a context block. Eliminates O(n²) storage of repeated context snapshots.SlicedMemory.slices → SlicedMemory.slice — singular accessor for per-slice operations (mem.slice.medication.search()); slices is now the subset methodeventCountMetric / eventSequenceMetric — filter callback infers narrowed event type from eventType; casts no longer needed. Metric<S> threads the app's state schema through run.stateidle → active → ending → ended) with atomic tryEnd() — first caller wins, concurrent shutdown events are safely ignoreduserAwayTimeout — supports repeated firings and inactivityCount reset on user speechSimulator type — removed from main package and adk/eval exports (was the literal signature of app.simulate)llmJudge / LlmJudgeConfig — removed from adk/eval; implement the Metric interface directly with your own judge agent (see migration below)runEval / runEvalSuite / EvalSuiteConfig — use app.evaluate(cases, options)EvalError — use error?: { message: string; stack?: string } on EvalCaseResultEvalCaseResult.tokenUsage — use result.usage (UsageSummary)EvalCase.initialState / EvalCase.firstMessage — use standard input (string or { message, state })TestOptions.maxIterations → TestOptions.maxTurnstoolCallCountMetric — use eventCountMetric with eventType: 'tool_call' and a filterdurationMetric — use timingMetric with measure: 'total_duration'modelLatencyMetric — use timingMetric with measure: 'model_latency_average'timeToFirstResponseMetric — use timingMetric with measure: 'time_to_first_assistant'initialState / firstMessage → inputEvalCase 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' }
```
(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 MetricllmJudge 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
handler.turn — shared streaming lifecycle (resolve session, run, stream events, commit, resolve conflict); returns StreamResult<TurnResult> with invocationId on the stream; use for custom projections (Slack, cron, CLI) without duplicating persistenceHook.afterTurn — turn-level lifecycle hook that runs within the handler.turn commit boundary (after run completes, before commitSession); state mutations are included in the commit atomically; receives TurnContext with session, result, and runnableTurnContext — context type for afterTurn; provides writable session, RunResult, and the runnableCommitStatus, TurnResult — TurnResult extends RunResult with sessionId, invocationId, optional commitStatus ('committed' | 'merged' | 'skipped' | 'orphaned')RunConfig.invocationId — optional root invocation ID; when set, runner uses it instead of generating one (enables traceability with AG-UI runId)RunConfig.errorHandlers — per-run error handlers, composed after runner and agent handlers (mirrors RunConfig.hooks)sqliteIndex() — SQLite vector index provider via sqlite-vec with auto-provisioningvoyage() sagemaker option — 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; contains accepts string | 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 via FilterInputslices config on memory() — heterogeneous collections with per-slice typed metadata; records.slices.medication.search() returns SearchResult<MedicationMeta>, records.search() returns a discriminated union with match.kind for narrowing (renamed to records.slice in 0.5.0)SlicedMemory, SliceAccessor, SlicedMatchUnion, SlicedSearchResult — types for sliced memoryCollectionSpec.payloadIndexes — auto-populated with _slice#kind when slices are declaredMatch.kind — optional; present when the document belongs to a slicebetter-sqlite3, sqlite-vec, @aws-sdk/client-sagemaker-runtime — optional peer dependencieshandler.agui — delegates to turn; events stream live (no buffering until commit); AG-UI runId is the turn’s invocationId; RUN_FINISHED result payload includes commitStatus for reconciliationhandler.rest — delegates to turn internally; external contract (buffered JSON response) unchangedresolveConflict return type — ConflictOutcome → CommitStatus (same values, adds 'committed' for happy path)UpsertItem.content — now required; content is stored alongside vectors and returned as Match.contentCollectionSpec.textIndexes — Qdrant users should provision text indexes from this field to enable SearchOptions.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 to variant in 0.5.0) — dynamic method replaced by upfront property mapcollectionSpec(config, variants) → collectionSpec(config) — variants now read from config.variants_content# → _variant#; _slice# reserved for slicescreateRunId() — use turn(config, input).invocationId (or the root invocation ID from the stream) as AG-UI runIdConflictOutcome — replaced by CommitStatus (import from handler or runtime types)vectorKey() — no longer public; use collectionSpec() insteadEmbedResult, Point, VectorMatch, DistanceMatrixPair, DistanceMatrixResult — removed from top-level exports (importable from @animahealth/adk/memory for custom providers)MemoryContextConfig, MemoryToolConfig — removed aliases; use inline mem.context() / mem.tool() config```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)
```
collectionSpec signature```typescript
// Before
collectionSpec({ model, collection }, ['questionnaire', 'full'])
// After
collectionSpec({ model, collection, variants: ['questionnaire', 'full'] })
```
2026-02-15
memory() — composable vector memory; typed metadata via Zod schema, provider-agnostic Embedder / VectorIndex interfacesvoyage() — Voyage AI embedding provider with batching (128/request), automatic inputType routing, 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() — returns ContextRenderer for deterministic recall before reasoningmem.tool() — returns FunctionTool for agent-driven recall via tool callmem.search() — returns typed Match<TMetadata>[] and computed embedding for downstream forwardingmem.upsert() — batch-aware write accepting content (embeds) or pre-computed embedding; validates dimensionsmem.updateMetadata() — merge metadata without re-embedding; null deletes 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 same model#variant vector 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 dependencies2026-02-11
isToolCallEvent, isToolYieldEvent, isToolInputEvent, isToolResultEvent, isAssistantEvent, and 10 more for every Event/StreamEvent memberSimulateYieldContext exported from main entry pointSimulateYieldContext, Transform, SimulateOptions — now generic over TArgs (defaults to unknown) so Transform callbacks can type ctx.args without casting2026-02-11
Runnable/Hook at orchestration boundaries widened to Runnable<any> / Hook<any>[] so typed agents and hooks work without casts2026-02-10
Exports the eval framework as @animahealth/adk/eval and moves simulation termination into the core run loop.
@animahealth/adk/eval — subpath export: runEval, runEvalSuite, interceptTools, metric factories, typesSimulateOptions.maxTurns, .maxDuration, .stateMatches — flat termination fields replacing maxIterationsRunStatus: 'terminated' with terminationReason: TerminationReason on the resultSimulateOptions.userAgent is now optional — tool-only flows no longer need a stubSimulateOptions.maxIterations → maxTurnsEvalSuiteConfig.parallel: boolean → concurrency: number (defaults to Infinity; use 1 for sequential)runEval / runEvalSuite — first arg is now a Simulator function (pass app.simulate)SimulateOptions.maxIterations — use maxTurnsEvalSuiteConfig.parallel — use concurrencymaxIterations 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
app.hook() — callable hook namespace for typed custom hooks, mirroring app.context()createEventId / createCallId — exported from public APIToolYieldEvent.preparedArgs → ToolYieldEvent.argsyieldedTools — returns ToolYieldEvent[] instead of ToolCallEvent[]tool_yield args instead of raw tool_call argsfc_ prefix at serialization boundaryyieldedTools 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
Consolidates the public API with a canonical Input/Output pair — descriptive yield statuses, unified media and tool, namespaced handler payloads, fewer redundant types.
RenderContext — all fields are now readonly; 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 to ctx.run() in 0.5.1) / ctx.spawn() — typed Agent<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 by RunResult, Session, CallResult (renamed to SubRunResult in 0.5.1), SpawnResultRunInput / BaseInput → Input — canonical input shape with message, tools, and state fieldsHandlerInput, RunOptions, TestOptions, SimulateOptions — payload fields grouped under input namespace; HandlerInput.input uses Input directly; toolInputs → input.tools; message widened to string | MessageInputRestResponse.output — uses canonical Output type; opt-in events, usage via HandlerConfig.responseRestResponse.yieldedTools — yielded tools promoted to top-level field (replaces toolCall / toolCalls)session.pendingYieldingCalls → session.yieldedTools; result.pendingCalls → result.yieldedTools; pendingCallIds → yieldedToolIdsresult.response — use result.output.value or result.output.textresult.awaitingInput — check result.status === 'yielded_message'ImageSource, AudioSource, DocumentSource, ImageInput, AudioInput — use MediaSource / MediaPart[]ResultInput, session.input.result() — use ToolInput / session.input.tool()CallResultOutput, CallOptions, SpawnOptions, DispatchOptions, FunctionToolHookContext, ToolHookContextRunResultOutput — use OutputRunInput, BaseInput — use InputSessionOutputNamespace — session.output now returns Output directlyAdkRunConfig — use RunOptions```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'],
}))
```
```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 })
}
```
```typescript
// Before
session.input.tool({ callId, data: value })
// After
session.input.tool({ callId, input: value })
```
```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 } },
],
})
```
```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' } } })
```
```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
```
```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 } }] },
})
```
```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 }>
```
```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.
```typescript
// Before
session.pendingYieldingCalls
result.pendingCalls
event.pendingCallIds
// After
session.yieldedTools
result.yieldedTools
event.yieldedToolIds
```
2026-02-07
safeParse failure — feed validation errors back as tool_result instead of silently hangingsession_) and AG-UI runs (run_)2026-02-07
Introduces the Hook system, pluggable session persistence, protocol handlers, and deterministic testing — replaces middleware, standalone runners, and user primitives.
Hook interface — unified observation (onEvent, onStep) + interception (before*/after*)app.run() accepts RunOptions with state and call-site hooksapp.test() — deterministic yield/resume testing (replaces scriptedUser())app.simulate() — LLM-powered eval loop (replaces agentUser())app.hook.logging(), app.hook.metrics() — built-in hook factories.app.handler.rest() / app.handler.agui() — protocol handlers with HandlerInput / HandlerConfigSessionStore interface with sessionService(store) factory — pluggable persistenceInMemoryStore (main entry), SQLiteStore (/stores/sqlite), DynamoDBStore (/stores/dynamodb), PostgresStore (/stores/postgres)runSessionStoreTests() — shared compliance suite for custom storessession.scopes, getScopedState() / setScopedState()ConflictError — thrown on OCC version conflict during commitSession()runner.run() no longer commits sessions** — callers must call sessionService.commitSession() (built-in handlers do this automatically)Middleware / Hooks → single Hook interface; onStream → onEventAgent.middleware + Agent.hooks → Agent.hooks: Hook[]; AdkConfig.middleware → AdkConfig.hookscomposeMiddleware() → composeHooks(); loggingMiddleware() → loggingHook(); cliMiddleware() → cliHook()session.version type: string → number; SessionStoreSnapshot → StoredSessionsrc/users/ — scriptedUser(), humanUser(), agentUser(), User interface (use app.test() / app.simulate())src/middleware/ — replaced by src/hook/InMemorySessionService, LocalSessionService — use sessionService(new InMemoryStore())getUserState, etc.) — use getScopedState / setScopedStateHandlerInput.toolInput — use toolInputs array@animahealth/adk/persistence subpath — use main entry or store subpaths```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.
```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 }] })
```
```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'
```
```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
app.cli() — transparent import() wrapper so CJS consumers work unchangedextractCurrentThoughtBlock, buildInvocationBlocks → exported from @animahealth/adk/cli instead of main entryreact, ink, ink-spinner, ink-text-input — optional peer dependencies for app.cli() consumerstsup.config.ts now includes an esbuild plugin that externalizes ./cli in the CJS build, so dist/index.js emits require("./cli/index.js") instead of inlining the CLI module tree.scripts/postbuild-cli-cjs-wrapper.cjs generates a CJS→ESM wrapper at dist/cli/index.js that does import('./index.mjs') to load Ink in native ESM context.lodash is force-bundled (noExternal) to avoid Node ESM's "Named export not found" error when importing CJS-only packages.moduleNameMapper mocks added for ink and ink-text-input since they are ESM-only and cannot be require()'d in test.**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
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.
adk() factory — creates typed app instance with name and schemaapp.* 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() — callable FunctionTool[] - toolDefinitions() — raw MCPToolInfo[] - 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 by session.input.result() in 0.3.3)result.output.* namespace with convenient accessors: - text — last assistant message text - value — structured output (if schema configured) - items — all assistant events - media — generated media (images, audio)session.input.message({ text, images, audio, media })result.output.media and tool __media return patternMediaPart type for image, audio, and document attachmentsImageInput and AudioInput helpers: { url } or { mimeType, data } (base64)spec.* namespace for cross-app reusable specsapp.* methods (agent() → app.agent(), etc.)app.context.* (injectSystemMessage() → app.context.system(), etc.)openai(), gemini(), claude()output: 'key' instead of output: output(schema, 'key')ctx.state.mode (not ctx.state.session.mode) - Other scopes remain explicit: ctx.state.user.theme, ctx.state.patient.idaddMessage() → session.input.message()addToolInput() → session.input.tool({ callId, data })UserEvent structure simplified: - text: string — always the text message - media?: MediaPart[] — optional attachments (images, audio)BaseRunner — use app.run() insteadapp.* methodsget, set, delete, toObject)initialState from CreateSessionOptions — use session.state.update()session.addMessage() — use session.input.message() insteadsession.addToolInput() — use session.input.tool() insteadsession.addToolResult() — use session.output.tool() (superseded by session.input.result() in 0.3.3)session.append() — use session.pushEvent() if needed (internal)```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');
```
```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 })
```
```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 })
```
```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 })
```
```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
```typescript
// Before
import { agent, tool } from '../../../modules/adk'
// After
import { agent, tool } from '@animahealth/adk'
```
PersistentSessionService (DynamoDB) was not part of this extraction and still came from the original service at the time of this release.
Agent Development Kit · Reference
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 covers the words the package spends twice.
Vocabulary
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
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, session, state and
runnable, plus per-status extras. There is no run.events;
the ledger is run.session.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 where the afterTurn hook
fires — including through rest and agui, which delegate to
it. 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 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
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, accumulating across
every run on the session. run.usage reports that accumulated total, not
one run's slice, 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 only 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
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
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
Two layers: 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
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 not the rest.
| 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, but its tools 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.
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, /agents/coding/claude-code — may change or
vanish in any release, and is reachable only through its own subpath, never the main
entry.
|
Read this one twice
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.
| 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, 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
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
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 sizes the column, the virtual table, or the named vector the index
creates. modelName matters as much — 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
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
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)
That schema has three consequences. 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
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 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 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.
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: 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
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.
export function runVectorIndexTests(
name: string,
createIndex: () => Promise<VectorIndex>,
cleanup?: () => Promise<void> | void,
)
The suite is package source, not a published entry point —
@animahealth/adk/testing does not export it — so run it against an index of your
own by copying it from the package's test sources. The next section is the behaviour it pins
down.
Step 7
One suite defines the contract, and the in-memory index is its reference implementation. It asserts shared behaviour only, and stays 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.
|
Three of the four shipped backends register against it. The in-memory reference and sqlite-vec
need no environment, so they run anywhere; pgvector registers when you point it at a Postgres
carrying the vector extension. That registration wipes and recreates the
public schema before it starts, and the suite reuses fixed collection names, so
point it only at a dedicated test database.
Qdrant is provisioned, does not run the shared suite. 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 hold for it.
Two Qdrant behaviours therefore differ from the contract, and both matter when you hold the
index yourself. 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. And
get() and count() both ignore the variant option: a
count is a count of points, not of a named vector.
Agent Development Kit · Experimental
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
@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 it before you depend on anything here.
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. 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
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
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,
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 these three shapes and invents
nothing else.
Step 3
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 contracts 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
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 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
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. Everything
outside it raises UnsupportedCCFeatureError, whose message names the specific
feature. Nothing is 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)
|
Refusing budget beats approximating it: cross-run token accounting does not exist
here, so budget.remaining throws instead of returning a number a workflow would
then trust.
Three consequences of how the loader executes a file. Each one is a constraint to write a workflow file around.
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.
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
Claude Code has one agent(). The ADK 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
A workflow file cannot import Zod, so agent({ schema }) carries a JSON Schema
object literal. The loader converts it statically, and the conversion is narrow: widening a
constraint would let a node return a shape the file then dereferences.
required is preserved exactly as written — a strict subset. Properties not
listed become .optional(). It is never widened to every key, never emptied.
additionalProperties: false becomes a strict object that rejects extra keys.
enum membership is enforced whatever the declared type; a
non-string enum becomes a union of literals.
string, number, integer, boolean,
null, and array element types are enforced, never collapsed to
z.any(). Nested objects inside arrays keep their own required.
type and no enum stays unknown — the
converter does not fabricate a constraint it was not given.
What reaches the file is the parsed value, unwrapped. A validation failure the node cannot
recover from is a null, which the file is expected to guard — the same contract
every other node failure has. Structured output covers how
schema-shaped output behaves on the native surface, where you write the Zod yourself.
Agent Development Kit · Experimental
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/
Read this first
Experimental means no deprecation cycle. The coding-agent surfaces ship
only behind their own subpaths — @animahealth/adk/agents/coding and
@animahealth/adk/agents/coding/claude-code — and any release may change or
remove them. Nothing here is re-exported from the main entry, so none of it 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 read
rather than run — it transcribes src/agents/coding/, which is the thing to open
when something surprises you.
Step 1
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 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. It differs from run on failure:
execute drains the stream, so a failing run rejects there rather than resolving
an error-status result.
// 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. With the shipped Claude Code agent, send is an abort channel:
it acts on abort and logs a warning for the other two.
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
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
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.
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, createWorkspaceProvisioner } from '@animahealth/adk/agents/coding'
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. 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
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 the smallest
interface that can: one method in, a disposable handle out.
// src/agents/coding/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, so the failure mode that lets two coding agents edit the same tree is not
reachable. Supply a backend for every strategy you use: with none,
provision returns an in-process placeholder — a path nothing created, and a
dispose that does nothing. It exercises the lifecycle; it does not isolate a run.
import {
createWorkspaceProvisioner,
ISOLATION_STRATEGIES, // ['session', 'worktree', 'sandbox']
isIsolationStrategy,
UnknownIsolationStrategyError,
WorkspaceProvisionError,
} from '@animahealth/adk/agents/coding'
// 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
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 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,
})
Where the package stops
The package ships one seam between a coding agent and the environment it works in: the
WorkspaceProvisioner above. It is per-run, it hands
back a path and a dispose, and runCodingNode drives it. There is no
second environment abstraction underneath it.
So running a coding agent off the local filesystem — in a container, in a remote sandbox, on a
machine that is not this one — is yours to build. The provisioner is where it plugs in: a
sandbox backend that materializes the environment, returns the path the coder
should work in, and tears it down in dispose. The package ships no container or
remote-sandbox backend, so nothing here spares you the provider's own SDK.
Aside
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
Running a Claude Code node, and scoring it on anything real, both depend on something the package deliberately does not carry.
| What | Why, and what to do |
|---|---|
| The harness SDK |
@anthropic-ai/claude-agent-sdk is not a declared dependency, and nothing
imports it statically — createClaudeCodeAgent loads it on the first run.
So importing the subpath or building a factory costs nothing, npm install
will not fetch the SDK for you, and a run without it fails with one error naming the
package. Install it alongside the ADK to run a Claude Code node.
|
| The delta probe |
With no probe, the delta is synthesized from the result's modifiedFiles
rather than read from the filesystem: the diff becomes
modified <file> lines and the command result becomes
status: completed, so the default score answers only whether the coding
agent claimed to touch a file. 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.
|
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.