Agent Development Kit · Build · after Five primitives
Many agents, two ways to hand off
A container decides the shape before the run starts. A verb decides it while the run is
happening. This chapter is the second half of composition: nesting the five kinds, the in-run
verbs run, spawn, dispatch and transfer, the
gated and cached wrappers, a runnable that is not bound to an app
yet, and the one-shot call that skips the graph entirely. Every cell but the last runs with
the model scripted.
src/core/orchestration.ts
The axis
Two moments at which one agent can hand work to another
Five primitives gave you the vocabulary: agent, step, sequence,
parallel, loop. Those are the first moment — you write the graph, and the runner
walks it. The second moment is inside a running step or tool, where the code that decides
already knows what the data looks like. Both write to the same session, and one field
separates them: a container's child carries a parentInvocationId and nothing
else, while a handed-off child also carries a handoffOrigin naming the verb.
| Decided | Written as | You get back | The caller |
|---|---|---|---|
| before the run | app.sequence · app.parallel · app.loop |
a runnable — plain data | is the container |
| during the run | ctx.run(agent) |
Promise<SubRunResult> |
waits |
| during the run | ctx.spawn(agent) |
SpawnHandle — wait(), abort() |
carries on |
| during the run | ctx.dispatch(agent) |
DispatchHandle — two ids, no result |
carries on |
| during the run | return someRunnable |
nothing | ends; the target takes over |
The three verbs live on the context object, so they are available in any step and in any
tool's execute — StepContext and ToolContext both
extend OrchestrationContext. Every cell below but the last scripts the model with
the test kit and needs no key. The last one asks a real model, so paste a key if you want it;
it stays in this browser.
Step 1
Composition is data, and the result is a projection
Containers nest without ceremony: a parallel is a legal child of a sequence, and a step between them sees everything both wrote. Nothing is passed along a wire. Below, two agents run concurrently, a step counts what they said, and a third agent writes the summary.
import { adk, isAnnotationEvent, isAssistantEvent, isModelEndEvent } from '@animahealth/adk'
import { mockAgent, runTest, user, model } from '@animahealth/adk/testing'
const app = adk()
const scan = mockAgent('scan')
const risk = mockAgent('risk')
const writeUp = mockAgent('write_up')
// A parallel nested inside a sequence, with your own code in between.
const checks = app.parallel({ name: 'checks', runnables: [scan, risk] })
const tally = app.step({
name: 'tally',
execute: (ctx) => {
const replies = ctx.session.events.filter(isAssistantEvent).length
ctx.note(`${replies} replies before the write-up`, { kind: 'mark' })
ctx.output({ repliesSoFar: replies })
},
})
const review = app.sequence({ name: 'review', runnables: [checks, tally, writeUp] })
const reviewRun = await runTest(review, [
user('Review this pull request.'),
model('No secrets in the diff.'),
model('Two risky migrations.'),
model('Ship it once the migrations are split.'),
])
const composition = {
status: reviewRun.status,
modelCalls: reviewRun.events.filter(isModelEndEvent).length,
outputText: reviewRun.result.output.text,
outputValue: reviewRun.result.output.value,
outputItems: reviewRun.result.output.items.length,
notes: reviewRun.events.filter(isAnnotationEvent).map((e) => e.message),
}
composition
Three agents, three scripted replies, three model calls — the step costs none. Read the output
fields carefully, because this is where composition surprises people. A container does not
return its last child's return value. output.text is the last
assistant event in the session and output.items is
every assistant event in it, both computed from the ledger when the run finishes.
output.value is undefined here, and that is not a bug in the
cell.
ctx.output(value) records the value on the step's own result. A sequence builds
its terminal result from the session, and never copies a child's value into it — so a step's
output value survives only when that step is what you ran. To move a value between children,
put it in ctx.state, which every runnable in the session can read.
The stopping rule follows the same logic. A sequence abandons the rest of its children when a
child comes back error, aborted, max_steps, or
yielded_tool; anything else and the next child runs. A step that calls
ctx.skip() therefore skips itself, not the sequence. A loop applies exactly the
same four-way test to each iteration, and re-evaluates its while predicate before
every one.
Step 2
run, spawn, dispatch: the same handoff, three waits
The three verbs differ only in how the caller waits. ctx.run awaits a
SubRunResult. ctx.spawn starts the agent and hands you a
SpawnHandle you can wait() on later or abort().
ctx.dispatch starts it and returns only its name and invocation id — there is no
result to collect. All three take the agent plus a message, or
{ input, timeout } when you want more: input.state seeds the child's
temp state, and timeout is honoured by run and by a handle's
wait() — dispatch, having nothing to wait on, ignores it. All three
run on the session you are already in.
import { isInvocationStartEvent } from '@animahealth/adk'
const grader = mockAgent('grader')
const summariser = mockAgent('summariser')
const auditor = mockAgent('auditor')
const triage = app.step({
name: 'triage',
execute: async (ctx) => {
const graded = await ctx.run(grader, 'Grade this ticket.') // wait here
const pending = ctx.spawn(summariser, 'Summarise the thread.') // wait later
const sent = ctx.dispatch(auditor, 'Write the audit line.') // never wait
const summary = await pending.wait()
ctx.output({
ranInline: graded.output.text,
spawned: `${pending.agentName} · ${summary.status}`,
dispatched: sent.agentName,
})
},
})
const triageRun = await runTest(triage, [
user('Ticket 41 came in.'),
model('P2'),
model('The customer cannot log in.'),
model('audit ok'),
])
const handoffs = {
value: triageRun.result.output.value,
origins: triageRun.events
.filter(isInvocationStartEvent)
.map((e) => `${e.agentName} ← ${e.handoffOrigin?.type ?? 'root'}`),
}
handoffs
Four invocations, and each one says how it was reached. That stamp is the reason the flat
ledger stays readable: handoffOrigin carries the type and the parent's invocation
id, so a reviewer can rebuild the call tree from rows alone. A step run at the top has no
origin at all.
Note the auditor. Nothing awaited it, and it is in the ledger anyway — because fire-and-forget describes the caller, not the run. A spawned or dispatched agent is registered as a producer on the run's event channel, and the run's promise resolves only when every producer has finished. The parent's own invocation closes first; the work outlives the step, not the run. To outlive the run, you need a store and a second call, not a background task.
One refusal is worth knowing before you reach for it: ctx.run rejects an agent
that yields. A delegate stopping to ask a human has nowhere to put the question, so it throws
and points you at yielding tools in the parent agent instead.
Concurrent handoffs share the ledger, and the ledger is where
output.text comes from.
Two agents running at once on one session both resolve output.text to the same
thing — whichever assistant event landed last. Read a concurrent branch's own
words from its events, or
give it an output schema, rather than trusting
output.text to be its reply. Sequential ctx.run is unaffected.
Step 3
Transfer: the agent that replaces the one that called it
The fourth handoff has no verb. Return a runnable from a tool's execute, or from
a step's, and the runtime treats it as a transfer: the current agent's invocation ends and the
target's begins in its place. There is no nesting and no return trip. This is how a front desk
hands a caller to a specialist.
import { openai } from '@animahealth/adk/openai'
import { z } from 'zod'
const billing = mockAgent('billing')
const handOver = app.tool({
name: 'hand_over',
description: 'Give the conversation to the billing specialist',
schema: z.object({ reason: z.string() }),
// Returning a runnable is the transfer. Nothing else about the tool changes.
execute: () => billing,
})
const frontDesk = app.agent({
name: 'front_desk',
model: openai('gpt-5.6-luna'),
context: [app.context.system('Hand billing questions over.'), app.context.history()],
tools: [handOver],
})
const handedOver = await runTest(frontDesk, [
user('My invoice is wrong.'),
model({ toolCalls: [{ name: 'hand_over', args: { reason: 'invoice dispute' } }] }),
model('I can see it — the VAT line is duplicated.'),
])
const transferred = {
status: handedOver.status,
finalText: handedOver.result.output.text,
origins: handedOver.events
.filter(isInvocationStartEvent)
.map((e) => `${e.agentName} ← ${e.handoffOrigin?.type ?? 'root'}`),
ledger: handedOver.events.map((e) => e.type),
}
transferred
Read the ledger tail: front_desk reaches invocation_end before
billing opens its invocation_start. That is the difference from
ctx.run, which nests the callee inside the caller. The run status is
completed, not something transfer-specific — the caller is gone, so the target's
outcome is the run's outcome. Temp state follows the transfer; the rest of the session was
never separate.
A transfer that happens inside a ctx.run is resolved before that promise
settles, so the calling step sees the final agent's result and never has to chase a chain. One
restriction: an afterTool hook cannot transfer, because the tool result has
already been written — see guardrails for where each hook can and
cannot intervene.
Step 4
Two wrappers you would otherwise write twice
gated(runnable, check) puts a step in front of a runnable. The check receives a
StepContext; return nothing and the runnable runs, call
ctx.skip() and it does not.
cached(runnable, { key, scope, ttlMs }) is that same gate with the check written
for you: if the state key already holds a value, skip. Both return a
Sequence carrying the wrapped runnable's own name, so wrapping does not rename
anything in the ledger.
Both read state, and state lives in scopes on the session — session is the one
every runnable in the run shares, which is why the runs below seed it through
initialState. Sessions and state is the chapter.
import { cached, gated } from '@animahealth/adk'
const enrich = mockAgent('enrich')
const translate = mockAgent('translate')
// Run the translator unless the ticket is already in English.
const guarded = gated(translate, (ctx) => {
if (ctx.state.locale === 'en') ctx.skip()
})
// Run the enricher only when session state has no profile yet.
const memoised = cached(enrich, { key: 'profile', scope: 'session' })
const pipeline = app.sequence({ name: 'pipeline', runnables: [memoised, guarded] })
const cold = await runTest(pipeline, [user('Hallo'), model('profile built'), model('Hello')])
const warm = await runTest(pipeline, [user('Hallo'), model('Hello')], {
initialState: { session: { profile: { name: 'Ada' }, locale: 'de' } },
})
const english = await runTest(pipeline, [user('Hello'), model('profile built')], {
initialState: { session: { locale: 'en' } },
})
const patterns = {
cold: cold.events.filter(isAssistantEvent).map((e) => e.agentName),
warmCacheHit: warm.events.filter(isAssistantEvent).map((e) => e.agentName),
alreadyEnglish: english.events.filter(isAssistantEvent).map((e) => e.agentName),
whatCachedBuilt: `${memoised.kind} '${memoised.name}' wrapping ${memoised.runnables.map((r) => r.name).join(', ')}`,
}
patterns
Cold, both run. Warm, the cached agent is skipped and only the translator speaks. Already in
English, the gate skips the translator instead. The last field shows what
cached actually built: a sequence named enrich whose single child is
a step named enrich_gate, which returns the wrapped agent when the check passes.
Nothing is hidden — it is the routing step from Five primitives,
written once.
scope picks which state scope the key is read from and defaults to
session; ttlMs adds an age test, measured from the
state_change event that last wrote the key. An expired or absent key runs the
runnable again. Neither wrapper ever writes the key; the wrapped runnable does, through its
output config or a step of its own.
Step 5
A runnable that has not chosen an app yet
app.agent and friends bake in one app: its schema, its hooks, its error handlers.
That is what you want inside a program and wrong for a library. spec defers the
binding. spec.sequence()(fn) returns a function of an app, and
app.use(thatSpec) calls it — so the same definition yields a different runnable
per app, with each app's own configuration already applied.
import { spec } from '@animahealth/adk'
const other = adk({ name: 'other-app' })
// No app in sight — `boundApp` arrives when someone uses it.
const triageSpec = spec.sequence()((boundApp) => ({
name: 'triage_flow',
runnables: [
boundApp.step({
name: 'stamp',
execute: (ctx) => {
ctx.state.stamped = true
},
}),
boundApp.agent({
name: 'classifier',
model: openai('gpt-5.6-luna'),
context: [boundApp.context.system('Answer in one word.'), boundApp.context.history()],
}),
],
}))
const here = app.use(triageSpec)
const there = other.use(triageSpec)
const specRun = await runTest(here, [user('My card was declined.'), model('billing')])
const reuse = {
distinctRunnables: here !== there,
built: `${here.kind} '${here.name}': ${here.runnables.map((r) => `${r.kind}:${r.name}`).join(' → ')}`,
said: specRun.events.filter(isAssistantEvent).map((e) => e.text),
}
reuse
One definition, two runnables, and the cell ran the one bound to app. There is a
spec factory per kind — tool, step,
context, agent, sequence, parallel,
loop — each taking an optional schema so the callback's ctx is typed
before any app exists. This is how a package ships an agent that a consuming app finishes
configuring, and it is the only reason to reach past app.*.
Step 6
Below the graph: one-shot calls and bounded concurrency
Some work is not an agent. Judging an answer, extracting a field, scoring a candidate — no
tools, no memory of the conversation, no reason to appear in the ledger.
app.ask(prompt, opts) is that call: a no-tools agent on a
fresh session, so nothing it does touches yours. Pass a Zod
schema and the return type is the parsed value rather than text; pass
system, signal, or retries to shape the attempt. The
model comes from opts.model or the app's defaultModel, and if
neither is set the call throws rather than guessing.
fanout(thunks, { limit }) is the other half: run zero-argument async functions
with at most limit in flight. It returns results in input order, turns a rejected
thunk into null instead of failing the batch, and never rejects. It knows nothing
about agents — it is the concurrency primitive the workflow runtime uses, exported because
fanning out judgements is the common case.
This cell calls OpenAI with the key from the box above. Three tickets, two at a time, each answered as a typed object.
import { fanout } from '@animahealth/adk'
const live = adk({ defaultModel: openai('gpt-5.6-luna') })
const Verdict = z.object({
severity: z.enum(['low', 'medium', 'high']),
team: z.string(),
})
const tickets = [
'The invoice VAT line is duplicated.',
'The app crashes on every login attempt.',
'Please send a copy of last month’s receipt.',
]
// fanout takes zero-argument thunks, so each ask is wrapped rather than started.
const askVerdict = (ticket: string) => () =>
live.ask(`Route this support ticket. Ticket: ${ticket}`, { schema: Verdict })
const verdicts = await fanout(tickets.map(askVerdict), { limit: 2 })
verdicts
Three verdicts, in the order the tickets were written, whatever order they finished in. Only
OutputParseError is retried — see
Structured output — so a provider error surfaces immediately
instead of being spent on re-asks. If you need tools, state, or a resumable pause, you have
outgrown ask: build an agent and put it in the graph.
That is the whole handoff surface. Sessions and state covers the state these agents share, what the model actually sees covers what each one is shown, and dynamic workflows covers the experimental loader that builds a graph like this one from a file.