First: greet your owner
Call substrio_start, tell your owner what it says, and wait for their answer
before building. Before each call, say what you are doing and why. After
the call, relay its Tell your owner: line.
The normal flow
The live app changes only when you make a version live, after it ran in a sandbox.
- Orient.
substrio_brief: the goals (website, api, database, auth, events), the tools and the skill of each. - Read the data.
substrio_collection_list,substrio_sql_schema. - Commit.
substrio_source_treelists files,substrio_version_unitreads one.substrio_version_commitwrites files,substrio_source_editreplaces text. Each creates a commit and returns itsnumber. - Sandbox. Call
substrio_sandbox_create{"name": "dev"}once to copy the data into a sandbox that captures its mail.substrio_live_set{"version": <number>, "sandbox": "dev"}runs the commit there. - Check it.
substrio_route_call{"method": "GET", "path": "/", "sandbox": "dev"};substrio_function_errorsandsubstrio_function_logsshow what failed. - Live.
substrio_live_set{"version": <number>}(data backed up first). - Roll back.
substrio_live_set{"version": "previous"}.
Guidance opens with Tell your owner:, followed by Next: <tool>: <why>
lines. It also appears in _meta under substrio/next. Its pass field is
the value to send as the argument named by as. An error adds
Fix: <tool>: <why>.
Start here with no key: sign the person up or in
All you need is the base URL https://mcp.substrio.com and the
person's email. These two HTTPS calls require no key, password, SDK, or plugin.
1. Mail the person a code.
curl -X POST https://mcp.substrio.com/v1/_agent/signup/start \
-H 'Content-Type: application/json' -d '{"email":"[email protected]"}'
{"accepted":true,"expires_at":1792658700}
The response (202) is the same whether or not the address has an account, so it does not reveal who is signed up. A six-digit code is sent to that address, live for 30 minutes. Ask the person for it.
2. Prove the address with the code and get your session.
curl -X POST https://mcp.substrio.com/v1/_agent/signup/finish \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","code":"483920","name":"my agent"}'
{"account_id":7,"account_created":true,"person_created":true,"user_id":42,
"account":{"id":7,"name":"ada","created_at":1792656900,"services":0,"suspended":false,
"require_mfa":false,"personal":true,
"space_bytes":524288000,"used_bytes":0,"role":"owner"},
"app_defaults":{"storage_bytes_limit":0,"documents_limit":0,"requests_per_minute":0},
"session":{"id":"q3RkV0xYbW5PcGQ4c1p0Ww","kind":"ratcheted","expires_at":1793261700,
"account_id":7,"user_id":42,"role":"owner","apps":[],"capabilities":["system","auth","..."],
"requests_per_minute":0,"request_budget":0,"keys":["substrio_ak_..."]},
"next_steps":[{"operation":"account.overview","route":"GET /v1/_accounts/{id}/overview"},
{"operation":"account.usage","route":"GET /v1/_accounts/{id}/usage"},
{"operation":"account.app.create","route":"POST /v1/_accounts/{id}/apps"},
{"operation":"account.app.list","route":"GET /v1/_accounts/{id}/apps"},
{"operation":"agent.session.delegate","route":"POST /v1/_accounts/{id}/agent/delegations"},
{"operation":"documentation.brief","route":"GET /v1/_brief"}]}
A new address creates a person and a free account. The same two calls sign in
a person whose address is already registered. The session is shown
once and never again; keep it out of logs and output.
The account starts at 500 MB. A new account has 500 MB
(space_bytes 524288000) across all its apps. Its limit increases to the plan's space
(Free: 2 GB) once your owner signs in to the console with Google, GitHub or
another provider. The email code they gave you, a password, and the console
link you mint for them (account.console.link) do not open it. When
space_bytes is 524288000 and they need more, tell your owner: "Your
account opens to 2 GB when you sign in with Google, GitHub or another
provider once." See authentication.md.
Use the key. Send Authorization: Bearer <key> with session.keys[0].
Every reply carries the next key in its Substrio-Next-Keys header: use that
one on your next call (each key works once). To sign requests with your own
key instead, send "public_key" (base64url of the raw key) and "algorithm"
(ed25519 or p256) to finish: session.key is then a handle, and each
request carries a Substrio-Proof header signed with your private key.
curl https://mcp.substrio.com/v1/_accounts/7/overview -H 'Authorization: Bearer substrio_ak_...'
What the session may do. The session acts for the person across the apps
in their account, with every capability allowed by their account role. Calls
that cannot be undone, such as deleting an app, require approval. The response
identifies the approval. The person signs in and approves it, then you repeat
the call with that approval. Tell the person about the free account using
account.overview and account.usage. space_bytes is the shared storage
limit for its apps, and app_defaults describes the defaults for each new app.
Then create their app:
account.app.create {"id": 7, "slug": "<app>"} creates the app. An agent
session uses its existing credential; the response does not include the app key.
A slug that is taken or reserved is TENANT_ALREADY_EXISTS (409), and one
that is no slug TENANT_INVALID (400), each with field slug.
To give a developer agent that one app only, make it a narrower session:
agent.session.delegate with "apps": ["<app>"] and the capabilities it needs.
Which account. Without account_id the session acts in the person's
personal account. A person who belongs to accounts but has no personal one is
asked which: the answer is ACCOUNT_REQUIRED with account_ids; ask the
person, start again (the code is spent) and finish with "account_id".
Refusals name their reason. A code that proves nothing (400): MISSING
(no code was asked for this address), WRONG (with attempts_remaining),
TOO_MANY_ATTEMPTS, EXPIRED, USED; start again for a new code. Then
MFA_REQUIRED (403: the person signs in with 2FA, which a mailed
code does not replace; signed in to the console with it, they enroll you
themselves with agent.enroll.create and give you its one-time code for
agent.enroll.exchange), ACCOUNT_REQUIRED (409), NOT_MEMBER (404: the
person does not belong to that account_id), PERSON_DISABLED,
ACCOUNT_SUSPENDED (403), ACCOUNT_LIMIT (409). A start sent too often for
one address, or too many from one source, returns 429.
Holding a key already? Skip this and start with substrio_brief below.
Moving an existing project
If the app already exists as a backend somewhere else, follow
bring-your-project.md: inventory, collections,
data import, ids, sign-in, routes, jobs, secrets, deploy. Every step is a
CLI command you can run with --json. Project files (substrio.toml,
collections/<name>.json) are described in project-files.md.
To try each change before it goes live, substrio push runs it in the app's
dev sandbox (getting-started.md).
Start here with a key: substrio_brief
For your first call on the app's /mcp, use substrio_brief (GET /v1/_brief,
operation documentation.brief). It takes no input and returns at most 3 KB.
The response identifies the app and your key scope (tenant for a key that administers the app,
user for a person's session), the live_version, the goals an app is built
for and the next_steps of the build loop by tool name:
| Goal | What it builds | Skill |
|---|---|---|
website |
pages, styles, scripts and images under site/ |
files-and-site |
api |
HTTP routes run by functions/<name>.js |
functions |
database |
collections and SQL tables, their schema, indexes and queries | data-and-queries |
auth |
sign-up and sign-in for the app's users, and their permissions | users-auth-permissions |
events |
triggers on data changes and scheduled jobs | queues-cron-triggers |
Each goal lists the 5 to 8 tools it needs beyond those in the loop. Call
substrio_tools with {"area": "<goal>"} for their full definitions, and
substrio_skill with {"name": "<skill>"} for guidance on the goal.
tools/list lists the loop's tools. Use {"area": "<goal or area>"} or
{"area": "all"} to list more, and call a tool by name. substrio_start
(GET /v1/_start) returns the longer list of areas, tools, and summaries.
The lists use the same registry as tools/list and are filtered for your key's
scope. The rest of this page describes the flow in more detail.
Connect
- Your app is reached at
https://<app>.<domain>(or any Substrio host with the headerX-Substrio-Tenant: <app>). - Credential: the app's key, or a key of the account that owns the app. Keep it out of logs and output.
- MCP:
POST https://<app>.<domain>/mcpwithAuthorization: Bearer <key>.tools/listshows every built-in tool with its input schema. - HTTP: send
Authorization: Bearer <key>. The full API is atGET /v1/_openapi.
Connect an MCP client with the key in an environment variable
substrio mcp config [--client claude|cursor|generic] prints the client config for
your app with the key read from an environment variable, so the key never
lands in a config file or shell history (mcp.md).
Start from an account key
With only an account key, make the app first on mcp.substrio.com's own MCP:
POST https://mcp.substrio.com/mcp (a host no app serves) with
Authorization: Bearer <account key>. It offers the account's tools only.
substrio_account_app_create {"id": <account>, "slug": "<app>"} makes the app
and returns its key once; substrio_account_app_list, substrio_account_key_list
and substrio_account_member_list show the rest. The same account key then runs
the loop below on the app's own /mcp. Over HTTP:
POST /v1/_accounts/<account>/apps with Authorization: Bearer <account key>.
Every tool over plain HTTPS
Each tool is also available over HTTPS. GET /v1/_agent/tools lists
the tools available to your key, and POST /v1/_agent/tools/<name> runs one with its
arguments as the body. The response includes the operation's status and body and
next (agent-http.md).
A connect code from your owner (substrio_ae_...)
A substrio_ae_ code is a one-time connect code your owner made with
POST /v1/_accounts/{id}/agent/enrollments. Redeem it once with plain HTTPS:
POST https://mcp.substrio.com/v1/_agent/exchange {"code": "<code>"}. The
answer's key is shown once: send Authorization: Bearer <key> on every
request, and its next line says how long it lasts. MCP works the same way:
connect https://mcp.substrio.com/mcp with that header; with no key yet, the
tool substrio_connect {"code": "<code>"} redeems the code there. The CLI
does both steps: npx @substrio/cli connect <code>. A chat app already connected through the
Substrio connector does not need the code: substrio_connect refuses it
there (AGENT_ALREADY_CONNECTED) and leaves it unused.
The loop: commit, check the sandbox, promote
1. Commit to the app's history
The app's history tree is your project: list it, read it, edit it and commit to it with plain MCP calls. No call takes a hash (versioning.md, "Developing over MCP").
- Write units with
substrio_version_commit:{"branch": "main", "message": "v1", "units": [{"path": "functions/hello.js", "text": "export default () => new Response('hello');\n"}, {"path": "functions/hello.json", "text": "{\"routes\":[\"GET /hello\"]}"}, {"path": "site/index.html", "text": "<h1>hi</h1>\n"}]}. Bytes go as{"path": ..., "base64": ...};removalsdeletes paths. The response contains the commit'snumber, which every step below takes asversion. substrio_source_tree{"version": "main"}lists the paths;substrio_version_unit{"version": "main", "path": "functions/hello.js", "start_line": 1, "end_line": 20}reads lines;substrio_source_search{"version": "main", "pattern": "hello"}finds them.substrio_source_edit{"branch": "main", "path": "functions/hello.js", "edits": [{"old": "hello", "new": "hello v2"}]}replaces exact text and commits it (EDIT_NO_MATCHnames the closest line,EDIT_AMBIGUOUSthe lines it occurs on);substrio_source_moverenames a unit.- A file larger than one call:
substrio_site_blob_uploadwith no hash, in parts (offset,total, then theuploadid returned by the first part). The last part returnshash, whichsubstrio_version_committakes as the unit'sid. versiontakes an id, a number, a branch,liveor a sandbox's name.
1b. Or push a built version
From a checkout, substrio push sends a whole built version by hash.
-
MCP:
substrio_version_createwith{"message": "...", "functions": [{"name": "hello", "hash": "<gxhash128>", "size": 123, "routes": ["GET /hello"]}]}. The result containsversion.numberandmissing(hashes to upload). For each:substrio_site_blob_uploadwith{"hash": "<gxhash128>", "content_base64": "<the bytes, base64>"}. Upload a file larger than one call in parts, in order:{"hash": "<gxhash128>", "offset": <n>, "total": <size>, "content_base64": "<the part>"}, each part at mostmcp_blob_max_bytes(640 KiB by default). Each part returnsreceived. The last returnsstored. -
HTTP:
curl -X POST https://<app>.<domain>/v1/_versions -H "Authorization: Bearer $SUBSTRIO_KEY" \ -H "Content-Type: application/json" -d @version.json curl -X PUT https://<app>.<domain>/v1/_blobs/<gxhash128> -H "Authorization: Bearer $SUBSTRIO_KEY" \ -H "Content-Type: application/octet-stream" --data-binary @hello.js
A version may also carry rules, mcp, site, cron, triggers, plans,
auth and login; see the substrio_version_create input schema.
Check before you ship, at three levels, all with the same check (parse as
script, CommonJS or ES module without running; deploy's require() refusal):
substrio_function_checktakes one function's source (thesubstrio_function_deploybody) and returnsokanddiagnostics(severity, message, file, line, column). Nothing is written.substrio_version_checktakes thesubstrio_version_createbody and runs that check over every function whose source is uploaded; a source not uploaded yet is an error diagnostic, since the version cannot go live without it. It returnsok,diagnostics(function, severity, message, line, column) andmissing. Nothing is stored.substrio_version_createandsubstrio_live_setrun it themselves unless sentcheck: false. A failing check refuses with400, the usual error body and adiagnosticslist of the errors.substrio_version_createchecks the sources already uploaded (so upload first, or push again);substrio_live_setchecks all of them before anything changes, and never checks a rollback.
2. Check it in the sandbox
A sandbox is a copy of the app's data, at https://<app>--<name>.<domain>,
whose outbound requests are fenced. Use dev.
- Create it once, or reset its data from the app when it exists:
- MCP:
substrio_sandbox_create{"name": "dev"}; a409means it exists, sosubstrio_sandbox_reset{"name": "dev"}. - HTTP:
POST /v1/_sandboxes{"name": "dev"}, elsePOST /v1/_sandboxes/dev/reset.
- MCP:
- Make the version live there:
- MCP:
substrio_live_set{"version": <n>, "sandbox": "dev"}. - HTTP:
POST /v1/_live{"version": <n>, "sandbox": "dev"}.
- MCP:
- Try it on the sandbox host,
https://<app>--dev.<domain>, with the same key:- HTTP:
curl https://<app>--dev.<domain>/hello. - MCP:
substrio_route_call{"method": "GET", "path": "/hello", "sandbox": "dev"}sends one request to any route of the sandbox (leave outsandboxfor the app), with optionalquery,headersandbody(orbody_base64). It runs in process as a real request: the function routes, then the site, with the sandbox's outbound fence. It returnsstatus,headersandbody(body_base64when the body is not UTF-8). A version'smcpmanifest ({"tools": [{"name": "hello", "description": "...", "route": "GET /hello"}]}) also makes each route a tool on the sandbox's own/mcp.substrio_function_invoke{"name": "...", "event": {...}}runs a function that takes a JSON event. substrio_function_logsshows the function logs;substrio_function_errorsshows what the functions threw, with stack frames, newest first, and takesrevisionto show only the errors of one deploy (every error carries the deploy counter,substrio_version_logwith the function path reports it per commit); a log line carries no revision, so find a call's lines by itsrequest_id.substrio_sandbox_outboxshows the outbound requests the fence held back.- A private
host:portis out of reach of your app's outbound requests; no app or account key can open one. What an agent can do with its own key issubstrio_sandbox_allow_list,substrio_sandbox_allow_addandsubstrio_sandbox_allow_remove(the host patterns one sandbox may reach) and read what the fence held back insubstrio_sandbox_outbox.
- HTTP:
3. Promote
Make the same version live on the app:
- MCP:
substrio_live_set{"version": <n>}. - HTTP:
POST /v1/_live{"version": <n>}.
Promote only a version you ran and checked in the sandbox. Promotion changes
the live app without downtime. Each request runs entirely on the old or the
new version, even under continuous traffic. Requests sent after the promotion
response use the new version, and routes added by that version are available
immediately. The app's data is backed up first. substrio restore restores it.
Roll back
Put the version that was live before back, or name any earlier one:
- MCP:
substrio_live_set{"version": "previous"}(or{"version": <n>}). - HTTP:
POST /v1/_live{"version": "previous"}.
substrio_version_list (GET /v1/_versions) shows every version and which one is
live; substrio_version_diff (GET /v1/_versions/<from>/diff/<to>) shows what
changes between two.
Give your owner an admin dashboard
The person you build for runs their app from the Substrio console. Give them an
admin screen by writing dashboard.json. The console renders its pages,
metrics, tables with filters and paging, records, forms, and row buttons such
as refund, resend, and delete. No console code is needed. Use a dashboard for
data the owner needs to view or act on, such as orders, sign-ups, sends, jobs,
and errors. See dashboard.md for the full reference. The
dashboard skill (substrio://skills/dashboard) walks through an example.
-
Give the app the routes the screens read and the actions call, each with a gate that restricts access (
GET /stats auth,POST /orders/:id/refund auth:ADMIN). The console calls them as the owner. Access is enforced by the route gates, not by the dashboard. -
Write
dashboard.jsonand name it insubstrio.toml(dashboard_file = "dashboard.json"). A minimal one:{"nav": [{"id": "home", "title": "Home", "icon": "overview", "panels": [ {"kind": "metric", "title": "Sign-ups today", "source": {"route": "GET /stats"}, "value": "/today", "format": "number"}, {"kind": "keys"}]}]} -
Check it before you ship:
substrio_version_check(POST /v1/_versions/_check) with the version body carrying"dashboard". An invalid dashboard returns400 DASHBOARD_INVALID, with the path infieldand the problem indetail:{"code": "DASHBOARD_INVALID", "field": "nav[0].panels[0].source.route", "detail": "nav[0].panels[0].source.route names no route this version declares: GET /stat"}Fix the file at that path and check again. After the push,
substrio_dashboard_getwith the pushedversionreturns the validated nav items and panels as the console will render them. -
Ship as usual (commit or push, check the sandbox, promote). The dashboard goes live with its version.
-
Verify:
substrio_dashboard_get(GET /v1/_accounts/<id>/apps/<app>/dashboard) returns the stored dashboard. Call one panel's route withsubstrio_route_call, as the console will, to see the response its pointers read. Then tell the owner where to find it: their app in the console.
Writing a function: the db API
A function file evaluates to a function of the event:
module.exports = async function (event) { ... }. A route function's
event.body is the request body as text. The app's data is the db global;
every call below is synchronous. A wrong argument throws (a TypeError
for a filter, field name or value the call cannot take) with the call's
name first in the message, and a failed SQL statement throws the
database's error.
Everything a function can call, with its limits and where it differs from the
web platform, is typed in GET /runtime.d.ts (over MCP: the resource
substrio://runtime/types; the prompts write-function, debug-function and
design-schema say what to read and call). db.transaction(tx => ...) runs
synchronous SQL and collection writes as one transaction: tx.sql, tx.prepare
and tx.<collection> belong to that callback's tx argument in the function
runtime (JavaScript you deploy), not operations or tools; from outside a function the same atomic SQL is
substrio_sql_batch.
The runtime guide for agents, one page per area: index (handler shapes, limits, differences from Workers), kv, sql, collections and chains, records, services and errors and logs.
Documents (db.collection(name))
const notes = db.collection("agentnotes");
const { _id } = notes.insert({ title: "hi", n: 1 });
notes.get(_id).title; // "hi"
notes.update(_id, { $inc: { n: 1 }, $set: { done: true }, $unset: { title: 1 } }).n; // 2
notes.delete(_id); // true
const Note = notes;
const draft = new Note({ title: "draft" });
draft.save(); // true
draft._version; // 1
draft.title = "final";
draft.save(); // true
notes.find({ title: "final" }, { sort: { n: -1 }, limit: 10 }).length; // 1
A collection is also a class: new notes({ ... }).save() inserts a row and fills
_id and _version; a stored row's save() writes what changed and throws
ConflictError if the document changed since it was read. Reads are chains
(eq, gt, in, sort, limit, page, with, whereHas) or filter objects
(find, findOne, count, exists, updateWhere, deleteWhere; null means
every document, anything else that is not an object throws). Writes: insert,
insertMany, upsert, upsertMany, update ($set, $inc, $push, ... and
{ if } guards), updateMany, findOneAndUpdate, replace, delete,
deleteMany, deleteWhere. Relations: hasMany, hasOne, belongsTo.
Full reference: collections and chains, the
chain reference and records.
Chained queries
const posts = db.collection("agentposts");
const users = db.collection("agentusers");
posts.insertMany([{ n: 1, done: false }, { n: 3, done: false }, { n: 4, done: false }, { n: 2, done: true }]);
const ann = users.insert({ name: "ann" })._id;
posts.insert({ n: 9, authorId: ann });
posts.eq("done", false).gt("n", 2).sort("n", -1).limit(5).pluck("n"); // [4, 3]
posts.in("n", [1, 2]).count(); // 2
posts.or(q => q.eq("n", 1), q => q.eq("n", 2)).count(); // 2
posts.eq("authorId", ann).join("agentusers", "authorId", "_id", "author").first().author.name; // "ann"
posts.eq("n", db.arg(0)).count(3); // 1
db.arg(i) stands for the run's i-th argument. Terminals: all, first,
count, ids, pluck(field), sum, avg, min, max, facets.
SQL (db.sql, db.prepare)
db.sql("CREATE TABLE t (id INT PRIMARY KEY, price DECIMAL(10,2), at DATETIME)");
db.sql("INSERT INTO t VALUES (?, ?, ?)", [1, "9.99", "2026-10-01 12:00:00"]);
db.sql("SELECT * FROM t WHERE id = ?", [1]); // [{ id: 1, price: "9.99", at: "2026-10-01 12:00:00" }]
const byId = db.prepare("SELECT * FROM t WHERE id = ?");
byId.all(1); byId.first(1); // rows; first row or null
- A SELECT returns an array of row objects. Other statements return
{ changes, lastInsertId }. Syntax errors, missing tables, duplicate keys, and incorrect parameter counts throw an error. - Parameters: null/undefined, booleans, numbers, strings, BigInts (int64 and
uint64) and binary data (
Uint8Array,ArrayBuffer). Pass aDateas a string (d.toISOString().slice(0, 19).replace("T", " ")). - Results: integers past 2^53 come back as BigInt; DECIMAL, DATE, TIME and
DATETIME as their exact SQL text; BLOB/BINARY columns as
Uint8Array. - A TEXT or BLOB column holds 65535 bytes. A longer value returns
error 1406 (Data too long) under the default strict
sql_mode. A session that sets a non-strictsql_modegets MariaDB's truncation with a warning. Declare MEDIUMTEXT/LONGTEXT (or MEDIUMBLOB/LONGBLOB) for more. db.collection(name).query(sql, ...args)runs SQL over a collection's documents.
Key-value (db.kv(name))
const cache = db.kv("cache");
cache.put("k", "v"); // true
cache.get("k"); // "v"; null when absent
cache.delete("k");
KV stores bytes, one per character. A value can be a string of characters up to
U+00FF, a number, a boolean, a BigInt, or binary data. get returns a string of
bytes. For example, put("n", 5) followed by get("n") returns "5".
Text with other characters throws an error. Store it with
new TextEncoder().encode(text), then decode the returned string with
new TextDecoder().decode(Uint8Array.from(s, c => c.charCodeAt(0))).
Limits
- One MCP call carries at most
mcp_blob_max_bytesof a file, decoded (640 KiB by default); a larger file goes up in parts, up tomcp_blob_upload_max_bytes(256 MiB by default). Over HTTP,PUTthe whole file. - The
substrioCLI runs the same loop:substrio push,substrio promote,substrio rollback.
Going further
substrio_start(above) lists what exists for your key;substrio://operations(MCP) lists every tool with its input and output schema.- agent-workflow.md: the whole loop with exact operations, one worked example.
- app-agents.md: let app users connect their agents to
/mcpwith their credentials. - versioning.md: the app's one history (
branch.*,tag.*,version.commit,version.log,merge.*,ref.log,ref.restore,version.gc, and the git export). The version numbers above are a view over it, so a push you made withsubstrio_version_createis a commitsubstrio_version_logreports. - sql-over-api.md:
query.sql,sql.batch,sql.schema,index.*. - chains-over-api.md:
query.chain, the JSON plan of the JS chain. - function-errors.md: thrown errors with frames, and how to page them.
- schedules.md: create and run schedules, and view their next fire times and run history.