Reference
REST API
Authentication, errors, rate limits, usage and plan limits, pagination and every /v1 endpoint.
The REST API is what both SDKs and the MCP server are built on. It speaks JSON over HTTP and is versioned in the path.
| Base URL | |
|---|---|
| Cloud | https://api.useprimitives.com |
Local (primitives dev) | http://localhost:7777 |
All paths below start with /v1. The engine also serves GET /health, which returns { "ok": true, "version": "…" }.
Authentication
Send a Bearer token with every request:
curl https://api.useprimitives.com/v1/groups \
-H "Authorization: Bearer $PRIMITIVES_API_KEY"- Workspace API keys (
pk_…, cloud), scoped to every group and every tool, can call every endpoint for your account. - Scoped API keys (
pk_…, cloud), limited to one group or to some tools, can reach only their group and the primitives in it, and only through endpoints their tool allowlist covers. A primitive in another group answers404 not_found, so its existence isn't leaked. Endpoints marked account below reject them with403 forbidden. Create and scope keys in the dashboard; see Auth & scoping. - Locally,
primitives devneeds no token on localhost. Started with--token <secret>, it requires that value as the Bearer token for account-level calls.
No endpoint returns a secret's value.
Errors
Every error returns a non-2xx status and the same JSON envelope:
{
"error": {
"code": "branch_conflict",
"message": "Main database changed since branch 'try-migration' was created."
}
}Every response carries an X-Request-Id header. Include it when you contact support.
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_request | The body or parameters failed validation, or the SQL is invalid. |
| 400 | wrong_type | The data route doesn't apply to this primitive's type, such as query on a filesystem. The message names its type. |
| 401 | invalid_token | The API key or token is missing, malformed, revoked or expired. |
| 403 | forbidden | The caller can't do this, such as a scoped API key calling an account endpoint, or a write through a read-only key. |
| 403 | group_mismatch | The path or X-Primitives-Group header names a group outside the API key's scope. |
| 403 | plan_limit | The workspace's plan doesn't include this, such as a second group on Starter, a snapshot, restore or fork on Starter, or the audit log on Starter. |
| 403 | tool_not_allowed | The API key's tool allowlist doesn't cover this endpoint. |
| 403 | secret_policy_denied | The request is outside the secret's grant, there is no grant, or the destination is blocked. |
| 404 | not_found | The group, primitive, file, job, memory, key, object, branch or snapshot doesn't exist, or the primitive is outside the API key's group. The message names it. |
| 409 | already_exists | A group or branch with that name exists, or the group already has a primitive of that type with that name. |
| 409 | conflict | The request conflicts with current state, such as a constraint violation, requeueing a job that isn't dead, or deleting a non-empty group without ?force=true. |
| 409 | branch_conflict | The main database changed since the branch was created. |
| 409 | fs_conflict | A file or directory is in the way, or a directory isn't empty. |
| 410 | lease_expired | The queue lease ran out before the job was settled. |
| 410 | secret_grant_expired | The secret's grant has expired. |
| 412 | precondition_failed | A condition didn't hold: a key's if_version didn't match, or an object's If-Match / If-None-Match failed. Nothing was written. |
| 413 | payload_too_large | A file, object, key value or secret value exceeds its limit. |
| 416 | range_not_satisfiable | The Range of an object read starts past its end. |
| 423 | paused | The primitive is paused. Resume it to read or write its data. |
| 429 | plan_limit | A Starter workspace reached its monthly operations cap, its storage cap on a write, or its 100-primitive cap on a create. See Usage and limits. |
| 429 | rate_limited | Too many requests. See below. |
| 500 | internal_error | Something went wrong on our side. Safe to retry. |
| 502 | upstream_error | The upstream request made by a vault's fetch failed or timed out. |
Rate limits
The cloud API limits each API key to about 20 requests per second, with bursts up to 60. Over the limit it answers 429 rate_limited with a Retry-After header in seconds. Both SDKs retry 429 with backoff. The local engine doesn't rate-limit. RateLimit-* headers are coming soon.
Usage and limits
On Primitives Cloud, usage is measured in operations. An operation is any call that touches a primitive's data or lifecycle through REST, the SDKs or MCP:
- Every route under
/v1/primitives/{id}/…: databasequery,execandbatch, files, queues, memories, vaults (includingfetch), keys, objects (includingpresign), and snapshots, fork, restore, pause, resume, export and stats (but notaudit). - Every MCP
tools/callof a primitive tool. Each call in a JSON-RPC batch counts on its own.
These are not operations: creating a primitive (POST /v1/primitives or primitive_create), GET /v1/groups and the other group routes, reading the audit trail (GET /v1/audit and /v1/primitives/{id}/audit), listing primitives with GET /v1/primitives, the primitive_list MCP tool, API key management, MCP initialize and tools/list, and dashboard page loads. Queue messages aren't a separate limit; sending and receiving them are operations like any other.
| Plan | Operations / month | Storage | Primitives |
|---|---|---|---|
| Starter | 1M, hard cap | 5 GB, hard cap | 100, hard cap |
| Pro | 25M included, then $0.50 per extra 1M | 50 GB included, then $0.25 / GB-month | Unlimited |
| Enterprise | Custom | Custom | Unlimited |
On Starter, requests over a cap are refused, never billed. Once the workspace has used its 1M operations for the month, further operations get 429 plan_limit:
{
"error": {
"code": "plan_limit",
"message": "This workspace used its 1M operations for this month on the Starter plan. Upgrade to Pro or wait until 2026-11-01."
}
}At 5 GB of storage, write operations get 429 plan_limit: database exec and batch, file writes, queue sends, remember, setting a secret, setting or incrementing keys, putting or copying objects (including uploads through a signed URL) and creating a primitive (creating one isn't an operation, but it adds storage). Reads and deletes keep working, so you can free up space. Unlike rate_limited, retrying with backoff won't help; upgrade or wait for the reset.
Pro is never cut off. Usage beyond the included operations and storage is billed as overage. Operation counters reset on the 1st of each month, UTC.
Pagination
Endpoints that page take limit (default 50, max 200) and cursor, and return:
{ "data": [], "next_cursor": null }Pass next_cursor back as cursor for the next page. When it's null, there are no more pages. Key and object listings page the same way, with 100 entries by default and up to 1,000. Other list endpoints return { "data": [...] } in one response.
Durations
Fields such as delay, lease, ttl and expires_in take strings like "250ms", "30s", "5m", "1h" or "7d"; a bare number in a string means seconds. Queue durations also accept an integer number of milliseconds. Timestamps in responses are milliseconds since the Unix epoch.
Endpoints
Every primitive (a database, filesystem, queue, memory, vault, key-value store or object store) has an immutable id, a 26-character lowercase ULID, and every primitive endpoint addresses it by that id: /primitives/{id}/…. Renaming or moving a primitive never changes its id.
A primitive also has a type (db, fs, queue, memory, vault, kv or storage), a name that's unique per group and type, and a group. The group is only a collection: it decides which account owns the primitive and which scoped API keys can reach it. {group} is the group name. Group and primitive names are 1–63 characters from a-z, 0-9, -, _ and ., starting with a letter or digit.
Shapes
| Shape | Fields |
|---|---|
| Manifest | { id, namespace, name, created_at } |
| GroupInfo | Manifest plus primitives: PrimitiveInfo[] |
| PrimitiveInfo | { id, name, type, group, created_at, paused, size_bytes, forked_from }. paused is a boolean, size_bytes a number. forked_from is { id, name, type, group, snapshot } or null; id may be null, and snapshot is null for a fork of live data |
| SnapshotInfo | { id, name, created_at, primitive }. name is the snapshot's label, or null. primitive is the primitive's id |
| PrimitiveStats | { id, name, type, size_bytes, items, paused }. items counts tables (db), files (fs), pending jobs, ready or leased (queue), live memories (memory), secrets (vault), unexpired keys (kv) or objects (storage) |
| KvEntry | { key, value, encoding, version, expires_at, created_at, updated_at }. encoding is "json" or "bytes"; bytes are base64 in value. Listings leave the value field out entirely unless values=true. expires_at is null for a key that doesn't expire |
| ObjectInfo | { key, size, etag, content_type, metadata, created_at, updated_at }. metadata is an object of string pairs |
| AuditEntry | { id, ts, group, primitive, primitive_id, instance, actor, action, target, detail }. primitive is "group" for group events (then primitive_id and instance are null), otherwise the type of the primitive the event is about, including its lifecycle events; instance is its name |
Groups
A group has no lifecycle of its own beyond create and delete. It starts empty.
| Method | Path | Description |
|---|---|---|
POST | /groups | Account. Create. Body: { name }. 201 with GroupInfo |
GET | /groups | Account. List manifests. Paged |
GET | /groups/{group} | Get the group and its primitives (GroupInfo) |
DELETE | /groups/{group} | Account. Delete an empty group. ?force=true also deletes every primitive in it; without it a non-empty group answers 409 conflict. 204 |
GET | /groups/{group}/audit | Account. Audit trail of the group and its primitives, newest first. Paged AuditEntry. Takes the audit filters |
Audit
| Method | Path | Description |
|---|---|---|
GET | /audit | Account. The workspace's audit trail across every group, newest first. Paged AuditEntry |
Every audit route takes these query filters, which combine: group, type (group or a primitive type), primitive (an id), action (exact, or a prefix ending in . or * such as db.), actor (exact, or a prefix ending in * such as key:*), and since / until (inclusive, ms since the epoch), plus limit and cursor. An unknown type or since after until is 400 invalid_request. Scoped API keys get 403 forbidden. In the cloud the audit log needs Pro (403 plan_limit on Starter) and Pro reads go back 30 days. See Audit log.
Primitives
Snapshots, restore, fork, pause and stats belong to one primitive, not to the group.
| Method | Path | Description |
|---|---|---|
POST | /primitives | Create a primitive. Body: { name, type, group }. 201 with PrimitiveInfo, including the new id. Key tool: primitive_create, and group must be the key's |
GET | /primitives | List primitives. ?type=&group= filter. Paged PrimitiveInfo. A scoped key sees only its own group |
GET | /primitives/{id} | Get one (PrimitiveInfo) |
PATCH | /primitives/{id} | Account. Rename or move. Body: { name?, group? }. Returns PrimitiveInfo. A vault moved to another group is re-encrypted under that group's key. 409 already_exists if the name is taken there |
DELETE | /primitives/{id} | Account. Delete the primitive and its data. 204 |
POST | /primitives/{id}/snapshots | Snapshot. Body: { name? }, a label. 201 with SnapshotInfo. Key tool: primitive_snapshot |
GET | /primitives/{id}/snapshots | List snapshots, newest first. Paged SnapshotInfo |
POST | /primitives/{id}/restore | Restore. Body: { snapshot }, a snapshot label or id. Returns SnapshotInfo. Key tool: primitive_snapshot |
POST | /primitives/{id}/fork | Copy into a new primitive. Body: { name?, group?, snapshot? }. 201 with the copy's PrimitiveInfo, which has a new id. Key tool: primitive_fork |
POST | /primitives/{id}/pause | Account. Pause. Returns PrimitiveInfo |
POST | /primitives/{id}/resume | Account. Resume. Returns PrimitiveInfo |
GET | /primitives/{id}/stats | PrimitiveStats. Key tool: the type's read tool |
GET | /primitives/{id}/audit | Account. Every audit entry about this primitive (its data and lifecycle events), newest first. Paged AuditEntry. Takes the audit filters. Works while paused |
GET | /primitives/{id}/export | Databases, KV and storage. Download the primitive as a SQLite file (application/vnd.sqlite3); other types answer 400 wrong_type. Key tool: the type's read tool (db_query, kv_get or storage_read) |
Fork. name defaults to <name>-fork-<6 chars>. group is a group name and defaults to the source's group; a group that doesn't exist answers 404 not_found. snapshot (a label or id) forks from that snapshot instead of the live data. The copy gets a new id; its forked_from records the source, and its group says where it landed. If the target group already has a primitive of that type and name, the fork fails with 409 already_exists. A filesystem fork shares files over 64 KiB instead of copying them. A queue fork releases leases: leased jobs are ready again in the copy. A vault fork re-encrypts every value under the target group's key. A storage fork doesn't accept the source's signed URLs, and moving a storage primitive to another group invalidates its signed URLs.
Pause. Every data call on a paused primitive answers 423 paused. Info, stats, listing and creating snapshots, restore, fork, pause and resume still work. Pausing survives restarts.
Types. The data routes below are per type. Calling one on a primitive of another type, such as …/query on a filesystem, answers 400 wrong_type.
Database
Under /primitives/{id}, for a primitive of type db:
| Method | Path | Description |
|---|---|---|
POST | …/query | Run one statement. Body: { sql, params?, branch? }. …/exec is an alias |
POST | …/batch | Run statements in one transaction. Body: { statements: [{ sql, params? }], branch? }. Returns { results } |
GET | …/tables | List tables and views. ?branch= |
GET | …/tables/{table} | Columns and SQL of one table. ?branch= |
POST | …/branches | Create a branch. Body: { name } |
GET | …/branches | List branches |
POST | …/branches/{branch}/promote | Promote. Body: { force? } |
DELETE | …/branches/{branch} | Discard |
GET | …/export | Download as a SQLite file |
Filesystem
Under /primitives/{id}, for a primitive of type fs:
| Method | Path | Description |
|---|---|---|
GET | …/files/{path} | Read a file (raw bytes), or list a directory. ?list=1&recursive=1, ?stat=1 |
PUT | …/files/{path} | Write a file. The raw body is the contents; X-Primitives-Mode sets permissions |
DELETE | …/files/{path} | Delete. ?recursive=1 for a non-empty directory |
POST | …/move | Move. Body: { from, to, overwrite? } |
POST | …/mkdir | Create a directory. Body: { path } |
GET | …/glob | ?pattern=&limit=. Returns { data: [paths] } |
GET | …/search | ?q=&path=&limit=. Returns { data: [{ path, snippet }] } |
POST | …/forks | Fork a directory inside this filesystem. Body: { from, to? }. Returns { path, files } |
GET …/files with no path lists the root.
Queue
A queue primitive (type queue) holds any number of named queues and topics. Under /primitives/{id}:
| Method | Path | Description |
|---|---|---|
GET | …/queues | Per-queue counts |
POST | …/queues/{queue}/messages | Send. Body: { body, delay?, dedupe_key?, priority?, max_attempts? }. 201, or 200 with deduplicated: true |
GET | …/queues/{queue}/messages | List jobs. ?status=&limit=&cursor= |
GET | …/queues/{queue}/messages/{job} | Get a job |
POST | …/queues/{queue}/receive | Lease jobs. Body: { lease?, wait?, max? }. Returns { data: [jobs with lease_token] } |
POST | …/queues/{queue}/messages/{job}/ack | Settle. Body: { lease_token, action?: "ack" | "retry" | "fail" | "dead", in?, error? } |
POST | …/queues/{queue}/messages/{job}/heartbeat | Extend a lease. Body: { lease_token, extend? } |
POST | …/queues/{queue}/messages/{job}/requeue | Move a dead job back to ready |
POST | …/queues/{queue}/purge | Delete finished jobs. Body: { status: "done" | "dead", older_than? } |
POST | …/topics/{topic}/subscriptions | Subscribe a queue. Body: { queue } |
DELETE | …/topics/{topic}/subscriptions/{queue} | Unsubscribe |
POST | …/topics/{topic}/publish | Publish. Body: { body }. Returns { delivered } |
Memory
Under /primitives/{id}, for a primitive of type memory:
| Method | Path | Description |
|---|---|---|
POST | …/memories | Remember. Body: { kind, text, tags?, owner?, metadata? } |
GET | …/memories | List, newest first. ?kind=&tag=&owner=&limit=&cursor= |
GET | …/memories/{memory} | Get one |
DELETE | …/memories/{memory} | Forget |
POST | …/memories/{memory}/supersede | Supersede. Body: { text, kind?, tags?, metadata? } |
POST | …/recall | Recall. Body: { query, kinds?, tags?, owner?, limit?, include_superseded? } |
POST | …/consolidate | Merge duplicates. Body: { threshold? }. Returns { merged, superseded } |
Vault
Under /primitives/{id}, for a primitive of type vault:
| Method | Path | Description |
|---|---|---|
PUT | …/secrets/{NAME} | Account. Store a value. Body: { value } |
DELETE | …/secrets/{NAME} | Account. Delete a secret and its grant |
GET | …/secrets | List names, versions and grants. Paged |
POST | …/grants | Account. Grant. Body: { name, allow: { hosts, methods? }, ttl? } |
GET | …/grants | List grants. Paged |
DELETE | …/grants/{NAME} | Account. Revoke a grant |
POST | …/fetch | Make a request with a secret injected. Body: { url, auth, method?, headers?, body? } |
KV
Under /primitives/{id}, for a primitive of type kv. {key} is the rest of the path, so keys may contain /:
| Method | Path | Description |
|---|---|---|
GET | …/keys | List keys in order. ?prefix=&cursor=&limit=&values=. Returns { data: [KvEntry], next_cursor } |
GET | …/keys/{key} | Get one (KvEntry). 404 not_found if missing or expired |
PUT | …/keys/{key} | Set. Body: { value, encoding?, ttl?, if_version? }; value is required (null is a valid JSON value). Returns KvEntry. Without ttl, any expiry is cleared. if_version: 0 means the key must not exist |
DELETE | …/keys/{key} | Delete. ?if_version=. Returns { deleted } |
POST | …/mget | Get many. Body: { keys }. Returns { data: [KvEntry | null] } in order |
POST | …/mset | Set many. Body: { items: [{ key, value, encoding?, ttl?, if_version? }], atomic? }, up to 100. Atomic by default; with atomic: false each item is its own transaction and the call stops at the first failure, keeping earlier items. Returns { data: [KvEntry] } |
POST | …/mdelete | Delete many. Body: { keys }. Returns { deleted }, a count |
POST | …/incr | Increment an integer. Body: { key, by?, ttl? }, by defaults to 1. Returns KvEntry |
A failed if_version answers 412 precondition_failed; in an atomic mset, the message names the item's index and nothing is written.
Storage
Under /primitives/{id}, for a primitive of type storage. {key} is the rest of the path:
| Method | Path | Description |
|---|---|---|
GET | …/objects | List. ?prefix=&delimiter=&cursor=&limit=. Returns { data: [ObjectInfo], prefixes, next_cursor } |
GET / HEAD | …/objects/{key} | Read the raw bytes. Takes Range (206 with Content-Range) and If-None-Match (304). Responds with ETag, Content-Type, Content-Length, Accept-Ranges, Last-Modified and x-object-meta-*. ?stat=1 returns ObjectInfo as JSON instead |
PUT | …/objects/{key} | Put. The raw body is the object; Content-Type, x-object-meta-*, If-Match and If-None-Match: * apply. Returns ObjectInfo |
DELETE | …/objects/{key} | Delete. 204, even if the object doesn't exist. Takes If-Match, which fails with 412 if it doesn't |
POST | …/copy | Copy. Body: { from, to, if_none_match? }. Returns ObjectInfo |
POST | …/presign | Sign a URL. Body: { key, method?: "GET" | "PUT", expires_in?, content_type?, max_size?, if_none_match? }. method defaults to GET; if_none_match: "*" makes a PUT URL create-only. expires_at is in ms. Returns { url, method, headers, expires_at } |
POST | …/presign/revoke | Invalidate every signed URL issued for this store. Returns { epoch } |
A failed If-Match or If-None-Match on a write answers 412 precondition_failed. A Range that starts past the end answers 416 range_not_satisfiable with Content-Range: bytes */<size>; a malformed or multi-range Range is ignored and the whole object is returned with 200. Object reads send X-Content-Type-Options: nosniff and Content-Security-Policy: sandbox.
Signed URLs
Signed URLs are served at the top level, outside /primitives, and need no Authorization header: the token in the path is the credential.
| Method | Path | Description |
|---|---|---|
GET / HEAD | /signed/{token} | Download the object a GET URL was signed for, as …/objects/{key} does, including Range and If-None-Match |
PUT | /signed/{token} | Upload to the key a PUT URL was signed for. Send the headers that presign returned; a body over max_size or another content type than the signed one is refused |
A bad signature, an expired or revoked URL, the wrong method or a primitive that no longer exists all answer 403 forbidden, without saying which, and a paused store answers 423 paused. In the cloud, a malformed token is 400 invalid_request. In the cloud, signed routes allow cross-origin requests from any origin and are rate-limited by IP. Each request counts as one operation of the store's group. See Signed URLs.
Tool allowlists and endpoints
For API keys limited to some tools, each endpoint needs one tool on the allowlist:
| Tool | Endpoints |
|---|---|
primitive_create | Create a primitive in the key's group |
primitive_snapshot | Create a snapshot, restore |
primitive_fork | Fork a primitive, only into the key's own group |
db_query, db_exec | query, exec, batch, tables (any db tool); writes need db_exec. export needs db_query |
db_branch | branches |
fs_read / fs_write / fs_search / fs_fork | Reading / writing, moving, deleting, mkdir / glob, search / forks |
queue_send | Send, publish |
queue_receive | Receive, list and get jobs, queue counts, subscribe, unsubscribe |
queue_ack | Ack, heartbeat, requeue, purge |
memory_remember / memory_recall / memory_forget | Remember / recall, list, get / forget, supersede, consolidate |
vault_list / vault_use | List secrets and grants / fetch |
kv_get / kv_list | keys/{key}, mget and export / keys |
kv_set / kv_delete | PUT keys/{key}, mset, incr / DELETE keys/{key}, mdelete |
storage_list / storage_read | objects / reading an object or its info, export |
storage_write | Put, copy and delete objects |
storage_sign | presign, presign/revoke |
A primitive's stats needs its type's read tool: db_query, fs_read, queue_receive, memory_recall, vault_list, kv_get or storage_read. primitive_list is always allowed for any key that reaches the group, whatever its allowlist. Reading the group, listing primitives (GET /primitives), getting one (GET /primitives/{id}) and listing snapshots need only a key for that group. Deleting, renaming, moving, pausing and resuming a primitive, creating, listing and deleting groups, and the audit trail are account-only. A primitive outside the key's group answers 404 not_found.
Example: create a database and query it
Create returns the new primitive's id; every later call uses it.
curl -X POST http://localhost:7777/v1/primitives \
-H "Content-Type: application/json" \
-d '{"name": "main", "type": "db", "group": "agent-42"}'{
"id": "01jbz3k8m5q2w7x9v4n6t0r1ce",
"name": "main",
"type": "db",
"group": "agent-42",
"created_at": 1760486400000,
"paused": false,
"size_bytes": 0,
"forked_from": null
}curl -X POST http://localhost:7777/v1/primitives/01jbz3k8m5q2w7x9v4n6t0r1ce/query \
-H "Content-Type: application/json" \
-d '{"sql": "select id, title from tasks where status = ?", "params": ["open"]}'{
"columns": ["id", "title"],
"rows": [{ "id": 1, "title": "Write docs" }],
"rows_affected": 0,
"last_insert_rowid": null,
"truncated": false
}Idempotency
Idempotency-Key headers are coming soon. Until then, use dedupe_key when sending queue messages, and prefer idempotent SQL such as insert … on conflict do nothing for writes you may retry. The SDKs never retry POST requests on 5xx.