Agent Console¶
flux agent start opens mission control for your agents: every session in
one rail, the open session's transcript in the middle, and its plan, tool
activity and sub-agents on the right — with the approvals queue one keystroke
away.
The console is a client of the Flux server, not part of it. It runs as your
process, with your token, and reads the same REST/SSE surface any other client
would. Two surfaces share one core (flux/agents/console/): a Textual terminal
app and a browser app.
Quick start¶
flux agent start # console, every agent's sessions (terminal)
flux agent start reviewer # console focused on a reviewer session
flux agent start --mode web # same console in the browser (port 8080)
flux agent start --mode api # headless: JSON + SSE, no renderer
flux agent session resume <id> # console opened straight into a session
NAME is optional everywhere. Given, it filters the session rail to that
agent (and labels the header) and opens focused on one of its sessions —
the most recent one still going, or a fresh one when there is none. Omitted,
the console is rail-first and shows every agent's sessions. Either way you
pick the agent when you start a new session, so one console can drive them
all.
Terminal mode needs a real terminal. When stdout is not a TTY (piped,
scripted, CI) flux agent start NAME falls back to the plain single-agent
REPL, and a NAME-less invocation fails loudly rather than opening a console
that cannot draw. --plain (or FLUX_PLAIN_TERMINAL=1) forces that REPL
explicitly and requires a NAME.
That check applies to terminal mode only: web and api render in a browser
or over HTTP, so they start fine from a service manager, container or CI with
no TTY attached.
Options: --mode terminal|web|api, --port, --host (default 127.0.0.1),
--allow-remote, --allow-origin, --server, --session <id>, --plain.
The token comes from $FLUX_AUTH_TOKEN, else the credentials stored by
flux auth login.
The layout¶
Both surfaces are the same three regions: rail, stage, context.
Terminal¶

- 1 sessions — every session grouped
ACTIVE/IDLE/DONE/FAILED, one glyph per state (●running,◔waiting on an approval,◐idle,○done,✗failed).IDLEis where a healthy session spends most of its life: paused, waiting for your next turn. Failed sessions get their own group; they are never dimmed in with the finished-normally ones. - 2 chat — the transcript: your turns, the agent's replies, and each tool call as a collapsible row.
- 3 context —
PLANwith a done/total hero figure,ACTIVITY(tool calls with live timers),SUB-AGENTS(delegations, expanded while running). - The status line carries the hotkeys, the open session's title, its state and the pending-approval count.
Below 100 columns the rail and context panels fold away and the hotkeys still switch between them; below 80 the status line goes compact.
Web¶


Same regions, plus a top bar with the running count, the approvals drawer
button, + new session, the server URL and a theme toggle. The theme follows
prefers-color-scheme on first load and remembers your choice after that.
Keyboard¶
The terminal console's grammar is hotkeys, not focus-follows-typing — the composer only holds focus while you are actually writing.
| Key | Action |
|---|---|
1 |
Focus the session rail |
2 |
Focus the chat transcript (scroll it; the composer is not focused) |
3 |
Focus the context panel |
enter |
On the rail: open the highlighted session. In chat: step into the composer |
escape |
Leave the composer (or close an overlay) and step back to the panel |
n |
New session — pick an agent, optionally name it |
a |
Approvals overlay — approve/reject, including the standing-grant options |
e |
Answer the open session's MCP authorization prompt (accept/decline/cancel) |
r |
Rename the open session |
x |
Stop the highlighted session — press twice; a cancel is not undoable |
ctrl+d |
Quit |
In the browser: Enter sends, Shift+Enter inserts a newline, Escape
closes the approvals drawer, the new-session modal, or an armed stop. The
stage header carries the rename pencil and, for a session that has not
finished, a ◼ stop button that asks for a second click before it fires.
What the console shows, and when¶
The persisted execution log is the source of truth; the live SSE stream is an overlay on top of it.
- Opening a session renders its transcript, tool calls and approval gates from
GET /executions/{id}?detailed=true— the redacted read path. - During a turn, progress frames overlay live tokens, reasoning, tool start/done ticks, plan revisions and sub-agent lifecycle.
- Every turn ends with one reconciliation read, so a dropped frame or a
disconnected browser never leaves a stale transcript behind. A stream that
ends at
PAUSEDis normal: that is the steady state between turns. - Plans and sub-agent cards come only from progress frames (progress is never persisted), so they are live-only detail — reopening a session replays its log, not its plan.
- Rail rows for sessions you have not opened show only what the cheap listings carry (agent, state, age, pending-approval flag). Step counts appear on the open session. This is a deliberate v1 limit — no per-row detail fetches.
Tool calls are ordinary Flux tasks, so the log carries their real names, args
and outputs, and a duration for every call whose start and terminal events are
both present — which the engine records per call, including calls that begin
in a resumed run. One shape is worth knowing about because the console handles
it for you: task outputs are stored as output-storage envelopes rather than
bare values, so the console unwraps inline outputs and names a value kept
outside the log (e.g. local_file storage) rather than dumping it.
Permissions¶
The console never holds authority of its own — every action is the corresponding server call, made with your token.
| Console action | Server call | Permission required |
|---|---|---|
| Rail listing, agent picker | GET /agents/sessions, GET /admin/agents |
agent:*:read |
| Open a session (transcript, activity, gates) | GET /executions/{id}?detailed=true |
execution:*:read and workflow:{ns}:{wf}:read |
| Approvals queue | GET /approvals |
results are scoped to the workflows you can read (workflow:{ns}:{wf}:read); a token holding no workflow read grant at all gets a hard 403 naming workflow:*:*:read, not an empty list |
| New session | POST /workflows/{ns}/{wf}/run/stream |
workflow:agents:{workflow}:run (plus workflow:agents:{workflow}:task:{task}:execute for the workflow's declared tasks) |
| Send a turn | POST /workflows/{ns}/{wf}/resume/{id}/stream |
same as starting a session |
First spawn of an agent shipping a workflow_file |
POST /workflows |
workflow:{ns}:*:register, where {ns} is the namespace the agent's workflow_file source declares — agents for anything the console generates, but an agent shipping a workflow that declares its own namespace needs the grant for that one |
| Approve / reject | POST /executions/{id}/approvals/{call}/approve\|reject |
workflow:{ns}:{wf}:read and workflow:{ns}:{wf}:task:{task}:approve |
| Rename a session | PUT /executions/{id}/name |
workflow:{ns}:{wf}:run — naming an execution is a run-level act, deliberately the same grant as cancelling it |
| Stop a session | GET /workflows/{ns}/{wf}/cancel/{id} |
workflow:{ns}:{wf}:run |
Console sessions live in the agents namespace, on agent_chat or on the
agent's own agent_custom_<name> workflow: ConsoleService.spawn passes
namespace="agents" when starting one, so every row above that names
workflow:agents:... is reading that contract rather than a default. The
registration row is the exception — the namespace there comes from the
uploaded source's own decorator, not from the console.
Read-only degradation¶
A token that can read but not write gets a usable console, not a broken one. Every write control — send, new session, rename, stop, approve — is disabled and labelled with the permission it is missing, rather than failing when you press it.
The web and headless surfaces learn this before you touch anything: the first
GET /console/state runs a side-effect-free probe (a cancel against an
execution id that cannot exist, which the server authorizes before it looks
anything up) and returns can_write plus missing_permission. Naming the
permission there is the only chance to learn it — once every control is
disabled the page can never provoke a 403 of its own. The answer is tracked
per token, never per process, so in api mode one caller's denial never
degrades the console for another.
The terminal console runs in-process with no /console/state to ask, so it
learns the same thing from the first denied write and then dims its write
hints in the status line. A write denied later (a grant revoked mid-session)
flips the switch on either surface.
Headless (--mode api)¶
--mode api serves the console core with no renderer: JSON and SSE for
scripts, tests, or a UI of your own. Every request carries a Bearer token that
is passed through to the Flux server per request, and every state-changing
request additionally carries X-Flux-Console: 1 — a header a cross-origin
page cannot set without a preflight, which is what keeps a random website from
driving your console. When the browser sends an Origin, it must also match
the console's own.
| Method | Path | Notes |
|---|---|---|
GET |
/console/state |
Bound agent, server URL, can_write, missing_permission |
GET |
/console/agents |
Agents available to spawn, projected to name/model/description — never the full definition |
GET |
/console/sessions |
Rail rows (derived_title is the cached display fallback). One server page — the count behind it rides on the X-Flux-Session-Total response header, and both UIs offer a "load the rest" control (widens the page with ?limit=; survives refreshes) when the two differ |
GET |
/console/approvals |
Pending approvals across executions |
GET |
/console/sessions/{id}/detail |
The execution's detailed log |
POST |
/console/sessions |
{"agent": "...", "name": "..."} → {"execution_id": ...} |
POST |
/console/sessions/{id}/send |
{"text": "..."} → SSE; every frame carries the session_id it belongs to, and the turn always ends with one log_delta frame |
POST |
/console/approvals/{execution_id}/{task_call_id} |
{"approve": true, "always": false, "always_for_target": false} → {"result": "decided"\|"already_decided"} |
POST |
/console/sessions/{id}/elicitation |
{"payload": {...}} |
PUT |
/console/sessions/{id}/name |
{"name": "..."} |
POST |
/console/sessions/{id}/stop |
Cancels the session's execution |
curl -s -X POST http://127.0.0.1:8080/console/sessions \
-H "Authorization: Bearer $FLUX_AUTH_TOKEN" \
-H "X-Flux-Console: 1" \
-H "Content-Type: application/json" \
-d '{"agent": "reviewer"}'
web mode serves the same endpoints backed by the operator token set at
process start, so the browser never handles a credential.
Trust model¶
The console is single-operator by design: it binds 127.0.0.1, holds one
operator token, and does no authentication of its own — authorization is
entirely the Flux server's, per call. Three things follow.
Exposure is a deliberate act. In web mode a non-loopback --host exits
1 unless you also pass --allow-remote, because reaching the port is the
authorization: anyone who can connect lists every session, reads every
transcript, spawns agents, approves gated tasks and cancels executions. When
you do pass it, the console says so at startup. Put a proxy that authenticates
in front, or use --mode api, which requires a Bearer on every request and is
the right shape for remote or scripted use.
The console answers only to its own name. Bound to loopback, web mode
rejects any request whose Host header names something else with a 400. That
covers reads as well as writes, because DNS rebinding is a read attack: a page
you visit resolves its own domain to 127.0.0.1 and, without the check, the
browser would treat it as same-origin with your console. The check is dropped
under --allow-remote — once the port is genuinely reachable, rebinding buys
an attacker nothing.
Browsers must prove they are the console's own frontend. Every
state-changing request needs X-Flux-Console: 1, which a third-party page
cannot set without a CORS preflight this app never grants, and — when a browser
sends an Origin — a match against the allowlist. If you serve the console
under a hostname, add it with --allow-origin (repeatable): a wildcard bind
like 0.0.0.0 never appears in an Origin header, so without an explicit
entry a state-changing request from that hostname is rejected with 403.
None of this constrains other processes on your own machine, which can set any header they like. On a single-operator box local code already runs as you; closing that would need an OS-level boundary no browser can speak.
Known follow-ups¶
Pre-existing issues found while building the console, tracked separately:
- Progress SSE bypasses secret redaction. The REST read paths the console renders from are redacted; the server's live progress stream is not, so a secret value emitted inside a progress payload reaches a connected client unredacted.
flux agent stoptargets a route that does not exist. It POSTs to/executions/{id}/canceland 404s. The console's stop button uses the working workflow-scoped cancel route; from the CLI useflux workflow cancel agents/<workflow> <id>.- Eager
AgentManagerimport influx/agents/__init__.py. It pulled the whole agent stack into anyflux.agentsimport, which no honest agent-process import budget survives. Fixed on this branch (the package imports lazily now), noted here because released versions still carry it. /chat,/approvaland/elicitationwere drive-by callable. The pre-console web UI accepted cross-site POSTs on those routes. They are removed on this branch — the console's per-session/console/*routes replace them, behind theX-Flux-Console+ Origin check described above — noted for awareness of released versions.
See also¶
- Agent Harness — defining agents, tools, serving modes.
- Agent Skills — filesystem-defined agent capabilities.
- Task Progress Streaming — the progress primitive behind the live overlay.