Install
openclaw skills install @chrischall/simplisafe-mcpQuery and control a SimpliSafe alarm system from the shell with curl — read system state, sensors, locks, events and settings, and arm/disarm or lock/unlock. Use when the user asks about their SimpliSafe alarm, house sensors, door locks, or whether the system is armed. Requires a one-time browser login to mint a refresh token.
openclaw skills install @chrischall/simplisafe-mcpSimpliSafe's API is reachable server-side — no browser bridge, no extension,
no signed-in tab. Auth is a bearer token minted from a long-lived OAuth refresh
token, so everything here is plain curl + jq.
Requires curl and jq.
SimpliSafe issues no API keys. The credential is an OAuth refresh token, minted by a browser login you do once:
node ~/git/simplisafe-mcp/scripts/bootstrap-auth.mjs # prints an authorize URL
# sign in, then copy the com.simplisafe.mobile://... URL the browser fails to open
node ~/git/simplisafe-mcp/scripts/bootstrap-auth.mjs "<that URL>"
That writes SIMPLISAFE_REFRESH_TOKEN to ~/git/simplisafe-mcp/.env (mode 0600).
SimpliSafe does not rotate refresh tokens, so this survives indefinitely —
until you sign out of all devices in the SimpliSafe app, which revokes it.
To capture the code from the browser: open DevTools → Network → tick Preserve
log before signing in, then find the failed navigation to
com.simplisafe.mobile://…?code=… and copy its link address. The code is
single-use and expires in ~2 minutes.
source ~/git/simplisafe-mcp/skills/simplisafe-api/references/ss-helpers.sh
ss_api GET /api/authCheck | jq # who am I
SID=$(ss_sid) # the single active system id
ss_api GET "/ss3/subscriptions/$SID/sensors?forceUpdate=false" | jq
ss_api <METHOD> <PATH> [JSON_BODY] attaches auth, prints the body on stdout,
and on a non-2xx prints to stderr and returns 1 — so a failure never looks
like an empty result. Access tokens are cached in $TMPDIR (0600) and re-minted
only when stale; the helpers never write to the repo's .env.
Almost every path needs a system id (sid), and getting one takes two calls
(authCheck → userId, then the subscriptions route). ss_sid does both, and
fails loudly if the account has more than one system rather than guessing —
pass the sid explicitly in that case.
Note two routing traps:
ss3/ routing is at
location.system.version, not the top-level systemVersion (a different
number entirely).ss3/ prefix. Everything
else is.# Is it armed? -> OFF | HOME | AWAY | HOME_COUNT | AWAY_COUNT | ALARM
ss_api GET "/users/$(ss_api GET /api/authCheck | jq -r .userId)/subscriptions?activeOnly=true" \
| jq -r '.subscriptions[0].location.system.alarmState'
# Anything wrong with a sensor?
ss_api GET "/ss3/subscriptions/$SID/sensors?forceUpdate=false" \
| jq '[.sensors[] | select(.flags.offline or .flags.lowBattery) | {name, type, flags}]'
# Recent activity
ss_api GET "/subscriptions/$SID/events?numEvents=10" \
| jq -r '.events[] | "\(.eventTimestamp|todate) \(.info)"'
More recipes, including lock state and base-station health: references/recipes.md.
These commands act on a physical security system. Disarming leaves a house unmonitored; arming can trip a siren and a monitoring-center dispatch; unlocking opens a real door. Confirm intent with the user before running any of them, and never run one speculatively.
ss_api POST "/ss3/subscriptions/$SID/state/away" # or /home, /off — no body
ss_api POST "/doorlock/$SID/<serial>/state" '{"state":"lock"}' # or "unlock"
A 2xx is not proof it worked. Re-read and check one field:
sleep 3
ss_api GET "/users/$(ss_api GET /api/authCheck | jq -r .userId)/subscriptions?activeOnly=true" \
| jq -r '.subscriptions[0].location.system.alarmState'
Four things that make naive verification lie:
AWAY_COUNT / HOME_COUNT while the exit delay runs, settling
to AWAY / HOME. That is success in progress, not failure.alarmStateTimestamp, stateUpdated and
lastUpdated advance on their own, so any call would look successful.forceUpdate=true. The cached payload lags by minutes
and has been seen reporting a jammed lock as cleanly unlocked.Arming also LOCKS your doors. Each lock's setting has home/away flags
that auto-lock on arm, and homeToOff/awayToOff controlling whether disarming
unlocks them. With homeToOff: 0 (common), arm-then-disarm leaves the doors
locked — so it is not a safe way to test arming. Check with:
ss_api GET "/ss3/subscriptions/$SID/sensors?forceUpdate=false" \
| jq -r '.sensors[] | select(.type==16) | "\(.name)\t\(.setting)"'
A lock reporting lockJamState: 1 will not respond to commands at all: the POST
still returns 200, and nothing moves. That is a hardware fault, not a bad
request.
/ss3/subscriptions/$SID/settings/normal returns settings.pins — the master,
duress and named-user alarm codes, in cleartext, in the same payload as the
harmless settings. Project .settings.normal unless the user explicitly wants
the codes:
ss_api GET "/ss3/subscriptions/$SID/settings/normal?forceUpdate=false" | jq '.settings.normal'
forceUpdate=true makes the base station re-poll its devices — slower and
harder on the hardware. Leave it false unless freshness matters.lockState 1 = locked, 2 =
unlocked, and lockJamState overrides both.21, 23 and 24 appear on real systems but aren't in any
public enum — handle unknown types gracefully.Full verified endpoint reference: ~/git/simplisafe-mcp/docs/SIMPLISAFE-API.md.