~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,.internaland 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. Sendcapabilities(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_devicetakesparsed.result ?? parsedfrom your reply. Return{result: "…"}for prose. If your turn produced pictures, upload them throughPOST /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_devicewaits about 45 s; a slower job is returned aspending: trueand 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/deviceslists the device with the rightkindandcapabilities;onlineistrue(dial-in) ornull(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