Reporting Protocol
All communication between a SLAW instance and a Botfather tower is instance-initiated over HTTPS. The instance calls four endpoints; the tower never pushes to the instance except via directive payloads returned inside heartbeat and sync responses.
Prerequisites: An enrolled, active instance with a valid API key. See Identity & Keys for how keys are issued and secured.
Protocol version
The current wire protocol is v1. Every request body must include "protocolVersion": 1.
The tower accepts the current version and one prior version (PROTOCOL_VERSION - 1). Instances on an older version receive HTTP 426 Upgrade Required with a human-readable reason. Upgrade SLAW to resume reporting.
Authentication
Authenticated endpoints require:
Authorization: Bearer <instance-api-key>
The enroll endpoint is the only unauthenticated call — the instance has no key yet.
Endpoints
1. Enroll — POST /api/ingest/v1/enroll
Token-less self-enrollment. The instance calls this on startup when no enrollment record exists locally.
Request body
{
"protocolVersion": 1,
"instance": {
"machineId": "<salted-hash>",
"instanceId": "<alphanumeric-id>",
"hostname": "eng-laptop-01",
"os": "darwin",
"slawVersion": "1.4.2"
},
"capabilities": {
"reportIssueTitles": true,
"liveStream": false
}
}
| Field | Type | Description |
|---|---|---|
instance.machineId | string (8–128 chars) | Salted-hash machine identity from ~/.slaw/machine.json. |
instance.instanceId | string (≤64 chars, [a-zA-Z0-9_-]) | Stable identifier for this SLAW installation. |
instance.hostname | string | System hostname, used in the tower's Fleet View. |
instance.os | darwin | linux | win32 | Operating system. |
instance.slawVersion | string | Installed SLAW version. |
capabilities.reportIssueTitles | boolean (default true) | When false, issue titles are redacted to their key in all subsequent sync payloads. See What Is and Isn't Synced. |
capabilities.liveStream | boolean (default false) | Whether this instance supports the live WebSocket channel. |
Response
{
"enrollmentId": "550e8400-e29b-41d4-a716-446655440000",
"state": "pending",
"pollIntervalSec": 10
}
| Field | Description |
|---|---|
state | One of pending, active, rejected, revoked. If active (auto-approve rule matched), apiKey is also returned. |
apiKey | Present only when state === "active". Store this key — it is never returned again. |
pollIntervalSec | How often to call the poll endpoint while waiting for approval. |
When state is pending, the instance polls until it transitions to active or rejected.
Poll — POST /api/ingest/v1/enroll/poll
{
"protocolVersion": 1,
"enrollmentId": "550e8400-e29b-41d4-a716-446655440000"
}
Returns the same state + optional apiKey fields as the enroll response. The instance stops polling when state reaches a terminal value.
2. Heartbeat — POST /api/ingest/v1/heartbeat
Sent approximately every 60 seconds. Any successful authenticated ingest call counts as proof of life, but the Heartbeat is the primary liveness signal.
Request body
{
"protocolVersion": 1,
"sentAt": "2026-06-09T01:00:00.000Z",
"status": "ok",
"uptimeSec": 3600,
"counts": {
"squads": 2,
"agents": 8,
"activeRuns": 1,
"openIssues": 14
},
"spend": {
"todayCents": 420,
"monthCents": 6800
},
"lastEventCursor": "cursor-abc123",
"appliedLimitVersion": 3,
"appliedSkillCatalogVersion": 12
}
| Field | Description |
|---|---|
status | ok or degraded. Use degraded if the instance is running but experiencing errors. |
counts | Live snapshot: squads, agents, active runs, open issues. |
spend.todayCents | Spend since UTC midnight (integer cents). |
spend.monthCents | Month-to-date spend (integer cents). |
lastEventCursor | The cursor the instance will use in the next sync batch, for observability. Null on first run. |
appliedLimitVersion | The monotonic version of the budget limit currently applied. Lets the tower skip a set_limits push it already sent. |
appliedSkillCatalogVersion | The catalog version last seen/pulled (observability; never forced). |
Response
{
"acknowledged": true,
"directives": [
{ "kind": "set_limits", "limit": { ... } }
]
}
The directives array is the tower's back-channel. Process each directive in order. See Directives below.
3. Sync — POST /api/ingest/v1/sync
Sends delta upserts (entity changes) and fact events (cost, run, activity). Called when there is new data to report; the interval is adjusted by the set_sync_interval directive.
Request body
{
"protocolVersion": 1,
"sentAt": "2026-06-09T01:01:00.000Z",
"batchCursor": "cursor-abc124",
"upserts": [ ... ],
"facts": [ ... ]
}
| Field | Description |
|---|---|
batchCursor | Monotonically increasing string cursor the instance proposes for this batch. |
upserts | Array of entity upserts (squads, agents, skills, projects, issues). Max 2000 per batch. |
facts | Array of fact events (cost, run, activity). Max 5000 per batch. |
See What Is and Isn't Synced for the full breakdown of which fields are included in upserts.
Response
{
"acknowledgedCursor": "cursor-abc124",
"accepted": {
"upserts": 5,
"facts": 12,
"deduplicated": 0
},
"directives": []
}
The acknowledgedCursor confirms the batch was stored. The instance advances its local cursor only after receiving this acknowledgement.
4. Manifest — POST /api/ingest/v1/manifest
Nightly self-healing reconciliation. The instance reports per-entity-type counts; the tower compares against its own view and requests a full resync of any divergent type.
Request body
{
"protocolVersion": 1,
"sentAt": "2026-06-09T02:00:00.000Z",
"counts": {
"squads": 2,
"agents": 8,
"projects": 3,
"issues": 142,
"costEvents": 1024
}
}
Response
{
"inSync": false,
"resyncTypes": ["issue"]
}
When resyncTypes is non-empty, the instance queues a full resync of those entity types in the next sync batch.
Directives
Directives are returned in Heartbeat and sync responses. The instance processes them in order.
kind | Payload | Action |
|---|---|---|
set_sync_interval | seconds: number | Change the sync polling interval. Range: 10–3600 s. |
request_reconciliation | (none) | Trigger a manifest call on the next cycle. |
request_live_stream | durationSec: number | Open the WebSocket live channel for the specified duration. See Live Channel. |
stop_live_stream | (none) | Close the live channel if open. |
set_limits | limit: LimitSpec | Apply a new tower-governed budget limit. Apply only when limit.version is greater than the currently applied version. |
skills_updated | catalogVersion: number | The skill catalog has advanced; pull at your leisure. |
Error codes
| HTTP status | code | Meaning |
|---|---|---|
| 426 | protocol_version_unsupported | Client version is too old. Upgrade SLAW. |
| 400 | invalid_payload | Request body failed schema validation. |
| 401 | unauthorized | Missing or invalid API key. |
| 404 | enrollment_not_found | The enrollmentId is unknown to this tower. Re-enroll. |
| 403 | enrollment_rejected | An Operator rejected this instance. |
| 403 | enrollment_revoked | This instance was revoked from the tower UI. |
| 429 | rate_limited | Too many requests. Back off and retry. |
Next steps
- Identity & Keys — how
machineIdis generated and how API keys are stored and revoked. - What Is and Isn't Synced — full breakdown of synced fields and the sovereignty boundary.
- Live Channel — the optional WebSocket drill-down.
- API Reference: Botfather Protocol — Zod schemas and full type definitions.