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
Cloudhttps://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 answers 404 not_found, so its existence isn't leaked. Endpoints marked account below reject them with 403 forbidden. Create and scope keys in the dashboard; see Auth & scoping.
  • Locally, primitives dev needs 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.

StatusCodeMeaning
400invalid_requestThe body or parameters failed validation, or the SQL is invalid.
400wrong_typeThe data route doesn't apply to this primitive's type, such as query on a filesystem. The message names its type.
401invalid_tokenThe API key or token is missing, malformed, revoked or expired.
403forbiddenThe caller can't do this, such as a scoped API key calling an account endpoint, or a write through a read-only key.
403group_mismatchThe path or X-Primitives-Group header names a group outside the API key's scope.
403plan_limitThe 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.
403tool_not_allowedThe API key's tool allowlist doesn't cover this endpoint.
403secret_policy_deniedThe request is outside the secret's grant, there is no grant, or the destination is blocked.
404not_foundThe 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.
409already_existsA group or branch with that name exists, or the group already has a primitive of that type with that name.
409conflictThe 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.
409branch_conflictThe main database changed since the branch was created.
409fs_conflictA file or directory is in the way, or a directory isn't empty.
410lease_expiredThe queue lease ran out before the job was settled.
410secret_grant_expiredThe secret's grant has expired.
412precondition_failedA 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.
413payload_too_largeA file, object, key value or secret value exceeds its limit.
416range_not_satisfiableThe Range of an object read starts past its end.
423pausedThe primitive is paused. Resume it to read or write its data.
429plan_limitA 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.
429rate_limitedToo many requests. See below.
500internal_errorSomething went wrong on our side. Safe to retry.
502upstream_errorThe 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}/…: database query, exec and batch, files, queues, memories, vaults (including fetch), keys, objects (including presign), and snapshots, fork, restore, pause, resume, export and stats (but not audit).
  • Every MCP tools/call of 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.

PlanOperations / monthStoragePrimitives
Starter1M, hard cap5 GB, hard cap100, hard cap
Pro25M included, then $0.50 per extra 1M50 GB included, then $0.25 / GB-monthUnlimited
EnterpriseCustomCustomUnlimited

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

ShapeFields
Manifest{ id, namespace, name, created_at }
GroupInfoManifest 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.

MethodPathDescription
POST/groupsAccount. Create. Body: { name }. 201 with GroupInfo
GET/groupsAccount. 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}/auditAccount. Audit trail of the group and its primitives, newest first. Paged AuditEntry. Takes the audit filters

Audit

MethodPathDescription
GET/auditAccount. 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.

MethodPathDescription
POST/primitivesCreate 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/primitivesList 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}/snapshotsSnapshot. Body: { name? }, a label. 201 with SnapshotInfo. Key tool: primitive_snapshot
GET/primitives/{id}/snapshotsList snapshots, newest first. Paged SnapshotInfo
POST/primitives/{id}/restoreRestore. Body: { snapshot }, a snapshot label or id. Returns SnapshotInfo. Key tool: primitive_snapshot
POST/primitives/{id}/forkCopy 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}/pauseAccount. Pause. Returns PrimitiveInfo
POST/primitives/{id}/resumeAccount. Resume. Returns PrimitiveInfo
GET/primitives/{id}/statsPrimitiveStats. Key tool: the type's read tool
GET/primitives/{id}/auditAccount. 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}/exportDatabases, 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:

MethodPathDescription
POST…/queryRun one statement. Body: { sql, params?, branch? }. …/exec is an alias
POST…/batchRun statements in one transaction. Body: { statements: [{ sql, params? }], branch? }. Returns { results }
GET…/tablesList tables and views. ?branch=
GET…/tables/{table}Columns and SQL of one table. ?branch=
POST…/branchesCreate a branch. Body: { name }
GET…/branchesList branches
POST…/branches/{branch}/promotePromote. Body: { force? }
DELETE…/branches/{branch}Discard
GET…/exportDownload as a SQLite file

Filesystem

Under /primitives/{id}, for a primitive of type fs:

MethodPathDescription
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…/moveMove. Body: { from, to, overwrite? }
POST…/mkdirCreate a directory. Body: { path }
GET…/glob?pattern=&limit=. Returns { data: [paths] }
GET…/search?q=&path=&limit=. Returns { data: [{ path, snippet }] }
POST…/forksFork 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}:

MethodPathDescription
GET…/queuesPer-queue counts
POST…/queues/{queue}/messagesSend. Body: { body, delay?, dedupe_key?, priority?, max_attempts? }. 201, or 200 with deduplicated: true
GET…/queues/{queue}/messagesList jobs. ?status=&limit=&cursor=
GET…/queues/{queue}/messages/{job}Get a job
POST…/queues/{queue}/receiveLease jobs. Body: { lease?, wait?, max? }. Returns { data: [jobs with lease_token] }
POST…/queues/{queue}/messages/{job}/ackSettle. Body: { lease_token, action?: "ack" | "retry" | "fail" | "dead", in?, error? }
POST…/queues/{queue}/messages/{job}/heartbeatExtend a lease. Body: { lease_token, extend? }
POST…/queues/{queue}/messages/{job}/requeueMove a dead job back to ready
POST…/queues/{queue}/purgeDelete finished jobs. Body: { status: "done" | "dead", older_than? }
POST…/topics/{topic}/subscriptionsSubscribe a queue. Body: { queue }
DELETE…/topics/{topic}/subscriptions/{queue}Unsubscribe
POST…/topics/{topic}/publishPublish. Body: { body }. Returns { delivered }

Memory

Under /primitives/{id}, for a primitive of type memory:

MethodPathDescription
POST…/memoriesRemember. Body: { kind, text, tags?, owner?, metadata? }
GET…/memoriesList, newest first. ?kind=&tag=&owner=&limit=&cursor=
GET…/memories/{memory}Get one
DELETE…/memories/{memory}Forget
POST…/memories/{memory}/supersedeSupersede. Body: { text, kind?, tags?, metadata? }
POST…/recallRecall. Body: { query, kinds?, tags?, owner?, limit?, include_superseded? }
POST…/consolidateMerge duplicates. Body: { threshold? }. Returns { merged, superseded }

Vault

Under /primitives/{id}, for a primitive of type vault:

MethodPathDescription
PUT…/secrets/{NAME}Account. Store a value. Body: { value }
DELETE…/secrets/{NAME}Account. Delete a secret and its grant
GET…/secretsList names, versions and grants. Paged
POST…/grantsAccount. Grant. Body: { name, allow: { hosts, methods? }, ttl? }
GET…/grantsList grants. Paged
DELETE…/grants/{NAME}Account. Revoke a grant
POST…/fetchMake 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 /:

MethodPathDescription
GET…/keysList 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…/mgetGet many. Body: { keys }. Returns { data: [KvEntry | null] } in order
POST…/msetSet 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…/mdeleteDelete many. Body: { keys }. Returns { deleted }, a count
POST…/incrIncrement 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:

MethodPathDescription
GET…/objectsList. ?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…/copyCopy. Body: { from, to, if_none_match? }. Returns ObjectInfo
POST…/presignSign 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/revokeInvalidate 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.

MethodPathDescription
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:

ToolEndpoints
primitive_createCreate a primitive in the key's group
primitive_snapshotCreate a snapshot, restore
primitive_forkFork a primitive, only into the key's own group
db_query, db_execquery, exec, batch, tables (any db tool); writes need db_exec. export needs db_query
db_branchbranches
fs_read / fs_write / fs_search / fs_forkReading / writing, moving, deleting, mkdir / glob, search / forks
queue_sendSend, publish
queue_receiveReceive, list and get jobs, queue counts, subscribe, unsubscribe
queue_ackAck, heartbeat, requeue, purge
memory_remember / memory_recall / memory_forgetRemember / recall, list, get / forget, supersede, consolidate
vault_list / vault_useList secrets and grants / fetch
kv_get / kv_listkeys/{key}, mget and export / keys
kv_set / kv_deletePUT keys/{key}, mset, incr / DELETE keys/{key}, mdelete
storage_list / storage_readobjects / reading an object or its info, export
storage_writePut, copy and delete objects
storage_signpresign, 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.