arizuko › components › route tokens
The route_tokens table plus the two URL prefixes that read from it: /chat/<token>/ for visitor web chat, /hook/<token> for inbound webhooks. One row per token, four columns (token_hash, jid, owner_folder, created_at). The JID prefix tells webd which surface a token belongs to.
Each token is 32 random bytes, base64url-encoded. Stored as sha256(token); raw token returned once at issuance.
Two long-standing needs — “give my site a chat URL” and “let GitHub POST events to the agent” — want the same primitive: a URL bound to a destination JID. Before this table the chat URL was hardcoded as one column on the groups table and webhooks improvised on top of it. Pulling the token out into a first-class row gives every inbound URL a uniform shape, a uniform mint surface, and a uniform revoke step.
GET /chat/<token>/ → chat widget (HTML)
POST /chat/<token>/ → message in; SSE reply stream out
POST /hook/<token> → one inbound at the JID; 204; no reply
Chat tokens carry JIDs prefixed web:; hook tokens carry hook:. Both URL prefixes accept any valid token — the kind is metadata for the agent, not a URL gate. Inbound JID is whatever the token row stores; the surface contract (widget+SSE vs fire-and-forget) is fixed by the URL.
browser / curl / GitHub / Linear / ...
|
v /chat/<token>/... or /hook/<token>
|
proxyd (public path; per-token rate shield)
|
webd (hash token, look up row, append inbound at row.jid)
|
gated (route + spawn the agent; SSE replies only for /chat/)
Issuance is handled in gated — both the MCP tools (issue_chat_link, issue_webhook) and the REST endpoints (POST /v1/route_tokens/chat, POST /v1/route_tokens/hook) call one internal insertRouteToken writer. webd never writes the table; gated never reads the /chat/ or /hook/ URLs.
The grants tier of the caller bounds which folders they can mint for. Tier 0 mints for any folder; tier 1 mints for self and descendants; tier 2 mints for self only; tier 3+ cannot mint. The row's owner_folder records the issuer's folder, not the JID target — revocation requires admin on owner_folder.
Per-token in-memory bucket in webd. The bucket ceiling is chosen by JID prefix: web: uses a smaller bucket sized for human typing; hook: uses a larger bucket sized for machine bursts. Body cap is 1 MiB. Excess returns 429.
No body signature verification (no X-Hub-Signature check), no second-factor auth on the URL, no per-IP fingerprinting. The bearer model is intentional — the URL is the credential. Upstream HMAC validation is a skill concern, executed on the agent side from the row's headers field.
No automatic reissue on schedule. No back-channel reply on /hook/ — agents that want to acknowledge a webhook reply on a different channel (Slack, email).
The table and writer live in gated; the URL handlers live in webd. Neither runs standalone — both need the shared SQLite file and the gated /v1/messages endpoint to deliver inbounds. The unit of standalone use is webd itself; see components/webd.
/chat/* and /hook/*.specs/5/W-webhook-routes.md — the route_tokens spec.