ops-unifi — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited ops-unifi (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Control Ubiquiti UniFi infrastructure across all three official APIs:
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).https://<gateway>/proxy/network/integration/v1) — per-console control: list/restart devices, list/block clients, manage hotspot vouchers, read live statistics.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.
Before executing, load available context:
${CLAUDE_PLUGIN_DATA_DIR:-$HOME/.claude/plugins/data/ops-ops-marketplace}/preferences.jsontimezone — display all timestamps in user's timezonehome_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 URLhome_network.unifi_protect_api_key — Protect Integration API key (defaults to local key if unset — one UniFi OS key often works for both)${CLAUDE_PLUGIN_DATA_DIR:-$HOME/.claude/plugins/data/ops-ops-marketplace}/daemon-health.jsonaction_needed is not null → surface it before running any UniFi operationsaction_needed back to daemon-health.jsonBase URL: https://api.ui.com Auth header: X-API-Key: ${UNIFI_SM_KEY} Key generation: unifi.ui.com → (top-right account) → Settings → Control Plane → Integrations → Create API Key (or developer.ui.com). Key is shown once — store it. Currently read-scoped for most keys; a 429 means you hit the rate limit (back off).
| Endpoint | Method | Description |
|---|---|---|
/v1/hosts | GET | List every UniFi OS console (host) on the account |
/v1/hosts/{id} | GET | One console: model, fw, IP, owner, state |
/v1/sites | GET | List all sites across all hosts |
/v1/devices | GET | List all adopted devices across all hosts |
/v1/isp-metrics/{type} | GET | ISP/WAN metrics, type = 5m or 1h (latency, downtime, throughput) |
/v1/isp-metrics/{type}/query | POST | Query ISP metrics for specific sites/time ranges |
/v1/sd-wan-configs | GET | List SD-WAN configurations |
/v1/sd-wan-configs/{id} | GET | SD-WAN config detail |
/v1/sd-wan-configs/{id}/status | GET | SD-WAN config deployment status |
Pagination: responses carry nextToken; pass ?pageSize=N&nextToken=….
Base URL: ${UNIFI_LOCAL_URL}/proxy/network/integration/v1 Auth header: X-API-Key: ${UNIFI_LOCAL_KEY} Key generation: UniFi Network app → Settings → Control Plane → Integrations → Create API Key (UniFi OS consoles only — UDM/UDR/UCG/UX/UDW/UCG-Ultra/UniFi OS Server; the legacy self-hosted Network app does not support API keys). TLS: local consoles use a self-signed cert → all curl calls use -k.
| Endpoint | Method | Description |
|---|---|---|
/info | GET | Network application version + metadata |
/sites | GET | List sites on this console |
/sites/{siteId}/devices | GET | List adopted devices (APs, switches, gateways) |
/sites/{siteId}/devices/{deviceId} | GET | Device detail (uptime, fw, ports, radios) |
/sites/{siteId}/devices/{deviceId}/statistics/latest | GET | Latest device stats (throughput, CPU, mem, uplink) |
/sites/{siteId}/devices/{deviceId}/actions | POST | Device action — {"action":"RESTART"} |
/sites/{siteId}/clients | GET | Connected clients (wired + wireless) |
/sites/{siteId}/clients/{clientId} | GET | Client detail (IP, MAC, AP, signal, usage) |
/sites/{siteId}/clients/{clientId}/actions | POST | Client action — {"action":"BLOCK"} / {"action":"UNBLOCK"} |
/sites/{siteId}/vouchers | GET | Hotspot vouchers |
/sites/{siteId}/vouchers | POST | Create voucher(s) |
/sites/{siteId}/vouchers/{voucherId} | DELETE | Revoke a voucher |
Base URL: ${UNIFI_PROTECT_URL}/proxy/protect/integration/v1 Auth header: X-API-Key: ${UNIFI_PROTECT_KEY} Key generation: UniFi OS → Protect → Settings → Control Plane → Integrations → Create API Key (or reuse the UniFi OS console key). -k for self-signed cert.
| Endpoint | Method | Description |
|---|---|---|
/meta/info | GET | NVR + Protect application info/version |
/nvrs | GET | NVR(s) detail (storage, recording mode) |
/cameras | GET | List cameras |
/cameras/{id} | GET | Camera detail (state, fw, recording, motion zones) |
/cameras/{id} | PATCH | Update camera settings (partial JSON — only changed fields) |
/cameras/{id}/snapshot?highQuality=true | GET | JPEG snapshot (binary) |
/cameras/{id}/rtsps-stream | GET/POST | Retrieve / manage the RTSPS stream URL |
/sensors | GET | UniFi Protect sensors |
/lights | GET | Protect smart lights (PATCH /lights/{id} to control) |
/chimes | GET | Chimes (PATCH /chimes/{id}) |
/viewers | GET | Protect viewers (PATCH /viewers/{id} to change live view) |
Real-time: WebSocket wss://${UNIFI_PROTECT_URL#https://}/proxy/protect/integration/v1/subscribe/devices (and /subscribe/events) streams state changes — used only for live-watch flows, not the default dashboard.
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.
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:
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"
}| 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.
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.
# 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 | predictIf 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).
Site Manager fleet inventory — every console and site across the account.
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.
Local Network device inventory for a site — APs, switches, gateways with live state.
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).
Connected clients on a site, wired + wireless.
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).
Site Manager ISP metrics — WAN latency, packet loss, downtime, throughput. The backbone of predict mode.
# 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.
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.
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).
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.
PATCH /cameras/{id} with partial JSON (e.g. toggle recording, mic sensitivity) is state-changing. Show the diff and require AskUserQuestion confirmation before firing.
All of the following REQUIRE AskUserQuestion confirmation (Rule 5) showing the exact target before executing, and are appended to the audit log.
# 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.
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}" DELETECreate + revoke require AskUserQuestion confirmation.
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:
downtimeSec > 0 in the trailing window, packetLossPct > 1, or avgLatencyMs > 1.5× the site's 24h baseline → _WAN trending unhealthy_.state toggling or uptimeSec reset within the window (recent reboot), or CPU/mem > 85% sustained → _device under stress_.signal < -72 dBm or high retry rate > 20% → _coverage/interference risk in [zone/AP]_.> 90% of negotiated speed sustained → _capacity ceiling near_.state != CONNECTED, NVR storage > 90%, or expected-recording camera not recording → _surveillance gap_.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 | ispsetup, configure, init, token)Delegate to the central setup wizard with the network section:
/ops:setup --section networkIf the wizard is unavailable, run inline discovery (background per Rule 4):
# 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; doneInstruct the user where to mint each key (Rule 3 — never silently skip):
X-API-Key.Verify each acquired key before writing it to preferences.json under home_network.*:
# 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):
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]).
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).
After the main output, evaluate cross-channel triggers (suggestions only — never auto-execute Rule-5/Rule-6 actions):
/ops:ops-comms draft (WhatsApp/Telegram) to emergency contacts; Rule 6 per-message approval.daemon-health.json under action_needed; suggest /ops:ops-fires./ops:ops-go briefing.action_needed: "unifi_unreachable" to daemon-health and exit with a banner.| 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.
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.
[s] sites [d] devices [c] clients [i] isp [p] protect [x] predict) on desktop.AskUserQuestion (max 4 options, Rule 1) for any state-changing action and for site selection when ambiguous.-k (self-signed cert) and a short --max-time; degrade to Site Manager read-only when the console is off-LAN.~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.