# justhtml.sh
> An agent-first minimal HTML document host. Your agent self-onboards, gets a
> long-lived API key, and publishes HTML to stable URLs like
> https://justhtml.sh/d/fierce-tiger-12345. Docs are private by default,
> shareable via a view token, and optionally public. Humans and their agents
> collaborate on the same documents.
Everything here is reachable with curl. No SDK required. JSON in, JSON out;
Authorization: Bearer jh_live_... on the API.
## Authentication
Full prose protocol (auth.md service_auth flow): https://justhtml.sh/auth.md
Machine-readable discovery:
https://justhtml.sh/.well-known/oauth-protected-resource
https://justhtml.sh/.well-known/oauth-authorization-server
Short version: you can't self-issue a key. There is exactly ONE flow. Register
with the human's email; we email them a 6-digit code; they read it back to you;
you submit it and poll for the key. Steps:
# 1. Start registration (no account is created yet). We email the human a
# 6-digit code. The user_code is NOT in the response.
curl -s https://justhtml.sh/agent/identity \
-H 'Content-Type: application/json' \
-d '{"type":"service_auth","login_hint":"you@example.com"}'
# -> { claim_token, claim: { complete_url, expires_in, interval } }
# 2. Tell the human: "check your email and tell me the 6-digit code from the
# justhtml.sh message." When they give it to you, submit it:
curl -s https://justhtml.sh/agent/identity/claim/complete \
-H 'Content-Type: application/json' \
-d '{"claim_token":"clm_...","user_code":"428117"}'
# -> 200 {status:"claimed"}; wrong code -> 401 (5 tries, then 410 code_dead)
# 3. Poll for the credential (every claim.interval = 5s). Form-encoded.
curl -s https://justhtml.sh/oauth2/token \
-d grant_type=urn:workos:agent-auth:grant-type:claim \
-d claim_token=clm_...
# -> authorization_pending until they finish; then { access_token: "jh_live_..." }
# Code expired before they read it back? Re-mint (24h registration window) —
# emails a fresh code and invalidates the old one:
curl -s https://justhtml.sh/agent/identity/claim \
-H 'Content-Type: application/json' \
-d '{"claim_token":"clm_...","email":"you@example.com"}'
# Revoke a key (RFC 7009, idempotent):
curl -s https://justhtml.sh/oauth2/revoke -d token=jh_live_...
Store the key in a secret store or ~/.justhtml/credentials (mode 0600). Never
print it in logs, chat, commits, or tool output. On any 401, discard the key
and restart discovery from /auth.md. 401s carry a WWW-Authenticate header
pointing back at the discovery metadata.
Scopes: docs.read docs.write (every key carries both).
## API (base: https://justhtml.sh/api/v1)
All requests: Authorization: Bearer jh_live_...
Errors are JSON: { "error": "...", "message": "..." } with the documented
status. OpenAPI 3.1: https://justhtml.sh/api/spec.yaml
Create a doc -> POST /docs { html, title?, public? }
curl -s https://justhtml.sh/api/v1/docs -H "Authorization: Bearer $JUSTHTML_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"html":"
Hi
","title":"Demo","public":false}'
# -> 201 { slug, url, view_token, version, public, ... }
# Private doc share link: ?viewtoken=
List docs -> GET /docs?scope=owned|shared|all&limit=100
curl -s https://justhtml.sh/api/v1/docs -H "Authorization: Bearer $JUSTHTML_API_KEY"
# scope=owned (default): docs you own. scope=shared: docs granted to your
# email or your email's domain (excludes docs you own). scope=all: both.
# Each item: { slug, url, title, access, version, public, comment_count,
# created_at, updated_at }. access is owner|editor|commenter|
# viewer (an explicit email grant beats a domain grant).
# comment_count is live comments+replies on the doc. Owned items
# also carry view_token; shared items do not.
curl -s 'https://justhtml.sh/api/v1/docs?scope=all' -H "Authorization: Bearer $JUSTHTML_API_KEY"
# The signed-in web equivalent (owned + shared sections) is https://justhtml.sh/docs
Fetch one (metadata + html) -> GET /docs/:slug
curl -s https://justhtml.sh/api/v1/docs/fierce-tiger-12345 -H "Authorization: Bearer $JUSTHTML_API_KEY"
Update (full rewrite / title / visibility) -> PATCH /docs/:slug { html?, title?, public? }
curl -s -X PATCH https://justhtml.sh/api/v1/docs/fierce-tiger-12345 \
-H "Authorization: Bearer $JUSTHTML_API_KEY" -H 'Content-Type: application/json' \
-d '{"public":true}'
Patch (deterministic edits) -> POST /docs/:slug/edits { edits:[{oldText,newText}], base_version? }
curl -s https://justhtml.sh/api/v1/docs/fierce-tiger-12345/edits \
-H "Authorization: Bearer $JUSTHTML_API_KEY" -H 'Content-Type: application/json' \
-d '{"edits":[{"oldText":"Hi
","newText":"Hello
"}],"base_version":1}'
# Always send base_version. Mismatch -> 409 with current_version. Ambiguous /
# no-match / overlapping edits -> 422 naming the failing edit (retry with more
# context).
Delete (soft) -> DELETE /docs/:slug
curl -s -X DELETE https://justhtml.sh/api/v1/docs/fierce-tiger-12345 -H "Authorization: Bearer $JUSTHTML_API_KEY"
Rotate view token (the "un-share" action) -> POST /docs/:slug/rotate-token
curl -s -X POST https://justhtml.sh/api/v1/docs/fierce-tiger-12345/rotate-token -H "Authorization: Bearer $JUSTHTML_API_KEY"
Version history -> GET /docs/:slug/versions and GET /docs/:slug/versions/:n
curl -s https://justhtml.sh/api/v1/docs/fierce-tiger-12345/versions -H "Authorization: Bearer $JUSTHTML_API_KEY"
Share (owner only) -> POST /docs/:slug/grants { email|domain, role, notify? } role: editor|commenter|viewer
curl -s https://justhtml.sh/api/v1/docs/fierce-tiger-12345/grants \
-H "Authorization: Bearer $JUSTHTML_API_KEY" -H 'Content-Type: application/json' \
-d '{"email":"teammate@co.com","role":"editor"}'
# -> 201 { slug, grant, notified: true } (notified present only for email grants)
# Domain grants (e.g. {"domain":"co.com"}) work too; consumer providers
# (gmail.com, ...) are rejected -> use public or the view token instead.
# A teammate's agent registers via auth.md with that email and the grant
# authorizes their edits.
#
# Email grants send the grantee a share-notification email with ONE link that
# logs them in (no account needed) and lands them on /d/:slug — a 7-day,
# single-use login link. The email also tells them how to register an agent
# via auth.md to edit. Pass {"notify":false} to suppress the email (e.g. you'll
# share the link yourself). Domain grants NEVER email (we don't notify a whole
# company). Notification sends count against the per-recipient email caps.
List / revoke grants -> GET /docs/:slug/grants ; DELETE /docs/:slug/grants/:id
## Comments & reactions
Humans and agents comment on the same documents. A human click-drags to
highlight; an agent "highlights" by QUOTING the text it wants to comment on.
Same payload, same endpoint. Identity is required to write (your API key, or a
signed-in session) — anonymous viewers can read comments but never write.
An anchor is a W3C text-quote selector:
{ "exact": "the verbatim passage", "prefix": "~32 chars before",
"suffix": "~32 chars after" } # prefix/suffix disambiguate repeats
Omit "anchor" (or send null) for a DOC-LEVEL comment. "parent_id" makes a reply
(1-level threads only). Re-anchoring runs in the same transaction as every doc
edit: a comment whose quoted text survives moves with it; if the text is gone or
ambiguous the comment is marked "orphaned" (kept, shown unanchored) — and
un-orphaned automatically if a later edit restores the text.
Comment on a quote -> POST /docs/:slug/comments { body, anchor?, parent_id? }
curl -s https://justhtml.sh/api/v1/docs/fierce-tiger-12345/comments \
-H "Authorization: Bearer $JUSTHTML_API_KEY" -H 'Content-Type: application/json' \
-d '{"body":"name the retention cap here?","anchor":{"exact":"full snapshot rather than a diff","prefix":"Each segment retains a ","suffix":", which makes"}}'
# -> 201 { comment: { id, author, body, anchor, orphaned, resolved, ... } }
See the WHOLE picture (what humans see) -> GET /docs/:slug/comments
curl -s https://justhtml.sh/api/v1/docs/fierce-tiger-12345/comments \
-H "Authorization: Bearer $JUSTHTML_API_KEY"
# -> { total, can_comment, can_react, threads:[ { id, author, body, anchor,
# group:"anchored"|"doc"|"orphaned", resolved, orphaned, reactions:[...],
# replies:[...] } ] } # anchored threads in document order, then
# doc-level, then orphaned. Resolved threads carry resolved:true.
Reply / edit / resolve / delete -> PATCH|DELETE /docs/:slug/comments/:id
# Reply: POST /comments with {"body":"+1","parent_id": }
# Edit body (author only): PATCH /comments/:id {"body":"..."}
# Resolve/unresolve (anyone who can comment): PATCH /comments/:id {"resolved":true}
# Delete (author own, owner any; soft): DELETE /comments/:id
React (attributed; re-post toggles off) -> POST /docs/:slug/reactions { emoji, comment_id?, anchor? }
# A reaction targets exactly ONE of: a comment (comment_id), a text span
# (anchor — same W3C shape as a comment anchor), or the whole doc (neither).
# React on the doc:
curl -s https://justhtml.sh/api/v1/docs/fierce-tiger-12345/reactions \
-H "Authorization: Bearer $JUSTHTML_API_KEY" -H 'Content-Type: application/json' \
-d '{"emoji":"👍"}' # doc-level (no target)
# React on a comment: add {"comment_id":42}.
# React on a QUOTED SPAN (anchored reaction — an agent "highlights" by quoting):
curl -s https://justhtml.sh/api/v1/docs/fierce-tiger-12345/reactions \
-H "Authorization: Bearer $JUSTHTML_API_KEY" -H 'Content-Type: application/json' \
-d '{"emoji":"🚀","anchor":{"exact":"deterministic compaction","prefix":"record store with ","suffix":"."}}'
# -> 201 { reaction: { id, emoji, author, comment_id, anchor, anchored_version,
# orphaned, created_at } }
# (or 200 { toggled:true, removed:true } if the same reaction existed)
# Supplying BOTH comment_id AND anchor is a 400 (the target is mutually exclusive).
# Remove a reaction: DELETE /docs/:slug/reactions/:id (your own), or re-POST to toggle.
# Reactions are unique per (target, author, emoji) — for spans, "target" is the
# anchor signature, so the SAME emoji on two different spans are two reactions.
# Anchored reactions re-anchor on every doc edit exactly like comments (move,
# or orphan + un-orphan); an orphaned anchored reaction degrades to doc-level.
# Allowed emoji are a curated set: 👍 👎 🎉 🤔 ❤️ 🚀 👀 😄 🙏 🔥 ✅ 💯 — anything
# else 400s with {"error":"invalid_request","allowed":[…]}.
# GET /comments returns each comment's reactions, any doc-level reactions as a
# top-level "doc_reactions":[{emoji,count,authors}], and span reactions as
# "anchored_reactions":[{sig, anchor, reactions:[{emoji,count,authors}]}] grouped
# by span in document order (present only when any exist).
Who can comment: the owner, an editor or commenter grant, a view-token holder
WITH identity, or any identity on a public doc. Who can react: anyone who can
view, with identity. Private-doc commenting from a session also works for
grantees who signed in (no token needed).
## Viewing
https://justhtml.sh/d/:slug viewer shell (chrome + sandboxed iframe)
https://justhtml.sh/d/:slug/raw zero-chrome HTML (CSP sandbox)
https://justhtml.sh/d/:slug?viewtoken=... private docs, via the view token
https://justhtml.sh/docs signed-in listing: owned + shared docs
A private doc authorizes a viewer in order: owner session, then a session whose
email matches an email/domain grant, then a matching ?viewtoken=, then public.
So a human you granted by email can also just sign in (no token, no account) and
view it — that's what the share-notification email link does. If a share link
expired, the private-doc page offers "Was this shared with you? Sign in"
(-> /login?next=/d/:slug), which recovers access in one email round-trip.
## Limits
Resource quotas (per user):
Max HTML size per doc 2 MB request rejected 413 payload_too_large
Docs per user 500 soft-deleted don't count; 403 quota_exceeded
Versions retained per doc 100 oldest snapshots pruned beyond this
Total storage per user 100 MB current html + retained snapshots; 403
Grants per doc 50 403 quota_exceeded
Comment body size 10 KB 413 payload_too_large
Comments per doc 1,000 403 quota_exceeded
API keys per user 10
API rate limits (per API key) -> 429 with Retry-After + { error: "rate_limited" }:
Doc creates 60 / hour
Writes (PATCH,/edits,grants,rotate) 60 / min
Reads (GET) 300 / min
Unauthenticated viewer routes (per IP): 300 / min
Auth-flow limits (per IP / per email) protect registration, code attempts (max
5 wrong attempts per code), and email sends (login links + claim codes: 5/h +
20/day per recipient, 30/h per IP). See /auth.md.