Skillforge Field notes on shipping with AI tools

Claude Agent SDK SessionStore: Managed Postgres or Valkey

claude-codeai-workflows

We earn commissions when you shop through the links below, at no extra cost to you. We only link products we would use ourselves.

The Claude Agent SDK ships three reference SessionStore adapters: S3, Redis and Postgres. DigitalOcean sells a managed database for two of those shapes, PostgreSQL and Valkey (its Redis-compatible engine), at the same $15 entry price. Which one should hold your agent transcripts?

Short answer: Managed PostgreSQL, single node, 1 GiB, with the reference PostgresSessionStore unchanged. It has daily backups and seven days of point-in-time recovery, one agent session holds a single backend connection, and the JSONB column stored our sessions in about two thirds of their raw size. Valkey is the right pick only if you already run a Valkey cluster for something else, because a transcript store on it has no backups, no point-in-time recovery, and one eviction-policy toggle between you and silent data loss. Below is the evidence: both adapters passing the SDK's own conformance suite against real servers, six measured sessions, and the DigitalOcean limits that decide the rest. Verified September 22, 2026 against Agent SDK 0.3.278, pg 8.23.0, ioredis 6.0.0, the adapter sources on GitHub, and DigitalOcean's PostgreSQL and Valkey pricing, limits, connection, connection-pool, eviction-policy and memory-usage pages.

If you want the object-store version of this decision, we covered the S3 adapter on Spaces in SessionStore on DigitalOcean Spaces.

The decision, row by row

QuestionManaged PostgreSQLManaged Valkey
Survives a node lossDaily backups, point-in-time recovery to any point in the last 7 daysNo backups, no restore; RDB persistence every 10 minutes is the only copy
Can the store drop data on its ownNoYes, if the eviction policy is anything but noeviction (the default)
Connections one agent session holds1 measured; the 1 GiB plan allows 221; nodes allow 10,000, but only 200 new connections per second per CPU
Bytes for a one-turn session (24 entries)41,279 stored as JSONB (65,241 as JSON text)69,829 in memory (64,261 raw)
RetentionYou write a DELETE ... WHERE created_at < ... jobYou subclass the adapter to EXPIRE keys, or pick a volatile-* policy
TLS from NodeStandard Edition uses DigitalOcean's own CA; pg 8.x verifies by default, so ship the CA fileLet's Encrypt certificate; rediss:// with no CA file
Entry price$15.15 per month, 1 GiB RAM, 1 vCPU, 10 GiB disk (Basic, Standard Edition)$15.00 per month, 1 GiB RAM, of which 0.5 GiB is available to your data

The first two rows settle it for most teams. The SDK's own adapter README already says to set maxmemory-policy noeviction on Redis because eviction "will silently drop session data". On Managed Valkey you cannot run CONFIG at all; the command is on the restricted list. The policy is a cluster setting you change in the control panel or through the API, and DigitalOcean's own eviction page marks allkeys-lru as Recommended. If anyone on your team follows that recommendation, the store starts discarding transcripts the moment the node fills, and nothing in the SDK will tell you, because the adapter's load() simply returns fewer entries.

The tested setup

Neither adapter is an npm package. Copy the file you need from the SDK repository's examples/session-stores/ directory and install its one client dependency. Node 24 runs the .ts file directly; no build step.

npm install @anthropic-ai/claude-agent-sdk pg
# copy examples/session-stores/postgres/src/PostgresSessionStore.ts into ./src/
// store.mjs
import pg from 'pg';
import { PostgresSessionStore } from './src/PostgresSessionStore.ts';

// DATABASE_URL is the control panel's connection string with two changes:
// sslmode=verify-full and sslrootcert pointing at the downloaded CA file.
// postgresql://doadmin:[email protected]:25060/defaultdb?sslmode=verify-full&sslrootcert=/etc/ssl/do-ca-certificate.crt
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 5 });
export const store = new PostgresSessionStore({ pool });
await store.ensureSchema(); // CREATE TABLE IF NOT EXISTS, run once at startup
// run.mjs
import { query } from '@anthropic-ai/claude-agent-sdk';
import { store, pool } 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');
await run('What single word did you just reply with?', sid); // any host, same cwd
await pool.end();

cwd matters: the store key encodes the working directory, so a resume from a different path looks for a session that does not exist. Pin it, or set CLAUDE_CODE_PROJECT_DIR_NAME beside CLAUDE_CONFIG_DIR in the query's env.

Conformance first

We did not have a DigitalOcean cluster in this session. The Postgres side was PostgreSQL 17.10 started locally through embedded-postgres; the Redis side was the 5.0.14 Windows build, not Valkey, since Valkey publishes no Windows binary. Both are real servers speaking the real wire protocol, and the adapter files were byte-identical to upstream. The SDK repository vendors a 13-check conformance suite for bun:test; a 40-line shim mapping test and expect onto node:test and node:assert was the only change, and the Postgres live test adds two checks of its own.

node --test conformance-pg.test.ts      # PostgreSQL 17.10
  15 tests, 15 pass, 0 fail, 1841 ms
node --test conformance-redis.test.ts   # redis_version:5.0.14.1
  13 tests, 13 pass, 0 fail, 6230 ms

Append order, unknown-key null, subpath isolation, project isolation, list, cascade delete, subkey listing: green on both. The Postgres adapter's ORDER BY id on a BIGSERIAL and the Redis adapter's RPUSH plus LRANGE are both order-preserving by construction, which is the property the SDK relies on.

What we measured

Three one-turn sessions per backend on the haiku alias, settingSources: [], no tools, no MCP servers, mirrored to the local server. Between runs 1 and 2 we moved the local transcript out of ~/.claude/projects/ so the resume had nowhere to read but the store. A counting proxy around each adapter recorded every append() and load(); for Postgres, a second connection sampled pg_stat_activity every 100 ms.

RunBackendappend() callsEntriesStored bytesPeak connectionsModel cost
1, fresh, batchedPostgres22441,2791$0.0033
2, resume, local copy gonePostgres29 more (33 total)47,2631$0.0046
3, fresh, eagerPostgres62441,7891$0.0038
1, fresh, batchedRedis22469,829not sampled$0.0136
2, resume, local copy goneRedis29 more (33 total)79,779not sampled$0.0148
3, fresh, eagerRedis62469,696not sampled$0.0037

Stored bytes are sum(pg_column_size(entry)) for Postgres and MEMORY USAGE on the list key for Redis. Both resumes kept their session id, answered "pineapple" from store context, called listSubkeys once, read 5,494 tokens back from the prompt cache, and left no local transcript behind. Zero mirror_error messages across all six runs.

Evidence for each row

Connections. The 1 GiB PostgreSQL plan allows 22 backend connections (DigitalOcean's table goes 22, 47, 97, 197 for 1, 2, 4 and 8 GiB), and pg.Pool defaults to max: 10. Across roughly 90 samples per run the monitor never saw more than one connection open for the session, because the adapter issues one query at a time through pool.query(). So the arithmetic is: one connection per concurrently running agent, plus whatever your app holds. Set max to your expected concurrency rather than leaving the default, and do not share this pool with request handlers that hold connections, which is the README's advice too. If you outgrow 22, DigitalOcean's PgBouncer pools cost nothing extra; the adapter uses plain parameterized queries with no named prepared statements, so transaction mode should work, but we did not run it through a pool.

Bytes. Postgres stored the same 24 entries in 41,279 bytes against 65,241 bytes of JSON text, because it compresses large values in the row; the biggest single entry came to 15,691 bytes stored. Redis held them in 69,829 bytes, slightly above the 64,261 raw bytes, which is list overhead. On a 10 GiB Postgres disk that is roughly 250,000 sessions of this size before the first $0.215 per GiB of extra storage; on Valkey's 0.5 GiB of usable memory it is about 7,500. Real sessions grow with every tool result, so treat both as the ceiling on an idle store, not a plan.

What is actually in those bytes. Of the 24 entries, 12 were attachment records and they held 58,576 of the 64,552 transcript bytes: prompt snapshots carrying the system prompt and tool list, plus hook output. The conversation itself, one user message and two assistant messages, was 4,440 bytes. In a first pass, before we pinned the MCP configuration, the signed-in account's MCP servers connected and a single snapshot entry weighed 429,271 bytes, taking the session to 493 KB. The tool list you expose sets the size of your store far more than what the agent says, and on Valkey that is memory.

Flush mode. Eager flush made six append() calls where batched made two, with the same 24 entries. For Postgres that is six INSERT statements instead of two, and the stored size barely moved. Unlike Spaces, where every part is a billed object, there is no per-write charge here, so eager flush is a latency choice, not a cost one. Keep the default unless you need the mirror to be current mid-turn.

Durability. DigitalOcean's feature matrix marks daily point-in-time backups for PostgreSQL and leaves the Valkey column blank; the Valkey limits page lists backups and restoring from backups as unsupported. Valkey's RDB persistence writes every 10 minutes and exists so a replaced node can reload, not so you can restore. A transcript store is exactly the data you want back after an incident, so this row alone should end most debates.

Eviction. noeviction is the default and is what you want; the SDK never deletes from the store, so nothing else frees memory. The Valkey limits page puts config on the restricted-commands list, so a client cannot change or even read the policy the way the adapter README suggests; it is a control panel or API setting on the cluster. Check it after anyone tunes the cluster for a cache workload.

TLS. DigitalOcean's control panel hands you sslmode=require by default for PostgreSQL and says that mode "does not verify the server identity". The pg driver disagrees: in pg-connection-string 8.x, prefer, require and verify-ca are treated as aliases for verify-full and emit a deprecation warning saying so. Standard Edition clusters sign with DigitalOcean's own CA, which is why the panel offers a CA download. Reading the driver code, a connection string with sslmode=require and no sslrootcert verifies against a certificate Node does not trust and fails; add sslrootcert=<path> and the driver reads the file into ssl.ca, or append uselibpqcompat=true to get libpq's unverified require. We did not connect to a live cluster, so treat this as what the code says rather than what we saw. Valkey clusters use a Let's Encrypt certificate, and ioredis with a rediss:// URL verifies it against the system store with no extra file.

What a month costs

The pricing page lists the smallest PostgreSQL Standard Edition node at $15.15 per month (the docs round it to $15.00) with 10 GiB of disk, and the smallest Valkey node at $15.00. High availability starts at $30 per node on either. Traffic to and from managed databases does not count against bandwidth. Current figures are on the Managed Databases pricing page.

The database is not where the money goes. A cold session cost between $0.003 and $0.014 in model spend here, and a store-backed resume read 5,494 tokens from cache instead of rebuilding the prompt. Our prompt caching notes cover why that ratio dominates; the Droplet-versus-App-Platform half of the hosting question is in the Agent SDK hosting comparison.

What we did not test

No DigitalOcean cluster was involved, so the TLS row is a code reading, the PgBouncer path is untested, and the Redis-protocol server was Redis 5.0.14, not Valkey 8. The adapter uses RPUSH, LRANGE, SADD, SMEMBERS, ZADD, ZRANGE WITHSCORES, ZREM, SREM, DEL and MULTI, none of which changed between those versions, and none of which is on DigitalOcean's restricted list. We also did not run the Python adapters, and we did not load-test either store; the README is explicit that conformance proves correctness, not resilience.

FAQ

Should I switch the JSONB column to TEXT?

Only if something byte-compares entries. JSONB reorders object keys on read-back, which the SessionStore contract allows because the SDK requires deep-equal, not byte-equal, results, and the conformance suite canonicalizes keys before comparing. JSONB also gave us the 37 percent size reduction above, and it lets you query inside entries later (entry->>'type'), which a transcript audit will eventually want.

Can I point the adapter at a DigitalOcean connection pool?

Probably, in transaction mode. The adapter runs plain parameterized queries through pool.query() and never opens a transaction spanning calls or a named prepared statement, which are the things transaction pooling breaks. We did not test it. Note the arithmetic first: 25 connections per GiB minus 3 reserved, and one agent session used one connection, so a small deployment is unlikely to need PgBouncer at all.

Does a Valkey store lose transcripts?

Not with noeviction, and not while the node is healthy. It loses them when the node is replaced with nothing to restore from, when memory fills under noeviction (writes start failing, and the SDK drops the batch after three attempts with a mirror_error), or when the policy is anything else (Valkey evicts whole keys, so an entire session's list disappears at once). If you run one anyway, monitor mirror_error and the cluster's evicted-keys metric.

Do I need a SessionStore on a Droplet at all?

Not for durability of a single host: a Droplet's disk persists, and the local transcripts under ~/.claude/projects/ are the SDK's normal storage. You need one when more than one host has to resume the same session, when the host is ephemeral (App Platform, autoscaled workers, CI), or when you want transcripts in storage with your own retention and access rules. That last reason is where the Postgres backups matter most.