Emits

Docs

Publishing & versions

A publish takes an HTML file and stores a frozen artifact served at a URL. This page covers the model behind that: versions, slugs, visibility, and what actually gets uploaded.

Immutable versions

Every publish and every update creates one new immutable version. Prior versions are frozen and retained; nothing is overwritten in place. A publish mints a new page (version 1) at a fresh URL. An update adds a version to an existing page and keeps the same URL.

Plans window how much of that history you can list and restore: Free sees a page's last 5 versions, Pro the full history. The window is read-side only — older versions are never deleted, links you shared keep opening, and upgrading reveals the whole history again.

emit publish ./page.html          # -> page, v1, new URL
emit update pg_abc123 ./page.html # -> same URL, v2

Use emit get <id> to see a page's version history (the last 5 on Free; every version on Pro).

Idempotency

Both publish and update accept --idempotency-key. A retry with the same key returns the same result instead of creating a duplicate version, so an agent can safely re-send a request after a network hiccup. The one case it does not replay is a page that has since gone: a deleted page's key is refused as IDEMPOTENCY_KEY_REUSED and a page under moderation review as PAGE_QUARANTINED, because handing back a cached success for a URL that no longer serves would be a lie the caller cannot see through.

emit publish ./page.html --idempotency-key launch-2026-08-14

--bundle accepts it too, and replays the whole bundle result — the same version, URL and file list.

Use a fresh key per operation. A key is refused rather than replayed when the request under it is not a retry of the one that spent it: a key already used for a single-file publish is refused for a bundle (the two results are different shapes); a key already used for one publish is refused for a publish naming a different --domain, bundle or not, and one already used on emit update is refused for a different page; a key is refused when the payload has changed at all — edited HTML, a different title, a different file list; and a key is refused once the page it published has been deleted, because the URL it would hand back is no longer served. A page that was taken down answers differently — the takedown itself, PAGE_QUARANTINED, because "mint a fresh key and publish somewhere else" is the one remedy a moderation refusal must not offer.

That third one is what a hard-coded key runs into. Without the check you would be told the publish succeeded and handed the URL while the old page went on serving, and no retry would clear it — only waiting out the key's 48-hour life would, which is not a fix, it is two days of a stale page.

A key's result lives for 48 hours. Past that the key is forgotten and the request runs as if nobody had seen it: emit update publishes a new version, emit publish on a slug you already hold answers 409, and emit publish with no slug mints a second page at a new URL. Every real retry — a dropped socket, a timeout, an agent picking a task back up — is minutes old, well inside that window; a date-shaped key you might re-send next week is not a retry.

A --domain you did not pass is never compared: a derived handle is not a choice, so a retry that omits the flag replays as the retry it is. One you did pass is compared against the page the key actually published — so the same key aimed at a different handle is refused rather than replayed back at you with the first one's URL.

For --bundle, a key is recorded only once the publish has fully landed, so a request that failed part-way leaves no key behind and a retry republishes it from the top.

What a key does not do is serialize two requests running at the same time. It answers a retry of a request that already finished; fire two copies at once and both miss the lookup — without --domain you get two pages, and with it the loser gets a 409. Retry after a response or a timeout, not alongside one.

Slugs and URLs

Free pages serve at a per-page subdomain on emits.page:

https://<slug>.emits.page

Pass --domain <slug> to choose the slug; omit it and one is assigned. If the slug you want is taken, publish returns a 409 and the CLI prints the id of the page already holding it, plus the update command to reuse it. For --bundle, the advice is --domain rather than update — see multi-page publishes.

On Pro, a bound custom domain also serves every page at your-domain/<slug>. See custom domains.

A single-file page answers at its own URL and nowhere else. There is one document, so <slug>.emits.page/anything returns a 404 with a link back to it rather than serving that document a second time under another path. One filename is an exception: <slug>/index.html redirects (301) to the page itself, so a document that links to itself that way still arrives. Every other filename 404s — a document that links to siblings by name wants a multi-page publish, where the filenames are real.

Visibility

Every page is public, unlisted, or private. Set it at publish time with --visibility, or change it later without republishing. A publish with no --visibility is unlisted: the URL works for anyone you hand it to, but the page stays out of listings and search. Say --visibility public when you want it found. Changing visibility never requires a republish — and a republish only changes it if you pass --visibility again. Leave the flag off and the page keeps the visibility it has.

emit publish ./page.html --visibility private
emit visibility pg_abc123 public
  • public: served to anyone and discoverable.
  • unlisted: served to anyone with the URL, not discoverable.
  • private: not served publicly.

Tags

Attach tags at publish time (--tag, repeatable) and filter on them in find. Tags are how an agent groups and re-locates its own pages:

emit publish ./page.html --tag launch --tag q3
emit find --tag launch

Multi-page publishes

A single publish can ship more than one page. Pass --bundle and point it at an entry file; the CLI publishes that file's whole directory as one linked unit under a single slug:

emit publish ./site/index.html --bundle --domain handbook
  • The entry file (index.html above) serves at the slug root: https://handbook.emits.page.

  • Every other file in the directory serves at its authored path: site/about.html becomes https://handbook.emits.page/about.html, site/docs/guide.html becomes .../docs/guide.html, and site/assets/site.css becomes .../assets/site.css — served as CSS, not as HTML.

  • A directory URL serves that directory's index.html: site/docs/index.html serves at .../docs/ as well as at its authored .../docs/index.html. Requesting .../docs without the trailing slash redirects to .../docs/ first, so relative links inside that file resolve against the directory rather than one level above it.

  • Links between pages are ordinary relative links (<a href="about.html">, <a href="../index.html">). They resolve exactly as written, because each file is served at the path you authored it at. No rewriting, no absolute URLs.

  • Spaces, accents, and punctuation in filenames work. site/About Us.html serves at .../About%20Us.html, which is what a browser sends for <a href="About Us.html">; the same holds for accented names in either Unicode normalization, and for names containing #, ?, % or &. Each file has exactly ONE canonical URL, so any other spelling of the same path — an extra slash, a ./, a differently-escaped character — redirects to it rather than serving a duplicate.

    Some names are refused instead. Most of them because no link you could write would reach the file: a name containing a control character (a tab or a newline — the HTML parser deletes those out of a link before the URL is even formed), a name containing a backslash (a\b.html, which a browser reads as the path a/b.html), and a name that begins or ends with a space (a browser trims those off a link, so <a href=" notes.html"> asks for notes.html). Spaces inside a name are fine — it is only the ends, and only of that one name, so docs/a b.html publishes and docs/ b.html does not.

    One more is refused for the opposite reason: a name spelled like a percent-escape (About%20Us.html) IS reachable, but a link written as the bare name resolves to About Us.html instead — so publishing both would serve one of them at the other's link and leave this one unreachable. A plain % is fine (100%.html).

    Paths are also capped at 800 bytes, which is a limit of the storage key rather than anything about links.

    A refused publish comes back as a 400 naming the file. The two ways in that read files off your machine apply the same rules first: emit publish --bundle and the web app name the file and stop while the bytes are still local, rather than uploading a folder of images to be told no. Renaming it server-side would be worse: it would break the relative links you wrote, which is the whole promise here.

  • One path, one document. Two files whose paths normalize to the same string (about.html and ./about.html) are refused, and so are two that differ only in Unicode normalization — a decomposed café.html from a Mac and a composed one from anywhere else are two files on disk and one URL here. Publishing both would store both and serve one, leaving the other billed, counted against your size cap, and unreachable under any spelling.

The whole unit is one page as far as the rest of Emits is concerned: it has one slug, one visibility, one version, and a takedown or delete affects all of its pages together.

A few constraints for this first release:

  • A fixed list of formats. .html, .htm, .css, .js, .mjs, .json, .txt, .xml, .webmanifest, .svg, and the images and fonts .png, .jpg, .jpeg, .gif, .webp, .avif, .ico, .woff2, .woff, .ttf and .otf. Everything is served under the right content type, at its authored path. The entry file must be HTML.

    Binaries take a different road to get there: the CLI hashes them, uploads them to a separate endpoint, and then names them in the publish, so their bytes never pass through the API request. You do not have to do anything about this — reference logo.png as you normally would — but it is why the pre-flight lists them apart, and why POST /v1/bundles takes them as an assets manifest rather than as file contents. If you script against the API directly, that road is three requests and is written out in binary assets in a bundle.

    An agent working over MCP walks the same road without the CLI, provided its connection is the local one: publish_bundle takes a dir and the server reads, hashes and uploads the folder itself. The bytes never enter the conversation, which is the point — a tool call is generated text, and no amount of it is a PNG. See MCP.

    The ways in treat an unsupported format differently, and the difference matters if you script against the API: emit publish --bundle skips anything it cannot carry and lists what it uploaded before it uploads it, while POST /v1/bundles refuses the whole publish with a 400 naming the file. MCP does both, along the seam you would expect: a folder given as dir is walked by the same code as the CLI and skips the same way, naming the skips in the result, while an IMAGE or a font handed over inline as files is refused by the tool before a request is made. An unsupported format sent inline is not refused there — nothing inline can carry it correctly anyway — and reaches the API, which answers 400. So a stray README.md is a non-event for the CLI, a note on the result for dir, and a failed request for files and for the API. A bad name on a file it can carry is never a skip: a .css you meant to ship, dropped quietly, is a broken site, so the CLI and the web app refuse it as the API does — including when the offending character is what makes the extension unreadable, so about.html is answered as the space it ends with rather than as a file type nobody supports. A format that was never publishable stays a skip whatever it is called.

    The CLI also never descends into node_modules, dist, build, out, coverage, vendor, target or __pycache__. Point it at a build output directly (emit publish dist/index.html --bundle) and that directory is the bundle root, so its contents publish normally.

  • The dashboard takes a folder two ways. Drag one onto the publish dialog, or click Choose a folder to pick one through the OS dialog. Both stage the whole tree — pages, stylesheets, scripts, images and fonts alike, under the same allowlist as the CLI — and both keep each file's path relative to the folder you gave, so css/site.css publishes at css/site.css and the relative links inside your HTML resolve. The plain choose a file picker is still there for loose files; it just cannot select a directory, which is why the folder affordance is a separate control.

    A folder names the site: the address is pre-filled from the folder's own name, whatever is inside it. Two exceptions, and both leave the field blank — a name we have reserved (docs, admin, app and a handful more), and a drop of several folders at once, which has no single name. Loose files that make up a site are blank for the same reason; a single loose page keeps its filename, because there the filename is the only name there is.

    A blank address is deliberate, not an omission. An address you did not choose is not a target, so a blank one derives a handle server-side — and if that handle is already one of your sites, the publish comes back as a conflict you can act on rather than replacing what is there.

  • Only HTML files are expanded. Recipes are resolved in your .html/.htm; every other file is stored and served byte-for-byte as you wrote it.

  • Assets are seekable; pages are not. Everything that is not HTML is served with Accept-Ranges: bytes, so a client can ask for a slice of a large font or image rather than all of it, and a download interrupted partway can ask for the rest. HTML pages are not: a page is assembled as it is served, so a byte offset into it would not mean what the client thinks it means, and every page is served with Accept-Ranges: none. A browser navigating to a page never asks for a slice. Our CDN may still answer a Range for an already-cached page out of its own copy — what that returns is a window into the same bytes we served, badge and all, not a different page.

  • Service workers are refused. A published page will not serve a script for navigator.serviceWorker.register(). A service worker outlives the page that installed it, and a takedown has to be final.

  • Relative links only. A root-absolute link (/about.html) is treated as a site-root link, not a bundle link.

  • No directory listing. A directory without an index.html has no page of its own — Emits never generates an index of the files inside it.

  • Unknown paths 404. A path the bundle does not contain returns a 404 with a link back to the entry, rather than quietly serving the entry itself.

  • Re-publishing a slug you NAMED updates it in place. Publishing a bundle to a slug you passed with --domain and already own updates it: the entry keeps its URL, a new immutable version is appended (prior versions retained), and added/removed sub-pages are reflected.

  • Re-publishing without a file removes it. A sub-page or asset that is in the bundle you published before and not in the one you publish now stops being served within seconds — its URL starts returning a 404, not the old copy. For an HTML sub-page the copies stored for the version you just replaced are deleted too, so republishing is a real way to take a page back down. For an uploaded asset (images, fonts, anything --bundle uploaded rather than inlined) the file stops being served but the stored object is retained: identical bytes are stored once and shared across paths, versions and bundles, so removing one reference cannot prove nothing else points at it. The bundle's older versions are retained in both cases, and a sub-page dropped by this publish is dropped from the version this publish creates — restoring an earlier bundle version is not offered, so nothing serves those bytes again. The entry page itself cannot be removed by republishing at all: take the whole site down with emit delete.

  • A publish that named no slug never updates. Without --domain the handle is derived from the entry (its <title>, else its filename), and a derived handle is not a target you chose — so landing on one you already own returns a 409 carrying that handle, rather than silently replacing the site there. Pass it back with --domain to update, or pick another to publish a new site. A slug held by a non-bundle page, or by a deleted or quarantined one, 409s as it always did — and for a page that was taken down, another handle is not the way out of that; the case is at /dashboard/moderation.

  • emit update is refused on a bundle's entry page. It writes one page, and a bundle's other pages serve from their own stored copies it would not touch — so it would move the landing page to the new version and leave every sub-page on the old one. Re-publishing is the only whole-bundle revision.

  • Caps: up to 2000 files per bundle. Total size is per-plan: 5 MB on Free, 100 MB on Pro (the same caps apply to a single page's HTML + CSS).

How recipes travel with a publish

Most pages are plain HTML, uploaded and served untouched. This section is only for pages authored with the Shortwind class layer's @recipe shorthand. When you publish such a page from a project that has a recipes/ directory, Emits expands the shorthand to Tailwind server-side at publish time, and the publish carries what the expander needs: your html, the lockfile pinning recipe versions, and only the recipe families the page actually touches. The CLI assembles this for you by walking up from the current directory to find the project. A recipe that is missing expands to nothing and goes out as raw text, so compose only from recipes you actually have. Published pages stay frozen against the versions they shipped with.

Finding and removing pages

emit find --q "launch"    # search your pages
emit get pg_abc123        # metadata + version list
emit delete pg_abc123     # tombstone the page (prompts unless -y)

Deleting tombstones the page so it stops resolving. See trust & safety for how abuse takedowns differ from a user delete.