unifi-api
UniFi API Skill
Interact with a UniFi Dream Router or other UniFi OS console via its REST API. Tested against UDR-7 running UniFi OS 4.x / Network Application 10.x.
CLI (restish) — Preferred for Integration API
A unifi CLI wrapper is installed at ~/.local/bin/unifi. It uses restish with the official OpenAPI spec and injects the API key from 1Password automatically via op run.
# List devices
unifi get-adopted-device-overview-page default
# List connected clients
unifi get-connected-client-overview-page default
# List networks
unifi get-networks-overview-page default
# Get specific network details
unifi get-network-details <networkId> default
# List firewall policies
unifi get-firewall-policies default
# JSON output
unifi get-connected-client-overview-page default --rsh-output-format json
# Filter results (restish shorthand query)
unifi get-connected-client-overview-page default -f 'data[].{name, ipAddress, type}'
Use default as the site ID shorthand — the wrapper replaces it with the real UUID. Override with UNIFI_SITE=<uuid> unifi ... for other sites.
Run unifi --help for the full list of generated commands (44 total, covering devices, clients, networks, firewall, DNS, WiFi, hotspot, ACLs, and more).
Restish configuration
- Config:
~/Library/Application Support/restish/apis.json - Spec file:
~/Library/Application Support/restish/specs/network-10.2.105.json(patched: server URL set to/proxy/network/integrationfor UDR proxy path) - Auth:
X-API-Keyheader, injected viaUNIFI_API_KEYenv var - Wrapper:
~/.local/bin/unifi— handlesop run, TLS skip, WARN suppression, anddefault→ UUID replacement
Recreating the restish setup from scratch
If restish or the unifi wrapper aren't installed, follow these steps to set them up.
1. Install restish:
brew install restish
2. Download the OpenAPI spec from Ubiquiti:
Go to https://developer.ui.com/ and download the Network API spec for your controller version (JSON format). Save it to the restish specs directory:
mkdir -p ~/Library/Application\ Support/restish/specs
# Move or copy the downloaded spec file:
mv ~/Downloads/network-<version>.json ~/Library/Application\ Support/restish/specs/
3. Patch the spec's server URL:
The downloaded spec has "servers": [{"url": "/proxy/network/integration"}]. This is correct for UDR proxy-path access — no changes needed. If the url value differs (e.g., an absolute URL), edit the JSON to set it to /proxy/network/integration.
4. Create the restish API config:
cat > ~/Library/Application\ Support/restish/apis.json << 'EOF'
{
"$schema": "https://rest.sh/schemas/apis.json",
"unifi": {
"base": "https://10.0.0.1",
"spec_files": [
"/Users/fishy/Library/Application Support/restish/specs/network-<version>.json"
],
"profiles": {
"default": {
"headers": {
"X-API-Key": "${UNIFI_API_KEY}"
}
}
}
}
}
EOF
Replace <version> with the actual spec version (e.g., 10.2.105). Update base if your router IP differs from 10.0.0.1.
5. Create the wrapper script:
mkdir -p ~/.local/bin
cat > ~/.local/bin/unifi << 'SCRIPT'
#!/bin/bash
# UniFi Network CLI — wraps restish with 1Password API key injection
UNIFI_SITE="${UNIFI_SITE:-88f7af54-98f8-306a-a1c7-c9349722b1f6}"
# Replace "default" with the real site UUID in positional args
args=()
for arg in "$@"; do
[[ "$arg" == "default" ]] && arg="$UNIFI_SITE"
args+=("$arg")
done
exec op run --env-file=<(echo 'UNIFI_API_KEY=op://Claude/Unifi API Key/credential') -- \
restish unifi --rsh-insecure --rsh-no-cache "${args[@]}" 2> >(grep -v '^WARN:' >&2)
SCRIPT
chmod +x ~/.local/bin/unifi
Update the UNIFI_SITE UUID for your site. Find it by running:
op run --env-file=<(echo 'UNIFI_API_KEY=op://Claude/Unifi API Key/credential') -- \
restish unifi --rsh-insecure get-sites --rsh-output-format json 2>/dev/null
6. Verify it works:
unifi get-sites
unifi get-adopted-device-overview-page default
When to use the CLI vs raw API
- CLI (
unifi) — for Integration API endpoints. Auto-discovers all operations from the OpenAPI spec. Handles pagination flags, output formatting, and auth. - Raw curl/python — for Legacy API endpoints (e.g.,
stat/sta,cmd/stamgr,rest/wlanconf). The legacy API is not covered by the OpenAPI spec.
Raw API Setup (Legacy + Integration)
For Legacy API endpoints or when CLI isn't available:
- Router IP: read from the
unifiwrapper or ask the user (commonly10.0.0.1) - API key: injected via
op run— never hardcode or print
import os, urllib.request, json, ssl
BASE = f"https://{ROUTER_IP}"
KEY = os.environ["UNIFI_API_KEY"]
HEADERS = {"X-API-KEY": KEY, "Accept": "application/json"}
CTX = ssl._create_unverified_context() # self-signed cert
def get(path):
req = urllib.request.Request(f"{BASE}{path}", headers=HEADERS)
with urllib.request.urlopen(req, context=CTX) as r:
return json.loads(r.read())
def post(path, body=None):
data = json.dumps(body or {}).encode()
req = urllib.request.Request(f"{BASE}{path}", data=data, method="POST",
headers={**HEADERS, "Content-Type": "application/json"})
with urllib.request.urlopen(req, context=CTX) as r:
return json.loads(r.read())
def put(path, body):
data = json.dumps(body).encode()
req = urllib.request.Request(f"{BASE}{path}", data=data, method="PUT",
headers={**HEADERS, "Content-Type": "application/json"})
with urllib.request.urlopen(req, context=CTX) as r:
return json.loads(r.read())
Curl equivalent:
curl -sk -H "X-API-KEY: $UNIFI_API_KEY" -H "Accept: application/json" \
https://<ROUTER_IP>/proxy/network/integration/v1/sites
Note: The API key works on both the Integration API and the legacy private API — no session cookie needed on UDR-7.
Security note:
ssl._create_unverified_context()disables TLS certificate verification to accommodate the router's self-signed cert. This is acceptable on a trusted home LAN but means a MITM on the same network could intercept the API key. Do not use this pattern over untrusted networks.
Site Identifiers
Two formats are used depending on the API layer. Always start by fetching sites:
sites = get("/proxy/network/integration/v1/sites")["data"]
SITE = sites[0]["id"] # UUID — for Integration API
SITE_S = sites[0]["internalReference"] # short name (usually "default") — for Legacy API
IV1 = f"/proxy/network/integration/v1/sites/{SITE}"
LEGACY = f"/proxy/network/api/s/{SITE_S}"
For the unifi CLI, pass default as the site ID — the wrapper handles UUID substitution.
Response Formats
Integration API — paginated:
{"offset": 0, "limit": 25, "count": 25, "totalCount": 100, "data": [...]}
Page through with ?offset=N&limit=25. To get all results loop until offset + count >= totalCount.
Legacy API — flat list:
{"meta": {"rc": "ok"}, "data": [...]}
Endpoints Reference
Clients / Stations
# All currently connected clients (rich: ip, mac, hostname, tx/rx bytes,
# uptime, switch port, network, fingerprint, fixed_ip flag, etc.)
get(f"{LEGACY}/stat/sta")
# All known clients including historical
get(f"{LEGACY}/stat/alluser")
# Single client by MAC
get(f"{LEGACY}/stat/user/aa:bb:cc:dd:ee:ff")
# Via Integration API (lighter fields, paginated)
get(f"{IV1}/clients")
get(f"{IV1}/clients/{{clientId}}")
Key client fields: mac, ip, hostname, name, network, network_id,
is_wired, uptime, first_seen, last_seen, wired-tx_bytes,
wired-rx_bytes, wired-tx_bytes-r (current rate), wired-rx_bytes-r,
sw_port, use_fixedip, fixed_ip, local_dns_record, is_guest,
dev_family, oui, confidence.
Client commands (destructive — confirm MAC before executing):
post(f"{LEGACY}/cmd/stamgr", {"cmd": "block-sta", "mac": "aa:bb:cc:dd:ee:ff"})
post(f"{LEGACY}/cmd/stamgr", {"cmd": "unblock-sta", "mac": "aa:bb:cc:dd:ee:ff"})
post(f"{LEGACY}/cmd/stamgr", {"cmd": "kick-sta", "mac": "aa:bb:cc:dd:ee:ff"})
post(f"{LEGACY}/cmd/stamgr", {"cmd": "forget-sta", "mac": "aa:bb:cc:dd:ee:ff"})
Devices
# Full device stats (rich: system-stats, uplink, wan1, speedtest-status, port stats)
get(f"{LEGACY}/stat/device")
# Lightweight list
get(f"{LEGACY}/stat/device-basic")
# Via Integration API
get(f"{IV1}/devices")
get(f"{IV1}/devices/{{deviceId}}")
Key device fields: mac, name, model, version, uptime,
system-stats (cpu%, mem%, uptime string), uplink (ip, rx_bytes, tx_bytes,
drops, latency, xput_down, xput_up, speedtest_lastrun), wan1 (full port stats),
speedtest-status (latency, xput_download, xput_upload, server details).
Device commands (destructive — restart, upgrade, and power-cycle will disrupt connectivity; confirm with user before executing):
post(f"{LEGACY}/cmd/devmgr", {"cmd": "restart", "mac": "aa:bb:cc:dd:ee:ff"})
post(f"{LEGACY}/cmd/devmgr", {"cmd": "upgrade", "mac": "aa:bb:cc:dd:ee:ff"})
post(f"{LEGACY}/cmd/devmgr", {"cmd": "set-locate", "mac": "aa:bb:cc:dd:ee:ff"}) # blink LED
post(f"{LEGACY}/cmd/devmgr", {"cmd": "unset-locate", "mac": "aa:bb:cc:dd:ee:ff"})
post(f"{LEGACY}/cmd/devmgr", {"cmd": "speedtest"})
post(f"{LEGACY}/cmd/devmgr", {"cmd": "speedtest-status"})
post(f"{LEGACY}/cmd/devmgr", {"cmd": "power-cycle", "mac": "...", "port_idx": 1})
Health & Status
# Subsystem health: wlan, wan, www, lan, vpn
# wan: WAN IP, ISP name, latency monitors, uptime stats, client counts, CPU/mem
# www: xput_up/down, latency, speedtest_status, speedtest_lastrun
get(f"{LEGACY}/stat/health")
# Controller version, timezone, hostname, all IP addresses, uptime
get(f"{LEGACY}/stat/sysinfo")
Health www subsystem fields: xput_up, xput_down, latency, uptime,
drops, speedtest_status, speedtest_lastrun, speedtest_ping.
Networks / VLANs
# Full network config (WAN + all LANs/VLANs)
# Fields: name, purpose (wan/corporate), ip_subnet, vlan, wan_dns1/2,
# wan_provider_capabilities, firewall_zone_id
get(f"{LEGACY}/rest/networkconf")
put(f"{LEGACY}/rest/networkconf/{{id}}", updated_fields)
# Integration API (cleaner, full CRUD)
get(f"{IV1}/networks")
post(f"{IV1}/networks", new_network)
put(f"{IV1}/networks/{{networkId}}", updated_network)
# DELETE also available
WiFi / SSIDs
# All WLANs — fields: name, enabled, wlan_band (both/2g/5g), wpa_mode,
# x_passphrase (returned in plaintext — avoid logging), networkconf_id,
# usergroup_id, hide_ssid, is_guest, pmf_mode
get(f"{LEGACY}/rest/wlanconf")
# Toggle on/off
put(f"{LEGACY}/rest/wlanconf/{{id}}", {"enabled": True})
# Change password
put(f"{LEGACY}/rest/wlanconf/{{id}}", {"x_passphrase": "newpassword"})
# Create new SSID
post(f"{LEGACY}/add/wlanconf", {...})
Firewall
Firewall policies (Integration API — the primary firewall system on UDR-7):
# Paginate through all policies
get(f"{IV1}/firewall/policies?offset=0&limit=25")
# Fields: id, enabled, name, description, index, action.type (BLOCK/ALLOW),
# source.zoneId, source.trafficFilter, destination.zoneId,
# destination.trafficFilter (ipAddressFilter, portFilter, macAddressFilter),
# ipProtocolScope.ipVersion, loggingEnabled, metadata.origin
# Enable/disable a policy (PATCH — use requests library for PATCH support)
# PATCH /proxy/network/integration/v1/sites/{SITE}/firewall/policies/{id}
# body: {"enabled": false}
Firewall zones:
get(f"{IV1}/firewall/zones")
# Zone fields: id, name, networkIds, metadata.origin (SYSTEM_DEFINED/USER_DEFINED)
# System zones: Gateway, Vpn, Dmz, Hotspot, External, Internal
Legacy firewall (old-style rules — may be empty if using new policy system):
get(f"{LEGACY}/rest/firewallrule") # individual rules
get(f"{LEGACY}/rest/firewallgroup") # IP/MAC address groups
Traffic rules (v2):
get(f"/proxy/network/v2/api/site/{SITE_S}/trafficrules")
post(f"/proxy/network/v2/api/site/{SITE_S}/trafficrules", new_rule)
Port Forwarding
# Read current rules
# Fields: name, fwd (dest IP), fwd_port, dst_port, proto, enabled,
# pfwd_interface, rx_bytes, rx_packets
get(f"{LEGACY}/stat/portforward")
# Create
post(f"{LEGACY}/rest/portforward", {
"name": "My Service", "fwd": "10.0.0.x", "fwd_port": "8080",
"dst_port": "8080", "proto": "tcp_udp", "pfwd_interface": "wan"
})
# Enable/disable
put(f"{LEGACY}/rest/portforward/{{id}}", {"enabled": False})
Time-Series Stats
# Always specify attrs to get useful data back
ATTRS = ["bytes", "rx_bytes", "tx_bytes", "num_sta"]
post(f"{LEGACY}/stat/report/5minutes.site", {"attrs": ATTRS})
post(f"{LEGACY}/stat/report/hourly.site", {"attrs": ATTRS})
post(f"{LEGACY}/stat/report/daily.site", {"attrs": ATTRS})
# Per-client (filter by MAC)
post(f"{LEGACY}/stat/report/hourly.user",
{"attrs": ["bytes", "rx_bytes", "tx_bytes"], "mac": "aa:bb:cc:dd:ee:ff"})
# Per-AP
post(f"{LEGACY}/stat/report/hourly.ap", {"attrs": ATTRS})
Data retention defaults: 24h at 5-min resolution, 7d hourly, 90d daily.
Events & Alarms
get(f"{LEGACY}/stat/event") # recent events
get(f"{LEGACY}/stat/alarm") # active alarms
Site Settings
# Full settings blob (keys: super_identity, super_mgmt, connectivity, locale,
# snmp, mgmt, usg, ips, rsyslogd, broadcastping, and more)
get(f"{LEGACY}/rest/setting")
What Does NOT Work on UDR-7 (Integration API 404s)
These endpoints exist in the docs but return 404 on UDR-7 firmware 4.x:
/wifi-broadcasts→ use legacy/rest/wlanconfinstead/dns-policies/supporting-resources/wan-interfaces/supporting-resources/dpi-categories/supporting-resources/device-tags/supporting-resources/site-to-site-vpn-tunnels/supporting-resources/vpn-servers/supporting-resources/radius-profiles/v2/api/site/{site}/trafficstats
Common Patterns
Get WAN status:
health = get(f"{LEGACY}/stat/health")["data"]
wan = next(s for s in health if s["subsystem"] == "wan")
www = next(s for s in health if s["subsystem"] == "www")
print(f"WAN IP: {wan['wan_ip']}, ISP: {wan.get('isp_name','?')}")
print(f"Speed: ↓{www['xput_down']} ↑{www['xput_up']} Mbps, latency: {www['latency']}ms")
List clients sorted by current throughput:
clients = get(f"{LEGACY}/stat/sta")["data"]
for c in sorted(clients, key=lambda x: x.get("wired-tx_bytes-r", 0) + x.get("wired-rx_bytes-r", 0), reverse=True):
name = c.get("name") or c.get("hostname") or c["mac"]
tx_r = c.get("wired-tx_bytes-r", 0)
rx_r = c.get("wired-rx_bytes-r", 0)
print(f"{name:35} {c.get('ip','?'):16} ↓{rx_r/1024:.1f} ↑{tx_r/1024:.1f} KB/s")
Find a client by name, hostname, or IP:
clients = get(f"{LEGACY}/stat/sta")["data"]
query = "my-device"
target = next((c for c in clients if
query.lower() in (c.get("name","") + c.get("hostname","")).lower() or
c.get("ip") == query), None)
Paginate through all firewall policies:
policies, offset = [], 0
while True:
page = get(f"{IV1}/firewall/policies?offset={offset}&limit=25")
policies.extend(page["data"])
if offset + page["count"] >= page["totalCount"]:
break
offset += 25
Toggle an SSID:
wlans = get(f"{LEGACY}/rest/wlanconf")["data"]
wlan = next(w for w in wlans if w["name"] == "My SSID")
put(f"{LEGACY}/rest/wlanconf/{wlan['_id']}", {"enabled": not wlan["enabled"]})
PATCH support (needed for firewall policy enable/disable):
# urllib doesn't support PATCH — install requests or use this workaround:
import urllib.request
class PatchRequest(urllib.request.Request):
def get_method(self): return "PATCH"
req = PatchRequest(f"{BASE}{path}", data=json.dumps(body).encode(),
headers={**HEADERS, "Content-Type": "application/json"})
with urllib.request.urlopen(req, context=CTX) as r:
return json.loads(r.read())
More from trtmn/agent-skills
homebrew-dev
Package and distribute macOS apps, fonts, CLI tools, and arbitrary files using Homebrew formulas and casks. Use this skill whenever the user wants to create a Homebrew formula or cask, set up a personal tap, package a macOS .app bundle, distribute fonts or pre-built binaries via brew, use `brew create`, bump a formula or cask to a new version, submit a package to homebrew-core or homebrew-cask, or publish anything with Homebrew — even if they just ask how to "make something installable with brew", "share my app through Homebrew", "update my formula", or "get my package into Homebrew".
5tailscale-policy-manager
>
5skills-manager
Install, remove, list, find, and update Claude Code skills using the `npx skills` CLI. Use this skill whenever the user wants to manage their agent skills — install a new skill, search the skills registry, remove a skill, check for updates, or update all skills. Also trigger for requests like "find me a skill for X", "install the Y skill", "what skills do I have installed", "remove the Z skill", or "are my skills up to date.
4