Full HTTP reference, including zip-site deploys. (← back to README)
The /api/artifacts* and /api/config routes accept either an Authorization: Bearer <key> (a scoped managed key or the bootstrap ARTIFACTS_API_KEY) or a valid admin session cookie (how the dashboard calls them). /mcp is bearer-only. Each write route enforces a minimum scope (below). Reads under /a/ are public unless the artifact's visibility is set.
POST /api/artifacts {content, type: html|jsx|tsx|md|redirect, slug?, title?, description?, ogImage?, tags?, project?, expiresAt?, frame?, visibility?, password?} → 201 {slug, url, visibility} [publish]
POST /api/artifacts/zip raw zip body (?slug=&title=&description=&ogImage=&tags=&project=&expiresAt=&visibility=&password=) → 201 {slug, url, files, visibility} [publish]
PUT /api/artifacts/:slug {content, type, title?, description?, ogImage?, tags?, project?, expiresAt?, frame?, visibility?, password?} → {slug, url, visibility} [publish]
PATCH /api/artifacts/:slug {slug?, disabled?, expiresAt?, description?, ogImage?, tags?, project?, frame?, visibility?, password?, rotateToken?} → {slug, url, visibility} [publish]
DELETE /api/artifacts/:slug → {deleted} [full]
GET /api/artifacts list (?tag= and/or ?project= to filter) → [...] [read]
GET /api/artifacts/:slug/link mint a fresh share link, no mutation → {url, visibility} [read]
GET /api/artifacts/:slug/qr QR code for the canonical URL (?format=svg|png&scale=&margin=) → image [read]
GET /api/config → {frame: {enabled, default}, md: {font, width, size, theme}, baseUrl} [read]
PUT /api/config {frame?: {enabled?, default?}, md?: {font?, width?, size?, theme?}} → updated config [full]
GET /a/:slug rendered artifact, framed when active (public unless private/password)
GET /a/:slug?k=<token> capability-link exchange: sets the unlock cookie, 302s to a clean URL (private/password)
GET /a/:slug?raw=1 bare artifact without the frame
GET /a/:slug/source original uploaded source, text/plain (for a redirect, the stored target)
POST /a/:slug/unlock {password} → sets a per-slug unlock cookie (password mode only)
The [read|publish|full] tag on each route is the minimum key scope required (full implies publish implies read). Admin session + managed-key endpoints (/api/auth/*, /api/keys*) are documented in Auth & API keys.
Semantics:
- Body limits: 10 MB JSON, 50 MB zip.
type: "redirect"publishes a short link instead of a page:contentis the target, andGET /a/:sluganswers301withLocation: <target>,Cache-Control: no-storeandReferrer-Policy: no-referrer. The target must be an absolutehttp://orhttps://URL and cannot carry a username or password; anything else is a400. What gets stored is the normalized URL, capped at 2048 characters, inmeta.target(which the 301 follows) and in the artifact body. Redirects are never framed and ignore?raw=1, andGET /a/:slug/sourcereturns the stored target astext/plain. Full behavior, including what an open redirector costs you, in Redirects.PUTwithouttitlekeeps the title already stored, the way it keepstagsandproject. Send"title": ""to clear it back to the slug.- A write that stores a redirect answers with
target, the normalized URL it stored, which is not always the string that was sent. - QR codes:
GET /api/artifacts/:slug/qrreturns an image of the artifact's canonical URL (<base>/a/<slug>, with the trailing slash for a zip site).formatissvg(default) orpng,scaleis 1 to 16 pixels per module (default 8) andmarginis 0 to 8 modules of quiet zone (default 4). Both take digits only: anything else, including a value out of range, is a400rather than a silent fallback. Rendering a PNG is the only synchronous CPU on areadroute, which is what the scale ceiling is for. The code always carries the permanent link, never a capability link: a?k=token expires and can be revoked, and a printed code cannot be reissued. A scan of a password artifact lands on the unlock page; a private artifact answers404on its bare link, so make it public or password-protected before printing the code. A disabled or expired artifact still has a QR, the same way it still has a share link. Encoding is byte mode at error-correction level M, up to 666 bytes, generated in-process with no external service. - Link previews:
descriptionandogImageare what a chat app shows when someone pastes the link.descriptionis one line, max 300 characters, with runs of whitespace collapsed to single spaces.ogImagemust be an absolutehttp://orhttps://URL and cannot carry a username or password, capped at 2048 characters on the normalized URL; another artifact's URL works, a relative path does not, because the chat app fetches the image from its own base. Anything else is a400, not a silent drop.PUTkeeps both when they are omitted, the way it keepstagsandproject;PATCHwith""clears one. Both rideGET /api/artifacts. The tags render in the viewer frame and in a markdown page, so an html, jsx or zip artifact carries them only while it is framed, and a redirect stores them and renders nothing. Full rules in Link previews. POSTwith an existing slug →409(usePUTto update).- Disabled artifacts return
404; expired ones (expiresAtin the past, or holding a value that cannot be read as a date at all) return410. Both keep their content — re-enable or clear/extend the expiry to serve again. - Tags: an array of strings, or one comma-separated string (the only form the zip endpoint's
?tags=accepts). Each tag must match[a-z0-9][a-z0-9-]{0,31}; max 10 per artifact. Input is lowercased and deduplicated.PATCHreplaces the whole list; an empty list clears it.PUTwithouttagskeeps the existing ones. Artifacts published before tags existed list as"tags": []. In the web UI, tags render as chips — click one to filter the list. - Project: a single grouping label (one per artifact), distinct from tags. Unicode letters/digits, spaces, and
-_., starting with a letter or digit, max 64 chars; internal whitespace is collapsed and case is preserved. Matching (?project=and UI grouping) is exact and case-sensitive —Acmeandacmeare different projects.PATCHsets it; an empty string clears it.PUTwithoutprojectkeeps the existing one.GET /api/artifacts?project=<name>returns only that project's artifacts (an empty?project=is ignored, not a filter for "no project"). The web UI groups the list into collapsible sections per project (with a search box across project / title / slug / tags / a redirect's target).
GET /a/:slug can wrap the artifact in a slim top frame (title + copy-link + hide toggle) that loads the artifact in an iframe. ?raw=1 returns the bare artifact — it's the URL the frame's iframe points at, and the escape hatch for embedding. Redirects are the exception: they are never framed, and ?raw=1 still answers the 301.
Whether an artifact is framed resolves as config.frame.enabled && (meta.frame ?? config.frame.default):
GET/PUT /api/configmanage the global config:{frame: {enabled, default}}(both booleans) plus the four markdown render settings documented in Markdown render settings.enabledis the master switch;defaultapplies to items with no per-item value.PUTaccepts a partialframeormdobject and merges it. The optionalFRAME_ENABLED/FRAME_DEFAULTenv vars (both defaulttruewhen unset) supply the values while no config has been saved. The server writes nothing on boot.config.jsonappears the first time aPUTis accepted, which keeps the git backend from committing on every startup. It is a reserved key in whatever backend you run, landing atDATA_DIR/artifacts/config.jsonon the local one. APUTwrites all six fields at once, so once the server has saved it the env vars stop having any effect. Edit that file by hand and the rule is per field: anything missing or invalid falls back to the env var, then to the built-in default.- Per item, the
framefield onPOST/PUT/PATCHistrue(always framed),false(never framed), or — viaPATCH {"frame": null}— cleared so the item inherits the global default.
GET /api/config also returns baseUrl, the BASE_URL the server builds artifact links from. It is not config and PUT ignores it: the dashboard needs it because the origin an operator opens the dashboard on is not always the origin artifact links use.
When the frame is globally disabled or off for an item, /a/:slug serves the artifact exactly as ?raw=1 does.
Each artifact has one of three access levels, set with the visibility field on POST / PUT / PATCH (and the set_artifact_visibility MCP tool / artifacts visibility CLI command). New artifacts default to private (set DEFAULT_VISIBILITY=public to restore link-is-access); an overwrite (PUT) or PATCH with no visibility keeps whatever the artifact already had.
public— anyone with the unguessable link views it. The returnedurlis the bare/a/<slug>.private(default) — viewed through a capability link: the write returnsurlwith a?k=<token>grant. Opening it sets a per-slug unlock cookie and302s to a clean URL. Without a valid token or cookie every serve path (/a/:slug,?raw=1,/source, zip assets) returns a byte-identical404— no password, no prompt, no existence leak. No per-artifact secret is stored.password— the link plus a shared password.visibility: "password"requires apasswordfield; the top-level URL returns a prompt that accepts that per-artifact password, sub-resources404until unlocked.
The write response is { slug, url, visibility }, plus target when the artifact is a redirect — url is the tokened capability link for private/password, the bare link for public. Mint a fresh link later without mutating the artifact via GET /api/artifacts/:slug/link → { url }. An artifact whose expiresAt has passed answers 410 there instead of minting a link nobody can open.
Revocation. PATCH /api/artifacts/:slug {"rotateToken": true} bumps a per-artifact epoch, invalidating every issued capability token and every live unlock cookie for that slug immediately; it returns a fresh url. Capability tokens expire on their own after CAP_TOKEN_TTL_DAYS (default 30).
The gate is enforced on all serve paths, so ?raw=1, /source, and zip sub-assets never leak a locked artifact's body. Setting visibility to public or private clears any stored password. Sending password alone (while already in password mode) rotates it. The password is stored only as a scrypt hash — GET /api/artifacts returns visibility and a hasPassword boolean, never the hash or the epoch. POST /a/:slug/unlock (password mode only) is rate-limited to 10 failures per hour per client IP + slug (429 with Retry-After), and scrypt verification runs off the event loop.
Publish a file:
jq -n --rawfile c page.html '{content: $c, type: "html"}' | \
curl -s -X POST https://artifacts.example.com/api/artifacts \
-H "Authorization: Bearer $ARTIFACTS_API_KEY" \
-H "Content-Type: application/json" -d @-POST /api/artifacts/zip with the raw zip as the body deploys a whole static site (HTML + CSS + JS + images) under /a/{slug}/:
curl -s -X POST "https://artifacts.example.com/api/artifacts/zip?slug=my-site" \
-H "Authorization: Bearer $ARTIFACTS_API_KEY" \
-H "Content-Type: application/zip" \
--data-binary @site.zip
# {"slug":"my-site","url":"https://artifacts.example.com/a/my-site/","files":12}The archive is validated before anything is stored:
- must contain
index.htmlat the root (a single shared top-level folder is stripped automatically, sozip -r site.zip my-project/works as-is) - only static-hostable extensions are allowed (html, css, js/mjs, json, images, fonts, audio/video, pdf, wasm, source maps); anything else is rejected with the offending paths listed
- path traversal (
../), absolute paths, and symlinks are rejected - limits: 50 MB zip, 100 MB uncompressed, 2000 files;
__MACOSX/,.DS_Store,Thumbs.dbare ignored
A 404.html at the root of the zip becomes the site's not-found page: any miss under /a/{slug}/ serves it with a 404 status, and sites without one keep the plain-text not found. Locked artifacts are unaffected, they return the standard artifact-not-found page on every path. See formats.
Rename, disable/enable, expiry, and delete all work the same as single-file artifacts. PUT (inline content) is refused on zip sites — delete and re-upload instead. The web UI accepts dropped .zip files. No MCP tool for zips (binary payload) — agents should use the curl call above or the CLI.