On this page
Publishing & versions
Every publish is kept forever. Going live is one pointer moving to a version; rolling back is the same pointer moving again. Nothing is ever overwritten.
What a publish is#
A publish uploads a directory, as a gzipped tar or a .zip. Each file is stored by the SHA-256 of its contents, so a file that has been uploaded before — by any site in your workspace — is not stored twice. The set of files becomes an immutable version, and the site’s live pointer moves to it.
colophon publish ./out --name "Q3 report" --slug q3-report --visibility unlisted| Flag | Meaning |
|---|---|
--name | Display name in the dashboard. Defaults to the directory name. |
--slug | The URL path. Defaults to a slug of the name. Pass it explicitly when you intend to update the site later, so a changed name cannot move the URL. |
--visibility | public, unlisted (the default for a new site), password, restricted or private. Left off when updating a site, the level it already has is kept. See Access & sharing. |
--password-stdin | With --visibility password, the password readers type, read from a pipe; a terminal asks for it instead. Never an argument. See Password-protected sites. |
--spa | Turns on the single-page-app fallback; --spa=off turns it off. Left off when updating a site, its setting is kept. |
From the dashboard#
No CLI needed: drop a folder or a .zip on the Sites page, or choose one, give it a name, an address and who can open it, and publish. On a site’s own page, Upload a new version takes the whole site again, as a draft if you tick it. It is the same publish the CLI makes, with the same limits, review and history, and it names you as its publisher. An editor uploads drafts, which a publisher makes live. A folder leaves out what the CLI leaves out (below), and a .zip of one folder publishes that folder’s contents, so a site zipped in Finder or Explorer arrives with its index.html at the root.
Updating a site#
Publish again with the same slug. The URL does not change; a new version is created and made live. Every earlier version stays listed on the site’s page with a Make live button — rollback is instant, because it is a pointer change and not a re-upload. An agent has the same button: colophon make-live, POST /api/v1/sites/:id/live, or the MCP make_live tool, each with the same rules — the publisher role, and never a version held for review or rejected in it.
Each version also says what it changed against the one published before it — +2 ~1 −1: files added, changed, removed — or identical when a republish moved nothing. Every file is stored once by its content hash, so this is known without reading anything back. The line opens a side-by-side view of the files that differ, and Compare with live on an older version shows what making it live would change, with the button to do so beside the answer. Images show both versions side by side; other files that are not text, or over 1 MB, show their sizes and a link to each version.
Drafts and previews#
colophon publish ./site --slug report --draft
# → https://p<version id>.usercontent.colophon.fyi/report/A draft is a version that is stored, scanned and addressable, but not live: the site’s URL keeps serving what it served, and the site’s name and visibility stay as they are. The preview URL is a host of the version’s own, p and its id without the dashes, with the site at the path it lives at. So a page that names its own files by a root-relative path (/report/style.css rather than style.css, as built apps and most static site generators write them) gets this version’s files, and a built single-page app draws as it will live. A URL written with the full origin — a generator’s baseURL or site, a canonical tag, a redirect to the live address — still reaches the live site, so build what you want to preview with root-relative paths. A preview of a private site asks for the same sign-in the site does, and a password site’s preview for its password, once for each preview, since each is an address of its own; no preview is ever indexed, or kept by a shared cache. Send it to whoever needs to approve; when they do, Make live on the site’s Versions list is the same pointer flip as a rollback, and so is colophon make-live with the command the draft’s publish printed. A workspace that posts to a channel when a site goes live posts then, not when the draft is stored.
Every kept version has the same kind of URL, so an older version can be looked at without making it live again. A first publish as a draft creates the site with nothing live yet. Preview addresses of the older form, …/report/_v/<version id>/, handed out before previews had hosts of their own, still work: opening one sends you on to the version’s own host, at the same page.
Review and approval#
colophon publish ./site --slug report --review --note "New Q3 numbers"
# → https://p<version id>.usercontent.colophon.fyi/report/ (in review, not live)A version can be sent for review: a draft, or an earlier version someone wants put back. The workspace’s publishers and owners are mailed, and the workspace’s channel is told. On the site’s page, the Review panel shows the version in review with its preview, a comparison with what is live, and two buttons: Approve and make live and Request changes. Approving is the same pointer flip as Make live, with the same rules and the same notice on the channel, naming the approver. Requesting changes needs a note saying what; whoever sent the version is mailed it, and an agent reads it with colophon versions or the MCP get_review tool.
One version per site is in review at a time. Sending a newer one replaces the request, and the old one is recorded as superseded, so an agent’s loop is: publish with --review, read the note and the comments, fix, publish with --review again. Reviewers are mailed once per round, not once per version.
On a restricted or private site, the people who can comment can comment on the preview of the version in review, select text and Comment, as on the live page. Those threads are pinned to that version: they read as current there, and as outdated on the live page until it is approved. Public, unlisted and password sites take no comments, since nobody there is signed in; the note on Request changes carries the feedback instead.
A restricted site can also be approved from outside the workspace, by the people its list names at Can approve: a client, say. They are mailed with the publishers, follow the page’s Review panel to Colophon’s own site and decide there, with a note, and one approval is enough, theirs or a publisher’s. The Review panel, colophon versions and get_review mark their decisions as an outside approver’s.
| Who | Can |
|---|---|
| Editor and up, or an API key | Send a version for review. |
| Commenter and up, or an API key | Request changes, with a note. |
| Publisher and up, signed in as themselves | Approve, which makes the version live. Never an API key: approval is a person’s sign-off. |
| An outside approver, on a restricted site | Approve, or request changes, from the page’s Review panel, on Colophon’s own site, with a note. Only the version in review. |
Requiring approval#
An owner can turn on Approval before going live under Members & settings. From then on, a version goes live only by approval. A publish from an API key, the CLI, an MCP connection or any member below owner is stored as a draft and sent for review instead of going live, and says so; Make live is for owners only; and below owner, nobody approves a version they sent themselves. An agent can only propose: what goes live is a person’s call. A publish that would change an existing site’s visibility, expiry or password is refused while the rule is on, since a version in review leaves the site as it is.
Owners are not held to it. An owner can turn the rule off, so holding them to it would only slow them down, and a workspace with one owner would have nobody else to approve. To keep your own agent to proposals, connect it with an API key, or have it publish with --review. Turning the rule on or off is recorded in the audit log.
Expiry#
curl … -F expires_at=2026-10-01T00:00:00Z # on publish
curl -X PATCH … -d '{ "expires_at": null }' # clear it laterA site can be given a moment after which it stops answering. Agents produce a great deal of output that is only needed for a while, and this is how it stops needing to be remembered. Past the moment every path answers 410 Gone, previews included, and caches are told beforehand not to hold a page past what is left. For thirty days nothing is deleted: the versions stay, the row stays, and the date can be pushed out or cleared from the site’s page or the API to bring it back. After that the site and its versions are deleted, and the storage they held is freed.
Omitted on a republish, the expiry stays as it was; a draft never touches it. Publishing something already expired is refused, since it is never what anyone meant; dating a live site into the past through the API is deliberate, and takes it down without deleting it for those thirty days.
What gets uploaded#
Every regular file under the directory, with paths preserved. The CLI skips .git .env node_modules .next .venv __pycache__ .DS_Store and macOS ._* resource forks, and so does a folder dropped on the dashboard. The server drops the same junk, Finder’s __MACOSX folder included, and refuses anything unsafe: absolute paths, .. segments, symlinks. A .zip is read for stored and deflated files, the kinds every zip tool writes by default; an encrypted one, or one compressed some other way, is refused with what to do instead.
| Limit | Value |
|---|---|
| Per file | 25 MB; 100 MB on Pro |
| Per version | 200 MB, 2,000 files; 1 GB on Pro |
| Publishes | 10 per minute, 200 per day, per workspace |
Bare files are made readable at publish time. Every .md gets an .html rendered beside it, with links between Markdown files rewritten to match; a README.md or index.md stands in for a missing index.html in its directory; and a site root with no index gets the one page it has, or a file browser: every file listed beside a preview of the one selected. The original files stay at their own URLs. Ship your own x.html beside x.md to keep the render off.
How it is served#
- HTML pages get a small analytics beacon injected at publish time — see Analytics.
- On the free plan, a public site carries a one-line “Published with Colophon” footer under the content of every page, added at publish time. Unlisted, password, restricted and private sites never do.
- A page without its own link preview gets one at publish time, so a link pasted into Slack, Teams or a mail unfolds with a title, a description and a card naming the page, its site and its workspace. Only what is missing is added: your own
og:andtwitter:tags are kept, and a page with its ownog:imagekeeps it. The card shows only while the site is public or unlisted; a password, restricted or private link unfolds as Colophon and nothing more. - Public sites are cacheable, and indexable only once an owner allows search engines (below). Everything else is served with
X-Robots-Tag: noindex, nofollow. - Restricted and private sites require a signed-in visitor, and password sites the password; see Access & sharing.
Single-page apps#
A React, Vite or Vue build is one index.html and a router that draws every other page in the browser. Without help, a link to /users/42, or a refresh on it, finds no file there and is a 404. Turn on the single-page-app fallback and a page path with nothing published at it is answered with the site’s root index.html, exactly as stored, with a 200, and the router takes it from there.
colophon publish ./dist --slug app --spa
colophon spa app offIt is off for a new site. Pass --spa (the spa field of the API or the MCP publish tool) when publishing, or change it afterwards without publishing: colophon spa <site> on|off, PATCH with {"spa": true}, the set_spa tool, or Routing on the site’s page. It is a setting of the site, like its visibility: an owner’s to change once the site exists, and left as it is by a draft.
- Only page paths. The fallback answers a path whose last segment has no extension (
/users/42,/settings/), from a browser asking for HTML. A missing/assets/app.jsor/logo.pngis still a 404, so a broken build fails loudly instead of loading a page where a script should be. So is a request for JSON or Markdown. - Your 404.html. With the fallback on, a page path gets the app, not
404.html; the app’s router shows its own not-found.404.htmlstill answers every other miss. - Never over Colophon’s own.
llms.txt,llms-full.txt, a page’s Markdown copy, previews and the gate answer as they always do. A preview of a version falls back to that version’s ownindex.html. - Build for the address. On a workspace host the site lives under
/<slug>/, so build with that base, e.g.vite build --base=/app/and the samebasenamefor the router; on the site’s own domain it lives at/. A relative base (./) breaks asset paths on any page two levels deep. - Previews of a built app. A preview has a host of its own with the site at the path it lives at,
https://p<version id>.usercontent.colophon.fyi/app/, so the app built for/app/finds its scripts there and its router reads its own paths: a draft, or a version in review, can be clicked through as it will be live, deep links and the fallback included. A preview address of the older form,/app/_v/<id>/, sends you on to the version’s own host, at the same page.
Redirects#
A _redirects file at the root of what you publish moves or rewrites paths, one rule a line, as Netlify and Cloudflare Pages read it. It is read when you publish: a line that is wrong fails the publish with a 400 that names the line, before anything is stored. The rules are kept with the version, so a version always answers the way it did and making an older one live brings its rules back. The file is kept among the version’s files, so a diff shows it, and is never served.
# from to status
/old-page /new-page
/blog/:year/:slug /posts/:year/:slug 302
/docs/* /v2/docs/:splat 301
/app/* /index.html 200
/ /en/ 302!
/chat https://chat.example.com/ 307| Part | Reads |
|---|---|
from | A path in the site. :name matches one segment; a final * matches the rest, and :splat is what it matched. A trailing slash makes no difference. No query string, no domain. |
to | A path in the site, or an http(s) URL on another host, using the placeholders its from names. The request’s query string goes along when to has none. |
| status | 301 (the default), 302, 307 or 308 to redirect; 200 to rewrite, answering at the requested address with that file of the same version. A ! after it applies the rule even where a file exists. |
# | A comment, to the end of the line. |
Paths are the site’s own: /new-page is /<slug>/new-page on a workspace host and /new-page on the site’s own domain, and a redirect in a preview stays in the preview. Rules are read top to bottom and the first whose from matches is the only one used. A file published at the path wins over it, unless its status ends in !, which is what makes /* /index.html 200 a fallback rather than an answer to every script; rules also come before the single-page-app fallback. A rewrite can only answer with a file of the same version: never another site’s, never _redirects. On a password, restricted or private site the gate comes first, so a rule sends on only a reader it has let in.
At most 1,000 rules and 256 KB, and at most 100 different addresses on other hosts. Not supported, and refused by name: query-parameter matching, conditions (Country=, Language=, Role=), proxying to another host with 200, 404 and other statuses, and a whole domain as from. A target is never javascript: or data:, never protocol-relative (//host) or written with a backslash, and never carries a user@ before its host. A redirect to the very address it matches (/docs /docs/, say, since a trailing slash makes no difference) would loop, and is refused too.
Another host. An address on our domain that bounces somewhere else is how a phishing link gets past a filter, so every URL target is checked with Google Safe Browsing when you publish, with the pages’ own links (below). A flagged one holds the version for review, as a flagged link in a page does. A publish without an account may only redirect within its own site.
Read by AI agents and search engines#
Hand an assistant like Claude or Codex a link and it can read the whole site, not one page of HTML. Every site answers llms.txt at its root (the llms.txt convention): its title, a summary and every page, each linked to a Markdown copy of itself. llms-full.txt is the whole site’s text in one Markdown file. Any page is Markdown at its own address with .md added, and an agent that sends Accept: text/markdown gets Markdown at the page’s own address. A page published from Markdown links to its source.
https://acme.usercontent.colophon.fyi/q3-report/llms.txt
https://acme.usercontent.colophon.fyi/q3-report/index.html.mdNone of it is stored: it is made when asked, from the version that is live, so every site already published answers it and your files stay exactly as you published them. It goes through the same gate as the pages, so a restricted site’s Markdown is exactly as restricted. An agent holding a key reads any site in its workspace instead through the API or the read_site MCP tool.
None of this puts a site in search results. Every page tells search engines not to index it until an owner allows search engines for the workspace on the Members & settings page, and even then only public sites appear. Nothing lists a workspace’s sites: there is no sitemap. A workspace’s robots.txt lets crawlers in, so they can read that instruction, and an owner can turn AI crawlers away there too. That does not stop a person’s own assistant opening a link they were given.
Scanning and review#
Every version is scanned before it goes live: a phishing heuristic over the HTML, and Google Safe Browsing over every outbound href and src and every URL a _redirects rule sends readers to. A hit does not delete anything — the version is stored but held, the site’s previous version (if any) stays live, and the CLI prints held for review with the reasons. A person reviews held versions and either releases or rejects them.
Deleting a site#
From the site’s page, type its slug to confirm, or colophon delete <slug>. Every version, every sharing grant and the URL itself go at once; links people already hold will 404. Stored bytes shared with other sites are untouched, and unreferenced ones are collected after seven days.