Primitives
KV
A key-value store with JSON or byte values, TTLs, versions for compare-and-swap, atomic increments and batches.
KV is a key-value store for the small pieces of state an agent reads and writes all the time: session state, feature flags, rate-limit counters, cursors, cached API responses. Each key holds a JSON value or raw bytes, with an optional expiry and a version you can compare-and-swap on. Increments and batches are atomic.
Creating a store
A KV store is a primitive inside a group with an immutable id and a name. A group can hold as many as you need, for example a cache that can be thrown away and a state the agent relies on:
const cache = await group.kv.create('cache'); // 409 already_exists if taken; cache.id is its id
const state = group.kv('state'); // by name in the group, resolved on first use
const same = p.kv(cache.id); // the same store, by idcache = await group.kv.create("cache")
same = p.kv(cache.id)From the CLI: primitives primitive create cache --type kv --group agent-42 (prints the id).
Reading and writing
await cache.set('user:42:prefs', { theme: 'dark', lang: 'en' });
await cache.set('session:abc', { step: 3 }, { ttl: '1h' }); // gone in an hour
await cache.set('thumb:42', pngBytes); // a Uint8Array is stored as bytes
const entry = await cache.get('user:42:prefs');
// { key, value: { theme: 'dark', lang: 'en' }, encoding: 'json', version: 7, expires_at: null, created_at, updated_at }
const missing = await cache.get('nope'); // null
await cache.delete('session:abc'); // true if it existedawait cache.set("user:42:prefs", {"theme": "dark", "lang": "en"})
await cache.set("session:abc", {"step": 3}, ttl="1h")
entry = await cache.get("user:42:prefs") # None if missing
print(entry.value, entry.version)
await cache.delete("session:abc")| Method | Description |
|---|---|
get(key) | The entry, or null if the key doesn't exist or has expired. |
set(key, value, { ttl?, ifVersion? }) | Create or replace a key. Any JSON value, or a Uint8Array for bytes. |
delete(key, { ifVersion? }) | Delete a key. Returns whether it existed. delete() with no key deletes the whole store. |
incr(key, by = 1, { ttl? }) | Add by to an integer and return the new entry. |
getMany(keys) / setMany(items, { atomic? }) / deleteMany(keys) | Batches of up to 100 keys. |
list({ prefix?, cursor?, limit?, values? }) | One page of keys in order. entries(…) iterates every page. |
In Python the batch and iteration methods are get_many, set_many, delete_many and iter, and options are keyword arguments such as ttl="1h" and if_version=7.
An entry is { key, value, encoding, version, expires_at, created_at, updated_at }. encoding is json or bytes; over REST and MCP, bytes travel as base64 in value. Times are milliseconds since the epoch, and expires_at is null for a key that doesn't expire.
Versions and compare-and-swap
Every write gives the key a new version. Versions come from one counter per store that only goes up, so a version is never reused: if a key is deleted and created again, it gets a higher version than before, and a stale read can't match by accident.
Pass ifVersion to write only if the key is still at the version you read. Otherwise the write fails with 412 precondition_failed and nothing changes:
const current = await state.get('plan');
const next = { ...current?.value, step: 4 };
await state.set('plan', next, { ifVersion: current?.version ?? 0 }); // 0 means "must not exist yet"current = await state.get("plan")
await state.set("plan", {**current.value, "step": 4}, if_version=current.version)ifVersion: 0 creates a key only if it doesn't exist, which makes a simple lock or claim. delete(key, { ifVersion }) deletes only at that version.
Expiry
ttl sets how long a key lives, as a duration such as '30s', '15m' or '7d' (or milliseconds), from 1 second to 365 days.
- An expired key is gone at once. Reads, lists and counts skip it, and writes treat it as absent, so
ifVersion: 0succeeds on an expired key. - Storage is reclaimed lazily. There's no background timer: each write first removes up to 64 expired keys. An idle store keeps its expired rows on disk until the next write, but they're never returned.
- A plain
setclears the expiry. Writing a key withoutttlmakes it permanent, even if it had a TTL before. Passttlagain on every write to keep a key expiring. - Expiry is absolute.
expires_atis a point in time, so a restored or forked key expires when the original would have.
Counters
incr adds to an integer atomically, so concurrent callers never lose an update:
const { value: calls } = await cache.incr('rate:agent-42', 1, { ttl: '1m' }); // a per-minute counter
await cache.incr('stock:sku-9', -3); // by can be negativecalls = (await cache.incr("rate:agent-42", 1, ttl="1m")).valueA missing key starts at 0. The value must be a JSON integer that fits in 64 bits; anything else, or a result that would overflow, fails with 400 invalid_request. incr keeps the key's existing expiry unless you pass ttl.
Batches
const [a, b] = await state.getMany(['task:1', 'task:2']); // null for missing keys, in order
await state.setMany([
{ key: 'task:1', value: { status: 'done' }, ifVersion: a?.version ?? 0 },
{ key: 'task:2', value: { status: 'ready' }, ttl: '1d' },
]);
const deleted = await state.deleteMany(['task:1', 'task:2']); // how many existedA batch holds up to 100 keys. setMany is atomic by default: every item is written in one transaction, and if any ifVersion doesn't match, nothing is written and the call fails with 412 precondition_failed naming the item's index. With atomic: false, each item is written in its own transaction, in order, and the call stops at the first failure: items before it stay written. An atomic batch is audited as one kv.mset; a non-atomic one as a kv.set per item.
Listing
Keys are kept in byte order, so related keys that share a prefix list together:
const page = await cache.list({ prefix: 'user:42:', limit: 100 });
// { data: [{ key, version, expires_at, … }], next_cursor: 'dXNlcjo0Mjp0…' | null }
const more = await cache.list({ prefix: 'user:42:', cursor: page.next_cursor });
for await (const entry of cache.entries({ prefix: 'session:', values: true })) {
console.log(entry.key, entry.value);
}async for entry in cache.iter(prefix="session:", values=True):
print(entry.key, entry.value)Pages hold 100 keys by default and up to 1,000. Values are left out unless you pass values: true: without it, entries have no value field at all. The cursor is opaque: pass next_cursor back to get the next page, and stop when it's null.
Keys
Keys are UTF-8 strings of 1 to 1,024 bytes. They can't contain control characters, start with /, or have . or .. as a /-separated segment. Any other naming scheme works; : and / are common separators.
Limits
| Limit | Value |
|---|---|
| Key | 1–1,024 bytes of UTF-8 |
| Value | 1 MiB (JSON as serialized, or bytes) |
| TTL | 1 second to 365 days |
Batch (getMany, setMany, deleteMany) | 100 keys |
| Page size | 100 by default, up to 1,000 |
| Counter | 64-bit signed integer |
Snapshots, forks and pause
A KV store has its own lifecycle, like every primitive:
await state.snapshot('before-run');
await state.restore('before-run');
const copy = await state.fork({ name: 'state-try' }); // a new store in the same group, new id
await state.pause(); // reads and writes fail with 423 paused
await state.resume();
const { items } = await state.stats(); // items = keys that haven't expired
const file = await state.export(); // Uint8Array, the store as a SQLite fileWrites are audited as kv.set, kv.delete, kv.incr, kv.mset and kv.mdelete. Values never are.
Over REST
curl -X PUT http://localhost:7777/v1/primitives/$ID/keys/session:abc \
-d '{"value":{"step":3},"ttl":"1h"}'
curl http://localhost:7777/v1/primitives/$ID/keys/session:abc
curl -X POST http://localhost:7777/v1/primitives/$ID/incr -d '{"key":"rate:agent-42","by":1}'All endpoints are in the REST API reference.
MCP tools
| Tool | Read-only | Arguments |
|---|---|---|
kv_get | yes | primitive?; key, or keys for many |
kv_list | yes | primitive?, prefix?, cursor?, limit?, values? |
kv_set | no | primitive?; key and value with encoding? (json or bytes, base64), ttl?, if_version?; or items to set many; or key and incr (an integer) to increment |
kv_delete | no | primitive?; key and if_version?, or keys |
primitive is the store to use: its id or its name from primitive_list. It can be left out when the group has exactly one KV store. Agents create one with primitive_create { "type": "kv", "name": "cache" }. See the tools reference.