Getting started
Groups
The unit of isolation: primitives by id and name, per-primitive snapshots, forks and pause, access and the audit trail.
A group is a collection of primitives reached with one key: databases, filesystems, queues, memory stores, vaults, KV stores and object stores for a single agent, task or session, isolated from every other group. A group has no behaviour of its own beyond create and delete: it only decides which account owns its primitives and which scoped API keys reach them. It starts empty, and you add as many primitives of each type as you need, each with its own name. On disk it is one folder with one SQLite file per primitive (db/orders.sqlite, fs/workspace.sqlite, …), plus group.sqlite for its manifest, primitive registry, snapshots and keys.
Creating a group
const group = await p.groups.create({ name: `agent-${agentId}` });| Field | Type | Description |
|---|---|---|
name | string | Unique within your workspace. 1–63 lowercase letters, digits, -, _ or ., starting with a letter or digit. |
Creating a name that exists fails with 409 already_exists. create returns the group with its primitives, none yet:
{
"id": "01k7c3…",
"namespace": "default",
"name": "agent-42",
"created_at": 1760000000000,
"primitives": []
}To open an existing group, call get. p.group(name) returns a handle without a request, which is what an agent holding a key scoped to that group uses. To page through every group:
const group = await p.groups.get('agent-42');
for await (const m of p.groups.all()) {
console.log(m.name);
}Primitives
Each primitive has an immutable id (a 26-character lowercase ULID), a type (db, fs, queue, memory, vault, kv or storage), a name that is unique per group and type, and the group it lives in. Names follow the group naming rules. The API addresses a primitive by its id, so renaming or moving it never breaks a handle.
const orders = await p.primitives.create({ name: 'orders', type: 'db', group: 'agent-42' });
orders.id; // '01jbz3k8m5q2w7x9v4n6t0r1ce'
const same = p.db(orders.id); // typed handle by id, no request
const any = p.primitive(orders.id); // untyped handle by id
const info = await p.primitives.get(orders.id); // PrimitiveInfo
const dbs = await p.primitives.list({ type: 'db', group: 'agent-42' });A group handle has shortcuts that work by name inside that group:
const cache = await group.db.create('cache'); // a second database in the same group
const docs = await group.fs.create('docs');
const notes = await group.memory.create('notes');
const byName = group.db('orders'); // resolved by name on first use
const all = await group.db.list(); // every database in the group
const fsOnly = await group.primitives.list({ type: 'fs' });Each primitive is returned as { id, name, type, group, created_at, paused, size_bytes, forked_from }. Creating one whose name is taken in that group for that type fails with 409 already_exists; an unknown id fails with 404 not_found. Calling a data method that doesn't fit the primitive's type fails with 400 wrong_type.
await orders.rename('orders-v1'); // same id
await orders.moveTo('agent-43'); // same id, now in another group
await orders.delete(); // the primitive and its dataMoving a vault re-encrypts its secrets under the new group's key, and moving a storage primitive invalidates its signed URLs. Renaming or moving fails with 409 already_exists if the name is taken there.
Snapshots, forks and pause all belong to one primitive, never to the group. On Primitives Cloud, managed snapshots, forks and restore are on the Pro and Enterprise plans (on Starter, listing existing snapshots still works). The open-source engine includes them on every install.
Snapshots
A snapshot is a restore point for one primitive.
await orders.snapshot('before-deploy'); // name is optional
const snaps = await orders.snapshots(); // newest first
await orders.restore('before-deploy'); // by name or idRestoring replaces that primitive's file with the snapshot's copy; the group's other primitives are untouched. Snapshot names follow the group naming rules. Snapshots live inside the group and are deleted with it.
Forks
fork() copies one primitive, at one consistent moment, into a new primitive with a new id. The copy lands in the source's group by default, or in another group with group.
const trial = await orders.fork({ name: 'orders-try' });
// ... let a sub-agent work on `trial` ...
await trial.delete();
const sandbox = await docs.fork({ group: 'agent-42-sandbox' }); // another group
const before = await orders.fork({ snapshot: 'before-deploy' }); // from a snapshot| Option | Description |
|---|---|
name | Name of the copy. Default <name>-fork-<6 chars>. Fails with 409 already_exists if the target group already has it. |
group | Target group. Default: the source's group. It must exist: forking into a group that doesn't fails with 404 not_found. |
snapshot | Fork from this snapshot (name or id) instead of the live data. |
The copy's forked_from records the source as { id, name, type, group, snapshot }. What it gets:
- Database: the data as it was at the fork. Branches are not copied.
- Filesystem: every file. File contents over 64 KiB live in a content-addressed store shared by the workspace, so forks never duplicate large files.
- Queue: every queue and job. Leased jobs are released, so they are ready in the copy.
- Memory: every memory.
- Vault: every secret and grant, re-encrypted under the target group's key. Forking a vault is explicit, so there is no extra opt-in.
- KV: every key with its version and absolute expiry, so a key expires in the copy when it would have in the source.
- Storage: every object with its content type and metadata. The copy has a new id, so signed URLs for the source don't work on it.
To hand a sub-agent less, fork only the primitives it needs into a group of its own and give it an API key scoped to that group. See Fork and snapshot primitives.
Pause and destroy
await orders.pause(); // every data call now fails with 423 paused
await orders.resume();
await group.destroy({ force: true }); // delete the group, every primitive in it and its snapshotsA paused primitive still answers info, stats, snapshot, restore and fork calls, and stays paused across restarts. orders.stats() returns { id, name, type, size_bytes, items, paused }. Without force, destroy deletes only an empty group and fails with 409 conflict otherwise. It cannot be undone, and the name can be reused immediately.
Access
There are two kinds of caller:
- Account callers manage groups: a workspace
pk_API key in the cloud (every group, every tool), or anyone on localhost withprimitives dev(optionally gated by a root token). - Scoped API keys (
pk_…) reach one group, every primitive in it, and only the tools on their allowlist. Create them in the dashboard and hand them to agents. MCP clients that sign in with OAuth get the same scope. See Auth & scoping.
Scoped keys can't create, list or destroy groups, delete, rename, move, pause or resume primitives, read the audit trail, or store and grant secrets. They can read the group, list and get its primitives, and list snapshots. A primitive in another group looks like it doesn't exist (404 not_found). With primitive_create, primitive_snapshot or primitive_fork on their allowlist they can create primitives in their group, snapshot and restore them, and fork them within their own group.
API keys
In the cloud, each pk_ API key is scoped when you create it in the dashboard:
- Group: one named group, or the whole workspace (every group, including ones created later).
- Tools: optionally, a subset of tools such as
db_queryorfs_*. Calls to anything outside the list fail with403 forbidden.
A key scoped to one group can't create, list or destroy other groups. Use workspace keys for the code that provisions groups, and keys scoped to one group and its tools for agents.
Audit trail
Every primitive's file carries its own audit table, written in the same transaction as the change it records. group.audit() (account callers only) merges the group's events and every primitive's, newest first:
const { data, next_cursor } = await group.audit({ limit: 20, action: "db." });
// [{ id, ts, group: 'agent-42', primitive: 'db', primitive_id: '01k…', instance: 'orders', actor: 'key:01k…', action: 'db.exec', target, detail }]Use p.audit() for the whole workspace and primitive.audit() for one primitive. See Audit log for what's recorded, the filters and retention.
Limits
| Resource | Limit |
|---|---|
| Request body | 32 MiB |
| File size | 32 MiB |
| Rows returned per query | 10,000 (the response sets truncated) |
| Statements per batch | 1,000 |
| Memory text | 32 KiB |
| Secret value | 64 KiB |
| Page size for list endpoints | 50 by default, 200 max |