Skip to content

~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
interface SessionUser { sub: string; login: string; name?: string; avatar?: string }

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·

export const TINY_NOT_EXISTS = 'tiny.technology is not exists'

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·

import { CHAT_HEADERS, SSE_DONE, type ChatWireFrame } from '@tiny-vercel/contracts'

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.