Intent Reconcile: Pushing Nautobot Changes to a Live Switch, With a Human in the Loop
TL;DR: The last two posts wired detection to NetClaw and then made it cheap to run. This one goes the other direction: instead of the network waking NetClaw up, I edit a device interface in Nautobot and let that change flow out to the switch. When I change an interface in Nautobot, a webhook tells NetClaw. NetClaw computes the exact config diff, posts a proposal to Discord, and waits. Nothing touches a device until I reply approve <id>. Only then does it run a baseline, apply the change, verify it, and roll back if verification fails. A separate daily job reports drift between what Nautobot says and what the switches are actually running, read-only. Scope today is deliberately narrow: interface changes on switches. Firewalls are never touched, and I’ll tell you why.
If you’ve followed the Convergence series, the shape of this is familiar by now. There’s a source of truth (Nautobot), an autonomous agent (NetClaw), and a webhook doing the last-mile wiring between two systems that don’t need to know much about each other. The detection posts had the network firing alerts into NetClaw. This is the mirror image. The intent lives in Nautobot, and a change there should eventually show up on the box. The interesting part isn’t the plumbing. It’s the gate I put in the middle of it.
Why a human gate at all
Everything I’ve automated so far has been read-heavy or low-stakes. Detection reads logs. Triage reads device state and posts a report. The worst a bad triage does is waste some tokens and send me a confusing Discord message.
This is different. This is the first automation in NetClaw that can write config to a live switch. A careless edit in Nautobot could otherwise reconfigure a production interface with nobody watching. Shut down the wrong port, flip a trunk to access, blow away a VLAN assignment, and the blast radius is real.
So the design brief here is the opposite of “let the agent run.” The webhook only ever proposes. Nothing reaches a device without an explicit approval typed into Discord, and every apply is reversible. The agent captures a baseline first and rolls back automatically if the change doesn’t verify. That’s the whole safety model in one sentence: propose, wait for a human, apply reversibly.
To be clear, the human gate isn’t a permanent design choice. It’s a POC-stage choice. This is the first write path in NetClaw, so I want a human in the loop while I learn where it goes wrong. As I trust the apply-and-verify cycle more, I expect to let it do more on its own. The gate is where I’m starting, not where I plan to stay.
And firewalls are excluded entirely. Not “handled carefully,” excluded. The lockout risk on a firewall isn’t worth any amount of convenience. If I fat-finger a switch port I lose an access port. If I fat-finger a firewall rule I lose the ability to reach the thing to fix it. The scope gate drops firewalls before the agent ever sees them.
The flow, top to bottom
Here’s the whole pipeline. Read it once and the rest of the post is just filling in each hop.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Nautobot (edit an interface)
│ Webhook, HMAC-SHA512 signed (X-Hook-Signature), fires for ALL interface changes
▼
Receiver /nautobot-webhook (192.168.3.252:8099)
│ 1. verify signature
│ 2. gate: model == interface AND device role == switch
│ 3. build a reconcile proposal prompt
│ POST /hooks/reconcile
▼
Gateway hook "reconcile" → wakes the agent (fresh session)
▼
intent-reconcile skill, Workflow A (propose):
read intent (nautobot-sot) + live state (pyATS) → diff → render config →
dry-run → store state/pending-changes/<id>.json → post proposal to Discord
… waits …
You reply "approve CHG-1234" in Discord
▼
intent-reconcile skill, Workflow B (apply):
look up the change → pyats-config-mgmt: baseline → apply → verify → rollback
→ post the result to Discord
The webhook fires for every interface change in Nautobot, and that’s fine. I didn’t try to make Nautobot smart about what to send. The receiver and the skill are the gate. The receiver drops anything that isn’t an interface change on a switch, and the skill re-checks scope again before it reads or writes anything. Two independent checks, because the cost of a mistake here is high enough that one felt thin.
If you’ve read the last two posts this receiver will look familiar. It’s the same FastAPI app at 192.168.3.252:8099 that already takes Alertmanager webhooks. I just added a second endpoint to it. Same front door, new door within it.
Why there’s a pending-change store
This tripped me up conceptually before it tripped me up in code, so it’s worth slowing down on.
The proposal and the approval happen in two different sessions. When the webhook fires, NetClaw wakes up in a fresh session, computes the diff, and posts the proposal. Then it goes back to sleep. Your approve CHG-1234 reply arrives later as a brand-new inbound Discord message, which wakes NetClaw in a different fresh session that has no memory of the proposal it made earlier.
The agent that proposes and the agent that applies are the same skill but not the same conversation. So the proposal has to be written down somewhere both sessions can reach it. That’s what state/pending-changes/<id>.json is: the device, the rendered config, the diff, the intent, and the captured baseline, all persisted to disk. The propose step writes it. The approve step reads it back and applies it. That file is the bridge between the two sessions.
This is the same lesson the whole NetClaw design keeps teaching. The agent is stateless between wakeups by design (that’s what keeps it cheap, see the last post). Anything that has to survive from one wakeup to the next has to live outside the session.
Security
The write path is the dangerous part, so the guardrails all cluster here.
HMAC-SHA512 signatures. Nautobot signs the raw request body with a shared secret and sends the result in an X-Hook-Signature header. The receiver recomputes the signature with a timing-safe compare and rejects anything unsigned or mismatched. If no secret is configured at all, it rejects everything. Fail closed, not open. An empty secret is treated as “not configured,” which matters in a way I’ll get to in the setup section, because it bit me.
Scope gating in two places. The receiver gates on the model and the device role. The skill re-checks switch-plus-interface before any read or write. Belt and suspenders, on purpose.
No blind apply. Nothing is applied without a matching, current approve <id>. Deletes and trunk or mode changes get flagged as higher risk right in the proposal text, so the approval isn’t a rubber stamp.
Reversible by construction. Every apply captures a baseline first and auto-rolls-back on failed verification. Every step is recorded in GAIT, NetClaw’s session audit trail, which is the thread the firewall post said it would eventually pick up.
The daily drift report
There’s a second, quieter half to this. The webhook path is reactive: you change Nautobot, the change flows out. But intent and reality drift for reasons that never touch Nautobot. Someone consoles into a switch at 2am, an old change never got pushed, a port gets patched by hand. Nautobot says one thing, the switch runs another, and nothing fired a webhook.
So there’s a daily job, daily-switch-reconcile, an OpenClaw cron that runs Workflow C. It enumerates every switch-role device, compares intended interface state against live interface state, and posts a concise drift report to Discord. It is strictly read-only. It never auto-applies anything. For each drift item it finds, it can open a proposal, so the fix still flows through the same approve <id> gate as everything else. Detection and correction stay separated, same as the whole series.
It ships disabled. You turn it on when you’re ready:
1
openclaw cron enable daily-switch-reconcile
Setting it up
The design rationale above is the “why.” Here’s the concrete “how,” in the order you’d actually do it. There’s a longer runbook in the repo (docs/runbooks/intent-reconcile-webhook-setup.md); this is the operator’s-eye version.
The receiver config
scripts/alert-receiver/.env, everything off by default:
RECONCILE_ENABLED=false
NAUTOBOT_WEBHOOK_SECRET= # must match the Nautobot webhook "Secret"
RECONCILE_ALLOWED_MODELS=interface
RECONCILE_ALLOWED_ROLES=switch
RECONCILE_CHANNEL_ID=1426234100339048701
OPENCLAW_GATEWAY_URL=http://127.0.0.1:18789
OPENCLAW_HOOK_TOKEN=<hooks.token from openclaw.json>
NAUTOBOT_TOKEN=<token>
Generate a real secret and use the same value on both sides:
1
openssl rand -hex 32
Here’s the gotcha I actually hit. I left NAUTOBOT_WEBHOOK_SECRET empty on the first run, and every single delivery came back rejected:
1
{"status":"rejected","reason":"signature"}
That’s the fail-closed behavior working exactly as designed. An empty secret means “not configured,” and an unconfigured receiver rejects everything rather than accepting unsigned webhooks. The fix is obvious once you see the reason string: set a non-empty secret, put the identical value in the Nautobot webhook, restart.
The gateway hook rule
The receiver POSTs to the OpenClaw gateway, which is what actually wakes the agent. Under hooks.rules in ~/.openclaw/openclaw.json, a rule maps the reconcile path to an agent session:
1
2
3
4
5
6
7
8
{
"match": { "path": "reconcile" },
"action": "agent",
"wakeMode": "now",
"name": "NetClaw Intent Reconcile",
"sessionKey": "hook:reconcile::",
"messageTemplate": ""
}
hooks.enabled has to be true and hooks.token has to be set, because the receiver sends that token as a Bearer header. The messageTemplate pulls annotations.reconcile_prompt out of the receiver’s POST body, and that prompt is the instruction the agent runs.
The Nautobot webhook
There’s an idempotent helper that reads the secret straight from the receiver’s .env so nothing is hard-coded:
1
.venv/bin/python scripts/alert-receiver/create_webhook.py
Or create it by hand under Extensibility → Webhooks:
| Field | Value |
|---|---|
| Name | netclaw-intent-reconcile |
| Content type | dcim \| interface |
| Events | Created, Updated, Deleted |
| URL | http://192.168.3.252:8099/nautobot-webhook |
| HTTP method | POST |
| HTTP content type | application/json |
| Secret | same value as NAUTOBOT_WEBHOOK_SECRET |
| SSL verification | off (the receiver is plain HTTP) |
Device access for pyATS
To build a real proposal, the agent has to read the live switch and diff intent against actual. That means SSH has to work. My home Catalyst 3850s run old IOS-XE and reject modern SSH twice over, first on key exchange, then on the host key algorithm. The fix lives in ~/.ssh/config:
1
2
3
4
Host 192.168.3.2 192.168.3.3 HomeSwitch01 HomeSwitch02
KexAlgorithms +diffie-hellman-group-exchange-sha1,diffie-hellman-group14-sha1
HostKeyAlgorithms +ssh-rsa
PubkeyAcceptedAlgorithms +ssh-rsa
The testbed generator (scripts/generate-testbed-from-nautobot.py) emits the matching ssh_options for IOS and IOS-XE devices, so testbed/testbed.yaml carries the same settings. Confirm it negotiates before you rely on it. ssh cisco@192.168.3.2 should reach a password prompt, not a “no matching key exchange method” error.
Turn it on
1
2
3
# flip RECONCILE_ENABLED=true in .env, then:
sudo systemctl restart netclaw-alert-receiver
systemctl is-active netclaw-alert-receiver
Testing it end to end
Make a change on a safe, unused switch port in Nautobot (HomeSwitch01 Gi1/0/4 is my throwaway). Any create, update, or delete fires the webhook. Then watch the receiver:
1
sudo journalctl -u netclaw-alert-receiver -f
A healthy run reads like this:
1
2
3
4
POST /nautobot-webhook 200 ← delivered
GET .../devices/?name=HomeSwitch01&depth=1 200 ← role resolves
POST http://127.0.0.1:18789/hooks/reconcile 200 ← handed to NetClaw
Reconcile proposal triggered: interface updated on HomeSwitch01
Within a minute or two a proposal (or a no-op notice, if there’s nothing to change) shows up in the Discord alerts channel. Reply approve <id> or deny <id>.
If you just want to check the endpoint is alive without touching Nautobot:
1
2
3
curl -s -X POST http://192.168.3.252:8099/nautobot-webhook -d '{}'
# reconcile off → {"status":"disabled"}
# reconcile on, unsigned → {"status":"rejected","reason":"signature"}
The bugs worth knowing about
Real work is never clean. A few things broke, and each one taught me something about the pipeline.
The device role kept coming back empty, and switches were getting skipped with a log line like Nautobot change on X (role=) not in allowed roles, skipping. The device query was missing depth=1, so Nautobot returned the role as a bare reference with no name attached, and an empty string never matches “switch.” Adding depth=1 (plus a name/display fallback) in server.py fixed it.
Early proposals came back as no-ops even when there was obvious drift. The original skill diffed the webhook’s own prechange and postchange payloads against each other, which just tells you what changed in Nautobot, not whether the device matches. The fix was to diff intent against the live device instead. Now the skill and the prompt both read the actual switch with pyATS and compare that to Nautobot’s intent, which is the whole point.
Admin-state drift slipped through at first. A port could be physically up while Nautobot said enabled: false, and nothing flagged it, because admin state wasn’t in the diff. Now enabled maps to shutdown / no shutdown and it’s a first-class field in the comparison.
And the one that’s easy to miss: the receiver logs a clean 200 to /hooks/reconcile but no Discord post ever appears. That’s the gateway hook or the agent failing after a successful hand-off, not the receiver. Check journalctl --user -u openclaw-gateway, and confirm the reconcile hook rule and hooks.token are actually set. A 200 from the receiver only means “handed off successfully,” nothing about what happened on the other side. Same honesty problem as the firewall post: delivery confirmed, outcome is a separate claim.
If a reconcile session logs failed to start server "pyats-mcp"/"nautobot-mcp" … Connection closed, the agent can’t read the device or the intent, and you’ll get a weak proposal or none at all. Chase the MCP startup before you trust a real reconcile.
Verified vs not
I try to be precise about this in every post, because “it’s wired up” and “it works against a real device” are different claims.
Verified: HMAC accept and reject, empty-signature reject, model gating, device-role gating (switch allowed, firewall blocked), the proposal-prompt scope guard, the endpoint returning disabled until it’s enabled, and the hook mapping plus skill both present.
Not yet exercised against a live switch: the full approve → apply → verify cycle writing to a real device, and the Discord-reply-to-agent matching across sessions. The plumbing is proven. The device write path only runs when I enable it and push a real change through. When I do, it’ll be on one non-production interface first, and that’ll be its own post.
Where this sits in the series
Two posts ago the network woke NetClaw up to investigate. Last post I made that investigation cheap enough to leave running. This post reverses the arrow: intent in Nautobot flows out to the device, and the dangerous direction (writing config) is exactly where I slowed everything down and put a human in the path.
That’s the pattern I keep landing on. Reads and investigations can be autonomous. Writes to live infrastructure get a gate, a baseline, and a rollback. The agent proposes; I decide; the apply is reversible. Firewalls stay off the table until I trust the switch path completely. VLAN create, update, and delete is the obvious next scope to add, and it flows through the same approve <id> gate when it lands.
Files involved
| File | Role |
|---|---|
scripts/alert-receiver/server.py |
/nautobot-webhook endpoint, HMAC verify, role gate, prompt, POST to gateway |
scripts/alert-receiver/.env |
reconcile flags and shared secret (gitignored) |
scripts/alert-receiver/create_webhook.py |
idempotent Nautobot webhook creator |
~/.openclaw/openclaw.json |
hooks.rules → reconcile agent mapping |
workspace/skills/intent-reconcile/SKILL.md |
intent-vs-actual diff, propose / approve / apply / daily drift |
OpenClaw cron daily-switch-reconcile |
daily drift report, read-only, disabled by default |
state/pending-changes/<id>.json |
pending-change records that bridge the two sessions |
~/.ssh/config + testbed/testbed.yaml |
legacy SSH so pyATS can read the old Catalysts |
Stay curious, and never write to a live switch without a human and a rollback in the loop.
