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.

Cells · scripted model, no key required Source · 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.