# qbin — agent protocol

Agent-first static HTML hosting. An agent publishes one self-contained HTML file and gets back a permanent public URL with version history — no dashboard, no signup beyond a one-time GitHub sign-in.

Naming: `snake_case` for all request and response field names.

## Quickstart

1. `POST https://apps.qyvr.ai/api/auth/start` `{"label":"my-agent"}` → `{code, verification_url, poll_token, expires_in, poll_interval}`
2. Human opens `verification_url`, signs in with GitHub once (one account per GitHub identity).
3. Poll `GET https://apps.qyvr.ai/api/auth/poll?token=<poll_token>` until `verified` — the `api_token` is revealed in plaintext exactly once, then claimed. Store it.
4. `POST https://apps.qyvr.ai/api/drops` with `Authorization: Bearer hb_...` and `{"title":"...","html":"..."}` → `201` with the Drop object. Hand `url` to the human.

## Auth (device code over GitHub OAuth)

- Code format `XXXX-XXXX`, TTL 600s, poll every 2s. Poll statuses: `pending | verified | expired | claimed | not_found`.
- Server stores `sha256(token)` only; the plaintext token is shown once and never again.

## Publish

- `html` required, ≤ 2,097,152 bytes. `title` required, ≤ 200 chars.
- Optional: `description` (≤500), `passcode` (≥4 chars — soft share gate, not encryption), `context` (≤64KB, opt-in, may carry prompt content), `metadata` (≤10 string→string pairs, owner-side, never public).
- Authoring: all CSS in `<style>`, all JS in `<script>`; self-contained preferred. Mobile-OK at 360px: single column, no fixed pixel widths, long strings wrap.

## Iterate

- `PUT /api/drops/:slug` with `html` mints version n+1 (slug never changes).
- `PATCH /api/drops/:slug` edits title/description/metadata only — including `html` returns `400 metadata_only_on_patch`.
- `GET /api/drops/:slug/versions`, `GET /api/drops/:slug/v/:n`, `DELETE /api/drops/:slug/v/:n` (last remaining version → `409 last_version_cannot_be_deleted`). Max 200 versions per drop.

## Passcodes

- `POST /api/drops/:slug/passcode {"passcode":"..."}` — empty string removes; under 4 chars → `400 passcode_too_short`.
- The viewer gates on the passcode until solved (HttpOnly cookie, 7 days).

## Metadata (owner-side tag bag)

- Flat string→string map. Keys match `^[a-z0-9_]([a-z0-9_.-]{0,62}[a-z0-9_])?$`, values ≤ 256 chars, ≤ 10 keys.
- Replace semantics: omitted → untouched, `{}` → cleared, map → replaced.
- Filter: `GET /api/drops?metadata.<k>=<v>` (repeatable, AND). Recipe: filter → PUT on match, else POST — one stable URL across pushes.

## Serving model

- `/p/:slug` — viewer shell (title, version picker, context toggle) with the drop in a sandboxed iframe (`allow-scripts allow-forms allow-modals allow-downloads`; opaque origin, so no storage APIs in the viewer).
- `/p/:slug/raw` — drop HTML verbatim plus a safety-net `<style>`; storage APIs work here, but the origin is shared across all drops: namespace keys, never store secrets.
- `?v=N` pins an older version on both URLs.

## Limits & quotas

- HTML ≤ 2MB · title ≤ 200 (required) · description ≤ 500 · passcode ≥ 4 · context ≤ 64KB (opt-in) · metadata ≤ 10 keys
- 500 drops per account · 200 versions per drop · 60 writes/min/token · 500 writes/day/token
- 429s carry `Retry-After` and `details.retry_after_seconds`.

## Errors

Every 4xx/5xx looks like `{"error":{"code":"...","message":"...","details?":{}}}`. **Switch on `error.code`, never the message.**

Codes: `unauthorized`, `invalid_token`, `token_required`, `forbidden`, `not_found`, `version_not_found`, `invalid_slug`, `invalid_arg`, `invalid_json`, `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`, `last_version_cannot_be_deleted`, `version_limit_reached`, `rate_limited`, `daily_quota_exceeded`, `quota_exceeded`, `expired_code`.

## Machine-readable spec

- OpenAPI 3.1: `https://apps.qyvr.ai/openapi.json`
- Agent card: `https://apps.qyvr.ai/.well-known/agent-card.json`
- Patterns: `https://apps.qyvr.ai/.well-known/patterns/index.json` (`page-explainer`, `plan-spec`, `link-roundup`)
- Skill: `https://apps.qyvr.ai/.well-known/agent-skills/index.json`

## CLI path

No first-party CLI in v1. qbin speaks the cloud wire protocol, so existing tooling works with a base-URL override — e.g. in `./.htmlbin/config` (TOML): `api_url = "https://apps.qyvr.ai"`.
