Categories
AI UniFi

Managing UniFi from the Terminal with Claude Code

Use Claude Code with the UniFi Network API to document your network in Git and make device changes in plain English, gated on approval before anything is sent.

Most of my UniFi estate lives behind a terminal now. I stopped clicking through the controller UI months ago. Claude Code talks to the UniFi Network integration API directly, which means I can pull a full inventory of sites, devices and clients into a Git repo, and I can ask for a device restart in plain English instead of building the curl call myself. For an MSP that’s the point: network operations that are codified, auditable and repeatable, not tribal knowledge sitting in someone’s head or a half-remembered controller login.

The official API is deliberately narrow. It gives you read access to sites, devices and clients, plus a small set of state-changing actions like restarting a device. That’s not everything you’d want from a network API, but it’s enough to build a genuinely useful workflow around, and it’s stable enough to trust with automation.

The API, and how Claude Code reaches it

The base URL is https://<console>/proxy/network/integration/v1/. Authentication is a single header, X-API-KEY: <key>. You generate the key from inside the local UniFi OS console, under UniFi Network, then Settings, then Control Plane, then Integrations. If you’re working against the cloud rather than a local console, keys are issued from unifi.ui.com instead.

I keep the key in an environment variable, UNIFI_API_KEY, and reference it as $UNIFI_API_KEY in every call. It never gets typed into a prompt, never lands in shell history as a literal string, and never gets committed to Git by accident. That matters more than it sounds. A prompt is a string that gets logged, stored in conversation history, and potentially sent to a model provider. An environment variable substitution is just a shell mechanic that resolves at execution time and leaves no trace of the value itself in anything Claude actually reads or writes. If you’re doing this across client sites, that distinction is the difference between a mildly awkward key rotation and a proper incident.

Local consoles run a self-signed certificate, so every curl call needs -k:

curl -k -s -H "X-API-KEY: $UNIFI_API_KEY" \
  https://192.168.1.1/proxy/network/integration/v1/sites

That returns your sites, each with an id you’ll need for every subsequent call.

I scope Claude’s tool access with --allowedTools "Bash(curl:*),Read,Write". That restricts it to the curl binary, plus reading and writing files, nothing else. It’s a coarse net. It cannot tell a GET from a POST, so it won’t stop Claude from constructing a state-changing call if it decides one is warranted. The real backstop is the permission prompt: Claude has to show me the exact command before it runs, and I approve or reject it. The allowlist keeps the blast radius small if something goes wrong; the approval step is what actually stops a bad action.

Documenting the network into Git

Three GET calls cover the read side of the API:

curl -k -s -H "X-API-KEY: $UNIFI_API_KEY" \
  https://192.168.1.1/proxy/network/integration/v1/sites

curl -k -s -H "X-API-KEY: $UNIFI_API_KEY" \
  https://192.168.1.1/proxy/network/integration/v1/sites/$SITE_ID/devices

curl -k -s -H "X-API-KEY: $UNIFI_API_KEY" \
  https://192.168.1.1/proxy/network/integration/v1/sites/$SITE_ID/clients
Endpoint Method Returns
/proxy/network/integration/v1/sites GET Sites and their IDs
/proxy/network/integration/v1/sites/{siteId}/devices GET Adopted devices: model, name, IP, state, firmware
/proxy/network/integration/v1/sites/{siteId}/clients GET Currently connected clients

The devices response typically includes the model, name, IP address, state and firmware for each adopted device. I say “typically” deliberately. The exact field set has shifted between releases as the integration API matures, so I don’t hardcode assumptions about it into scripts. I read what comes back and work with that, rather than asserting a fixed schema.

In practice, this is enough to answer the questions that actually come up during a support call. Which access points are currently offline. Which firmware version a switch is running before you push an update. Which clients are connected to a given site right now, useful when a customer rings up saying “the wifi’s down” and you need to know in ten seconds whether that’s true for everyone or just their laptop. None of this needs the controller UI open. It’s a couple of curl calls and a JSON blob.

What I do with that JSON blob is turn it into a Markdown file, network.md, per site, and commit it to Git. Claude does the formatting: it takes the raw device and client output and writes a readable summary, tables of devices with their state and firmware, a client count, whatever’s useful for that site. Then it commits the file.

The reason the Git history matters is drift. Networks change constantly and quietly. A switch gets swapped during an outage and nobody updates the documentation. A firmware update rolls out to half the estate and stalls on the other half. An access point goes offline for three days before anyone notices because nobody’s watching it. If you run this drift-log workflow on a schedule, daily or weekly, each commit is a timestamped snapshot of exactly what the network looked like at that moment. When something breaks, you don’t ask “what changed”, you look at the diff. Git tells you precisely which device dropped off, which firmware version moved, which client disappeared, and when. That’s a genuinely different position to be in during an incident, compared to reconstructing the timeline from memory or a support ticket written after the fact.

It also means documentation and reality can’t drift apart the way they normally do. Nobody has to remember to update a wiki page. The commit is the update, and it’s generated from the API response, not from someone’s recollection of what they think is out there.

Claude Code calling the UniFi Network integration API with an X-API-KEY header and writing network.md into Git
Figure 1 – Claude Code calling the UniFi Network API with X-API-KEY and writing network.md into Git.

Making changes with plain English

The read side is useful on its own, but the part that changed how I actually work day to day is the write side, specifically the single action endpoint for restarting a device.

The workflow starts with a plain English instruction: restart the access point in the server room, say. Claude doesn’t guess at a device ID from that sentence. It calls the sites endpoint first and lists what’s available, then calls the devices endpoint for the relevant site and lists what’s adopted there. It matches the device I described against the name field in that device list, so “the access point in the server room” resolves to an actual device with an actual name, not a guess. Once it has a match, it resolves both the site id and the device id from what it just retrieved, and constructs the exact POST request needed. Before anything is sent anywhere, it shows me that request in full, method, URL, headers, body, all of it. Only once I approve does it actually run.

curl -k -s -X POST \
  -H "X-API-KEY: $UNIFI_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"action":"RESTART"}' \
  https://192.168.1.1/proxy/network/integration/v1/sites/$SITE_ID/devices/$DEVICE_ID/actions

The approval gate is the whole point of doing this through Claude Code rather than a fully autonomous script. I’m not trying to remove the human from the loop, I’m trying to remove the tedious part, working out which site, which device, which endpoint, what the JSON body needs to look like, from the part that actually matters, deciding whether restarting that device right now is the correct call. Claude does the lookup and the construction. I do the judgement. The permission prompt sitting between “here’s what I’m about to send” and “it’s been sent” is not friction I want to engineer away, it’s the entire safety mechanism.

This scales reasonably well across an MSP estate too. Each site has its own id and its own device list, so the same plain-English pattern works whether I’m talking about a client with one office or one with fifteen sites, provided I identify which site I mean, or let Claude ask if it’s ambiguous. I haven’t tried to fully automate the site selection because getting that wrong at 2am against the wrong client’s network is exactly the kind of mistake the approval step exists to catch. For now, naming the site explicitly in the instruction, or being ready to confirm it when asked, is the safer habit.

Plain English instruction resolved by Claude into a proposed UniFi device restart, gated on human approval
Figure 2 – Plain English to device lookup to a proposed action to human approval before the change reaches the controller.

Where the official API stops

The official API does not cover everything you’d want to manage on a UniFi network. Firewall rules and port forwarding are the two I run into most, and neither is exposed through the integration API today. For those you’re back on the legacy controller API, authenticating with /api/auth/login against the UniFi OS console to get a session cookie, then carrying a CSRF token on every subsequent request to endpoints under /proxy/network/api/s/{site}/.... It works, but it’s a different authentication model entirely, cookie and token rather than a static API key, and it’s not documented with the same care as the integration API. I use it sparingly and I don’t build the same drift-log automation around it, because the surface is less predictable and I’d rather not commit to a pattern that legacy endpoint might change under me without notice.

Towards an API that covers more

The integration API is still young, and UniFi has clearly been extending it steadily rather than shipping it complete and walking away. The gap between what it covers now, sites, devices, clients, a handful of actions, and what a full network configuration API would need, firewall rules, port forwarding, VLAN configuration, is real, but it’s the kind of gap that gets narrower with each release rather than wider. I’d rather build the drift-log and plain-English change workflow on the smaller, well-documented surface today and extend it as the API grows, than reverse-engineer the legacy controller API more than I have to. The pattern doesn’t change when new endpoints land: read first, write once approved, commit the evidence. That’s the part worth keeping stable, regardless of how much surface the API eventually covers.

Leave a Reply

Your email address will not be published. Required fields are marked *