Static sites

Deploy a directory as a release and serve it with clean URLs, a real 404 page, and cache headers.

Docs menu

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)
  1. 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.
  2. Each missing file is uploaded once (site.blob.upload). A blob must hash to its name.
  3. site.release.activate switches 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.upload with no hash (over HTTP PUT /v1/_site/blobs with the raw bytes) names the file by the SHA-256 the server takes anyway and answers {hash, size, stored}. A given hash is still checked against the bytes.
  • In parts with no hash. A file larger than one MCP call (640 KiB) goes in order with offset and total. With no hash, the first part answers {upload, received, total, complete: false}; every later part carries that upload id; the last answers {hash, size, stored}. A part in parts with neither a hash nor an upload (past offset 0), or an upload no longer in progress (finished, or idle for 10 minutes), is refused naming upload.
  • Committed by id. The hash an upload answers is the object's id: version.commit takes 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:

  1. the file itself
  2. <path>/index.html
  3. <path>.html

For navigations only (the client accepts text/html, sends */*, or sends no Accept):

  1. the nearest ancestor <dir>/index.html below the root, so an app at public/app/index.html owns every path under /app/
  2. 404.html, answered with status 404
  3. 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-cache and an ETag. The browser asks again each time, and a matching If-None-Match gets 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 whose v is that tag names exactly those bytes, so it is sent with Cache-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 like app-3f2a9b1c.js carries no hash by this rule and revalidates, and so does a base64url hash such as Vite's default index-DCEBzBzW.js: a word like settings would pass for one. Tell the bundler to write hex hashes after a dot (Vite with rolldown: hashCharacters: 'hex' and entryFileNames: 'assets/[name].[hash].js', the same for chunks and assetFileNames: '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 @import of 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 .gz is written again from the tagged bytes (gzip, level 9), so a client that accepts gzip gets the tagged page;
  • its .br is 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>
  • HEAD gets exactly the headers GET would (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>.br and <file>.gz are chosen by Accept-Encoding (a q=0 refusal is honoured), with Vary: 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).