The dashboard sideband is a read/write REST + SSE surface served on a separate port from the MCP proxy itself (default 127.0.0.1:3100). It is the canonical way for operators, custom dashboards, and monitoring tooling to inspect the proxy's in-memory and on-disk state.
Why a separate port? The sideband is deliberately isolated from the
/mcpport. This is what prevents an agent speaking/mcpfrom self-approving its own pending tickets — the approval REST API is mounted exclusively on this sideband, not on the MCP port.
Not the same as the SDK sideband. This document covers the dashboard sideband (
:3100), the operator read/write surface. There is a second, separate SDK sideband (:3200,sdk.*) that serves the Python SDK's evidence routes and the adapter governance API (/evaluate,/audit,/install-scan,/approval/:id/resolve) for hook-based adapters like OpenClaw. Different server, different port, different token.
- Default port:
127.0.0.1:3100(configurable viadashboard.port). - Bind address: localhost-only by default. Bind publicly only behind a TLS-terminating reverse proxy you control.
- Auth: By default,
dashboard.enabled: truerequiresdashboard.api_secret. Browser clients unlock withPOST /api/auth/session+ HttpOnly cookie; non-browser clients can useAuthorization: Bearer <secret>. The value presented is always the secret itself; the config may hold either that secret or itssha256:digest (see Authentication below). Running withoutapi_secretis only allowed with explicitdashboard.allow_open_mode: trueon loopback hosts. Open mode disables all sideband authentication and is a local/demo posture only: the CORS allowlist above does not make it safe, since an unauthenticated loopback service is still reachable by a determined local attacker (for example via DNS rebinding). - CORS: Allows same-origin,
localhost/127.0.0.1/0.0.0.0, and validated private-network IPv4 literals (10.x,172.16-31.x,192.168.x) for Docker bridge and LAN access. Origins are matched as real IP addresses, not by hostname prefix; every other origin, including hostnames, receives no CORS headers. (CORS is a browser-enforced control, not a server-side auth boundary.) - Content type: JSON for everything except
/api/audit/exportand/api/budgets/:name/events/export(JSON or CSV attachments) and/api/events(SSE stream). - Non-200 errors: Always JSON in the shape
{ "error": "<message>" }. Unknown/api/*paths return404with this shape (never HTML).
The sideband uses three principled response shapes, chosen by endpoint category. Knowing which category an endpoint belongs to tells you exactly how to parse its response.
Endpoints that return one resource by ID always wrap the resource in a data key, so clients can destructure const { data } = await res.json() without branching on field name.
Endpoints in this category: GET /api/audit/:id, GET /api/approvals/:id, GET /api/evidence/:session_id.
Endpoints that return a filtered collection wrap the array in data and flatten pagination metadata at the top level.
data— the current page of results.total— the full count after filters are applied but before pagination. Use it to drive "showing N of M" affordances.limit— the page size the server actually applied. May differ from what the client requested if clamping kicked in (all paginated endpoints clamp to endpoint-specific ranges).offset— the offset the server actually applied.
For pagination and boolean query fields, the sideband uses tolerant parsing at the boundary: empty or non-numeric limit / offset values fall back to endpoint defaults, and invalid boolean filter strings are treated as unset rather than causing a 400.
Endpoints in this category: GET /api/feed, GET /api/audit, GET /api/approvals, GET /api/budgets/:name/events.
Clients that need a page number compute it as Math.floor(offset / limit) + 1.
Endpoints that return a computed view of in-memory state are not "resources" in the REST sense, and wrapping them in { data } adds ceremony without signal. They return their computed view directly, with shapes specific to each endpoint.
Endpoints in this category: GET /api/health, GET /api/analytics, GET /api/limits, GET /api/adapters, GET /api/budgets.
Envelope category does not imply authentication policy: when dashboard auth is enabled, GET /api/analytics, GET /api/limits, GET /api/adapters, and GET /api/budgets still require auth. GET /api/health remains the only intentionally unauthenticated probe endpoint.
GET /api/health in particular is preserved in this form so that container orchestrators (Kubernetes, Docker Compose, Nomad) can point healthcheck probes at it without a custom JSON parser: probes only evaluate the HTTP status code, and the flat status, version, and uptime keys stay easy to read for the humans and scripts that hit the same URL.
The auth endpoints (/api/auth/*) and the approval action POSTs sit outside these three categories: they return small purpose-built objects ({ "ok": true }, the session state) documented inline in their endpoint sections.
Three endpoints fall outside the envelope model entirely:
GET /api/audit/export— binary download withContent-Disposition: attachmentand atext/csvorapplication/jsonbody. The body is a bareAuditRecord[]array (no envelope) or a CSV document, intended forcurl -o audit.jsonand spreadsheet pipelines.GET /api/budgets/:name/events/export— the same attachment contract for one budget's spend ledger: a bare ledger-row array or a CSV document.GET /api/events— Server-Sent Events stream. Each frame is anevent: <name>\ndata: <json>\n\nblock. Not a single response body.
Every 4xx and 5xx response across every endpoint (with the three non-JSON exceptions above) returns:
{ "error": "Human-readable error message" }Some validation errors on POST endpoints additionally include a details array; see the individual endpoint sections for specifics.
Every type that crosses a JSON boundary — REST response bodies, webhook payloads, SSE event payloads, audit records — uses snake_case field names throughout, including inside its TypeScript interface definition. There is no camelCase/snake_case mapping layer: JSON.stringify(obj) on an internal DTO produces the wire exactly. POST request bodies follow the same convention (approved_by, denied_by, reason).
Strictly-internal TypeScript types (e.g. ApprovalOutcome, RateLimitResult, constructor option interfaces) remain idiomatic camelCase; they are not serialized. When an internal value needs to flow into a DTO field — for example, copying outcome.resolvedBy into an AuditRecord.approved_by column — that is a single-line value copy at the boundary, not a shape conversion.
When dashboard.api_secret is set in helio.yaml (default secure mode), authenticated access works in two modes. The value a client presents is always the secret itself. The config may store either that secret or its SHA-256 digest in the form sha256:<64 hex>: helio init writes the digest and prints the secret once, helio secret prints a fresh secret and digest pair, and a ${VAR} placeholder may resolve to either form.
- Browser dashboard flow (recommended for operators)
POST /api/auth/sessionwith{ "secret": "<secret>" }- Server returns an HttpOnly
helio_sessioncookie, signed with a key the proxy generates at startup, and a CSRF token in JSON (sessions last 8 hours and never survive a proxy restart) - Browser then calls
/api/*with cookie credentials (no secret exposed in JS runtime)
- Machine client flow (backward compatible)
- Send
Authorization: Bearer <secret>on protected/api/*calls
- Send
Bearer verification is constant time: verifyBearer hashes the presented value with SHA-256 and compares it with timingSafeEqual against the stored digest, or against the hash of a stored plaintext, so neither the header length nor the stored form changes timing behavior.
Machine-client auth header:
Authorization: Bearer <secret>
Protected routes reject unauthenticated requests with 401. Cookie-authenticated mutating routes additionally require x-helio-csrf; a missing or mismatched token gets 403 — { "error": "Invalid CSRF token" }.
When dashboard.api_secret is unset and the dashboard is enabled, config validation requires dashboard.allow_open_mode: true and a loopback dashboard.host (127.0.0.1, localhost, or ::1) — otherwise the proxy refuses to start. Open mode is also unavailable whenever the secret is mandatory: any rule using require_approval, or policies.flag_destructive: require_approval or policies.on_tool_drift: require_approval, requires dashboard.api_secret regardless of allow_open_mode (see Approval Workflows). In the open-mode posture, middleware is a no-op and endpoints are unauthenticated. Do not run this mode behind any shared or non-local endpoint.
helio init generates a fresh 256-bit secret on first run, prints it once to stderr, and writes only its sha256: digest into the scaffolded helio.yaml. Keep that secret unless you are intentionally opting into local open mode.
If an operator loses the secret, recover by running helio secret, storing the printed digest: value under dashboard.api_secret in helio.yaml, and restarting the proxy; dashboard.* changes are not applied by a hot reload. Existing sessions are invalidated and all browser clients must sign in again.
Liveness and version probe. Does not require authentication — it stays open (alongside the /api/auth/* login endpoints) so that container healthchecks can hit it without bearer plumbing.
Response (200):
{
"status": "ok",
"version": "0.0.0",
"uptime": 3601.42
}status— always"ok"when the process is responsive.version— the Helio proxy version string, read frompackage.jsonat runtime. Set automatically by the release workflow from the git tag.uptime— seconds since the proxy process started, as reported byprocess.uptime().
Raw-shape endpoint: stays unwrapped for Kubernetes/Docker liveness probe compatibility.
Returns the current dashboard auth state for browser bootstrapping and session refresh checks.
Response (200):
{
"auth_required": true,
"authenticated": true,
"expires_at": "2026-04-20T12:34:56.000Z",
"csrf_token": "optional-when-authenticated"
}auth_required—truewhendashboard.api_secretis configured.authenticated—truewhen the current request is authenticated (session cookie or bearer).expires_at— present for authenticated cookie sessions.csrf_token— present for authenticated cookie sessions; include asx-helio-csrfon mutating cookie-auth calls.
Creates a browser session from the dashboard secret (the secret itself, never a stored sha256: digest).
Request body:
{ "secret": "<secret>" }Behavior:
- On success: sets
Set-Cookie: helio_session=...; HttpOnly; SameSite=Lax; Path=/; ...and returns{ auth_required: true, authenticated: true, expires_at, csrf_token }. - On invalid secret: returns
401 { "error": "Unauthorized" }. - On a malformed or invalid body: returns
400—{ "error": "Invalid JSON" }or{ "error": "Validation error", "details": [...] }.
Revokes the current session (if present), clears helio_session, and returns:
{ "ok": true }Aggregated statistics for the dashboard charts. Computed from the audit store for the supplied time range.
Query parameters:
| Parameter | Default | Description |
|---|---|---|
from |
now - 24h (ISO 8601) |
Start of the aggregation window (inclusive). |
to |
now (ISO 8601) |
End of the aggregation window (inclusive). |
upstream |
— | Scope every aggregate to one upstream name (exact match, issue #297). |
Response (200):
{
"total": 1247,
"allowed_total": 1100,
"blocked_total": 147,
"dry_run_total": 94,
"applied_total": 1153,
"by_decision": [
{ "decision": "allow", "count": 1100 },
{ "decision": "deny", "count": 89 },
{ "decision": "require_approval", "count": 58 }
],
"by_block_reason": [
{ "reason": "policy_denied", "count": 89 },
{ "reason": "approval_denied", "count": 31 },
{ "reason": "approval_timeout", "count": 27 },
{ "reason": "client_disconnected", "count": 12 },
{ "reason": "shutdown_cancelled", "count": 4 }
],
"top_tools": [
{ "tool_name": "get_weather", "upstream": null, "count": 412 },
{ "tool_name": "send_email", "upstream": null, "count": 301 }
],
"approval_rate": 0.87,
"per_hour": [
{ "bucket": "2026-04-15T10:00:00Z", "count": 52 },
{ "bucket": "2026-04-15T11:00:00Z", "count": 60 }
]
}total— total number of audit records in the window.allowed_total— records that resolved without a block (block_reason IS NULL), excluding drift events (tool_drift/tool_drift_reverteddecisions) and policy reload records (record_kind: policy_reload). When drift events or reload records fall inside the window,allowed_total + blocked_totaladds up to less thantotal.blocked_total— records that resolved with a block (block_reason IS NOT NULL), excluding policy reload records (a refused reload carries its outcome inblock_reasonbut is not a blocked call).dry_run_total— records produced in dry-run mode (dry_run = true). Policy reload records are excluded.applied_total— records produced in applied mode (dry_run = false). Policy reload records are excluded.by_decision— counts grouped bypolicy_decision, sorted descending. Policy reload records are excluded.by_block_reason— blocked counts grouped byblock_reason, sorted descending. Policy reload records are excluded.client_disconnectedandshutdown_cancelledare counted inblocked_totalandby_block_reason, and remain distinct from human denials (approval_denied) and natural timeouts (approval_timeout).top_tools— top 10 rows by call count, sorted descending. Each row is a (tool, upstream) pair (issue #297): same-named tools on different upstreams stay distinct, andupstreamis null in singular mode — where the rows reduce to exactly the per-tool shape. Drift events, nameless-call rejections (policy_decision: rejected), and policy reload records are excluded — none names a real tool.approval_rate—approved / total_require_approvalin the window, ornullwhen no approvals were requested.per_hour— hourly buckets of record counts over the window.
Raw-shape endpoint: this is an RPC-style view, not a resource lookup.
Current state of every active rate-limit and spend-limit bucket. Returns empty arrays when no buckets exist yet.
Response (200):
{
"rate_limits": [
{
"key": "tool:send_email:rule:0",
"current": 12,
"limit": 50,
"window_ms": 60000,
"reset_at_ms": 1744718400000
}
],
"spend_limits": [
{
"key": "session:abc-123:rule:2",
"current_spend": 4.75,
"limit": 20.0,
"currency": "USD",
"window_ms": 86400000,
"reset_at_ms": 1744804800000
}
]
}rate_limits[].current— calls consumed in the current window.rate_limits[].reset_at_ms— epoch ms at which the oldest recorded call ages out of the sliding window andcurrentdrops. The bucket does not reset wholesale.spend_limits[].current_spend— total spend in the current window incurrency.rate_limits[].keyandspend_limits[].key— both carry a:rule:<index>suffix naming the rule that owns the bucket; two rules sharing a scope track separately.
Raw-shape endpoint: this is an RPC-style view.
Per-origin liveness of the adapters driving the SDK sideband's governance API: when each origin was first and last seen, and the adapter_version it most recently reported on POST /evaluate. State is in-memory (reset on restart) and bounded by the sideband's 32-origin budget. Returns an empty list when the SDK sideband is disabled, so dashboards need no capability probe.
Response (200):
{
"adapters": [
{
"origin": "openclaw",
"adapter_version": "0.1.0",
"first_seen": "2026-07-04T12:00:00.000Z",
"last_seen": "2026-07-04T12:34:56.789Z"
}
]
}- An entry is created by the origin's first
POST /evaluate(the path that enforces the origin budget)./install-scanand/auditonly refresh existing entries, so an origin whose sole traffic is install scans does not appear here. adapter_version— the last value the origin supplied on/evaluate;nulluntil one is supplied.last_seen— the most recent/evaluate,/install-scan, or successfully finalized/auditfrom the origin. Entries are sorted most recently seen first.
Raw-shape endpoint: this is an RPC-style view.
Named cross-tool spend budgets (the budgets: config section). See the Policy Guide for budget semantics.
Every configured budget with its live bucket states. Unlike GET /api/limits, which lists only live keys, configured budgets appear even with zero live buckets ("buckets": []) — the dashboard shows every pot at full headroom. Zero budgets configured returns { "budgets": [] }.
Response (200):
{
"budgets": [
{
"name": "session-cap",
"limit": 50,
"currency": "USD",
"window": "session",
"key": "session",
"on_exceed": "require_approval",
"buckets": [
{
"bucket_key": "budget:session-cap:session:abc123",
"spent": 12.5,
"remaining": 37.5,
"reset_at_ms": null,
"last_activity_ms": 1783944000000
}
]
}
]
}window— the raw config string: a duration like"24h", or"session".buckets[].reset_at_ms— epoch ms at which the bucket's oldest recorded charge ages out of the sliding window;nullfor session windows (the pot never replenishes; idle pots are collected instead).buckets[].remaining— headroom before the next charge:max(0, limit - spent). An approved overage can pushspentpastlimit;remainingfloors at 0.
Raw-shape endpoint: this is an RPC-style view.
One budget's spend history — the durable ledger rows behind the pot, newest first. This is the "where did the money go" surface: rows survive restarts and config changes (history spans config resets; a limit/currency/window/key change resets the live pot but not the listing), and are bounded by the audit retention window.
Query parameters:
| Param | Default | Notes |
|---|---|---|
limit |
50 | Page size, clamped to 1000. |
offset |
0 | Pagination offset. |
An unknown budget name returns 200 with an empty page (budget names are configuration, not secrets, and 404 semantics would race hot-reloads).
Response (200):
{
"data": [
{
"id": "0d9e7c3a-9be2-4de1-a2fc-6f0f4bfae1a2",
"budget_name": "session-cap",
"bucket_key": "budget:session-cap:session:abc123",
"kind": "approved_overage",
"amount": 5,
"currency": "USD",
"tool_name": "stripe_charge",
"origin": "mcp",
"audit_record_id": "b53a2f8e-6a3d-4a56-8a3c-2f1f9df1c001",
"timestamp": "2026-07-13T12:00:00.000Z",
"timestamp_ms": 1783944000000,
"created_at": "2026-07-13T12:00:00.012Z",
"upstream": null
}
],
"total": 137,
"limit": 50,
"offset": 0
}kind—spend, orapproved_overagefor charges committed through a break-glass approval.upstream— upstream attribution read from the charge's audit record (issue #292). Null in singular mode, on rows whoseaudit_record_idis null or unresolved, and on rows predating the audit column.audit_record_id— the audit record of the call that produced the charge. The ledger is the source of truth for spend; the audit trail for calls — the referenced audit row may lag briefly (async audit writer) or be missing after a crash.origin—mcp, or the adapter origin for sideband-committed charges.
Bulk export of one budget's ledger rows as a downloadable attachment. Not a JSON envelope — the body is a bare array of the listing's wire rows (or a CSV document with the same thirteen columns, in the same order). Returned with Content-Disposition: attachment; filename="helio-budget-<name>-events.<ext>", where <name> is the budget name stripped to the config-name character set (A-Z a-z 0-9 _ -), so browsers download a file identifiable per pot.
Rows are exported newest-first — the listing's own order, and the deliberate opposite of the audit export's oldest-first: this endpoint takes no time filters, so a capped export keeps the most recent spend reachable, while older rows age out through the audit retention window. Like the listing, history spans config resets, and an unknown budget name returns 200 with an empty artifact.
Query parameters:
| Parameter | Default | Description |
|---|---|---|
format |
json |
json or csv. |
limit |
10000 |
Maximum records. Capped at 10k. |
Example:
curl -s -H "Authorization: Bearer $HELIO_DASHBOARD_SECRET" \
"http://localhost:3100/api/budgets/daily-cap/events/export?format=csv" > daily-cap.csvCSV cells follow the audit export's serialization rules: RFC 4180 escaping, null audit_record_id as an empty cell, and the formula-injection defense described in Audit Trail → CSV Format. The dashboard's Budgets view offers the same download per pot, and helio export --budgets <name> produces it offline from the database file.
Non-JSON endpoint — attachment download, no envelope.
See Audit Trail for the full audit record field reference and CLI export documentation.
Most recent audit records, newest-first. Designed for a live "activity feed" view; its server-side filters are upstream (issue #292) and session_source (issue #316) — every other dimension stays client-side.
Query parameters:
| Parameter | Default | Range | Description |
|---|---|---|---|
limit |
50 |
[1, 200] |
Maximum records per response. |
offset |
0 |
[0, ∞) |
Number of records to skip. |
upstream |
— | — | Filter by upstream name (exact match). |
session_source |
— | — | Filter by session identity source (header, meta, legacy_header, transport, sideband; exact match). |
Response (200):
{
"data": [
/* AuditRecord[] */
],
"total": 1247,
"limit": 50,
"offset": 0
}See Audit Trail → What's Recorded for the full AuditRecord field list.
Paginated-list envelope.
Searchable, filtered, paginated audit log.
Query parameters:
| Parameter | Default | Range | Description |
|---|---|---|---|
limit |
50 |
[1, 1000] |
Page size. |
offset |
0 |
[0, ∞) |
Number of records to skip. |
tool |
— | — | Tool name substring filter (LIKE %tool%). |
decision |
— | — | Filter by policy_decision. |
reason |
— | — | Filter by block_reason. |
blocked |
— | true/false |
Filter by whether block_reason is non-null. |
session |
— | — | Filter by session ID. |
agent |
— | — | Filter by agent ID. |
from |
— | ISO 8601 | Lower bound on created_at (inclusive). |
to |
— | ISO 8601 | Upper bound on created_at (inclusive). |
upstream_status_min |
— | integer | Minimum upstream HTTP status (inclusive). |
upstream_status_max |
— | integer | Maximum upstream HTTP status (inclusive). |
destructive |
— | true/false |
Filter by the flagged_destructive column. |
dry_run |
— | true/false |
Filter by the dry_run column. |
origin |
— | — | Filter by enforcement origin (mcp, or an adapter slug like openclaw). |
record_kind |
— | — | Filter by record category (tool_call, drift_event, install_scan, evaluation_expired, policy_reload). |
channel_id |
— | — | Filter by metadata.channel_id (adapter-supplied). |
sender_id |
— | — | Filter by metadata.sender_id (adapter-supplied). |
upstream |
— | — | Filter by upstream name (issue #292). |
session_source |
— | — | Filter by session identity source (header, meta, legacy_header, transport, sideband; issue #250). |
tool, origin, channel_id, and sender_id use substring matching (LIKE %value%). decision, reason, session, agent, record_kind, upstream, and session_source use exact equality matching (rows with a null upstream or session_source never match a filter value).
Response (200):
{
"data": [
/* AuditRecord[] */
],
"total": 1247,
"limit": 50,
"offset": 0
}Paginated-list envelope.
Look up a single audit record by ID.
Response (200):
{
"data": {
/* AuditRecord */
}
}Error responses:
404—{ "error": "Record not found" }
Resource-singleton envelope.
Bulk export of audit records as a downloadable attachment. Not a JSON envelope — the body is a bare AuditRecord[] array (or a CSV document). Returned with Content-Disposition: attachment; filename="helio-audit-export.<ext>" so browsers trigger a download.
Records are exported oldest-first (ascending created_at) — the opposite of the newest-first list endpoints. Combined with the limit cap, an export whose filters match more than 10k records returns the oldest 10k.
Query parameters:
| Parameter | Default | Description |
|---|---|---|
format |
json |
json or csv. |
limit |
10000 |
Maximum records. Capped at 10k. |
tool |
— | Filter by tool name substring. |
decision |
— | Filter by policy decision. |
reason |
— | Filter by block reason. |
blocked |
— | Filter by whether block_reason is non-null. |
dry_run |
— | Filter by dry-run records (true/false). |
session |
— | Filter by session ID. |
agent |
— | Filter by agent ID. |
from |
— | Start time (ISO 8601). |
to |
— | End time (ISO 8601). |
upstream_status_min |
— | Minimum upstream HTTP status (inclusive). |
upstream_status_max |
— | Maximum upstream HTTP status (inclusive). |
origin |
— | Filter by enforcement origin (mcp, or an adapter slug like openclaw). |
record_kind |
— | Filter by record category (tool_call, drift_event, install_scan, evaluation_expired, policy_reload). |
channel_id |
— | Filter by metadata.channel_id (adapter-supplied). |
sender_id |
— | Filter by metadata.sender_id (adapter-supplied). |
upstream |
— | Filter by upstream name (exact match, issue #292). |
session_source |
— | Filter by session identity source (header, meta, legacy_header, transport, sideband; issue #250). |
See Audit Trail → Dashboard API Export for full context and examples.
Non-JSON endpoint — attachment download, no envelope.
See Approval Workflows for the full approval model, channel configuration, timeout semantics, and break-glass policy.
List approval tickets. Tickets are sorted newest-first by requested_at before pagination, so offset=0 always points at the most recent page regardless of queue depth.
Query parameters:
| Parameter | Default | Range | Description |
|---|---|---|---|
status |
— | see below | Filter by pending / approved / denied / timeout / break_glass / client_disconnected / shutdown_cancelled / cancelled. |
limit |
50 |
[1, 1000] |
Page size. |
offset |
0 |
[0, ∞) |
Number of tickets to skip. |
Response (200):
{
"data": [
/* ApprovalTicket[] */
],
"total": 42,
"limit": 50,
"offset": 0
}total reflects the full count after the status filter but before pagination.
Paginated-list envelope.
Look up a single approval ticket by ID.
Response (200):
{
"data": {
"id": "ticket-abc",
"tool_name": "delete_record",
"tool_input": { "id": "rec-1" },
"matched_rule": "rule-destructive",
"rule_index": 0,
"channel_name": "dashboard",
"session_id": "session-abc",
"session_source": "header",
"requested_at": "2026-04-15T10:00:00.000Z",
"timeout_at": "2026-04-15T10:05:00.000Z",
"timeout_ms": 300000,
"status": "pending",
"notification_failures": []
}
}session_source (issue #251) appears when a session identity resolved (sideband on adapter-owned tickets), and upstream (issue #292) appears only on tickets from a named upstream — both are omitted entirely when unattributed. Resolved tickets additionally include resolved_at, plus — depending on resolution — resolved_by, denial_reason, or break_glass_reason (resolved_by appears only when a resolver identity was supplied; the proxy's own timeout, client_disconnected, and shutdown_cancelled resolutions never include one). Resolution statuses include approved, denied, timeout, break_glass, client_disconnected, shutdown_cancelled, and — for adapter-owned tickets — cancelled. Escalated tickets include escalated_at and escalated_to; notification_failures records failed notification deliveries. Tickets live in memory, and resolved tickets are dropped about an hour after resolution — see Approval Workflows for the retention model.
Error responses:
404—{ "error": "Ticket not found" }
Resource-singleton envelope.
Approve a pending ticket.
Request body:
{ "approved_by": "alice" }Response (200):
{ "ok": true }Error responses:
400—{ "error": "Invalid JSON" }or{ "error": "Validation error", "details": [...] }404—{ "error": "Ticket not found" }409—{ "error": "Ticket already resolved", "status": "<current-status>" }409—{ "error": "native_ticket", "resolve_in": "<origin>" }for adapter-owned tickets (channel_nameofnative:<origin>): their approval UI lives in the adapter, so the/api/approvals/*endpoints refuse to resolve them. See the adapter governance API.
Deny a pending ticket.
Request body:
{ "denied_by": "bob", "reason": "Suspicious activity" }reason is optional.
Response (200):
{ "ok": true }Error responses: same as /approve.
Emergency force-approval. Both approved_by and reason are required. The resolution is flagged in the audit trail.
Request body:
{ "approved_by": "admin", "reason": "Emergency override needed" }Response (200):
{ "ok": true }Error responses: same as /approve.
Get the full evidence + context + completed-tools state for a single resolved session identity. Unknown session IDs return an empty state (not a 404) so that dashboard pages can render cleanly on first load.
Response (200):
{
"data": {
"session_id": "session-abc",
"evidence": {
"orders.lookup": {
"evidence_key": "orders.lookup",
"data": { "id": 123, "status": "shipped" },
"tool_name": "lookup_order",
"timestamp": "2026-04-15T10:00:00.000Z",
"expires_at": 1744720800000
}
},
"context": {},
"completed_tools": [
{
"tool_name": "lookup_order",
"timestamp": "2026-04-15T10:00:00.000Z",
"succeeded": true
}
]
}
}Expired evidence entries are omitted from the evidence map in this response. Helio may still keep session-level "seen key" metadata internally so evidence checks can distinguish evidence_missing from evidence_expired within the same session.
Resource-singleton envelope.
Server-Sent Events stream of dashboard events. The stream stays open indefinitely; the server sends a heartbeat every 30 seconds (configurable via dashboard.sse_heartbeat_interval) so network-level idle timers do not close the connection. A client that disconnects releases its connection slot immediately. A background sweeper additionally handles clients that stop reading without disconnecting: once such a connection's transport buffers fill, writes to it stop completing, and after three heartbeat intervals without a completed write the sweeper closes the connection — the socket is severed, not merely dropped from the connection count — and frees its slot. On proxy shutdown, active /api/events connections are drained and closed before process exit. At most 256 concurrent connections are served per proxy; a GET past the bound is refused with 503 and a JSON body ({"error":"connection capacity reached"}) and never displaces an established stream. The bound is not configurable. A browser EventSource treats the refusal as terminal and does not reconnect, so a refused dashboard tab keeps its last-loaded data until reloaded.
Authentication: requires either a valid session cookie or Authorization: Bearer <secret>. Query-string token auth is intentionally not supported.
Event types:
| Event | Payload fields |
|---|---|
heartbeat |
empty data. Sent on connect and every dashboard.sse_heartbeat_interval. |
action |
id, tool_name, policy_decision, block_reason, approval_status, session_id, session_source (which identity strategy produced session_id, issue #218), protocol_version (the client's verbatim MCP-Protocol-Version wire claim, issue #219 — the client's claim, not the upstream era; always null for sideband-origin records, which have no MCP wire), agent_id, environment, timestamp, total_duration_ms, approval_wait_ms, proxy_compute_ms, flagged_destructive, dry_run, matched_rule, matched_rule_index, origin (enforcement origin: mcp or adapter slug), record_kind (tool_call / drift_event / install_scan / evaluation_expired / policy_reload), upstream (upstream attribution, issue #292 — null in singular mode and on sideband records) |
approval_requested |
ticket_id, tool_name, channel, requested_at, upstream (null when the ticket carries no upstream attribution) |
approval_resolved |
ticket_id, status, resolved_by (optional), resolved_at |
approval_notification_failed |
ticket_id, channel, phase (initial/escalation), error |
limit_warning |
key, type (rate/spend), current, limit, utilization, upstream (parsed from an upstream-partitioned bucket key; null for session, singular, and sideband keys) |
budget_update |
name, bucket_key, kind (spend/approved_overage), amount, spent, remaining, limit, currency, utilization, upstream (the charging door's upstream attribution; null in singular mode and on deferred sideband commits). One event per budget per committed charge — utilization drives dashboard thresholds; there is no separate budget warning event. The one exception: a charge that commits under a stale config generation (an in-flight call outliving a pot-resetting reload) is ledgered but fires no event, since the pot it would describe no longer exists. |
budget_breached |
name, bucket_key, on_exceed (deny/require_approval), attempted_amount, spent, limit, currency, upstream (as on budget_update). Fired once per genuinely breached budget when a peek denies a call or raises the break-glass ticket. Dry-run peeks never fire it, and an invalid-amount failure emits nothing for itself — though when such a denial also carries genuine breaches on other budgets, those still fire. |
policy_reload |
id (the audit record's id), at (its timestamp), outcome (applied, rejected_invalid, rejected_unroutable, rejected_budget_flush, rejected_pinned, watch_failed), config_path, sha256_before, sha256_after, rule_count_before, rule_count_after, default_action_before, default_action_after, budget_count_before, budget_count_after, rules_removed, restart_required_paths, error. Emitted beside the record's own action event when a policy reload attempt is recorded (issue #341; see Policy Reload Records). |
For approval_resolved, status is one of approved, denied, timeout, break_glass, client_disconnected, shutdown_cancelled, or — for adapter-owned tickets — cancelled.
Every non-heartbeat event carries a unique id: line for client-side de-duplication and debugging. The SSE stream is live-only (no replay endpoint): reconnecting clients should backfill from REST endpoints (/api/feed, /api/approvals, /api/limits, /api/budgets) before resuming live consumption.
Non-JSON endpoint — SSE stream, not a single response.
Every 4xx and 5xx JSON response follows this shape:
{ "error": "Human-readable error message" }Some POST validation errors include a details array of { path, message } entries from Zod; some 409 responses include a status field with the current ticket status (the native-ticket 409 carries resolve_in instead). The error field is always present.
Unknown /api/* paths are caught by a dedicated 404 guard and return { "error": "Not found" } with 404. Unknown paths never fall through to the SPA catch-all (which would otherwise return HTML), so an API client probing the sideband always gets a parseable JSON error.
- Approval Workflows — approval model, channels, timeouts, escalation, break-glass policy
- Audit Trail — audit record field reference, storage, CLI export, CSV format
- Policy Guide — how policy decisions drive
/api/feed,/api/audit, and/api/approvalspopulation - Getting Started → Production Checklist — security-hardening checklist for running the sideband in production