Agent Development Kit · Build · after Five primitives

Many agents, two ways to hand off

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

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

The axis

Two moments at which one agent can hand work to another

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

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

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

Step 1

Composition is data, and the result is a projection

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

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

const app = adk()

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

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

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

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

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

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

composition

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

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

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

Step 2

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

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

import { isInvocationStartEvent } from '@animahealth/adk'

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

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

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

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

handoffs

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

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

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

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

Step 3

Transfer: the agent that replaces the one that called it

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

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

const billing = mockAgent('billing')

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

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

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

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

transferred

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

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

Step 4

Two wrappers you would otherwise write twice

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

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

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

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

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

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

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

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

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

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

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

patterns

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

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

Step 5

A runnable that has not chosen an app yet

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

import { spec } from '@animahealth/adk'

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

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

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

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

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

reuse

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

Step 6

Below the graph: one-shot calls and bounded concurrency

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

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

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

import { fanout } from '@animahealth/adk'

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

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

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

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

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

verdicts

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

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