Building on substrio

Build and ship an app on substrio as an AI agent, over MCP or plain HTTP with the same results.

Docs menu

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.

  1. Orient. substrio_brief: the goals (website, api, database, auth, events), the tools and the skill of each.
  2. Read the data. substrio_collection_list, substrio_sql_schema.
  3. Commit. substrio_source_tree lists files, substrio_version_unit reads one. substrio_version_commit writes files, substrio_source_edit replaces text; each is one commit and answers its number.
  4. 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.
  5. Check it. substrio_route_call {"method": "GET", "path": "/", "sandbox": "dev"}; substrio_function_errors and substrio_function_logs show what failed.
  6. Live. substrio_live_set {"version": <number>} (data backed up first).
  7. Roll back. substrio_live_set {"version": "previous"}.
  8. 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 header X-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>/mcp with Authorization: Bearer <key>. tools/list shows every built-in tool with its input schema.
  • HTTP: send Authorization: Bearer <key>. The full API is at GET /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": ...}; removals deletes paths. The answer holds the commit's number, which every step below takes as version.
  • 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_MATCH names the closest line, EDIT_AMBIGUOUS the lines it occurs on); substrio_source_move renames a unit.
  • A file larger than one call: substrio_site_blob_upload with no hash, in parts (offset, total, then the upload id the first part answered); the last part answers hash, which substrio_version_commit takes as the unit's id.
  • version takes an id, a number, a branch, live or 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_create with {"message": "...", "functions": [{"name": "hello", "hash": "<sha256>", "size": 123, "routes": ["GET /hello"]}]}. The result holds version.number and missing (hashes to upload). For each: substrio_site_blob_upload with {"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 most mcp_blob_max_bytes (640 KiB by default). Each part answers received; the last answers stored.

  • 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_check takes one function's source (the substrio_function_deploy body) and answers ok and diagnostics (severity, message, file, line, column). Nothing is written.
  • substrio_version_check takes the substrio_version_create body 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 answers ok, diagnostics (function, severity, message, line, column) and missing. Nothing is stored.
  • substrio_version_create and substrio_live_set run it themselves unless sent check: false. A failing check refuses with 400, the usual error body and a diagnostics list of the errors. substrio_version_create checks the sources already uploaded (so upload first, or push again); substrio_live_set checks 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"}; a 409 means it exists, so substrio_sandbox_reset {"name": "dev"}.
    • HTTP: POST /v1/_sandboxes {"name": "dev"}, else POST /v1/_sandboxes/dev/reset.
  • Make the version live there:
    • MCP: substrio_live_set {"version": <n>, "sandbox": "dev"}.
    • HTTP: POST /v1/_live {"version": <n>, "sandbox": "dev"}.
  • 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 out sandbox for the app), with optional query, headers and body (or body_base64). It runs in process as a real request: the function routes, then the site, with the sandbox's outbound fence. It returns status, headers and body (body_base64 when the body is not UTF-8). A version's mcp manifest ({"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_logs shows the function logs; substrio_function_errors shows what the functions threw, with stack frames, newest first, and takes revision to show only the errors of one deploy (every error carries the deploy counter, substrio_version_log with the function path reports it per commit); a log line carries no revision, so find a call's lines by its request_id. substrio_sandbox_outbox shows the outbound requests the fence held back.
    • A private host:port is 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 is substrio_sandbox_allow_list, substrio_sandbox_allow_add and substrio_sandbox_allow_remove (the host patterns one sandbox may reach) and read what the fence held back in substrio_sandbox_outbox.

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.

  1. 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.

  2. Write dashboard.json and name it in substrio.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"}]}]}
  3. Check it before you ship: substrio_version_check (POST /v1/_versions/_check) with the version body carrying "dashboard". A fault answers 400 DASHBOARD_INVALID with field naming the path and detail what 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_get with the pushed version answers every nav item and panel, checked, as the console will draw them.

  4. Ship as usual (commit or push, check the sandbox, promote). The dashboard goes live with its version.

  5. 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 a Date as 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_mode is strict, as MariaDB's. Only a session that sets a non-strict sql_mode itself 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_bytes of a file, decoded (640 KiB by default); a larger file goes up in parts, up to mcp_blob_upload_max_bytes (256 MiB by default). Over HTTP, PUT the whole file.
  • The substrio CLI 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 /mcp with 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 with substrio_version_create is a commit substrio_version_log reports.
  • 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).