Primitive CLI
The Primitive CLI (primitive) is a command-line tool for managing your Primitive applications. It handles authentication, app configuration, user management, and provides access to guides and documentation.
Why Use the CLI?
While most day-to-day development happens in your code editor, the CLI is essential for:
AI Agent Integration — The CLI is designed to be used by AI coding assistants (like Claude). When you ask an agent to write a server function, configure an integration, or deploy a prompt, it uses
primitivecommands to interact with the Primitive server. This enables agents to fully manage your app's backend configuration without you needing to use the admin console.Version-Controlled Configuration — Use
primitive configto export your app's configuration (server functions, prompts, integrations) as TOML files that live in your repo alongside your code.Automation & CI/CD — Script deployments and configuration changes in your build pipelines.
Quick Admin Tasks — Invite users, check analytics, or test prompts without leaving your terminal.
Agent Usage
If you're working with an AI coding assistant, it can run primitive guides list to see available documentation and primitive guides get <topic> to learn how to use specific features. The agent can then use CLI commands to implement what you've asked for.
Installation
Install the CLI globally:
pnpm add -g primitive-adminThis installs the primitive command globally on your system. pnpm is the recommended package manager; npm install -g primitive-admin works too. Install with one manager only — installing with both leaves two global copies and whichever comes first on your PATH wins.
Updating
When a newer release is available, the CLI prints an update notice with the command for the package manager that owns the copy you are running — for example pnpm add -g primitive-admin@latest for a pnpm install and npm i -g primitive-admin@latest for an npm one. If it finds a second global copy installed by the other manager, it names that copy's path so you can remove it.
Authentication
Before using most commands, you need to authenticate:
primitive loginThis opens your browser for OAuth authentication. Tokens are stored per-environment so dev and prod can stay signed in independently — see Project Configuration and Environments below for where they land.
login authenticates against the active environment's server. Inside a project it uses the selected environment's apiUrl — pick the environment with -e <env>. Outside a project it uses the default server, which you can point at a local or custom server with the PRIMITIVE_SERVER_URL environment variable:
primitive -e prod login # a named environment's server
PRIMITIVE_SERVER_URL=http://localhost:8787 primitive login # a custom server, no project configTo check your current authentication status:
primitive whoamiTo log out:
primitive logoutlogout ends the session on the server, not only on this machine: the refresh token it held is refused from then on, and the local credentials are cleared. An access token the session already issued keeps working until it expires (at most an hour). If the server can't be reached, logout keeps the local credentials and exits with an error, so running it again retries the revocation.
Admin sessions
Every sign-in — a CLI login, a web-admin login, a bootstrap login — is a session on the server. List yours:
primitive auth sessions list
primitive auth sessions list --jsonThe list shows each session's kind (cli, web or bootstrap), its scope (full), the apps it is pinned to (— for none), its status (active, revoked or expired), when it was created, last refreshed and expires, and which one is this login's (CURRENT). It is paginated with --limit (25 sessions a page by default, at most 100) and --cursor.
Revoke one — for example a login on a machine you no longer use:
primitive auth sessions revoke 01J9ZK3M4N5P6Q7R8S9T0V1W2XThe revoked session's refresh fails from then on; your other sessions are unaffected. revoke asks for confirmation (pass --yes to skip it — required when not running in a terminal). Revoking this login's own session also clears its local credentials. A super-admin can list and revoke any admin's sessions with --admin-id <admin-id>.
Project Configuration and Environments
Primitive CLI config is project-scoped. When you run primitive init or any CLI command inside a directory containing primitive/config.json (the CLI walks up from the current working directory looking for it), the CLI operates against the named environments declared in that file.
my-app/
├── primitive/
│ └── config.json # committed — environment definitions
├── .primitive/
│ └── credentials.json # gitignored — per-environment tokens
├── src/
└── package.jsonTwo files, two purposes:
primitive/config.jsonis committable. It defines named environments and an optional default. Your whole team targets the same apps because the file is checked in..primitive/credentials.jsonis gitignored. It holds the per-environment access/refresh tokens written byprimitive login. Never check it in.
primitive init scaffolds both files for a new project: it writes a dev environment bound to the app it just created, and seeds that environment's credential slot with the session you logged in with — so a freshly scaffolded project is already in project mode and signed in.
Because the lookup walks up, one project config can serve several clients. primitive init my-app --platform web,ios scaffolds a web client and a native client of the same app into web/ and ios/, with the config, the git repository and the shared model schema at the root; running init inside such a repository adds another client to that same app. See One App, Several Clients.
Outside a project directory (no project config found by walking up from cwd), only the commands that run before a project exists work — login, logout, init and bootstrap — plus the ones that are about the CLI itself: guides, skill, help and --version. Every other command stops with an error naming the missing primitive/config.json. See Outside a Project below.
Named Environments
A single project usually targets multiple backends — a development app for day-to-day coding, a staging app for QA, a production app for your customers. Each environment in primitive/config.json binds an apiUrl and an appId together so "which server" and "which app" always travel as one unit:
{
"version": 1,
"defaultEnvironment": "dev",
"environments": {
"dev": { "apiUrl": "http://localhost:8787", "appId": "app_..." },
"prod": { "apiUrl": "https://primitiveapi.com", "appId": "app_..." }
}
}Manage environments with the env subcommands:
# Add an environment to an existing project (primitive init creates the project)
primitive env add dev --api-url http://localhost:8787 --app-id app_dev123
primitive env add prod --api-url https://primitiveapi.com --app-id app_prod456
# List all environments and inspect one
primitive env list
primitive env show prod
# Point THIS machine at an environment (writes .primitive/local.json, which is
# gitignored — switching backends never dirties a tracked file)
primitive env use dev
# Delete an environment (also clears its credential slot, so re-adding the
# same name later cannot reuse stale tokens)
primitive env remove staging--app-id is required: every environment names exactly one app, which is what lets selecting an environment select its app with nothing else to keep in step.
A freshly-added environment starts logged-out. primitive env add writes only the config entry — it seeds no credentials, and project mode never borrows credentials from ~/.primitive/credentials.json. So right after env add, commands report "not logged in" for that environment even though another environment in the same project is signed in; run primitive login with that environment active to sign it in. (The dev environment scaffolded by primitive init is the exception — init seeds it with the session it authenticated during setup.) For agents and CI, sign in without a browser by piping a refresh token — see API Tokens for CI and Servers.
How the Active Environment Is Resolved
Every command resolves an active environment in this order — the first match wins:
--env <name>flag on the command itselfPRIMITIVE_ENVenvironment variable (handy for CI)- This machine's selection in
.primitive/local.json, written byprimitive env use defaultEnvironmentinprimitive/config.json— the committed team default- The only environment defined — if there is exactly one, it's used automatically
- Otherwise the CLI errors and asks you to choose
Steps 3 and 4 are different jobs, which is why they are different files. defaultEnvironment is a team decision: it is committed, and it is what a fresh clone resolves. Which backend your machine is pointed at right now is not — so primitive env use writes .primitive/local.json instead, next to credentials.json, and both are gitignored. primitive env list shows both: the resolved current environment and the committed team default.
A broken selection is never papered over. If .primitive/local.json is corrupt, or names an environment that no longer exists, env list and env show say so instead of quietly falling back to the default — the fix is to delete the file or re-run primitive env use <name>.
Name environment entries after the backend/app pair they bind (prod, prod-test, alpha), not after a build stage. A build stage is a separate axis: which front end you are building is chosen at build time, and the two cross more often than you would expect.
So in CI you can do:
PRIMITIVE_ENV=prod primitive config push…or override per-command without changing the default:
primitive functions list --env staging
primitive config push --env prodSwitching Apps Inside a Project
There is no "currently active app" anywhere but the environment. The active app is whatever the resolved environment's appId says it is, and every environment has one. To point an environment at a different app, edit its "appId" in primitive/config.json. primitive whoami and primitive env show report the app the next command will act on.
Outside a Project
With no project config anywhere up the directory tree there is no environment, so there is nothing for an app-scoped command to target. Only the commands that run before a project exists work there:
primitive initcreates a project — the app,primitive/config.jsonwith adevenvironment bound to it, and that environment's credentials.primitive loginsigns in against the default server (orPRIMITIVE_SERVER_URL);primitive logoutclears that session, andprimitive bootstrapcreates the first admin on a fresh self-hosted server.primitive guides,primitive skill,helpand--versionwork anywhere; outside a projectprimitive guidesserves the guides for the default server.
Everything else — whoami, apps list, users list, env add, config pull, … — exits non-zero with an error that names the missing primitive/config.json and points at primitive init. There is no global credentials fallback — --env outside a project fails the same way, and neither --app nor an environment variable creates one — and no flag redirects a command at another project's tree or app: to work on an app, run the command from inside the project whose environment names it.
primitive apps list
# No primitive/config.json found in /tmp/scratch or any parent directory. … Run 'primitive init' to create one.
cd ~/code/my-app && primitive apps list # the project's environment names the server and appThe one cross-app read that remains is inside a project: users, apps, admins and the database data verbs accept --app <id> to read another app on the environment's server.
Common Commands
Managing Apps
# List all your apps
primitive apps list
# Create a new app
primitive apps create "My New App"
# View app details
primitive apps getApp settings — access mode, auth providers, CORS, member invitations, and more — live in app.toml and change by editing that file and pushing it. There is no command that sets an app setting directly; see TOML Is the Only Write Path.
# Show the current server-effective settings
primitive apps get
# Fetch settings into app.toml, edit one, then apply just the app settings
primitive config pull --only app
primitive config set app app.mode=invite-only
primitive config push --only appManaging Users
# List users in your app
primitive users list
# Invite a user
primitive users invite user@example.com
# Block a user's access without removing them (reversible)
primitive users disable <user-id>
primitive users enable <user-id>
# List pending invitations
primitive users invitations
# Change a user's role
primitive users set-role <user-id> adminManaging Console Admins
Console admins have access to the admin console for your app. Manage them separately from regular app users:
# List console admins
primitive users admins list
# Add a console admin (sends invitation if they don't have an account)
primitive users admins add admin@example.com
# Remove a console admin
primitive users admins remove <admin-id>
# List pending console admin invitations
primitive users admin-invitations list
# Delete a pending admin invitation
primitive users admin-invitations delete <invitation-id>Managing Documents
Inspect documents from the command line — an operator view for debugging and support. Add --json to any of the inspection commands for scripting:
# List a user's documents (documentId, title, tags, permission, granted date).
# --user-id is required — there is no app-wide document enumeration.
primitive documents list --user-id <user-id>
# Show one document's metadata, including its tags, and the caller's access
# (permission, access source, link access)
primitive documents get <document-id>
# List the user-level permissions on a document
primitive documents permissions list <document-id>
# Discover a document's models, then describe one model's fields and indexes
primitive documents records models <document-id>
primitive documents records describe <document-id> <model-name>
# Query, get, count, and aggregate records in a model
primitive documents records query <document-id> <model-name> --filter '{"status":"open"}' --limit 50
primitive documents records get <document-id> <model-name> <record-id>
primitive documents records count <document-id> <model-name>
primitive documents records aggregate <document-id> <model-name> --op sum --field qty --group-by symbol
# Dump every record grouped by model, and read summary statistics
primitive documents dump <document-id>
primitive documents stats <document-id>records models and records describe read the models a client has written into the document. A brand-new document with no records yet reports no models.
records query returns a page of records as { items, hasMore, nextCursor? } (--limit caps at 100; pass the reported nextCursor back as --cursor to fetch the next page), records get returns one record by id — or null at exit 0 when there is none — and records count returns how many match a --filter. records aggregate reads the same way as its database twin below: one operation at a time (count, sum, avg, min, max), --field required for all but count, and --group-by (repeatable) for per-group rows. Group-by takes plain field names — grouping by a StringSet field is a database-only capability and is rejected here. Any of these --filter options also accepts --filter-file <path> to read the filter from a JSON or TOML file. dump assembles the whole document from paged reads, grouped by model — it is not a single atomic snapshot — and prints only JSON on stdout, so primitive documents dump <doc> | jq . parses. stats reports record, model, and blob counts plus an approximate byte size and the last-modified time. A large document has its own snapshot and bulk-load commands — see Large Documents: Snapshots and Bulk Loads.
# Export a document to a directory, and import from one
primitive documents export <document-id> --output ./primitive-export
primitive documents export-all --user-id <user-id>
primitive documents import <path>export writes a document — its data, blobs, permissions, and aliases — to a directory (--no-blobs skips blob data); export-all does the same for every document a user has access to (--owned-only narrows it to documents they own). import recreates documents from an export directory: by default it skips colliding document ids and aliases (--overwrite merges the exported state into the document that is already there, --aliases overwrite reassigns aliases), --dry-run previews without writing, and an admin can assign the imported documents to a user with --owner.
--overwrite merges an import into an existing document rather than replacing it — with one exception for a large document, whose export installs only into a document with no records yet. A root document export is retargeted onto the target user's root document (--owner, or the owner the export recorded) rather than restored under its exported id. The merge rule, the root-document exception, and the bundle's on-disk layout — every file export writes, which of them import restores, and the two digest encodings it carries side by side — are covered in Moving Documents Between Environments (see Bundle layout for the file table).
Mint whole documents from the command line as well — documents create goes through the same POST /documents endpoint every client uses (documents delete, its counterpart, is covered below):
# Create a document — prints the minted document id (add --json for the full response)
primitive documents create "Quarterly Report"
# Create it owned by another app user, given a user id or an email
primitive documents create "Quarterly Report" --owner user@example.comWho ends up owning a created document depends on the token you are signed in with. An app-user token — member, admin, or owner — always creates the document owned by the caller; the server ignores --owner for those tokens. A super-admin token, or a console-admin token assigned to the app, acts through an admin shadow app user: without --owner that shadow user owns the document, and with --owner the named user does. A console admin who is not assigned to the app has no access to it at all. An email passed to --owner is resolved to a user id before anything is created, and a user who is not in the app fails the command without creating a document.
Write records from the command line too. Writes go through the same server-side path as collaborative edits, so concurrent client edits merge automatically and connected clients see the change live. Writing requires read-write or higher on the document (an admin token also works):
# Create a record (--id is optional — a unique id is generated when omitted)
primitive documents records save <document-id> <model-name> --data '{"name":"gear","qty":5}'
# Merge fields into an existing record
primitive documents records patch <document-id> <model-name> <record-id> --data '{"qty":6}'
# Delete a record (prompts unless -y; deleting a missing record is a no-op, except that unique-index entries still naming that id are cleared)
primitive documents records delete <document-id> <model-name> <record-id> -y
# Apply several operations in one atomic batch from a file
primitive documents records bulk <document-id> --data-file ops.json -y
# Delete the whole document — its records, Yjs update history, blobs, aliases, and permissions
primitive documents delete <document-id> -yrecords save replaces the record at --id (or creates it); pass --upsert-on <field> to update the record whose field value matches instead. --data-file <file> reads the fields from a JSON file for larger payloads. records bulk applies an ordered list of operations — {"operations": [{"model": "...", "action": "create" | "patch" | "delete", "id": "...", "data": {...}}, ...]} — as a single all-or-nothing batch: if any operation fails validation, nothing is written. data carries the record fields, exactly as it does on records save / records patch; it is required and must be non-empty on create and patch, delete takes none, and any other key in an operation is rejected. Batches are capped at 500 operations, and create requires a well-formed 26-character record id.
A refused batch reports which failure it was rather than a bare message: the command prints the server's stable code and status beside the sentence — ✗ … [DOCUMENT_UNAVAILABLE] (503) — and under --json emits { "ok": false, "code": ..., "status": ..., "error": ... }, exiting 1 either way. On a large document (documentFormat: 2) the refusal codes and which are worth retrying are covered in Bulk-Loading a Large Document; on an ordinary document the batch answers the route's standard codes.
Every records verb takes --document-format <1|2> — the format you expect the document to have. When it disagrees with the format the platform resolved, the command exits 1 printing DOCUMENT_FORMAT_MISMATCH and the server's sentence (which names both formats) instead of reading or writing out of the wrong tables; a value outside 1|2 is refused before any request is made. Omit the flag and the request is exactly what it was.
Record writes are checked against the model's declared field types before anything is stored. A value that converts safely is converted — the 1/0 a SQL query returns for a boolean field is stored as true/false — and one that doesn't, such as "maybe" or 2 in a boolean field, fails the command with a type error naming the model and field instead of writing a value the model forbids.
documents delete removes the document itself rather than records inside it — the server cascades through the Yjs state and update history, blob records and objects, aliases, user and group permissions, invitations, and collection memberships, exactly as the client SDK's documents.delete() does. It prompts for confirmation unless you pass -y, passes the server's { success, message } envelope through under --json, and cannot delete a user's root document.
Deletion is authorized by the server, not the CLI. The document's owner, the app owner, and super-admin or assigned-console-admin tokens (which act with app-owner authority) delete directly. Everyone else — including app-role admins — can delete only when a containing collection's document.delete rule allows it, so an app admin cannot delete a standalone document they do not own. Server refusals, including the fail-closed error raised when collection authorization cannot be read, are reported as-is at exit 1.
Grant and revoke a user's access to a document. Grant takes either --user-id or --email and a --permission level of reader or read-write; granting again for the same user updates the level in place. Revoke takes the user id as an argument (or --email) and prompts for confirmation unless you pass -y:
# Grant a user reader (or read-write) access
primitive documents permissions grant <document-id> --user-id <user-id> --permission reader
primitive documents permissions grant <document-id> --email user@example.com --permission read-write
# Revoke a user's access
primitive documents permissions revoke <document-id> <user-id>
primitive documents permissions revoke <document-id> --email user@example.com -yTurn on "anyone with the link" access for a document — a shared level (reader or read-write) that applies to any signed-in user of your app who has the document ID, without an explicit grant:
# Show the current link-access level (or "off")
primitive documents link-access get <document-id>
# Turn it on at a given level
primitive documents link-access set <document-id> --level reader
# Turn it off — anyone relying on the link loses access immediately
primitive documents link-access clear <document-id>Ask what one user may actually do with a document. The answer folds in every source at once — a direct grant, a group grant, collection membership, and "anyone with the link" — so it is the one read that matches what the document routes enforce for that user:
# What may this user do with this document?
primitive documents access get <document-id> --user <user-id>
# The same answer as raw JSON
primitive documents access get <document-id> --user <user-id> --jsonThe row reports PERMISSION (owner, read-write or reader), SOURCE (owner, grant, group or link — where the winning level came from), and APP_ROLE, the user's role in the app. The role sits beside the grant and is never folded into it: an admin or owner with no grant may change a document's tags, but a content write — retitling it, or writing its blocks or blobs — needs a real read-write or owner grant like everybody else. Naming a user requires an admin token or an app owner/admin; a member may only ask about themselves.
Managing Collections
Collections group documents and carry shared access — see Collections for the concept. The CLI manages them end to end:
primitive collections create "Q1 Reports" --description "Quarterly reports"
primitive collections create "Q1 Reports" --owner user@example.com # admin token: the named user owns it
primitive collections list # collections you belong to (--all: every one, admin only)
primitive collections get <collection-id>
primitive collections documents add <collection-id> <document-id> # also: remove, list
primitive collections for-document <document-id> # collections a document belongs to
# Access: group grants and direct members
primitive collections share <collection-id> --group team/engineering --permission read-write
primitive collections unshare <collection-id> --group team/engineering
primitive collections members add <collection-id> <user-id> --permission reader
primitive collections members list <collection-id>
primitive collections members remove <collection-id> <user-id>
primitive collections access <collection-id> # combined groups + members view
primitive collections delete <collection-id> # documents are preserved
# Scripted removal: -y skips the prompt, --json reports what was removed
primitive collections documents remove <collection-id> <document-id> -y --json
primitive collections unshare <collection-id> --group team/engineering -y --json
primitive collections members remove <collection-id> <user-id> -y --json
primitive collections delete <collection-id> -y --json
# Move an app's collections to another app (run AFTER documents import)
primitive collections export --output ./primitive-export
primitive collections import ./primitive-export --dry-run
primitive collections import ./primitive-export --overwriteWho owns a created collection follows the same token matrix documents create does, and it matters more here: the default collection rules key editing, deleting, and managing documents and members on the creator, so the creator is the only non-admin who can manage it. An app-user token — member, admin, or owner — always creates the collection owned by the caller; the server ignores --owner for those tokens. A super-admin token, or a console-admin token assigned to the app, acts through an admin shadow app user: without --owner that shadow user owns the collection, and with --owner the named user does, indistinguishably from one they created themselves. A console admin who is not assigned to the app has no access to it at all. An email passed to --owner is resolved to a user id before anything is created, and a user who is not in the app fails the command without creating a collection.
The four verbs that remove something — delete, unshare, documents remove, members remove — prompt for confirmation unless you pass -y. Under --json each prints a result object on stdout naming what it acted on: { success, collectionId } for delete, the same plus groupType and groupId for unshare, plus documentId for documents remove, and plus userId for members remove. The server's body for these deletes is a bare { success: true } that names nothing, so the ids come from the CLI, as they do on documents transfer-owner. --json is not a second way to skip the confirmation — without -y in a non-interactive shell the command still refuses, names --yes, and removes nothing — and a refusal from the server exits non-zero with its message on stderr and stdout left empty. Without --json the verbs are unchanged: the success line goes to stderr.
collections export / collections import move a whole app's collections through one file, collections.json, written beside a document export's manifest.json. Run documents import first: the file refers to documents by the ids that import preserves. The resolve-then-apply plan, --dry-run, --overwrite, --owner and the per-item problems are covered in Moving Collections Between Apps.
Inspecting and Fixing Database Records
Read and repair individual records directly. These are admin commands — they go through the record endpoints with your admin credentials, and every write is logged with the authorization that permitted it.
# What models exist, and what a model's records look like
primitive databases records models <database-id>
primitive databases records describe <database-id> <model-name>
# Read: a page of records, one record, a count, or an aggregate
primitive databases records query <database-id> <model-name> --filter '{"status":"open"}'
primitive databases records get <database-id> <model-name> <record-id>
primitive databases records count <database-id> <model-name> --filter '{"status":"open"}'
primitive databases records aggregate <database-id> <model-name> --op avg --field price --group-by statusaggregate takes one operation at a time (count, sum, avg, min, max); --field is required for all but count. Use --group-by (repeatable) for per-group rows, or leave it off for a single number over the whole model — with --json that ungrouped form returns {"result": {…}} with the inner object keyed by the operation ({"result":{"count":2}}, {"result":{"sum_age":60}}), not a bare number.
get prints null and exits 0 when the record is not there — a miss is not an error, so check the output rather than the exit code. query, count, aggregate, and delete-all also take --filter-file <path> to read the filter from a JSON or TOML file instead of --filter. Under --json, query prints { items, hasMore, nextCursor? } — the same list envelope as documents records query.
Both write verbs merge the fields you pass — a field you leave out of --data keeps its stored value. They differ in what happens when the record does not exist: save creates it, and patch fails, so patch never creates a record by accident.
# Create with a generated id, or create at an id you choose
primitive databases records save <database-id> <model-name> --data '{"name":"Widget","price":9}'
primitive databases records save <database-id> <model-name> <record-id> --data '{"name":"Widget","price":9}'
# Merge fields into a record that must already exist
primitive databases records patch <database-id> <model-name> <record-id> --data '{"price":11}'
# Apply several operations in one atomic batch from a file
primitive databases records bulk <database-id> --data-file ops.json -yrecords bulk takes the same operations file as documents records bulk — {"operations": [{"model": "...", "action": "create" | "patch" | "delete", "id": "...", "data": {...}, "precondition": {...}}, ...]} — and applies it all-or-nothing: if any operation fails, nothing is written. create is a strict create on both surfaces (a create against an id that already exists fails the whole batch), and an operation's optional precondition — a field-equality map checked against the live record — rolls the whole batch back when it does not hold. A null in a precondition means "the field is present and holds null" on both surfaces, so a record that never had the field does not satisfy it. On databases records bulk a precondition value must be a string, or a number other than 0 and 1: a database compares JSON booleans as 1/0, so true cannot be told apart from the number 1 there. The CLI rejects those values before sending anything rather than checking a weaker guard than you wrote — guard on a string field, or read the record first and gate the write on that. The documents twin compares in JS and accepts them. Under --json it reports the same summary as its documents twin, { "applied": n, "added": [...], "updated": [...], "deleted": n } — with one reading difference: on documents deleted counts records actually removed, while here it counts delete operations applied, because a database delete reports no rows-affected. Deleting an id that was not there still counts 1.
To clear a field rather than change it, pass it explicitly as null. Both verbs store that the same way: the key stays on the record and its value becomes null, so it reads back as null from records get and a {"field": {"$exists": true}} filter still matches the record. Removing a key is not something a write body can ask for — leave a field out and it keeps its stored value.
Both print id plus any fields the server applied itself (server-stamped fields, computed values). Pass --data-file <path> instead of --data to read the JSON from a file, and --json to get the response as-is.
To find databases by who created them:
primitive databases list --owner <user-id>With the filter set the table gains a CREATED BY column, so you can see the filter took effect — the PERMISSION column reads owner on every row for an app admin and says nothing about who created the database.
Inspecting Live Connections
Answer the most common sync question — "does the server think this user is connected, and to what?" — by listing the live connections it believes exist. Pass exactly one selector: a document or a user.
# Every live connection subscribed to a document
primitive connections list --document <document-id>
# Every live connection for a user (documents, channels, and the user's
# notification channel)
primitive connections list --user-id <user-id>
# Page through results, or get the raw JSON
primitive connections list --user-id <user-id> --limit 50 --cursor <cursor>
primitive connections list --user-id <user-id> --jsonEach row is a single connection-to-subscription binding, so the same connection can appear on more than one row — once per document or channel it is subscribed to, plus its notification channel. The kind column tells them apart (document, channel, or user-channel). These are the connections the server believes are live: exact right after a graceful disconnect, and otherwise bounded by each row's expiry time, so a connection that dropped without a clean close can linger briefly until it expires.
Inspecting Sessions
List a user's authentication sessions — one per sign-in — to answer "is this user signed in, and when does their access expire?".
# Every session for a user
primitive sessions list --user-id <user-id>
# Page through results, or get the raw JSON
primitive sessions list --user-id <user-id> --limit 50 --cursor <cursor>
primitive sessions list --user-id <user-id> --jsonThe active column shows whether a session has expired. An expired session that has not yet been swept still appears, with active set to false. Authentication tokens are never returned.
OAuth Configuration
The Google OAuth provider toggle, the per-platform client entries, the email-redirect allow-list, and CORS origins are all app settings that sync from app.toml, so the drift-free path is to edit the TOML and push:
primitive secrets set GOOGLE_CLIENT_SECRET --value <client-secret>
primitive config push --only appSee Setting Up Google OAuth for the per-platform client model, the clientSecret rules and error codes, and the redirect-URI selection semantics, and Server App Settings for how those fields interact with CORS and the email sign-in allow-list. See Configuration for the full list of TOML-syncable settings.
Accessing Guides
The CLI includes built-in guides for various Primitive features:
# List available guides
primitive guides list
# Read a specific guide
primitive guides get documents
primitive guides get server-functions
primitive guides get authenticationGuides come in TypeScript and Swift variants. By default you get the TypeScript guide; pass --language swift to fetch the Swift variant, or --platform ios (or --platform macos) to select the language that platform targets:
# Fetch the Swift variant of a guide
primitive guides get documents --language swift
# ...or let the platform choose the language (iOS and macOS target Swift)
primitive guides get documents --platform iosprimitive guides list takes the same flags. An unknown --language or --platform value is rejected rather than silently served as TypeScript, so a typo surfaces immediately.
Guides are cached locally at ~/.primitive/guides/.
For AI Agents
When working with an AI coding assistant, point it to these guides before asking it to implement features. For example: "Read primitive guides get server-functions and then write a function that emails each user a weekly digest on a cron schedule." The agent will fetch the guide, learn the patterns, and implement your request correctly. Building for iOS or macOS? Tell the agent to pass --platform ios so it fetches the Swift guides instead of the TypeScript default.
Configuration
primitive config round-trips your app's configuration between the server and a directory of TOML files. It is the only way the CLI writes configuration — Configuring Primitive Services states that rule and covers the loop, the directory layout, and the app.toml settings in full. This is the command reference.
primitive config init # create the directory (auto-resolves primitive/<env>/)
primitive config pull # download current server config as TOML
primitive config fields <type> # a type's TOML keys, types, required flag, and defaults
primitive config create <type> <key> # scaffold that object's file from the defaults
primitive config set <object> <path>=<value> # set a scalar in a file, comments preserved
primitive config diff # preview entities that would be created, changed, or removed
primitive config push # apply local changes
primitive config push --only <selector>[,...] # apply just these objects
primitive config push --dry-run # walk the full push, reported but not applied
primitive config push --prune # also delete managed entities whose local TOML you removed
primitive config revert # restore the config directory from a pre-pull snapshotfields, create, and set are local — they read and write files and call no API. <object> on set is <type>/<key>, app, or vars; --only selectors are <type>/<key>, app, or var/<key>. So the scripted form of a one-field change is two commands:
primitive config set function/order-intake function.description="Order intake"
primitive config push --only function/order-intakeAll of these read and write the selected environment's directory, and only that one. config revert --list enumerates snapshots and --snapshot <id> restores a specific one.
A plain config push only creates and updates. --prune also deletes managed entities (ones a prior pull recorded in sync state) whose local TOML you removed, confirming each with a fresh read; --force skips that drift check, --yes skips the one confirmation prompt, and --dry-run previews the deletions. See The Sync Loop for how prune decides what's eligible.
Push applies each file as the whole object, not as a patch of the lines you changed. For a server function, a push that changes the file creates a new version from all of it, so a deleted line — a trigger, a capability, a limit — is absent from the version that runs. Availability is not part of any file — see Availability is server-owned and Retiring an object: archive vs prune. Server-side edits made since your last sync are reported rather than overwritten: push compares your file against the state the server holds now — the same comparison config diff makes — and names a server-side change as DRIFT (config pull takes it, --force overwrites it) or, when both sides moved, as a CONFLICT. A file whose content matches the server is skipped without a request whatever its bytes say, so a comment-only edit plans nothing, and an edit diff reports is one push acts on.
primitive apps get is the read view of the app-settings half of the loop — the [app], [auth], [cors], and [invitations] tables in app.toml — as the server is running them, alongside the app's id and name. Fetching, comparing and applying that file are config commands scoped to it:
primitive apps get # render current server-effective settings
primitive config pull --only app # write server settings to app.toml
primitive config diff --only app # per-field differences between app.toml and the server
primitive config push --only app # apply the edited fileThe config commands read the selected environment's committed tree; apps get reads the server.
Validation, preview fidelity, and push convergence (re-running a failed push adopts entities missing from sync state by key) are covered in The Sync Loop.
CLI diagnostics (success/warning/info messages) are written to stderr; only structured data (--json output) goes to stdout, so primitive config diff --json | jq works without any extra redirects.
Claude Code Skill
The CLI includes a built-in skill for Claude Code that gives it expert knowledge of the Primitive platform. When installed, Claude Code automatically follows Primitive patterns and best practices as you build.
# Install or update the skill
primitive skill install
# Check installation status
primitive skill status
# Remove the skill
primitive skill uninstallThe skill is installed to ~/.claude/skills/primitive-platform/SKILL.md. If the skill is already installed, the CLI will automatically update it to the latest bundled version after each command run.
During Init
When you run primitive init, you'll be prompted to install the skill as part of project setup. If you skip it, you can always install it later with primitive skill install.
Advanced Features
App Secrets
Store app-level secrets server-side and reference them from integrations and webhook triggers as {{secrets.KEY_NAME}}, or read them in a server function with ctx.secret — your client code and repo never see the values:
primitive secrets set OPENAI_API_KEY --value sk-...
primitive secrets list
primitive secrets delete OPENAI_API_KEYResource Metadata
Read and write resource metadata category values — category definitions (schema, readRule, writeRule) are managed through primitive config, not this command:
primitive metadata set user 01HXY... profile --data '{"tier":"pro"}'
primitive metadata get user 01HXY... profile --json
primitive metadata get-batch --resource user:01HXY...:profile,billing
primitive metadata list user 01HXY... # every stored category on a resource
primitive metadata delete user 01HXY... profile # idempotent
primitive metadata resolve user billing stripeCustomerId cus_ABC # reverse lookup by unique value; a miss exits 0Category definitions have their own read-only noun, alongside the other type-config nouns:
primitive metadata-category-configs list # every definition: schema, rules, version
primitive metadata-category-configs get user profile # adds the full schema JSONDefinitions are authored in metadata-category-configs/<resourceType>.<category>.toml and applied with primitive config push. Delete one by removing its file and running primitive config push --prune — stored values are not deleted and become unreachable, so delete them first (primitive metadata delete) if you need them gone.
Integrations
External API connections are defined in integrations/*.toml; these commands read and test them:
primitive config create integration my-api # then edit and push
primitive integrations list
primitive integrations get <integration-id> # IDs from the list
primitive integrations test <integration-id>
primitive integrations disable <integration-id> # take it out of service now
primitive integrations enable <integration-id>
primitive integrations archive <integration-id> # retire it (soft delete; ID from list)Prompts
LLM prompts are defined in prompts/*.toml and run from a server function; these commands read and trial them:
primitive config create prompt my-prompt # then edit and push
primitive prompts list
primitive prompts get <prompt-id> # IDs from the list
primitive prompts execute <prompt-id> # a diagnostic: runs even when inactive
primitive prompts disable <prompt-id> # take it out of service now
primitive prompts enable <prompt-id>
primitive prompts archive <prompt-id> # retire it (soft delete; ID from list)Availability is not part of the file — see Availability is server-owned. disable takes a prompt out of service — ctx.prompts.run refuses it with PROMPT_NOT_EXECUTABLE — and enable puts it back. prompts execute is an admin diagnostic and runs an inactive prompt on purpose, which is how you trial one before enabling it. archive is the third verb and a different act — see Retiring an object: archive vs prune.
Who may reach a prompt is decided by the access gate of the server function that runs it.
Server Functions
Operate the server functions a config tree pushes as functions/<key>.toml; the code itself is authored and shipped through primitive config push. The primitive functions verbs:
- Read —
list(filter with--status active|inactive|archived),get,configs, andactivateto point a function at an earlier version - Run —
invoke <key>inside the request,start <key>as a task run (--waitto wait for it,--run-keyso a repeat replays the existing run) - Runs and logs —
runs,runs steps,runs wait,runs terminate,logs - Generated types —
codegen(--checkfor CI,--lang swiftfor Swift invokers) - Availability —
disable,enable,archive(see Availability is server-owned)
Operating a function covers each verb — which take the key and which the function id, who a run executes as, timeouts and exit codes — and Debugging a failing function walks through a failure.
Webhook Triggers
A function's webhook trigger is declared in its TOML and shown by primitive functions get <function-id>, which prints the trigger's webhook id. These commands take that id:
primitive webhooks events <webhook-id> # recent deliveries (accepted/rejected/duplicate)
primitive webhooks rotate-secret <webhook-id> --secret "{{secrets.STRIPE_WEBHOOK_SECRET_NEXT}}"
primitive webhooks test <webhook-id> --payload '{"type":"charge.succeeded","data":{"id":"ch_1"}}'
primitive webhooks test <webhook-id> --payload '{…}' --deliver # post it for real
primitive webhooks verify <webhook-id> --header 'Stripe-Signature: t=…,v1=…' --body-file captured.jsonwebhooks test signs the payload and, by default, only returns the request body plus signature headers — it does not post them to your receive endpoint (--payload is signed exactly as given, not wrapped in {"payload": ...}; omit it to sign a canned webhook.test ping instead). It succeeds only for schemes it can preview, failing with a code like WEBHOOK_TEST_SIGNING_UNSUPPORTED otherwise. See Previewing Deliveries with webhooks test.
--deliver posts that signed body to the webhook's receive endpoint for real, so signature verification, deduplication and dispatch all run and the function actually runs; --json reports which of "dispatched", "refused" or "unconfirmed" it was. See Delivering the payload for real.
webhooks verify answers the other direction: hand it a delivery you captured (--header, repeatable, plus --body or --body-file) and it reports whether that signature verifies against the webhook — the only way to check the schemes webhooks test cannot preview. See Checking a captured delivery.
A function's cron schedules are declared the same way, as [[function.triggers.cron]] entries; primitive functions get shows each one's schedule, next fire and last run, and primitive functions runs lists the task runs its fires started.
Retiring an object: archive vs prune
primitive integrations archive <id>, functions archive <id> and prompts archive <id> are the CLI spelling of the API's soft delete; config push --prune (after deleting the TOML file) is the hard delete. Retiring an object: archive vs prune covers what each does and how to recover from an archive. --json prints the server's envelope on stdout for the archive commands; there is no --hard flag, since prune-by-push owns that path.
Blob Buckets
Manage general-purpose blob storage. A bucket is defined in blob-buckets/*.toml:
primitive config create blob-bucket avatars # then set the access model and push
primitive config push --only blob-bucket/avatars
primitive blob-buckets listSee Blobs and Files for the full command set — uploading, signed URLs, and blob deletion.
Email Templates
Manage overrides for the emails Primitive sends. See Email Templates for the built-in types, template variables, the override file's [template] shape, and the override/revert model.
primitive config create email-template email-sign-in # scaffold the file
primitive config push --only email-template/email-sign-in
# List all email types and their override status
primitive email-templates list
# View the current template for an email type
primitive email-templates get email-sign-in
# See available template variables for an email type
primitive email-templates variables email-sign-in
# Send a test email using the current template
primitive email-templates test email-sign-inRevert to the built-in default by deleting the file and running primitive config push --prune. Each command takes an email type — a built-in or a custom kebab-case type you registered — and primitive email-templates variables <type> lists the variables that type substitutes at send time.
Analytics
View usage metrics:
primitive analytics overview
primitive analytics top-users
primitive analytics user-detail <user-ulid>
primitive analytics user-search --query user@example.com
primitive analytics user-search --signup-day 2026-09-06
primitive analytics events --window-days 7
primitive analytics events-grouped --group-by feature
primitive analytics cohort-retention
primitive analytics prompts
primitive analytics integrationsFeature Flags
Platform feature flags are super-admin state, not app configuration — they are not in the TOML tree and config push neither reads nor writes them. Read and toggle them from the terminal:
primitive feature-flags list
primitive feature-flags get <flag-key>
primitive feature-flags enable <flag-key>
primitive feature-flags disable <flag-key>get also shows the per-app overrides (enabledAppIds / disabledAppIds), which are what make a flag's effective value for one app differ from the global setting.
Large Documents: Snapshots and Bulk Loads
A large document (documentFormat: 2) has commands of its own for its snapshot builds and bulk loads. They refuse an ordinary document.
# List a large document's snapshot builds, and inspect one with its verification result
primitive documents snapshots list <document-id>
primitive documents snapshots get <document-id> <build-id>
# Snapshot a large document now, optionally waiting for the build to verify
primitive documents snapshots build <document-id>
primitive documents snapshots build <document-id> --wait
# Recompute the chain locally and compare it with the document's table
primitive documents snapshots audit <document-id>
# List a large document's bulk-load sessions, and inspect one
primitive documents ingests list <document-id>
primitive documents ingests get <document-id> <session-id>
# Bulk-load records into a large document from a directory
primitive documents ingest <document-id> --input ./records -ysnapshots list and snapshots get show a large document's base-snapshot builds and refuse an ordinary document; any reader of the document or an app admin may run them. snapshots build is the one write in that group — it seals the open epoch, starts the base build, and prints the epoch and build id snapshots get then describes — and it takes the permission a bulk load takes: an app admin, or a document grant at read-write or above. --wait exits 0/1 on the build's pass/fail, 124 on --timeout, and 130 on Ctrl-C, always leaving the build running rather than cancelling it. See Inspecting snapshot builds for the verification codes, the empty and SNAPSHOT_TOO_SOON answers, and why a seal is rate-limited.
ingests list and ingests get are the readers for a large document's bulk-load sessions, through the same permission as snapshots; ingests get additionally shows one session's progress, per-stage timings, and throughput, and — when a read can't be answered — a code naming why. documents ingest turns a directory of records (a plain per-model NDJSON layout, or an export directory reloaded as it stands) into a bulk-load session and drives it to completion, validating every line against the document's schema before opening a session; -y/--yes skips the confirmation prompt (required in a non-TTY shell), --no-wait returns as soon as the session is committed, and exit codes follow the same 0/1/124/130 shape as snapshots build --wait. See Bulk-Loading a Large Document for the input layouts and their refusals, the validation and read-failure codes (including INGEST_REGISTER_STALLED), and the retry backoff while waiting.
snapshots audit recomputes the chain locally and compares it with the document's table, needing both the app admin arm (to export the chain) and a reader of the document (to read the table it's compared with). Exit codes: 0 the table is the chain, 1 a divergence, 2 no verdict. See Inspecting snapshot builds for what each verdict reports and when to retry.
Test Users for Development and Testing
Test users are the recommended sign-in for local development as well as automated tests — a +primitivetest OTP bypass, exercised through the app's real login flow with no real email round-trip. See Test User Sign-In for the sign-in pattern, the token semantics, the testAccountBaseEmails whitelist and its cap, and the security guardrails. The commands here manage that whitelist:
# Authorize the listed base emails (edited into app.toml) to derive +primitivetest test accounts
primitive config push --only app
# Inspect the current whitelist (along with other app settings)
primitive apps getClear the whitelist by setting it to an empty array and pushing again. See Invite-Only Apps for the CI recipe that adds a test user directly with primitive users create, bypassing the invitation email.
Scripting
For automation and scripting, most commands support --json output:
primitive apps list --json
primitive users list --jsonEvery list verb — every noun, with no exceptions — prints the same envelope, so a script never has to know which one it asked about:
{
"items": [],
"hasMore": true,
"nextCursor": "01HXY..."
}items holds the rows, hasMore says whether there is another page, and nextCursor is present only when there is. Read a list with jq '.items[]'.
Pagination follows the route the verb reads, not the verb: a list whose route pages takes --limit and --cursor, prints exactly one page, and reports the boundary — it never fetches every page on your behalf. A list whose route does not page takes neither flag and prints the whole set with "hasMore": false. Run primitive <noun> list --help to see which flags a given verb carries.
# The first page, then the next
primitive users list --json --limit 50
primitive users list --json --limit 50 --cursor 01HXY...To get the current access token for use with other tools:
primitive tokenprimitive token automatically refreshes the active access token if it's expired or about to expire, so scripts that pipe it into another tool ( curl -H "Authorization: Bearer $(primitive token)" ...) keep working past the access-token lifetime without a manual primitive login.
API Tokens for CI and Servers
For headless environments where a browser-based primitive login isn't possible — CI pipelines, deploy scripts, server-side jobs — you have two non-interactive paths.
To sign in as yourself, pipe a refresh token into login --token-stdin. Select the environment with -e <env> so login targets the right server (or, without a project config, point it at one with PRIMITIVE_SERVER_URL):
primitive token --refresh | primitive -e <env> login --token-stdinOr create a long-lived API token, which isn't tied to a user session:
primitive tokens create --name "CI deploys" --ttl 90d
primitive tokens list
primitive tokens revoke <token-id>TTL accepts m/h/d/w/mo/y units; omit --ttl for a non-expiring token. Treat these like passwords — store them in your CI's secret store and revoke any token you no longer need.
Non-Interactive primitive init
primitive init (the command behind npx create-primitive-app) prompts for anything it needs — app name, target platforms, access mode. For scripted setups, put the answers in a .primitive-init.toml file in the directory where you run it (or point at one elsewhere with --config <path>), and init skips every prompt:
action = "create" # or "use-existing" (then set app_id instead of app_name)
app_name = "My App"
platform = "web" # "web", "ios", "web,ios", or ["web", "ios"] (defaults to web)
dir = "my-app"
access_mode = "invite-only" # "public", "domain", or "invite-only"
dev_port = 5173 # optional; dev server port for a web project
skip_install = false # optional; same effect as --skip-install
server = "https://primitiveapi.com" # optional; same effect as -s/--serverplatform names one client or several: a list scaffolds each client into its own directory under one project root. -p/--platform takes the same list on the command line (--platform web,ios), and the interactive prompt is a multi-select.
Adding a client to an app that already exists is its own action, valid inside a directory whose primitive/config.json is found by the walk-up above:
action = "add-client" # the app comes from the selected environment
platform = "ios"
dir = "ios" # optional: one platform defaults to its own name
promote_schema = true # consent to move a single-client repo's model
# schema to <project root>/models/models.tomlAn add-client run names no app of its own — no app_name, app_id, access_mode, invite_emails or allowed_domains — and it writes no nested .primitive/, no nested .git/ and no commit. See One App, Several Clients.
dev_port sets the web project's dev server port without the port prompt: init pins it in vite.config.ts and .env, and adds http://localhost:<port> to the app's CORS allowed origins and redirect URIs. skip_install skips the dependency install; --skip-install requests the same thing, and passing the flag always skips even if the config says otherwise.
server picks which Primitive server the app is created on and which URL the scaffolded project records. The -s/--server flag wins if you pass both. If the CLI is already pointed at a different server, init stops and tells you how to point it at the target first, rather than quietly creating the app on your current one. What that takes depends on where you run it:
- Outside a Primitive project, the CLI's server is whatever you last logged in to. Log in to the target server first, then re-run init. If you are not logged in at all, init runs the normal browser login flow against the server named here.
- Inside a Primitive project, the CLI's server is the selected environment's
apiUrl— logged in or not — so a differentservervalue always stops the run. Select the environment that targets it instead: runprimitive -e <name> login, then re-run init with the same-e <name>. Init names the matching environment for you, or tells you to add one withprimitive env add.
For unattended runs, log in ahead of time.
Run primitive init --help for the full key list (dev port, invite emails, allowed domains, overwrite behavior).
Getting Help
Every command has built-in help:
primitive --help
primitive apps --help
primitive users invite --helpNext Steps
- Quick Start — Create your first app
- Working with Databases — Server-side storage, read and written from your server functions
- Server Functions — Your TypeScript, running on the server behind an access gate
- Deploying to Production — Deploy your app