Session storage
Mirror Agent SDK session transcripts to your own database or object store with a SessionStore adapter, so any host can resume a conversation.
Out of the box, the SDK saves transcripts as JSONL files under ~/.claude/projects/ on whichever machine ran the agent. That is fine on a laptop and useless on a fleet of autoscaled workers, where the next request may land on a container that has never seen the session. A SessionStore adapter fixes this by copying every transcript entry to a backend you choose, so another host running from a matching working directory can resume it.
Reasons I have used one:
- Several hosts. Serverless functions, autoscaled workers and CI runners do not share a disk.
- Durability. Containers are disposable; a database is not.
- Governance. Transcripts sit in storage you already control, with your own retention, encryption and access policies.
The adapter contract
An adapter is an object with two required methods and four optional ones. The SDK calls append while the agent runs and load when resuming.
Keys
Every call is addressed with a SessionKey:
| Field (TS / Python) | Meaning |
|---|---|
projectKey / project_key | A stable, filesystem-safe encoding of the working directory |
sessionId / session_id | The session UUID |
subpath (optional) | Set for subagent transcripts and sidecar files, for example subagents/agent-<id>. Treat it as an opaque suffix. Absent means the main transcript. |
Because the project key comes from the working directory, resume with the same cwd the original run used. In TypeScript, if you set CLAUDE_CODE_PROJECT_DIR_NAME together with CLAUDE_CONFIG_DIR in a query's env, that query's entries and its resume and continue lookups are keyed by that name instead. Standalone helpers such as listSessions and deleteSession read the process environment rather than an env option, so set both variables in the host process too. This needs Agent SDK v0.3.234 or later.
Methods
| Method | Required | When the SDK calls it | If you leave it out |
|---|---|---|---|
append(key, entries) | Yes | After each batch of entries is written locally. Each entry is a JSON-safe object (one JSONL line). | n/a |
load(key) | Yes | Before spawning the subprocess when resuming or when continue picks the newest stored session; also once per session if listing falls back from summaries. Return null for an unknown session. | n/a |
listSessions(projectKey) | No | By listSessions({ sessionStore }) and by query() or startup() with continue: true. Returns { sessionId, mtime } items. | continue: true throws; listSessions throws unless summaries are implemented |
listSessionSummaries(projectKey) | No | By listSessions({ sessionStore }) to fetch metadata for every session in one go | Listing falls back to listSessions plus one load per session |
delete(key) | No | By deleteSession({ sessionStore }). Deleting the main key must also remove every subkey and the session's summary. | Deletion does nothing, which suits append-only backends |
listSubkeys({ projectKey, sessionId }) | No | During resume, to find subagent transcripts | Only the main transcript is restored |
Python names are snake_case (list_sessions, list_session_summaries, list_subkeys); optional methods can be omitted or raise NotImplementedError. The types are exported as SessionStore, SessionKey, SessionStoreEntry and SessionSummaryEntry from both packages.
Summaries
A SessionSummaryEntry has sessionId, mtime and data. Two rules:
mtimeis when you wrote the summary, and must use the same clock as themtimevalues fromlistSessions.databelongs to the SDK. Store it exactly as given.
Build summaries inside append by passing each batch to the exported helper foldSessionSummary (Python: fold_session_summary). Skip batches that have a subpath, because subagent entries must not feed the main session's summary. The helper never sets mtime, so stamp it yourself: via the options.mtime argument in TypeScript, or by overwriting the field on the returned entry in Python. The fold is pure, but two concurrent appends for one session can race on the stored summary, so wrap read-fold-write in a transaction, compare-and-swap or per-session lock.
Trying it with the in-memory store
The SDK includes InMemorySessionStore for tests and local experiments. Attach it, capture the session ID, then resume from the store in a second call:
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions, InMemorySessionStore, ResultMessage
store = InMemorySessionStore()
async def main():
sid = None
try:
async for msg in query(prompt="Count the migration files under db/migrations",
options=ClaudeAgentOptions(session_store=store)):
if isinstance(msg, ResultMessage):
sid = msg.session_id
except Exception as exc:
print(f"First run failed: {exc}")
async for msg in query(prompt="Which of those touch the invoices table?",
options=ClaudeAgentOptions(session_store=store, resume=sid)):
if isinstance(msg, ResultMessage) and msg.subtype == "success":
print(msg.result)
asyncio.run(main())
In TypeScript the option is sessionStore and the class is imported from @anthropic-ai/claude-agent-sdk. If the second answer refers to the migrations from the first, the transcript came back from the store.
Writing an adapter
Implement append and load for your backend first. Add the optional methods when you need listing, deletion, one-call metadata or subagent resume.
Treat entries as opaque JSON. Persist them in order and return them in the same order. What load returns must be deep-equal to what was appended, but it need not be byte-identical, so a JSON column type that reorders keys is fine. Because failed appends are retried (see below), the same entry can arrive twice: deduplicate on entry.uuid.
Here is a sketch of a Postgres-backed adapter in TypeScript using pg. It is deliberately minimal: no summaries, no deletion.
import type { SessionStore, SessionKey, SessionStoreEntry } from "@anthropic-ai/claude-agent-sdk";
import type { Pool } from "pg";
// CREATE TABLE agent_transcripts (
// id bigserial PRIMARY KEY,
// project_key text, session_id text, subpath text NOT NULL DEFAULT '',
// entry_uuid text, entry jsonb,
// UNIQUE (project_key, session_id, subpath, entry_uuid)
// );
export function postgresStore(pool: Pool): SessionStore {
return {
async append(key: SessionKey, entries: SessionStoreEntry[]) {
for (const entry of entries) {
await pool.query(
`INSERT INTO agent_transcripts (project_key, session_id, subpath, entry_uuid, entry)
VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING`,
[key.projectKey, key.sessionId, key.subpath ?? "", (entry as any).uuid ?? null, entry],
);
}
},
async load(key: SessionKey) {
const { rows } = await pool.query(
`SELECT entry FROM agent_transcripts
WHERE project_key = $1 AND session_id = $2 AND subpath = $3 ORDER BY id`,
[key.projectKey, key.sessionId, key.subpath ?? ""],
);
return rows.length ? rows.map((r) => r.entry) : null;
},
async listSessions(projectKey: string) {
const { rows } = await pool.query(
`SELECT session_id, max(id) AS last FROM agent_transcripts
WHERE project_key = $1 AND subpath = '' GROUP BY session_id`,
[projectKey],
);
return rows.map((r) => ({ sessionId: r.session_id, mtime: Number(r.last) }));
},
};
}
In real use, batch the inserts in one statement and use a proper timestamp for mtime; I used the serial ID here to keep the example short.
Reference adapters
Both SDK repositories include runnable adapters you can copy: examples/session-stores/ in the TypeScript repo and examples/session_stores/ in the Python repo. They are not published packages; copy the closest one into your project and adapt it. Each takes a client you have already configured, so credentials, TLS, region and pooling stay under your control.
| Backend style | How it stores a transcript | Example |
|---|---|---|
| Object store | One object per append; load lists, sorts and concatenates the parts | S3 |
| Key-value store | A list per transcript plus a sorted index of sessions | Redis |
| Relational or document database | One row or document per entry, ordered by an insert key | Postgres |
Conformance tests
Both SDKs ship a conformance suite that checks your adapter against the contract. Tests for optional methods are skipped if you have not implemented them.
- TypeScript: copy
shared/conformance.tsfrom the examples folder into your tests. - Python: the suite is in the package as
claude_agent_sdk.testing.run_session_store_conformance. Installpytestyourself (it is not an SDK dependency) and pass a zero-argument factory:
import pytest
from claude_agent_sdk.testing import run_session_store_conformance
@pytest.mark.anyio
async def test_postgres_store():
await run_session_store_conformance(lambda: PostgresStore(fresh_test_schema()))
The suite reuses the same keys across contracts, so every store the factory returns must start empty. Use a new in-memory fake, a unique key prefix or a fresh test database each time.
How it behaves
Local disk first, store second
The Claude Code subprocess always writes each batch to local disk, then the SDK forwards it to append. The store is a mirror, not a replacement. Which copy survives depends on how the run started:
| Run type | Local transcript after the run | Store |
|---|---|---|
| New session, or resume where the store had nothing | Kept | Has a copy |
| Resumed from the store | Deleted at the end | The only durable copy |
To stop new sessions leaving files on disk, set CLAUDE_CONFIG_DIR to a temporary directory in env (in TypeScript, spread process.env in too, as env replaces the environment). If you authenticate through files in the config directory, such as OAuth credentials or an apiKeyHelper in user settings.json, copy them into the temporary directory or set ANTHROPIC_API_KEY in env, or the run fails with Not logged in.
Two options cannot be combined with a store, and the SDK throws at startup if you try:
persistSession: false(TypeScript only), because it disables the local writes the mirror depends on.- File checkpointing (
enableFileCheckpointing/enable_file_checkpointing), because its backups go straight to local disk and are not mirrored.
Resuming from the store
With a store attached, resume asks the store for that session ID, and continue: true (Python: continue_conversation=True) asks for the newest stored session. If the store has it, the SDK writes the transcript into a temporary config directory, runs the subprocess with CLAUDE_CONFIG_DIR pointing there, and deletes the directory afterwards.
The temporary directory is seeded from your real config directory, and the two languages differ:
- TypeScript copies credentials,
.claude.jsonand usersettings.json, strippingenabledPlugins,extraKnownMarketplaces, its aliasadditionalMarketplaces, and anyCLAUDE_CONFIG_DIRin the file'senvblock. SoapiKeyHelperin user settings works. (Before v0.3.222 only credentials and.claude.jsonwere copied; before v0.3.232 the marketplaces alias was not stripped.) - Python copies credentials and
.claude.jsononly. AnapiKeyHelperin usersettings.jsontherefore fails withNot logged inon a store resume. One in managed or project settings still works, because those are not underCLAUDE_CONFIG_DIR.
If the store has nothing for the session, the SDK uses your real config directory:
| Option | What happens |
|---|---|
resume (either SDK) | The ID is passed through and the local transcript is resumed as normal |
continue: true (TypeScript) | A fresh session starts |
continue_conversation=True (Python) | The newest local session is continued |
Mirror writes are best effort
If append rejects, the SDK retries up to twice more with a short backoff (three attempts in total). A call that times out is not retried, since it may still succeed. If all attempts fail, the SDK logs the error, emits a system message with subtype mirror_error, drops that batch and carries on. The agent is never interrupted by a store outage because it writes locally first, but watch for mirror_error if losing data matters. On a store-resumed run, a dropped batch is gone for good once the run ends.
Reading messages back
getSessionMessages({ sessionStore }) returns the chain the agent would see on resume, which after compaction means the summary rather than the original turns. A store holding hundreds of raw entries might return a couple of dozen messages. Call store.load(key) directly for the complete raw history.
Forking rewrites entries
forkSession({ sessionStore }) reads the source entries, rewrites every sessionId, remaps message UUIDs and appends the result under a new key. It deliberately does not do a byte-level copy (such as S3 CopyObject), which would leave the old session ID inside the transcript.
Subagents
Subagent transcripts are stored under subpath: "subagents/agent-<id>". listSubagents({ sessionStore }) needs listSubkeys. getSubagentMessages({ sessionStore }) uses it if present and otherwise reads the direct subpath. Resume uses listSubkeys to restore subagent files; without it only the main transcript comes back.
Retention is yours
The SDK never deletes anything from your store. Use your backend's expiry or lifecycle rules, or a scheduled job, to meet your retention policy. Local transcripts are swept separately according to the cleanupPeriodDays setting (see the .claude directory). Since store-resumed runs leave nothing locally, your store's retention is the only retention for them.
Which functions accept a store
TypeScript: pass sessionStore to query(), startup(), listSessions(), getSessionInfo(), getSessionMessages(), renameSession(), tagSession(), deleteSession(), forkSession(), listSubagents() and getSubagentMessages().
Python: set session_store on ClaudeAgentOptions for query(). Other operations have separate store-backed functions that take the store as an argument:
list_sessions_from_store()get_session_info_from_store()get_session_messages_from_store()list_subagents_from_store()get_subagent_messages_from_store()rename_session_via_store()tag_session_via_store()delete_session_via_store()fork_session_via_store()
There is no Python startup(). The plain helpers such as list_sessions() always read local files.