agentpub publishing
agentpub turns local HTML/static files into a live URL via a three-step API: create → upload → finalize. Base: https://agentpub.io.
When to use / when NOT
Use to put static HTML, CSS, JS, images, PDFs, or other static assets online fast. Do NOT use for server-side compute, databases, or dynamic backends — served sites are static only.
Step 0 — resolve a key and authenticate by default (DO THIS FIRST)
The single most common mistake is publishing anonymously by accident — that creates a throwaway 24-hour site and forces a separate claim. Before the three calls, resolve a key and decide ownership:
- Resolve a key, first match wins: (1)
AGENTPUB_API_KEYenv var; (2)~/.config/agentpub/credentials(file mode0600). - If a key resolves → publish authenticated (send
Authorization: Bearer <key>on the create call). The site is owned at creation: permanent, in the dashboard immediately, no per-page claim, and share-safe (no claim link a recipient could hijack). - If no key resolves: if the user wants to keep the work, acquire one once (see "First use" below) and publish authenticated. Only publish anonymous for a deliberate zero-signup throwaway/trial.
- Anonymous is an explicit choice, never a fallback. Don't quietly create a 24h site because no key resolved — if the user wants to keep the work, acquire a key. Only go anonymous when the user explicitly asked for a throwaway. For durable/automated workflows set
AGENTPUB_REQUIRE_AUTH=1to forbid anonymous entirely (belt-and-suspenders).
Shortcut: the bundled agentpub.sh encodes all of this. ./agentpub.sh publish ./dir publishes owned when a key exists and hard-stops when none does (telling you to login or pass --anonymous) — it never creates a silent/accidental anonymous site. --anonymous is the explicit throwaway; AGENTPUB_REQUIRE_AUTH=1 forbids anonymous even then. MCP-host agents (Claude, Cursor) can instead use the agentpub.io/mcp endpoint, where the host manages auth.
The three calls
- Create — declare each file with its exact byte size. Authenticated by default:
curl -sX POST https://agentpub.io/api/v1/publish \
-H "authorization: Bearer $AGENTPUB_API_KEY" \
-H 'content-type: application/json' \
-d '{"files":[{"path":"index.html","size":1234,"contentType":"text/html"}]}'
Returns upload.versionId, upload.uploads[] (each {path, method:"PUT", url}), upload.finalizeUrl, and the ownership state authenticated/anonymous/expiresAt. Omit the authorization header only for a deliberate anonymous site — anonymous responses also include claimToken, claimUrl, warning.
- Upload — PUT each file's bytes to its presigned
url, sending content-type:
curl -sX PUT '<upload.uploads[].url>' \
-H 'content-type: text/html' --data-binary @index.html
- Finalize — flip it live:
curl -sX POST '<upload.finalizeUrl>' \
-H 'content-type: application/json' \
-d '{"versionId":"<upload.versionId>"}'
Live at https://{slug}.agentpub.io/.
Name the site for humans. Pass "artifact":{"title":"…","description":"…"} on the create body so the owner's dashboard and list_my_sites show a meaningful label (not just the slug) and it's searchable. Omitted? The title auto-derives from the page's <title> (or first <h1>) and the description from <meta name="description"> — so ship a good <title>/meta-description either way.
Name it so you can update it in place (stable addressing)
Pass a name on the create body — an account-scoped handle YOU choose (e.g. "name":"ads-daily"):
curl -sX POST https://agentpub.io/api/v1/publish \
-H "authorization: Bearer $AGENTPUB_API_KEY" -H 'content-type: application/json' \
-d '{"name":"ads-daily","files":[{"path":"index.html","size":1234,"contentType":"text/html"}]}'
- Re-publishing with the same
nameupdates that site in place — you never have to discover, store, or guess the random slug. This is the right way to run any "updates daily/in place" routine: re-derive yournamefrom context each run, no out-of-band slug tracking. - Resolve a name → slug anytime:
GET /api/v1/publish/by-name/{name}(Bearer) →{slug, name}.list_my_sitesalso shows each site'sname. nameis lowercase letters/digits/hyphens, unique per account; doing a fresh create with a name you already own returns409(publish-by-name to update it instead).name≠title:nameis the stable machine handle you address by;title(inartifact) is the human display label. The public URL stays a random{slug}.agentpub.io(random by design — not enumerable);nameis how you find it, not the public link.
Confirm ownership after finalize (surface it loudly)
Every publish response carries the ownership state — check it and report it to the user so an accidental anonymous site is caught immediately:
authenticated: true
anonymous: false
expiresAt: null
If you see authenticated: false / anonymous: true / a non-null expiresAt when the user wanted to keep the site, you published the unsafe path — acquire a key and republish (or claim via the returned claimUrl).
First use — acquire and persist a key once (no browser)
Preferred for headless agents: device pairing. Run ./agentpub.sh pair (or use the /api/v1/pair/* endpoints directly): the agent starts a pairing, the human approves in their browser, and the key is delivered out-of-band on poll — so neither the user code nor the API key ever passes through chat. The deviceSecret stays with the agent and agentpub.sh pair persists the key to ~/.config/agentpub/credentials (0600). For headless integration, run agentpub.sh pair --json — machine-readable events go to stdout (start / poll pending|slow_down / approved with keyId/keyName, never the key), human text to stderr; exit codes: 0 ok, 2 denied, 3 expired, 4 timed out. The email-code flow below remains the fallback when a browser approve link is impractical.
When no key resolves and the user wants to keep their work:
POST /api/auth/agent/request-code{"email":"you@example.com"}→ a 6-digit code is emailed.- User reads the code back to you →
POST /api/auth/agent/verify-code{"email":"you@example.com","code":"482913"}→ returns{"apiKey":"...","accountCreated":true|false}. - Persist it so you never claim again:
mkdir -p ~/.config/agentpub && umask 177
printf '%s\n' "$APIKEY" > ~/.config/agentpub/credentials
chmod 600 ~/.config/agentpub/credentials
- Name the key for the tool holding it:
POST /api/v1/keys{"name":"claude"}(useclaude,cursor,hermes, …) — each tool keeps its own revocable key on the one account (least privilege; revoke one without breaking the others). - Never echo, log, commit, or paste the key into chat history, code, or shared docs — only the
0600file or env. Treat it like a password.
Anonymous (only when explicitly intended)
- No auth → 24h site + a one-time
claimToken/claimUrl. SURFACE theclaimUrlto the user IMMEDIATELY and prominently — it is shown only once and is the only way to keep the site past 24h. Never log or paste theclaimTokenanywhere else.
Updating — full replace vs patch
Address an existing site by slug (or resolve it from your name). Two ways to update:
- Full replace —
PUT /api/v1/publish/{slug}(same body shape as create). Authorize with the ownerBearer(or{"claimToken":"..."}for anonymous). Send a per-filehash(sha256 lowercase hex —shasum -a 256 file) so unchanged files skip upload (returned underupload.carried); only changed files get presigned URLs. Then finalize. Sendhashon every publish incl. the first, or dedup can't compare. - Patch (overlay just some files) —
POST /api/v1/publish/{slug}/patchwith only the files you want to change (same{files:[...]}shape; Bearer or{"claimToken"}). Unmentioned files are kept (carried forward byte-identical); patch never deletes. Then finalize. This is the cheap path for incremental updates — e.g. swapping onedata.jsonwithout resending the rest of the site. MCP:patch_site.
Recurring reports / dashboards that update in place daily
The robust pattern for a page a scheduled agent refreshes (daily report, live dashboard):
- Publish once with a
nameand a staticindex.htmlwhose markup never changes — itfetch('data.json')s its numbers at load and renders them client-side. - Each run, patch only the data:
POST /api/v1/publish/{slug}/patchwith just[{"path":"data.json",...}], then finalize. The design stays byte-identical every day (no drift) and the payload is a few KB. - Read back to compute deltas vs the last run:
GET /api/v1/publish/{slug}/content?path=data.json(Bearer) → current file contents. MCP:get_site_content.
Why this matters: if a model re-emits a large HTML document each run it will drift (spacing, layout, bar heights wobble) — models reconstruct markup rather than reproduce it byte-for-byte. Static design + data-only patch eliminates that. (Generating the HTML with deterministic code also avoids drift — but patching only the data is still cheaper and is what get_site_content + patch_site are built for.)
Tips: agentpub is static hosting only — "live daily numbers" need an external scheduler (your cron/agent runner) calling patch+finalize. And don't bake the page's own public URL into the markup (you don't know the slug until after create) — read location.href client-side instead.
Quick reference
| Action | Call |
|---|---|
| Site status | GET /api/v1/sites/{slug} |
| List my sites | GET /api/v1/sites (Bearer) — includes each site's name + versionNumber |
| Resolve a name | GET /api/v1/publish/by-name/{name} (Bearer) → {slug, name} |
| Patch (overlay) | POST /api/v1/publish/{slug}/patch — change some files, keep the rest — MCP patch_site |
| Read content | GET /api/v1/publish/{slug}/content[?path=&version=] (Bearer) — MCP get_site_content |
| Versions | GET /api/v1/publish/{slug}/versions — each has a vN number — (Bearer or ?claimToken=) |
| Rollback | POST /api/v1/publish/{slug}/rollback {"version":"v2"} or {"versionId":"..."} |
| Delete | DELETE /api/v1/publish/{slug} (Bearer, or {"claimToken":"..."}) |
| List keys | GET /api/v1/keys (Bearer) |
| Mint named key | POST /api/v1/keys {"name":"cursor"} (Bearer) |
| Revoke key | DELETE /api/v1/keys/{id} (Bearer) |
| Enable review | POST /api/v1/publish/{slug}/review {"enabled":true} (Bearer) — MCP enable_review |
| Get feedback | GET /api/v1/sites/{slug}/comments → {slug, approved, comments[]} — MCP get_feedback |
| Mark addressed | POST /api/v1/sites/{slug}/comments/{id}/addressed (Bearer) — MCP mark_addressed |
Enabling review mode turns on a feedback widget on the served page where reviewers leave page-level comments and approve. Comments can target a specific element via an anchor {selector, tag, text}, surfaced by get_feedback so the agent can locate and edit that exact element.
Applying review feedback
Run this loop whenever asked to "apply the agentpub comments for {slug}":
Fetch comments — MCP
get_feedback(slug)orGET /api/v1/sites/{slug}/comments(Bearer). Returns{slug, approved, approvedVersionId, comments[]}. Each comment hasstatus("open"|"addressed"),body(the requested change), andanchor: {selector, tag, text} | null.Filter — work only on comments where
status === "open".Fetch the live page source —
GET https://{slug}.agentpub.io/(or whichever file the comment targets). Strip the review widget before editing: the served HTML of a review-enabled site has a block injected immediately before</body>that begins with<button id="apb-approve-top". Remove everything from that<button>to and including</body>, then append a clean</body>— do NOT bake the widget back into the published source.Apply each change — for comments with an anchor, locate the element by matching
tag+textfirst; useselectorto disambiguate when multiple elements match. For anchorless comments, apply the change to the page as a whole. Edit the in-memory source.Republish — standard three-call flow against
PUT /api/v1/publish/{slug}(Bearer$AGENTPUB_API_KEY): declare files with exact byte sizes and sha256 hashes → upload changed files → finalize.Mark addressed — for each comment you handled: MCP
mark_addressed(slug, commentId)orPOST /api/v1/sites/{slug}/comments/{commentId}/addressed(Bearer). Call after a successful finalize.
Gotchas
sizeis validated for format and limits at create (must be an integer, ≤ 25 MB), but a mismatch between declared and actual bytes is not enforced at upload — still declare exact bytes (wc -c < file) because the manifest is recorded metadata.- Send the
content-typeon the upload PUT — R2 records it and serves it back. - Finalize
409means a declared file didn't upload — re-PUT that file, then re-finalize. 429→ respect theRetry-Afterheader before retrying.- Anonymous sites show a claim badge; claiming (via
claimUrl) removes it.
Full reference: https://agentpub.io/llms.txt and https://agentpub.io/openapi.json.