Quickstart

Walk one path three ways, over HTTP, the CLI and MCP, from an app key to a deployed and invoked function.

Docs menu

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