Claude Agent SDK SessionStore on DigitalOcean Spaces
We earn commissions when you shop through the links below, at no extra cost to you. We only link products we would use ourselves.
If a Claude Agent SDK app runs anywhere its disk can vanish, a SessionStore is the thing that makes resume survive. The SDK ships a reference S3 adapter, DigitalOcean Spaces speaks S3, and the two fit together with one config block. The rest is settings, and a few of them matter more than the docs let on.
Short answer: use the reference S3SessionStore unchanged, point it at a Spaces Standard Storage bucket in the same region as your Droplet or app, keep the default batched flush, scope the access key to that one bucket, and add a lifecycle expiry rule because the SDK never deletes anything. Below is the evidence: the adapter passing the SDK's own 13-check conformance suite against a local S3-compatible server, a real session mirrored, wiped locally and resumed from the store alone, and the object counts that decide what Spaces will bill you. Verified September 16, 2026 against Agent SDK v0.3.273, the session storage page, the adapter source on GitHub, and DigitalOcean's Spaces pricing, limits, S3 compatibility, availability, access and lifecycle pages.
What the store does, in one paragraph
The Claude Code subprocess always writes each transcript batch to local disk first. With sessionStore set, the SDK then forwards the same batch to your adapter's append(). The reference S3 adapter turns every append() into one new object named {prefix}/{projectKey}/{sessionId}/part-{epochMs}-{rand}.jsonl. On resume, the SDK calls load() before spawning the subprocess; the adapter lists the parts under that key, sorts them, fetches them 16 at a time, and the SDK materializes the result into a temporary config directory. A run resumed that way deletes its local copy at the end, so the bucket holds the only durable transcript. Mirror writes are best-effort: a failed batch is retried twice, then dropped with a mirror_error system message, and the agent keeps going.
The tested setup
The adapter is not an npm package. Copy examples/session-stores/s3/src/S3SessionStore.ts from the claude-agent-sdk-typescript repository into your project and install its one dependency. Node 24 runs the .ts file directly; no build step.
npm install @anthropic-ai/claude-agent-sdk @aws-sdk/client-s3
// store.mjs
import { S3Client } from '@aws-sdk/client-s3';
import { S3SessionStore } from './src/S3SessionStore.ts';
// Spaces: region is a placeholder the AWS SDK validates and ignores; the
// datacenter comes from the endpoint. forcePathStyle false = virtual-host URLs.
export const client = new S3Client({
region: 'us-east-1',
endpoint: 'https://nyc3.digitaloceanspaces.com',
forcePathStyle: false,
credentials: {
accessKeyId: process.env.SPACES_KEY,
secretAccessKey: process.env.SPACES_SECRET,
},
});
export const store = new S3SessionStore({
bucket: 'my-agent-sessions',
prefix: 'transcripts',
client,
});
// run.mjs
import { query } from '@anthropic-ai/claude-agent-sdk';
import { store } from './store.mjs';
async function run(prompt, resume) {
let sessionId;
for await (const m of query({
prompt,
options: { sessionStore: store, resume, maxTurns: 1, cwd: '/srv/agent' },
})) {
if (m.type === 'system' && m.subtype === 'init') sessionId = m.session_id;
if (m.type === 'system' && m.subtype === 'mirror_error') console.error(m);
if (m.type === 'result') console.log(m.subtype, 'result' in m ? m.result : '');
}
return sessionId;
}
const sid = await run('Reply with exactly the word: pineapple');
// Later, on any host with the same cwd:
await run('What single word did you just reply with?', sid);
The cwd line is not decoration. projectKey is an encoding of the working directory (ours came out as C--Users-VadimL-AppData-Local-Temp-sf-s3store), so a resume from a different path looks for a session that does not exist. Pin cwd on every host, or set CLAUDE_CODE_PROJECT_DIR_NAME beside CLAUDE_CONFIG_DIR in the query's env (SDK v0.3.234 or later) so the key no longer depends on the path.
Conformance first
We did not have a Spaces bucket in this session, so the S3 surface was a local SeaweedFS 4.47 gateway on 127.0.0.1:8333 (Docker-free, one binary). The SDK repository vendors a 13-test conformance suite for bun:test; a short shim mapping test and expect onto node:test and node:assert was the only change, and the adapter file was untouched.
node --test run-conformance.ts
13 tests, 13 pass, 0 fail, 783 ms
Append order, unknown-key null, subpath isolation, project isolation, list, cascade delete, subkey listing: all green against a real S3 wire protocol, not a mock. That is the strongest statement this page can make about Spaces without a bucket, and the weakest link is named below.
What we measured
Three one-turn sessions on the haiku alias with settingSources: [], mirrored to the local bucket. Between the first two, we moved the local transcript out of ~/.claude/projects/ so the resume had nowhere to read but the store.
| Run | What happened | Objects written | Bytes | Cost |
|---|---|---|---|---|
| 1, fresh, batched flush | Replied "pineapple" | 1 | 190,375 | $0.0506 |
2, resume with local copy gone | Same session id, replied "pineapple" from store context; 24,245 tokens read from cache | 2 more | 12,841 | $0.0034 |
3, fresh, sessionStoreFlush: 'eager' | Replied "pineapple" | 5 | 204,352 | $0.0287 |
Three details from the runs that the docs state and we can now confirm. The resumed session kept its id and its cache: run 2 wrote 367 new tokens to the cache and read 24,245 back. No local transcript was recreated after the store-backed resume, exactly as the dual-write notes say. And zero mirror_error messages across all three runs, including the eager one.
The number that matters for Spaces is objects per turn. Batched flush wrote one 190 KB part for a whole turn; eager flush wrote five parts for the same turn, two of them under 300 bytes. Spaces bills a minimum of 4 KiB per object, rounded up, so a session's footprint is parts times 4 KiB or its real size, whichever is larger. Runs 1 and 2 together: 203,216 real bytes, 212,992 billed. Run 3 alone: 204,352 real, 221,184 billed. Small difference here, but eager flush during a long tool-heavy turn is many tiny parts, each billed as 4 KiB and each one PUT against the bucket's 800 operations per second.
Settings that follow from the evidence
| Decision | Pick | Why |
|---|---|---|
| Flush mode | 'batched' (default) | One object per turn instead of one per frame; fewer PUTs, less 4 KiB rounding |
| Storage type | Standard | Cold Storage bills 128 KiB minimum per object, charges per retrieval, and penalizes deletes inside 30 days; every transcript part would hit all three |
| Region | Same datacenter as the compute | Inbound to Spaces is always free, and Spaces-to-Droplet transfer is free inside a region group (NYC3 to NYC1-3, AMS3 to AMS2-3, and so on); resume load() is outbound from Spaces and otherwise counts against the 1,024 GiB allowance |
| Access key | Limited, one bucket, Read/Write/Delete | Keys are created in the control panel only; a limited key cannot coexist with a bucket policy, so pick one model |
| Retention | Lifecycle expiry rule on the bucket | The SDK never deletes from a store; the adapter's delete() only runs when you call deleteSession() |
| Local disk | CLAUDE_CONFIG_DIR to a temp dir in options.env | A fresh session still leaves a full local transcript; only store-resumed runs clean up after themselves |
The lifecycle rule is one s3cmd expire --expiry-days=30 s3://my-agent-sessions or the equivalent PutBucketLifecycleConfiguration call. Tag-based lifecycle rules are not supported on Spaces, so the rule is per prefix or per bucket.
What a month costs
The base Spaces subscription is $5.00 per month for 250 GiB across all buckets and 1,024 GiB of outbound transfer, then $0.02 per GiB stored and $0.01 per GiB out. Billing starts when the first bucket exists and stops when the last one is destroyed. Current numbers are on the Spaces pricing page.
At the measured 208 KiB billed for a one-turn session plus one resume, the included 250 GiB holds roughly 1.3 million such sessions. Real sessions grow with every turn and every tool result, so treat the ceiling as "storage is not the bill". The bill is the $5 floor, and the tokens: a cold start costs 24,245 cache-write tokens on this configuration, a resume 367. Our prompt caching notes cover why that ratio, not the host, decides the economics of a hosted agent.
What we did not test
Everything above ran against SeaweedFS, not Spaces. The adapter's load(), listSessions() and delete() all page through ListObjectsV2 with ContinuationToken, and DigitalOcean's own limits page lists "the Spaces API does not currently support list-objects-v2 pagination" as a known issue. A single S3 list call returns at most 1,000 keys. With batched flush writing one or two parts per turn, that is several hundred turns in one session before it would matter, and we did not reproduce it, but if your agents run that long, either verify pagination against your bucket or start a new session id well before that point. We also did not run the Python adapter, whose store-resume path copies fewer files into the temporary config directory (credentials and .claude.json, not settings.json).
FAQ
Do I need a SessionStore on a Droplet?
Not for durability: a Droplet's disk persists across restarts, and the local transcripts under ~/.claude/projects/ are the SDK's normal storage. You need one when more than one host must resume the same session, or when you want transcripts in storage you govern with your own retention and access rules. The Droplet-versus-App-Platform question itself is in our Agent SDK hosting comparison.
Does the store replace the local transcript?
No. It is a mirror. The subprocess writes locally first and the SDK forwards the batch. Setting persistSession: false together with a store throws at startup, and file checkpointing is refused with a store too, because its backups go straight to local disk and are never mirrored.
Why is listSessions slow on the S3 adapter?
The reference adapter implements listSessions but not listSessionSummaries, so listSessions({ sessionStore }) falls back to one load() per session, which is a list plus every part fetched. For a session picker over thousands of sessions, add listSessionSummaries by folding entries with the exported foldSessionSummary helper inside append() and writing one sidecar object per session. Serialize that read-fold-write per session; concurrent appends can race on the sidecar.
Can I use Spaces Cold Storage to save money?
Not for this. Transcript parts are small, written constantly and read back on resume. Cold Storage bills every object as at least 128 KiB, charges $0.01 per GiB retrieved with a 128 KiB minimum per read, and treats an overwrite or an early delete inside 30 days as a billable event. Standard Storage at $0.02 per GiB above the included 250 GiB is the cheaper tier for this shape of data.