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; onPATCH /v1/pages/{id}, one already spent on another page id. Replaying would report success, hand back the first target'surl, and touch nothing; - a key already spent, sent with a changed payload. Everything you author
counts:
htmlorfiles,entryPath,title,css,tags,visibility,expiresAt,projectGroup,recipes,lockfile, and everyassetsentry 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
urlthat 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 a409. 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/bundlesthe 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, beforeexpiresAtandprojectGroupare 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
slugthat means two pages; with one, the loser gets a409. 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:
403PAGE_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.410PAGE_TOMBSTONED— the page was deleted. It is retained for audit and is never reopened;POST /v1/pagescreates a new page, at a new URL. Send it a differentslug: a deleted page keeps its handle, so re-using it is a409.
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.