Skip to content

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_keyA stable, filesystem-safe encoding of the working directory
sessionId / session_idThe 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

MethodRequiredWhen the SDK calls itIf you leave it out
append(key, entries)YesAfter each batch of entries is written locally. Each entry is a JSON-safe object (one JSONL line).n/a
load(key)YesBefore 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)NoBy 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)NoBy listSessions({ sessionStore }) to fetch metadata for every session in one goListing falls back to listSessions plus one load per session
delete(key)NoBy 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 })NoDuring resume, to find subagent transcriptsOnly 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:

  • mtime is when you wrote the summary, and must use the same clock as the mtime values from listSessions.
  • data belongs 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 styleHow it stores a transcriptExample
Object storeOne object per append; load lists, sorts and concatenates the partsS3
Key-value storeA list per transcript plus a sorted index of sessionsRedis
Relational or document databaseOne row or document per entry, ordered by an insert keyPostgres

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.ts from the examples folder into your tests.
  • Python: the suite is in the package as claude_agent_sdk.testing.run_session_store_conformance. Install pytest yourself (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 typeLocal transcript after the runStore
New session, or resume where the store had nothingKeptHas a copy
Resumed from the storeDeleted at the endThe 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.json and user settings.json, stripping enabledPlugins, extraKnownMarketplaces, its alias additionalMarketplaces, and any CLAUDE_CONFIG_DIR in the file's env block. So apiKeyHelper in user settings works. (Before v0.3.222 only credentials and .claude.json were copied; before v0.3.232 the marketplaces alias was not stripped.)
  • Python copies credentials and .claude.json only. An apiKeyHelper in user settings.json therefore fails with Not logged in on a store resume. One in managed or project settings still works, because those are not under CLAUDE_CONFIG_DIR.

If the store has nothing for the session, the SDK uses your real config directory:

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