ops-unifi · diff

git:20260822.1ddc215 to git:20260822.2cfccdf

1 added, 1 removed. Audit B to B.

---
name: ops-unifi
- description: "This skill should be used when the user asks to \"unifi\", \"cameras\", or \"/ops:ops-unifi\". UniFi network command center. Full control across the three official UniFi APIs — Site Manager (cloud multi-site oversight), Network Integration (local per-console device/client control), and Protect Integration (cameras/NVR). Plus a predict mode that flags WAN/AP/client anomalies before they page you. Configure once via /ops:setup."
+ description: "OPS on-demand: This skill should be used when the user asks to \"unifi\", \"cameras\", or \"/ops:ops-unifi\""
argument-hint: '[status|sites|devices|clients|isp|sdwan|protect|snapshot|restart|block|predict|setup]'
allowed-tools:
- Bash
- Read
- Write
- Grep
- Glob
- Agent
- TeamCreate
- SendMessage
- AskUserQuestion
- WebFetch
- WebSearch
effort: medium
maxTurns: 30
context: fork
---
# OPS ► UNIFI — Network Command Center (UniFi OS)
Load `ops-rules` before acting. Public repo (no personal data). Outbound: one draft → one approval → one send. If `AskUserQuestion` / `Workflow` are missing, follow Rule 10 in `ops-rules` (Hermes: numbered options / two-turn Telegram card; `delegate_task`).
Control Ubiquiti UniFi infrastructure across **all three official APIs**:
1. **Site Manager API** (cloud, `api.ui.com`) — fleet-wide oversight: every console, site, device, and ISP/WAN health metric across all your UniFi deployments in one call. Read-first (write endpoints roll out per-key).
2. **Network Integration API** (local, `https://<gateway>/proxy/network/integration/v1`) — per-console control: list/restart devices, list/block clients, manage hotspot vouchers, read live statistics.
3. **Protect Integration API** (local, `https://<host>/proxy/protect/integration/v1`) — cameras, NVR, sensors, lights, chimes, viewers: list, snapshot, stream, and patch device settings; real-time WebSocket event stream.
This skill is **curl-native** (works headless, no SDK dependency) — consistent with the rest of `/ops:*`. If you want a richer natural-language surface, an optional community MCP can be enabled (see **Optional MCP path** at the end); the skill never depends on it.
## Runtime Context
Before executing, load available context:
1. **Preferences**: Read `${CLAUDE_PLUGIN_DATA_DIR:-$HOME/.claude/plugins/data/ops-ops-marketplace}/preferences.json`
- `timezone` — display all timestamps in user's timezone
- `home_network.unifi_site_manager_api_key` — cloud API key from unifi.ui.com (read-only multi-site)
- `home_network.unifi_local_gateway_url` — e.g. `https://192.168.1.1` (UniFi OS console LAN address)
- `home_network.unifi_local_api_key` — Network Integration API key (generated in the Network app)
- `home_network.unifi_protect_url` — Protect host (often same as gateway); defaults to gateway URL
- `home_network.unifi_protect_api_key` — Protect Integration API key (defaults to local key if unset — one UniFi OS key often works for both)
2. **Daemon health**: Read `${CLAUDE_PLUGIN_DATA_DIR:-$HOME/.claude/plugins/data/ops-ops-marketplace}/daemon-health.json`
- If `action_needed` is not null → surface it before running any UniFi operations
- On any auth/connectivity failure in this skill, write `action_needed` back to daemon-health.json
3. **Secrets**: Resolve UniFi credentials via userConfig → env vars → Doppler → keychain (see Phase 1 below)
## Phase 1 — Resolve credentials
Resolve UniFi credentials in this order (userConfig → env → Doppler → keychain). The three surfaces are independent — any subset may be configured; the skill degrades gracefully and only runs the surfaces it has keys for.
```bash
PLUGIN_DATA_DIR="${CLAUDE_PLUGIN_DATA_DIR:-$HOME/.claude/plugins/data/ops-ops-marketplace}"
PREFS_PATH="${PLUGIN_DATA_DIR}/preferences.json"
# Capture shell-exported protect host before prefs load overwrites UNIFI_PROTECT_URL
_ENV_UNIFI_PROTECT_URL="${UNIFI_PROTECT_URL:-}"
# 1. Plugin userConfig (preferences.json → home_network.*)
UNIFI_SM_KEY=$(jq -r '.home_network.unifi_site_manager_api_key // empty' "$PREFS_PATH" 2>/dev/null)
UNIFI_LOCAL_URL=$(jq -r '.home_network.unifi_local_gateway_url // empty' "$PREFS_PATH" 2>/dev/null)
UNIFI_LOCAL_KEY=$(jq -r '.home_network.unifi_local_api_key // empty' "$PREFS_PATH" 2>/dev/null)
UNIFI_PROTECT_URL=$(jq -r '.home_network.unifi_protect_url // empty' "$PREFS_PATH" 2>/dev/null)
UNIFI_PROTECT_KEY=$(jq -r '.home_network.unifi_protect_api_key // empty' "$PREFS_PATH" 2>/dev/null)
# 2. Environment variables (override userConfig if set)
[ -n "$UNIFI_SM_KEY" ] || UNIFI_SM_KEY="${UNIFI_SITE_MANAGER_API_KEY:-${UNIFI_SM_KEY:-}}"
[ -n "$UNIFI_LOCAL_URL" ] || UNIFI_LOCAL_URL="${UNIFI_LOCAL_GATEWAY_URL:-${UNIFI_LOCAL_URL:-}}"
[ -n "$UNIFI_LOCAL_KEY" ] || UNIFI_LOCAL_KEY="${UNIFI_LOCAL_API_KEY:-${UNIFI_LOCAL_KEY:-}}"
[ -n "$UNIFI_PROTECT_URL" ] || UNIFI_PROTECT_URL="${_ENV_UNIFI_PROTECT_URL:-}"
[ -n "$UNIFI_PROTECT_KEY" ] || UNIFI_PROTECT_KEY="${UNIFI_PROTECT_API_KEY:-${UNIFI_PROTECT_KEY:-}}"
# 3. Doppler fallback (project: unifi)
if command -v doppler &>/dev/null; then
[ -z "$UNIFI_SM_KEY" ] && UNIFI_SM_KEY=$(doppler secrets get UNIFI_SITE_MANAGER_API_KEY --project unifi --plain 2>/dev/null)
[ -z "$UNIFI_LOCAL_URL" ] && UNIFI_LOCAL_URL=$(doppler secrets get UNIFI_LOCAL_GATEWAY_URL --project unifi --plain 2>/dev/null)
[ -z "$UNIFI_LOCAL_KEY" ] && UNIFI_LOCAL_KEY=$(doppler secrets get UNIFI_LOCAL_API_KEY --project unifi --plain 2>/dev/null)
[ -z "$UNIFI_PROTECT_KEY" ] && UNIFI_PROTECT_KEY=$(doppler secrets get UNIFI_PROTECT_API_KEY --project unifi --plain 2>/dev/null)
fi
# 4. Keychain fallback (macOS)
[ -z "$UNIFI_SM_KEY" ] && UNIFI_SM_KEY=$(security find-generic-password -s "unifi-site-manager-key" -w 2>/dev/null)
[ -z "$UNIFI_LOCAL_KEY" ] && UNIFI_LOCAL_KEY=$(security find-generic-password -s "unifi-local-key" -w 2>/dev/null)
[ -z "$UNIFI_PROTECT_KEY" ] && UNIFI_PROTECT_KEY=$(security find-generic-password -s "unifi-protect-key" -w 2>/dev/null)
# 5. Sensible defaults — Protect commonly shares host + key with the Network console
[ -z "$UNIFI_PROTECT_URL" ] && UNIFI_PROTECT_URL="$UNIFI_LOCAL_URL"
[ -z "$UNIFI_PROTECT_KEY" ] && UNIFI_PROTECT_KEY="$UNIFI_LOCAL_KEY"
```
Unless the argument is `setup`, `configure`, `init`, or `token`, if **none** of (`UNIFI_SM_KEY`), (`UNIFI_LOCAL_URL` + `UNIFI_LOCAL_KEY`), nor (`UNIFI_PROTECT_URL` + `UNIFI_PROTECT_KEY`) is resolvable, tell the user and exit gracefully:
```
No UniFi credentials configured. Run /ops:setup --section network to configure your UniFi Site Manager and/or local console API keys.
```
Write `action_needed: "configure_unifi"` to `${PLUGIN_DATA_DIR}/daemon-health.json` and exit.
Set helpers used by all phases below:
```bash
SM_BASE="https://api.ui.com"
NET_BASE="${UNIFI_LOCAL_URL}/proxy/network/integration/v1"
PRO_BASE="${UNIFI_PROTECT_URL}/proxy/protect/integration/v1"
# Site Manager (cloud) call
sm_call() { # sm_call <path> [method] [body]
[ -z "$UNIFI_SM_KEY" ] && { echo '{"error":"no site-manager key"}'; return 1; }
curl -s --max-time 15 -X "${2:-GET}" \
-H "X-API-Key: ${UNIFI_SM_KEY}" -H "Accept: application/json" -H "Content-Type: application/json" \
${3:+--data "$3"} "${SM_BASE}$1"
}
# Network Integration (local) call — self-signed cert ⇒ -k
net_call() { # net_call <path> [method] [body]
[ -z "$UNIFI_LOCAL_URL" ] || [ -z "$UNIFI_LOCAL_KEY" ] && { echo '{"error":"no local network creds"}'; return 1; }
curl -sk --max-time 12 -X "${2:-GET}" \
-H "X-API-Key: ${UNIFI_LOCAL_KEY}" -H "Accept: application/json" -H "Content-Type: application/json" \
${3:+--data "$3"} "${NET_BASE}$1"
}
# Protect Integration (local) call — self-signed cert ⇒ -k
pro_call() { # pro_call <path> [method] [body]
[ -z "$UNIFI_PROTECT_URL" ] || [ -z "$UNIFI_PROTECT_KEY" ] && { echo '{"error":"no protect creds"}'; return 1; }
curl -sk --max-time 12 -X "${2:-GET}" \
-H "X-API-Key: ${UNIFI_PROTECT_KEY}" -H "Accept: application/json" -H "Content-Type: application/json" \
${3:+--data "$3"} "${PRO_BASE}$1"
}
```
---
## Phase 2 — Route by argument
| Input | Action |
| ------------------------------------------- | ------------------------------------------------ |
| (empty) | Cross-surface status dashboard |
| status, dashboard | Cross-surface status dashboard |
| sites, hosts, consoles | Site Manager: hosts + sites |
| devices, device, aps, switches, gateway | Network: devices per site |
| clients, who, wifi, online | Network: clients |
| isp, wan, internet, uptime, latency | Site Manager: ISP/WAN metrics |
| sdwan, sd-wan | Site Manager: SD-WAN configs |
| protect, cameras, camera, nvr, surveillance | Protect: cameras + NVR |
| snapshot <camera> | Protect: camera snapshot |
| restart, reboot <device> | Network: device restart (CONFIRM — Rule 5) |
| block <client> / unblock <client> | Network: client block/unblock (CONFIRM — Rule 5) |
| voucher [create\|list\|revoke] | Network: hotspot vouchers |
| predict, insights, anomaly, health | Predict / insights (cross-surface anomaly scan) |
| setup, configure, init, token | Setup flow |
Pick the first site automatically when a surface needs a `siteId` and only one site exists; otherwise present sites via `AskUserQuestion` (max 4, Rule 1) and let the user choose.
---
## STATUS (default — empty argument)
One-screen network dashboard. Probe Site Manager (hosts/devices/ISP), Network (devices/clients), and Protect (cameras) in parallel — separate Bash calls or the Agent Team in **Agent Teams support** below — then render.
```bash
# Site Manager fleet view (skip surface when key missing)
[ -n "$UNIFI_SM_KEY" ] && sm_call "/v1/hosts" | jq '{hosts: ([.data // [] | .[] | {name:.reportedState.hostname, model:.reportedState.hardware.shortname, state:.reportedState.state}])}'
[ -n "$UNIFI_SM_KEY" ] && sm_call "/v1/devices" | jq '{fleet_devices: ([.data // [] | length])}'
# Local network (skip surface when creds missing)
if [ -n "$UNIFI_LOCAL_URL" ] && [ -n "$UNIFI_LOCAL_KEY" ]; then
SITE=$(net_call "/sites" | jq -r '.data[0].id // .data[0].internalReference // empty')
net_call "/sites/${SITE}/devices" | jq '{net_devices: (.data|length), online: ([.data[]|select(.state=="ONLINE")]|length)}'
net_call "/sites/${SITE}/clients" | jq '{clients: (.data|length)}'
fi
# Protect (skip surface when creds missing)
[ -n "$UNIFI_PROTECT_URL" ] && [ -n "$UNIFI_PROTECT_KEY" ] && pro_call "/cameras" | jq '{cameras: length, recording: ([.[]|select(.isRecording==true)]|length), offline: ([.[]|select(.state!="CONNECTED")]|length)}'
```
Desktop render:
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
OPS ► UNIFI — [host-name] — [timestamp]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CONSOLE [model] ([fw]) state: [online]
SURFACES site-manager [✓] network [✓] protect [✓]
NETWORK [N online] / [N devices] ([N] offline)
CLIENTS [N connected] (wired [N] · wifi [N])
WAN / ISP [status] latency [Nms] uptime [N.NN%] (24h)
PROTECT [N cameras] [N recording] [N offline]
ALERTS [N]
[device] offline / [WAN] degraded / [camera] disconnected (if any)
──────────────────────────────────────────────────────
[s] sites [d] devices [c] clients [i] isp
[p] protect [x] predict [setup] credentials
──────────────────────────────────────────────────────
```
Mobile mode (`$SSH_CONNECTION` set or `$OPS_MOBILE=1`): plain text, 5–8 lines, no banners.
```
unifi: [N]/[N] net devices online · [N] clients.
wan: [status] · latency [N]ms · uptime [N.NN]%.
protect: [N] cams ([N] recording, [N] offline).
alerts: [N].
next: /ops-unifi devices | isp | protect | predict
```
If any surface key is missing, show that surface as `[—]` and skip its calls (never error the whole dashboard on one missing key). If a device/WAN/camera alert is critical, surface it at the top and suggest piping to `/ops:ops-comms` (Rule 6 — stage draft, never auto-send).
---
## SITES / HOSTS
Site Manager fleet inventory — every console and site across the account.
```bash
sm_call "/v1/hosts" | jq -r '.data[]? | "• \(.reportedState.hostname // .id) \(.reportedState.hardware.shortname // "?") \(.reportedState.state // "?") ip=\(.ipAddress // "?")"'
sm_call "/v1/sites" | jq -r '.data[]? | " site: \(.meta.name // .name // .siteId) devices=\(.statistics.counts.totalDevice // "?") clients=\(.statistics.counts.totalClient // "?")"'
```
Render grouped by host → its sites, with model/fw/state and device+client counts. Footer offers `[d] drill into a site's devices`.
---
## DEVICES
Local Network device inventory for a site — APs, switches, gateways with live state.
```bash
SITE="${CHOSEN_SITE}"
net_call "/sites/${SITE}/devices" | jq -r '.data[]? | "• \(.name) \(.model // .shortname) \(.state) fw=\(.firmwareVersion // "?") uplink=\(.uplink.type // "?")"'
# Drill: latest stats for one device
net_call "/sites/${SITE}/devices/${DEVICE_ID}/statistics/latest" | jq '{cpu:.cpuUtilizationPct, mem:.memoryUtilizationPct, rxMbps:(.uplink.rxRateBps/1e6), txMbps:(.uplink.txRateBps/1e6), uptime_s:.uptimeSec}'
```
Filter from argument: `devices aps` → `type=="uap"`/class AP; `devices switches` → `usw`; `devices gateway` → `ugw`/`udm`. Render grouped by type, flag any `state != "ONLINE"`. Offer `[a] restart a device` (→ goes through the confirmed RESTART path below).
---
## CLIENTS
Connected clients on a site, wired + wireless.
```bash
SITE="${CHOSEN_SITE}"
net_call "/sites/${SITE}/clients" | jq -r '.data[]? | "• \(.name // .hostname // .mac) \(.ipAddress // "?") \(if .uplinkDeviceId then "wifi@"+(.connectedToName//"?") else "wired" end) rx=\(.rxBytes // 0)"'
```
Sort by usage; flag clients with weak signal (`.signal < -70`) or high retries. Footer offers `[b] block a client` / `[u] unblock` (confirmed path below).
---
## ISP / WAN
Site Manager ISP metrics — WAN latency, packet loss, downtime, throughput. The backbone of **predict** mode.
```bash
# 1h-granularity metrics for the trailing window
sm_call "/v1/isp-metrics/1h" | jq '{
sites: [.data[]? | {
site: (.siteId // .meta.name),
avgLatencyMs: (.metrics.latencyAvgMs // null),
maxLatencyMs: (.metrics.latencyMaxMs // null),
packetLossPct: (.metrics.packetLossPct // null),
downtimeSec: (.metrics.downtimeSec // 0),
rxMbps: (.metrics.download.avgMbps // null),
txMbps: (.metrics.upload.avgMbps // null)
}]
}'
```
Render per-site WAN health. Flag: `downtimeSec > 0` (outage in window), `packetLossPct > 1`, `avgLatencyMs > 1.5×` the site's trailing baseline. If any flagged → suggest **predict** mode for the full picture and offer to broadcast (Rule 6).
Mobile: `wan [site]: [status] · lat [N]ms · loss [N]% · down [N]s`.
---
## SD-WAN
```bash
sm_call "/v1/sd-wan-configs" | jq -r '.data[]? | "• \(.name) type=\(.type // "?") status=\(.deploymentStatus // "?")"'
# Detail + status for one
sm_call "/v1/sd-wan-configs/${CFG_ID}/status" | jq '.'
```
Render config list with deployment status; flag any not `ACTIVE`/`DEPLOYED`.
---
## PROTECT (cameras / NVR)
```bash
pro_call "/meta/info" | jq '{nvr:.name, version:.version, mac:.mac}'
pro_call "/cameras" | jq -r '.[]? | "• \(.name) \(.type // .modelKey) state=\(.state) rec=\(.isRecording) fw=\(.firmwareVersion // "?")"'
```
Render cameras grouped by state; flag `state != "CONNECTED"` and any with `isRecording == false` that are expected to record. Footer offers `[snapshot]` and `[live]` (WebSocket watch).
### Snapshot
```bash
CAM_ID="${CHOSEN_CAM}"
pro_call "/cameras/${CAM_ID}/snapshot?highQuality=true" > "/tmp/unifi-snap-${CAM_ID}.jpg"
echo "saved /tmp/unifi-snap-${CAM_ID}.jpg ($(wc -c < /tmp/unifi-snap-${CAM_ID}.jpg) bytes)"
```
Report the saved path; the snapshot is binary JPEG — do not inline it.
### Camera settings (PATCH) — CONFIRM (Rule 5)
`PATCH /cameras/{id}` with partial JSON (e.g. toggle recording, mic sensitivity) is state-changing. Show the diff and require `AskUserQuestion` confirmation before firing.
---
## RESTART / BLOCK — confirmed state-changing actions
All of the following REQUIRE `AskUserQuestion` confirmation (Rule 5) showing the exact target before executing, and are appended to the audit log.
```bash
# Restart a device
net_call "/sites/${SITE}/devices/${DEVICE_ID}/actions" POST '{"action":"RESTART"}'
# Block / unblock a client
net_call "/sites/${SITE}/clients/${CLIENT_ID}/actions" POST '{"action":"BLOCK"}'
net_call "/sites/${SITE}/clients/${CLIENT_ID}/actions" POST '{"action":"UNBLOCK"}'
```
After firing, re-read the target once to confirm the new state and report it.
---
## VOUCHER (hotspot)
```bash
net_call "/sites/${SITE}/vouchers" | jq -r '.data[]? | "• \(.code) \(.durationMinutes)min used=\(.usedCount)/\(.quota) expires=\(.expiresAt // "—")"'
# Create (CONFIRM — creates a credential)
net_call "/sites/${SITE}/vouchers" POST '{"count":1,"durationMinutes":1440,"quota":1,"name":"ops"}'
# Revoke (CONFIRM — destructive)
net_call "/sites/${SITE}/vouchers/${VOUCHER_ID}" DELETE
```
Create + revoke require `AskUserQuestion` confirmation.
---
## PREDICT / INSIGHTS (`predict`, `insights`, `anomaly`, `health`)
Cross-surface anomaly scan that surfaces problems _before_ they become outages. Pull ISP metrics (Site Manager), device stats (Network), and camera state (Protect), then score against simple thresholds and trailing baselines. Read-only — never changes state.
Signals scored:
1. **WAN degradation** — `downtimeSec > 0` in the trailing window, `packetLossPct > 1`, or `avgLatencyMs > 1.5×` the site's 24h baseline → _WAN trending unhealthy_.
2. **AP/switch flapping** — any device with `state` toggling or `uptimeSec` reset within the window (recent reboot), or CPU/mem `> 85%` sustained → _device under stress_.
3. **Client RF health** — share of wireless clients with `signal < -72 dBm` or high retry rate > 20% → _coverage/interference risk in [zone/AP]_.
4. **Uplink saturation** — any uplink at `> 90%` of negotiated speed sustained → _capacity ceiling near_.
5. **Protect** — any camera `state != CONNECTED`, NVR storage `> 90%`, or expected-recording camera not recording → _surveillance gap_.
```bash
SITE=$(net_call "/sites" | jq -r '.data[0].id // .data[0].internalReference // empty')
# Pull the three inputs in parallel (or via the Agent Team)
sm_call "/v1/isp-metrics/1h" > /tmp/unifi_isp.json &
net_call "/sites/${SITE}/devices" > /tmp/unifi_dev.json &
net_call "/sites/${SITE}/clients" > /tmp/unifi_cli.json &
pro_call "/cameras" > /tmp/unifi_cam.json &
wait
# Score locally with jq (thresholds above) and rank findings CRITICAL→LOW.
```
Render:
```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
OPS ► UNIFI ► PREDICT — [host] — [timestamp]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
WAN [healthy | degrading | down]
⚠ [site] latency 48ms → trending 2.1× baseline, loss 1.4%
DEVICES [N] under stress
⚠ [AP name] CPU 91% sustained 20m — consider load-balance / reboot
CLIENTS [N]% wireless below -72 dBm near [AP]
UPLINKS [N] approaching capacity
PROTECT [N] surveillance gaps
VERDICT [all clear | watch | act now]
──────────────────────────────────────────────────────
Actions:
a) Restart the flagged device
b) Broadcast WAN/outage risk to /ops:ops-comms
c) Drill into a finding
d) Re-run after [N] min
──────────────────────────────────────────────────────
```
Cross-channel: if VERDICT is `act now` (active WAN outage, NVR full, or a security camera offline), suggest piping to `/ops:ops-comms` (Rule 6 — stage draft, never auto-send) and writing the finding to `daemon-health.json` for `/ops:ops-fires`.
Mobile:
```
unifi predict: [verdict].
wan: [site] [status] (lat [N]ms, loss [N]%).
devices: [N] stressed · clients: [N]% weak RF.
protect: [N] gaps.
next: /ops-unifi devices | isp
```
---
## SETUP FLOW (`setup`, `configure`, `init`, `token`)
Delegate to the central setup wizard with the network section:
```
/ops:setup --section network
```
If the wizard is unavailable, run inline discovery (background per Rule 4):
```bash
# 1. Env vars
printenv UNIFI_SITE_MANAGER_API_KEY UNIFI_LOCAL_GATEWAY_URL UNIFI_LOCAL_API_KEY UNIFI_PROTECT_URL UNIFI_PROTECT_API_KEY 2>/dev/null
# 2. Shell profiles
grep -hE 'UNIFI_' ~/.zshrc ~/.bashrc ~/.envrc ~/.mcp-secrets.env 2>/dev/null | grep -v '^#'
# 3. Doppler
doppler secrets --project unifi --config prd --json 2>/dev/null | jq -r 'to_entries[] | select(.key|test("UNIFI";"i")) | "\(.key)=(present)"'
# 4. Keychain
security find-generic-password -s "unifi-site-manager-key" 2>/dev/null
# 5. Discover the console on the LAN (mDNS / common gateway IPs)
for ip in 192.168.1.1 192.168.0.1 10.0.0.1; do curl -sk --max-time 2 "https://$ip" -o /dev/null -w "$ip → %{http_code}\n" 2>/dev/null; done
```
Instruct the user where to mint each key (Rule 3 — never silently skip):
1. **Site Manager (cloud)**: unifi.ui.com → Settings → Control Plane → Integrations → Create API Key. Header `X-API-Key`.
2. **Network (local)**: Network app → Settings → Control Plane → Integrations → Create API Key. UniFi OS consoles only.
3. **Protect (local)**: Protect → Settings → Control Plane → Integrations → Create API Key (or reuse the OS key).
Verify each acquired key before writing it to `preferences.json` under `home_network.*`:
```bash
# Site Manager
sm_call "/v1/hosts" | jq -e '.data' >/dev/null && echo "site-manager OK"
# Network
net_call "/sites" | jq -e '.data' >/dev/null && echo "network OK"
# Protect
pro_call "/meta/info" | jq -e '.version' >/dev/null && echo "protect OK"
```
After each surface passes its smoke test, merge verified values into `$PREFS_PATH` (omit empty fields — keep existing prefs for skipped surfaces):
```bash
mkdir -p "$(dirname "$PREFS_PATH")"
jq --arg sm "${UNIFI_SM_KEY:-}" \
--arg url "${UNIFI_LOCAL_URL:-}" \
--arg lk "${UNIFI_LOCAL_KEY:-}" \
--arg pu "${UNIFI_PROTECT_URL:-}" \
--arg pk "${UNIFI_PROTECT_KEY:-}" \
'.home_network = ((.home_network // {}) + {
unifi_site_manager_api_key: (if $sm != "" then $sm else (.home_network.unifi_site_manager_api_key // "") end),
unifi_local_gateway_url: (if $url != "" then $url else (.home_network.unifi_local_gateway_url // "") end),
unifi_local_api_key: (if $lk != "" then $lk else (.home_network.unifi_local_api_key // "") end),
unifi_protect_url: (if $pu != "" then $pu else (.home_network.unifi_protect_url // "") end),
unifi_protect_api_key: (if $pk != "" then $pk else (.home_network.unifi_protect_api_key // "") end)
})' \
"$PREFS_PATH" > "$PREFS_PATH.tmp" && mv "$PREFS_PATH.tmp" "$PREFS_PATH"
```
Clear `action_needed` in `daemon-health.json` when at least one surface is configured.
401/403 → key invalid; re-prompt via `AskUserQuestion` (`[Paste new key]`, `[Skip this surface]`).
---
## Agent Teams support
If `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` is set, use **Agent Teams** for parallel surface probing:
```
TeamCreate("unifi-team")
Agent(team_name="unifi-team", name="fleet-scanner", prompt="Site Manager: list hosts + sites + fleet device count + ISP metrics, return structured JSON")
Agent(team_name="unifi-team", name="network-scanner", prompt="Network Integration API for the chosen site: devices (online/offline) + clients + per-device latest stats, return structured JSON")
Agent(team_name="unifi-team", name="protect-scanner", prompt="Protect Integration API: cameras + NVR state + recording status + storage, return structured JSON")
```
If the flag is NOT set, dispatch `ops:unifi-agent` as standard fire-and-forget subagents per surface (site-manager, network, protect).
---
## Phase 3 — Cross-channel integration
After the main output, evaluate cross-channel triggers (suggestions only — never auto-execute Rule-5/Rule-6 actions):
1. **WAN outage / security-camera offline → comms** — stage a `/ops:ops-comms` draft (WhatsApp/Telegram) to emergency contacts; Rule 6 per-message approval.
2. **Predict verdict = act now → fires** — write the finding to `daemon-health.json` under `action_needed`; suggest `/ops:ops-fires`.
3. **Recurring device offline → status** — if a device has been offline across consecutive scans, note it for the next `/ops:ops-go` briefing.
4. **Both Site Manager and local unreachable → status** — write `action_needed: "unifi_unreachable"` to daemon-health and exit with a banner.
---
## Phase 4 — Error handling
| Failure | Behavior |
| ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| Site Manager 401 | Cloud key expired/invalid. Tell user to regenerate at unifi.ui.com. |
| Site Manager 429 | Rate-limited. Back off; note "site-manager throttled" and use cached/local data. |
| Local connection refused / timeout | Console unreachable on LAN (off-site or wrong IP). Fall back to Site Manager for read-only fleet view; note `transport=cloud-only`. |
| Local 401 | Network/Protect key invalid. Tell user to regenerate in the app. |
| Protect 404 on integration paths | Protect Integration API not enabled / older firmware. Note "protect integration unavailable". |
| All surfaces fail | Report which failed; write `action_needed: "unifi_unreachable"` to daemon-health; exit with banner. |
| jq missing | Print raw JSON, suggest installing jq. |
| No credentials at all | Exit gracefully with `/ops:setup --section network`. |
Audit every state-changing call (device RESTART, client BLOCK/UNBLOCK, camera PATCH, voucher create/revoke) to `${CLAUDE_PLUGIN_DATA_DIR:-$HOME/.claude/plugins/data/ops-ops-marketplace}/ops-unifi-audit.log` with timestamp, action, target id, and result.
---
## Optional MCP path
The skill is fully self-sufficient via curl. If you want a richer natural-language tool surface, community UniFi MCP servers exist (no official Ubiquiti MCP as of 2026):
- `ry-ops/unifi-mcp-server` — local consoles **and** cloud Site Manager in one server.
- `sirkirby/unifi-mcp` — Network (stable, ~169 tools), Protect, Access.
- `DataKnifeAI/unifi-network-mcp` + `unifi-protect-mcp` — split Network / Protect servers.
To use one, register it via the on-demand MCP registry (`~/.claude/mcp-ondemand.json` → `mcp-toggle.sh on unifi`) and restart Claude Code. Treat these as untrusted third-party code — security-review before install. This skill does not require any of them.
---
## Output style
- Terse-direct. Plain text in mobile mode (Rule 7). Tables only on desktop.
- Always show the hotkey footer (`[s] sites [d] devices [c] clients [i] isp [p] protect [x] predict`) on desktop.
- Use `AskUserQuestion` (max 4 options, Rule 1) for any state-changing action and for site selection when ambiguous.
- Never auto-send messages — always stage drafts per Rule 6.
- Local calls always use `-k` (self-signed cert) and a short `--max-time`; degrade to Site Manager read-only when the console is off-LAN.
## Additional resources
CLI detail: `references/cli.md`.