---
name: chirpiot
description: Configure Warble 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 Warble (formerly Chirp IoT / ChirpIoT), a warblet, a Warble 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.
---

# Warble

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.chirpiot.com/mcp`) or plain REST
(`https://api.chirpiot.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 warbled 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 **L0** (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 **L1 at most**, whatever the spec says, and a
searched claim binds at L0 — 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 → L1; token+signature
→ L2). There is no L3 (v0.19): TLS is a per-device flag, not a rung.

## The security ladder, briefly

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

Auth axis, who may send: L0 open (id only) → L1 token per device → L2 token plus
an HMAC of every payload, on every transport. That is the whole ladder; **there
is no L3**. Level lives on the spec and can be overridden per device; raising a
device to L2 returns its signing key exactly once, so never discard that
response.

Transport axis, who can read it: the per-device `enforceTls` flag, off by
default and armable at **any** level — including L0, 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 (mqtt/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.`.

## 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.
- `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, and `None` values 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.

## 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.
- 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.

### 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.

## 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 and any `decodeError` — read it before rewriting a decoder that the
user says "isn't working", because the answer is usually visible there.

## 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.

## 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.
