Agent Development Kit · Prove & ship

Stores: where a sleeping agent actually lives

A paused agent is rows in a table — that is the whole claim, and a store is what makes it true. One interface, four implementations, one shared conformance suite. The cells below run the shipped runtime against a real store in this page, with the model scripted, so none of them needs a key.

Audience · engineers deploying agents Needs · nothing (all cells are scripted) Package · @animahealth/adk (MIT)

Step 1

The contract a store implements

SessionStore is seven methods and no cleverness. It persists two things: a session's metadata row plus its events, and scoped state — the values shared by every session bound to one user, patient, practice, org, or team. Everything else the ADK does with sessions (binding scopes, buffering events, tracking what is dirty) sits above the store, in the session service, so a store never has to think about it.

interface SessionStore {
  load(
    appName: string,
    sessionId: string,
  ): Promise<{ session: StoredSession; events: Event[] } | null>

  commit(
    session: StoredSession,
    newEvents: Event[],
    expectedVersion: number,
    scopedChanges?: ScopedStateChange[],
  ): Promise<CommitResult>

  delete(appName: string, sessionId: string): Promise<void>

  loadScopedState(appName: string, scope: string, scopeId: string): Promise<Record<string, unknown>>

  saveScopedState(
    appName: string,
    scope: string,
    scopeId: string,
    state: Record<string, unknown>,
  ): Promise<void>

  close(): Promise<void>

  list(appName: string): Promise<Array<{ id: string; updatedAt: number }>>
}

Note what StoredSession does not carry: the events. It is id, appName, version, scopes, and createdAt. Sessions routinely reach several megabytes of event data, so a store that kept events inside the metadata row would rewrite the entire history on every turn — and would hit DynamoDB's 400KB item limit as a hard ceiling on conversation length.

inMemoryStore() is exported from the package root, so the contract is exercisable right here. Commit takes an expectedVersion; 0 means create this session. Commits are append-only and idempotent by event id, so the second commit below — a retried batch that overlaps the stored history — cannot double an event, and its genuinely new one still lands at the end in order.

import { inMemoryStore } from '@animahealth/adk'

const store = inMemoryStore()

// The metadata row — no events in it, by design.
const meta = {
  id: 'session_demo',
  appName: 'bookings',
  version: 0,
  scopes: {},
  createdAt: Date.now(),
}

const makeEvent = (n: number) => ({
  id: `evt-${n}`,
  type: 'user' as const,
  createdAt: Date.now() + n,
  text: `message ${n}`,
})

const created = await store.commit(meta, [makeEvent(1), makeEvent(2)], 0)

// The same batch again, one event further on: event 2 is already stored, event 3 is not.
const appended = await store.commit({ ...meta, version: 1 }, [makeEvent(2), makeEvent(3)], 1)

const reread = await store.load('bookings', 'session_demo')

const ledger = {
  created,
  appended,
  events: reread?.events.map((e) => e.id),
  version: reread?.session.version,
}

ledger

Concurrency is optimistic, not locked. Two runtimes can hold the same session; the second one to commit finds the version moved and is told so, with the version it actually needs. Nothing is written on a rejected commit — not the events, not the scoped-state changes that rode along with it.

// A writer that loaded at version 1, while someone else has already moved the row to 2.
const stale = await store.commit({ ...meta, version: 1 }, [makeEvent(4)], 1)

const afterStale = await store.load('bookings', 'session_demo')

const conflict = {
  stale,
  eventsAfterTheRejectedCommit: afterStale?.events.length,
}

conflict

Why optimistic concurrency rather than a lock or a per-session queue: an agent turn runs for five to sixty seconds. That is far too long to hold a distributed lease — a crashed runtime would strand it — and a per-session queue would park every incoming message behind whatever slow run is in flight.

Scoped state is the store's other surface, and its save is a merge: keys you do not mention are untouched, and a key set to undefined is deleted.

await store.saveScopedState('bookings', 'user', 'u-1', { theme: 'dark', locale: 'en-GB' })
await store.saveScopedState('bookings', 'user', 'u-1', { theme: 'light', locale: undefined })

const scoped = await store.loadScopedState('bookings', 'user', 'u-1')

scoped

Step 2

The row is real only after a commit

A bare app.run does not persist anything, store or no store. Events accrue on the in-memory session; the store is not touched until something commits. app.handler.rest() and app.handler.turn() commit after every turn, which is why serving an agent needs no persistence code. Driving app.run yourself, the commit is yours: await app.sessions.commit(session). Skip it and the sleeping agent is not asleep — it is gone when the process exits.

Below is a real app on a real store, with the model scripted so the page needs no key (see Testing agents without a model). Everything under the model — session, ledger, store — is the shipped runtime. app.sessions.create() writes the metadata row immediately: version 1, zero events.

import { adk } from '@animahealth/adk'
import { openai } from '@animahealth/adk/openai'
import { MockAdapter } from '@animahealth/adk/testing'

const app = adk({
  name: 'bookings',
  store: inMemoryStore(),
  adapters: { openai: new MockAdapter({ responses: [{ text: 'Tuesday 14:30 is open.' }] }) },
})

const concierge = app.agent({
  name: 'concierge',
  model: openai('gpt-5.6-luna'),
  context: [app.context.system('Be brief.'), app.context.history()],
  tools: [],
})

const session = await app.sessions.create()

const run = await app.run(concierge, { session, input: { message: 'Anything on Tuesday?' } })

// Read the row back from the store while the run's events are still only in memory.
const onDisk = await app.sessions.get(session.id)

const afterRun = {
  runStatus: run.status,
  said: run.output.text,
  eventsInMemory: session.events.length,
  eventsInTheStore: onDisk?.events.length,
}

afterRun

The run completed, the agent replied, and the store holds nothing. Now commit. The version advances, the whole ledger lands, and the reloaded row answers for the agent — the reply comes back out of storage, not out of the object you still have in scope.

const committed = await app.sessions.commit(session)

const reloaded = await app.sessions.get(session.id)

const afterCommit = {
  committed,
  eventsInTheStore: reloaded?.events.length,
  versionInTheStore: reloaded?.version,
  saidByTheReloadedRow: reloaded?.output.text,
  ledger: reloaded?.events.map((e) => e.type),
}

afterCommit

Pass no session at all and app.run builds one for the duration of the call. It is never registered with the store, so it is absent from app.sessions.list() — the listing that a "pending work" screen or a sweeper reads. It is not lost, though: nothing wrote it, and committing it afterwards creates the row.

const orphan = await app.run(concierge, 'Anything on Wednesday?')

const listedBefore = await app.sessions.list()
const rescued = await app.sessions.commit(orphan.session)
const listedAfter = await app.sessions.list()

const listing = {
  orphanId: orphan.session.id,
  listedBefore: listedBefore.map((s) => s.id),
  rescued,
  listedAfter: listedAfter.map((s) => s.id),
}

listing

When the handlers commit for you they resolve conflicts too, and report which way it went in commitStatus; the four words it can hold are tabled in serving. When you are done with the app, app.close() closes the store, so a CLI or a worker exits instead of hanging on a live pool.

Step 3

The four stores

Which store is a deployment decision, not an agent one. The agent, the tools, and the session code are identical across all four; only the line that builds the app changes. The three backed stores sit behind subpath exports, with their drivers as optional peers.

In-memory

The default. adk() with no store is already using it. Maps in the process; everything is gone when the process exits. It holds the same append-by-id dedup and the same OCC semantics as the SQL stores, which is what makes it a legitimate stand-in for them in tests.

import { adk, inMemoryStore } from '@animahealth/adk'

const app = adk({ name: 'bookings', store: inMemoryStore() })
// identical to: adk({ name: 'bookings' })

SQLite

Zero infrastructure and genuinely durable — a file. This is the right store for local development, for a CLI, and for a single-process deployment. Parent directories are created for you, and the connection runs in WAL mode. Pass ':memory:' for an ephemeral database that still exercises the real SQL path.

import { adk } from '@animahealth/adk'
import { sqliteStore } from '@animahealth/adk/stores/sqlite'

const app = adk({ name: 'bookings', store: sqliteStore('./data/bookings.db') })

// npm install better-sqlite3   (optional peer, >=11)

A commit — the version bump, the event appends, and the scoped-state writes — is one transaction, so a rejected commit writes nothing at all.

Postgres

The multi-process store: several web nodes and workers sharing one session table, with OCC as the referee between them. Either hand it a connection string and let it build a pool, or hand it a pool you already manage — the store leaves an injected pool entirely alone, including its error handling and shutdown. It throws at construction if given neither.

import { adk } from '@animahealth/adk'
import { postgresStore } from '@animahealth/adk/stores/postgres'

const app = adk({
  name: 'bookings',
  store: postgresStore({ connectionString: process.env.DATABASE_URL }),
})

// or bring your own pool — the store will not close or re-configure it:
import { Pool } from 'pg'

const shared = new Pool({ connectionString: process.env.DATABASE_URL, max: 20 })
const store = postgresStore({ pool: shared })

// npm install pg   (optional peer, >=8)

Every commit runs inside BEGIN/COMMIT on one dedicated client from the pool, so a version conflict rolls the whole thing back — there is no window in which the version advanced but the events did not.

DynamoDB

Single-table, serverless, and the one store with no transaction across items. The table needs a string partition key and a string sort key; their names default to pk and sk and are configurable if your table already uses others.

import { adk } from '@animahealth/adk'
import { dynamoStore } from '@animahealth/adk/stores/dynamodb'

const app = adk({
  name: 'bookings',
  store: dynamoStore({
    tableName: 'adk-sessions',
    client: { region: 'eu-west-2' }, // any DynamoDBClientConfig
    partitionKey: 'pk', // default
    sortKey: 'sk', // default
  }),
})

// npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb   (optional peers, >=3)

Two honest caveats, both in the source. First, atomicity: the metadata PutItem is the OCC gate and runs first; events and scoped state follow as batched writes. If the gate succeeds and a batch then fails, the version has advanced without its events. Second, list() is a table Scan — the key layout has no app-level partition — so put a GSI over a dedicated app-name attribute in front of it before leaning on it at scale.

Store Import Peer Commit atomicity Reach for it when
inMemoryStore() @animahealth/adk none process-local, all-or-nothing tests, demos, anything disposable
sqliteStore(path) /stores/sqlite better-sqlite3 one transaction local dev, CLIs, one process
postgresStore(config) /stores/postgres pg one transaction many processes, one database
dynamoStore(config) /stores/dynamodb AWS SDK v3 OCC gate, then batched writes serverless, no database to run

Step 4

What lands on disk, and who creates it

Three tables, the same shape in SQLite and Postgres: the session row, the event log keyed by event id and ordered by idx, and scoped state. This is the whole footprint of a sleeping agent.

-- Postgres. SQLite is the same three tables with TEXT/INTEGER in place of JSONB/BIGINT.
CREATE TABLE IF NOT EXISTS sessions (
  app_name TEXT NOT NULL,
  id TEXT NOT NULL,
  version INTEGER NOT NULL DEFAULT 0,
  scopes JSONB NOT NULL DEFAULT '{}',
  created_at BIGINT NOT NULL,
  updated_at BIGINT NOT NULL,
  PRIMARY KEY (app_name, id)
);

CREATE TABLE IF NOT EXISTS events (
  app_name TEXT NOT NULL,
  session_id TEXT NOT NULL,
  event_id TEXT NOT NULL,
  idx INTEGER NOT NULL,
  data JSONB NOT NULL,
  PRIMARY KEY (app_name, session_id, event_id)
);

CREATE INDEX IF NOT EXISTS idx_events_order
  ON events (app_name, session_id, idx);

CREATE TABLE IF NOT EXISTS scoped_state (
  app_name TEXT NOT NULL,
  scope TEXT NOT NULL,
  scope_id TEXT NOT NULL,
  key TEXT NOT NULL,
  value JSONB NOT NULL,
  PRIMARY KEY (app_name, scope, scope_id, key)
);

There is no migration step to run. Both SQL stores issue that DDL themselves — SQLite when it opens the file, Postgres once per store instance before its first query. It is IF NOT EXISTS throughout, so it is safe to run from every process on every boot. The practical consequence is a permissions one: the role your Postgres store connects as needs table-creation rights the first time it runs against a fresh database. A SELECT/INSERT-only role will fail on that first statement, not on the first commit.

The tables are unqualified, so they land in the connection's current schema — point the store at a dedicated database or set the search_path if you want them somewhere specific.

DynamoDB has no DDL to issue, so its one piece of provisioning is yours: create the table with a string HASH key and a string RANGE key. Everything the store writes — the meta item, one item per event, one item per scoped-state key — is keyed inside that pair.

// The table this store expects. Events sort by (v, seq): `v` is the session version the OCC gate
// just granted, `seq` the 0-based position within that commit's batch — both known at write time,
// with no read-before-write and no counter to race on.
await client.send(
  new CreateTableCommand({
    TableName: 'adk-sessions',
    AttributeDefinitions: [
      { AttributeName: 'pk', AttributeType: 'S' },
      { AttributeName: 'sk', AttributeType: 'S' },
    ],
    KeySchema: [
      { AttributeName: 'pk', KeyType: 'HASH' },
      { AttributeName: 'sk', KeyType: 'RANGE' },
    ],
    BillingMode: 'PAY_PER_REQUEST',
  }),
)

Step 5

One suite, four stores, in public CI

The suite is the contract for a store you write yourself: implement the seven methods, register it with runSessionStoreTests(name, createStore, cleanup), and any gap between your store and the shipped ones surfaces as a failure in the same assertions. It is what backs "they implement the same interface" for the shipped stores too — four stores, five registrations in one file, because SQLite runs twice, file and ':memory:'.

// src/session/compliance.test.ts — every store registers against the same suite.
runSessionStoreTests('InMemoryStore', () => new InMemoryStore())
runSessionStoreTests('SQLiteStore', /* a throwaway file database */)
runSessionStoreTests('SQLiteStore (:memory:)', /* the advertised ephemeral mode */)
runSessionStoreTests('PostgresStore', /* when TEST_DATABASE_URL is set */)
runSessionStoreTests('DynamoDBStore', /* when TEST_DYNAMODB_ENDPOINT is set */)

The suite covers load, commit, delete, list, and scoped state — including the behaviours that are easy to get subtly wrong and impossible to notice: event order preserved within a batch and across batches, a re-committed event id neither duplicated nor allowed to disturb later ordering, scoped state written atomically with the commit and not written on a conflict, a conflict returned for a commit against a deleted session, and batches larger than DynamoDB's 25-item write limit. The second SQLite registration earns its place: ':memory:' has its own failure shape, where a second connection is a second, empty database.

The backed stores are not skipped in the public repository's CI. A second job runs the compliance file against service containers — stock Postgres (via the pgvector image, which also backs the vector-index suites) and amazon/dynamodb-local — wired in by environment variable. Neither job needs a secret, so both run on fork pull requests too.

# .github/workflows/ci.yml — the backend-compliance job
services:
  postgres:
    image: pgvector/pgvector:pg17
    ports: ['5432:5432']
  dynamodb:
    image: amazon/dynamodb-local
    ports: ['8000:8000']

env:
  TEST_DATABASE_URL: postgres://postgres:postgres@127.0.0.1:5432/adk
  TEST_DYNAMODB_ENDPOINT: http://127.0.0.1:8000

steps:
  - run: pnpm run test -- src/session/compliance.test.ts …

Run it locally the same way: set those two variables and the skipped registrations wake up. With neither set, the suite still runs in full against the in-memory and SQLite stores, and the other two report themselves skipped rather than passing quietly.

Step 6

A sleeping agent you can actually run

The Bookings sample in the package is this chapter with a filesystem attached: the session becomes a row in bookings.db, the process exits, and a later command resumes the run from exactly where it paused. The store is one line of the app, and every command that runs the agent also commits it.

// sample/src/bookings.ts
export const app = adk({
  name: 'bookings',
  schema: { session: { confirmation: confirmation.optional() } },
  store: sqliteStore(DB_PATH),
})
// sample/src/cli.ts
const session = await app.sessions.create()
const result = await app.run(bookingAgent, { session })
await app.sessions.commit(session)