Drop HTML. Get a public URL. Agent-first HTML hosting at htmlbin.dev — pastebin energy, for the HTML your agent writes.
An agent does a one-time human-verified device-code dance (humans sign in with GitHub — one htmlbin account per GitHub identity), then publishes self-contained HTML to a public URL — no human after auth. Built for the HTML-as-output-format era. Hosted entirely on Cloudflare: Workers + D1
- KV.
If your agent runtime resolves skills.sh installs (Claude Code, Cursor, Codex, Gemini, Aider, …):
npx skills add https://github.com/utsengar/htmlbin-cli --skill htmlbin-publishThat installs the official htmlbin-publish skill — pattern-before-
publish workflow, uses the @htmlbin/cli under the hood. The skill
lives in the htmlbin-cli
repo. For runtimes that fetch SKILL.md directly, the comprehensive
reference is served at
/.well-known/agent-skills/htmlbin/SKILL.md.
agent ─ POST /api/auth/start ──┐ sign in with GitHub
│ │
(verification code) │
│ │
human ─ open /verify ───────┘
│ (single human moment)
┌────┴────────────────────┐
│ upsert by github_user_id│ ◀── D1
│ mint token │
└────┬────────────────────┘
│
agent ─ GET /api/auth/poll ───┘ → api_token (one-time read)
agent ─ POST /api/drops → slug, public URL
KV ──▶ HTML body
visitor ─ GET /p/:id → viewer + iframe D1 ──▶ metadata
(passcode gate if locked)
npm install
npm run setup # provisions D1 + KV, applies schema
cp .dev.vars.example .dev.vars # uses dev-mock GitHub OAuth; works as-is
npm run dev # http://localhost:8787Once the dev server is up, exercise the whole protocol with the agent test runner:
npm run test:e2e72+ checks across discovery, auth, drop CRUD, versioning, password lifecycle, ownership, validation, abuse-report, and cleanup. All should pass.
npm run dashboard # http://127.0.0.1:5173, against remote D1
npm run dashboard -- --local # against local D1A tiny Node + vanilla-JS dashboard that proxies read-only SQL through
wrangler d1 execute and renders an interactive overview with
click-through into any user (drops, tokens, signup date, daily activity)
or any drop (versions, owner, storage). User detail pulls real name /
bio / followers / repos from the public GitHub API. Sibling of
npm run stats, just clickable. Local-only — bound to 127.0.0.1, not
deployed, not part of the product surface.
# 1. Real GitHub OAuth app
# https://github.com/settings/applications/new
# - Authorization callback URL: https://htmlbin.dev/auth/github/callback
# Paste the client id into wrangler.toml; the secret is a Worker secret:
wrangler secret put GITHUB_CLIENT_SECRET
# 2. Apply schema (fresh DB) or migrations (existing DB) to remote
npm run db:apply:remote # fresh DB only
npm run db:migrate:remote # existing DB — applies migrations/
# 3. Ship
npm run deployCustom domain: in the Cloudflare dashboard, attach htmlbin.dev to the
Worker, then uncomment the routes block in wrangler.toml.
.github/workflows/deploy.yml ships every push to main and posts a
versioned preview URL on each PR.
One-time setup:
- Create a Cloudflare API token with "Edit Cloudflare Workers" template scope: https://dash.cloudflare.com/profile/api-tokens.
- In GitHub: repo → Settings → Secrets and variables → Actions →
New repository secret→ nameCLOUDFLARE_API_TOKEN, paste the token value.
That's it. PRs automatically get a Cloudflare preview URL commented
back; merges to main deploy to production.
Bindings (D1, KV, AI) are shared between previews and production. Add an
[env.preview]block inwrangler.tomlwith separate IDs if you want isolated preview data — see https://developers.cloudflare.com/workers/wrangler/environments/.
| Endpoint | What it returns |
|---|---|
GET /api/onboard |
JSON protocol descriptor (default). Accept: text/markdown or ?format=md returns the same protocol as a markdown walkthrough. |
GET /openapi.json |
OpenAPI 3.1 spec |
GET /.well-known/agent-card.json |
Compact capability descriptor |
GET /.well-known/agent-skills/index.json |
Agent Skills Discovery RFC v0.2.0 index (entry skill: htmlbin/SKILL.md) |
GET /.well-known/api-catalog |
RFC 9727 linkset+json |
GET /llms.txt |
llmstxt.org-style site index |
GET /robots.txt |
Explicit allow-list of GPT/Claude/Perplexity bots |
GET /sitemap.xml |
Sitemap |
GET /index.md |
Landing rendered as Markdown via Workers AI (Accept: text/markdown on / works too) |
GET /favicon.svg |
Single source-of-truth favicon (auto-adapts to light/dark) |
GET /og.png, GET /og.svg |
Open Graph card for the landing, 1200×630. PNG is what unfurlers consume; SVG is the lightweight fallback. |
GET /p/:id/og.png, GET /p/:id/og.svg |
Per-drop OG card (title-focused). Same PNG/SVG split. |
The landing page also sets a Link: HTTP header advertising all of the above.
| Method | Path | Notes |
|---|---|---|
POST |
/api/auth/start |
Returns {code, verification_url, poll_token} |
GET |
/api/auth/poll?token=… |
{status, api_token?} — token revealed once |
| Method | Path | Notes |
|---|---|---|
POST |
/api/drops |
{title, description?, html, passcode?, context?, metadata?} — creates v1 |
GET |
/api/drops |
List your drops. Filter with repeated metadata.<key>=<value> (AND across pairs) |
GET |
/api/drops/:slug |
Drop metadata |
PUT |
/api/drops/:slug |
Mints a new version (slug + URL preserved). May also update title/description/metadata |
PATCH |
/api/drops/:slug |
Update title / description / metadata without minting a version. metadata replaces the whole map |
GET |
/api/drops/:slug/versions |
List all versions |
GET |
/api/drops/:slug/v/:n |
Specific version metadata + context |
DELETE |
/api/drops/:slug |
Deletes all versions |
POST |
/api/drops/:slug/passcode |
Soft share gate; empty string removes it |
GET |
/api/tokens |
List your active tokens (across machines) |
DELETE |
/api/tokens/:id |
Revoke a token by short id |
| Method | Path | Notes |
|---|---|---|
GET |
/p/:id |
Viewer (with passcode gate when locked) |
GET |
/p/:id?v=N |
Pinned to a specific version |
GET |
/p/:id/raw |
Raw HTML, edge-cached for unlocked drops |
Every PUT with new HTML mints a new version on the same slug. The URL
never changes. Switch versions in the viewer with ?v=N.
Every drop carries a metadata field — a flat string → string map
(≤10 keys, ≤64 chars per key, ≤256 chars per value). Set on POST,
replace on PUT/PATCH, filter on GET /api/drops?metadata.k=v.
Free-form, no reserved keys. Owner-only — the public viewer never
exposes it.
The point is: agents tag drops with whatever they need to find them by later. A few examples to spark ideas:
{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 on the same drop.{client: "acme", project: "rebrand", status: "draft"}— an agent maintaining a portfolio of in-progress work for an end-user.
The canonical recipe is lookup-then-mutate: GET with metadata
filters, then PUT if a drop matches or POST if not — no slug
bookkeeping on the client. There is intentionally no server-side
upsert endpoint; for shapes where parallel writes are possible (e.g. CI)
serialize at the call site. See CLAUDE.md.
Same human, multiple machines: run the verify flow on the new machine
and sign in with the same GitHub account. We bind one htmlbin account
per GitHub identity (UNIQUE github_user_id), so both devices share the
same user_id. Tokens are independent — revoke one, the other still
works.
- 2 MB / drop
- 64 KB / context per version
- 200 versions / drop
- 60 writes / minute / token
- 500 writes / day / token
- 500 drops / account
- 10-minute TTL on verification codes
Adjust in src/drops.ts and src/auth.ts.
src/
index.ts ─ Hono app: routes + chrome
auth.ts ─ device-code flow + Bearer middleware
drops.ts ─ /api/drops CRUD with versioning
onboard.ts ─ /api/onboard JSON descriptor + markdown walkthrough
skill.ts ─ /.well-known/agent-skills/* (RFC v0.2.0)
crypto.ts ─ Web Crypto wrappers (PBKDF2, HMAC)
slug.ts ─ short alphanumeric id generator
db.ts ─ D1 helpers + rate limiter
discoverability.ts─ robots.txt, llms.txt, sitemap, agent-card, openapi, api-catalog
styles.ts ─ shared CSS + STYLE_HREF (auto-bumping cache buster)
types.ts ─ shared types
views/
chrome.ts ─ shared top bar, footer, HTTP-memo card
favicon.ts ─ inline SVG favicon
og-image.ts ─ inline SVG OG card (fallback / per-tab source)
og-png.ts ─ satori + resvg-wasm PNG renderer (1200×630)
landing.ts ─ /
verify.ts ─ /verify
viewer.ts ─ /p/:id + passcode gate (with version switcher)
skills/htmlbin/
SKILL.md ─ human-browsable mirror of src/skill.ts
.github/workflows/
deploy.yml ─ production deploy on main, versioned preview on PR
schema.sql ─ D1 schema
wrangler.toml ─ Cloudflare config (incl. [[rules]] CompiledWasm for OG fonts)
scripts/
setup.mjs ─ one-shot provisioning
agent-e2e.sh ─ full functional test
stats.mjs ─ text stats snapshot (npm run stats)
dashboard/ ─ local web dashboard (npm run dashboard)
DB table, URL path, and user-facing copy all align: drops.
- API tokens are stored only as
sha256(pepper || token). Plaintext is shown to the agent once via the device-code flow and never again. - Passcode-protected drops use PBKDF2-SHA-256 (100k iterations, 16-byte random salt) for the soft share gate. Unlock cookie is HMAC-SHA-256-signed and scoped to the slug; it does not contain the passcode. This is a share gate, not encryption — the underlying HTML is unencrypted in KV.
- Iframe sandbox + CSP
frame-ancestors 'self'on/p/:id/rawto prevent UI redress / clickjacking from external sites. - Rate limiting is single-region D1 (best-effort). For higher-traffic deployments, swap in Cloudflare Rate Limiting.
Visual system documented in DESIGN.md. Short version:
white paper, Geist + Geist Mono, single red accent, HTTP-style memo on
every page, vim-modeline breadcrumb top bar. Single source of truth
is src/styles.ts — edit one file, every page updates. The link is
content-hashed (/style.css?v=<hash>) so the edge cache busts itself
on every CSS change.
Markdown has become a restricting format. HTML can convey almost any information an agent can read — and the chance of someone actually reading your spec, report or PR writeup is much, much higher if it's HTML.
— Thariq, "The Unreasonable Effectiveness of HTML"
htmlbin is a place to put it.
MIT.