Primitives
Database
Named SQLite databases, as many per group as you need: parameterized SQL, atomic batches, branches and export.
A database is a SQLite file inside a group. A group can hold as many as you need, each with its own immutable id and a name: one per tenant, per task, or a main plus a scratch. Agents get full SQL over HTTP with no shared schema and no noisy neighbours, and they can branch a database before a risky change, or snapshot, fork and pause it on its own.
Creating a database
const db = await group.db.create('tasks'); // 409 already_exists if taken; db.id is its id
const cache = group.db('cache'); // by name in the group, resolved on first use
const all = await group.db.list(); // PrimitiveInfo[] for every database in the group
const same = p.db(db.id); // the same database, by iddb = await group.db.create("tasks")
cache = group.db("cache")
same = p.db(db.id)From the CLI: primitives primitive create tasks --type db --group agent-42 (prints the id).
Querying
Use the sql tagged template. Interpolated values are always sent as bound parameters and never concatenated into the query:
const { rows } = await db.sql`
select id, title from tasks
where status = ${'open'}
order by priority desc
`;result = await db.sql(
"select id, title from tasks where status = ? order by priority desc",
["open"],
)
rows = result.rowsOr pass parameters yourself: a positional array for ?, or an object for named parameters (:name, @name or $name):
await db.query('select * from tasks where id = ?', [7]);
await db.query('update tasks set status = :status where id = :id', { status: 'done', id: 7 });Every call returns:
{
"columns": ["id", "title"],
"rows": [{ "id": 1, "title": "Write docs" }],
"rows_affected": 0,
"last_insert_rowid": null,
"truncated": false
}Each call runs exactly one statement. exec is an alias of query for writes and DDL. At most 10,000 rows are returned; beyond that truncated is true.
Values
| JSON | SQLite |
|---|---|
null | NULL |
true / false | INTEGER 1 / 0 |
| integer / float | INTEGER / REAL |
| string | TEXT |
{"$blob": "<base64>"} | BLOB (the SDKs accept Uint8Array / bytes) |
| other arrays and objects | TEXT holding their JSON |
Batches
batch runs several statements in one transaction. If any of them fails, none are applied:
await db.batch([
{ sql: 'update accounts set balance = balance - ? where id = ?', params: [amt, from] },
{ sql: 'update accounts set balance = balance + ? where id = ?', params: [amt, to] },
]);async with db.transaction() as tx:
await tx.sql("update accounts set balance = balance - ? where id = ?", [amt, src])
await tx.sql("update accounts set balance = balance + ? where id = ?", [amt, dst])In Python, transaction() collects the statements and sends them as one batch when the block exits without an exception. A batch holds up to 1,000 statements. TypeScript's transaction(statements) is an alias of batch.
Introspection
const tables = await db.tables(); // [{ name, type: 'table' | 'view', sql, rows }]
const schema = await db.table('tasks'); // { name, type, columns: [{ name, type, notnull, default, pk }], sql }Row counts stop at 1,000,000.
Branches
A branch is a consistent copy of the database. Create one, make changes, then promote it over the main database or discard it:
const trial = await db.branch('try-migration');
await trial.exec('alter table tasks add column priority integer');
const { rows } = await trial.query('select count(*) as n from tasks');
if (rows[0].n > 0) {
await trial.promote(); // replace the main database with the branch
} else {
await trial.discard();
}| Method | Description |
|---|---|
db.branch(name) | Copy the current database into a new branch. Returns a handle with query, sql, exec and batch. |
db.branchHandle(name) | A handle to an existing branch, without a request. |
branch.promote({ force? }) | Replace the main database with the branch. The branch is removed. |
branch.discard() | Delete the branch. |
db.branches() | List branches: { name, created_at, base }. |
Promote fails with 409 branch_conflict if the main database was written after the branch was created. Pass { force: true } to promote anyway. Branches are not included in snapshots or forks.
Snapshots, forks and pause
Each database has its own lifecycle, independent of the other primitives in the group:
await db.snapshot('before-migration'); // restore point for this database only
await db.restore('before-migration');
const copy = await db.fork({ name: 'tasks-try' }); // a new database in the same group, new id
const elsewhere = await db.fork({ group: 'agent-42-sandbox' }); // 404 not_found if that group doesn't exist
await db.pause(); // data calls fail with 423 paused
await db.resume();
const { size_bytes, items } = await db.stats(); // items = tablesA branch tries a change inside one database; a fork is a separate database you can hand to another agent. See Groups.
Export
export() returns the whole database as a SQLite file you can open with any SQLite tool:
import { writeFile } from 'node:fs/promises';
await writeFile('tasks.sqlite', await db.export());curl -o tasks.sqlite http://localhost:7777/v1/primitives/$ID/exportWhat SQL is allowed
It's SQLite 3 with foreign keys on, JSON functions and FTS5. To keep each group self-contained, these are rejected with 403 forbidden:
ATTACHandDETACHBEGIN,COMMITand savepoints (each call is already a transaction; use a batch)- Tables, indexes, triggers and views named
_primitives_*, which belong to the engine - Writing pragmas other than
user_version,application_id,foreign_keys,defer_foreign_keysandrecursive_triggers load_extension
Syntax errors are 400 invalid_request; constraint violations are 409 conflict.
Read-only access
An API key without db_exec can only run statements that don't write. A key with db_query alone is a safe read-only grant, and the MCP db_query tool is read-only even when the key also allows db_exec.
Over REST
Create the database with POST /v1/primitives; every database endpoint then lives under its id:
curl -X POST http://localhost:7777/v1/primitives \
-d '{"name":"tasks","type":"db","group":"agent-42"}' # returns { "id": "01jbz3…", … }
curl -X POST http://localhost:7777/v1/primitives/$ID/query \
-d '{"sql":"select * from tasks where status = ?","params":["open"]}'All endpoints are in the REST API reference.
MCP tools
| Tool | Read-only | Arguments |
|---|---|---|
db_query | yes | primitive?, sql, params?, branch? |
db_exec | no | primitive?; sql and params?, or statements: [{ sql, params? }]; branch? |
db_branch | no | primitive?; action: create, promote, discard or list; branch; force? |
primitive is the database to use: its id or its name from primitive_list. It can be left out when the group has exactly one database. Agents create databases with primitive_create { "type": "db", "name": "tasks" }. See the tools reference.