Skip to content

Storage

The Agent SDK owns durable storage for sessions, continuation tokens, reminders, playground eval history, and live A/B samples. It chooses the keys (under agentkit/v1/), when to read and write, and how to restore after restart.

Keys have bounded length: caller-controlled segments (channel ids, continuation tokens) are URI-encoded, and any segment past 256 encoded bytes is replaced by its sha256:… digest — deterministically, so writes and lookups always agree. Backends can rely on this instead of imposing their own key-length caps (which would silently drop writes, since a throwing put is at-most-once).

By default that storage lives under --state-root on local disk. Fine for one machine; it does not survive replacing the host.

To keep the same framework storage across hosts, plug in a key-value backend with agent/storage.ts. You provide put / get / delete / list, plus the cas group when the deployment uses coordination features (conditional writes — see Conditional writes). The Agent SDK does the rest.

ts
// agent/storage.ts
import { defineStorage } from "@cursor/july/storage";

export default defineStorage({
  put: (key, value) => db.upsert(key, value),
  get: (key) => db.get(key),
  delete: (key) => db.delete(key),
  list: (prefix) => db.listByPrefix(prefix), // [{ key, value }], key order
  cas: {
    getWithVersion: (key) => db.getRow(key), // { value, version }
    putIfAbsent: (key, value) => db.insertIfAbsent(key, value),
    putIfVersion: (key, value, expected) => db.casUpdate(key, value, expected),
    listKeys: (prefix) => db.listKeysByPrefix(prefix),
  },
});

NOTE

Import paths here use @cursor/july/storage. On projects still using @anysphere/agent-serve, swap the import. See Run the CLI for the full rename table.

Which fields to provide

Implement the small KV core — put/get/delete/list plus the cas group — and you get full functionality: eval-run and A/B history are derived over the core automatically. The dedicated evals / abs groups are backend-native optimizations, not required-or-lose-history hooks.

FieldRequiredRole
putYesWrite or update a value
casFor coordinationConditional writes; see Conditional writes
getFor restoreLook up one key (also: derived A/B snapshot backfill)
listFor restoreReturn entries under a prefix, in key order (also: derived eval-runs hydrate)
deleteFor cleanupRemove a key (also: derived eval-runs pruning)
nameNoLabel surfaced on GET /v1/info diagnostics
policyNoTiming knobs; see Policy
evalsNoBackend-native eval-runs table; derived over the core when omitted — see Eval and A/B tables
absNoBackend-native A/B metrics table; derived over the core when omitted — see Eval and A/B tables

A throwing put is logged and dropped. It never fails a turn. When resolving a missing continuation token, a throwing get fails the follow-up so a store outage does not open a new session. Return undefined only for a real miss.

Eval and A/B tables

Two dedicated table groups carry structured rows instead of opaque KV values. Both are optional optimizations: when a group is not authored, defineStorage derives it over the KV core, so a backend that implements only the core loses nothing. Author a group only when the backend has a better native shape (a real database table, an analytics pipeline) — the built-in fileKv and cursorHostedStorage both do.

evals keeps playground eval batches across restarts (put, delete, list over run snapshots keyed by runId). The Agent SDK upserts a snapshot as a batch starts, progresses, and finishes, prunes runs past the playground history window, and lists everything back at serve start. Derived form: one key per run under agentkit/v1/{agent}/eval-runs/{runId} — needs core put + delete + list. Only a core missing delete or list leaves eval history in process memory (cleared on restart). See Evals.

abs exports live A/B metrics: putSample appends one cumulative metric sample per enrolled experiment on each completed or failed turn; optional putSnapshot / getSnapshot store and serve back the latest aggregate so a replacement host with no local sessions can still serve the A/Bs surface. Derived form: each sample lands as its own key (agentkit/v1/{agent}/ab-samples/{experiment}/{sessionId}/{at} — a blind append-only put, never a read-modify-write of one growing array) and the snapshot lives at the fixed agentkit/v1/{agent}/ab-snapshot key (last-write-wins is correct for "latest aggregate"). putSample and putSnapshot need only core put; getSnapshot needs core get. Session event logs remain the assignment source of truth either way. See Live A/B metrics.

Policy

Two knobs change behavior:

ts
export default defineStorage({
  policy: {
    // Batch event writes while the session is busy (default: once per turn)
    debounceMs: 30_000,
    // Cap how much loads at serve start, or "off" to restore on demand
    restore: { maxSessions: 500 },
  },
  // put / get / delete / list …
});
KnobDefaultMeaning
debounceMsunset (once per turn)Wait this long after activity before writing event batches
restorecaps belowHow much to load at serve start
restore.maxSessions1000Max sessions loaded at serve start
restore.maxAgeMs30 daysSkip older sessions at serve start
restore.maxTotalBytes1 GiBStop loading once this budget is reached

Set restore: "off" on high-traffic hosts. Sessions then load when a follow-up arrives instead of at startup.

Restore after restart

With get and list, serve can rebuild local state from your store:

  • At startup, the Agent SDK loads recent sessions up to the restore caps. Local disk wins when both sides have the same session. Reminders hydrate the same way into --state-root/reminders.
  • On demand, a missing continuation token resolves through the store and resumes that session.
  • Playground eval history and A/B aggregates can load from the same sink.

A turn in flight at crash time is not replayed. The next follow-up resumes from the last flushed state.

Author KV (ctx.host.kv)

Handlers can store their own JSON under the same sink without minting framework keys:

ts
await ctx.host.kv.put("alert-memory/abc", { updated: "…" });
const prior = await ctx.host.kv.get("alert-memory/abc");

The Agent SDK prefixes author keys as agentkit/v1/{agent}/kv/{key} (same bounded encoding as continuation tokens). Writes await the sink and propagate errors — unlike session mirrors, which are at-most-once.

Without agent/storage.ts, host.kv falls back to files under --state-root/kv. That is fine for local dogfood; it does not survive replacing the host. For Cursor-managed hosting, prefer @cursor/july/storage/cursor-hosted so sessions and author KV share the platform Bugbot tables through a control-plane HTTP proxy (authenticated as the deployment pod credential — engines never receive a database URL).

ts
// agent/storage.ts — Cursor-managed hosting
import { defineStorage } from "@cursor/july/storage";
import { cursorHostedStorage } from "@cursor/july/storage/cursor-hosted";

export default defineStorage({
  ...cursorHostedStorage(),
});

Built-in helpers:

ImportBackend
@cursor/july/storage/file-kvFile-per-key under .agent-serve/kv
@cursor/july/storage/cursor-hostedPlatform Bugbot agent_serve_* via control-plane proxy

Bring your own backend

There is no built-in Postgres backend on purpose. Cursor's internal agent_serve_* tables are owned by the backend and reachable only through the hosted proxy, and this doc does not prescribe a schema — what you back the KV with is your call. Any durable store works:

  • Local disk — the built-in fileKv (single process only).
  • Object storage (S3-class) — one object per key; conditional writes map directly onto the contract (putIfAbsent = put with If-None-Match: *, putIfVersion = put with If-Match: <etag>, the ETag is the version token, listKeys is a prefix listing).
  • Redis, DynamoDB, a SQL table, … — anything that can do an atomic compare-and-set and a prefix listing.

Implement the StorageConfig methods (and the cas group when the deployment uses coordination features) against that store. The contract, defined at @cursor/july/kv: version tokens are opaque strings that change on every successful write — including plain put, so a stale token fences instead of clobbering; conditional writes are atomic; listKeys returns every key under the prefix. @cursor/july/kv/memory is a complete reference implementation to compare behavior against.

Conditional writes (the cas group)

The cas group is compare-and-swap over the same keyspace: getWithVersion / putIfAbsent / putIfVersion / listKeys, defined backend-agnostically at @cursor/july/kv. Plain storage works without it, so an existing backend keeps working across a platform upgrade. Coordination features require it: they fail at startup, with a message naming this group, when the backend lacks it. defineStorage rejects a partial group — implement all four methods or none. Version tokens are opaque strings that must change on every write (a counter column, a row version, a content hash). The built-in backends both include it: fileKv uses content-hash tokens (single-process correctness) and cursorHostedStorage the control-plane proxy (a version counter on the server).

Durable sessions (the session ledger)

Conditional writes are the substrate for the session ledger, which ships behind defineAgent({ serving: "ledger" }): session identity, one-writer-per-session leases with generation fencing, a fenced commit sequence, and a durable inbox. A ledger-mode delivery parks its event in the session mailbox first — the source ack point moves after that write — then tries the lease: if another holder is serving the session, the delivery walks away and the holder's commit drains the parked event in a follow-up turn under the same lease. Sessions whose holder died with events pending are recovered by a janitor loop that scans ledgers by prefix, which is why ledger mode requires the full cas group including listKeys: the janitor scan and the orphan sweep are prefix scans, and mailbox payload blobs go through the backend's plain get/put/delete. Coordination runs through the agent's storage backend by default or an explicit durableSessionStore override. Either way you supply the raw store: the engine namespaces every ledger key under agentkit/v1/{agent}/durable-session/ itself, at the same boundary where host.kv gets its agent scoping — a self-hoster never writes prefixes. A backend that implements cas needs no storage migration to adopt this.

One sharp edge: the cas group and the plain methods must be backed by the same durable store. Pairing a real plain backend with memoryCasTable() (or any process-local cas stand-in) and serving: "ledger" silently voids ack-after-durable — the mailbox blobs persist but the leases, heads, and pending indexes evaporate on restart, so acked events are never redelivered. memoryCasTable() is for test fixtures and inert sinks only.