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

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.

  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 creates a commit and returns its number.
  4. 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.
  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"}.

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 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 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": ...}; removals deletes paths. The response contains 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 returned by the first part). The last part returns 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": "<gxhash128>", "size": 123, "routes": ["GET /hello"]}]}. The result contains version.number and missing (hashes to upload). For each: substrio_site_blob_upload with {"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 most mcp_blob_max_bytes (640 KiB by default). Each part returns received. The last returns 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/<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_check takes one function's source (the substrio_function_deploy body) and returns 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 returns 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. 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.

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

  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". An invalid dashboard returns 400 DASHBOARD_INVALID, with the path in field and the problem in detail:

    {"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 returns the validated nav items and panels as the console will render 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) returns the stored dashboard. Call one panel's route with substrio_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 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 returns error 1406 (Data too long) under the default strict sql_mode. A session that sets a non-strict sql_mode gets 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_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 app users connect their agents to /mcp with 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 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 and run schedules, and view their next fire times and run history.