HTTP API
The daemon exposes the same surface over HTTP that it does over the Unix socket. The HTTP gateway is suitable for browser-facing UIs and third-party tooling that cannot speak length-prefixed JSON IPC. Listening address is 127.0.0.1:8421 (loopback only) by default; override the port with the COVENANT_HTTP_PORT environment variable and the bind address with COVENANT_HTTP_BIND_ADDR. Changing the bind address away from 127.0.0.1 widens the network exposure of every protected route to the chosen interface. Only do so when the host firewall and bearer-token policy account for it.
Conventions
- Request bodies are JSON; responses default to JSON. The
/memory/recent,/audit/recent, and/intentroutes additionally opt into Server-Sent Events when the request setsAccept: text/event-stream. See Streaming responses below. - Validation-level conditions (missing capability, no agent matched) return
200 OKwith{ "kind": "error", "message": "…" }, matching IPC behavior so that callers parse a single response shape. - Internal errors (panic, I/O fault) return
500 Internal Server Errorwith{ "error": "…" }. - CORS uses an explicit origin allow-list. Default is
http://localhost:3000; override withCOVENANT_HTTP_ORIGINS.
Streaming responses
Three read-style routes (GET /memory/recent, GET /audit/recent, and POST /intent) accept a per-request switch between the default buffered JSON response and a Server-Sent Events stream of the same logical content. Clients opt in by sending Accept: text/event-stream; any other Accept value (or absent header) keeps the v1 buffered shape byte-identical with the pre-streaming contract.
The Accept gate matches exactly. text/event-streamcomparison is case-insensitive and tolerates q= values; comma-separated media types are split and trimmed. */* and text/* do not trigger the SSE branch; the buffered fallback is the safe default for callers that did not explicitly ask to stream.
SSE responses carry a fixed header set:
Content-Type: text/event-stream
Cache-Control: no-cache
X-Accel-Buffering: noContent-Type is the bare media type: strict EventSource implementations reject a charset=utf-8 suffix. Cache-Controlkeeps intermediate caches forwarding every chunk; X-Accel-Bufferingdefeats nginx's default response buffering.
Each event frame on the wire is:
event: <kind>
data: <single-line JSON of the envelope>
<kind> is one of stream_begin, stream_chunk, stream_end, or stream_error. The trailing blank line is the SSE delimiter; the JSON data: line carries the full envelope (including its own kind field, so a consumer that reads only data: still has the discriminator).
stream_begin announces the response variant the stream materializes via its response_kind field: memories for /memory/recent, audit_events for /audit/recent, and intent_result for /intent. stream_chunk frames carry a monotonic sequence counter and the per-verb payload (a memory record, an audit event, or an AgentResult). stream_end closes the stream; for /intent it carries a summary object with intent_id, status, and settlement.
stream_error is reserved for streams that opened successfully and then failed mid-flight. Streaming refused (capability gate failure, ignore-rule match, budget exhaustion on /intent) renders as a buffered JSON document with Content-Type: application/json regardless of the Accept header, so a consumer can distinguish a refusal from a mid-flight failure by the response content type.
Routes
Health
GET /health
→ 200 { "status": "ok" }
GET /version
→ 200 {
"kind": "protocol_info",
"info": {
"protocol": "covenant.ipc",
"version": 1,
"min_supported": 1,
"max_supported": 2
}
}/version mirrors the unauthenticated protocol_info IPC probe. The response shape is stable across protocol versions 1 and 2; max_supportedadvertises the highest version the daemon understands, while the top-level version reflects the negotiated wire version (v1 unless a client opts into v2 streaming per request). Adding required fields to the shape implies a future protocol version bump.
Intents
POST /intent
Content-Type: application/json
Body: { "text": "summarise recent work on agent memory" }
→ 200 {
"kind": "intent_result",
"intent_id": "…",
"status": "ok",
"text": "…",
"sources": ["…"],
"settlement": null
}
POST /intents/resume
Body: { "intent_id": "uuid" }
→ 200 { "kind": "intent_result", ... }With Accept: text/event-stream, POST /intent returns the same logical result as a Server-Sent Events stream of begin/chunk/end envelopes (see Streaming responses):
POST /intent
Accept: text/event-stream
Content-Type: application/json
Body: { "text": "summarise recent work on agent memory" }
→ 200 Content-Type: text/event-stream
event: stream_begin
data: { "kind": "stream_begin", "stream_id": "…", "response_kind": "intent_result" }
event: stream_chunk
data: { "kind": "stream_chunk", "stream_id": "…", "sequence": 0,
"chunk": { "text": "…", "sources": [...], "runtime_events": [] } }
event: stream_end
data: { "kind": "stream_end", "stream_id": "…",
"summary": { "intent_id": "…", "status": "ok", "settlement": null } }/intents/resumere-dispatches a previously paused intent by replaying its original text from the recent audit window (the last 1024 events). The caller must be the same peer that submitted the original intent; resuming another peer's row returns an error response so the pausedintent_text never leaks. If the row has aged out of the window or no budget_exhausted row matched, re-submit the original text via POST /intent.
Memory
GET /memory/recent?tier=working&limit=10
GET /memory/search?q=agent+memory&tier=longterm&limit=5&min_relevance=0.6
POST /memory/purge
Body: { "tier": "working", "before_ms": 1714938000000 }
POST /memory/repair
Body: MemoryRepairRequest # see /memory for command shape
POST /memory/compact
Body: MemoryCompactionRequest
POST /memory/records/backfill
Body: { "dry_run": true } # repair legacy memory-receipt correlations
→ 200 { "kind": "memories", "records": [ ... ] }
or { "kind": "memory_purged", "purged": 42 }
or { "kind": "memory_repaired", ... }
or { "kind": "memory_compacted", ... }
or { "schema": "covenant.memory.backfill.v1",
"row_count": 7,
"savepoint_name": "memory_backfill_sp_001",
"dry_run": true }/memory/records/backfill defaults to apply (dry_run: false); pass dry_run: true to plan without mutating. The savepoint_name is always a non-null string: the daemon allocates one even in dry-run mode so consumers can correlate planning runs against later mutation runs. Apply wraps the row updates in a SQLite SAVEPOINT so a per-row failure rolls the entire batch back to zero rows changed. The envelope uses the versioned schema discriminator (schema, not kind); see the IPC docs for the full pin.
With Accept: text/event-stream, GET /memory/recent streams one stream_chunk per record (see Streaming responses):
GET /memory/recent?tier=working&limit=10
Accept: text/event-stream
→ 200 Content-Type: text/event-stream
event: stream_begin
data: { "kind": "stream_begin", "stream_id": "…", "response_kind": "memories" }
event: stream_chunk
data: { "kind": "stream_chunk", "stream_id": "…", "sequence": 0,
"chunk": { "id": "uuid", "tier": "working", "text": "…", ... } }
# … one stream_chunk per record …
event: stream_end
data: { "kind": "stream_end", "stream_id": "…" }Receipts
GET /receipts/recent?limit=10&since_ms=1714938000000
→ 200 { "kind": "receipts", "receipts": [ ... ] }Settlement
POST /settlement/receipts/backfill
Body: { "dry_run": true } # repair legacy settlement-receipt rows
→ 200 { "schema": "covenant.settlement.backfill.v1",
"row_count": 12,
"rollback_path": null,
"dry_run": true }/settlement/receipts/backfill defaults to apply (dry_run: false); pass dry_run: true to plan without mutating. The rollback_path is nullin dry-run mode and a filesystem path on the daemon's local host once an apply pass writes its rollback evidence sibling file. scope_pubkey is reserved for a future delegated mode and is not yet wired: a request that sets it is rejected before the capability check. The envelope uses the versioned schema discriminator (schema, not kind); see the IPC docs for the full pin.
Budget
GET /budget/debits?limit=20
→ 200 {
"kind": "debits",
"debits": [
{
"agent": { ... },
"credits": 128,
"paired_receipt": "uuid",
"at_ms": 1714938000000
}
]
}Each BudgetDebit pairs 1:1 with a settlement receipt via paired_receipt, so the budget log and the receipt log can be joined for reconciliation.
Chain
GET /chain/status
→ 200 {
"kind": "chain_status",
"status": {
"chain": "solana",
"cluster": "…",
"rpc_url": "…" | null,
"ws_url": "…" | null,
"program_id": "…" | null,
"covnt_mint": "…" | null,
"ready": false,
"missing": ["rpc_url", "program_id"]
}
}
GET /chain/receipt-batches?limit=10
→ 200 {
"kind": "receipt_batches",
"batches": [
{
"batch_id": "…",
"merkle_root": "…",
"receipt_count": 20,
"tx_sig": "…" | null,
"slot": 123 | null
}
]
}
POST /chain/flush-receipts
Body: { "limit": 10 }
→ 200 {
"kind": "receipt_batch_flushed",
"batch": { ... },
"receipts_updated": 20
}/chain/status reports the configured settlement chain and the names of any missing endpoints, mint, or program fields./chain/flush-receipts batches unsettled local receipts into one ReceiptBatchSummary and stamps the receipts with the batch identifier; on-chain submission and tx_sig population follow once the chain configuration is complete.
Capabilities
GET /capabilities/recent?limit=10
POST /capabilities/grant
Body: {
"action": "tool.web_search",
"scope": null,
"expires_at": null
}
POST /capabilities/revoke
Body: { "signature_b58": "4qXP…" }
POST /capabilities/purge
Body: { "before_ms": 1714938000000 }
→ 200 {
"kind": "capability_granted",
"signature_b58": "…",
"subject_display": "user@local",
"action": "tool.web_search"
}
or { "kind": "capability_revoked", "signature_b58": "…", "removed": true }
or { "kind": "capabilities_purged", "purged": 7 }Verify
GET /verify?window=100
→ 200 {
"kind": "verify_report",
"window": 100,
"checks": [
{ "name": "memory ↔ audit", "passed": true, "message": "…" },
{ "name": "memory parent references", "passed": true, "message": "…" },
{ "name": "capability ↔ audit", "passed": true, "message": "…" },
{ "name": "memory ↔ receipts", "passed": true, "message": "…" }
],
"drift": [
{
"kind": "memory_stale_parent",
"id": "uuid",
"message": "…",
"repair": "…"
}
],
"orphans_total": 0
}Tools
GET /tools
POST /tools/call
Body: { "name": "echo", "arguments": { "text": "hi" } }
→ 200 { "kind": "tool_list", "tools": [ ... ] }
or { "kind": "tool_result", "content": [ ... ], "is_error": false }Audit
GET /audit/recent?limit=20&since_ms=1714938000000
→ 200 { "kind": "audit_events", "events": [ ... ] }
GET /audit/verify
→ 200 {
"kind": "audit_integrity",
"report": {
"events": 42,
"anchors": 42,
"valid": true,
"root_hash_hex": "…",
"failures": []
}
}
POST /audit/purge
Body: { "before_ms": 1714938000000 }
→ 200 { "kind": "audit_purged", "purged": 42 }With Accept: text/event-stream, GET /audit/recent streams one stream_chunk per event (see Streaming responses):
GET /audit/recent?limit=20
Accept: text/event-stream
→ 200 Content-Type: text/event-stream
event: stream_begin
data: { "kind": "stream_begin", "stream_id": "…", "response_kind": "audit_events" }
event: stream_chunk
data: { "kind": "stream_chunk", "stream_id": "…", "sequence": 0,
"chunk": { "issuer": { ... }, "kind": { ... }, "timestamp_ms": 1714938000000, ... } }
# … one stream_chunk per event …
event: stream_end
data: { "kind": "stream_end", "stream_id": "…" }Agent-to-agent
POST /a2a/tasks # body: A2ATask JSON
GET /a2a/tasks/next # leases the next queued task
GET /a2a/tasks/recent?limit=N # non-consuming snapshot
POST /a2a/results # body: A2ATaskResult JSON
GET /a2a/results/next # drains the next queued result
GET /a2a/results/recent?limit=N # non-consuming snapshot
GET /a2a/queue?limit=N&min_lease_age_ms=5000&deadline_within_ms=60000&state_filter=queued|in_flight
# queued tasks, in-flight leases, pending results
POST /a2a/repair
Body: {
"task_id": "uuid",
"command": { "action": "requeue",
"duplicate_risk": "idempotent" | "operator_accepted",
"lease_id": "uuid" | null }
| { "action": "force_error",
"message": "…",
"lease_id": "uuid" | null },
"reason": "…"
}
→ 200 {
"kind": "a2_a_repaired",
"outcome": {
"task_id": "uuid",
"action": "requeued" | "forced_error",
"state": "queued" | "result_pending",
"attempt": 2,
"result": null | { ... }
}
}
POST /a2a/compact
→ 200 { "kind": "a2_a_compacted", "dropped": 7 }Write paths (POST) require capability tokens; see Agent-to-agent for the exact actions. /a2a/repair is the operator-driven recovery verb for stuck leases or runaway tasks; the requeue form puts the task back on the queue (callers must declare duplicate_risk when the task is not provably idempotent) while force_errorterminates the task with an operator-supplied message. /a2a/compact drops settled, fully resolved task rows beyond the retention window and reports how many were dropped.
Peers
GET /peers/list?limit=20&prefix=A1B2&status=live
→ 200 {
"kind": "peer_list",
"peers": [
{
"agent_id": { ... },
"token_prefix": "abc123",
"registered_at": 1714938000000,
"revoked_at": null
}
],
"operator_pubkey_b58": "…",
"truncated": false
}
POST /peers/purge
Body: { "before_ms": 1714938000000 }
→ 200 { "kind": "peers_purged", "purged": 3 }
POST /peers/rotate
→ 200 { "kind": "operator_token_rotated", "token_b58": "…" }
POST /peers/revoke
Body: {
"token_prefix": "abc123",
"force": false,
"match_limit": null
}
→ 200 {
"kind": "peer_revoked",
"outcome": { "type": "revoked", ... }
| { "type": "already_revoked", ... }
| { "type": "not_found" }
| { "type": "ambiguous", "matches": [ ... ], "truncated": false }
| { "type": "self_revoke_forbidden", ... }
}/peers/list filters by 6-char base58 prefix over the redacted token; status islive or revoked; anything else (or absent) means no status filter. /peers/rotateissues a new operator token; the rotated token replaces the bootstrap file and the caller's current connection keeps working; new connections must authenticate with token_b58. /peers/revoke takes the 6-char token_prefixfrom a /peers/list row; the five-case RevokeOutcometagged enum distinguishes a fresh revoke, an idempotent retry, a missing prefix, an ambiguous prefix, and a refusal to revoke the operator's own bootstrap token.
Authentication
Every route except /health and /version requires Authorization: Bearer <token>. The token must resolve to a live peer in the daemon registry, matching the Unix-socket authentication model. The gateway still binds to loopback by default and should not be exposed directly to an untrusted network.
Related
- CLI: same surface, but talking to the Unix socket.
- Local IPC: the wire protocol on the Unix socket.
- Security model: what the loopback-only assumption costs you.