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.
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)