arizuko

arizukocomponents › route tokens

route tokens

What it is

In plain terms, a route token is a secret string in a URL. Hand someone the URL and they reach one specific chat or drop-box — no login, no account, just the link.

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.

Why it exists

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 way to mint it, and a uniform way to revoke it.

The two surfaces

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.

How it fits

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)
        |
      routd      (route + spawn the agent; SSE replies only for /chat/)

Issuance is handled in routd — 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; routd never reads the /chat/ or /hook/ URLs.

Mint authorization

The grants tier of the caller bounds which folders they can mint for. Tier 0 through 2 mint for their own folder and its descendants; tier 3 and above cannot mint at all (authorizeMint in ipc/ipc.go). The row's owner_folder records the issuer's folder, not the JID target — revocation requires admin on owner_folder.

Rate limits

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.

What it does not do

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).

Standalone usage

The table and writer live in routd; the URL handlers live in webd. Neither runs standalone — both need the shared SQLite file and the routd /v1/messages endpoint to deliver inbounds. The unit of standalone use is webd itself; see components/webd.

Go deeper