The switchbot policy command group (CLI ≥ 2.8.0) reads and validates a
single YAML file that declares how the switchbot CLI and any
connected agent should behave. This document is the field-by-field
spec. If you just want to get started, run switchbot policy new and
edit the generated file — every block in it is commented with a
summary.
The JSON Schema that backs this document lives at
src/policy/schema/v0.2.json (Draft 2020-12). It is also mirrored to
examples/policy.schema.json for editor autocomplete.
| OS | Default path |
|---|---|
| Linux / macOS | ~/.config/switchbot/policy.yaml |
| Windows | %USERPROFILE%\.config\switchbot\policy.yaml |
Override order (first hit wins):
--policy <path>flag on thepolicysubcommands$SWITCHBOT_POLICYenvironment variable- The default path above
switchbot policy new writes to the resolved path; switchbot policy validate reads from it; switchbot policy migrate reads, upgrades in
memory, and writes back.
The top-level version field is required. The CLI currently
supports two schemas:
| Version | Status | What it adds |
|---|---|---|
"0.1" |
Removed in v3.0 — migrate with policy migrate (CLI ≤2.15) |
aliases, confirmations, quiet_hours, audit, cli |
"0.2" |
Current (required) | typed automation.rules[] for the rules engine |
A file with anything other than "0.2" fails validation
with a named unsupported-version error. v0.2 is the default emitted
by switchbot policy new. Existing v0.1 files must be migrated using
CLI ≤2.15 before upgrading to v3.0+:
switchbot policy migrate # in-place upgrade, preserves commentspolicy migrate applies additive changes only (new optional fields,
tighter types on reserved blocks), rewrites the version constant, and
refuses to migrate if any user edits would conflict (exit code 7).
version: "0.2" # current default
# version: "0.1" # legacy — upgrade with `switchbot policy migrate`Every block other than version is optional. If absent, or explicitly
set to null (e.g. a commented-out body), the CLI falls back to safe
defaults.
| Block | Purpose | Default when missing |
|---|---|---|
aliases |
Map user-spoken names to deviceIds | No aliases — name resolution falls through to the CLI's match strategies |
confirmations |
Override per-action confirmation policy | Default tier behaviour (see Safety tiers) |
quiet_hours |
Require confirmation during a daily window | No quiet hours |
audit |
Where to write and how long to keep the audit log | ~/.switchbot/audit.log, retention 90d |
automation |
Reserved for the Phase 4 rule engine | enabled: false |
cli |
CLI-level overrides (profile, cache TTL) | CLI defaults |
Map of friendly names → deviceIds. Recommended for anything an agent or human will refer to by name, because it removes the ambiguity in the CLI's match-by-name path.
aliases:
"living room light": "01-202407090924-26354212"
"bedroom AC": "02-202502111234-85411230"
"front door lock": "03-202501201700-99887766"Rules:
- Keys are free-form strings. Quote them if they contain spaces or non-ASCII characters.
- Values must match
^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$— also accepts hex MAC format and hyphenated multi-segment IDs. Get IDs fromswitchbot devices list --format=tsv.
Override the default confirmation policy derived from each action's
safetyTier.
confirmations:
always_confirm:
- "setTargetTemperature"
- "setThermostatMode"
never_confirm:
- "turnOn"
- "turnOff"| Subkey | Meaning | Constraints |
|---|---|---|
always_confirm |
Action names that always require explicit confirmation, even when the tier would auto-run | List of strings, unique |
never_confirm |
Action names that normally confirm but the user has pre-approved | List of strings, unique. MUST NOT include destructive actions |
The destructive blocklist the schema enforces on never_confirm:
lockunlockdeleteWebhookdeleteScenefactoryReset
Attempting to pre-approve any of these is a validation error. This is deliberate — no YAML edit should silently disable the unlock confirmation gate.
Window during which every mutation (not just destructive ones) requires explicit confirmation.
quiet_hours:
start: "22:00"
end: "08:00"startandendareHH:MM24-hour local system time.startandendare mutually required (JSON SchemadependentRequired): set both, or neither.- Overnight ranges (
start > end) are allowed and interpreted as crossing midnight.
Controls the JSONL audit log the CLI writes when you pass
--audit-log to a mutating command.
audit:
log_path: "~/.switchbot/audit.log"
retention: "90d"| Field | Format | Default |
|---|---|---|
log_path |
Absolute or ~-prefixed path |
~/.switchbot/audit.log |
retention |
never or <N>d / <N>w / <N>m |
90d |
retention is a lexical pattern only — the CLI does not rotate the
file itself today; external log rotation tools (logrotate,
PowerShell scheduled task, etc.) should honour the value.
Rule engine block. Available in v0.2 — set enabled: true to activate
switchbot rules run. In v0.1 this block is a reserved stub; flip
enabled: true on v0.1 and the CLI prints a warning and skips the block.
Run switchbot policy migrate first to unlock the rules engine.
automation:
enabled: true # must be true for `rules run` to do anything
audit:
evaluate_trace: sampled # full | sampled | off (default sampled)
evaluate_retention_days: 7 # min 1 (default 7)
llm_budget:
max_calls_per_hour: 60 # global limit across all LLM conditions (default 60)
rules:
- name: hallway motion at night # unique per file; audit label
enabled: true # default true; false silences the rule
when: # trigger — exactly one source
source: mqtt # mqtt | cron | webhook
event: motion.detected # classifier output (see below)
device: hallway motion # optional alias/deviceId filter
conditions: # optional; AND-joined
- time_between: ["22:00", "07:00"] # local-time window, overnight OK
then: # one or more actions, run in order
- command: "devices command <id> turnOn"
device: hallway lamp # alias resolves to deviceId at fire time
args: null # optional map of verb arguments
on_error: continue # continue (default) | stop
- type: notify
channel: webhook # webhook | file | openclaw
to: https://your.host/hook
template: '{"rule":"{{ rule.name }}","fired":"{{ rule.fired_at }}"}'
on_failure: log # log | retry | ignore
throttle:
max_per: "10m" # minimum spacing: \d+[smh]
dedupe_window: null # event deduplication window
cooldown: null # shorthand for throttle.max_per
requires_stable_for: null # hysteresis guard duration
maxFiringsPerHour: null # per-hour rate limit
suppressIfAlreadyDesired: false # skip if device already in desired state
dry_run: true # default true in v0.2; writes audit but skips the API callTrigger sources (v0.2).
source |
Required fields | Status |
|---|---|---|
mqtt |
event (+ device?) |
active — fires on shadow MQTT |
cron |
schedule (5-field) |
active — local time, optional days weekday filter |
webhook |
path |
active — bearer-token HTTP ingest |
MQTT event names classified today: motion.detected,
motion.cleared, contact.opened, contact.closed,
button.pressed. Unmatched
payloads classify as device.shadow — you can match that catch-all
too.
Conditions (v0.2).
| Keyword | Meaning | Status |
|---|---|---|
time_between |
[HH:MM, HH:MM] local-time window, start > end → overnight |
active |
device_state |
{ device, field, op, value } read device status inline |
active |
all |
AND-join multiple sub-conditions | active |
any |
OR-join multiple sub-conditions | active |
not |
Negate a sub-condition | active |
llm |
AI judgement — prompt an LLM before firing (see below) | active |
LLM condition fields:
conditions:
- llm:
prompt: "Is the temperature above normal comfort range?"
provider: auto # auto | openai | anthropic
timeout_ms: 5000 # 500–10000 (default 5000)
cache_ttl: 5m # none | \d+[smh] (default 5m)
recent_events: 5 # 0–20 (default 5) — recent events included in prompt
budget:
max_calls_per_hour: 10 # per-condition limit (default 10)
on_error: fail # fail | pass | skip (default fail)Set OPENAI_API_KEY or ANTHROPIC_API_KEY. rules lint flags misconfigured LLM conditions. Global LLM budget can be set via automation.llm_budget.max_calls_per_hour (default 60).
Destructive verbs are refused upstream. The v0.2 validator
rejects lock, unlock, deleteWebhook, deleteScene,
factoryReset in any then[].command. The engine re-checks at fire
time as a defence-in-depth — you cannot bypass this with aliases or
manual runtime invocation.
Hot-path behaviour. Every fire is serialised through a dispatch
queue so two MQTT events arriving in the same tick respect throttle
windows. Rules are executed in the order declared; on_error: stop
halts the remaining actions in a single rule's then[] but doesn't
affect other rules.
See docs/design/phase4-rules.md for the
pipeline and examples/policies/automation.yaml
for a working walkthrough.
Optional CLI-level overrides.
cli:
profile: "default"
cache_ttl: "5m"| Field | Format | Default |
|---|---|---|
profile |
Non-empty string | "default" |
cache_ttl |
<N>s, <N>m, or <N>h |
CLI default (typically 5 minutes) |
profile must match a profile you've configured with
switchbot config set-token --profile <name>.
Note: the policy file path is not profile-aware today — every profile shares the same
~/.config/switchbot/policy.yaml. If you need separate policies per profile, point each to its own file via the$SWITCHBOT_POLICY_PATHenvironment variable when you run the CLI. Tracking profile-scoped paths as a future enhancement.
switchbot policy validateExit codes:
| Code | Meaning |
|---|---|
| 0 | File is valid and matches schema v0.2 |
| 1 | Schema violation (line-accurate error with hint) |
| 2 | File is missing |
| 3 | YAML is malformed (parse error, with line/col) |
| 4 | Internal error |
Every non-zero exit prints a compiler-style block:
policy.yaml:12:14 error lowercase deviceId
|
12 | "bedroom ac": "02-202502111234-abc123"
| ^^^^^^^^
= hint: SwitchBot deviceIds are uppercase. Try "ABC123".
For machine consumption, pass --json. The envelope is the standard
{schemaVersion, data|error} shape:
{
"schemaVersion": "1.2",
"error": {
"kind": "usage",
"message": "lowercase deviceId at policy.yaml:12:14",
"hint": "SwitchBot deviceIds are uppercase.",
"file": "/home/you/.config/switchbot/policy.yaml",
"line": 12,
"column": 14,
"rule": "aliases-deviceId-pattern"
}
}| Error | Trigger | Fix |
|---|---|---|
missing version |
Top-level version is absent |
Add version: "0.2" |
unsupported version |
version is not "0.1" or "0.2" |
Check spelling; run switchbot policy migrate to upgrade from v0.1 |
wrong version |
version: "0.1" on a CLI that requires v0.2 |
Run switchbot policy migrate |
lowercase deviceId |
aliases value doesn't match the accepted patterns |
Copy the exact ID from devices list |
destructive in never_confirm |
lock/unlock/etc in confirmations.never_confirm |
Remove it; intentional by design |
quiet_hours.start without end |
Only one of the two times is set | Set both, or remove the block |
invalid retention |
audit.retention isn't never / Nd / Nw / Nm |
Use one of the documented formats |
unknown top-level key |
You misspelled a block (e.g. alias: not aliases:) |
Check the spelling against this reference |
Every error includes the offending line and column, and most include a
machine-readable rule field so tooling can suggest fixes.
v0.2 is the current required schema. If you have a v0.1 file from an earlier release, upgrade it:
switchbot policy migrate # in-place upgrade, preserves commentspolicy migrate:
- Detects your current
versionfield. - Applies additive changes only (new optional fields, tighter types on reserved blocks).
- Rewrites the file with the new
versionconstant. - Refuses to migrate if any user edits conflict, and explains what conflicts (exit code 7).
After migrating, run switchbot policy validate to confirm the file is
valid before using the rules engine.
examples/policies/— four annotated starter files (minimal / cautious / permissive / rental), each with a rationale for when to pick it.docs/agent-guide.md— how an AI agent should read and honourpolicy.yaml.docs/audit-log.md— the format of the audit logaudit.log_pathpoints at.switchbot policy --help— command-line help for the three subcommands.examples/policy.schema.json— JSON Schema for editor autocomplete (VS Codeyaml.schemas, JetBrains, etc.).