Agent Development Kit · Start · after the quickstart
Five primitives and one ledger
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
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,
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
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 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(' · '))
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
A step is your code, in the same session
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
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 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.