Agent Development Kit · Start · after the quickstart
Five primitives and one ledger
The quickstart ran one agent. A program is usually several — a classifier, a worker, a check, a retry — and the ADK gives you exactly five runnable kinds to compose them: agent, step, sequence, parallel, loop. They all write to one append-only event ledger, held by one session. Every cell below runs the shipped runtime with the model scripted, so nothing here needs a key.
src/types/runnables.ts
Reads after · Quickstart
The vocabulary
Everything the runner executes is one of five shapes
Runnable is a union of five interfaces. Each carries a kind and a
name, and the runner switches on kind — there is no sixth case and
no escape hatch. The factories on your app (app.agent, app.step,
app.sequence, app.parallel, app.loop) return plain
objects. A runnable is data: you can build it, nest it, and pass it around before anything
runs.
| Kind | Carries | What the runner does with it |
|---|---|---|
agent |
model, context, tools |
Calls the model in a loop until it stops asking for tools. The only kind that talks to a provider. |
step |
execute(ctx) |
Runs your TypeScript. No model, no prompt. May return another runnable to delegate to. |
sequence |
runnables |
Runs them in order on the same session, stopping early on error or yield. |
parallel |
runnables, merge, minSuccessful |
Runs branches concurrently on cloned sessions, then merges their new events back. |
loop |
runnable, while, maxIterations |
Repeats one runnable while the condition holds, capped by the iteration limit. |
Any of them may hold any of them: a loop over a sequence of agents, a parallel of loops, a step that returns whichever agent the data calls for.
Step 1
A sequence of two agents, sharing one session
mockAgent(name) is an agent whose model is scripted — it lets a page like this
one show composition without a key. The scripted replies are handed out in the order the model
is actually called. So a sequence of two agents consumes two model() steps: one
turn each.
import { adk, isModelEndEvent } from '@animahealth/adk'
import { mockAgent, runTest, user, model, getLastAssistantText } from '@animahealth/adk/testing'
const app = adk()
const triage = mockAgent('triage')
const reply = mockAgent('reply')
const intake = app.sequence({ name: 'intake', runnables: [triage, reply] })
const intakeRun = await runTest(intake, [
user('My laptop will not boot.'),
model('Category: hardware.'),
model('Hold the power button for ten seconds, then plug in the charger.'),
])
const intakeSummary = {
status: intakeRun.status,
modelCalls: intakeRun.events.filter(isModelEndEvent).length,
lastReply: getLastAssistantText(intakeRun.events),
}
intakeSummary
Two model calls, one status, and the last reply is the second agent's. Nothing was passed from
triage to reply by hand. They share a session, and the second agent
reads the first agent's answer out of it — which is what the next section is about.
Step 2
One ledger, and both agents are in it
A session is an append-only list of events. Print it with the agent that wrote each one and the structure of the run falls out. The sequence opens an invocation, each child agent opens its own inside it, and the model calls and replies land between the brackets.
intakeRun.events.map((e) => [e.type, e.agentName].filter(Boolean).join(' · '))
Note what is not there: no per-agent history, no message bus, no copy of the
conversation handed from one agent to the next. There is one list. Every event carries the
invocationId that produced it and the agentName that wrote it, so
the nesting is recoverable from the flat list alone. Session state is computed from these
events rather than stored beside them.
Where to reach for it. The ledger belongs to the session:
run.session.events. run.stepEvents is this run's slice of it, and
the test kit's TestResult.events is an alias for session.events —
which is what the cells on this page print. There is no run.events. The same
test result also carries TestResult.result: the RunResult you get
by awaiting app.run, with its output, usage,
status and session.
Step 3
A step is your code, in the same session
Not every node needs a model. A step receives a context with the session, typed state,
ctx.note() for annotations, ctx.output() to record the step's own
output value, and the signals ctx.skip(), ctx.fail(message) and
ctx.respond(text). Return a runnable from execute and the step
delegates to it — that is how routing is written here.
import { isAnnotationEvent, isAssistantEvent } from '@animahealth/adk'
const audit = app.step({
name: 'audit',
execute: (ctx) => {
const authors = ctx.session.events.filter(isAssistantEvent).map((e) => e.agentName)
ctx.note(`${authors.length} replies from ${authors.join(', ')}`, { kind: 'mark' })
},
})
const audited = app.sequence({ name: 'audited_intake', runnables: [triage, reply, audit] })
const auditedRun = await runTest(audited, [
user('My laptop will not boot.'),
model('Category: hardware.'),
model('Hold the power button for ten seconds.'),
])
auditedRun.events.filter(isAnnotationEvent).map((e) => e.message)
The step read the two agents' replies straight off the ledger, then appended an
annotation event of its own. Narrowing the union is a filter,
because the package exports a type guard per event type. The sequence now has three children
but still consumes two scripted replies: a step never calls a model, so it emits no
model_start/model_end pair. Deterministic work — parsing, gating,
fetching, scoring — belongs in a step, where it costs no tokens and cannot hallucinate.
Step 4
Repeat one, or run several at once
A loop takes a single runnable, a while predicate over
{ iteration, lastResult, state, session }, and a required
maxIterations so a bad predicate cannot run forever. A parallel takes several
runnables, clones the session for each branch, runs them concurrently, and merges each
branch's new events back into the parent ledger. failFast,
branchTimeout, minSuccessful, and a custom merge are
there when the default concatenation is not what you want.
const drafter = mockAgent('drafter')
const revise = app.loop({
name: 'revise',
runnable: drafter,
maxIterations: 3,
while: (ctx) => ctx.iteration < 3,
})
const loopRun = await runTest(revise, [
user('Draft a subject line.'),
model('Draft 1'),
model('Draft 2'),
model('Draft 3'),
])
const sentiment = mockAgent('sentiment')
const wordcount = mockAgent('wordcount')
const analysis = app.parallel({ name: 'analysis', runnables: [sentiment, wordcount] })
const fanRun = await runTest(analysis, [
user('Two reviews came in.'),
model('positive'),
model('nine words'),
])
const branching = {
drafts: loopRun.events.filter(isAssistantEvent).map((e) => e.text),
branches: fanRun.events.filter(isAssistantEvent).map((e) => `${e.agentName}: ${e.text}`),
}
branching
Three iterations, three drafts, one session. Two branches, two replies, still one session — the branch sessions are clones, and only the events they produced come back. The merge goes into the same append-only list, so a parallel is not a special result type you have to unpack. Whatever the branches did is simply in the ledger afterwards.
Step 5
The ledger is history — the prompt is rendered from it
An agent's context is an array of renderers applied in order. The render context
they receive starts with an empty event list. Nothing reaches the model until
a renderer puts it there. app.context.system(…) adds an instruction, and
app.context.history() pulls events out of the ledger — its scope
decides which ones. This runs again before every model call. Put a recorder of your own in the
array and you can watch it happen.
import { openai } from '@animahealth/adk/openai'
const rendered: { at: string; sees: string[] }[] = []
const recordAs = (at: string) =>
app.context((ctx) => {
rendered.push({ at, sees: ctx.events.map((e) => e.type) })
return ctx
})
const scribe = app.agent({
name: 'scribe',
model: openai('gpt-5.6-luna'),
context: [
recordAs('renderers start'),
app.context.system('Answer in one sentence.'),
app.context.history(),
recordAs('history added'),
],
})
const twice = app.loop({
name: 'twice',
runnable: scribe,
maxIterations: 2,
while: (ctx) => ctx.iteration < 2,
})
const scribeRun = await runTest(twice, [
user('What is the ledger for?'),
model('It is the append-only history of everything that happened.'),
model('Every runnable in the program writes to it.'),
])
const seam = { renderedPerModelCall: rendered, ledgerLength: scribeRun.events.length }
seam
Four entries, two per model call: empty at the start of the renderer chain, populated after
history(). The second call's context contains the first call's reply, because by
then it was in the ledger. The agent's model config never changed; the input to it was rebuilt
from scratch, twice.
The ledger records what happened in full, and the prompt is a projection of it chosen per agent and per turn — a renderer decision, taken apart in Context: what the model sees. What that ledger buys you once it is persisted belongs to Sessions and state.