Skip to content

~8 min readgrounded in packages/contracts/src/devices.ts · apps/worker/src/{devices,relay}.ts · apps/web/app/api/devices/**/route.ts · apps/web/lib/chat/tools/platform.ts (use_device) · examples/echo-device/

Bring your own device·

A device in tiny-vercel is a row in D1 and one of two conversations. Either tiny calls you — you run an HTTPS API and the worker dials it with a bearer — or you call tiny — your process holds a device token, heartbeats, and polls a mailbox. Pick by where the code can live:

Endpoint (dial-out) Dial-in
Fits a robot, printer or service that already has an HTTP API and a public HTTPS address a laptop daemon, a phone, an ESP32, anything behind NAT
Credential your secret, held by the worker a device token, held by the device
Reachable when your URL is up your process last heartbeated within 60 s
Effort 3 routes 1 heartbeat + 2 relay calls

Devices is the reference for both; this page is the build guide.

Path A · An endpoint device·

1 · Serve three routes·

Any language. Every /api/* route must require Authorization: Bearer <secret> and answer 401 otherwise.

tiny calls You return Budget
GET /api/telemetry any JSON — state, sensors, uptime 20 s
POST /api/chat {prompt} {reply} — or {result}, {text}, or plain text 90 s
GET /api/camera/snapshot image/png, image/jpeg or image/webp bytes, ≤ 8 MB 10 s

/api/chat is what the agent's use_device tool uses, so put a real agent — or at least real answers — behind it. A device that only echoes the prompt makes the model conclude it has no data; examples/echo-device/server.mjs answers status-shaped questions with its telemetry inline for exactly that reason.

Rules the worker enforces, so build to them:

  • https:// and a public hostname. IP literals in any encoding, localhost, .local, .internal and dotless hosts are refused — the worker fetches this URL server-side, and a private address would turn the registry into a pivot into Cloudflare's network.
  • No redirects. A 3xx is refused, never followed (it could bounce the bearer to another origin). Serve the API at the URL you register.
  • A 401/403 from you is reported to the owner as "device rejected our credential".
  • Snapshot is a still frame. Do not point it at a multipart stream; the worker pins the content type from its own allowlist and never echoes yours.

2 · Prove it with curl before enrolling·

S=https://<your-device>; A="Authorization: Bearer $SECRET"
curl -s -o /dev/null -w '%{http_code}\n' $S/api/telemetry        # 401 without the bearer
curl -s -H "$A" $S/api/telemetry                                # your JSON
curl -s -H "$A" -H 'content-type: application/json' \
     -d '{"prompt":"status?"}' $S/api/chat                      # {"reply":…}
curl -s -H "$A" -o /tmp/f.png -w '%{content_type}\n' \
     $S/api/camera/snapshot                                     # image/png

No public address yet? cloudflared tunnel --url http://127.0.0.1:8080 (or ngrok, Tailscale Funnel) gives you an HTTPS hostname the worker accepts.

3 · Enroll·

From a signed-in browser session — the owner's session is the enrollment authority:

POST /api/devices
  {name, kind: "endpoint", url: "https://…", secret: "…", capabilities?: [...]}
→ {ok, device_id, kind: "endpoint", url}

No device token is minted for an endpoint device — nothing inbound may ever speak as it. examples/echo-device/enroll.mjs --app <origin> --url <https url> --name <name> does this for you, walking the same consent flow as the CLI, then calls GET /api/devices/endpoint?deviceId=&action=telemetry — one line proving app → worker → your process → back.

4 · Use it·

Open /devices: the row shows your address and a snapshot tile. In chat: "ask my device named <name> for its status". The tool calls POST {url}/api/chat through the worker and returns your reply.

Path B · A dial-in device·

No session, no inbound port. The device token authenticates every call and resolves the owner in one lookup; the device can only act inside its owner's world. Three calls are the whole protocol:

Call Body Answer
POST /api/devices/heartbeat {deviceId, token, capabilities?, lanUrl?, wantUnread?} {ok, unread?} — presence for the next 60 s
PUT /api/devices/relay {deviceId, token, max?} (1–50, default 10) {ok, messages: [{id, payload, created_at}]} — payload is a JSON string
PATCH /api/devices/relay {deviceId, token, inReplyTo: <id>, payload} {ok} — payload JSON, ≤ 8 KB

1 · Enroll and keep the token·

POST /api/devices  {name, kind: "daemon" | "browser" | "cli", platform?, capabilities?}
→ {ok, device_id, device_token}       ← the token appears exactly once

Store it like a password. Lost it? Do not enroll again (that leaves a ghost row with a frozen last_seen): POST /api/devices/adopt {deviceId} rotates the token for a device you own and returns the new one once.

2 · The loop·

A complete daemon, Node ≥ 18, no dependencies:

const APP = process.env.APP, id = process.env.DEVICE_ID, token = process.env.DEVICE_TOKEN
const call = (method, path, body) => fetch(APP + path, {
  method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body),
}).then(r => r.json())

async function handle(env) {                      // env = the parsed envelope
  if (env.type === 'invoke') return { result: await runLocally(env.prompt) }
  if (env.type === 'notify') { show(env.title, env.body); return { ok: true } }
  return { ok: false, error: `unknown envelope type ${env.type}` }
}

setInterval(() => call('POST', '/api/devices/heartbeat',
  { deviceId: id, token, capabilities: ['shell', 'screenshot'] }), 20_000)

for (;;) {
  const { messages = [] } = await call('PUT', '/api/devices/relay', { deviceId: id, token, max: 10 })
  for (const m of messages) {
    const reply = await handle(JSON.parse(m.payload))
    await call('PATCH', '/api/devices/relay', { deviceId: id, token, inReplyTo: m.id, payload: JSON.stringify(reply) })
  }
  await new Promise(r => setTimeout(r, messages.length ? 0 : 3_000))
}

What to get right:

  • Heartbeat under 60 s (PRESENCE_WINDOW_S), or the fleet shows you offline. Send capabilities (the full list — it replaces, it does not merge) — the agent reads that list to decide which device a task belongs to, and is told never to claim a device lacks something without checking it.
  • lanUrl (optional) is an address the phone app can dial directly on the same network.
  • Reply shape. use_device takes parsed.result ?? parsed from your reply. Return {result: "…"} for prose. If your turn produced pictures, upload them through POST /api/media {data (base64), contentType, deviceId, token} → {key, url} first and reply {result, images: [{url, format}]} — URLs on the worker's /media/ origin come back to the model as real image blocks; any other origin is refused.
  • Envelopes you did not ask for. {type:'invoke', prompt} asks your local agent to do something with its own tools; {type:'notify', title, body, tag, url} is a notification to show; anything else is firmware-defined — reply with an error rather than silence.
  • Time limits. The owner's use_device waits about 45 s; a slower job is returned as pending: true and the owner redeems it later (action: 'result'), so answer eventually — undelivered envelopes are dead-lettered after 1 hour, replies are kept 24 hours.

3 · Say what you noticed·

The pull model cannot cover a wake word or a door sensor. POST /api/devices/event {deviceId, token, kind, detail?} puts it on the owner's event ring; kind is allowlisted (nicla_wake, nicla_sentry, nicla_transcript, device_note) — use device_note for anything generic. A finished background job goes to POST /api/devices/task-result {deviceId, token, taskId, summary?, result?} and turns into one ring event and one push. A device with a microphone can POST /api/devices/ask with text or a media id and get {text, card?} from its owner's tiny — the card is a shape an e-ink can render natively.

Declaring capabilities·

Free strings, but choose them for the reader — a model. ['shell', 'screenshot', 'camera', 'print', 'move'] tells it what a device can do; ['v2', 'prod'] tells it nothing. Send the full list whenever you send it: a heartbeat that carries capabilities replaces the stored list, one that omits it leaves it untouched.

Checklist before you call it done·

  • GET /api/devices lists the device with the right kind and capabilities; online is true (dial-in) or null (endpoint — reachability is per-call)
  • From chat: "list my devices" names it; "ask <name> …" returns a real answer, not an echo
  • Endpoint: the three curl lines above pass from outside your network; a request without the bearer is 401
  • Dial-in: unplug it — the row goes offline within a minute; plug it back — it comes back without re-enrolling
  • Token or secret lives in a secret store on the device, never in the repo