Agent access

Connect your agent

One API key, and Claude Code — or any MCP client, or a shell script, or anything that can make an HTTP request — can do what this portal does. Onboard devices, write and test decoders, build routines, read messages. From your terminal, in your editor, next to the firmware you’re already writing.

There is no Warble AI subscription to buy. The agent is yours and the subscription is yours; Warble just exposes its surface honestly and gets out of the way. The portal’s built-in Write it for me assist still exists for people who don’t have an agent — it calls the Anthropic API with your own key — but if you drive Warble from an agent you never need it. Same models, same account, one fewer key to store here.

Get a key

Settings → API keys. Name it after the machine or agent that will hold it, and copy the chk_… value when it appears — the key is hashed on the way in, so that panel is the only place it is ever readable. Keys are created and revoked only from a signed-in browser session: a key cannot mint or delete keys, so one that leaks can never lock you out or spawn siblings. Ten active keys per account.

That one key authenticates all three doors: /v1/* REST, the WebSocket live tail, and the MCP endpoint. Send it as Authorization: Bearer chk_… or X-Api-Key: chk_….

Connect Claude Code

One line, run in any project. The server is streamable HTTP, so there is nothing to install and nothing to keep running locally.

claude mcp add --transport http chirpiot https://api.chirpiot.com/mcp \
  --header "Authorization: Bearer chk_YOUR_KEY"

Then ask /mcp to confirm it says connected. The command saves the configuration without checking the credential, so a typo in the key shows up here as a failed server with a 401, not as an error at add time.

Any other MCP client takes the same server as JSON — this is also what .mcp.json looks like if you want the server checked into a project:

{
  "mcpServers": {
    "chirpiot": {
      "type": "http",
      "url": "https://api.chirpiot.com/mcp",
      "headers": {
        "Authorization": "Bearer chk_YOUR_KEY"
      }
    }
  }
}

Some clients label this transport streamable-http rather than http; both name the same thing. An entry with a url and no type is read as a local command and skipped, so keep the type line.

What the agent gets

ToolWhat it does
device_listEvery device on the account, with status and last-seen.
device_getOne device in full.
device_renameChange a device’s display name.
device_set_specMove a device onto a spec, which is what gives it a decoder.
device_messagesRecent messages: decoded fields, raw bytes, and any decode error.
device_sendQueue a downlink for the device to collect.
device_id_suggestA free adjective-animal-hex id, checked against devices and sightings.
claim_code_createMint a claim code, optionally bound to a spec. The onboarding half that goes on the hardware.
unclaimed_searchFind a device that already sent a warblet, by exact id or by the tail of one.
spec_list, spec_createThe device classes that own a decoder and a security level.
spec_set_levelMove a spec between L0 and L2.
decoder_get, decoder_putRead and write a spec’s decoder source.
decoder_testRun a decoder against a sample payload in the sandbox. Stores nothing — the loop an agent should live in before writing.
routine_list, routine_create, routine_updateCross-device automation, source and all. specId sets the trigger scope: bound to a spec, only messages from that spec’s devices evaluate the routine — anything else leaves it untouched, debounce and run log included. Leave it out and every message in the account evaluates it. Reads are separate: state.get() sees every device either way. routine_update with specId: "" unbinds.
routine_testDry run against real account state with your overrides. No actions execute — nothing is sent, no webhook fires, no alert lands.
routine_runsThe run log: each live evaluation’s trigger, queued actions, print() output and error, newest first. The answer to “what has this routine actually been doing?”
alerts_listWhat your routines have raised.
settings_getThe alert address and whether an AI key is stored. Never secrets.
The tools cannot delete anything, and cannot touch credentials. No device, spec or routine deletion. No API key management. No writing spec secrets or your AI key. That is deliberate, and it is the reason handing an agent a key is a reasonable thing to do: the worst a confused loop can do is create clutter you can see and remove. The REST API behind the same key does have the destructive endpoints — if you want them, you are writing the curl yourself and you know it.

The same power over plain REST

MCP is a convenience, not a gate. Every tool above is a /v1 endpoint, and the chk_ key works on all of them — useful for CI, for a cron job, or for checking what the agent just did.

curl -s https://api.chirpiot.com/v1/devices \
  -H "Authorization: Bearer chk_YOUR_KEY"

[
  {"id":"dv_8f2a","hwId":"sunny-otter-7c2f","name":"soil sensor",
   "specId":"pr_31c9","securityLevel":1,"status":"online",
   "lastSeen":"2026-08-16T09:14:02Z"}
]
curl -s -X POST https://api.chirpiot.com/v1/claim-codes \
  -H "Authorization: Bearer chk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"specId":"pr_31c9","count":1}'

[{"code":"CHIRP-7K2QF4XN","expiresAt":"2026-08-19T09:02:00Z"}]

Errors come back in one shape everywhere: {"error":{"code":"not_found|unauthorized|invalid|throttled|unavailable|upstream","message":"…"}}, with line added when a decoder or routine failed to compile. Every 429 carries Retry-After in seconds — an agent should read it rather than retry on a hunch.

A worked example

“Onboard a soil sensor and email me when it goes quiet.” Condensed, but the tool calls and shapes are real:

you  ▸ Onboard a soil sensor on a new spec and email me when it goes quiet.

     ▸ spec_create {"name":"Soil probe","transport":"http",
                       "securityLevel":1,"retentionDays":30}
     ◂ {"id":"pr_31c9","name":"Soil probe","transport":"http",
        "securityLevel":1,"retentionDays":30,"hasSecret":false}

     ▸ device_id_suggest {}
     ◂ {"hwId":"sunny-otter-7c2f"}

     ▸ claim_code_create {"specId":"pr_31c9"}
     ◂ [{"code":"CHIRP-7K2QF4XN","expiresAt":"2026-08-19T09:02:00Z"}]

claude ▸ Flash the board with hardware id sunny-otter-7c2f and claim code
         CHIRP-7K2QF4XN. Its first packet binds it to your account at L1.
         Meanwhile I'll write the decoder — what does the payload look like?

you  ▸ Four bytes: moisture then temperature, both big-endian tenths.

     ▸ decoder_test {"specId":"pr_31c9","payloadB64":"AhwA1g==",
                     "source":"def decode(payload, meta):\n    m = int(payload[0]) << 8 | int(payload[1])\n    t = int(payload[2]) << 8 | int(payload[3])\n    return {\"moisture_pct\": m / 10.0, \"temp_c\": t / 10.0}\n"}
     ◂ {"fields":{"moisture_pct":54.0,"temp_c":21.4}}

     ▸ decoder_put {"specId":"pr_31c9","language":"python","source":"…same…"}
     ◂ {"language":"python","source":"…"}

     ▸ settings_get {}
     ◂ {"alertEmail":"you@example.com","hasAiKey":false}

     ▸ routine_test {"source":"…draft below…",
                  "state":{"sunny-otter-7c2f":{"moisture_pct":54.0}},
                  "event":{"hwId":"kitchen-clock","fields":{"tick":1}}}
     ◂ {"actions":[{"type":"alert","subject":"soil sensor is quiet",
                    "body":"No reading from sunny-otter-7c2f for 74 minutes."}],
        "memAfter":{"quiet":true}}

     ▸ routine_create {"name":"soil sensor quiet","source":"…",
                    "enabled":true,"minIntervalMs":60000}
     ◂ {"id":"rl_44b1","name":"soil sensor quiet","enabled":true,
        "minIntervalMs":60000,"lastRunAt":null,"lastError":null}

claude ▸ Done: spec, decoder, claim code and routine. One caveat — routines are
         evaluated when a message arrives, so a lone device that goes silent
         can't trigger its own alert. You have kitchen-clock reporting every
         minute, which is what will fire this one. Alerts go to the portal and
         to you@example.com.

The routine it tested and then saved:

def routine(event, state, mem):
    age = state.age_s("sunny-otter-7c2f", "moisture_pct")
    if age == None: return                    # never reported: nothing to miss
    quiet = mem.get("quiet", False)
    if not quiet and age > 3600:
        alert("soil sensor is quiet",
              "No reading from sunny-otter-7c2f for %d minutes." % (age // 60))
        mem["quiet"] = True                   # latch: one alert, not one a minute
    elif quiet and age < 600:
        mem["quiet"] = False                  # it's back — arm the alert again

Two things in there are worth stealing. The mem latch turns “age is over an hour” into one alert instead of one per message — mem is a small dict that survives between evaluations, and it is how every non-flapping routine is built. And the honest caveat at the end: routines run when a message arrives, so a device that goes silent cannot trigger a routine about its own silence unless something else in your account is still warbling. An agent that tells you that up front is doing its job. Note that this one is deliberately left unscoped — give it a specId and only that spec’s devices can be the something else that keeps it running.

Routines can ask a model too

Everything above is an agent driving Warble from outside. A routine can also call a model from inside an evaluation, with one function: ai(question, choices). It runs on your own Anthropic key — the same key the Write it for me buttons use — so this is the point where bringing your own key stops being an accounting detail and starts buying you something.

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 (two to eight short strings) the call returns exactly one of them, verbatim — a reply that matches nothing is an error rather than a guess, which is what makes the branch above safe to write. Without choices you get free text, capped at 256 characters, which is what an alert body wants. The engine adds the triggering event — hardware id, decoded fields, timestamp — to the prompt; everything else you want judged goes into the question string, and you have state.get() to build it.

The guards are worth knowing before you write one, because they are enforced at save time and at run time rather than being advice: one ai() call per evaluation, a routine whose source calls it must set minIntervalMs to at least 30 000, and there are 60 calls an hour per account. It is available in routines only — a decoder stays a pure function of its bytes.

Dry runs never spend money. routine_test and the portal’s test panel do not call the API at all: ai() returns choices[0] and the response carries aiSimulated: true. An agent iterating on a routine for twenty rounds costs nothing — but it should say out loud that the branch it just watched was taken on a simulated answer.

The skill

A Claude Code skill that teaches an agent the workflow — claim-code-first onboarding order, what the decoder sandbox does and doesn’t have, how the routine environment fits together, and the error shape. Without it an agent works it out from tool descriptions and a few wrong turns; with it, it starts where you would.

Download SKILL.md and drop it at ~/.claude/skills/chirpiot/SKILL.md — personal skills apply across all your projects. Or in one go:

mkdir -p ~/.claude/skills/chirpiot
curl -o ~/.claude/skills/chirpiot/SKILL.md \
  https://chirpiot.com/skills/chirpiot/SKILL.md

Claude Code picks up new skills without a restart, and loads this one when you ask about a Warble device, decoder or routine. Use .claude/skills/chirpiot/ instead if you want it committed to one repo rather than living in your home directory.

Worth knowing before you point a loop at it

  • The key is the account — there are no per-key scopes yet. Treat it like an SSH key: one per machine, revoked the moment a laptop leaves your hands.
  • Rate limits are the REST limits — a tool call is a call. Device searching in particular backs off hard on consecutive misses, because that path is the one worth defending against enumeration.
  • Dry runs are genuinely dry decoder_test and routine_test store nothing, send nothing and don’t write mem. An agent can iterate on a decoder for twenty rounds without your dashboard noticing.
  • Nothing here flashes a board — getting the id and claim code onto hardware is still the browser flasher, CHIRP-PROV over USB, or your own build. The agent prepares both halves and hands them to you.