# Getting Started Get Helio running in under 5 minutes. By the end, you'll have an MCP governance proxy intercepting tool calls, enforcing policy rules, and recording an audit trail — with a live dashboard showing everything. ## Prerequisites - **Node.js 24+** — check with `node --version` - **`jq` (optional)** — used in examples below for pretty-printing JSON. If unavailable, remove `| jq` and read raw JSON output. - **An MCP server to govern** — any server that speaks Streamable HTTP, SSE, or stdio. Helio works with any spec-compliant MCP server (e.g. FastMCP, the official MCP SDKs) with zero changes to the server. **No server to test against?** Use the zero-dependency echo server below — no clone or install needed. (If you've cloned the repo as a contributor, you can instead run the bundled examples with `pnpm start`; see [`examples/basic/`](../examples/basic/).) ### No MCP server to test with? Helio ships a tiny echo server that speaks MCP over plain `node:http` — no dependencies, no install. Save it and run it on the default upstream port: ```bash curl -O https://raw.githubusercontent.com/gethelio/helio/main/examples/_shared/mcp-echo-server.mjs node mcp-echo-server.mjs ``` It prints `MCP echo server listening on http://127.0.0.1:8080` and exposes a handful of demo tools (`get_weather`, `send_email`, `delete_record`, `create_payment`, `create_refund`, `stripe_charge`, `paypal_payout`) with realistic annotations, so the policy examples below have something meaningful to match. Leave it running in its own terminal — the default `upstream.url` (`http://localhost:8080/mcp`) already points at it. No traffic to look at yet either? `npx @gethelio/proxy init --demo` writes a directory of sample traffic (a config, an audit database of 45 days of governed calls, a sample upstream) so you can see every surface with history behind it before your first real call; see [Sample traffic](./demo.md). ## Step 1: Install Scaffold a new configuration file: ```bash npx @gethelio/proxy init ``` This creates a `helio.yaml` in your current directory with commented defaults. Alternatively, install globally: ```bash npm install -g @gethelio/proxy helio init ``` `@gethelio/proxy` includes the dashboard assets in the same package. There is no separate dashboard npm install step. ## Step 2: Configure Open `helio.yaml` and set `upstream.url` to point at your MCP server. > **Helio starts in audit-only mode.** `helio init` scaffolds the `policies` section **commented out**, so out of the box Helio runs with `default: allow` and **zero rules** — it records every tool call but **blocks nothing** until you add rules. The example below is an **illustrative target** (not the file `init` writes); uncomment and adapt `policies` to start enforcing. See the [Policy Guide](./policies.md) for rule syntax. ```yaml version: '1' upstream: url: 'http://localhost:8080/mcp' transport: streamable-http listen: port: 3000 policies: default: allow # These rules match on MCP tool annotations (readOnlyHint / destructiveHint): rules: - name: block-destructive match: annotations: destructiveHint: true action: deny feedback: message: 'Destructive operations are blocked by policy.' suggestion: 'Use a non-destructive alternative.' - name: allow-reads match: annotations: readOnlyHint: true action: allow audit: storage: sqlite retention: 90d include_responses: true dashboard: enabled: true port: 3100 api_secret: '${HELIO_DASHBOARD_SECRET}' ``` Omitted fields like `listen.host`, `dashboard.host`, and `audit.path` fall back to safe defaults (`127.0.0.1` for both hosts — loopback only — and `./helio-audit.db`). The section order here (`upstream → listen → policies → audit → dashboard`) follows the canonical order of the [Configuration Reference](./configuration.md) and the `helio init` scaffold (which also stubs the remaining sections: `environment`, `session`, `budgets`, `approval`, and `sdk`). This example configuration blocks any tool marked as destructive, explicitly allows read-only tools, and falls through to `default: allow` for everything else. Order matters, since Helio applies the first rule that matches: a tool that sets `readOnlyHint: true` but omits `destructiveHint` is destructive by MCP default, so `block-destructive` denies it before `allow-reads` is reached, and `allow-reads` only reaches tools that also set `destructiveHint: false`, as the echo server's `get_weather` does. Every tool call is recorded to a local SQLite database. (Until you uncomment a `policies` block like this one, the scaffolded config blocks nothing.) If you use the `${HELIO_DASHBOARD_SECRET}` placeholder above, export it before starting: ```bash export HELIO_DASHBOARD_SECRET="$(openssl rand -hex 32)" ``` If you started from `helio init`, the generated `helio.yaml` already stores the digest of a secret that `init` printed once; keep that secret and skip the export. See the [Configuration Reference](./configuration.md) for all available fields and defaults. ### Already have MCP servers wired to a client? If Claude Code, Cursor or VS Code already reaches your servers through a project file (`.mcp.json`, `.cursor/mcp.json` or `.vscode/mcp.json` in the directory you are in), let Helio write the config for you: ```bash helio init --client ``` It backs each client file up, writes a `helio.yaml` whose `upstreams:` list holds every server it found, and repoints each entry at its Helio door. With a `.mcp.json` like this one: ```json { "mcpServers": { "files": { "command": "node", "args": ["./files-server.js"], "env": { "FILES_ROOT": "/tmp" } }, "github": { "type": "http", "url": "http://127.0.0.1:8087/mcp", "headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" } } } } ``` it prints: ``` Found 2 servers in 1 client config: .mcp.json: files (stdio), github (http) Backed up .mcp.json to .mcp.json.helio-backup Wrote .helio-init-client.json (undo reads it; tied to this directory) .helio-init-client.json and the .helio-backup files are local to this machine; do not commit them. Created helio.yaml (2 upstreams; doors at http://127.0.0.1:3000/mcp/) Copied env for files into helio.yaml (FILES_ROOT); the file now holds those values Set CLAUDE_PROJECT_DIR=/home/you/project for files in helio.yaml (Claude Code sets it for stdio servers; update it if the project moves) Rewrote .mcp.json: files, github now point at Helio Environment variables helio.yaml needs (helio start and helio policy status): GITHUB_TOKEN Dashboard secret (shown once; the file stores only its SHA-256 digest): <64 hex characters> Store it in your password manager. Use it to log in to the dashboard and as the Bearer credential for sideband API clients (127.0.0.1:3100 by default). If you lose it, run `helio secret`, paste the new digest into dashboard.api_secret, and restart the proxy. helio policy status needs this secret in HELIO_DASHBOARD_SECRET. Next: run `helio start`. Restart your MCP client. In Claude Code, run `claude` and approve the project servers (`claude mcp list` stays at pending approval until you do). Undo: from this directory, helio init --client --undo ``` The written `helio.yaml` is the `helio init` scaffold with a live `upstreams:` list in place of `upstream:` and a live `listen:` block, so the door URLs in the client file and the port in this file agree: ```yaml upstreams: - name: 'files' transport: 'stdio' command: 'node' args: - './files-server.js' env: FILES_ROOT: '/tmp' CLAUDE_PROJECT_DIR: '/home/you/project' - name: 'github' transport: 'streamable-http' url: 'http://127.0.0.1:8087/mcp' headers: Authorization: 'Bearer ${GITHUB_TOKEN}' listen: port: 3000 host: 127.0.0.1 ``` and `.mcp.json` now reads: ```json { "mcpServers": { "files": { "type": "http", "url": "http://127.0.0.1:3000/mcp/files" }, "github": { "type": "http", "url": "http://127.0.0.1:3000/mcp/github" } } } ``` Export the variables the `Environment variables` line names, run `helio start`, then restart your client. In Claude Code, project servers need an interactive approval: run `claude` and approve them, or `claude mcp list` stays at "pending approval". A Cursor entry is rewritten to `url` alone and a VS Code entry to `type: "http"` plus `url`, the shapes those clients document. `helio init --client ` adopts one named file instead, which is the only way a user-level file is touched: `helio init --client ~/.claude.json` rewrites the top-level `mcpServers` of Claude Code's user file (run it with no Claude Code session open; user-scope servers connect on the next start with no approval step, and per-project servers under `projects..mcpServers` are left alone). `claude_desktop_config.json` is refused: Claude Desktop reaches HTTP servers through Settings > Connectors, not that file. A second run refuses when it finds the manifest, a backup, or an entry that already points at a Helio door, so Helio never wraps Helio. To put everything back, from the same directory: ```bash helio init --client --undo ``` restores every backup byte for byte, removes the backups and the manifest, and leaves `helio.yaml` in place for you to delete. Edits made to a client file after the adoption are discarded by the restore. The manifest (`.helio-init-client.json`) and the `.helio-backup` files hold your original configuration, literal secrets included, and absolute paths: they are local to this machine, so keep them out of version control even though Claude Code tells teams to commit `.mcp.json`. What survives a rewrite: unrelated keys, their order and their values. Comments, trailing commas, and escape spellings do not, because the file is re-serialized (the backup has them), and a file whose values or key order would change is refused before anything is written. Substitution tokens are rewritten to what each client actually sends: Cursor's and VS Code's `${env:NAME}` becomes Helio's `${NAME}` and `${workspaceFolder}` becomes the directory; in a Claude Code file the command emulates Claude Code's own expansion, drops the credential names Claude Code reads as empty (`ANTHROPIC_API_KEY` and its siblings) and refuses an entry whose header or URL Claude Code would send with a bare placeholder in it. Every such decision prints one line. ## Step 3: See What Your Agent Can Reach, Before the Proxy Runs Before anything is running, ask the upstream itself what it exposes. With the echo server from the [prerequisites](#no-mcp-server-to-test-with) on port 8080: ```bash helio scan --upstream http://localhost:8080/mcp ``` ``` [helio] Upstream MCP era detected: legacy (initialize handshake) Scan of http://localhost:8080/mcp (streamable-http), 2026-09-29 08:11 UTC Authority surface: 7 tool-door pairs across 1 upstream, 1 annotated destructive Policy coverage: 0 of 7 have a rule that can match them, default allow no config loaded: pass -c helio.yaml to cross-check coverage Tools get_weather read-only (server) not destructive (server) allow no rule, default allow send_email not read-only (server) not destructive (server) allow no rule, default allow delete_record not read-only (server) destructive (server) allow no rule, default allow create_payment not read-only (server) not destructive (server) allow no rule, default allow candidate: amount $.amount create_refund not read-only (server) not destructive (server) allow no rule, default allow candidate: amount $.amount stripe_charge not read-only (server) not destructive (server) allow no rule, default allow candidate: amount $.amount paypal_payout not read-only (server) not destructive (server) allow no rule, default allow candidate: amount $.total Summary: 7 tools exposed, 1 destructive (0 by MCP default), 0 governed ``` One row per tool: whether the server marks it read-only or destructive, or whether that reading is the MCP default because the server set no hint; the action an argument-less call would get; the rule that decides it; and the arguments a rule could match (here an amount on each of the four payment tools). The command connects to the upstream, reads `tools/list` once and prints. It starts no proxy, opens no audit database and reaches nothing else, so it is safe to run against any server you can point a client at. The vocabulary, the candidates table, the JSON form and the failure lines are in [Scanning an upstream](./policies.md#scanning-an-upstream-helio-scan). A stdio upstream is scanned from its config with `helio scan -c helio.yaml`; a stdio server that requires `initialize` before `tools/list` (the Python SDK and FastMCP servers) fails the command with one `Error:` line until issue #256 lands. `helio scan --upstream http://localhost:8080/mcp --write starter.yaml` turns that report into a starter config: a `require_approval` rule for `delete_record`, an `allow` rule for `get_weather`, a commented budget over the four amounts, and a dashboard secret minted and printed once, exactly as `helio init` does. It passes `helio validate` as written; the [policy guide](./policies.md#scanning-an-upstream-helio-scan) describes the file. Run this against your own setup before you write a rule: the destructive count and the candidates are the rules waiting to be written. ## Step 4: Start the Proxy ```bash npx @gethelio/proxy start # or: helio start (if installed globally) ``` You should see output like: ``` [helio] Upstream MCP era detected: legacy (initialize handshake) [helio] Annotation cache primed: 7 tool definitions baselined for drift detection (0 restored, 7 new) Helio proxy listening on http://127.0.0.1:3000 Policies: 2 rules loaded (default: allow) Authority surface: 7 tool-door pairs across 1 upstream, 1 annotated destructive Policy coverage: 2 of 7 have a rule that can match them, default allow Upstream: http://localhost:8080/mcp (streamable-http) Audit: ./helio-audit.db (retention: 90d) Dashboard API listening on http://127.0.0.1:3100 Approvals: timeout 300s, default on timeout: deny, 0 channels configured Rate limits: enabled Spend limits: enabled Budgets: 0 configured Config: helio.yaml Watching helio.yaml for policy changes ``` The two lines after `Policies:` describe your system. `Authority surface` counts the tool-door pairs the agent can reach through Helio right now (one tool name on one upstream is one pair) and how many of them the upstream annotates `destructiveHint: true`; of the echo server's seven tools that is one, `delete_record`. `Policy coverage` says how many of those pairs have a rule that can match them: `block-destructive` matches `delete_record`, `allow-reads` matches `get_weather`, and the other five fall through to `default: allow`. With the scaffolded config (zero rules) the second line reads `0 of 7 have a rule that can match them, default allow`, and the no-enforcement warning prints above it. [Step 9](#step-9-see-what-your-agent-can-reach) prints the full report. The final `Watching` line prints once the config watcher is armed — an edit to `helio.yaml` made after it appears will be picked up (see [Hot Reload](./configuration.md#hot-reload)). > **Note:** Use `-c` to specify a different config file: `npx @gethelio/proxy start -c production.yaml` > > On startup, Helio also sends a synthetic upstream `tools/list` to warm the tool-annotation cache before first traffic. If the prime attempt cannot complete quickly, Helio continues startup and retries in the background. During that window, annotation matching remains fail-closed using MCP defaults. > > If `dashboard.enabled: true`, startup requires bundled dashboard assets to be present. If assets are missing, Helio exits with an actionable error instead of silently serving API-only mode. > > If `listen.port`, `dashboard.port`, or `sdk.port` is already in use, Helio exits with one line naming the setting, the host, and the port instead of printing its listening line, for example `listen.port 3000 is already in use on 127.0.0.1 (EADDRINUSE). Stop the process holding it, or set listen.port in helio.yaml to a free port.` With `-c`, the line names the config file you passed. ## Step 5: Point Your MCP Client at Helio Instead of connecting your MCP client directly to the upstream server, point it at the proxy on `http://localhost:3000/mcp`. With a named [`upstreams:`](./configuration.md#upstreams) list, each upstream is served at its own door instead: point the client at `http://localhost:3000/mcp/`. If you ran `helio init --client` in Step 2, the files it rewrote already point there. **Claude Code** (`.mcp.json` in the project root; a remote entry needs `type`): ```json { "mcpServers": { "my-server": { "type": "http", "url": "http://localhost:3000/mcp" } } } ``` **Cursor** (`.cursor/mcp.json`; a remote entry is `url` alone): ```json { "mcpServers": { "my-server": { "url": "http://localhost:3000/mcp" } } } ``` **VS Code** (`.vscode/mcp.json`; the top-level key is `servers`): ```json { "servers": { "my-server": { "type": "http", "url": "http://localhost:3000/mcp" } } } ``` Claude Desktop reaches HTTP servers through Settings > Connectors, not its config file. **Any HTTP MCP client** — change the server URL from `http://localhost:8080/mcp` to `http://localhost:3000/mcp`. The proxy is fully transparent: it forwards all MCP methods unchanged and only intercepts `tools/call` for policy evaluation. **No client or agent handy?** You don't need one to try Helio: - **MCP Inspector** — run `npx @modelcontextprotocol/inspector`, choose the Streamable HTTP transport, and point it at `http://localhost:3000/mcp`. Inspector reaches Helio through its own local backend process, which sends no `Origin` header, so this works as written; if you switch it to connect directly from the browser, the browser-sent `Origin` is rejected by design (`listen.allowed_origins` is an allowlist, not CORS support). Every tool call you make appears in the dashboard with its policy decision. - **curl**: send a request straight through the proxy (see [Step 7](#step-7-send-a-test-tool-call) below). This works against any upstream, including the echo server. ### Error normalization behavior For requests that pass JSON-RPC ingress validation at `/mcp`, Helio always returns a JSON-RPC response envelope. - Valid upstream JSON-RPC responses pass through unchanged at the body layer. - Upstream forwarding failures and non-JSON-RPC upstream payloads are normalized to: `{"jsonrpc":"2.0","id":,"error":{"code":-32603,"message":"...","data":{"failure_class":"..."}}}` - HTTP ingress validation errors (for example malformed JSON or wrong `Content-Type`) still return transport HTTP errors such as `400` or `415`. Their JSON-RPC error bodies omit the `id` member when no request id was readable, per the MCP 2026-07-28 error shape; when the invalid envelope carried a usable id, it is echoed. ## Step 6: Open the Dashboard Navigate to [http://localhost:3100](http://localhost:3100) to open the governance dashboard. If `dashboard.api_secret` is set (recommended/default), enter the dashboard secret on the login screen first: the value `helio init` printed, never the `sha256:` digest stored in the file. ![Dashboard Feed](./images/dashboard-feed.png) The dashboard has six tabs: - **Feed** — Real-time stream of tool calls and policy decisions - **Approvals** — Pending approval queue (for `require_approval` rules) - **Audit** — Searchable log of every recorded action with filters - **Limits** — Current rate and spend limit status per tool. With named upstreams, MCP tool gauges group by upstream door, while unprefixed keys (session, sideband, and singular tool buckets) stay in a leading block - **Budgets** — Named cross-tool spend pots with live depletion and the spend ledger - **Analytics** — Charts showing action volume, decision breakdown, and top tools. Each top-tools row is a (tool, upstream) pair — a same-named tool on two named upstreams stays two rows — and upstream is null in singular mode Every time the dashboard shows is UTC and says so (`2026-09-23 14:23:45 UTC` on rows and tickets, `Actions Per Hour (UTC)` on the chart), matching `helio policy status` and the activation report; the Audit tab's custom time range is entered in your local time and converted for the query. ## Step 7: Send a Test Tool Call With the proxy running, send a request through it: > If `jq` is not installed, remove the trailing `| jq` from the commands below. ```bash # List available tools curl -s -X POST http://localhost:3000/mcp \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | jq ``` ```bash # Call a tool curl -s -X POST http://localhost:3000/mcp \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_weather","arguments":{"city":"London"}}}' | jq ``` The tool call passes through the policy engine, gets forwarded to the upstream server, and the response is returned — with an audit record created in the background. ![Dashboard Test Call](./images/dashboard-test-call.png) ## Step 8: Try a Policy Rule Add a rule to your config and watch hot-reload pick it up. With the proxy still running, edit `helio.yaml` and add a deny rule at the top of `policies.rules`, before the existing rules: ```yaml policies: default: allow rules: - name: block-email match: tool: 'send_email' action: deny feedback: message: 'Email sending is disabled.' suggestion: 'Contact your admin to enable email.' # ... your existing rules (block-destructive, allow-reads) continue here ``` Save the file. The proxy detects the change and reloads: ``` [helio] Budgets reloaded: 0 budgets [helio] Policy reloaded: 3 rules (default: allow) [helio] Policy coverage: 3 of 7 have a rule that can match them, default allow ``` The coverage line is reprinted after every reload: `send_email` now counts among the pairs a rule can match. Check the rule count. It should now read `3 rules`: the two rules from Step 2 plus the new one. If the proxy instead prints `Config reload failed (keeping current configuration)` with `Unrecognized key: "rules"`, the rule did not land inside `policies.rules` — a `rules:` key at the top level of the file is rejected, and the proxy keeps enforcing the previous 2 rules until you fix the nesting. Now try calling the blocked tool: ```bash curl -s -X POST http://localhost:3000/mcp \ -H 'Content-Type: application/json' \ -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"send_email","arguments":{"to":"alice@example.com","body":"Hello"}}}' | jq ``` The response includes a structured error with the feedback message and suggestion — information an AI agent can use to self-correct. Cross-check the file against the live surface without touching the running proxy: ```bash helio scan -c helio.yaml ``` The coverage line now reads `3 of 7 have a rule that can match them, default allow (helio.yaml)`, each row names its rule (`rule "block-email"` on `send_email`), and a section headed `Rules that match no tool on this upstream` lists any rule whose `match.tool` matches nothing the server exposes, a warning that never blocks anything. ## Step 9: See What Your Agent Can Reach With the proxy still running, ask it for the full report. `helio scan` in Step 3 asked the upstream and needed no proxy; `helio policy status` asks the running proxy for the same surface plus what the audit store holds: ```bash helio policy status ``` It prints the surface the proxy primed at startup, what the loaded rules cover, and what the audit store holds for the last four hours. After Step 8's rule and the two calls above, it reads: ``` Authority surface 7 tool-door pairs across 1 upstream 1 annotated destructive Policy coverage 3 of 7 have a rule that can match them 4 fall through to the default: allow Effective action: allow 5, deny 2 on_tool_drift: block Persisted (last 4h) 2 calls across 2 tool-door pairs, 0 sessions (denied and dry-run calls included) 4 reachable and permitted, never called in the last 4h audit rows since 2026-09-21 Readiness: suppressed, the policy enforces something (2 calls across 2 tool-door pairs in the last 4h) Tool-door pairs (calls in the last 4h) get_weather upstream allow rule "allow-reads" 1 send_email upstream deny rule "block-email" 1 delete_record upstream deny rule "block-destructive" 0 create_payment upstream allow no rule, default allow 0 create_refund upstream allow no rule, default allow 0 stripe_charge upstream allow no rule, default allow 0 paypal_payout upstream allow no rule, default allow 0 ``` `--format json` prints the same report as JSON for scripts, and `--window 7d` widens the persisted window (1 minute to 30 days; the window is named on every persisted line). The vocabulary is defined in [Policy coverage](./policies.md#policy-coverage-helio-policy-status). > **The secret.** `helio policy status` reads the running proxy through its dashboard API on `dashboard.host:port`, because the primed surface exists only in that process. The dashboard must be enabled, and the command needs the secret itself: it reads `HELIO_DASHBOARD_SECRET` from the environment (the value you exported in Step 2, or the one `helio init` printed), else a plaintext `dashboard.api_secret` from the file. The `sha256:` digest `helio init` writes into the file is refused with one line before any request, because the API verifies the secret, not its digest. In a terminal where the file reads `${HELIO_DASHBOARD_SECRET}` and the variable is not exported, the command refuses with one line before any request: `Error: HELIO_DASHBOARD_SECRET is not set and helio.yaml reads dashboard.api_secret from it. Export it to the secret helio init printed (or the value you exported before helio start) and rerun helio policy status.` ### Try a rule before it lands Before you add a rule to the file the proxy loads, replay the calls it has already recorded against a copy of the config that carries the rule. `helio policy simulate` reads the audit database on disk and needs no running proxy: ```bash cp helio.yaml helio.candidate.yaml # then add the rule to helio.candidate.yaml helio policy simulate # replays the trail against helio.candidate.yaml ``` The candidate is any complete `helio.yaml`; with no argument the command takes the one `helio.candidate.yaml` (or `.yml`) in the working directory, and refuses when there are two. Over a trail of ten sample calls recorded under a file with `default: allow` and no rule at all (a fresh install, before Step 2's two rules and Step 8's `block-email` existed: `get_weather`, `send_email` and `delete_record`, every call allowed, as Step 7's call was), with a candidate that adds `block-email`, it prints: ``` Policy simulation Candidate: helio.candidate.yaml Written by Helio 0.0.0 (unreleased build) on 2026-10-07 (UTC). Window: the whole trail Epoch: the most recent config epoch, 10 calls, 2026-10-06 14:00 UTC to 2026-10-06 14:09 UTC Annotations: the audit trail Baseline: no restrictive rules (default allow) This is your first policy, so every restriction is new. 3 calls would have been denied (policy_denied 3) 7 unaffected Changed decisions, by tool and rule tool "send_email": allow -> deny (policy_denied), rule "block-email": 3 calls, 2026-10-06 14:01 UTC to 2026-10-06 14:07 UTC Fidelity Skipped 0 row(s) that never entered policy evaluation: 0 rejected, 0 refused by the kill switch. 0 sideband evaluation(s) were decided but never reported (evaluation_expired); their outcome is unknown. No live tools were called. Nothing was applied. ``` The first-policy framing prints because every recorded decision was an allow under a file with no rule and the candidate changes some of them; a candidate that changes nothing keeps the `Baseline:` line and prints `N unchanged, 0 changed` under it, and once a rule is in force the report reads `N unchanged, M changed` with no baseline line, the changed decisions grouped the same way. The two `Fidelity` lines are the frozen sentences of [Simulation fidelity](./policy-fidelity.md), printed on every run; a warning there names a rule the replay could not fully evaluate and the calls it counts as unverified. `--format json` prints the same report as JSON, `--fail-on-change` exits 2 when any decision would change (a CI gate), and `--since 7d`, `--until`, `--upstream` and `--session` bound the window. Every run writes one `policy_simulation` record into the audit database it replayed, keyed by the candidate's hash, so the trail shows what was simulated when (see [Policy Simulation Records](./audit.md#policy-simulation-records)); the open purges rows older than the deployed `audit.retention` first, as `helio report activation` does, and the report says so whenever it deleted any. Inside a `helio init --demo` directory, `helio policy simulate --demo` replays the sample corpus against its own config with the sample server's listed definitions as the annotation source and reports zero changed decisions over the most recent of its three config epochs. The command's reference, with the CI example, is [Policy simulation](./policy-simulate.md). ## Step 10: Share What Happened You now have a history: two governed calls, one rule that fired, one that was added live. To show that to someone who has never seen this machine (a team lead, the Helio maintainers, a forum thread), write the activation report: ```bash helio report activation ``` It reads the audit database on disk for a timeline and the last seven days of counts, asks the running proxy for the same coverage snapshot `helio policy status` prints, and renders one report. Over the fixture of this guide, after Step 7's call and Step 8's rule and denied call, it reads: ``` Helio activation report Written by Helio 0.0.0 (unreleased build) on 2026-09-27 (UTC). Names: excluded (--include-names restores tool, door and rule names). Counts cover the last 7d; dates are within the audit retention of 90d. Sources: the audit database (read; this config file is the one that last wrote policy to it). The running proxy answered on the configured dashboard port (snapshot below); this command does not verify that it wrote this database. Kill switch: not active, from the proxy. Timeline (dates within retention) First call observed 2026-09-27 earliest persisted tool call First rule 2026-09-27 first call a rule decided Rules present at the first start, or edited between runs, leave no reload record; a rule is visible here only once it decides a call or arrives by a live reload. First generation none: no policy_generation record within retention First simulation not available in this version First apply not available in this version First enforcement decision 2026-09-27 first blocked call (policy_denied) Persisted (last 7d) 2 calls across 2 tool-door pairs, no session ids recorded (denied and dry-run calls included) Decisions: 1 permitted, 1 blocked (policy_denied 1), 0 dry-run, 0 approvals requested Config versions seen: 2 Config reloads: 1 (1 applied) audit rows since 2026-09-27 Snapshot (running proxy, 2026-09-27 17:59 UTC, window 7d) Authority surface: 7 tool-door pairs across 1 upstream; 1 annotated destructive; 0 destructive by MCP default Policy: 3 rules, default allow, on_tool_drift block Policy coverage: 3 of 7 have a rule that can match them; 4 fall through to the default: allow Effective action: allow 5, deny 2 Reachable and permitted, never called in the last 7d: 4 (from the proxy) Called on no primed door in the last 7d: 0 (from the proxy) Readiness: suppressed, the policy enforces something (2 calls across 2 tool-door pairs in the last 7d) Doors: 1 upstream primed, 0 not primed, 0 without annotations, 0 adapter origins ``` Every line is meant to be pasted as it stands: the default output carries counts, dates and decision classes and no tool, rule, session or door name, no path, no host and no config hash. `--include-names` restores tool, door and rule names (never session ids) and the header line says so. A release prints its version on the first line in place of `unreleased build`. `--format json` renders the same object for scripts, `--out ` writes the same bytes to a file, and `--window` widens or narrows the counts (1 minute to 30 days). Without a running proxy the timeline and the counts still print and the header names the missing snapshot in one sentence. The full contract, the timeline's sources and the cost of the two retention-wide reads are in [Activation report](./audit.md#activation-report-helio-report-activation). Sharing the file is your action: Helio writes it to your terminal or to the path you name and transmits nothing anywhere. ## Docker You can also run Helio with Docker Compose. One-time setup: ```bash cd docker cp .env.example .env openssl rand -hex 32 # paste this into .env as HELIO_DASHBOARD_SECRET docker compose up ``` This starts the proxy, a demo MCP echo server, and the dashboard. By default both published ports are bound to `127.0.0.1` on the host (not the LAN) so the quickstart is safe on untrusted networks. See [`docker/README.md`](../docker/README.md) for the security model and how to opt in to LAN or remote access. To run Helio in its own container **next to a coding agent or dev container** — so the agent is forced through governance and can't reach the MCP server directly — see [Running Helio as a Sidecar](./deployment-sidecar.md). ## Production Checklist The local steps above (`helio start`) run the proxy as the same user as your agent. That is the detection-only tier described in [SECURITY.md](../SECURITY.md#process-and-filesystem-boundaries): any process running as that user, including a coding agent with file and shell tools, can change policy live and read the config file; a `helio init` config holds only the digest of the dashboard secret, so the file no longer yields the secret, but a plaintext value in the file, or one supplied through the proxy's environment, is still readable. The proxy says so at startup: when the config file is writable by the user it runs as, `helio start` prints an `Enforcement posture` line naming the two ways up a tier. That is fine for trying Helio on your own machine. Before trusting it against an adversarial or prompt-injected agent, and before running it anywhere reachable from other people or machines, work through this checklist: - **Generate and store a strong dashboard secret.** `helio init` prints a fresh 256-bit secret once and writes only its SHA-256 digest (`sha256:...`) into `helio.yaml`; if you authored the file by hand, run `helio secret` and paste the printed `digest:` value into `dashboard.api_secret`. The secret itself is what you type at the login screen and send as the Bearer credential; the file never needs to hold it. Treat it like a password-manager secret: if you lose it, run `helio secret` again, replace the digest, and restart the proxy. A plaintext value in the file still works and draws a startup warning. - **Understand browser login behavior.** The dashboard UI uses a manual secret login screen: enter the dashboard secret once (the secret itself, never the `sha256:` digest from the file), then the browser uses a short-lived HttpOnly session cookie. The raw secret is not injected into frontend JS. - **Keep `dashboard.host` on `127.0.0.1`.** The dashboard sideband assumes a single trust boundary and has no identity layer of its own. If you need to reach it remotely, put it behind a reverse proxy (nginx, Caddy, Cloudflare Access, Tailscale Serve) that terminates TLS and adds per-user authentication before forwarding to `127.0.0.1:3100`. Do not bind `dashboard.host` to `0.0.0.0` directly. - **Keep the audit database on local disk.** Helio's audit sqlite file is created with mode `0600` (owner read/write only) — if you move it to a shared filesystem or back it up into an unencrypted bucket, you lose that isolation. Audit records contain tool inputs and upstream responses, often including PII and credentials. - **Rotate secrets deliberately and expect session invalidation.** The proxy reads `dashboard.api_secret` from disk at startup only. If you change it while the proxy is running, Helio logs a restart-required warning and keeps using the startup value (see the [reload boundary](./configuration.md#reload-boundary)). Run `helio secret`, paste the new `digest:` value into `helio.yaml`, then restart to rotate. Existing dashboard sessions are revoked (they are signed with a key the proxy generates at startup and never survive a restart) and users must log in again with the new secret. - **Keep the main MCP port behind the same trust boundary as the dashboard.** `listen.host` defaults to `127.0.0.1`; if you need remote agents, use a reverse proxy or SSH tunnel rather than binding to `0.0.0.0`. The main port does not carry the dashboard secret — it accepts MCP traffic based on network reachability alone. - **Run the proxy as its own user for anything adversarial.** Give the proxy a dedicated OS user or its own container, keep `helio.yaml`, the audit database, and the dashboard secret out of every directory your agent can reach, and delete the copy of `helio.yaml` you initialized in the workspace once the live file lives elsewhere. Your agent keeps only the network hop. The [separate-user recipe](./deployment-separate-user.md) walks through it on Ubuntu 24.04, the [sidecar recipe](./deployment-sidecar.md) does it with containers, and [SECURITY.md](../SECURITY.md#process-and-filesystem-boundaries) lists the four questions that tell you which tier you are on. ## Next Steps - [Configuration Reference](./configuration.md) — Every `helio.yaml` field with defaults and types - [Running Helio as a Sidecar](./deployment-sidecar.md) — Deploy next to a coding agent or dev container with the upstream and the config out of its reach and Helio off its network; `helio init --sandbox` writes the layout - [Sample traffic](./demo.md): See the report, the dashboard and `helio policy status` over 45 days of sample history before your first real call; `helio init --demo` writes it - [Running Helio as its own user](./deployment-separate-user.md) — The separate-user tier on Ubuntu 24.04, run end to end - [Policy Guide](./policies.md) — Rule syntax, matchers, actions, rate limits, spend limits, and common patterns - [Approval Workflows](./approvals.md) — Route sensitive actions to humans via Slack, webhook, or dashboard - [Audit Trail](./audit.md) — What's recorded, how to search, and how to export - [Examples](../examples/) — Five runnable configurations: basic, slack-approvals, spend-limits, budgets, multi-upstream