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

db.ts
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 id
db.py
db = 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:

db.ts
const { rows } = await db.sql`
  select id, title from tasks
  where status = ${'open'}
  order by priority desc
`;
db.py
result = await db.sql(
    "select id, title from tasks where status = ? order by priority desc",
    ["open"],
)
rows = result.rows

Or 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

JSONSQLite
nullNULL
true / falseINTEGER 1 / 0
integer / floatINTEGER / REAL
stringTEXT
{"$blob": "<base64>"}BLOB (the SDKs accept Uint8Array / bytes)
other arrays and objectsTEXT 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:

migrate.ts
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();
}
MethodDescription
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 = tables

A 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/export

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

  • ATTACH and DETACH
  • BEGIN, COMMIT and 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_keys and recursive_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

ToolRead-onlyArguments
db_queryyesprimitive?, sql, params?, branch?
db_execnoprimitive?; sql and params?, or statements: [{ sql, params? }]; branch?
db_branchnoprimitive?; 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.