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.
| Path | Shape | Purpose |
|---|---|---|
/metrics | Prometheus text | Scrape endpoint — re-use of the 0.6.0 exporter. |
/status | JSON | Snapshot of the up process's bound agents + source summaries. |
/events | JSON, paginated | Read-only view of EventStore.list(). |
/dlq | JSON, paginated | Read-only view of EventStore.listRejections() (dispatch DLQ). |
/audit | JSON, paginated | Read-only view of the hash-chained audit sink; optional ?verify=1. |
/logs | text/event-stream | Live 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
-
GET /statusarrives withHost: fleet.internalandAuthorization: Bearer <JWT>. -
The middleware verifies the JWT against the cached JWKS:
- Signature validates against an asymmetric key in the JWKS.
iss,aud,exp,iat(+nbfif present) match the config with a 60 s clock-skew tolerance.- Every scope declared under
scopes:is present on the token.
-
Pass → route dispatch runs normally. Fail →
401with a typed body:{ "error": "missing required scope \"control:read\"", "reason": "insufficient-scope" }The
reasonvocabulary is stable — remote CLIs can branch on it without string-matchingerror.
Rejection reasons
reason | When it fires |
|---|---|
missing-token | No Authorization: Bearer <token> header. |
malformed-token | JWT failed structural decode or used alg: none. |
bad-signature | Signature didn't verify against the JWKS. |
expired | exp + clock-skew is in the past. |
not-yet-valid | iat (or nbf) is in the future beyond the skew tolerance. |
wrong-issuer | iss doesn't match the configured issuer. |
wrong-audience | aud doesn't include the configured audience. |
insufficient-scope | A required scope was absent from scope / scp. |
idp-unreachable | JWKS fetch (or OIDC discovery) failed; cache had expired. |
provider-failed | The verifier threw unexpectedly — fail-closed, 401 not 500. |
config-error | Synthetic-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
| Param | Type | Default | Notes |
|---|---|---|---|
agent | string | all running agents (multiplexed) | Unknown agent id returns 400 {"error": "unknown agent \"X\""}. |
since | ISO-8601 | ms | tail-from-end | When 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)
Related
declaragent logs [-f]— file-path tail; no HTTP hop. Equivalent for a local operator;/logsis for remote fleet aggregation.declaragent fleet logs(planned) — multiplexes/logsacross every host in a fleet manifest.