~6 min readgrounded in packages/contracts/src/{index,sse,chat,auth,devices,push,tiny}.ts (269 lines) · packages/contracts/package.json · docs/CONTRACTS.md · apps/worker/src/get.ts · apps/web/lib/tiny-record.ts
Wire contracts·
packages/contracts is @tiny-vercel/contracts: seven files, 269 lines, types and string constants only — no runtime code. It is the ABI between the substrate and everything that talks to it: the Next.js app, the worker, the native apps, the tiny-vercel (apps/cli) daemon, a board on the wall, an endpoint device. The app and the worker both import it (apps/web/lib/tiny-record.ts, apps/worker/src/get.ts, six routes), so a constant changed in one place changes for both.
The package is private to the monorepo ("private": true, version 0.0.0); the ledger CONTRACTS.md records each change. This page is what is in it today.
sse.ts — what POST /api/chat streams·
Every frame is data: <json>\n\n; the JSON is one of the events below plus a monotonic seq the client uses to detect dropped frames (ChatWireFrame = ChatWireEvent & { seq }). A comment frame : ping arrives every SSE_KEEPALIVE_MS = 15 000 ms so proxies keep the connection; the stream ends with data: [DONE] (SSE_DONE).
type |
Carries | When |
|---|---|---|
modelMessageStartEvent |
— | the model begins a message |
modelContentBlockStartEvent |
toolStart: {name, toolUseId} |
a tool-use block opens |
modelContentBlockDeltaEvent |
textDelta? · reasoningDelta? · toolInputDelta? · citationsDelta? |
streamed text, reasoning, or tool-input JSON |
modelContentBlockStopEvent |
— | a block closes |
modelMessageStopEvent |
stopReason? |
the model finished (end_turn, tool_use, …) |
modelMetadataEvent |
usage {inputTokens, outputTokens, totalTokens} · metrics · modelId |
after each model call |
beforeToolCallEvent |
toolCall: {name?, toolUseId?, input?} |
a tool is about to run — the frame a device acts on for client-side tools |
afterToolCallEvent |
toolResult: {name?, toolUseId?, status?, content?, error?} |
it ran; name is back-filled from the turn's id→name map when the SDK omits it; base64 image bytes are elided as {image:{elided:true}} |
toolStreamUpdateEvent |
toolStream: {toolUseId?, name?, data?} |
a tool that streams partial output |
toolResultBlock |
toolResultBlock: {toolUseId?, status?, content?} |
legacy final result, kept for older clients |
agentResultEvent |
stopReason? |
the turn is over |
error |
error: string |
pre-stream on a preflight refusal (no model key), or mid-stream on an unrecoverable failure |
contextCompacted |
dropped, … |
the route dropped the older half of the history after a too-long-context rejection |
| anything else | type only |
other SDK lifecycle events are forwarded as markers |
ToolResultStatus is 'success' | 'error' plus any string the SDK might add.
chat.ts — the request·
interface ChatRequestBody { messages: ChatMessage[] } // required, non-empty; last 31 kept
interface ChatMessage { role: 'user'|'assistant'|'system'; content: string | unknown[] }
A string content is normalised to blocks server-side. Identity and bring-your-own-key travel in headers, all optional; header model config wins over the user's synced config:
CHAT_HEADERS key |
Header |
|---|---|
name |
x-tiny-name — which tiny answers |
systemPrompt |
x-tiny-system-prompt — override |
session |
x-tiny-session — client session id (tiny-ios, tiny-android change the tool set) |
metadata · legacyMetadata |
x-tiny-metadata · x-tiny-ip (old alias, still read) |
key |
x-tiny-key — a private tiny's key |
mcpServers |
x-tiny-mcp-servers |
x402Settled |
x-tiny-x402-settled — set by the x402 door after settlement |
internalKey |
x-internal-key — worker → app only |
modelProvider … modelAdditionalFields |
x-tiny-model-provider, -api-key, -id, -base-url, -max-tokens, -region, -additional-fields |
ModelProvider = openai · bedrock · gemini · google · vercel · anthropic · openrouter · groq · deepseek · mistral · xai · perplexity · custom — Configure models says which of these the server routes natively and which go through the OpenAI-compatible path.
auth.ts — the session·
| Constant | Value | Meaning |
|---|---|---|
SESSION_COOKIE |
tiny_session |
an HS256 JWT, Path=/; HttpOnly; Secure; SameSite=Lax |
SESSION_TTL_S |
30 days | cookie sessions |
CLI_TOKEN_TTL_S |
90 days | the same JWT shape sent as Authorization: Bearer |
OAUTH_STATE_COOKIE |
tiny_oauth_state |
the login-CSRF nonce during the GitHub round trip |
CLI_CODE_AUDIENCE · CLI_CODE_TTL_S |
tiny-cli-code · 300 s |
the one-shot code /api/auth/cli mints |
CLI_TOKEN_AUDIENCE |
tiny-cli |
the long-lived token /api/auth/cli/token returns |
IOS_AUTH_SCHEME |
tinyapp |
the only non-loopback redirect /api/auth/cli accepts |
sub is users.id — the userId on every internal-key call the app makes to the worker. There is no email claim in the JWT; email lives only in D1. Identity.
devices.ts — enrolment, presence, relay·
Bodies the app accepts (all field names exact):
| Type | Fields |
|---|---|
DeviceEnrollBody |
userId, name, platform?, kind?, capabilities?, url?, secret? — url+secret make an endpoint device |
DeviceHeartbeatBody |
deviceId, token, capabilities?, lanUrl?, wantUnread? |
DeviceEventBody |
deviceId, token, kind, detail? |
DeviceAskBody |
userId, deviceId, action, prompt? |
RelaySendBody |
toDevice, payload: RelayEnvelope |
RelayPollBody |
deviceId, token, max? |
RelayReplyBody |
deviceId, token, inReplyTo, payload |
TaskResultBody |
deviceId, token, taskId, summary?, result? |
RelayDepositBody (worker-facing) |
userId, ticket, payload |
Envelopes:
interface InvokeEnvelope { type: 'invoke'; prompt: string } // "do this"
interface NotifyEnvelope { type: 'notify'; title: string; body: string; tag: string; url: string }
type RelayEnvelope = InvokeEnvelope | NotifyEnvelope | { type: string; … } // open for new kinds
RELAY_PAYLOAD_MAX_BYTES = 8192 — a payload must be valid JSON under that; larger results go through /api/media as a URL.
The relay_messages row (RelayMessageRow): id, user_id, to_device, in_reply_to | null, payload (JSON string), created_at, delivered: 0 | 1.
The worker's exact error strings — the app classifies on these, never on prose:
RELAY_WIRE |
String |
|---|---|
notFound |
device not found |
tooBig |
payload must be valid JSON ≤8KB |
unauthorized |
unauthorized |
missing |
userId and toDevice required |
…and what a send resolves to, so every client renders the same eight outcomes:
type RelaySendKind = 'queued' | 'no_such_device' | 'too_big' | 'bad_request'
| 'server_key' | 'relay_fault' | 'unreachable' | 'no_envelope'
type RelaySendResult =
| { queued: true; kind: 'queued'; id: string }
| { queued: false; kind: Exclude<RelaySendKind,'queued'>; error: string;
delivered: 'no' | 'unknown'; retryable: boolean }
delivered: 'unknown' is the honest answer when the worker faulted after possibly writing the row. Devices.
push.ts — Web Push·
interface PushPayload { title?: string /* ≤100, default 'tiny' */; body?: string /* ≤400 */;
url?: string /* default '/' */; tag?: string /* default 'tiny-notification' */ }
interface PushSendResult { sent: number; pruned: number; relayed: number }
PUSH_TITLE_MAX = 100, PUSH_BODY_MAX = 400. pruned counts subscriptions the push service rejected as gone (410/404) and the worker deleted; relayed counts copies that went to devices via the relay instead. Notifications.
tiny.ts — one sentinel·
The worker answers a lookup for a tiny that does not exist with HTTP 200 and this string under response (get.ts) — older proxy routes put it under message. isTinyNotExists() in lib/tiny-record.ts checks both. It is the one place the upstream product's name survives in the code, kept verbatim because clients built against the original protocol (the native apps, boards in the field) match on the exact string; the drift ledger records the decision. It is a sentinel, not an address — nothing dials it.
Using it·
From inside the monorepo the package resolves through the workspace. Outside it — a daemon, a client in another language — copy the constants you need; the file names above are the whole surface, and docs/CONTRACTS.md is where a change must be written down first.