{"schema_version":"1","name":"htmlbin","public_url":"https://htmlbin.dev","summary":"Agent-first HTML hosting. Drop self-contained HTML, get a public URL.","naming_convention":"snake_case for all request and response field names","spec":{"openapi":"https://htmlbin.dev/openapi.json","agent_card":"https://htmlbin.dev/.well-known/agent-card.json","api_catalog":"https://htmlbin.dev/.well-known/api-catalog","agent_skills_index":"https://htmlbin.dev/.well-known/agent-skills/index.json","llms_txt":"https://htmlbin.dev/llms.txt","onboard_markdown":"https://htmlbin.dev/api/onboard (Accept: text/markdown)"},"cli":{"package":"@htmlbin/cli","install":"npm i -g @htmlbin/cli (or: npx -y @htmlbin/cli@latest <subcommand>)","runtime":"Node 20+","summary":"First-party CLI. Wraps every endpoint below, packages the token-resolution order, and auto-emits JSON when invoked from a coding-agent runner (CLAUDE_CODE / CURSOR_AGENT / CODEX / AIDER / ...). Stable exit codes (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 shape.","cloud_only_flags":"--metadata and --upsert are cloud-only. The CLI returns invalid_arg if combined with --to gh-pages / --to cloudflare since those backends don't store metadata server-side."},"skill":{"name":"htmlbin-publish","install_command":"npx skills add https://github.com/utsengar/htmlbin-cli --skill htmlbin-publish","distribution":"skills.sh","source":"https://github.com/utsengar/htmlbin-cli/tree/main/skills/htmlbin-publish","supports":["claude-code","cursor","codex","gemini","aider"],"summary":"Official agent skill, installable via skills.sh. Walks the agent through the pattern-before-publish workflow (`htmlbin patterns list` → match → read pattern → author HTML → `htmlbin publish`) so the artifact is shaped by a pattern instead of retrofitted after the fact. Complements (does not replace) the comprehensive reference skill served at /.well-known/agent-skills/htmlbin/SKILL.md.","see_also":"https://htmlbin.dev/.well-known/agent-skills/htmlbin/SKILL.md"},"error_shape":{"description":"Every 4xx/5xx response uses this canonical shape. Switch on `code`, not on `message`.","example":{"error":{"code":"html_too_large","message":"HTML exceeds 2097152 bytes.","details":{"max_bytes":2097152}}}},"auth":{"type":"device_code","header":"Authorization: Bearer <token>","token_format":"hb_<base62>","token_storage":{"primary":"./.htmlbin/token","fallback":"~/.config/htmlbin/token","env_var":"HTMLBIN_TOKEN","note":"Project-local storage avoids prompting an agent to write outside its working directory."},"steps":[{"step":1,"method":"POST","url":"https://htmlbin.dev/api/auth/start","body":{"label":"string (optional, e.g. 'claude-code')"},"returns":{"code":"string (show this to the human)","verification_url":"string (open this in a browser)","poll_token":"string (use in step 3)","expires_in":"integer (seconds)","poll_interval":"integer (seconds, default 2)"}},{"step":2,"human_action":"Open verification_url and sign in with GitHub.","note":"Only human moment. We bind one htmlbin account per GitHub identity (read:user only — public username + id), so quotas and existing drops follow the human across machines. After this the agent is autonomous."},{"step":3,"method":"GET","url":"https://htmlbin.dev/api/auth/poll","query":{"token":"<poll_token from step 1>"},"returns":{"status":"'pending' | 'verified' | 'expired' | 'claimed' | 'not_found'","api_token":"string (only on first 'verified' read; revealed exactly once)","user_id":"string (only on first 'verified' read)"},"note":"Poll every poll_interval seconds until status != 'pending'. The api_token is shown once — store it."}]},"drop_shape":{"description":"Every endpoint that creates, reads, or mutates a single drop returns this shape.","example":{"slug":"aB3xK7g","title":"My page","description":"Optional subtitle","url":"https://htmlbin.dev/p/aB3xK7g","raw_url":"https://htmlbin.dev/p/aB3xK7g/raw","locked":false,"latest_version":3,"view_count":17,"metadata":{"repo":"u/r","pr":"42"},"created_at":0,"updated_at":0}},"publish":{"method":"POST","url":"https://htmlbin.dev/api/drops","headers":{"Authorization":"Bearer <api_token>","Content-Type":"application/json"},"body":{"title":"string (required, ≤200 chars)","description":"string (optional, ≤500 chars)","html":"string (required, full self-contained HTML document, ≤2 MB)","passcode":"string (optional, ≥4 chars; soft gate, not encryption — shown on /p/<slug> before the body)","context":"string (optional, ≤64 KB; reasoning trace — opt-in per the human)","metadata":"object (optional, ≤10 keys; flat string→string. Owner-side tag bag for lookup via GET /api/drops?metadata.k=v. Not exposed on /p/.)"},"returns":"Drop (see drop_shape)","status":201,"cli":{"command":"htmlbin publish <file>","flags":{"--title <text>":"Sets `title` (defaults to filename).","--description <text>":"Sets `description`.","--metadata <k=v>":"Repeatable; up to 10. Sets `metadata`. Cloud only.","--upsert":"Lookup-by-metadata first; PUT if a drop matches, POST if not. Requires at least one --metadata pair. Cloud only. Returns `matched: true|false` alongside `slug`/`url` in JSON mode."}}},"iterate":{"description":"Two distinct operations on an existing drop: PUT for a new version, PATCH for metadata-only changes.","new_version":{"method":"PUT","url":"https://htmlbin.dev/api/drops/<slug>","body":{"html":"string (required — PUT always mints a new version)","title":"string (optional, ≤200 chars)","description":"string (optional, ≤500 chars)","context":"string (optional, ≤64 KB)","metadata":"object (optional; replaces whole metadata map — omit to leave untouched, {} to clear)"},"returns":"Drop (with bumped latest_version)","note":"Slug + public URL stay stable. Humans switch versions in the viewer with ?v=N."},"metadata_only":{"method":"PATCH","url":"https://htmlbin.dev/api/drops/<slug>","body":{"title":"string (optional, ≤200 chars)","description":"string (optional, ≤500 chars)","metadata":"object (optional; replaces whole metadata map — omit to leave untouched, {} to clear)"},"returns":"Drop (latest_version unchanged)","note":"Including `html` here returns 400 metadata_only_on_patch — use PUT."},"cli":{"command":"htmlbin update <slug>","flags":{"--file <path>":"New HTML body. Presence of this flag is what selects PUT (new version) vs PATCH (without it: metadata only).","--title <text>":"New title.","--description <text>":"New description.","--metadata <k=v>":"Repeatable. Replaces the whole metadata map. Mutually exclusive with --clear-metadata.","--clear-metadata":"Explicit clear; sends metadata: {}. Mutually exclusive with --metadata."},"dispatch":"With --file: PUT /api/drops/<slug> (new version). Without --file: PATCH /api/drops/<slug> (metadata-only, latest_version unchanged).","cloud_only":true}},"list_my_drops":{"method":"GET","url":"https://htmlbin.dev/api/drops","query":{"page":"integer (default 1, min 1)","pageSize":"integer (default 50, max 200)","sortBy":"'created_at' | 'updated_at' | 'view_count' (default 'created_at')","sortOrder":"'asc' | 'desc' (default 'desc')","metadata.<key>":"string (optional, repeatable; AND-filters drops where metadata.<key> = value. Conventional keys: repo, pr, sha, agent, ci_run.)"},"returns":{"data":"Drop[]","pagination":{"page":"integer","page_size":"integer","total_items":"integer","total_pages":"integer","sort_by":"string","sort_order":"string"}},"cli":{"command":"htmlbin list","flags":{"--metadata <k=v>":"Repeatable. AND-filters by metadata. Cloud only.","--limit <n>":"Cap the row count.","-n <n>":"Alias for --limit."}},"lookup_then_mutate":{"description":"Recipe for finding a drop you previously tagged and updating it without storing slugs client-side: GET filter, then PUT if a drop matches, otherwise POST. Works for any tag combination, not just CI / PR previews — the metadata field is free-form.","steps":["GET https://htmlbin.dev/api/drops?metadata.<k1>=<v1>&metadata.<k2>=<v2>","if data[0]: PUT /api/drops/<data[0].slug> with the new html","else:       POST /api/drops with html + metadata"],"cli_one_liner":{"command":"htmlbin publish <file> --upsert --metadata <k1>=<v1> --metadata <k2>=<v2>","note":"The CLI's --upsert flag packages the entire lookup-then-mutate dance into one command. There is still no server-side upsert endpoint — the CLI just runs the two HTTP calls for you and surfaces `matched: true|false` in JSON mode. Recommended path for stable cloud URLs across CI pushes (use `repo` + `pr` as the tag combination)."},"example_tag_setups":[{"repo":"u/r","pr":"42"},{"session_id":"<chat-id>","kind":"deck"},{"client":"acme","project":"rebrand","status":"draft"}],"race_note":"If two writers can run in parallel for the same tag combination, serialize them at the call site. For CI / PR previews specifically, set `concurrency: group:` on the GitHub Actions workflow. Other shapes (per-session, per-client) usually don't race."}},"other_endpoints":{"whoami":{"method":"GET","url":"https://htmlbin.dev/api/me","returns":{"user_id":"string","created_at":"integer (unix ms)","drop_count":"integer","token":{"id":"string (12 hex)","label":"string|null","created_at":"integer","last_used_at":"integer|null"}}},"get_drop":{"method":"GET","url":"https://htmlbin.dev/api/drops/<slug>"},"list_versions":{"method":"GET","url":"https://htmlbin.dev/api/drops/<slug>/versions"},"get_version":{"method":"GET","url":"https://htmlbin.dev/api/drops/<slug>/v/<n>"},"delete_drop":{"method":"DELETE","url":"https://htmlbin.dev/api/drops/<slug>","returns":"204 No Content"},"delete_version":{"method":"DELETE","url":"https://htmlbin.dev/api/drops/<slug>/v/<n>","returns":"Drop (with possibly-updated latest_version)","note":"Refused with 409 last_version_cannot_be_deleted on the only remaining version."},"set_passcode":{"method":"POST","url":"https://htmlbin.dev/api/drops/<slug>/passcode","body":{"passcode":"string ('' to remove)"},"returns":"Drop"},"list_my_tokens":{"method":"GET","url":"https://htmlbin.dev/api/tokens"},"revoke_token":{"method":"DELETE","url":"https://htmlbin.dev/api/tokens/<id>","note":"id = first 12 hex chars of the token hash","returns":"204 No Content"}},"cross_machine":{"method":"On a new machine, run /api/auth/start and sign in with the same GitHub account at /verify. Both machines end up bound to the same htmlbin account.","result":"Both machines share the same user_id with independent tokens."},"limits":{"max_html_bytes":2097152,"max_context_bytes":65536,"max_versions_per_drop":200,"writes_per_minute":60,"writes_per_day":500,"drops_per_account":500,"verification_ttl_seconds":600},"rate_limits":{"response":"429 with `error.code = rate_limited` (or daily_quota_exceeded / quota_exceeded)","retry_after_header":"Set to seconds until the next window opens.","retry_after_details":"Also returned as `details.retry_after_seconds` in the body."},"errors":{"shape":{"error":{"code":"<machine-readable identifier>","message":"<human-readable summary>","details":"<optional object with context, e.g. { max_bytes: 2097152 }>"}},"common_codes":["unauthorized","invalid_token","rate_limited","daily_quota_exceeded","quota_exceeded","html_too_large","html_required","title_required","title_too_long","description_too_long","context_too_large","passcode_required","passcode_too_short","metadata_only_on_patch","forbidden","not_found","version_not_found","last_version_cannot_be_deleted","version_limit_reached","invalid_slug","invalid_arg","invalid_json","token_required"]},"recommendations":{"mobile_floor":{"summary":"Render at 360px without horizontal scroll. This is the #1 failure mode for agent-authored drops — author mobile-first.","must_have":["<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"> in <head> (server injects if missing).","No fixed pixel widths >360px on layout containers. Use max-width paired with width: 100%.","Long unbreakable strings (URLs, paths, hashes) wrap: overflow-wrap: anywhere on prose, table cells, inline code.","<pre> and wide <table> scroll internally, not the page: overflow-x: auto; max-width: 100%.","Media is fluid: img, svg, video, iframe { max-width: 100% }.","Mentally test at 360px and 768px before publishing."],"server_safety_net":"Every /p/<slug>/raw response gets a small defensive style appended at the tail of <head>: html,body{max-width:100vw;overflow-x:clip}, plus fluid img/svg/video/iframe and max-width:100% on pre/table. It's a guard against the most common break, not a license to skip mobile design.","skill":"https://htmlbin.dev/.well-known/agent-skills/htmlbin/SKILL.md"}},"notes":["Drops are rendered in an iframe — HTML must be standalone (CDN imports OK; no build step).","If the human's agent sandbox blocks new domains, allow htmlbin.dev once before running this flow."]}