Skip to main content

Control-plane HTTP endpoints

declaragent up [-d] exposes a read-only HTTP listener on 127.0.0.1:9464 by default (override with DECLARAGENT_METRICS_PORT, set to 0 to disable). The listener multiplexes five endpoints used by declaragent fleet <verb> and external observability tooling.

PathShapePurpose
/metricsPrometheus textScrape endpoint — re-use of the 0.6.0 exporter.
/statusJSONSnapshot of the up process's bound agents + source summaries.
/eventsJSON, paginatedRead-only view of EventStore.list().
/dlqJSON, paginatedRead-only view of EventStore.listRejections() (dispatch DLQ).
/auditJSON, paginatedRead-only view of the hash-chained audit sink; optional ?verify=1.
/logstext/event-streamLive SSE tail of per-agent ~/.declaragent/logs/<agent>.log files.

Bind is localhost-only by default. Remote bind (Slice 2 of the control-plane plan) is gated on an explicit opt-in that isn't wired into the CLI yet.

Authenticated control plane

Shipped in Slice 2 of the Managed Control Plane rollout (docs/CONTROL_PLANE_PLAN.md §9 PR 2).

When a remote CLI needs to reach /status, /events, /dlq, /audit, or /logs across hosts, unauthenticated access is no longer enough. Declare an OIDC (or OAuth2 Client-Credentials) issuer in agent.yaml and every non-loopback request must carry a valid Authorization: Bearer <token> header. Same-host curls and declaragent ps keep working via the loopback bypass.

# agent.yaml
controlPlane:
auth:
enabled: true
provider: oidc
issuer: "https://dex.example.com"
audience: "declaragent-control-plane"
# Optional — override auto-discovery via /.well-known/openid-configuration.
jwksUri: "https://dex.example.com/keys"
# Strict AND semantics — every scope must be present on the token.
scopes: ["control:read"]
# Optional — flip to `false` to require tokens even on loopback.
# allowLoopback: false

OAuth2 Client-Credentials works the same way; the client secret is resolved through the standard env: / file: / secret: reference pipeline:

controlPlane:
auth:
enabled: true
provider: oauth2-client
tokenEndpoint: "https://idp.example.com/oauth/token"
clientId: "declaragent-control-plane"
clientSecretRef: "env:CP_CLIENT_SECRET"
audience: "declaragent-control-plane"
issuer: "https://idp.example.com/"
jwksUri: "https://idp.example.com/.well-known/jwks.json"
scopes: ["control:read"]

Request flow

  1. GET /status arrives with Host: fleet.internal and Authorization: Bearer <JWT>.

  2. The middleware verifies the JWT against the cached JWKS:

    • Signature validates against an asymmetric key in the JWKS.
    • iss, aud, exp, iat (+ nbf if present) match the config with a 60 s clock-skew tolerance.
    • Every scope declared under scopes: is present on the token.
  3. Pass → route dispatch runs normally. Fail → 401 with a typed body:

    { "error": "missing required scope \"control:read\"", "reason": "insufficient-scope" }

    The reason vocabulary is stable — remote CLIs can branch on it without string-matching error.

Rejection reasons

reasonWhen it fires
missing-tokenNo Authorization: Bearer <token> header.
malformed-tokenJWT failed structural decode or used alg: none.
bad-signatureSignature didn't verify against the JWKS.
expiredexp + clock-skew is in the past.
not-yet-validiat (or nbf) is in the future beyond the skew tolerance.
wrong-issueriss doesn't match the configured issuer.
wrong-audienceaud doesn't include the configured audience.
insufficient-scopeA required scope was absent from scope / scp.
idp-unreachableJWKS fetch (or OIDC discovery) failed; cache had expired.
provider-failedThe verifier threw unexpectedly — fail-closed, 401 not 500.
config-errorSynthetic-envelope construction failure (should never fire).

Loopback bypass

allowLoopback: true (the default) means requests whose Host header parses to 127.0.0.1, localhost, or the bracketed [::1] form bypass auth entirely. That keeps curl http://localhost:9464/metrics from the same box functional without a token. Zero-trust deployments flip this to false to require a token on every request — the listener is still bound to 127.0.0.1, so only co-located processes can reach it at all.

Enabling implicitly relaxes the Host-header sniff

startControlPlaneServer defaults to allowRemote: false — a request whose Host header isn't loopback normally gets 403 remote control-plane disabled before auth ever runs. When controlPlane.auth.enabled: true is parsed, the up daemon automatically flips allowRemote: true for the listener, since otherwise the middleware is unreachable. The listener still binds to 127.0.0.1 — a reverse proxy (nginx / envoy / cloud LB) is expected to front the port for remote access and the proxy's Host header rewrite plumbs the original hostname through.

Reusing the issuer with RPC auth

The block shape mirrors rpc-peers.yaml#auth deliberately — operators can issue one IdP integration and point both surfaces at it. Tokens are still verified per-surface (envelopes via @declaragent/plugin-agent-rpc/auth, HTTP via this middleware); only the config authoring is shared.

/logs — live log tail (SSE)

GET /logs[?agent=<id>][&since=<iso|ms>]
Accept: text/event-stream

Query params

ParamTypeDefaultNotes
agentstringall running agents (multiplexed)Unknown agent id returns 400 {"error": "unknown agent \"X\""}.
sinceISO-8601 | mstail-from-endWhen set, the tailer replays from byte 0 and the caller filters on the wire.

Framing

Each newline-terminated log record becomes one SSE frame:

event: log
data: {"agentId":"my-agent","ts":"2026-04-22T18:30:00.000Z","level":"info","event":"dispatcher.outcome", ...}

System messages (back-pressure drops, tailer errors) use event: system so clients can filter. Heartbeats are comment frames (: keep-alive) emitted every 15s so idle connections survive aggressive HTTP proxies.

Back-pressure

When the client stalls (or the consumer can't drain the socket faster than the tailer produces), the route buffers up to 1024 pending frames. When that cap is hit the whole buffer is dropped and replaced with one notice:

event: system
data: {"system":"dropped","count":1024}

Clients should treat this as "fall back to /events for the missing window" — the SSE stream is strictly best-effort.

Disconnect

Dropping the socket (client cancel, AbortController.abort(), TCP RST) tears down the per-request tailer, closes the heartbeat timer, and releases the file handle. No server-side garbage accumulates.

Response headers

Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transform
Connection: keep-alive
X-Accel-Buffering: no

X-Accel-Buffering: no tells nginx + certain cloud load-balancers to stop batching small responses — critical for a live tail.

curl example

# Tail a single agent, follow from EOF.
curl -N http://127.0.0.1:9464/logs?agent=my-agent

# Replay the whole file then follow.
curl -N "http://127.0.0.1:9464/logs?agent=my-agent&since=2026-04-22T00:00:00Z"

# Multiplex all running agents.
curl -N http://127.0.0.1:9464/logs

Pair the -N (no buffering) flag with the endpoint's X-Accel-Buffering hint so nothing on the path stands between the server and your terminal.

Python sseclient-py example

import sseclient
import requests

r = requests.get("http://127.0.0.1:9464/logs?agent=my-agent", stream=True)
for evt in sseclient.SSEClient(r).events():
if evt.event == "log":
record = json.loads(evt.data)
print(record["agent"], record.get("event"))
elif evt.event == "system":
print("!!", evt.data)
  • declaragent logs [-f] — file-path tail; no HTTP hop. Equivalent for a local operator; /logs is for remote fleet aggregation.
  • declaragent fleet logs (planned) — multiplexes /logs across every host in a fleet manifest.