Every step is an operation you can call over MCP (tool names below) or HTTP
(routes below); the same registry serves both. Keep the key out of output;
examples send it as Authorization: Bearer $SUBSTRIO_KEY to $SUBSTRIO_URL,
for the app acme.
The loop: discover, read the schema, write a function, deploy to dev, invoke in dev, read the error and the logs, fix, redeploy to dev, promote, roll back. It is the flow of agents.md, "The normal flow", which names each step's tool and what it answers; every MCP result names its next step the same way (mcp.md, "What comes next"). This page is the long form, with the CLI beside each step.
The rule (dev mode): every change is made and tested in the app's dev sandbox, and the live app changes only by promoting the exact version tested there. A live app never takes an untested change, and a promote takes nothing down: under continuous traffic every request answers, each runs wholly on the old or wholly on the new version, and a request started after the promote returned runs the new one. The steps:
| Step | CLI | Operation (MCP tool) | HTTP |
|---|---|---|---|
Make dev once: <app>--dev, a copy of the app's data, schedules paused, outbound mail and webhooks captured |
substrio push makes it |
sandbox.create |
POST /v1/_sandboxes {} |
| Commit or push a version and run it in dev only | substrio push |
version.commit (or version.create), then version.live.set with sandbox: "dev" |
POST /v1/_versions, POST /v1/_live {"version": N, "sandbox": "dev"} |
| Test in dev | substrio --tenant <app>--dev invoke NAME |
any operation with tenant <app>--dev |
any route with X-Substrio-Tenant: <app>--dev |
| See what dev's code sent out | sandbox.outbox |
GET /v1/_sandboxes/dev/outbox |
|
| Check it, and see what live would gain | substrio diff |
version.check, version.diff |
|
| Promote exactly what dev runs (the app's data is backed up first) | substrio promote |
version.live.set {"version": N} |
POST /v1/_live {"version": N} |
| Roll back the code, or the data | substrio rollback, substrio restore |
version.live.set {"version": "previous"}, version.restore |
POST /v1/_live {"version": "previous"} |
| Start dev over from live's data | sandbox.reset |
POST /v1/_sandboxes/dev/reset |
The project the CLI ships. substrio push reads substrio.toml in the
current folder: the app (tenant), the folder of function files
(functions_dir, one <name>.js a function), and each function's routes:
tenant = "acme"
functions_dir = "fns"
[functions.hello]
routes = ["GET /hello"]
[functions.feature] # a new function: fns/feature.js and its table
routes = ["GET /feature"]
A change to a function is an edit of its file; a new route is a new
[functions.<name>] table (or a new line in routes). push builds, uploads
the whole app as version N and runs it in dev; the first push also makes the
dev sandbox. Dev answers on its own host, <app>--dev.<domain> (what push
prints), the live app on <app>.<domain>:
curl https://acme--dev.substrio.com/hello
Then substrio promote makes exactly what dev runs live and substrio rollback
returns live to the version before.
function.deploy (section 4b) changes the app it is sent to at once: send it to
<app>--dev while you iterate, never to the live app. Each step below
names its operation. Record formats are in function-errors.md,
schedules.md, sql-over-api.md and
chains-over-api.md.
1. Discover
-
Start with
GET /v1/_brief(operationdocumentation.brief, toolsubstrio_brief, at most 3 KB): your key's scope, the live version, the goals (website, api, database, auth, events) with their tools and skills, and the build loop in order.GET /v1/_start(substrio_start) is the long form: every area of tools you may call with each tool's one-line summary. -
The guide:
GET /llms.txt(the agent guide); its opening through "The normal flow" is the MCPinitializeinstructions. -
Every operation with its schemas:
GET /v1/_operation_registry, or MCPtools/list(each tool has an input and an output schema, a title and annotations:readOnlyHintfor a read,destructiveHintfor the operations an unattended agent needs a human's approval for). OpenAPI:GET /v1/_openapi. -
Over MCP
tools/listanswers the build loop's tools (about twenty) andsubstrio_call;substrio_tools {area}answers the full definitions of a goal or an area,tools/listwith{"area": "all"}every tool, andsubstrio_call {tool, arguments}runs any tool by name. -
What a function can call (the
dbglobal,fetch, timers): the runtime typings, served atGET /runtime.d.ts. -
Over MCP the same material is resources:
substrio://guide(the guide),substrio://operations(the tool listing) andsubstrio://runtime/types(the runtime typings), read withresources/read. Three prompts start the common jobs and name the operations to call:write-function,debug-functionanddesign-schema(prompts/get).
2. Read the schema
curl -s $SUBSTRIO_URL/v1/_sql/schema -H "Authorization: Bearer $SUBSTRIO_KEY" # sql.schema
curl -s $SUBSTRIO_URL/v1/_collections -H "Authorization: Bearer $SUBSTRIO_KEY" # collection.list
sql.schema (tool substrio_sql_schema) answers every table's columns, indexes and
foreign keys; collection.list (substrio_collection_list) the document
collections. Create what is missing in one transaction with sql.batch:
POST /v1/_sql/batch
{"statements":[
{"sql":"CREATE TABLE ledger (id INT PRIMARY KEY AUTO_INCREMENT, owner VARCHAR(20) NOT NULL, amount DECIMAL(10,2) NOT NULL)"}]}
-> {"results":[{"index":0,"returns_rows":false,"changes":0,"last_insert_id":0}],"count":1}
3. Write a function
A function is a file that evaluates to a function of the event. This one validates in a nested call, which is what a stack trace is for:
function validate(event) {
if (typeof event.amount !== "number") throw new TypeError("amount must be a number");
return event.amount;
}
function record(event) {
const amount = validate(event);
db.sql("INSERT INTO ledger (owner, amount) VALUES (?, ?)", [event.owner, amount]);
return { ok: true };
}
module.exports = async function (event) { return record(event); };
Writes that must succeed together go in db.transaction(fn): fn is
synchronous and gets tx, which has tx.sql, tx.prepare and every collection
as tx.<collection> (collection writes commit and roll back with it; a write to
a key-value store or graph inside it is refused); a throw rolls the
transaction back, and a transaction inside one is a savepoint:
db.transaction((tx) => {
tx.sql("INSERT INTO ledger (owner, amount) VALUES (?, ?)", [event.owner, amount]);
tx.sql("UPDATE balances SET total = total + ? WHERE owner = ?", [amount, event.owner]);
});
4a. Check before deploying
POST /v1/_functions/_check (function.check, tool substrio_function_check)
{"name":"charge","script":"<the file above>"}
-> {"ok":true,"format":"commonjs","diagnostics":[
{"severity":"info","message":"exports are checked at deploy: ...","file":"charge","line":0,"column":0}]}
The body is the one function.deploy takes. Nothing is stored and none of your
code runs: the source is parsed (as a script, CommonJS or ES module), deploy's
manifest rules are applied, and a require() call is reported. A problem comes
back as a diagnostic, with ok false:
{"ok":false,"format":"commonjs","diagnostics":[
{"severity":"error","message":"Unexpected token ')'","file":"charge","line":4,"column":18}]}
message is the engine's; line and column count from 1 and are 0 when the
engine gives none. ok is false only for an error diagnostic; an info one
says what the check cannot know: which exports the manifest's triggers need
(a route needs fetch or a default export, a cron needs scheduled or a
default export) is read from the module when deploy loads it, because the engine
builds a module's export list only inside its parser. That the source evaluates to
a callable handler is known only by running it, so deploy checks that and a
refusal there is a deploy error.
4a2. Check a whole version
POST /v1/_versions/_check (version.check, tool substrio_version_check)
{"functions":[{"name":"charge","hash":"<sha256>","size":812}]}
-> {"ok":false,"diagnostics":[
{"severity":"error","message":"Unexpected token ')'","function":"charge","line":4,"column":18}],
"missing":[]}
The body is the one version.create takes. Every function whose source is
uploaded gets the same check as function.check; a source not uploaded yet is an
error diagnostic and its hash is in missing. Nothing is stored. version.create
and version.live.set run this check themselves (option check, default true):
a failing one answers 400 with the usual error body and a diagnostics list of the
errors, and nothing changes. version.create checks the sources already uploaded;
version.live.set checks all of them and never checks a rollback.
4b. Deploy (to dev)
POST /v1/deploy (operation function.deploy, tool substrio_function_deploy)
X-Substrio-Tenant: acme--dev
{"name":"charge","script":"<the file above>","vars":{}}
To dev, while you iterate; the live app gets it by a promote (the rule above).
It answers success. Each deploy whose source differs moves the function deploy
counter on, and the source of every past deploy is in the app history
(version.log with path=functions/charge.js, tool substrio_version_log).
5. Invoke
POST /v1/functions/charge/_invoke (function.invoke, tool substrio_function_invoke)
{"owner":"ann","amount":"12.50"}
The body is the event. Here amount is a string, so the function throws:
HTTP 500
{"error":"Cloud function execution error","code":"CF_EXECUTION_FAILED","status":500,
"retryable":false,"next_action":"reduce_request_or_increase_limit",
"detail":"amount must be a number",
"function_error":{"id":"0000019a1b2c3d4e00000001:acme/charge","revision":1,
"request_id":"req_41","name":"TypeError","message":"amount must be a number",
"frames":[{"function":"validate","file":"...","line":3,"column":...},
{"function":"record","file":"...","line":7,"column":...},
{"function":"","file":"...","line":11,"column":...}]}}
function_error already holds the type, message and frames and the request_id
of the call. Frames are innermost first: the throw is in validate, called from
record.
6. Read the error and the logs
By call (the id you were just given):
GET /v1/_function_errors?function=charge&request_id=req_41 (function.errors, substrio_function_errors)
-> {"errors":[{...the same entry, with "function":"acme/charge" and "timestamp"...}],"count":1,"next_cursor":null}
GET /v1/_logs?function=charge&request_id=req_41&limit=50 (function.logs, substrio_function_logs)
-> (the lines your function wrote with console.log, tagged with the call)
{"logs":[{"log_seq":...,"timestamp":...,"function":"charge","level":"info","message":"...","request_id":"req_41"}],"count":1, ...}
Errors of one deploy: every error entry carries the function's revision when it
threw, and ?revision=3 on function.errors returns only those (a revision
number the deploy counter reached, which version.log reports per commit with
the function path). A log line carries no revision: find the lines of a
failing call by its request_id.
Live tail: connect a WebSocket to GET /v1/_logs/ws (operation
function.logs.stream; no MCP tool, a socket is not a tool call) with the same
key headers and ?function=, ?level=, ?request_id= and ?cursor= (without
a cursor you get only lines stored after you connected). The server answers
{"type":"connected","cursor":"..."}, then {"type":"logs","cursor":"...","logs":[...]}
as lines are stored; no polling, the log store wakes the socket. A reader that
falls behind gets {"type":"lagged","cursor":"..."} and continues from that
cursor as it catches up; if the socket is closed (code 4290) reconnect with the
last cursor you saw. Nothing is dropped. A line's log_seq orders the whole
store (a cursor is the log_seq of a page's last line, as 16 hex digits).
No socket (an MCP tool call cannot stream)? Long-poll function.logs: pass
cursor (the next_cursor of a full page, else the latest_cursor every answer
carries) and wait_ms (1 to 30000; more is INVALID_ARGUMENT). The call is
authenticated and authorized first, like any other (a bad key is refused at
once). If a line past the cursor matching your filters is stored it answers
now; otherwise the request waits for the log store, runs again on each stored
line, and answers with the first non-empty page, or empty at the deadline.
Repeat with the new cursor (next_cursor when present, else latest_cursor).
An app holds at most 64 such waits at once; the next is SERVER_BUSY (503) at
once. A client that disconnects frees its wait immediately. MCP:
substrio_function_logs with wait_ms and cursor arguments. The CLI follows the socket:
substrio logs --follow [--function F] [--request-id ID] [--level L] (alias
--tail, -f) prints lines as they arrive and resumes from its last cursor if
the server asks it to.
An agent script must not hang on a tail with no end: --follow needs one of
--for DURATION (stop after it), --lines N (stop after N lines) or
--until-match TEXT (stop at the first line containing it, included): each
ends with exit 0. Without a terminal, or under --json, an unbounded
--follow is refused up front (INVALID_ARGUMENT, naming the three flags);
on a terminal with none given, it still runs unbounded (Ctrl+C stops it).
Under --json every streamed line is the server's own JSON object, one per
line, unchanged; a bounded follow's last line is
{"event":"end","reason":"lines|duration|match|closed","lines":N} (closed
when the server itself ends the stream before the bound is met).
Without --follow, substrio logs takes --limit N, --since and --until
(each a duration ago such as 30s/5m/1h, or a Unix-second/ISO
timestamp), passed straight through to function.logs.
Filters: function.logs takes function, level, request_id, since,
until (Unix seconds), limit and cursor; a full page answers
next_cursor: pass it back for the entries after it. function.errors takes
function, request_id, since, until, limit, cursor and answers newest
first with next_cursor. Page through everything: repeat with each
next_cursor until it is null (errors) or absent (logs).
7. Fix and redeploy
Change the file and POST /v1/deploy again (a new revision), or edit in place
with function.source.edit (PATCH /v1/functions/charge/source). Invoke again:
POST /v1/functions/charge/_invoke
{"owner":"ann","amount":12.5}
-> {"ok":true}
Verify the data with query.sql (POST /v1/_sql)
{"sql":"SELECT owner, amount FROM ledger"}, or run several checks and writes
atomically with sql.batch; a failure there names the statement
(failed_statement).
8. Schedule
POST /v1/_cron (schedule.create, substrio_schedule_create)
{"function":"charge","expression":"0 6 * * *"} -> {"id":"7_1","success":true}
POST /v1/_cron/7_1/run (schedule.run, substrio_schedule_run)
-> {"success":true,"id":"7_1","outcome":"ok","started_at":1760000000,"started_at_iso":"2025-10-09T08:53:20Z","duration_ms":9,"error_id":null,"error":null,"result":{"ok":true}}
GET /v1/_cron/7_1/next?count=2 (schedule.next, substrio_schedule_next)
-> {"id":"7_1","expression":"0 6 * * *","enabled":true,"timezone":"UTC","fires_at":[...],"fires_at_iso":["...T06:00:00Z","...T06:00:00Z"],"count":2}
GET /v1/_cron/7_1/history (schedule.history, substrio_schedule_history)
-> {"id":"7_1","runs":[{"id":"...","schedule":"7_1","started_at":...,"started_at_iso":"...","started_at_ms":...,"duration_ms":9,"outcome":"ok","trigger":"manual","error_id":null}],"count":1,"next_cursor":null}
Schedules run in UTC. A run that throws is outcome: "error" with an
error_id: read that entry with function.errors. An expression that never
fires (0 0 31 2 *) is refused at create.
8b. Routes and limits
GET /v1/_routes (route.list, substrio_route_list)
-> {"routes":[{"method":"POST","pattern":"/orders/total","function":"order_total","version":"<content hash>"}],"count":1}
Routes are not edited on their own: they are the routes of each function's
manifest, so a deploy declares them and a redeploy changes them (route.list
reads the same table the server dispatches from). Your own usage and limits,
with the function limits (cpu, memory, concurrency, timeout in seconds, the body
and value sizes):
GET /v1/_usage (tenant.usage.self, substrio_tenant_usage_self)
9. Roll back
POST /v1/_commits (version.commit, substrio_version_commit)
{"branch":"main","expected":"<the branch head>","mode":"changes",
"units":[{"path":"functions/charge.js","text":"<the source of the good commit>"}],
"message":"back to the working charge"}
puts a past source back as one more commit, so the history stays whole and
forward only (read that source with version.log?path=functions/charge.js,
then GET /v1/_commits/{version}/unit?path=functions/charge.js). For a whole
app version: substrio_live_set {"version": "previous"}
(agents.md, "Roll back").
Refusals to expect
Every refusal is the standard envelope: error (the code's message), code,
status, retryable, next_action (retry, reauthenticate,
request_permission, reduce_request_or_increase_limit, refresh_resource,
choose_supported_operation, correct_request or inspect_detail), and, when
there is one, detail, field, limit and current. The ones this loop meets: INVALID_ARGUMENT (400, a bad option,
cursor or plan; detail names it), MISSING_FIELD (400), SQL_FAILED (400,
the engine's message, and failed_statement for a batch), CF_EXECUTION_FAILED
(500, with function_error), COLLECTION_NOT_FOUND and FUNCTION_NOT_FOUND
(404). Fix the request and call again; do not retry unchanged.
The whole build-out over /mcp alone
An agent holding an app's key needs no HTTP client, CLI or dashboard: every
step above is a tool of the app's /mcp. The loop's tools are in the default
list; every other one is a goal or an area away, and runs by its name.
Those that are not (substrio_count, the
function source _search, _symbols, _preview and _replace,
substrio_function_environment_list, substrio_secret_list,
substrio_site_release_list and the schedule tools) are listed by
tools/list {"area": "<area>"} or defined by substrio_tools {area}, and run
by name or through substrio_call.
| Step | Tools |
|---|---|
| Orient | substrio_brief (goals, loop), substrio_start (areas), substrio_tools {area}, substrio_call |
| Collections and schema | substrio_collection_create, substrio_schema_get, substrio_schema_update |
| Data | substrio_insert, substrio_get, substrio_list, substrio_update, substrio_delete, substrio_query, substrio_count |
| Functions: deploy and check | substrio_function_deploy, substrio_function_get, substrio_function_check |
| Functions: read and edit the source | substrio_function_source_read, _search, _symbols, _preview, _edit, _replace (preview, then edit with expected_hash) |
| Run | substrio_function_invoke, substrio_route_call |
| Logs and errors | substrio_function_logs, substrio_function_errors |
| Environment and secrets | substrio_function_environment_set, substrio_function_environment_list, substrio_secret_write, substrio_secret_list, substrio_secret_delete (secrets.md) |
| Files | substrio_file_upload, substrio_file_list, substrio_file_download (a binary file comes back as body_base64), substrio_file_delete |
| Static site | substrio_site_blob_upload (no hash needed; its hash is a unit's id in substrio_version_commit), substrio_site_release_create, substrio_site_release_activate, substrio_site_release_list |
| The project (the app's history tree, no hash anywhere: versioning.md, "Developing over MCP") | substrio_source_tree, substrio_version_unit (lines or bytes), substrio_source_edit, substrio_source_search, substrio_source_move, substrio_version_commit (write and delete units) |
| Versions | substrio_version_create, substrio_version_check, substrio_version_list, substrio_sandbox_create, substrio_live_set (make live and roll back) |
| Schedules | substrio_schedule_create, substrio_schedule_run, substrio_schedule_history, substrio_schedule_list |
Every tool is annotated (readOnlyHint, destructiveHint, a title), so a client
asks a human before a file delete or a collection drop and not before a read.