Devices pull intent: why edge nodes should poll instead of taking commands
Every “send a command to the device” API is the same thing seen from the other side: an inbound path that can execute code on hardware sitting in somebody else’s building. The firewall rule that allows it, the credential on the server that can use it, and the queue that retries it are all attack surface, and all of it lives on the least defensible network you own: the field.
Field networks are hostile in the ordinary sense too. Nodes sit behind NAT, on LTE, behind captive portals; they go dark for hours and come back with the wrong clock. A push model has to model all of that as failure. A pull model gets it for free, because the only thing a device ever does is ask.
The shape
The device owns a small loop: authenticate, fetch the desired state, reconcile, report. The server stores intent. It never holds a session to the device, and there is no endpoint on the device at all.
The intent document
Intent is a small, versioned description of what the device should be running. Not a list of steps, a target. The agent’s job is to make reality match it and say how far it got.
{
"device_id": "site-042-node-3",
"revision": 187,
"updated_at": "2026-09-20T14:02:11Z",
"image": "registry.example/edge-vision:1.14.2",
"config_sha256": "9c1e…b7a0",
"config_url": "https://cp.example/v1/devices/site-042-node-3/config/9c1e…b7a0",
"services": { "vision": "enabled", "uploader": "enabled", "debug-shell": "disabled" },
"maintenance_window": "02:00-04:00"
}
The revision is monotonic per device. The config is addressed by its hash, so the agent fetches it once and never wonders whether it changed.
The API contract
Two endpoints do most of the work. Both are cheap, because most polls return nothing new:
GET /v1/devices/{id}/intent
If-None-Match: "187"
→ 304 Not Modified (nothing to do; most of the time)
→ 200 OK, ETag: "188", body: intent (something changed)
POST /v1/devices/{id}/status
{ "revision_applied": 188, "state": "converged", "image_running": "…:1.14.2",
"uptime_s": 431200, "disk_free_pct": 61, "errors": [] }
→ 202 Accepted
If-None-Match is the whole efficiency story. Ten thousand devices polling every minute is ten thousand tiny requests a minute that mostly end at the cache. Nothing on the server tracks connections, sessions or retries.
The reconcile loop
The agent is deliberately boring. A timer, a small script, no daemon that could hold state wrong for a week:
# /etc/systemd/system/agent.timer
[Timer]
OnBootSec=30
OnUnitActiveSec=60
RandomizedDelaySec=15 # ten thousand devices must not poll in the same second
[Install]
WantedBy=timers.target
#!/usr/bin/env bash
# /usr/local/bin/agent-reconcile — runs from agent.service, one shot
set -euo pipefail
CP=https://cp.example/v1/devices/$(cat /etc/device-id)
ETAG_FILE=/var/lib/agent/etag
code=$(curl -sS --cert /etc/agent/device.crt --key /etc/agent/device.key \
-H "If-None-Match: $(cat $ETAG_FILE 2>/dev/null || echo '"0"')" \
-o /run/agent/intent.json -w '%{http_code}' -D /run/agent/headers "$CP/intent")
[ "$code" = "304" ] && exit 0
[ "$code" = "200" ] || { logger -t agent "intent fetch failed: $code"; exit 1; }
want_image=$(jq -r .image /run/agent/intent.json)
want_cfg=$(jq -r .config_sha256 /run/agent/intent.json)
# converge: pull, verify, restart only if something differs
[ "$(docker inspect -f '{{.Config.Image}}' vision 2>/dev/null)" = "$want_image" ] || docker pull "$want_image"
[ "$(sha256sum /etc/vision/config.yaml | cut -d' ' -f1)" = "$want_cfg" ] \
|| curl -sS --cert /etc/agent/device.crt --key /etc/agent/device.key "$(jq -r .config_url /run/agent/intent.json)" -o /etc/vision/config.yaml
docker compose -f /etc/vision/compose.yaml up -d --quiet-pull # idempotent
grep -i '^etag:' /run/agent/headers | cut -d' ' -f2 | tr -d '\r' > $ETAG_FILE
curl -sS --cert /etc/agent/device.crt --key /etc/agent/device.key -X POST "$CP/status" \
-H 'content-type: application/json' \
-d "{\"revision_applied\": $(jq .revision /run/agent/intent.json), \"state\": \"converged\"}"
Idempotence is the property that makes this safe to run every minute forever. If the network dies halfway, the next tick finishes the job. If the device reboots, the timer starts the loop again. Nobody has to answer the question “did the command arrive?”.
Enrolment without a shared secret
The one moment a device has no identity is the first boot. Handle it with a single-use token and a private certificate authority, so the device ends up with a key it generated itself and a certificate the control plane trusts:
# provisioning: a one-time token, valid 10 minutes, bound to this device id
TOKEN=$(step ca token site-042-node-3 --ca-url https://ca.example --root /etc/agent/root.crt --not-after 10m)
# first boot on the device: exchange the token for a certificate, key never leaves the box
step ca certificate site-042-node-3 /etc/agent/device.crt /etc/agent/device.key \
--token "$TOKEN" --ca-url https://ca.example --root /etc/agent/root.crt
# renewal is automatic and also outbound
step ca renew --daemon /etc/agent/device.crt /etc/agent/device.key
The API accepts only client certificates from that CA, and the certificate’s common name is the device id. Nothing is copied into a fleet image, nothing is shared across devices, and revoking one device is one certificate.
When a human needs a shell
Pull does not mean unreachable. It means unreachable by default. For the day a technician or an engineer needs to look at a box, put the device on a private overlay network and let the overlay’s access rules decide who may open a shell:
# on the device, once: join the tailnet with a tag, no user identity
tailscale up --auth-key "$TS_AUTHKEY" --advertise-tags=tag:edge --ssh
// tailnet policy: only the ops group may SSH into edge nodes, as a non-root user, and it is logged
{
"tagOwners": { "tag:edge": ["group:ops"] },
"acls": [{ "action": "accept", "src": ["group:ops"], "dst": ["tag:edge:22"] }],
"ssh": [{ "action": "check", "src": ["group:ops"], "dst": ["tag:edge"], "users": ["ops"] }]
}
Still no inbound port on the public interface. The overlay is outbound from the device too.
- No inbound surface on the device: nothing to scan, nothing to exploit remotely
- Offline-tolerant by construction; a node that was dark converges when it returns
- Every change is a revision in a store, so the audit trail is the data model
- Works behind NAT, LTE and captive portals without any network engineering
- Latency is bounded by the poll interval; "apply now" is really "within a minute"
- "Why has it not applied yet?" needs good status reporting or it becomes guesswork
- The intent document is a schema you now own, version and migrate
- Bulk actions are a loop over devices on the server, not a broadcast
What it costs
The interval is a real constraint. A minute is fine for configuration and rollouts; it is not fine for “stop the motor”. If a device has a genuine real-time need, that path is a separate, narrowly scoped channel with its own threat model, not a reason to open the general one. The other cost is discipline: the moment someone adds a “run this script once” field to the intent document, it stops being intent and starts being a command queue with extra steps.
Where this applies
GPU boxes running computer vision in stores, industrial gateways, kiosks, managed tablets behind a vendor’s MDM, even VMs in a partner’s network you do not control. Anywhere the device is on a network you do not trust, which, from the platform’s point of view, is every network that is not yours.