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.

Runs · no key (the model is scripted) Builds on · tools (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.