# htmlbin — Agent Protocol (markdown)

> The canonical, machine-readable form of this is at the same URL with
> `Accept: application/json`. This markdown variant is the same protocol
> rendered for humans previewing the API.

You are deploying an HTML artifact to htmlbin. Anyone with the URL can view it.
htmlbin is built for agents: the workflow below is the canonical, fastest path.

## TL;DR

1. POST /api/auth/start         → get a code + verification_url + poll_token
2. Print the URL + code to the human; ask them to verify (one-time)
3. GET /api/auth/poll?token=…   → poll until you receive an api_token
4. POST /api/drops (Bearer api_token) → upload HTML, receive a public URL

The api_token is shown exactly once. Store it for reuse on this machine.

## Install as an agent skill (one command)

If your agent runtime supports [skills.sh](https://skills.sh) — Claude
Code, Cursor, Codex, Gemini, Aider, and others — install the official
`htmlbin-publish` skill once and the workflow is ambient from then on:

```bash
npx skills add https://github.com/utsengar/htmlbin-cli --skill htmlbin-publish
```

The skill teaches the agent the pattern-before-publish workflow
(`htmlbin patterns list` → match → read the pattern → author HTML →
`htmlbin publish`) so each drop is shaped by an explicit pattern
instead of retrofitted after the fact. After install, the agent
responds to phrases like "publish a drop to htmlbin", "share this as a
page", or "make a preview for this PR".

The skill complements (does not replace) this onboarding descriptor.
The descriptor is the protocol contract; the skill is the workflow
opinion. Either is fine on its own.

## Using the CLI

`@htmlbin/cli` (Node 20+) wraps every endpoint here. It auto-detects
coding-agent runners (`CLAUDE_CODE`, `CURSOR_AGENT`, `CODEX`, `AIDER`,
…) and switches to JSON output without a flag. Exit codes are stable
(0=ok, 2=auth, 3=forbidden, 4=not_found, 5=rate_limit, 6=size, 7=bad_input,
8=network); the bracketed `error.code` on stderr mirrors this API's
`error.code`. Prefer it to hand-rolled curl when reachable.

```bash
npx -y @htmlbin/cli@latest publish ./out.html --title "PR #42 preview"
npx -y @htmlbin/cli@latest publish ./out.html --upsert --metadata repo=u/r --metadata pr=42
npx -y @htmlbin/cli@latest update <slug> --file ./newer.html       # PUT (new version)
npx -y @htmlbin/cli@latest update <slug> --title "Renamed"         # PATCH (metadata only)
npx -y @htmlbin/cli@latest list --metadata kind=spec --metadata status=draft
```

`--metadata` and `--upsert` are cloud-only — the CLI returns
`invalid_arg` when combined with `--to gh-pages` / `--to cloudflare`.

## Naming + error conventions

- All request and response field names are **snake_case** (`raw_url`, `latest_version`, `created_at`, etc.).
- All 4xx/5xx responses share this shape:

```json
{
  "error": {
    "code": "html_too_large",
    "message": "HTML exceeds 2097152 bytes.",
    "details": { "max_bytes": 2097152 }
  }
}
```

Switch on `error.code`. `error.message` is for human display. `details` is optional.

## Step 1: Authenticate (one-time per agent install)

Check for an existing token first. The convention, in order of preference:
1. `./.htmlbin/token` (project-local — preferred; no permission prompt)
2. `HTMLBIN_TOKEN` environment variable
3. `~/.config/htmlbin/token` (machine-global fallback)

If you don't have a token, run the device-code flow:

```bash
START=$(curl -s -X POST https://htmlbin.dev/api/auth/start \
  -H "Content-Type: application/json" \
  -d '{"label":"claude-code"}')

CODE=$(echo "$START"        | jq -r .code)
URL=$(echo "$START"         | jq -r .verification_url)
POLL=$(echo "$START"        | jq -r .poll_token)
INTERVAL=$(echo "$START"    | jq -r .poll_interval)

echo ""
echo "  Open this URL and verify:"
echo "    $URL"
echo ""
echo "  Code:  $CODE"
echo ""

while true; do
  RESP=$(curl -s "https://htmlbin.dev/api/auth/poll?token=$POLL")
  STATUS=$(echo "$RESP" | jq -r .status)
  case "$STATUS" in
    pending)  sleep "$INTERVAL" ;;
    verified) export HTMLBIN_TOKEN=$(echo "$RESP" | jq -r .api_token); break ;;
    *)        echo "auth failed: $STATUS" >&2; exit 1 ;;
  esac
done

mkdir -p ./.htmlbin && echo "$HTMLBIN_TOKEN" > ./.htmlbin/token
chmod 600 ./.htmlbin/token
```

The verification URL drops the human onto a "Sign in with GitHub" page. We
ask for the `read:user` scope only (public username + id) and bind one
htmlbin account per GitHub identity, so quotas and existing drops stick
across devices. That click is the only human step.

## Step 2: Generate HTML

Author a complete, self-contained HTML document. The file is rendered in an
iframe, so it must look right standalone.

- All CSS in `<style>` (CDNs OK — Tailwind, Alpine, esm.sh, etc.)
- All JS in `<script>` (CDNs OK)
- No build step on our side — what you upload is what's served
- Up to 2 MB per file
- **Mobile-OK at 360px.** Single column, no fixed pixel widths, long strings
  wrap (`overflow-wrap: anywhere`), `<pre>` / wide `<table>` scroll
  internally (`overflow-x: auto`). The server injects a tail-of-`<head>`
  safety net (`html,body{overflow-x:clip}`, fluid media) on every served
  drop, but well-authored drops shouldn't rely on it.

## Step 3: Upload (creates v1)

```bash
cat > /tmp/htmlbin.html <<'HTMLEOF'
<!doctype html>
<html>...</html>
HTMLEOF

jq -n --arg title "My Prototype" \
       --arg description "What this is showing" \
       --rawfile html /tmp/htmlbin.html \
       '{title:$title, description:$description, html:$html}' \
| curl -s -X POST https://htmlbin.dev/api/drops \
    -H "Authorization: Bearer $HTMLBIN_TOKEN" \
    -H "Content-Type: application/json" \
    -d @-
```

Response (HTTP 201): the full `Drop` object:
```json
{
  "slug": "aB3xK7g",
  "title": "My Prototype",
  "description": "What this is showing",
  "url": "https://htmlbin.dev/p/aB3xK7g",
  "raw_url": "https://htmlbin.dev/p/aB3xK7g/raw",
  "locked": false,
  "latest_version": 1,
  "view_count": 0,
  "created_at": 0,
  "updated_at": 0
}
```

Print the `url` to the user.

## Iterating: PUT for new versions, PATCH for metadata

**`PUT /api/drops/<slug>`** mints a new version (html required):

```bash
jq -n --rawfile html /tmp/htmlbin.html \
       --arg context "Tweaked colors after user feedback" \
       '{html:$html, context:$context}' \
| curl -s -X PUT https://htmlbin.dev/api/drops/<slug> \
    -H "Authorization: Bearer $HTMLBIN_TOKEN" \
    -H "Content-Type: application/json" \
    -d @-
```

**`PATCH /api/drops/<slug>`** updates title/description without minting a version:

```bash
curl -s -X PATCH https://htmlbin.dev/api/drops/<slug> \
  -H "Authorization: Bearer $HTMLBIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"Better title"}'
```

Including `html` in a PATCH returns `400 metadata_only_on_patch`.

Humans switch versions in the viewer with `?v=N`. Default = latest.

The CLI collapses both into one subcommand — `htmlbin update <slug>`
dispatches PUT when `--file` is present, PATCH otherwise:

```bash
htmlbin update <slug> --file /tmp/htmlbin.html      # PUT — mints v(n+1)
htmlbin update <slug> --title "Better title"        # PATCH — metadata only
htmlbin update <slug> --metadata status=merged      # PATCH — replace map
htmlbin update <slug> --clear-metadata              # PATCH — metadata = {}
```

## Context (optional, opt-in)

The `context` field on POST/PUT lets you record the prompt, reasoning,
or thinking trace that produced the HTML. **It is opt-in and may be
sensitive — only include it if the human has agreed.** When present, the
viewer exposes it under a discreet "context" toggle.

## Metadata (owner-side tag bag)

Every drop has a `metadata` field: a flat object of `string → string`
that you can attach on POST and replace on PUT/PATCH. It's **free-form**
— tag drops with whatever lets you find them later. Filterable on the
list endpoint via `metadata.<key>=<value>` (AND across pairs). Owner-only —
the public viewer at `/p/<slug>` never sees it.

A few example tag setups to spark ideas (the server has no opinion
about your keys):

- `{repo: "foo/bar", pr: "42"}` — stable preview URL across CI pushes
  for one PR.
- `{session_id: "<chat-id>", kind: "deck"}` — the artifact this
  conversation produced, so the next turn can iterate the same drop.
- `{client: "acme", project: "rebrand", status: "draft"}` — an agent
  maintaining a portfolio of in-progress work for an end-user.
- `{kind: "spec", topic: "auth-rewrite"}` — buckets you can list later.

The canonical recipe is **lookup → mutate**: GET with metadata filters,
then PUT to the slug if a drop matches, otherwise POST a fresh one. The
CLI's `--upsert` flag packages those two HTTP calls into one command:

```bash
# One-shot: stable URL across pushes for one PR
htmlbin publish ./out.html --upsert \
  --metadata repo=u/r --metadata pr=42 \
  --title "PR #42 preview"
```

There is intentionally no server-side upsert endpoint — the CLI is
running the same two HTTP calls below. Either path is fine.

```bash
# Publish with tags
jq -n --rawfile html /tmp/artifact.html '{
  title: "Q3 plan",
  html: $html,
  metadata: { client: "acme", project: "rebrand", status: "draft" }
}' | curl -s -X POST https://htmlbin.dev/api/drops \
       -H "Authorization: Bearer $HTMLBIN_TOKEN" \
       -H "Content-Type: application/json" -d @-

# Lookup → mutate (find your earlier drop and update it)
EXISTING=$(curl -s -H "Authorization: Bearer $HTMLBIN_TOKEN" \
  "https://htmlbin.dev/api/drops?metadata.client=acme&metadata.project=rebrand" \
  | jq -r '.data[0].slug // empty')

if [ -n "$EXISTING" ]; then
  jq -n --rawfile html /tmp/artifact.html '{html: $html}' \
  | curl -s -X PUT "https://htmlbin.dev/api/drops/$EXISTING" \
      -H "Authorization: Bearer $HTMLBIN_TOKEN" \
      -H "Content-Type: application/json" -d @-
else
  jq -n --rawfile html /tmp/artifact.html '{
    title: "Q3 plan",
    html: $html,
    metadata: { client: "acme", project: "rebrand", status: "draft" }
  }' | curl -s -X POST https://htmlbin.dev/api/drops \
         -H "Authorization: Bearer $HTMLBIN_TOKEN" \
         -H "Content-Type: application/json" -d @-
fi
```

If your shape can write in parallel for the same tag combination,
serialize at the call site. In GitHub Actions, set
`concurrency: group: pr-${{ github.event.pull_request.number }}`
on the workflow. The CLI's `--upsert` flag races the same way the
hand-rolled lookup → mutate does, since it runs the same two HTTP calls.

Limits: ≤10 keys, ≤64 chars per key (alphanumerics, `_`, `.`, `-`;
no leading or trailing punctuation), ≤256 chars per value. Values must
be strings — stringify numbers and booleans agent-side. PATCH/PUT
metadata replaces the whole map; omit to leave untouched, send `{}` to
clear.

## Listing your drops (paginated, filterable)

```bash
curl -s "https://htmlbin.dev/api/drops?page=1&pageSize=50&sortBy=updated_at&sortOrder=desc" \
  -H "Authorization: Bearer $HTMLBIN_TOKEN"

# Filter by metadata — AND across pairs
curl -s "https://htmlbin.dev/api/drops?metadata.repo=u%2Fr&metadata.pr=42" \
  -H "Authorization: Bearer $HTMLBIN_TOKEN"
```

Response:
```json
{
  "data": [ /* Drop[] */ ],
  "pagination": {
    "page": 1, "page_size": 50, "total_items": 142, "total_pages": 3,
    "sort_by": "updated_at", "sort_order": "desc"
  }
}
```

## Delete

```bash
# Delete a single version (refused on the only remaining one) — returns the updated Drop
curl -s -X DELETE https://htmlbin.dev/api/drops/<slug>/v/<n> \
  -H "Authorization: Bearer $HTMLBIN_TOKEN"

# Delete the whole drop — returns 204 No Content
curl -s -X DELETE https://htmlbin.dev/api/drops/<slug> \
  -H "Authorization: Bearer $HTMLBIN_TOKEN"
```

## Passcode (soft gate)

Set, change, or remove a passcode via `POST /api/drops/<slug>/passcode` with
`{ "passcode": "..." }`. Pass `"passcode": ""` to remove. This is a soft
share gate — not encryption.

## Rate limiting

429 responses carry a `Retry-After` header and a
`details.retry_after_seconds` field. Back off accordingly.

Limits: 60 writes/min, 500 writes/day, 500 drops/account, 200 versions/drop, 2 MB / drop.

## Errors

All errors share `{ "error": { "code, message, details? } }`.
Switch on `error.code`. Common codes: `unauthorized`, `invalid_token`,
`rate_limited`, `daily_quota_exceeded`, `quota_exceeded`,
`html_too_large`, `html_required`, `title_required`, `forbidden`,
`not_found`, `version_not_found`, `metadata_only_on_patch`,
`last_version_cannot_be_deleted`, `token_required`.

That's the whole API. Build something good.
