~12 min readgrounded in apps/web/app/api/**/route.ts (65 files, each read for its exported verbs, header comment, body destructuring and runtime) · packages/contracts
HTTP API·
Sixty-five route files under apps/web/app/api/. Each row below is read from the file: the verbs it exports, its header comment, and the fields it destructures from the request. The app is a thin front for the worker — most routes validate, attach identity, and proxy to an internal worker endpoint with a 10 s bound; a hung worker degrades to 503 {error} rather than an opaque 500.
How to read the tables·
Auth is one of:
| Label | Means |
|---|---|
| session | a tiny_session cookie (browser) or Authorization: Bearer <cli token> — the same getSession everywhere. Missing → 401 {"error":"login required"} |
| device | {deviceId, token} in the body; no session. The worker resolves the owner from the token hash, and any userId in the body is ignored. Off the per-IP limiter |
| internal | X-Internal-Key from the worker's cron — not for clients |
| public | anyone; usually IP-rate-limited when it costs something |
| owner | session and OWNER_LOGIN |
Runtime: routes run on Vercel's Edge unless marked Node — those are the ones that need > 25 s to first byte or new Function/node:crypto.
Bodies are JSON unless stated. ? marks optional fields.
Health·
| Route | Auth | Returns |
|---|---|---|
GET /api/health |
public | {ok, service:"web", workerConfigured, appUrlConfigured, paymentsEnabled} — booleans only, never values |
Identity·
| Route | Auth | Body / query | Returns |
|---|---|---|---|
GET /api/auth |
public | — | redirect to GitHub authorize; state = <nonce>:<path> with the nonce in an httpOnly cookie (login-CSRF guard) |
GET /api/auth?code= |
public | GitHub callback | upserts the user in D1 via the worker, sets tiny_session, redirects to path |
GET /api/me |
session | — | the user + owned tinys + reputation standing; 401 when logged out |
GET · POST /api/logout |
any | — | clears the cookie |
GET /api/auth/webauthn/register |
session | — | registration options; challenge in a signed httpOnly cookie |
POST /api/auth/webauthn/register |
session | attestation | verifies, stores the credential in D1 |
GET /api/auth/webauthn/login |
public | — | authentication options (discoverable / usernameless) |
POST /api/auth/webauthn/login |
public | assertion | verifies against the stored public key, issues a session |
POST /api/auth/cli |
session | {port, state} or app scheme |
mints a 5-minute aud:tiny-cli-code JWT and redirects to 127.0.0.1:<port> (or the tinyapp scheme — an allowlist, never a client URL) |
POST /api/auth/cli/token |
public | {code, state} |
{ok, token, user, expires} — a 90-day aud:tiny-cli JWT accepted as Bearer by every session route |
GET /api/udid?profile=1 |
public | — | a .mobileconfig Profile Service payload |
POST /api/udid |
public (the iPhone) | PKCS7-signed plist | stores the UDID, 301 to /ios/registered.html?udid= |
GET /api/udid?list=1 · ?count=1 |
owner | — | enrolled UDIDs / their count |
The whole flow is explained in Identity.
Tinys·
| Route | Auth | Body / query | Returns |
|---|---|---|---|
POST /api/tiny |
public, IP-limited | {name, key?} |
the tiny's public record {name, private, active, systemPrompt…}; the owner's session, or the right key for a private tiny, unlocks the full config incl. MCP headers. Missing → {active:false} |
POST /api/login |
public, IP-limited | {name, key?} |
{name, ok, private, active…} — the private-tiny key check; the IP window is the brute-force budget, so no per-user widening |
POST /api/control |
session | {name, systemPrompt?, systemKnowledge?, data?, key?, hook?, priv?, worker?, schema?, skills?, mcpServers?, hero?, theme?, logo?, intro_vibe?, chips?, tagline?, voice?} |
create or save a tiny; the worker authorizes by ownership (userId match) |
DELETE /api/delete |
session | {name} |
permanently removes a tiny you own |
POST /api/worker |
public, IP-limited | {name, worker} |
fetches the URL you supply (size-bounded), {message:"Worker is active.", schema} — this is the route that fetches a caller-supplied URL, hence IP-keyed |
GET /api/manifest/[slug] |
public | — | a per-tiny PWA manifest; icons are square with true sizes (a mismatch has crashed Chrome's browser process) |
POST /api/visit |
public, IP-limited | {name} |
pageview beacon → owner notification, with the visitor's identity when signed in |
GET /api/follow?login= |
session | — | {following} |
POST /api/follow |
session | {login, action?: "follow" \| "unfollow"} |
the follower is always session.sub; follows are public edges |
POST /api/share |
session, limited | {name, messages[]} |
{id, url} — KV-backed share |
GET /api/share?id= |
public | — | the shared conversation |
DELETE /api/share |
session | {id} |
removes your share |
The agent loop·
| Route | Auth | Body / query | Returns |
|---|---|---|---|
POST /api/chat |
session or public (free tier) | {messages:[{role, content}]} + headers x-tiny-name, x-tiny-session, x-tiny-key, x-tiny-system-prompt, x-tiny-mcp-servers, x-tiny-metadata, x-tiny-model-{provider,id,api-key,base-url,region,max-tokens,additional-fields} |
an SSE stream of typed frames ending in [DONE] — The agent loop has the frame table |
POST /api/chat/tool-result |
session | {toolUseId, payload} |
{ok} — a device returning the outcome of a client-side tool (media included) so the model sees it |
POST /api/run-tool Node |
internal | {action:"validate", code} · {action:"run", code, args} |
{ok} / {ok, result} / {ok:false, error} — the sandbox the Edge chat route cannot host |
GET /api/tools |
session | — | {ok, tools:[{name, description, params, code, created}]} |
POST /api/tools |
session | {name, description, params?, code} |
forge a tool (sandbox-validated) |
DELETE /api/tools |
session | {name} |
{ok} |
POST /api/tools/run |
session, per-user limited | {name, args?} |
{ok, result} — run one of your forged tools; what the tiny-vercel MCP server exposes |
POST /api/tools/install |
session | {login, name} |
{ok, name} — copy another builder's public tool into your box, re-validated |
GET · POST · DELETE /api/tools/trust |
session | {owner} |
trusted GitHub owners for install_tool (cap 20) — a user action the model cannot take itself |
Devices·
| Route | Auth | Body / query | Returns |
|---|---|---|---|
GET /api/devices |
session | — | {ok, devices:[{id, name, kind, online, last_seen, capabilities, lan_url, url}]} |
POST /api/devices |
session | {name, platform?, kind?, capabilities?} or {name, kind:"endpoint", url, secret} |
{ok, device_id, device_token} — the token appears once; endpoint devices get no token |
DELETE /api/devices |
session | {deviceId} |
{ok} — instant revoke |
POST /api/devices/adopt |
session | {deviceId} |
{ok, device_id, device_token} — rotates the token for a device you own; the old one stops immediately |
POST /api/devices/heartbeat |
device | {deviceId, token, capabilities?, lanUrl?, wantUnread?} |
{ok, unread?} — presence for 60 s |
POST /api/devices/relay |
session | {toDevice, payload} (JSON ≤ 8 KB) |
{id} |
GET /api/devices/relay?inReplyTo= |
session | — | {reply?} |
PUT /api/devices/relay |
device | {deviceId, token, max?} |
{ok, messages:[{id, payload, created_at}]} |
PATCH /api/devices/relay |
device | {deviceId, token, inReplyTo, payload} |
{ok} |
POST /api/devices/event |
device | {deviceId, token, kind, detail?} |
onto the owner's event ring; kind allowlisted |
POST /api/devices/task-result |
device | {deviceId, token, taskId, summary?, result?} |
deposited under a task_* ticket + one push |
POST /api/devices/transcript |
device | {deviceId, token, text, label?, audioUrl?, durationS?} |
stores on-device transcriptions past the relay sweep |
GET /api/devices/transcript?id=&limit= |
session | — | a device's transcripts |
POST /api/devices/ask Node |
device | {deviceId, token, text \| audioUrl, tiny?, stream?} |
{text, card?} — an owner-scoped agent turn |
POST /api/devices/messages |
device | {deviceId, token, op:"send"\|"inbox"\|"thread"\|"unread", to?, body?, attachments?, with?, limit?} |
the DM rail for a device with a keyboard |
GET /api/devices/endpoint?deviceId=&action=telemetry\|snapshot |
session | — | {ok, result} or image bytes (GET so it works as <img src>) |
POST /api/devices/endpoint/chat Node |
session | {deviceId, prompt} |
{ok, result} · {ok:false, error, unreachable\|timeout\|unauthorized} — one 90 s agent turn on an endpoint device |
POST /api/firmware/manifest |
session | {channel, version, url, sha256} |
{ok} — point a channel at an artifact (a pointer, not a copy) |
GET /api/firmware/manifest?channel= |
session | — | {bundle?} |
PUT /api/firmware/manifest |
device | {deviceId, token, channel} |
{bundle?} — what a board polls |
Devices · Bring your own device.
Memory, graph, settings·
| Route | Auth | Body / query | Returns |
|---|---|---|---|
GET /api/learnings?q=&limit= |
session | — | {learnings, relevant?, total} — q is semantic recall |
POST /api/learnings |
session | {content} (≤ 2000 chars) |
store one memory |
DELETE /api/learnings |
session | {id} · {scope:"all"} |
close one (bitemporal) · erase everything + purge the index — a blank id is refused with 400 |
GET /api/graph |
session | ?node=&hops=&rels= · ?all=1&include_closed= · ?conflicts=1 · ?social=<node> · ?feed=1&limit= |
subgraph · whole fact graph · contradiction candidates · public social edges + trust · fresh facts from followed builders |
POST /api/graph |
session | {keep, close[]} |
resolve a conflict |
GET · POST · DELETE /api/archives |
session | ?id= · {tiny, messages} · {id} |
{archives} · archive JSON · {id} — rebuilt server-side so credential redaction always runs |
GET /api/prefs?key= · POST |
session | {key, value} |
{ok, value} — key allowlist (theme…); empty value clears |
GET · POST /api/model-config |
session | {config} |
{ok, config} — never the API key, only hasKey; omit apiKey to keep, "" to clear |
GET /api/model-providers · ?full=1 |
session | — | {ok, providers:[…hasKey, isActive]} · with decrypted keys — the cross-device sync read for your own devices |
POST · DELETE /api/model-providers |
session | {provider, modelId?, baseUrl?, region?, maxTokens?, additionalFields?, apiKey?, isActive?} · {provider} |
upsert one provider · remove |
GET · POST /api/account-voice |
session | {voice} |
{ok, voice} — the account-wide default live-call voice |
Notifications, messages, jobs·
| Route | Auth | Body / query | Returns |
|---|---|---|---|
GET /api/events?sinceId= |
session | — | the activity ring (200-cap); a worker outage is surfaced, not read as "nothing yet" |
GET /api/push |
session | — | {key} — the VAPID public key from the worker |
POST /api/push |
session | a PushSubscription |
stored for this user |
DELETE /api/push |
session | {endpoint} |
removed |
GET /api/messages · ?with= |
session | — | inbox with unread counts · one thread (marks read) |
POST /api/messages |
session | {to, message, attachments?} |
send; to = @login, login or tiny slug; ≤ 4 attachments from /api/media |
DELETE /api/messages |
session | {id} |
delete a message you sent |
GET /api/telegram |
session | — | {bot:{tiny, allowedChats, enabled, token(masked)} \| null} |
POST · DELETE /api/telegram |
session | {token?, tiny?, allowedChats?, enabled?} |
configure · disconnect |
GET /api/jobs |
session | — | {jobs, runs} |
POST /api/jobs |
session | {tiny?, name, prompt, schedule? \| run_in_minutes?} |
create |
DELETE /api/jobs |
session | {id} |
delete |
POST /api/job-run Node |
internal | the job, from the cron | one non-streaming agent turn with the owner's full capability set |
Notifications & Web Push · Scheduling jobs.
Voice·
| Route | Auth | Body / query | Returns |
|---|---|---|---|
POST /api/voice/session |
session | {tiny} |
{sessionId, wsUrl, ticket} — BYO OpenAI key only; the key goes to the Durable Object, never the browser |
GET /api/voice/sessions |
session | — | your sessions, recent first |
POST /api/voice/tool |
session | {name, args} |
the tool's result — an allowlist of server tools (memory); money movers and tiny CRUD stay chat-only |
GET /api/voice/replay/[id] |
session, owner of the record | — | the D1 row + R2 asset URLs; fails closed on an unowned row |
GET /api/voice/recording-status/[id] |
session, owner | — | why a recording would not play (the worker's 409/413/404/424 body, made same-origin readable) |
Location and media·
| Route | Auth | Body / query | Returns |
|---|---|---|---|
GET /api/location |
public | — | {ok, me, pins:[{userId, login, name, avatar, lat, lng, speedKmh, heading, updated}]} — pins are opt-in data |
POST /api/location |
session | {lat, lng, speedKmh?, heading?, accuracyM?} |
{ok} — beating is the opt-in |
DELETE /api/location |
session | — | {ok} — the pin vanishes |
POST /api/media |
session or device | {data (base64), contentType} · + deviceId, token |
{key, url} — R2 under an unguessable key, served from the worker's /media/:key |
Money — 404 until PAYMENTS_ENABLED="true"·
Every route here answers 404 before touching the worker or a chain unless payments are on for both app and worker. Payments.
| Route | Auth | Body / query | Returns |
|---|---|---|---|
GET /api/wallet |
session | — | {ok, balance_micro, history} |
POST /api/wallet |
session | {action:"set_price", resource, price_micro} · {action:"pricing", resource} |
set/clear a price · public lookup |
POST /api/wallet/faucet Node |
session | — | one drip per UTC day on a self-hosted chain: ledger first, then mint |
POST /api/wallet/withdraw Node |
session | {amount_micro} |
atomic ledger debit → signed USDC transfer to your linked address (never a body field) → recorded, or refunded |
POST /api/x402/chat/[slug] |
public | {message} (+ X-PAYMENT on retry) |
402 + PaymentRequirements, then the answer after settle-before-serve; free tinys answer without the dance |
GET /api/x402/chat/[slug] |
public | — | the price/requirements |
POST /api/x402/pay Node |
session | {url \| to, message \| prompt, max_spend_micro?, prior_quote?} |
a quote — no money moves |
PUT /api/x402/pay Node |
session | {quote, message \| prompt} |
execute: debit the ledger, sign, settle — the only outbound money-moving path |
GET /api/chain/join · ?format=genesis |
public | — | how to run a node · the raw genesis |
GET /api/chain/status Node |
public | — | the explorer's facts as JSON, spans and limits clamped |
GET /api/erc8004/registration/[slug] |
public | — | the ERC-8004 registration file for a public tiny; private tinys 403 |