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 id
cache = 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

kv.ts
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 existed
kv.py
await 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")
MethodDescription
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: 0 succeeds 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 set clears the expiry. Writing a key without ttl makes it permanent, even if it had a TTL before. Pass ttl again on every write to keep a key expiring.
  • Expiry is absolute. expires_at is 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 negative
calls = (await cache.incr("rate:agent-42", 1, ttl="1m")).value

A 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 existed

A 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

LimitValue
Key1–1,024 bytes of UTF-8
Value1 MiB (JSON as serialized, or bytes)
TTL1 second to 365 days
Batch (getMany, setMany, deleteMany)100 keys
Page size100 by default, up to 1,000
Counter64-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 file

Writes 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

ToolRead-onlyArguments
kv_getyesprimitive?; key, or keys for many
kv_listyesprimitive?, prefix?, cursor?, limit?, values?
kv_setnoprimitive?; 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_deletenoprimitive?; 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.