This guide covers everything an LLM agent (Claude, GPT, Cursor, Zed, a homegrown orchestrator…) needs to drive SwitchBot devices through the switchbot CLI safely and reliably, without the agent needing to guess at device-specific JSON payloads.
If you're a human looking for a tour, start with the top-level README. This file assumes you're writing code that calls the CLI or embeds the MCP server.
Skill packaging. This CLI is the authoritative machine-readable surface. The conversational skill that wraps it (Claude Desktop / third-party agent entry points) is tracked as Phase 3B and published out of a separate repo — the skill has no private contract with the CLI, only the documented surfaces below (
mcp serve,agent-bootstrap,schema export,capabilities --json). To detect CLI ↔ agent-bootstrap schema drift before a session starts, runswitchbot doctor --json | jq '.checks[] | select(.name=="catalog-schema")'— any status other thanokmeans the skill and CLI have diverged and should be upgraded in lockstep.
- Three integration surfaces
- Surface 1: MCP server (recommended)
- Surface 2: Structured plans (
switchbot plan) - Surface 3: Direct JSON invocation
- Catalog: the shared contract
- Safety rails
- Policy awareness
- Autonomous rule authoring (L3)
- Observability
- Performance and token budget
All three share the same catalog, HMAC client, retry/backoff, destructive-command guard, cache, and audit-log. Choose based on how your agent is hosted:
- MCP server
Use when your agent host speaks MCP
(Claude Desktop, Cursor, Zed, Anthropic Agent SDK).
Entry point:
switchbot mcp serve(stdio) or--port <n>. - Plan runner
Use when your agent already produces structured JSON and you want the CLI
to validate and execute it.
Entry point:
switchbot plan run <file>or stdin. - Direct CLI
Use when your agent wraps subprocesses and parses output directly.
Entry point: any subcommand with
--json.
switchbot mcp serve # stdio, for Claude Desktop / Cursor
switchbot mcp serve --port 8765 # http, for long-lived agent workersAdd to ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\Claude\claude_desktop_config.json (Windows):
{
"mcpServers": {
"switchbot": {
"command": "switchbot",
"args": ["mcp", "serve"],
"env": {
"SWITCHBOT_TOKEN": "...",
"SWITCHBOT_SECRET": "..."
}
}
}
}| Tool | Purpose | Safety tier |
|---|---|---|
list_devices |
Enumerate physical devices + IR remotes | read |
get_device_status |
Live status for one device | read |
send_command |
Dispatch a built-in or customize command | action (destructive needs confirm: true and still defaults to reviewed execution) |
list_scenes |
Enumerate saved manual scenes | read |
run_scene |
Execute a saved manual scene | action |
search_catalog |
Look up device type by name/alias | read |
describe_device |
Catalog-derived capabilities + optional live status | read |
account_overview |
Cold-start snapshot (devices/scenes/quota/cache/MQTT) | read |
get_device_history |
Latest state + ring history from disk | read |
query_device_history |
Time-range query over JSONL history | read |
aggregate_device_history |
Bucketed statistics over history | read |
policy_validate |
Validate policy.yaml | read |
policy_new |
Scaffold a starter policy file | action |
policy_migrate |
Upgrade policy schema in-place | action |
policy_diff |
Compare two policy files (leftPath/rightPath/equal/.../diff) |
read |
plan_suggest |
Draft plan JSON from intent + devices | read |
plan_run |
Validate and execute a plan JSON object | action |
audit_query |
Filter audit log entries | read |
audit_stats |
Aggregate audit stats by kind/result/device/rule | read |
rules_suggest |
Draft automation rule YAML from intent | read |
policy_add_rule |
Inject rule YAML into automation.rules[] with diff |
action |
The MCP server refuses destructive commands (Smart Lock unlock, Garage Door open, etc.) unless the tool call includes confirm: true, and the default safety profile still blocks direct destructive execution in favor of the reviewed CLI flow (plan save → plan review → plan approve → plan execute). The allowed list is the destructive: true commands in the catalog — switchbot schema export | jq '[.data.types[].commands[] | select(.destructive)]' shows every one.
Reads ~/.switchbot/device-history/<deviceId>.json written by events mqtt-tail. Requires no API call and costs zero quota.
// Without deviceId — list all devices with stored history
{ "tool": "get_device_history" }
// → { "devices": [{ "deviceId": "ABC123", "latest": { "t": "...", "payload": {...} } }] }
// With deviceId — latest + rolling history (default 20, max 100 entries)
{ "tool": "get_device_history", "deviceId": "ABC123", "limit": 5 }
// → { "deviceId": "ABC123", "latest": {...}, "history": [{...}, ...] }Workflow: run switchbot events mqtt-tail in the background (e.g. with pm2) to keep the history files fresh; then call get_device_history from any MCP session without consuming REST quota.
After events mqtt-tail runs on a device, ~/.switchbot/device-history/ contains up to three companion files per device:
<deviceId>.jsonl: append-only, authoritative event log. Source of truth forhistory rangeandhistory aggregate. Rotated at ~50 MB (up to 3 segments).<deviceId>.json: latest 100-entry ring buffer. Written on every MQTT event. Read by MCPget_device_historyfor fast, zero-quota retrieval.__control.jsonl: MQTT connection lifecycle events (heartbeat, connect, disconnect). Not a device log; used for diagnostics.
The .json file is not the source of truth for historical queries — use .jsonl (via history range or history aggregate) when you need a complete, time-bounded record. The .json file is optimised for "what is the latest state?" lookups.
Read-only snapshot of recent MQTT shadow-update events from the ring buffer. Returns {state, count, events[]}.
Enabled when SWITCHBOT_MQTT_HOST / SWITCHBOT_MQTT_USERNAME / SWITCHBOT_MQTT_PASSWORD env vars are set; returns {state:"disabled", count:0, events:[]} otherwise. To enable real-time events, add those three env vars to the MCP server config alongside SWITCHBOT_TOKEN / SWITCHBOT_SECRET.
Agents that prefer "emit JSON, let the CLI execute it" avoid the MCP dependency. The plan schema is fixed (versioned at 1.0), so you can fine-tune prompts or tool definitions once and reuse them across models.
switchbot plan schema > plan.schema.jsonGive that file to your agent framework (OpenAI tool schema, Anthropic JSON mode, function-calling, etc.) and it will produce plans shaped like:
{
"version": "1.0",
"description": "Evening wind-down",
"steps": [
{ "type": "command", "deviceId": "STRIP1", "command": "setColorTemperature", "parameter": 2700 },
{ "type": "wait", "ms": 500 },
{ "type": "command", "deviceId": "BOT1", "command": "turnOff" },
{ "type": "scene", "sceneId": "T_BEDTIME" }
]
}# CLI — produces a candidate plan JSON on stdout
switchbot plan suggest --intent "turn off all lights" --device D1 --device D2
# MCP — agents can call plan_suggest({intent, device_ids}) without leaving the sessionplan suggest uses keyword heuristics (no LLM) to pick a command from the intent text and generate
one step per device. Recognised verbs: turnOn, turnOff, press, lock, unlock, open, close,
pause. Defaults to turnOn with a warning when the intent is unclear. Always review and edit the
output before running.
cat plan.json | switchbot plan validate - # exit 2 on schema error
cat plan.json | switchbot --dry-run plan run - # preview — mutations skipped
cat plan.json | switchbot plan save - # reviewed destructive path
switchbot plan review <planId>
switchbot plan approve <planId>
switchbot plan execute <planId>
cat plan.json | switchbot --json plan run - # machine-readable outcome- Steps execute sequentially. A failed step stops the run (exit 1) unless you pass
--continue-on-error. waitusessetTimeout;msis capped at 600 000 so a malformed plan can't hang the agent.- Destructive commands are skipped (not failed) without
--yes, so an agent that omits the flag gets a clean preview summary. plan run --yesis reserved for explicit dev profiles. The default production path for destructive work isplan save→plan review→plan approve→plan execute.--require-approvalenables per-step TTY confirmation for destructive steps during execution. Non-TTY environments (CI, pipes) auto-reject.- Every successful/failed step lands in
--audit-log(see Observability).
--jsonOutput: Raw API payload — exact JSON the SwitchBot API returned. Use when: buildingjqpipelines or scripts that need the full response body.--format=jsonOutput: Projected row view — CLI column model,--fieldsapplies. Use when: you only need specific fields with a consistent row shape.
--json and --format=json differ only in output shape — they share the same HTTP client and auth.
Errors follow the same envelope on both paths (stderr):
{ "error": { "code": 152, "kind": "api", "message": "...", "hint": "...", "retryable": false } }Error kind values: api (SwitchBot API error), runtime (network/auth failure), usage (bad flag or unknown field), guard (destructive command blocked without confirm:true).
switchbot --json devices list | jq '.deviceList[] | select(.deviceType=="Bot") | .deviceId'
switchbot --json devices describe <id>
switchbot --json --dry-run devices command <id> turnOff
switchbot --json scenes execute <sceneId>--fields projects output to a named subset of columns. Field names are the exact column headers a command outputs (listed in --help). Unknown names exit 2 immediately with the list of allowed names — there is no silent fallback.
# Allowed fields for each command are in its --help text:
switchbot devices list --help # "Output columns: deviceId, deviceName, ..."
switchbot scenes list --help # "Output columns: sceneId, sceneName"
# For `devices status`, fields are device-specific — discover them first:
switchbot devices status <id> --format yaml # shows all field names for this device
switchbot devices status <id> --format tsv --fields power,battery
# --format=id only works on commands with a deviceId or sceneId column:
switchbot devices list --format id # ✓ — deviceId column present
switchbot scenes list --format id # ✓ — sceneId column present
switchbot devices status <id> --format id # ✗ — exits 2 (no ID column in status output)Some device commands require a packed string parameter (e.g., AC setAll takes "26,2,2,on"). devices expand accepts named flags and builds the parameter for you:
# Air Conditioner — setAll
switchbot devices expand <acId> setAll --temp 26 --mode cool --fan low --power on
# Curtain / Roller Shade — setPosition
switchbot devices expand <curtainId> setPosition --position 50 --mode silent
# Blind Tilt — setPosition
switchbot devices expand <blindId> setPosition --direction up --angle 50
# Relay Switch — setMode
switchbot devices expand <relayId> setMode --channel 1 --mode edgeUse switchbot devices expand --help or switchbot devices expand <id> <command> --help for the full flag list per command.
Every device, command, and parameter the CLI knows about lives in the catalog. Dumping it gives you a prompt-ready description of the controllable surface area:
switchbot schema export > catalog.json
switchbot schema export --type 'Smart Lock' | jq '.types[0].commands'Each command entry carries:
idempotent— safe to retrydestructive— requires explicit confirmationparameter/exampleParams— what the agent should fill incommandType(commandvscustomize) — built-in vs user-defined IR button
Use switchbot doctor to confirm the CLI is healthy before orchestrating anything non-trivial — it validates credentials, catalog size, cache state, clock drift, and quota file access.
- Destructive-command guard: Smart Lock
unlock, Garage Dooropen, and anything else taggeddestructive: truein the catalog refuses to run directly in the default profile. Use the reviewed plan workflow by default; only explicit dev profiles may pair direct execution with--yes/confirm: true. - Dry-run: Global
--dry-runshort-circuits every mutating HTTP request. GETs still execute. Command names are validated against the device catalog — unknown commands exit 2 when the device type has a known catalog entry, as do commands on read-only sensors. Use it for any "what would this do?" flow before letting the agent commit. - Quota: The SwitchBot API has a per-account daily quota.
--retry-on-429 <n>and--backoff <linear|exponential>handle throttling;~/.switchbot/quota.jsontracks daily counts. - Audit log:
--audit-log [path]appends every mutating command (including dry-runs) to JSONL for post-hoc review. - Non-zero exit codes are stable:
0success,1runtime error,2usage error (bad flag, invalid plan schema).
Users can declare per-account preferences in a policy.yaml file
(at the CLI's default policy path). Agents should
read it at session start — it holds the aliases, quiet-hours window,
and confirmation overrides the user wants honoured.
switchbot policy validate # exit 0 if the file is healthy
switchbot policy validate --json # machine-readable error envelopeDo not attempt to parse the YAML directly; let policy validate
parse it and surface the result. If validation fails, relay the
compiler-style error (file:line:col + hint) to the user — the CLI
already produces agent-friendly output.
Concepts an agent should honour:
aliases.<name>→ deviceId mapping. Prefer this over the CLI's match-by-name fallback, which can pick the wrong device when two names collide.confirmations.always_confirm[]/confirmations.never_confirm[]— per-action overrides of the tier-based confirmation default. The schema refuses to pre-approve destructive actions, so you can trustnever_confirmnot to containunlocketc.quiet_hours.start / end— during this window, evenmutation-tier actions require explicit user confirmation.
Full field-level reference: docs/policy-reference.md.
Agents operating at autonomy level L3 can author automation rules programmatically — no manual policy.yaml editing required.
# Step 1: Generate candidate rule YAML (no side effects)
switchbot rules suggest \
--intent "turn on hallway light when motion detected" \
--trigger mqtt \
--device "hallway-sensor" --device "hallway-lamp"
# Step 2: Dry-run into policy.yaml (shows diff, no write)
switchbot rules suggest --intent "..." | switchbot policy add-rule --dry-run
# Step 3: Show diff to user, wait for approval, then inject
switchbot rules suggest --intent "..." | switchbot policy add-rule --enable
# Step 4: Lint and reload
switchbot rules lint && switchbot daemon reloadMCP agents use rules_suggest + policy_add_rule tools for the same
pipeline without shell access.
- Never set
automation.enabled: truewithout explicitly informing the user. - Always start a new rule with
dry_run: true(the generator does this automatically). - Never arm a rule (
dry_run: false) on first author — require the user to confirm firings look correct viaswitchbot rules tail --follow. - Never use destructive commands (
unlock,deleteScene, etc.) in rulethen[].
After the user confirms the rule fires correctly:
# Edit policy.yaml: set dry_run: false
# Then reload:
switchbot rules lint && switchbot daemon reloadUse switchbot rules replay --since 24h --json regularly to surface misfires.
switchbot --audit-log devices command <id> turnOff # writes ~/.switchbot/audit.log
switchbot --audit-log=/tmp/agent.log plan run plan.json # custom path
switchbot history show --limit 20 # pretty-print recent entries
switchbot history replay 7 # re-run entry #7
switchbot --json history show --limit 50 | jq '.entries[] | select(.result=="error")'The audit format is JSONL with this shape:
{ "t": "2026-04-18T10:00:00.000Z", "kind": "command", "deviceId": "BOT1",
"command": "turnOn", "parameter": null, "commandType": "command",
"dryRun": false, "result": "ok" }Pair with switchbot devices watch --interval=30s for continuous state diffs (add --include-unchanged to emit every tick even when nothing changed), switchbot events tail to receive webhook pushes locally, or switchbot events mqtt-tail for real-time MQTT shadow updates.
Agent contexts are expensive; the CLI is designed to be frugal.
switchbot devices list --format=tsv --fields=deviceId,deviceName,type,cloud— typical output ≤ 500 chars for a 20-device account (vs ~5 KB for the default JSON).switchbot devices status --format=yaml— compact key/value, no array noise.switchbot schema export --type <t>— bring only the relevant part of the catalog into context.switchbot devices describe <id> --livereturns both the static catalog entry and live status in one call — prefer it over separatestatus+commands <type>calls.- Use
--cache=5mwhen polling the same device repeatedly in a session; it caches live status locally so you don't burn the daily quota.
If you're seeing token pressure, switchbot doctor --json | jq .checks will also show you how big the bundled catalog is, whether cache is active, and whether credentials round-trip cleanly.