Skip to main content

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 /intent routes additionally opt into Server-Sent Events when the request sets Accept: text/event-stream. See Streaming responses below.
  • Validation-level conditions (missing capability, no agent matched) return 200 OK with { "kind": "error", "message": "…" }, matching IPC behavior so that callers parse a single response shape.
  • Internal errors (panic, I/O fault) return 500 Internal Server Error with { "error": "…" }.
  • CORS uses an explicit origin allow-list. Default is http://localhost:3000; override with COVENANT_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: no

Content-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.