Edge Connector

Fleet API Reference

REST API reference for DefenseClaw IoT fleet device registration, policy and emergency distribution, threat intelligence, and observability.

The Fleet API is mounted at /api/v1/fleet/ on the DefenseClaw gateway HTTP server (gateway.api_port, 18970 by default). Set fleet.enabled: true in the gateway's config.yaml to register these routes. It provides device management, policy distribution, threat intelligence, and observability endpoints for IoT fleets running the Edge Connector.

Authentication

All endpoints require a Bearer token passed in the Authorization header. The admin token is read from DCLAW_FLEET_API_TOKEN on the server. Optional tenant-scoped tokens configured through fleet.tenant_tokens or DCLAW_FLEET_TENANT_TOKENS can access only their tenant's permitted data. The aggregate /metrics endpoint requires the admin token.

# GET request (read-only)
curl -H "Authorization: Bearer $DCLAW_FLEET_API_TOKEN" \
  https://gateway.example.com/api/v1/fleet/devices

# POST request (state-changing — requires CSRF header)
curl -X POST \
  -H "Authorization: Bearer $DCLAW_FLEET_API_TOKEN" \
  -H "X-DefenseClaw-Client: true" \
  -H "Content-Type: application/json" \
  -d '{"tenant_id":1,"fleet_id":2,"device_id":1001}' \
  https://gateway.example.com/api/v1/fleet/devices

If DCLAW_FLEET_API_TOKEN is not set, all requests are rejected with 401 Unauthorized. Keep the gateway API on loopback or behind a trusted TLS and authentication boundary; the token is a bearer credential.

JSON API request and response bodies use Content-Type: application/json; /metrics returns Prometheus text. Request bodies are limited to 1 MB.

CSRF protection

State-changing requests (POST, PUT, DELETE) through the gateway require the X-DefenseClaw-Client header. This is enforced by the gateway's CSRF middleware, not the fleet API itself. Direct fleet API calls (e.g., from the CLI) include this header automatically.


Devices

List devices

GET /api/v1/fleet/devices

Returns all registered devices with an aggregate fleet summary.

Response 200 OK

{
  "devices": [
    {
      "device_id": 281474976841729,
      "tenant_id": 1,
      "fleet_id": 2,
      "hw_profile": "rpi4",
      "fw_version": "1.2.0",
      "policy_version": 3,
      "capabilities": 127,
      "status": "online",
      "last_heartbeat": "2026-10-05T14:32:00Z",
      "last_audit_hmac": "base64...",
      "site_id": "factory-east",
      "registered_at": "2026-09-01T10:00:00Z",
      "flags": 0,
      "denied_total": 42,
      "allowed_total": 18305,
      "flash_writes": 1200
    }
  ],
  "summary": {
    "total": 150,
    "online": 142,
    "offline": 5,
    "degraded": 2,
    "lockdown": 1
  }
}

Register a device

POST /api/v1/fleet/devices

Registers a new device with the fleet manager. Pre-register each production device with the authenticated API and provision its issued per-device key; MQTT auto-registration is limited to unmanaged local development. If a device with the same composite ID already exists, API re-registration returns 200 OK instead of 201 Created and replaces its device key. Provision the returned key on the device immediately. A decommissioned device additionally requires force_reregister: true. Registration fails closed if the credential store or mandatory pre-commit credential audit is unavailable. Moving a C device to a different tenant/fleet requires trusted flash reset and reprovisioning of its durable emergency state.

Request body

FieldTypeRequiredDescription
tenant_iduint16yesTenant identifier
fleet_iduint16yesFleet identifier
device_iduint32yesDevice identifier (must be non-zero)
hw_profilestringnoHardware profile name (e.g., rpi4, jetson-nano)
fw_versionstringnoFirmware version string
policy_versionuint16noHighest accepted policy version (anti-rollback watermark); after a canary rollback this can exceed the active partition's version
capabilitiesuint8noCapability bitmask the device supports
force_reregisterboolnoRequired to re-enable a decommissioned device
{
  "tenant_id": 1,
  "fleet_id": 2,
  "device_id": 1001,
  "hw_profile": "rpi4",
  "fw_version": "1.2.0",
  "policy_version": 3,
  "capabilities": 127
}

Response 201 Created (new device)

{
  "device": {
    "device_id": 281474976841729,
    "tenant_id": 1,
    "fleet_id": 2,
    "hw_profile": "rpi4",
    "fw_version": "1.2.0",
    "policy_version": 3,
    "capabilities": 127,
    "status": "online",
    "last_heartbeat": "0001-01-01T00:00:00Z",
    "last_audit_hmac": null,
    "site_id": "",
    "registered_at": "2026-10-05T14:00:00Z",
    "flags": 0,
    "denied_total": 0,
    "allowed_total": 0,
    "flash_writes": 0
  },
  "device_key": "a1b2c3d4...hex-encoded-32-byte-key..."
}

Response 200 OK (device already exists)

{
  "device": {
    "device_id": 281474976841729,
    "tenant_id": 1,
    "fleet_id": 2,
    "status": "online"
  },
  "device_key": "new-hex-encoded-32-byte-key"
}

The device_key is returned on initial registration, re-registration, and explicit key rotation. Each response containing a new key invalidates the previous key; the key cannot be retrieved later. Provision it via DCLAW_DEVICE_KEY or /etc/defenseclaw/device.key. Registration returns 503 if the credential store is unavailable. Production key delivery requires HTTPS or a configured trusted TLS proxy.

Status codes

StatusCondition
200Device already exists (idempotent)
201Device created
400Missing device_id or malformed JSON
403Token is not authorized for the tenant, or credential delivery channel is not protected
409Device was decommissioned; force_reregister is required
413Request body exceeds 1 MB
503Atomic credential/tombstone store or mandatory audit unavailable; existing registration and key are preserved so the request can be retried

Rotate a device key

POST /api/v1/fleet/devices/{id}/rotate-key

Generates and persists a replacement 32-byte device HMAC key. The response returns device_id, device_key, and status: "rotated"; store the key securely and provision it on the device before sending more signed messages. This endpoint is rate limited to one rotation per device per minute and requires a configured key store. A tenant-scoped token can rotate only a device in that tenant. Production key delivery requires HTTPS or a configured trusted TLS proxy. Missing devices return 404; an unavailable key store returns 501.

Rotate the Fleet API token

POST /api/v1/fleet/token/rotate

Requires the admin token and returns a new 32-byte token once. Rotation invalidates the old admin token in the running gateway and is rate limited to once per minute. Save the returned token in the gateway's secret source: rotation is currently in memory only and does not update DCLAW_FLEET_API_TOKEN, so a restart without updating the secret restores the old token. Production token delivery requires HTTPS or a configured trusted TLS proxy.

Get device details

GET /api/v1/fleet/devices/{id}

Returns the full device record for a specific device. The {id} is the 64-bit composite device ID (not the raw device_id).

Response 200 OK

{
  "device_id": 281474976841729,
  "tenant_id": 1,
  "fleet_id": 2,
  "hw_profile": "rpi4",
  "fw_version": "1.2.0",
  "policy_version": 3,
  "capabilities": 127,
  "status": "online",
  "last_heartbeat": "2026-10-05T14:32:00Z",
  "last_audit_hmac": "base64...",
  "site_id": "factory-east",
  "registered_at": "2026-09-01T10:00:00Z",
  "flags": 0,
  "denied_total": 42,
  "allowed_total": 18305,
  "flash_writes": 1200
}

Status codes

StatusCondition
200Device found
400Invalid id (not a valid uint64)
404Device not found

Decommission devices (batch)

POST /api/v1/fleet/devices/decommission-batch

Removes multiple devices from the fleet in a single request. Returns which devices were decommissioned and which were not found. Each existing device needs a successful pre-commit credential audit before its key and tombstone can change.

Request body

FieldTypeRequiredDescription
devicesarrayyesList of device identifiers (must not be empty)
devices[].tenant_iduint16yesTenant identifier
devices[].fleet_iduint16yesFleet identifier
devices[].device_iduint32yesDevice identifier
{
  "devices": [
    { "tenant_id": 1, "fleet_id": 2, "device_id": 1001 },
    { "tenant_id": 1, "fleet_id": 2, "device_id": 1002 },
    { "tenant_id": 1, "fleet_id": 2, "device_id": 9999 }
  ]
}

Response 200 OK

{
  "batch_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "requested": 3,
  "decommissioned": 2,
  "status": "completed",
  "not_found": [281474976850023]
}

The not_found field is only present when one or more devices could not be found. Values are the 64-bit composite device IDs. A partial response may also include audit_failed or persistence_failed arrays of composite device IDs. Those devices were not durably decommissioned and need operator review/retry; successful siblings remain decommissioned. Tenant-scoped requests may include tenant_denied_count for skipped foreign-tenant entries.

Status codes

StatusCondition
200Batch completed (even if some devices were not found)
400Empty devices list or malformed JSON
207Partial failure (including a foreign-tenant entry) or 30-second batch deadline; inspect failure fields and status

Policy

Push policy

POST /api/v1/fleet/policy/push

Compiles a policy YAML, signs it with the explicit DCLAW_OTA_HMAC_KEY secret, stores the version, and publishes the signed binary to the target fleet's MQTT topic. The gateway assigns the wire version from stored fleet history, starting at 1 and increasing to 65534; a YAML version field does not select it. At 65534, a further push returns 409 and requires a new device policy lineage. A 200 response confirms broker publication, not device receipt or application. After an authenticated signed registration or heartbeat, the gateway can republish the latest stored policy to a device reporting an older version. Confirm application from a later signed heartbeat; MQTT publication alone is not an acknowledgment. Devices need the same secret and a production build with -DDCLAW_ALLOW_HMAC_OTA=ON. DCLAW_OTA_KEY is a public Ed25519 key name; the current C build has no Ed25519 verifier and does not use it as an HMAC secret. Emergency commands use the separate DCLAW_EMERGENCY_KEY.

Policy compilation needs Python 3 and PyYAML on the gateway. Raw release tar installs must provision them; Linux enterprise RPM/DEB installs require an operator-provisioned, trusted /usr/bin/python3 and PyYAML only when Fleet is enabled. Fleet-enabled managed install/ensure checks this prerequisite before changing the service. Per-user installers use bundled Python dependencies. A managed macOS .pkg with Fleet enabled requires PyYAML 6.0.3 in a trusted Python.org framework interpreter (3.10–3.14). For example, check /Library/Frameworks/Python.framework/Versions/3.13/bin/python3.13 -I -c 'import yaml; print(yaml.__version__)'. The interpreter, framework parents, and PyYAML package tree must be root-owned and not group/world-writable; managed install/ensure refuses Fleet-enabled config if no candidate passes. Apple's /usr/bin/python3 and Homebrew PATH are not selected. Managed Windows standalone Setup carries the compiler in its signed Setup payload; only authenticated Fleet-enabled config installs its active copy under %ProgramFiles%\Cisco\DefenseClaw\lib\defenseclaw\ and uses the admitted scanner runtime Python/PyYAML under %ProgramData%\Cisco\DefenseClaw-ScannerRuntime\python\. Fleet-off Setup/upgrade leaves no active compiler; disabling Fleet removes it. Secure Client managed profiles on Windows and macOS currently reject Fleet-enabled configuration because they do not carry the compiler/runtime.

Managed Linux/macOS packages carry the compiler as inert source in <InstallRoot>/share/fleet/. Only validated Fleet-enabled install/ensure activates a protected copy in <InstallRoot>/lib/defenseclaw/; Fleet-off install/upgrade needs no Python/PyYAML and activates no compiler. A Fleet-off raw payload install discards its optional compiler source, so later Fleet activation requires the staged release --payload again. Fleet API routes and their metrics exist only when fleet.enabled: true; the bundled local Prometheus stack has no Fleet scrape job until an operator configures one.

Standard per-user install.sh/install.ps1 copy the matching compiler only when Fleet is already enabled in the existing config. The interactive defenseclaw setup edge-connector flow installs the optional compiler and enables Fleet after credential setup succeeds. A core gateway install alone does not start a C Edge Connector device service.

Request body

FieldTypeRequiredDescription
tenant_iduint16yesTenant identifier
fleet_iduint16yesFleet identifier
policy_yamlstringyesRaw YAML policy content
profilestringnoBuild profile (defaults to standard)
{
  "tenant_id": 1,
  "fleet_id": 2,
  "policy_yaml": "name: factory-east\nskill_actions:\n  critical:\n    runtime: disable\n  high:\n    runtime: disable\n",
  "profile": "standard"
}

Response 200 OK

{
  "version": 1,
  "blob_size": 2048,
  "tenant_id": 1,
  "fleet_id": 2,
  "profile": "standard",
  "status": "distributed"
}

Status codes

StatusCondition
200Policy compiled, signed, stored, and published to the broker; device application is unconfirmed
400Missing required fields, invalid policy, or unsupported profile
409The fleet policy version reached 65534; provision a new device policy lineage
413Request body or compiled policy exceeds its size limit
422Policy YAML cannot be compiled
500Signing or distribution failed
501Policy service not configured

List policy versions

GET /api/v1/fleet/policy/versions?tenant_id={tenant_id}&fleet_id={fleet_id}

Returns all stored policy versions for a tenant/fleet pair, ordered newest first.

Query parameters

ParameterTypeRequiredDescription
tenant_iduint16yesTenant identifier
fleet_iduint16yesFleet identifier

Response 200 OK

{
  "tenant_id": 1,
  "fleet_id": 2,
  "versions": [
    {
      "tenant_id": 1,
      "fleet_id": 2,
      "version": 4,
      "profile": "standard",
      "policy_bin": null,
      "signature": null,
      "created_at": "2026-10-05T14:00:00Z",
      "distributed": true
    },
    {
      "tenant_id": 1,
      "fleet_id": 2,
      "version": 3,
      "profile": "standard",
      "policy_bin": null,
      "signature": null,
      "created_at": "2026-09-20T10:30:00Z",
      "distributed": true
    }
  ]
}

Status codes

StatusCondition
200Versions returned (may be empty list)
400Invalid or missing tenant_id / fleet_id
501Policy service not configured

Simulate policy (dry run)

POST /api/v1/fleet/policy/simulate

Compiles a policy YAML without signing or distributing. Use this to validate policy changes before pushing them.

Request body

FieldTypeRequiredDescription
policy_yamlstringyesRaw YAML policy content
profilestringnoBuild profile (defaults to standard)
{
  "policy_yaml": "name: test-policy\nversion: 1\nskill_actions:\n  critical:\n    runtime: disable\n",
  "profile": "edge"
}

Response 200 OK

{
  "blob_size": 1536,
  "payload_len": 1024,
  "canary_baseline": 0,
  "fits_target_profile": true,
  "status": "dry-run compilation succeeded"
}

Status codes

StatusCondition
200Compilation succeeded (or policy service not configured, returns stub result)
400Malformed JSON
422Compilation failed (invalid policy YAML)

Send emergency command

POST /api/v1/fleet/policy/emergency

Publishes a signed emergency command to the fleet MQTT topic. A 200 response confirms broker publication, not device receipt or execution. With clean MQTT sessions, devices offline at publication time miss the initial command. The gateway stores signed emergency history for the fleet and replays missed commands in predecessor order after an authenticated signed registration or heartbeat, up to 32 per pass; later heartbeats request subsequent pages. The 112-byte wire frame signs a predecessor sequence so a later command cannot silently bypass a missed lockdown. A history gap or unavailable durable store fails closed. Confirm the device's last-applied emergency sequence in a later signed heartbeat and its state during incident response.

Request body

FieldTypeRequiredDescription
tenant_iduint16yesTenant identifier
fleet_iduint16yesFleet identifier
commandstringyesOne of: BLOCK_ALL, ENTER_LOCKDOWN, RELEASE_LOCKDOWN, REVOKE_SESSIONS, FORCE_SYNC
{
  "tenant_id": 1,
  "fleet_id": 2,
  "command": "ENTER_LOCKDOWN"
}

Response 200 OK

{
  "tenant_id": 1,
  "fleet_id": 2,
  "command": "ENTER_LOCKDOWN",
  "status": "distributed"
}
CommandEffect
BLOCK_ALLA receiving device blocks every tool call
ENTER_LOCKDOWNA receiving device enters lockdown mode and blocks tool calls
RELEASE_LOCKDOWNA receiving device clears emergency lockdown and resumes normal policy evaluation
REVOKE_SESSIONSA receiving device terminates active agent sessions and clears its verdict cache
FORCE_SYNCA receiving device flushes its in-RAM audit buffer to durable storage

Status codes

StatusCondition
200Emergency command published to the broker; device execution is unconfirmed
400Missing required fields or unknown command
500Distribution failed
501Policy service not configured

Threat Intelligence

Push threat intelligence

POST /api/v1/fleet/threat-intel/push

Revokes previously allowed file hashes from the gateway verdict cache. Optionally flushes the entire gateway cache for emergency response. For each registered tenant/fleet, the gateway also publishes a signed REVOKE_SESSIONS message to request device cache invalidation. Publication does not confirm delivery or execution. An offline device misses the initial message with clean MQTT sessions; the C connector flushes its local verdict cache on successful broker reconnect before resuming online tool decisions. The gateway can replay missed emergency commands in signed predecessor order from durable history after an authenticated signed registration or heartbeat. Until reconnect, a cached ALLOW may remain. Treat device_cache_flushed: false as a delivery limit, not a device acknowledgment.

Request body

FieldTypeRequiredDescription
revoke_allow_hashesstring[]noList of SHA-256 hashes (64 hex chars each) to remove from the allow cache
emergencyboolnoIf true, flush the entire verdict cache before processing individual revocations
{
  "revoke_allow_hashes": [
    "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
  ],
  "emergency": false
}

Response 202 Accepted when all requested fleet publications succeed

{
  "revoked": 1,
  "device_cache_flush_published": 2,
  "device_cache_flush_target_fleets": 2,
  "device_cache_flushed": false,
  "warning": "MQTT publication does not confirm device receipt; offline devices may keep cached verdicts until reconnect or TTL expiry"
}

If any fleet publication fails, the gateway returns 207 Multi-Status with the successful publication count and a device_cache_flush_failed list of "tenant_id/fleet_id" strings. The gateway cache revocation still occurs. An empty or no-op request returns 202 with only revoked.

Status codes

StatusCondition
202Gateway cache revocations processed; requested device-cache flush messages published, if any. Device receipt is not confirmed
207Gateway cache revocations processed, but one or more fleet publications failed; inspect device_cache_flush_failed
400Invalid hex string or hash is not exactly 32 bytes (64 hex chars)

Observability

Fleet health

GET /api/v1/fleet/health

With the admin token, returns aggregate fleet status and verdict cache statistics. With a tenant-scoped token, returns only that tenant's device counts and policy versions under fleet; global cache statistics are omitted. A device's policy_version is its highest accepted anti-rollback version; after a canary rollback the active partition can be older. Check the persistent CANARY_ROLLBACK heartbeat flag or alert before treating the version distribution as active policy adoption.

Response 200 OK

{
  "fleet": {
    "total_devices": 150,
    "online": 142,
    "offline": 5,
    "degraded": 2,
    "lockdown": 1,
    "policy_versions": {
      "3": 10,
      "4": 140
    }
  },
  "cache_hits": 1843920,
  "cache_misses": 42817,
  "cache_size": 8192
}

Prometheus metrics

GET /api/v1/fleet/metrics

Returns global metrics in Prometheus exposition format (text/plain; version=0.0.4). Scrape this endpoint with Prometheus using the admin bearer token; a tenant-scoped token receives 403 Forbidden to prevent cross-tenant totals from leaking. The bundled local stack provisions the Fleet dashboard, but its scrape is opt-in because the gateway REST API is bound to loopback by default. See Fleet dashboard setup.

Response 200 OK (text/plain)

# HELP defenseclaw_fleet_devices_total Total registered devices
# TYPE defenseclaw_fleet_devices_total gauge
defenseclaw_fleet_devices_total{status="online"} 142
defenseclaw_fleet_devices_total{status="offline"} 5
defenseclaw_fleet_devices_total{status="degraded"} 2
defenseclaw_fleet_devices_total{status="lockdown"} 1

# HELP defenseclaw_fleet_heartbeats_received_total Total heartbeats received
# TYPE defenseclaw_fleet_heartbeats_received_total counter
defenseclaw_fleet_heartbeats_received_total 284910

# HELP defenseclaw_fleet_blocks_total Total block verdicts
# TYPE defenseclaw_fleet_blocks_total counter
defenseclaw_fleet_blocks_total 4821

# HELP defenseclaw_fleet_allows_total Total allow verdicts
# TYPE defenseclaw_fleet_allows_total counter
defenseclaw_fleet_allows_total 1893021

# HELP defenseclaw_fleet_verdict_cache_hits_total Verdict cache hits
# TYPE defenseclaw_fleet_verdict_cache_hits_total counter
defenseclaw_fleet_verdict_cache_hits_total 1843920

# HELP defenseclaw_fleet_verdict_cache_misses_total Verdict cache misses
# TYPE defenseclaw_fleet_verdict_cache_misses_total counter
defenseclaw_fleet_verdict_cache_misses_total 42817

# HELP defenseclaw_fleet_verdict_cache_size Current cache entries
# TYPE defenseclaw_fleet_verdict_cache_size gauge
defenseclaw_fleet_verdict_cache_size 8192

# HELP defenseclaw_fleet_policy_rollback_total Policy canary rollbacks
# TYPE defenseclaw_fleet_policy_rollback_total counter
defenseclaw_fleet_policy_rollback_total 0

# HELP defenseclaw_fleet_alerts_total Fleet alerts emitted
# TYPE defenseclaw_fleet_alerts_total counter
defenseclaw_fleet_alerts_total 2

# HELP defenseclaw_fleet_ota_rollbacks_total OTA canary rollbacks reported by devices
# TYPE defenseclaw_fleet_ota_rollbacks_total counter
defenseclaw_fleet_ota_rollbacks_total 0

Available metrics

MetricTypeDescription
defenseclaw_fleet_devices_totalgaugeRegistered devices by status (online, offline, degraded, lockdown)
defenseclaw_fleet_heartbeats_received_totalcounterTotal heartbeats received from all devices
defenseclaw_fleet_blocks_totalcounterTotal block verdicts across the fleet
defenseclaw_fleet_allows_totalcounterTotal allow verdicts across the fleet
defenseclaw_fleet_verdict_cache_hits_totalcounterServer-side verdict cache hits
defenseclaw_fleet_verdict_cache_misses_totalcounterServer-side verdict cache misses
defenseclaw_fleet_verdict_cache_sizegaugeCurrent number of entries in the verdict cache
defenseclaw_fleet_policy_rollback_totalcounterPolicy canary rollback transitions reported by devices
defenseclaw_fleet_alerts_totalcounterFleet alerts emitted
defenseclaw_fleet_ota_rollbacks_totalcounterOTA canary rollback transitions reported by devices

Both rollback counters increment when a device's authenticated heartbeat first changes from no CANARY_ROLLBACK flag to a flagged state. Repeated heartbeats carrying the persistent flag do not increment them again. The device's policy_version remains the accepted high-water version and does not prove that version is still active after rollback.


Error format

All error responses use a consistent JSON structure:

{
  "error": "human-readable error message"
}

Some errors include an additional detail field with diagnostic information:

{
  "error": "failed to dispatch command",
  "detail": "MQTT connection timeout"
}

Common status codes

StatusMeaning
200Success
201Resource created
202Accepted (async processing)
400Bad request (validation error)
401Unauthorized (missing or invalid Bearer token)
404Resource not found
413Request body too large (exceeds 1 MB)
422Unprocessable entity (e.g., invalid policy YAML)
500Internal server error
501Not implemented (policy service not configured)
503Service unavailable (MQTT not configured)