Emits

Docs

The agent API

The CLI is a thin wrapper over a small REST API. Anything the CLI does, an agent can do directly over HTTP: find, publish, update, delete. The surface is deliberately small enough to hold in one prompt. (If your agent speaks MCP, it does not need this page; the same verbs are exposed as tools.)

Base URL and auth

The API origin is:

https://api.emits.app

Override it with the --endpoint <url> flag on any CLI verb, or the EMITS_API_URL environment variable (the pre-rename SHORTWIND_CLOUD_API is still honored). The CLI refuses to send credentials to a non-default origin unless EMITS_ALLOW_CUSTOM_ENDPOINT=1 is also set; the env var makes pointing at a dev, staging or self-hosted deployment a deliberate act instead of a one-flag slip. A non-localhost override must be https. Every request carries a bearer token and JSON:

Authorization: Bearer <token>
Content-Type: application/json

Get a token with emit login (the device grant), or let an agent authorize itself with the authorization-code grant — both are covered in authentication. GET /v1/token introspects the token you hold: 200 { scopes, account } if it is live, a uniform 401 if it is not. The account object is the token's own identity (a stable id, name, and email), which is what emit whoami reports.

Pages

Method Path Body Returns
GET /v1/pages?q=&tag= — { pages: PageSummary[] }
GET /v1/pages/{id} — { page, versions } or 404
POST /v1/pages PublishPayload { id, url, version }, or 409 { existingId }
POST /v1/bundles BundlePayload like publish, plus the file list
POST /v1/bundles/begin-upload { assets, htmlBytes?, intent?, idempotencyKey? } { batches, expiresAt, assetCount }, { alreadySpent: true }, or 409
POST /v1/bundles/upload binary batch frame { stored, deduped, failed }
PATCH /v1/pages/{id} UpdatePayload { id, url, version }
PATCH /v1/pages/{id}/visibility { visibility } updated PageSummary
DELETE /v1/pages/{id} — tombstone lifecycle

GET /v1/pages filters with q (free text) and tag (repeatable; pages must carry all of them).

{id} is a ref: a page id, the page's slug, or the label in its URL. It resolves only within the calling token's account, so an identifier this account does not own is 404 — indistinguishable from one that never existed.

Publish

POST /v1/pages
{
  "html": "<h1>Launch notes</h1>",
  "slug": "launch-notes",
  "visibility": "public",
  "tags": ["launch"]
}

200 OK
{ "id": "pg_…", "url": "https://launch-notes.emits.page", "version": 1 }

html and lockfile are required. slug, tags, visibility, css, recipes, and idempotencyKey are optional. UpdatePayload is the same shape minus slug (the URL is fixed to the existing page). What a repeated idempotencyKey replays — and what it refuses — is under idempotency keys.

A slug collision returns 409 with a top-level existingId so the caller can switch to PATCH /v1/pages/{existingId}. One case where that switch does not work: if existingId is a bundle's entry page, the PATCH is refused with a 400 carrying "code": "BUNDLE_ENTRY_NOT_UPDATABLE" and the bundle's slug, because a bundle is revised as a whole (see bundles). (POST /v1/bundles conflicts carry two extra fields and a different recovery — also under bundles.)

The lockfile field

lockfile pins the recipe versions a publish expands against (see publishing). It is required on all three write routes — POST /v1/pages, PATCH /v1/pages/{id} and POST /v1/bundles — and it is checked in full:

{
  "version": 1,
  "registry": "default",
  "families": { "card": { "version": "0.4.0", "sha": "deadbeef…" } }
}

All three fields are required, unknown fields are refused, and each entry in families must be exactly { version, sha } with both as strings. A page that uses no recipes — most pages — sends an empty map: "families": {}. The CLI assembles all of this from your project's lockfile.

A lockfile that does not match this shape is a 400 with BAD_REQUEST and a message naming the field at fault, before anything is published.

Idempotency keys

idempotencyKey is optional on all three write routes — POST /v1/pages, PATCH /v1/pages/{id} and POST /v1/bundles. A retry under the same key returns the result the first call produced instead of publishing again: for a page the same id, url and version; for a bundle the whole result, files included. The key namespace is per account and shared across the three routes — one key, one result.

POST /v1/bundles/begin-upload accepts the key too, but it neither spends one nor produces a result: it only reports whether the key already has one, so a retry can skip re-uploading a bundle's binaries. See binary assets in a bundle.

When sent, the key must be a non-empty string of at most 256 characters. Surrounding whitespace is trimmed, so " k " and "k" are one key; an empty or whitespace-only one is a 400 rather than a silently unkeyed publish.

A key's result is kept for 48 hours, counted from the moment it was stored and enforced on every read of the key, so the cutoff does not drift. Past it the key is forgotten — including a request the same key would have refused — and what happens next is whatever that request would do unkeyed:

Request After 48 hours
PATCH /v1/pages/{id} publishes a new version
POST /v1/pages with a slug 409 CONFLICT on the slug it already took
POST /v1/pages without a slug a second page at a newly minted URL
POST /v1/bundles with a slug publishes a new bundle version
POST /v1/bundles without a slug 409 CONFLICT (needs-explicit-slug)

Every honest retry happens inside the window; a caller still holding a key two days later has long since stopped waiting on the response it names. A key is not a substitute for a slug — if two calls must land on the same page, name it.

A replay is only honest when the request under the key is a retry of the one that spent it. When it is not, the request is refused with a 400 carrying the code IDEMPOTENCY_KEY_REUSED — a distinct code because the remedy is unlike every other 400 here: change nothing, mint a fresh key, re-send the same payload. Four requests are refused rather than replayed:

  • a key already spent on a single-page publish, sent to POST /v1/bundles, and the reverse. The two results are different shapes, so replaying either as the other returns a success carrying none of the fields the caller reads;
  • a key aimed at a different target: on either publish route, one already spent and re-sent with another explicit slug; on PATCH /v1/pages/{id}, one already spent on another page id. Replaying would report success, hand back the first target's url, and touch nothing;
  • a key already spent, sent with a changed payload. Everything you author counts: html or files, entryPath, title, css, tags, visibility, expiresAt, projectGroup, recipes, lockfile, and every assets entry by path and digest. The write records a digest of the request the key answered and compares it. This is the case a hard-coded key falls into: without the check an edit is reported as a success while the previous bytes go on serving until the key ages out two days later;
  • a key whose page or bundle has since been deleted. The cached result promises a url that is being served, and that one is not. A fresh key is the way forward — but not on the same slug: a deleted page keeps its handle, so re-publishing there is a 409. Send the new key with a different one.

A key whose page was taken down is not in that list. It is refused as the takedown it is — 403 PAGE_QUARANTINED, not 400 IDEMPOTENCY_KEY_REUSED — because "mint a fresh key and publish under another slug" is the one remedy that must not be offered there. See writing to a page that is not active.

The slug is the one authored field that is not in that digest, on either route. It is compared on its own instead, which is what lets the two cases be told apart: a slug you did not send carries no choice to disagree with, so a retry that omits it replays normally, while a slug you did send is checked against the page or bundle the key actually produced. Folded into the digest it would be neither — a retry that merely dropped the flag would be reported as changed content, and the remedy that message gives you publishes a second copy.

Rows written before the digest existed carry none and replay on the key alone, as they always did. The slug comparison still applies to them, because it reads what was published rather than a hash that was never stored.

Two limits on what a key is for:

  • On POST /v1/bundles the key is recorded only after the publish has fully landed, so a request that failed part-way leaves no key behind and a retry republishes from the top. On the page routes it is recorded as part of the publish itself, before expiresAt and projectGroup are applied — so a replay re-applies those two rather than trusting the row, and a first attempt that died in that last step still ends with the expiry it asked for.
  • A key does not serialize concurrent requests. It answers a retry of a request that already finished; two copies in flight at once both miss the lookup. Without an explicit slug that means two pages; with one, the loser gets a 409. Retry after a response or a timeout, not alongside one.

Bundles

POST /v1/bundles publishes a linked multi-page unit in one request: files (an array of { path, html }), entryPath naming the entry file, and the same optional slug / tags / visibility / idempotencyKey as publish. The model and its constraints are covered in multi-page publishes.

A field the API does not recognise on a files[] or assets[] entry is ignored, not refused, so a client built against a newer version of this API still gets the publish it asked for. That is the opposite of lockfile and recipes, where an unknown field is a 400 naming it — those two are authored by hand, where a stray key is a typo worth reporting; the file and asset manifests are assembled by the publishing tool.

The response differs from single-file publish:

200 OK
{
  "bundleId": "handbook",
  "entryPageId": "pg_…",
  "url": "https://handbook.emits.page",
  "version": 1,
  "files": [{ "path": "about.html", "artifactKey": "…", "sourceHash": "…", "entry": false }, …]
}

bundleId is the entry page's slug, which is why entryPageId is sent alongside it: that is the id to pass to PATCH /v1/pages/{id}. (Either works now that {id} takes a ref, but only entryPageId is stable across a slug change.)

In the RESPONSE, files[] is the siblings — unlike the request's files, which must contain the entry document itself — and it never contains the entry. The entry is url and entryPageId, because it is a page rather than a bundle file. So the whole unit is files.length + 1 served paths, and a client rendering "what went out" has to add the entry itself. (Paths, not documents: files[] also carries one row per uploaded binary asset, and a .png is a served path rather than a page.) The entry field on each element is vestigial: it is always false, kept because it is stored on every version row ever written. Do not branch on it.

Updating a bundle, and the two conflicts

A bundle is updated by re-publishing it: POST /v1/bundles with a slug your account already holds a live bundle at appends a new version behind the same entry page and the same URL. PATCH /v1/pages/{entryPageId} is not that, and is refused: it writes the entry document alone, which would leave the siblings on the previous version. It answers 400 with "code": "BUNDLE_ENTRY_NOT_UPDATABLE" and a message naming the bundle's slug, so the re-publish can be built from the refusal.

The slug has to be EXPLICIT. Omit it and the handle is derived server-side — from title when one is sent, otherwise from entryPath, so an unnamed bundle entered at index.html derives index. A derived handle is not a target the caller chose, so it never updates; it conflicts, and says which handle it derived:

409 Conflict
{
  "existingId": "pg_…",
  "reason": "needs-explicit-slug",
  "slug": "index"
}

Re-send the same request with "slug": "index" to update that bundle, or with a different slug to publish a new one.

The other shapes say the handle belongs to a page of yours that is not a live bundle, and which kind of page it is — naming it will not help in any of them:

  • "reason": "slug-taken" — an ordinary page of yours. Pick another handle.
  • "reason": "slug-under-review" — the page holding it is under moderation review. Picking another handle is not the way out of a takedown; see the moderation list and writing to a page that is not active.
  • "reason": "slug-deleted" — the page holding it was deleted. A deleted page keeps its handle, so pick another one.

Both fields are optional on the wire: a 409 without a reason is the plain taken-slug case, and a client that does not know a reason should fall back to treating it as one.

idempotencyKey replays the whole bundle result — the same version, url and files. See idempotency keys for what is refused instead of replayed.

Binary assets in a bundle

files carries text. Images and fonts (.png, .jpg, .jpeg, .webp, .avif, .ico, .gif, .woff2, .woff, .ttf, .otf) do not survive a JSON string, so they take a separate road and are named in the publish by content hash. Three steps, in order:

1. Declare them. Send every binary as { path, sha256, bytes }, where sha256 is the lowercase hex SHA-256 of the file's bytes. htmlBytes is the size of the inline half of the same publish; sending it means an oversize bundle is refused here, before anything uploads.

intent is the same idea for the other refusal a publish can earn: it is what step 3 will ask for — { entryPath, slug?, title? }, the same three fields — and sending it means a handle conflict comes back HERE, as the same 409 step 3 would have answered with, before a byte is uploaded. It is optional and advisory: sending nothing changes nothing, and a 200 here is not a promise about step 3, which re-checks everything under its own transaction. Send exactly what you will publish — an intent that disagrees with the payload asks about a handle your publish never takes.

POST /v1/bundles/begin-upload
{
  "assets": [{ "path": "logo.png", "sha256": "9f86d0…", "bytes": 20481 }],
  "htmlBytes": 4096,
  "intent": { "entryPath": "index.html", "title": "Launch notes" }
}

200 OK
{
  "batches": [{ "grant": "eyJ2Ijox….sig", "sha256": ["9f86d0…"] }],
  "expiresAt": 1770000600,
  "assetCount": 1
}

idempotencyKey is the key step 3 will carry, and sending it saves the other wasted upload: the retry of a publish that already landed. When a result is already stored under that key the answer is a 200 with

{ "alreadySpent": true }

and no plan at all — skip step 2 and go straight to step 3 with the same key and the same manifest, and let it answer. This is a fact about the key, not a verdict on your retry: begin-upload never sees the HTML, so step 3 is still what decides, replaying a true retry and refusing a key reused across an edit with IDEMPOTENCY_KEY_REUSED. Only a caller that sends a key can receive this answer.

A 409 here is the bundle conflict described under the two conflicts, with the same existingId / reason / slug at the top level — the only difference is that nothing was uploaded to earn it. A spent key is answered ahead of it: retrying a publish that already landed is not a conflict with itself.

The server decides the batching. Each grant authorizes exactly the digests listed beside it and nothing else, so a batch must be sent as given: a partial re-send is a different set and is refused with 403 BATCH_NOT_AUTHORIZED. Send the batch whole on a retry — the objects that already landed are recognised and dedupe, which is what makes that cheap.

2. Upload each batch to POST /v1/bundles/upload on api.emits.app, with its own grant in the Authorization: Bearer header. The body is one frame:

[u32be manifest length][manifest JSON][file bodies, concatenated in order]

where the manifest is [{ sha256, bytes, contentType }, …] in the same order as the bodies. A 200 reports { stored, deduped, failed }; failed is per index and is retryable (re-send the whole batch). A body that does not hash to its declared sha256 fails the WHOLE batch with 422 and stores nothing.

You never name a key. The object's location is derived from your account and the content hash, so an upload cannot reach another account's storage or overwrite anything but identical bytes.

3. Publish, sending the same manifest as assets alongside files:

POST /v1/bundles
{
  "files": [{ "path": "index.html", "html": "<img src=\"logo.png\">" }],
  "assets": [{ "path": "logo.png", "sha256": "9f86d0…", "bytes": 20481 }],
  "entryPath": "index.html",
  "lockfile": { … }
}

Nothing is reachable until this step commits: uploaded bytes sit at addresses no URL points at. Assets count toward the same 2000-file and per-plan size limits as inline files, and identical bytes at two paths count once — against the size cap and against your stored total.

Grants expire ten minutes after they are minted (expiresAt, Unix seconds). After that the upload endpoint answers 401 GRANT_EXPIRED; call begin-upload again with the same manifest and re-send the batches that had not landed. The plan is a pure function of the manifest, so the renewed batches match the original ones position for position. emit publish does this for you once.

One path may be declared only once across files and assets together, and begin-upload refuses a repeat itself so the answer arrives before the upload rather than after it.

What bytes is for. It is read at step 1, where it is checked: the upload endpoint slices each body by its declared length before hashing it, so a wrong length shifts every following slice and the digests stop matching. The bytes you repeat at step 3 is ignored — your account is metered at the size recorded when the upload was authorized. A publish naming a digest this account never uploaded is refused with ASSET_NOT_UPLOADED; publish again and the missing bytes go up with it.

Uploads you abandon still count. Between the upload and the publish that references it, an object counts toward your stored total — it is in the bucket either way. If no publish ever references it, it is deleted about a day later and stops counting. begin-upload is also rate-limited per account — 30 mints a minute, bursting to 10 — and a 429 carries Retry-After. If enough uploads are outstanding at once, begin-upload answers TOO_MANY_PENDING_UPLOADS to a mint that would add more — retrying the same publish is always admitted, because its files are already on the ledger. They age out on their own, so waiting is the fix.

Files a takedown removed stay removed. An asset's address is its own content hash, so republishing the same bytes would otherwise produce the same object and undo the moderation action. Both begin-upload and publish refuse a digest a takedown removed, with CONTENT_BLOCKED naming the path — retrying cannot help, and the only fix is to drop the file. Most blocks apply to the account the page was taken down from; only content that is illegal in itself is blocked for everyone. Clearing the case lifts the blocks it created.

Domains

Account-level custom domains (Pro). See custom domains.

Method Path Body Returns
POST /v1/domains { hostname } DomainBindResult (needs domains:bind)
GET /v1/domains — { domains: AccountDomain[] }
POST /v1/domains/approve { hostname } DomainBindResult

Authentication

Two OAuth grants issue tokens: the device grant (how emit login works) and the authorization-code grant with PKCE (how an agent authorizes itself, with a human approving in a browser). Tokens are scoped (pages:read, pages:write, domains:bind) and short-lived, renewed with a rotating refresh token. Discovery documents live at /.well-known/oauth-authorization-server and /.well-known/api-catalog.

The full model (endpoints, scopes, token lifetimes, dynamic client registration, and revocation) is on the authentication page.

Errors

Responses map to typed error kinds by status: 401 unauthorized, 403 forbidden (a missing scope, or a page under moderation review — see below), 404 not_found, 409 conflict (carries existingId, and for /v1/bundles a reason and slug — see bundles), 410 gone (the page was deleted), 413 payload too large (the page or bundle is over the plan's size cap — 5 MB on Free, 100 MB on Pro), 429 too many requests (rate limit or monthly publish quota; carries Retry-After), 507 insufficient storage (the account has reached its retained-storage cap — 100 MB on Free, 10 GB on Pro), plus network and generic http errors. Error bodies use the shape { error: { code } }.

Writing to a page that is not active. PATCH /v1/pages/{id}, PATCH /v1/pages/{id}/visibility, PATCH /v1/pages/{id}/password and POST /v1/pages/{id}/restore refuse a page whose lifecycle has moved on, and so does a replayed Idempotency-Key on a quarantined page. DELETE /v1/pages/{id} refuses a quarantined page too; deleting an already deleted one is 400 INVALID_TRANSITION, not one of the two codes below. (A replayed key on a deleted page is refused as 400 IDEMPOTENCY_KEY_REUSED instead — see idempotency keys.) The code says which way to read it:

  • 403 PAGE_QUARANTINED — the page is under moderation review. It still exists and nothing is lost; the case, with the reason where one was recorded, is listed at /dashboard/moderation. Retrying cannot clear it, and publishing the same content as a new page is not a workaround for it. To contest the decision there is a real route: the counter-notice in section 5 of /legal/copyright for a copyright claim, or the abuse address on /legal/acceptable-use for anything else.
  • 410 PAGE_TOMBSTONED — the page was deleted. It is retained for audit and is never reopened; POST /v1/pages creates a new page, at a new URL. Send it a different slug: a deleted page keeps its handle, so re-using it is a 409.

Both are terminal for the id they name, which is why they are distinct statuses rather than a shared 400: a client that maps by status alone should stop on either, and should not read a moderation refusal as a token problem.

A bundle body naming a file the server will not serve is a 400 with a code of its own rather than the catch-all, because the two cases have different remedies. UNSUPPORTED_FILE_TYPE carries the offending path and the full allowed list — that format is not servable and never will be, so drop the file or rename it. BINARY_NOT_INLINE carries the path alone and means the opposite: the file is fine, the door is wrong. Send it through /v1/bundles/begin-upload and name it by hash, rather than trying to inline it.

Both are avoidable ahead of time. GET /.well-known/api-catalog declares bundleFormats.inline and bundleFormats.upload, so a client can drop what this deployment does not take before it sends anything. A catalog carrying no bundleFormats is not saying ".html only" — it is a deployment that predates the key, and the right reading is to change nothing and let these two codes answer.

The upload endpoint adds two of its own. A 422 with CHECKSUM_MISMATCH means a body did not hash to the digest its manifest declared — the whole batch is refused and nothing is written, including the entries that did match, because a digest that was never screened must not become an object. It carries indices, naming the entries at fault. A 400 with BAD_BATCH (or a 413 with BATCH_TOO_LARGE) means the frame does not match the grant it was sent under; neither is retryable with the same bytes, and both indicate a client bug rather than anything you did. A 503 means every entry's write failed, which is transient — retry the batch.

Trust and safety

Published pages are static artifacts, but the platform keeps two safety levers.

Abuse reports are unauthenticated and rate-limited:

POST /v1/abuse
{ "pageId": "pg_…", "reason": "…", "category": "phishing" }

202 { "state": "reported" }

category is one of csam, phishing, malware, or other. A report opens a moderation case without pulling the page, and the response is the same 202 whether or not the page exists, so the endpoint is not an existence oracle.

Takedowns are distinct from a user delete. A user delete tombstones a page; an abuse takedown quarantines it, sealing the artifact so it stops resolving — allow up to a couple of minutes for already-cached copies at the edge to age out — while the object and version history are preserved (never hard-deleted). Publishing also runs a content scan at publish time, and each account has a publish rate limit (10/min sustained, burst of 5) plus a monthly publish quota (50 versions on Free, 5,000 on Pro).

A takedown also reaches the bundle's uploaded assets, which the content scan never sees: their digests are blocked from being published again and answer CONTENT_BLOCKED. See binary assets in a bundle.