Agent Development Kit · Prove & ship
Stores: where a sleeping agent actually lives
A paused agent is rows in a table, and a store is what puts them there. 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 }>>
}
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 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 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 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 only 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 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. Nothing else of a sleeping agent
lands on disk.
-- 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
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, and SQLite
registers twice — as a file and as ':memory:'.
// 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', /* against a live Postgres */)
runSessionStoreTests('DynamoDBStore', /* against a live DynamoDB endpoint */)
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 is not redundant: ':memory:' has its own
failure shape, where a second connection is a second, empty database.
The in-memory and SQLite registrations run anywhere. The Postgres and DynamoDB ones need a live endpoint to point at; without one they report themselves skipped rather than passing.
Step 6
A sleeping agent you can 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 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)