---
name: warblet
description: Configure Warblet from the terminal — onboard devices with claim codes, write and test Starlark decoders, build cross-device routines, and read messages. Use whenever the user mentions Warblet (formerly Warble / Chirp IoT / ChirpIoT), a Warblet device or hw id, a decoder or decode(), a routine or routine(), a claim code, an ingest host, or asks to get a sensor onto a dashboard — and to file a bug or request a feature in Warblet itself.
---

# Warblet

An IoT backend for people who build things: a device is anything that can send a
packet. Devices post raw bytes over HTTP, MQTT, TCP or UDP; a **decoder** attached
to the device's **spec** turns those bytes into named fields; **routines**
watch every device's latest fields and take actions.

Connect over MCP (`https://api.warbletiot.com/mcp`) or plain REST
(`https://api.warbletiot.com/v1/*`), both with `Authorization: Bearer chk_…`. The
key comes from the portal's Settings page and is account-wide.

## Onboard a device: claim code first

This is the default flow. Do not send the user hunting for their device in a
list — generate both halves before the hardware ever transmits.

1. `spec_create` — a spec owns the decoder, the transport and the security
   level. Reuse an existing one via `spec_list` when the device is the same
   kind as something already there.
2. `device_id_suggest` — returns a free `adjective-animal-4hex` id, checked
   against both devices and unclaimed sightings. Call again to reroll. Any
   free-form id is legal, but a suggested one is guaranteed not to collide.
3. `claim_code_create` with the `specId` — returns `CHIRP-` + 8 base32 chars,
   single use, 72h expiry.
4. Hand the user the id **and** the code, and tell them where they go: the
   browser flasher at `/flash`, CHIRP-PROV over USB, or their own firmware. You
   cannot flash a board.
5. The device's first packet carries the code (`X-Chirp-Claim` header on HTTP,
   password on MQTT, `claim:CHIRP-…` in the TCP hello). It lands already bound to
   the account, and the claim code becomes its provisional token — the device
   keeps working with the credential it already has.

A device that already sent a packet before anyone thought about claim codes is found
with `unclaimed_search` (exact `hwId` or a suffix of 4+ chars — the inbox is not
browsable). That path backs off hard on consecutive misses; do not loop it.
A searched claim binds at **Open** (v0.15) — the level the sighting demonstrated —
so it is non-destructive: the device's next tokenless packet is still accepted.
The claim still returns a token once; push it to the board when you can, and the
platform elevates the device on its own from the first message that carries it.

Claim-code binding clamps to **Token at most**, whatever the spec says, and a
searched claim binds at Open — in both cases the spec's level is the DESIGN
target the device climbs toward, never the binding. No signing key is minted
by any claim (v0.15): `POST /v1/devices/{id}/signing-key` (one device) and
`POST /v1/specs/{id}/signing-keys` (a whole spec, v0.18) are the doors, and
elevation raises the level automatically on proof (token → Token; token+signature
→ Signed). There is no fourth level (v0.19): Encrypted is the per-device `enforceTls`
switch, not a rung.

## The Open starter: the smallest firmware that talks both ways

When someone wants a first board working, or asks you to write firmware from
nothing, start from the Open starter (public, MIT:
https://github.com/Robota-LLC/Warblet-IoT/tree/main/open-starter). It is one loop in
MicroPython (`main.py` and `open_setup.py`), one Arduino sketch, and one C file on
the sockets API, with no token, no TLS and no clock. Every 10 seconds (asking is
a POST with no body: a bare GET of that address is refused, contract v0.43):

    POST http://http.warbletiot.com/ingest/<id>/down  -> 200 and a command, or 204
    POST http://http.warbletiot.com/ingest/<id>       body "<count> <heard>" -> 202

A link with no TCP sends `CHIRP1 <id> - <count> <heard>` to
`udp.warbletiot.com:7701`, and nothing comes back. Read the status line and the
body; if you read a header, match its name case-insensitively (the HTTPS door
delivers them lower-cased). Its spec is Open, with one text control
(`{"type":"text","label":"Message"}`) and a decoder that splits the line at its
first space into `count` and `heard`. `device_id_suggest` gives the board an id.
It is claimed by that id, not by a claim code: the person uses Find device in the
portal and picks Open starter as the spec (there is no MCP claim tool; write them
the `POST /v1/devices/claim` curl if they want it). A command it collects
through the plain-HTTP door shows as Sent on the device page; the board's next
line is the proof it arrived. Anyone who knows the id can post as it, so take it
to Token before it controls anything: rotate its token (`POST
/v1/devices/{id}/token`; in the portal, the device's Security setting, Keys and
recovery, Rotate token), have the firmware send it in the `X-Chirp-Token` header,
and raise the spec's level to Token (`PATCH /v1/specs/{id}` with
`securityLevel: 1`; the spec page's Device authentication). The device moves up on its
first line that carries the token; raising the device itself instead would
refuse the board until its firmware has the token. `?token=` also works, but the
token then sits in logs along the way, and collecting must be a POST.

## The security ladder, briefly

**Three levels and one flag — two independent axes (v0.19).**

Auth axis, who may send: `securityLevel: 0` (Open, id only) → `1` (Token, a token
per device) → `2` (Signed, token plus an HMAC-SHA256 on every transport over
the signed input `<nonce> "\n" "tag=" <tag> "\n" <payload>` — both lines always
present, empty fields left empty, so no nonce and no tag signs
`"\ntag=\n" + payload`; contract v0.31). The integer is the wire value; the word is the label every surface
prints (v0.29.1). That is the whole ladder; **there is no fourth level**. Level
lives on the spec and can be overridden per device; raising a device to Signed
returns its signing key exactly once, so never discard that response.

Transport axis, who can read it: the per-device `enforceTls` switch — Encrypted,
in the portal — off by default and armable at **any** level — including Open,
which is the one-click way
to secure a fleet that already speaks HTTPS and holds no keys. With it off the
endpoint accepts plaintext whatever the level (deliberate — watch the wire
without going dark); turn it on and plaintext is rejected at the door. Never
imply a level delivers confidentiality: only this flag does, and only while it
is on. The core refuses exactly one case — arming it on a spec whose declared
transport (tcp/udp) has no TLS listener — and will otherwise arm it on a
device never seen over TLS, which takes that device dark until its firmware
posts to `https.`, or to MQTT on 8883.

## Decoders

`decoder_test` first, `decoder_put` second. Always. The test runs the sandbox
against a real sample payload, stores nothing, and returns either
`{"fields": {...}}` or `{"error": "…", "line": N}` — iterate there until the
fields are right, then write.

```python
def decode(payload, meta):
    t = int(payload[0]) << 8 | int(payload[1])
    return {"temp_c": t / 10.0}   # flat dict: str -> int|float|str|bool
```

Sandbox constraints — these are the ones that trip people up:

- The language is **Starlark (Python-like)**, not Python itself. No `import`, no I/O, no recursion,
  no classes, no `while`. Limits: 100k steps, 50 ms, and (contract v0.39) 32 MiB for any one
  value, 128 MiB held at once (the script's process limit), one `str`/`==`/sort/hash over at
  most 262,144 values and 256 KiB, 4096-bit
  integers, `float()` of at most 4,096 characters, at most 64 `for`/`if` clauses in a
  comprehension, crypto/hex input ≤ 1 MiB, `send` payload ≤ 8 KiB and hwId ≤ 128 bytes,
  `webhook` URL ≤ 2 KiB, `alert` subject ≤ 1 KiB / body ≤ 64 KiB. `fail()`/`print()` keep the
  first 4,000 bytes of their text and a stored error is at most 4,096 bytes. To build text
  from many pieces, collect them in a list and `"".join(pieces)` once - `s += piece` in a loop
  copies all of `s` every time and runs into the 50 ms clock. These bound abuse
  only; ordinary telemetry never comes near them.
- `payload` is `bytes`. Indexing gives a 1-byte `bytes`, and `int()` accepts one,
  so `int(payload[0])` is the idiom.
- `meta` is a dict with `hw_id`, `transport`, `ts_ms`, `secret` (the spec
  secret, empty string when unset) and `ip` (empty when there's no live packet).
- The return must be a **flat** dict of `str -> int|float|str|bool`. Nested
  dicts, lists, `None` values and text containing a NUL are not fields.
  Returning a non-dict or raising stores the message raw with a `decodeError` —
  sometimes deliberately: returning a string is the blessed way to reject a
  payload that fails its own HMAC check.
- Also available in decoders and routines: `crypto.hmac_sha256(key, data)`,
  `crypto.sha256(data)`, `hex(b)`, `unhex(s)`, and `block(seconds=3600)` which
  shuts out the source IP of the message being processed (account-scoped,
  clamped to 60–86400s). A `block()` sticks even when the script then raises —
  that is the point of it.
- `json.decode(payload)` reads a JSON payload (str or bytes, at most 16 KiB,
  nested at most 64 levels). A flat object can be returned as it is; from
  anything deeper, pick the values out and leave `None` values out, because the
  flat-dict rule above still applies. `json.decode(payload, None)` returns
  `None` instead of failing on a packet that is not JSON. `json.loads` is the same function, as in Python.
  There is no `json.encode`.

```python
def decode(payload, meta):     # the board sends {"temp_c": 21.4, "door": "open"}
    return json.decode(payload)
```

## Routines

(These were called *rules* through v0.8. The rename is complete and there are no
aliases: the entry point is `routine(`, the tools are `routine_*`, and a source
defining `rule(` is rejected at save time.)

`routine_test` first, `routine_create`/`routine_update` second. The dry run
evaluates against real account state overlaid with whatever `state` you pass, and
executes nothing: no downlink is queued, no webhook fires, no alert lands, `mem`
is not written back. It returns `{"actions": [...], "memAfter": {...}}`.

```python
def routine(event, state, mem):
    inside  = state.get("porch-sensor", "temp_c")
    outside = state.get("yard-sensor", "temp_c")
    if inside == None or outside == None: return
    if state.age_s("yard-sensor", "temp_c") > 600: return   # stale: do nothing
    on = mem.get("fan_on", False)
    if not on and inside > outside + 1.0:
        send("fan-controller", "fan:on");  mem["fan_on"] = True
    elif on and inside < outside - 1.0:
        send("fan-controller", "fan:off"); mem["fan_on"] = False
```

- `event` = `{"hw_id", "fields", "ts_ms", "spec_id"}` — the message that triggered
  this run. `spec_id` is `""` when the sending device has no spec.
- **Trigger scope: `specId`.** A routine bound to a spec is evaluated only for
  messages from devices of that spec — a message from anything else does not run
  it, does not advance its `minIntervalMs` debounce, and never appears in
  `routine_runs`. Unbound (the default) every message in the account evaluates
  it. This is trigger scope only: `state.get()` reads any device in the account
  either way. Set it on `routine_create`, change it with `routine_update`, and
  pass `specId: ""` to `routine_update` to unbind. Field names are an
  account-wide namespace, so binding is how you stop an unrelated device that
  happens to report `pressed` from firing your button routine.
- `state.get(hwId, field, default=None)` reads the latest decoded value of **any**
  device in the account; `state.age_s(hwId, field)` gives seconds since it
  updated, or `None` if never. Guard on both — a routine that trusts a stale
  reading is the most common bug here.
- `mem` is a dict persisted per routine between evaluations (≤4 KiB). Use it to
  latch: set a flag when you fire, clear it when the condition comfortably
  reverses. Two different thresholds for on and off, never one — otherwise the
  routine flaps on sensor noise.
- Actions **queue**, they don't execute inline: `send(hwId, payload)`,
  `webhook(url, body_dict)`, `alert(subject, body)`, `track(field, value)`.
  Max 16 per evaluation. `alert()` also emails when the account has an alert
  address set — check `settings_get` before promising the user an email.
- `track(field, value)` (v0.10) writes a **virtual field** onto the device that
  triggered the run: it renders on the dashboard like a decoded field and
  `state.get()` reads it back from the next run on. For counters prefer the
  state loop over `mem` — `n = state.get(event["hw_id"], "presses", 0) + 1`
  then `track("presses", n)` — because the value is visible, not hidden.
- `print(x)` goes to the routine's run log (`routine_runs`, newest first) and
  the dry-run result's `prints`. Use `routine_runs` to answer "what has this
  routine actually been doing?" — it records each live evaluation's trigger,
  queued actions, prints and error.
- A demo set up from warbletiot.com/flash may add its own routine, switched on
  (the doorbell camera's photo email, the two-board light pair). `routine_list`
  shows it with `installedFrom`, the demo's routine file; a routine written by
  hand has `installedFrom: null`. It is an ordinary routine: edit it, switch it
  off or delete it, but do not create a second copy, because both would fire.
  `devices_from(["demo-…"])` returns the hardware ids of the account's devices
  running those demos, which is how such a routine finds the other board.
- Debounce with `minIntervalMs` (default 5000; 0 evaluates on every message).

**The timing trap, worth saying out loud to the user:** routines are evaluated
after a message is stored and successfully decoded, for every enabled routine in
that account that is in scope for it. So a routine about a device going *silent*
only runs while something else in the account is still talking — and if the
routine is bound to a spec, only that spec's devices count as something else. A
lone device that stops sending cannot trigger an alert about itself.

**For a board's own silence, use its quiet alert instead of a routine.**
`device_quiet_alert {"deviceId": "dv_…", "on": true}` (or
`PATCH /v1/devices/{id} {"quietAlert": true}`): Warblet learns how often the board
sends and raises one alert when it stops and one when it sends again, whether or
not anything else in the account is talking - nothing to configure. While it is
on, `device_get` carries `quietRule` (`learning` until six messages, then the gap
it rarely goes past and how long a silence must last). Turn it on only when the
user asks, because it emails them; check `settings_get` and tell them if there is
no alert email (the alert then lands in the portal only). A board that only sends
when something happens can be quiet for hours and be fine - suggest a heartbeat.

### Letting a model decide: `ai()`

Routines — and only routines; never decoders — can call
`ai(question, choices=None)`, which asks Claude on the **account's own Anthropic
key** (the BYOK key in portal Settings; `settings_get` reports `hasAiKey`). Reach
for it when the judgement is genuinely fuzzy, not when a comparison would do: a
threshold is cheaper, deterministic, and testable.

```python
def routine(event, state, mem):
    inside  = state.get("porch-sensor", "temp_c")
    outside = state.get("yard-sensor", "temp_c")
    if inside == None or outside == None: return

    answer = ai("Inside " + str(inside) + "C outside " + str(outside) +
                "C humid. Fan on or off?", ["on", "off"])

    if answer != mem.get("fan", ""):
        send("fan-controller", "fan:" + answer)
        mem["fan"] = answer
```

- **With `choices`** (2–8 strings, each ≤40 chars, distinct) the call returns
  exactly one of them, verbatim. A reply matching none of them fails the
  evaluation like any other script error — it does not guess. Compare the result
  against the choices you passed, as above, and you never have a surprise branch.
- **Without `choices`** you get free text truncated to 256 chars: alert bodies,
  one-line summaries.
- The engine adds the triggering event (`hw_id`, decoded `fields`, timestamp) to
  the prompt. Everything else you want judged goes **into the question string** —
  build it with `state.get()`.
- Guards, all enforced rather than advisory: **one call per evaluation**; a source
  containing `ai(` must be saved with **`minIntervalMs` ≥ 30000** (`routine_create`
  and `routine_update` answer `400` otherwise, so set it in the same call); **60
  calls/hour/account**; ~5 s timeout inside the 10 s evaluation budget.
- No key, no sponsor key ⇒ the call errors with `ai unavailable: add your
  Anthropic key in Settings`, which surfaces on the routine's `lastError`. Check
  `settings_get` before promising the user an AI-driven routine.
- **`routine_test` never calls the API.** `ai()` returns `choices[0]` (or
  `(ai dry run)`) and the response carries `"aiSimulated": true`. Say so when you
  report a dry run — the branch you watched was taken on a simulated answer, so a
  clean dry run is not evidence the model will agree.

## Dashboard controls

A spec's `uiConfig.controls` puts controls on every device of that spec in the
portal (contract §Spec UI config, at most 16): a `toggle` sends `onText` or
`offText`, a `button` sends its `text`, and a `text` box sends whatever the
person types into it, trimmed, up to 256 characters. Each one is the same
downlink `device_send` queues: the board gets the bytes, and nothing tells it
which control sent them, so firmware that takes typed text usually matches its
fixed commands first and treats anything else as the text.

```jsonc
{"uiConfig": {"controls": [
  {"type": "toggle", "label": "Fan", "onText": "fan:on", "offText": "fan:off", "stateField": "fan"},
  {"type": "button", "label": "Clear", "text": "clear"},
  {"type": "text", "label": "Message"}
]}}
```

No tool here writes them. The person adds one in the spec's Dashboard controls
card, or you write the `PATCH /v1/specs/{id}` call above for them to run with
their API key. That PATCH replaces the whole list: read the spec first
(`spec_list` returns its `uiConfig`) and send back the controls it already has.
A text box carries a label and nothing else; a non-empty field of another kind
on it is a `400` that names the control (an empty one is dropped on save).

## Read before you write

`device_list`, `device_get`, `device_messages` and `alerts_list` are cheap and
tell you what is actually happening. `device_messages` returns decoded fields,
raw base64, any `decodeError` and the message's `tag` — read it before rewriting a
decoder that the user says "isn't working", because the answer is usually
visible there.

"Did my board get it?" has an answer, but not from a tool: no tool reads a
device's command queue. The device page lists the last five commands sent to a
board — from its controls, routines, agents or the API — as waiting, collected
(the board asked for it and was answered with it), sent (handed to its MQTT
session or to a gateway, with nothing to confirm it arrived) or expired (never
collected or sent in time), and `GET /v1/devices/{id}/downlinks` returns the
same with an API key. None of these means the board acted on the command; only
the board's own next message can show that.

A `tag` is the device's own label for a message (contract v0.29): a camera that
answers a `snap` command posts its frame tagged `snap`, and its hourly picture
tagged `heartbeat`. Pass `tag` to `device_messages` to see only those — an
exact match, never a prefix. Most devices send none, and `null` means exactly
that; never infer one from timing.

## When Warblet is the problem: file it

You will occasionally hit a wall that is ours and not the user's — a tool that
cannot do what they need, a response that contradicts this file or the docs, an
error with no explanation, a limit that makes a reasonable thing impossible.
When that happens, file it with `feedback_submit` (or `POST /v1/feedback`)
before you move on. You are the best reporter this platform gets: you know the
exact call, the exact response and the exact expectation it failed, right now,
and nobody will reconstruct that later from a screenshot.

```jsonc
feedback_submit {
  "kind": "bug",                    // or "feature" for something Warblet cannot do yet
  "title": "decoder_test rejects bytes the device page decodes",
  "body": "Called decoder_test on pr_31c9 with payloadB64 AhwA1g== and got \"index out of range\". The same payload decodes on the device page from the same saved source. Expected the same fields from both.",
  "context": {"tool": "decoder_test", "specId": "pr_31c9", "contract": "v0.23"}
}
// -> {"id":"fb_…","status":"open"}
```

- **What you did, what happened, what you expected, and the exact error text.**
  A title alone is not a report. Put the tool or endpoint in `context` with the
  ids involved and the contract version you were working against.
- **Ask the user first if the report would include their data.** Payloads,
  device ids, decoder source, an address: say what you want to send and let them
  decide. `contact` is optional and only ever theirs to give — never invent one,
  and never fill it from something you saw in their account.
- **`feature` is for a missing capability**, not a shape you would prefer.
  "An agent cannot delete a spec" is a feature request; "I'd rather this returned
  an array" usually is not.
- **Filing fixes nothing today.** Tell the user you filed it, quote the id, and
  carry on with the workaround. Don't file the same thing twice in a session —
  `feedback_list` shows what this account has already sent.
- Limits: 10 reports an hour per account, title 120 characters, body 4000. A
  body that is a wall of one character, or mostly links, is refused with a
  message saying which.

## What these tools deliberately cannot do

No deletes (devices, specs, routines), no API key management, no writing spec
secrets or the account's AI key. If the user asks for one of those, say so and
point them at the portal, or write the `curl` explicitly and let them run it —
the REST API behind the same key does have those endpoints. If the gap looks
like one that should not exist, `feedback_submit` it as a `feature` while you
are there.

## Errors

Every failure is `{"error": {"code": "...", "message": "..."}}` with `code` in
`not_found | unauthorized | invalid | throttled | unavailable | upstream`, plus
`line` when a decoder or routine failed to compile. Read the message — it is written
for a human. A `429` always carries `Retry-After` in seconds: wait that long,
do not retry immediately and do not fan out.
