Agent Development Kit · Build
Sessions and state
A session is an append-only list of events. State is not stored beside that list — it is replayed from it, key by key, so every value an agent holds has an event that put it there. This chapter builds a schema, writes state from a tool, reads the audit trail back, and rewinds the session to an earlier event.
ctx.state) and the quickstart's ledger
Package · @animahealth/adk (MIT)
Step 1
A schema, and its scopes
State is declared on the app, as Zod types grouped by scope. The scopes are
session (this conversation), the shared scopes user,
patient, practice, org and team (each
keyed by an id the session carries, so several sessions can address the same one), and
temp (one invocation, never recorded). Declare only the scopes you use.
The schema is a compile-time contract and a defaults table. It types every read and write
below, and its .default(…) values are filled in when state is seeded through it —
which step 3 does.
Step 2
Writing state from a tool
A tool's ctx.state is the session scope at the top level, with the shared scopes
and temp hanging off it: ctx.state.stage,
ctx.state.user.displayName, ctx.state.temp.scratch. There are two
spellings: assign a key, or hand ctx.state.update(changes) a batch of them. Both
follow the same rule — every key that actually changes appends its own
state_change event, a deep-equal value records nothing, and
undefined deletes the key.
import { adk } from '@animahealth/adk'
import { MockAdapter } from '@animahealth/adk/testing'
import { z } from 'zod'
const schema = {
session: {
stage: z.enum(['intake', 'collecting', 'triaged']).default('intake'),
concerns: z.array(z.string()).default([]),
},
user: {
displayName: z.string().default('friend'),
},
}
// The model on this page is a scripted stand-in, so every cell runs with no key. Step 3 writes
// the script; everything else is the shipped runtime.
const scriptedModel = new MockAdapter()
const app = adk({ name: 'triage', schema, adapters: { openai: scriptedModel } })
const noteConcern = app.tool({
name: 'note_concern',
description: 'Record one concern the patient reports.',
schema: z.object({ concern: z.string() }),
execute: (ctx) => {
ctx.state.concerns = [...ctx.state.concerns, ctx.args.concern]
ctx.state.stage = 'collecting'
return { concerns: ctx.state.concerns }
},
})
const triage = app.agent({
name: 'triage',
model: { provider: 'openai', name: 'gpt-5.6-luna' },
context: [app.context.system('Log every concern with note_concern.'), app.context.history()],
tools: [noteConcern],
})
const built = { scopes: Object.keys(app.schema), agent: triage.name, tool: noteConcern.name }
built
One thing the scope list above leaves out: temp is scratch space held in memory
for the invocation, and reading or writing it outside an invocation throws.
Step 3
Run it, then read state
setResponses hands the scripted model this run's two turns: one that calls the
tool, one that answers. Everything else is the ordinary run path — the tool really executes,
and no key or network is involved. input.initialState seeds the session before
the first turn, scope by scope, and the app's schema fills the rest in from
.default(…) — which is why stage starts at 'intake' and
concerns at [] without either being named.
scriptedModel.setResponses([
{ toolCalls: [{ name: 'note_concern', args: { concern: 'sore throat' } }] },
{ text: 'Noted a sore throat. Anything else?' },
])
const run = await app.run(triage, {
input: {
message: 'I have a sore throat.',
initialState: { session: {}, user: { displayName: 'Ada' } },
},
})
const afterRun = { stage: run.state.stage, concerns: run.state.concerns }
afterRun
The run is over; the session is not. run.session is the same session object the
tool wrote to, and its state is recomputed from the events on every read. Nothing
was saved to a state table — replay is the storage.
Step 4
The audit trail
Every write left a state_change event carrying its scope, its
source, and a changes list of
{ key, oldValue, newValue }. That is the audit: any value in the state can be
traced to the event that set it, what it replaced, and which invocation was running.
run.session.events.flatMap((event) =>
event.type === 'state_change'
? [{ scope: event.scope, source: event.source, changes: event.changes }]
: [],
)
Three sources tell you who moved the value. direct is a write from outside an
invocation — the seeding above. mutation is a write from inside one — the tool.
observation is not a write at all: it records the value an invocation
read from a shared scope, whose storage is bound to the session from outside and can
move between reads. Without that binding — as on this page — the seeded
user value sits in the ledger, but a tool reading
ctx.state.user.displayName finds nothing.
Step 5
Snapshots and time travel
Because state is derived, it can be derived at any point in the past.
session.stateAt(index) replays the events up to that index and returns a
snapshot: sessionState, scopedStates for the shared scopes, plus the
status, currentAgentName, yieldedTools and
invocationTree as they stood then.
session.eventIndexOf(eventId) maps an event id to its index;
session.forkAt(index) returns a new session carrying the events up to
that point, so an alternate history can be run without disturbing this one.
const timeline = run.session.events.flatMap((event, index) =>
event.type === 'state_change'
? [{ index, stage: run.session.stateAt(index).sessionState.stage }]
: [],
)
const forked = run.session.forkAt(run.session.events.findIndex((e) => e.type === 'tool_call'))
const timeTravel = {
timeline,
fork: { events: forked.events.length, state: { ...forked.state }, id: forked.id },
}
timeTravel
The fork holds the state as it was when the model asked for the tool, before the tool ran, and
it has its own session id. Same mechanism as the audit trail, read forwards instead of
backwards. Where those events outlive the process is a separate choice —
adk({ store }), covered in Where a sleeping agent lives.
The seam
The ledger is not the context
The ledger is the durable record; what the model sees is re-rendered from it before every
model call by the agent's context renderers, so no state_change
reaches the prompt unless a renderer puts it there. Which events become messages, and how
state is injected into them, belongs to
What the model actually sees — write to the session, render for the
model.