An app's website is its public directory, deployed as a release with the CLI.
Deploying
substrio site deploy ./dist # hash, upload what the server lacks, activate
substrio site deploy ./dist --dry-run
substrio site releases # list releases, most recently used first; * marks the active one
substrio site activate <id> # switch to any kept release (rollback)
- The CLI sends a manifest of
{path, hash, size}(site.release.create). The server answers with the release id and the hashes it does not already hold. - Each missing file is uploaded once (
site.blob.upload). A blob must hash to its name. site.release.activateswitches the site to the release in one step: every request is served either the old release or the new one, never a mix.
Files are stored by their SHA-256, so unchanged files are never uploaded twice. The release id is derived from the manifest, so deploying identical content produces the same release.
A release holds up to 20,000 files, each path up to 512 bytes. 10 inactive releases are kept for rollback, ranked by when each was last created, deployed again or replaced; the rest are pruned, so the release just replaced is always kept.
Shipping with no hash (an agent)
An agent that only makes plain-text MCP calls never computes a hash. It writes a site as units of
its app's history, site/<path>, with version.commit (text as text, bytes as base64) and ships the commit
with version.live.set (see versioning.md, "Developing over MCP"). A file too large for one call
goes up first:
- Uploads with no hash.
site.blob.uploadwith nohash(over HTTPPUT /v1/_site/blobswith the raw bytes) names the file by the SHA-256 the server takes anyway and answers{hash, size, stored}. A givenhashis still checked against the bytes. - In parts with no hash. A file larger than one MCP call (640 KiB) goes in order with
offsetandtotal. With nohash, the first part answers{upload, received, total, complete: false}; every later part carries thatuploadid; the last answers{hash, size, stored}. A part in parts with neither ahashnor anupload(past offset 0), or anuploadno longer in progress (finished, or idle for 10 minutes), is refused namingupload. - Committed by id. The
hashan upload answers is the object's id:version.committakes it as{path: "site/<path>", id: "<that hash>"}, so the bytes are never sent twice.
Worked example: a large file in parts
{"name": "substrio_site_blob_upload", "arguments": {"app": "shop", "offset": 0, "total": 1500000, "content_base64": "<the first part>"}}
{"name": "substrio_site_blob_upload", "arguments": {"app": "shop", "upload": "<the id it answered>", "offset": 655360, "total": 1500000, "content_base64": "<the second part>"}}
{"name": "substrio_site_blob_upload", "arguments": {"app": "shop", "upload": "<the id>", "offset": 1310720, "total": 1500000, "content_base64": "<the last part>"}}
{"name": "substrio_version_commit", "arguments": {"app": "shop", "branch": "main", "units": [{"path": "site/video.mp4", "id": "<the hash the last part answered>"}]}}
The commit answers its number; substrio_live_set {version: <number>} serves the file.
Refusals
| Code | Status | When | Field |
|---|---|---|---|
INVALID_ARGUMENT |
400 | a later part with no upload, an upload not in progress, an offset or total that does not follow |
upload, offset or total |
STORAGE_QUOTA_EXCEEDED |
507 | the app's storage limit does not hold the file |
Serving
Every GET that no API route or function route claims is resolved inside public/. The first match wins:
- the file itself
<path>/index.html<path>.html
For navigations only (the client accepts text/html, sends */*, or sends no Accept):
- the nearest ancestor
<dir>/index.htmlbelow the root, so an app atpublic/app/index.htmlowns every path under/app/ 404.html, answered with status 404- the root
index.html, so a single-page app keeps working at every path
A request for a missing script or image is a 404, never an HTML page. A path that escapes public/ is refused.
GET /_/<path> serves public/_/<path> when that file exists; otherwise it serves public/<path>, as /_/ always has.
An app with no site
A new app has an empty public/. Until a site is deployed, a navigation that nothing above answers, in a public/ that is missing or holds no entry at all, gets an answer chosen by Accept:
| Request | Answer |
|---|---|
GET / or any page path, Accept names text/html (a browser) |
404, Content-Type: text/html; charset=utf-8, a starter page from Substrio: "This app is live, and empty", one line on deploying, a link to the docs |
the same paths, any other Accept (application/json, */*, none) |
404, the typed error FILE_NOT_FOUND as JSON |
/_/<path> and every /v1/ route, whatever the Accept |
unchanged: their own typed JSON errors |
The starter page is compiled into the server: no script, no external fetch, no font download, dark and light by prefers-color-scheme. It is sent with Cache-Control: no-store (so the first deploy shows at once), Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none', X-Frame-Options: DENY and Referrer-Policy: no-referrer. Its status is 404, as the JSON error it stands in for: the path holds nothing, so crawlers index nothing and monitors see no site.
An app with any file in public/ never gets the starter page: its own 404.html answers a missing page, and with no 404.html and no root index.html a missing page is the JSON FILE_NOT_FOUND.
To replace it, deploy a site, for example:
substrio site deploy ./dist # ./dist/index.html becomes the app's root
or have an agent ship site/<path> units over MCP (see "Shipping with no hash" above). The next request serves the site.
Caching
A browser never keeps an old file after a new version, even one shipped under the same name:
- Every page, and every file whose name carries no content hash, is sent with
no-cacheand an ETag. The browser asks again each time, and a matchingIf-None-Matchgets 304 with no body. - When a release goes live, each reference its pages and stylesheets make to a
/_/file is written as/_/<path>?v=<tag>. The tag is the first 8 hex digits of that file's ETag. A request whosevis that tag names exactly those bytes, so it is sent withCache-Control: public, max-age=31536000, immutable. The next version that changes the file changes its tag, so its pages name a new URL. - A file whose name carries a content hash, as a bundler writes one, is sent as immutable on its own. That means a dot-separated piece of 8 or more hex digits before the extension, such as
app.3f2a9b1c.js. A name likeapp-3f2a9b1c.jscarries no hash by this rule and revalidates, and so does a base64url hash such as Vite's defaultindex-DCEBzBzW.js: a word likesettingswould pass for one. Tell the bundler to write hex hashes after a dot (Vite with rolldown:hashCharacters: 'hex'andentryFileNames: 'assets/[name].[hash].js', the same for chunks andassetFileNames: 'assets/[name].[hash][extname]').
| Request | Cache-Control |
|---|---|
A page, /_/app.js, /robots.txt |
no-cache (304 when unchanged) |
/_/app.js?v=3f2a9b1c, the tag of the bytes served |
public, max-age=31536000, immutable |
/_/app.js?v=<an older tag> |
no-cache |
/_/app.3f2a9b1c.js |
public, max-age=31536000, immutable |
Some references are left as written:
- a link to a page (
<a href>); - a URL that already carries a query;
- a stylesheet's
@importof a stylesheet later in the release; - a reference to a file past
--static-cache-file-bytes(its ETag is its time and size); - a reference to a file whose name carries a content hash (
/_/assets/index.3f2a9b1c.js), which is immutable by its name already. A page or stylesheet with such a name is still tagged, because the release changes its bytes under the same name.
JavaScript module imports are never rewritten: give a module a hashed name for it to be immutable. A served page is the version's page with these tags in it, so its bytes (and its ETag) differ from the unit committed.
A page or stylesheet shipped with precompressed siblings is tagged the same way, and no sibling is served with older bytes than its page. When the tags change the page:
- its
.gzis written again from the tagged bytes (gzip, level 9), so a client that accepts gzip gets the tagged page; - its
.bris left out of the release, because the server has no brotli encoder. A client that accepts brotli and gzip gets the.gz; one that accepts neither gets the plain page.
A page the tags leave unchanged (it names no /_/ file) keeps both siblings as shipped. To keep brotli for a page that names /_/ files, give those files hashed names: the page then needs no tag, and the bundler's .br is served.
<!-- site/index.html as committed -->
<link rel="stylesheet" href="/_/style.css"><script src="/_/app.js"></script>
<!-- as served -->
<link rel="stylesheet" href="/_/style.css?v=9c41e07a"><script src="/_/app.js?v=3f2a9b1c"></script>
HEADgets exactly the headersGETwould (status,Content-Type,Content-Length, ETag) and no body. Crawlers such as Googlebot check a sitemap or page this way before fetching it.- Precompressed siblings
<file>.brand<file>.gzare chosen byAccept-Encoding(aq=0refusal is honoured), withVary: Accept-Encoding. - A new release is served from the next request on.
Tools
An app's own binaries (not its website), named like a site blob: by the SHA-256 of their bytes.
PUT /v1/tools/{name}/{sha256} # tool.install, body is the binary
GET /v1/tools # tool.list
DELETE /v1/tools/{name}/{version} # tool.remove, version is the 16 lowercase hex digits tool.install returned
POST /v1/tools/{name}/run # tool.run, body names args/env/stdin/limits
A name is lowercase letters, digits, - and _, starting with a letter or digit. tool.install answers 201 {name, version, bytes, format, already_installed} on the first install of a given name and version, 200 with already_installed: true on a repeat of the same bytes; a wrong sha256 or a body that isn't a binary in the expected tool format is refused before anything is written. tool.list answers {tools: [{name, version, bytes, format, current, installed_at}, ...]}; the most recently installed version of a name is current. tool.remove deletes one name and version; removing a version never installed is refused. Each install and remove is one audit row; installing and removing is irreversible (no undo).
tool.run runs one installed version (the current one, or the version the body names) in a sandbox, under the app's own tool limits. The body: args (an array of strings, exactly argv[1..], no shell and no splitting), env (an object of strings; the server's own environment is never inherited), stdin (a string, or {"base64": ...} for bytes that aren't UTF-8), and limits (optional per-run overrides, each clamped to min(requested, the app's effective limit); 0 or absent takes the effective value). The reply: status (the exit code; a nonzero status is data, not an error), signal, limit (the name of whichever limit ended the run, or null), wall_us, cpu_user_us, cpu_system_us, max_rss_kb, and stdout/stderr as {"encoding":"utf8","text":...} or {"encoding":"base64","data":...}, each with bytes and truncated. Each run is one audit row (name, version, exit status or limit hit, wall and CPU microseconds; never arguments, environment or output); running is irreversible (no undo).