An embedded web dashboard with zero dependencies — no Phoenix, no Plug, no JS build. One child spec:
children = [
{Belay, name: MyApp.Belay, ...},
{Belay.Dashboard, belay: MyApp.Belay, port: 4004}
]Then open http://localhost:4004 — live queue tiles, a filterable job list, a job drawer with the full journal (steps with costs, events, errors, children), a rendered workflow DAG, and operator actions: retry, cancel, signal, and steer.
Options:
:belay(required) — the instance name:port— default 4004 (0picks an ephemeral port; seeport/1):bind— default{127, 0, 0, 1}; bind{0, 0, 0, 0}only behind a proxy you trust:token— when set, every request must carry it (Authorization: Bearer <token>;?token=is accepted only for GET requests so browserEventSourcecan authenticate):authorizer— gates the mutating endpoints exactly like the MCP server's authorizer:authorize(tool, args) -> :ok | {:error, msg}:allowed_hosts— theHostvalues this dashboard will answer to. Defaults to the loopback names when bound to loopback (the default), and to:anyotherwise, since a proxied deployment is addressed by the proxy's name. Set it explicitly (["belay.internal"]) when you bind beyond loopback;:anydisables the check.
Mutations are disabled by default. Configure either :token or
:authorizer to enable them. Mutating requests must use JSON and, when an
Origin header is present, it must match the request's Host header.
Host checking
Comparing Origin to Host cannot tell a genuine localhost request from
a DNS-rebinding one, because an attacker-controlled name pointed at
127.0.0.1 satisfies both sides. :allowed_hosts is what actually pins the
dashboard to the names you expect.
The server speaks plain HTTP/1.1 over gen_tcp with Erlang's built-in
request parsing; live updates stream over SSE. It is an operator tool:
no TLS (front it with your proxy for remote access) and one connection
per request.
Summary
Functions
@spec port(GenServer.server()) :: :inet.port_number()
The port the dashboard is listening on (useful with port: 0).