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 returns the release id and hashes it does not already have. - 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 use GxHash-128 content names with 32 lowercase hexadecimal digits. The CLI computes the same hash as the server. Each upload is hashed once, and stored bytes are compared before an existing name is shared. The release id is derived from the manifest, so deploying identical content produces the same release.
A release can contain up to 20,000 files, with paths up to 512 bytes each. Ten inactive releases are kept for rollback, ranked by when each was last created, redeployed, or replaced. The rest are pruned, so the release just replaced is always kept.
Shipping with no hash (an agent)
An agent can ship a site over MCP without computing a hash. Use version.commit to write files as
site/<path> units in the app's history, with text in text or bytes in base64. Then use version.live.set
to ship the commit. See versioning.md, "Developing over MCP". Upload a file too large for
one call first:
- Uploads with no hash.
site.blob.uploadwithouthash(or HTTPPUT /v1/_site/blobswith raw bytes) names the file using GxHash-128 and returns{hash, size, stored}. A suppliedhashmust have 32 lowercase hexadecimal digits and match 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.
version.commitaccepts the upload's 32-digithashas{path: "site/<path>", id: "<that hash>"}. Returned ObjectIds have 64 digits. Keep the full returned ID when storing or sending it.
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 returns 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 | |
CONTENT_HASH_COLLISION |
409 | Different bytes are already stored under this content name. The response has retryable: false. |
Retrying the same bytes under the same name returns the same refusal. Change the file contents and upload their new content name.
For example, the content name of hello is b72d5f54eba088e98c3218566d5a0719.
printf hello | curl -X PUT \
"https://<app>.<domain>/v1/_site/blobs/b72d5f54eba088e98c3218566d5a0719" \
-H "Authorization: Bearer $SUBSTRIO_KEY" \
-H "Content-Type: application/octet-stream" --data-binary @-
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.
Steps 4 and 6 answer page paths only. A missing file of a known type that is not a page, such as robots.txt, sitemap.xml or logo.png, gets the site's 404.html with status 404, or a 404 with no page. This holds even when the request accepts text/html, as browsers and search engine crawlers do. A single-page app's own routes keep working, because a path with no extension, .html, or an extension of no known type is still a page. Without this rule, a search engine reads every missing file as a copy of the home page.
GET /_/<path> serves public/_/<path> when that file exists; otherwise it serves public/<path>, as /_/ always has.
One address for your site
Search engines index a site that answers at two names as two copies of itself, and its ranking is split between them. When an app holds both names of a www pair, its custom domain is the site's one address. A page fetched on the other name is answered with a permanent redirect to the custom domain, with the same path and query.
| Custom domain | Also attached | A GET on | Answer |
|---|---|---|---|
example.com |
www.example.com |
https://www.example.com/pricing?ref=a |
301, Location: https://example.com/pricing?ref=a |
www.example.com |
example.com |
https://example.com/ |
301, Location: https://www.example.com/ |
example.com |
docs.example.com |
https://docs.example.com/ |
the site, as before |
The redirect covers every GET and HEAD that reaches the site, function routes included. API routes and every other method answer on both names as before. There is nothing to configure. The other name is attached with tenant.domain.attach (POST /v1/_tenant/domains), and the custom domain decides which of the two is the address.
A host that names no app
A host under a root domain whose name matches no app, such as noexist.substrio.com, gets an answer chosen by Accept:
| Request | Answer |
|---|---|
GET / or any page path, Accept names text/html (a browser or a search engine) |
404, Content-Type: text/html; charset=utf-8, the "App not found" page from Substrio |
the same paths, any other Accept (application/json, */*, none) |
404, the typed error TENANT_NOT_FOUND as JSON |
/_/<path>, /_files/<path> and every /v1/ route |
unchanged: their own typed JSON errors |
The page is compiled into the server. It runs no script and fetches nothing. It is always dark, and it follows prefers-reduced-motion. Its status is 404, so search engines index nothing at the name. It is sent with Cache-Control: no-store, so an app created at that name later shows at once, and with the same security headers as the starter page below. An app that is paused or suspended keeps its own answer.
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
Caching uses revalidation or content-specific URLs so a new version can serve updated files:
- 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 command line tools use SHA-256 pins for their image 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).