# Reconciling a UniFi site against NetBox in one prompt

> NetBox is only a source of truth if it still matches the network. Here is how one natural-language instruction reconciles a live UniFi controller against NetBox, writes only the differences, and proves the boring but important property: when nothing has drifted, it changes nothing.

Canonical: https://www.toolmesh.io/en/blog/unifi-network-to-netbox/

A source of truth is only true until someone touches the network. Swap an access point, let DHCP hand out a new management address, add a unit in a back room, and the controller is immediately right while NetBox is quietly wrong. Nobody catches that by hand-diffing ten devices every week, so the documentation drifts and the trust in it drifts with it.

The usual answer is a sync script. We wanted to give one instruction instead:

:::note[Prompt]
Reconcile the HOME site on the UniFi controller against NetBox. Add anything new, fix changed models or moved management IPs, and leave correct records alone.
:::

No pipeline. No field mapping spelled out. When the network changes next month, the same sentence runs again.

## What happens underneath

The agent talks to two backends through one gateway: it reads the live UniFi controller and reads and writes NetBox, and every call goes through the gateway's audited, policy-gated surface rather than a shell or a stored admin token. In Code Mode it is a short script the model writes itself, and its shape is a comparison, not a bulk import:

```js
const site = "23by5njv";                           // desc: "HOME"
const live = await toolmesh.unifi_list_devices({ site });

for (const ap of live) {
  const model = MODEL_MAP[ap.model] ?? ap.model;   // U7PG2 -> "UniFi AC Pro Gen2", etc.
  const [nb]  = await toolmesh.netbox_list_devices({ name: ap.name });

  if (!nb) {                                        // new device -> create
    await toolmesh.netbox_create_device({ name: ap.name, device_type: typeId(model), role: AP, site: LII });
    log("created", ap.name);
  } else if (mgmtIp(nb) !== ap.ip) {               // moved address -> update one field
    await toolmesh.netbox_update_device(nb.id, { primary_ip4: ipId(ap.ip) });
    log("updated mgmt IP", ap.name, mgmtIp(nb), "->", ap.ip);
  } else {
    log("ok", ap.name);                            // already matches -> write nothing
  }
}
```

Two things in there are worth pulling out, because they are where reconciliation is harder than import.

**The model codes are not product names.** To decide whether a UniFi device already matches its NetBox record, the comparison has to normalize what the controller reports. UniFi returns each device as an internal code: `U7PG2` is the UAP-AC-Pro (the `7` has nothing to do with Wi-Fi 7), `UAL6` is a U6 Lite, `U7NHD` a nanoHD, `U6EXT` a U6 Extender, `U7PRO` an actual U7 Pro, and `US8P60` the US-8-60W switch. That translation table is the difference between "this AP is already correct" and a duplicate device created next to the real one.

**"Leave correct records alone" is the load-bearing clause.** Every branch above is a get-or-create-or-update. A matching device produces a write of nothing. That is what makes the sentence safe to run on a schedule against a NetBox that already has data, instead of a one-time import you never dare repeat.

## What this run actually did

We ran it against the live controller. Here is the real result, not a constructed one:

```
reconcile HOME -> NetBox (site LII)
  AP-Kueche               U7NHD    192.168.100.43    ok
  AP-Praxis               U7NHD    192.168.100.46    ok
  AP-Labor                U7PG2    192.168.100.41    ok
  AP-Buero                U7NHD    192.168.100.44    ok
  AP-Garage               U7PG2    192.168.100.42    ok
  AP-U7-Pro Heizungsraum  U7PRO    192.168.100.154   ok
  AP-U6-Extender          U6EXT    192.168.100.199   ok
  AP-Mobil                UAL6     192.168.100.47    ok
  AP-Wohnzimmer           U7PG2    192.168.100.45    ok
  UbSwi-01                US8P60   192.168.100.183   ok
  -- 10 checked, 10 matched, 0 written.
```

Nine access points across five models and one PoE switch, every device already present, every management IP still matching, the IoT VLAN and the management prefix already in place. Net change to NetBox: zero.

That is the anticlimax that is actually the point. An agent that writes nothing when nothing has drifted is precisely the property that lets you point it at a CMDB you care about and run it again and again. You do not have to trust it not to clobber your data. You can watch it do nothing.

Drift is the case that earns its keep. If AP-Garage had come up on `.142` instead of `.42`, the run would have printed one line, `updated mgmt IP AP-Garage 192.168.100.42 -> 192.168.100.142`, written that single record, and left the other nine reading `ok`. The audit log shows exactly which field changed, on which device, run by which account, with no diff to read through.

## The definitions need the same scrutiny

Reconciling the devices keeps the inventory honest. The device-type definitions behind them do not get checked by a name-and-IP pass, and they drift in their own way. This run was a good moment to catch one: the U7 Pro in the boiler room was linked, correctly, to a device type whose slug matched, but that type was still labelled "AC Pro, 802.11ac". The device matched, so the reconciliation would not flag it; the label was simply wrong, since the model code and the unit's Wi-Fi 7 firmware say otherwise. We corrected it in the same session. A source of truth is only as good as the definitions it points at, and those deserve a deliberate look rather than an assumption.

## "I have done this with Nornir for years"

You have, and it works. The difference is that there is no script here to maintain. The mapping table lives in the model's context, the instruction is plain language, and "also reconcile the client list" or "skip the guest SSID" is a change to the sentence, not to a codebase and its tests. When Ubiquiti renames a model code in a controller update, you fix one line of a prompt.

## Try it against your own controller

The UniFi and NetBox connectors are open: [DADL](https://dadl.ai), one YAML file per API, behind an Apache-2.0 gateway. Clone it and point it at your own controller and your own NetBox. If you would rather have it run as a managed instance, talk to us at vertrieb@dunkel.cloud. Questions and war stories are welcome in [GitHub Discussions](https://github.com/DunkelCloud/ToolMesh/discussions).
