Run each command below on a fresh server. The outputs shown are trimmed; ids
and times differ on yours. You need curl, jq and, for the CLI, Node 20+.
The path: an app key, then a collection, insert, query, update, deploy a
function, invoke it. The CLI section below also signs up for the key itself,
with substrio signup.
Keys are secrets: each one goes into a file only you can read
(umask 077), never onto the command line or into a committed file. The
outputs below show the app key as $SUBSTRIO_KEY; get one from substrio.com
/keys, or follow the CLI section's substrio signup first.
export SUBSTRIO_URL=https://myapp.substrio.com
export SUBSTRIO_KEY=... # from substrio.com /keys, or the CLI section below
curl -s $SUBSTRIO_URL/health
# {"status":"ok"}
GET /v1/_whoami tells you what a key is and which app it belongs to:
curl -s $SUBSTRIO_URL/v1/_whoami -H "Authorization: Bearer $SUBSTRIO_KEY"
# {"key":"tenant","account_id":1,"tenant":"demo","tenant_id":2,"scopes":["tenant_admin"]}
The app key selects its app by itself: no X-Substrio-Tenant header or app host is needed.
HTTP (curl)
1. Collection, insert, query
curl -s -X POST $SUBSTRIO_URL/v1/_meta/collections -H "Authorization: Bearer $SUBSTRIO_KEY" \
-H 'content-type: application/json' -d '{"name":"notes"}'
# {"name":"notes","created":true,"acl_flags":0,"undo_token":"..."}
# Every undoable change answers undo_token: a token when it changed something, or
# "undo_token":null with "unchanged":true when it left the resource as it was, or
# with "pending":true when it was accepted (202) and not yet applied.
curl -s -X POST $SUBSTRIO_URL/v1/notes -H "Authorization: Bearer $SUBSTRIO_KEY" \
-H 'content-type: application/json' -d '{"title":"hello","n":1}'
# {"_id":"Qs7HRQnMTg","status":"created"}
curl -s -X POST $SUBSTRIO_URL/v1/notes -H "Authorization: Bearer $SUBSTRIO_KEY" \
-H 'content-type: application/json' -d '{"title":"world","n":5}'
# {"_id":"Qs7HRVu8oq","status":"created"}
curl -s -X POST $SUBSTRIO_URL/v1/notes/_query -H "Authorization: Bearer $SUBSTRIO_KEY" \
-H 'content-type: application/json' -d '{"where":{"n":{"$gt":2}}}'
# {"data":[{"_id":"Qs7HRVu8oq","_createdAt":1790799172221,"_updatedAt":1790799172221,"_version":1,"title":"world","n":5}],"count":1,"scanned":1,"has_more":false}
The insert body is the document itself, with no wrapper.
2. Update
data.update takes update operators: $set, $unset, $inc, $push.
ID=$(curl -s -X POST $SUBSTRIO_URL/v1/notes/_query -H "Authorization: Bearer $SUBSTRIO_KEY" \
-H 'content-type: application/json' -d '{"where":{"title":"hello"}}' | jq -r '.data[0]._id')
curl -s -X PATCH $SUBSTRIO_URL/v1/notes/$ID -H "Authorization: Bearer $SUBSTRIO_KEY" \
-H 'content-type: application/json' -d '{"$set":{"title":"hello again"},"$inc":{"n":10}}'
# {"status":"updated","fieldsUpdated":2,"fieldsDeleted":0}
curl -s $SUBSTRIO_URL/v1/notes/$ID -H "Authorization: Bearer $SUBSTRIO_KEY"
# {"_id":"Qs7HRQnMTg","_createdAt":1790799172203,"_updatedAt":1790799172279,"_version":2,"title":"hello again","n":11}
3. Deploy a function and invoke it
A function's source evaluates to a function of the event. It reads the
app's data through the db global.
cat > hello.js <<'JS'
(function (event) {
const notes = db.collection("notes").count();
return { hello: event.who || "world", notes: notes };
})
JS
jq -n --rawfile script hello.js '{name: "hello", script: $script}' \
| curl -s -X POST $SUBSTRIO_URL/v1/functions -H "Authorization: Bearer $SUBSTRIO_KEY" \
-H 'content-type: application/json' -d @- | jq '{success, function_id}'
# { "success": true, "function_id": "demo/hello" }
curl -s -X POST $SUBSTRIO_URL/v1/functions/hello/_invoke -H "Authorization: Bearer $SUBSTRIO_KEY" \
-H 'content-type: application/json' -d '{"who":"Ada"}'
# {"hello":"Ada","notes":2}
CLI
Install the CLI with npm install -g @substrio/cli (see install). Commands are
substrio <group> <verb> or substrio op NAME for any operation; input goes in
key=value arguments or --data '{...}', and --json prints JSON.
substrio login reads the key from stdin (--key -) and saves a profile in
~/.substrio/config.toml. substrio login --offline saves it with no round trip to
the server: --tenant is then required
(there is no way to look it up), and the profile is saved marked unchecked
until a later command reaches the server.
1. Sign up, make an app
substrio signup proves your email with a mailed
code, makes you an account and saves its first key as a profile; substrio app create then makes your app with that key.
substrio signup --url https://mcp.substrio.com --tenant identity --email [email protected] --account acme
# The code mailed to [email protected]: 123456
# ✓ Account acme (id 1) created; its key is saved to profile acme.
# "substrio app create NAME" makes your first app; "substrio whoami" shows this profile.
substrio --profile acme app create demo
# ✓ Tenant demo created; its key is saved to profile demo.
export SUBSTRIO_PROFILE=demo
--tenant names the identity app (substrio.com tells you its slug);
--password - (the password read from stdin) sets a password too, so you
can also sign in later with substrio login, instead of a code alone.
app create saves the app's key to a profile by itself (named after the
slug, unless --save-profile says otherwise), the way substrio signup just
did for the account key: nothing to copy by hand.
2. Collection, insert, query
substrio collections create name=notes --json
# { "name": "notes", "created": true, "acl_flags": 0, "undo_token": "..." }
substrio data insert notes --data '{"title":"hello","n":1}' --json
# { "_id": "Qs7JBXFP0a", "status": "created" }
substrio data insert notes --data '{"title":"world","n":5}' --json
# { "_id": "Qs7JC4k4MC", "status": "created" }
substrio data query notes --data '{"where":{"n":{"$gt":2}}}' --json
# { "data": [ { "_id": "Qs7JC4k4MC", ..., "title": "world", "n": 5 } ], "count": 1, "scanned": 1, "has_more": false }
--data is the document itself, with no wrapper (over MCP the same document goes
in the document argument). To load many documents, import a file:
printf '%s\n' '{"title":"a","n":1}' '{"title":"b","n":2}' > old-notes.ndjson
substrio data import old_notes --file old-notes.ndjson --json
# {"event":"done","imported":2,"skipped":0,"replaced":0,"failed":0,"elapsed_ms":12,"checkpoint":null}
substrio data import COLLECTION --file FILE streams NDJSON (one document per
line), a JSON array, {"documents":[...]}, or CSV (--types for its column
types), as its own bytes, never loading the file whole and never parsing or
re-serializing a document; the extension (.ndjson, .json, .csv) picks
the format, or --format does (needed for stdin, --file -). A document
that is refused is reported alone, as it happens, with its position (its
line, element or row number, from 1), a code and a message (a schema refusal
also lists each rule it broke in violations), and the others are still
written. The command then exits with code 8 (partial import), so a script
cannot miss it; 0 means every document was stored. A document that names
_id keeps it; one that does not gets a generated id. A 429 or 503 is
retried with backoff, never dropped, and is never silent: --json emits a
retry line before each wait. --checkpoint and --resume continue a run
that stopped partway without re-sending what already landed, but need
--upsert or --on-conflict skip to do it safely (refused otherwise, at any
concurrency: without an id to resend against, a crash right after the server
stored a batch but before the checkpoint was written would double-store it);
see bring-your-project.md.
A collection can carry a schema. strict defaults to true (a field no rule
names is refused), datetime takes an RFC 3339 string or epoch milliseconds,
and field names that start with _ are kept as written. See
project-files.md for the files that hold schemas, rules and
indexes, and bring-your-project.md to move a whole backend.
3. Update
ID=$(substrio data query notes --data '{"where":{"title":"hello"}}' --json | jq -r '.data[0]._id')
substrio data update notes $ID --data '{"$set":{"title":"hello again"},"$inc":{"n":10}}' --json
# { "status": "updated", "fieldsUpdated": 2, "fieldsDeleted": 0 }
substrio data get notes $ID --json
# { "_id": "Qs7JBXFP0a", ..., "_version": 2, "title": "hello again", "n": 11 }
4. Deploy a function and invoke it
substrio init scaffolds a project (substrio.toml names the profile, never a
key); substrio deploy uploads everything in functions/.
mkdir myapp && cd myapp
substrio init --tenant demo --yes
cp ../hello.js functions/hello.js # the function from the HTTP section
substrio deploy
# Functions (1 file)
# ✓ hello (0.1 KB)
# ✓ Deployed 1 function successfully.
substrio invoke hello --data '{"who":"Ada"}' --json
# { "hello": "Ada", "notes": 2 }
MCP
MCP is JSON-RPC 2.0 at POST /mcp (alias POST /v1/_mcp). The app key is
the credential; the server picks the app from it. Get the key with step 1
of the HTTP or CLI section.
mcp() { curl -s -X POST $SUBSTRIO_URL/mcp -H "Authorization: Bearer $SUBSTRIO_KEY" \
-H 'content-type: application/json' -d "$1"; echo; }
mcp '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}' \
| jq -c '.result | {protocolVersion, serverInfo}'
# {"protocolVersion":"2025-06-18","serverInfo":{"name":"demo","version":"1"}}
mcp '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | jq '.result.tools | length'
# one count per platform tool the app key reaches plus any the app's own manifest declares
The account key works on MCP too: with no app named it reaches the account's
own tools (make an app, list apps and keys); naming an app
(X-Substrio-Tenant or its host) it reaches that app's tools and the
account's. See agents.md.
curl -s -X POST $SUBSTRIO_URL/mcp -H "Authorization: Bearer $SUBSTRIO_ACCOUNT_KEY" \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"substrio_account_app_list","arguments":{"id":1}}}' \
| jq -c '[.result.structuredContent.apps[].slug]'
# ["demo"]
An MCP client (Claude Code, Cursor) connects to the same URL:
claude mcp add --transport http demo $SUBSTRIO_URL/mcp --header "Authorization: Bearer $SUBSTRIO_KEY".
See mcp.md; substrio mcp config prints this connection with the key read
from an environment variable.
Collection, insert, query
A tool's arguments are its route parameters (collection, id), its query
options and the body's fields; user data (a document, an event) is one
argument of its own, so a field of it is never taken for a parameter.
mcp '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"substrio_collection_create","arguments":{"name":"notes"}}}'
# {"jsonrpc":"2.0","id":3,"result":{"content":[...],"structuredContent":{"name":"notes","created":true,"acl_flags":0,"undo_token":"..."},"isError":false}}
mcp '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"substrio_insert","arguments":{"collection":"notes","document":{"title":"hello","n":1}}}}'
# {...,"structuredContent":{"_id":"Qs7M0DqCZc","status":"created"},"isError":false}}
mcp '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"substrio_insert","arguments":{"collection":"notes","document":{"title":"world","n":5}}}}'
# {...,"structuredContent":{"_id":"Qs7M0JnmIy","status":"created"},"isError":false}}
mcp '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"substrio_query","arguments":{"collection":"notes","where":{"n":{"$gt":2}}}}}'
# {...,"structuredContent":{"data":[{"_id":"Qs7M0JnmIy",...,"title":"world","n":5}],"count":1,"scanned":1,"has_more":false},"isError":false}}
Update
ID=$(mcp '{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"substrio_query","arguments":{"collection":"notes","where":{"title":"hello"}}}}' \
| jq -r '.result.structuredContent.data[0]._id')
mcp '{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"substrio_update","arguments":{"collection":"notes","id":"'$ID'","$set":{"title":"hello again"},"$inc":{"n":10}}}}' \
| jq -c .result.structuredContent
# {"status":"updated","fieldsUpdated":2,"fieldsDeleted":0}
mcp '{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"substrio_get","arguments":{"collection":"notes","id":"'$ID'"}}}' \
| jq -c .result.structuredContent
# {"_id":"Qs7O8JlUcS",...,"_version":2,"title":"hello again","n":11}
substrio_update accepts only the operators: an unknown argument such as
update is refused with INVALID_ARGUMENT ("Unknown field (strict mode
enabled)"), and nothing is written.
Deploy a function and invoke it
jq -n --rawfile script hello.js \
'{jsonrpc:"2.0",id:10,method:"tools/call",params:{name:"substrio_function_deploy",arguments:{name:"hello",script:$script}}}' > deploy.json
mcp @deploy.json | jq -c '.result.structuredContent | {success, function_id}'
# {"success":true,"function_id":"demo/hello"}
mcp '{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"substrio_function_invoke","arguments":{"name":"hello","event":{"who":"Ada"}}}}' \
| jq -c .result.structuredContent
# {"hello":"Ada","notes":2}
substrio_function_invoke takes name (the function) and event, the JSON
event, whatever its field names.
Next
- getting-started.md: from a key to a live app, step by step.
- bring-your-project.md: move an existing backend.
- Every operation, with its route, scope and MCP tool, is in the generated API reference.
- query-language.md: the
wherefilter and its operators. - cloud-functions.md: functions,
db, cron and triggers. - authentication.md: users, roles and rules.