This opening is the MCP initialize brief; the whole guide is GET /llms.txt.
The normal flow
The live app changes only when you make a version live, after it ran in a sandbox.
Talk as you work: before each tool call, tell your owner in one line what and why; after it, what happened. Never run calls in silence.
- 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 is one commit and answers itsnumber. - Sandbox.
substrio_sandbox_create{"name": "dev"}once: a copy of the data that holds 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"}. - Branches, tags.
substrio_branch_create,substrio_tag_create.
Each result ends with Next: <tool>: <why> lines, also in _meta
substrio/next (pass names the field to send as that argument); a
refusal ends with 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. Two plain HTTPS calls, no key, no password, no 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 answer (202) is the same whether or not the address has an account, so it says nothing about who is signed up. A six-digit code is on its way 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,"platform_operator":false,
"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 becomes a person with a free account of their own; an address
already held signs that person in (the same two calls). The session is shown
once and never again; keep it out of logs and output.
The account starts at 500 MB. A new account holds 500 MB
(space_bytes 524288000) across all its apps. It opens to its 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. It acts as the person in their account, in every
app of it, with every capability, capped by the person's role there. Calls
that cannot be undone (deleting an app, for one) are not run on your word: the
reply names an approval, the person approves it signed in, and you repeat the
call with it. Tell the person what the free account holds (account.overview,
account.usage: space_bytes is what all its apps may hold together, and
app_defaults what each new app starts with), then make their app:
account.app.create {"id": 7, "slug": "<app>"} returns the app's key once.
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, answers 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
Your first call on the app's own /mcp: substrio_brief (GET /v1/_brief,
operation documentation.brief, no input, at most 3 KB). It answers the app,
what your key is (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 names the 5 to 8 tools it needs beyond the loop's; substrio_tools
{"area": "<goal>"} answers their full definitions and substrio_skill
{"name": "<skill>"} teaches the goal. tools/list lists the loop's tools
only; {"area": "<goal or area>"} or {"area": "all"} lists more, and any tool
runs by its own name. substrio_start (GET /v1/_start) answers every area
with every tool and summary, the long form of the same answer. Every list is
built from the same registry as tools/list, so nothing in it is refused for
your key's scope. The rest of this file is the long form.
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 one HTTPS call by its name: GET /v1/_agent/tools lists
what your key reaches, and POST /v1/_agent/tools/<name> runs one with its
arguments as the body, answering the operation's own 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 on the
console's Agents page. 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 answer holds 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 the first part answered); the last part answershash, 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": "<sha256>", "size": 123, "routes": ["GET /hello"]}]}. The result holdsversion.numberandmissing(hashes to upload). For each:substrio_site_blob_uploadwith{"hash": "<sha256>", "content_base64": "<the bytes, base64>"}. A file larger than one call carries goes up in parts, in order:{"hash": "<sha256>", "offset": <n>, "total": <size>, "content_base64": "<the part>"}, each part at mostmcp_blob_max_bytes(640 KiB by default). Each part answersreceived; the last answersstored. -
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/<sha256> -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 answersokanddiagnostics(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 answersok,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: the live app changes
by nothing else. The switch takes nothing down: under continuous traffic every
request answers, each runs wholly on the old or wholly on the new version, a
request sent after the promote answered runs the new one, and a route the new
version adds answers at once. The app's data is backed up first
(substrio restore puts it back).
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 for it: write dashboard.json, and the console draws their pages,
numbers, tables with filters and paging, records, forms and row buttons (refund,
resend, delete) from it, with no console code. Do it whenever the app holds
something its owner will want to see or act on: orders, sign-ups, sends, jobs,
errors. The full reference is dashboard.md; the skill
dashboard (substrio://skills/dashboard) walks a whole example.
-
Give the app the routes the screens read and the actions call, each with a gate that refuses who should not use it (
GET /stats auth,POST /orders/:id/refund auth:ADMIN). The console calls them as the owner; the dashboard decides nothing about access. -
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". A fault answers400 DASHBOARD_INVALIDwithfieldnaming the path anddetailwhat is wrong:{"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 pushedversionanswers every nav item and panel, checked, as the console will draw 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) answers the stored dashboard. Call one panel's route the way the console will (substrio_route_call) to see the answer 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 answers an array of row objects; any other statement answers
{ changes, lastInsertId }. Errors (syntax, missing table, duplicate key, wrong number of parameters) throw. - 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 is refused with
error 1406 (Data too long), never cut: the default
sql_modeis strict, as MariaDB's. Only a session that sets a non-strictsql_modeitself gets MariaDB's cut 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 is a string of characters up to
U+00FF, a number, a boolean, a BigInt or binary data, and get answers it
as that string of bytes (put("n", 5) then get("n") is "5"). Text with other
characters throws; store new TextEncoder().encode(text) and decode the
string you get back 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 the app's own users connect their agents to its
/mcpwith their own 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, run now, next fire times, run history (UTC).