Agent workflow

Run one loop as an AI agent holding an app key: discover, write a function, deploy to dev, fix, promote, roll back.

Docs menu

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 (operation documentation.brief, tool substrio_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 MCP initialize instructions.

  • Every operation with its schemas: GET /v1/_operation_registry, or MCP tools/list (each tool has an input and an output schema, a title and annotations: readOnlyHint for a read, destructiveHint for the operations an unattended agent needs a human's approval for). OpenAPI: GET /v1/_openapi.

  • Over MCP tools/list answers the build loop's tools (about twenty) and substrio_call; substrio_tools {area} answers the full definitions of a goal or an area, tools/list with {"area": "all"} every tool, and substrio_call {tool, arguments} runs any tool by name.

  • What a function can call (the db global, fetch, timers): the runtime typings, served at GET /runtime.d.ts.

  • Over MCP the same material is resources: substrio://guide (the guide), substrio://operations (the tool listing) and substrio://runtime/types (the runtime typings), read with resources/read. Three prompts start the common jobs and name the operations to call: write-function, debug-function and design-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.