Appearance
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.
| Field | Required | Role |
|---|---|---|
put | Yes | Write or update a value |
cas | For coordination | Conditional writes; see Conditional writes |
get | For restore | Look up one key (also: derived A/B snapshot backfill) |
list | For restore | Return entries under a prefix, in key order (also: derived eval-runs hydrate) |
delete | For cleanup | Remove a key (also: derived eval-runs pruning) |
name | No | Label surfaced on GET /v1/info diagnostics |
policy | No | Timing knobs; see Policy |
evals | No | Backend-native eval-runs table; derived over the core when omitted — see Eval and A/B tables |
abs | No | Backend-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 …
});| Knob | Default | Meaning |
|---|---|---|
debounceMs | unset (once per turn) | Wait this long after activity before writing event batches |
restore | caps below | How much to load at serve start |
restore.maxSessions | 1000 | Max sessions loaded at serve start |
restore.maxAgeMs | 30 days | Skip older sessions at serve start |
restore.maxTotalBytes | 1 GiB | Stop 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:
| Import | Backend |
|---|---|
@cursor/july/storage/file-kv | File-per-key under .agent-serve/kv |
@cursor/july/storage/cursor-hosted | Platform 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 withIf-None-Match: *,putIfVersion= put withIf-Match: <etag>, the ETag is the version token,listKeysis 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.