Interface — TUI & web UI
agentd ships two display clients — a terminal UI and a web UI — built as
separate Node projects under interface/. They are thin by
design: agentd hosts all state, tools and secrets; the clients
only render daemon state and forward your intent. Open both at once — plus a
colleague's browser — and every surface shows the same conversation, tasks and
runs, live, because each one watches the same daemon feed. None of them holds
any truth of its own.
1. Enable it
The interface is off by default. Turn it on in the config:
a2a:
listen: http://127.0.0.1:8420 # the interface rides the A2A listener
interface:
enabled: true # serve the display-client surface
debug: false # extra information (see §5)
With enabled: false the daemon serves no interface surface at all — the event
feed and the interface reads answer with a clear "the interface surface is
disabled" error, and nothing is buffered for them.
Auth is the A2A listener's: on a plaintext loopback listener with no
principals a local client is the operator with zero setup; a remote client
presents a2a.bearer / an mTLS identity and sees only what its role and
ownership allow.
2. One command: agentd tui / agentd ui
The passthrough runs the daemon and its display client together:
agentd tui --config code.yaml # daemon + terminal UI
agentd ui --config code.yaml # daemon + web UI (opens the browser)
agentd tui --config code.yaml --debug # …with the debug surface on
The subcommand forces interface.enabled on, redirects the daemon's log lines
to a file (the path is printed first; AGENTD_INTERFACE_LOG overrides), hands
the terminal to the client, and ties the lifetimes: quitting the client drains
the daemon gracefully; the daemon exiting closes the client. The client binary
is found on PATH (npm install -g @agentd-dev/cli, which ships both;
AGENTD_TUI_BIN / AGENTD_UI_BIN override).
3. Detached: connect to any running agentd
Run the daemon on its own and attach displays whenever you like:
agentd --config code.yaml # the daemon
agentd-tui --endpoint http://127.0.0.1:8420 # a terminal, any time
agentd-ui --endpoint http://127.0.0.1:8420 --open # a local web UI
-
agentd-tuiflags:--endpoint(orAGENTD_ENDPOINT),--bearer(orAGENTD_BEARER),--code(pairing login, §4.3),--debug(open on the debug screen),--inline(§3.1),--insecure(self-signed dev TLS). -
agentd-uiserves the built web app on127.0.0.1:4173(--port) with the endpoint pre-filled;--openlaunches the browser. The page also takes?endpoint=…and remembers your last connection. -
Hosted web UI:
interface/dist/web/is a static site — deploy it anywhere, then allow its origin on each daemon it should reach:interface: enabled: true origins: ["https://ui.example.com"]Loopback origins (an
agentd-uion the same machine) never need listing. Any other cross-site origin remains rejected (the DNS-rebinding guard). -
Connecting to a remote daemon is the same
--endpoint https://…plus its bearer; everything a client can see or do is decided by the daemon's principal rules, not by the client.
3.1 Fullscreen (default) vs --inline
The TUI takes over the terminal — the alternate screen, like vim or
htop — so the layout is stable and your shell is restored untouched when you
quit. Since the alternate screen has no scrollback of its own, the client
owns it: PgUp / PgDn scroll the conversation, and a hint shows how many
messages are above the fold. New messages follow the live end unless you have
scrolled up, in which case your position holds until you PgDn back to the
bottom.
agentd-tui --inline (or AGENTD_TUI_INLINE=1) renders into the normal
buffer instead: settled messages go into your terminal's real scrollback
and stay there after you quit — handy for copying a session, piping, or
keeping the transcript in your shell history. A non-interactive run (a pipe,
CI) degrades to inline automatically.
4. The screens
Four screens, cycled with Tab (or jumped to with /chat, /tasks,
/subagents, /debug). The web UI has the same four as tabs.
Every frame below is the real program — rendered by the shipped TUI against
a mirror driven with daemon-shaped events, captured by
interface/tools/frames.mjs. They regenerate from the code, so they cannot
describe a screen the shipped client does not draw.
Chat — talking to the agent
agentd 1.3.2 triage-1 debug ▌ Triage the newest issue ⠋ working ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ › │ ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ ● live http://127.0.0.1:8420 1 active [chat] tab:screens esc:cancel /:cmd ^c:quit
The transcript shows every attached client's prompts (labelled by principal when
they are not yours), command invocations and replies, so two people watching one
agent see the same conversation. The line under it is the live activity: what
the daemon is doing right now, ticking its own clock. Esc cancels the newest
working task.
Subagents — the tree, and control over it
A subagent is a real child process the supervisor owns. The list is live:
agentd 1.3.2 triage-1 debug handle mode status tokens updated ▸ sa-review supervised running 4120 0s sa-lint detached failed 260 0s ↑/↓ select · enter details · tab next screen ● live http://127.0.0.1:8420 [subagents] tab:screens esc:cancel /:cmd ^c:quit
↑/↓ selects, Enter opens it. The detail view is where the verbs are:
agentd 1.3.2 triage-1 debug subagent sa-review status running mode supervised attempt 1 tokens 4120 instruction Review the diff for correctness regressions and report findings. requested_by operator m message · k stop · esc/backspace back · tab next screen ● live http://127.0.0.1:8420 [subagents] tab:screens esc:cancel /:cmd ^c:quit
m— message it. Opens the composer pre-addressed to this subagent, so talking to a running child is one keystroke rather than a/send <handle> …you have to retype the handle into. Only a warm subagent can be messaged; the view says so when it cannot.k— stop it. The supervisor owns the process group, so this is a real kill rather than a request the child can decline. It asks first, because stopping is not undoable:
agentd 1.3.2 triage-1 debug subagent sa-review status running mode supervised attempt 1 tokens 4120 instruction Review the diff for correctness regressions and report findings. requested_by operator stop sa-review? y to confirm, any other key to cancel ● live http://127.0.0.1:8420 [subagents] tab:screens esc:cancel /:cmd ^c:quit
instruction and result need interface.debug; without it the view shows the
summary the feed carries and says which fields are missing and why.
Debug — what the daemon is doing
agentd 1.3.2 triage-1 debug feed 1 run {"id":"pipeline-01M0C0","workflow":"pipeline","status":"running","steps":"3/7"} 2 step {"run":"pipeline-01M0C0","step":"fetch","kind":"mcp.tool","phase":"start"} 4 step {"run":"pipeline-01M0C0","step":"triage","kind":"extract","phase":"start","atte… 6 step {"run":"pipeline-01M0C0","step":"notify","kind":"a2a.send","phase":"start"} 3 step {"run":"pipeline-01M0C0","step":"fetch","phase":"done","status":"done","tokens"… 5 step {"run":"pipeline-01M0C0","step":"triage","phase":"done","status":"done","tokens… 7 subagent {"handle":"sa-review","mode":"supervised","status":"running","tokens":4120,"upd… 8 subagent {"handle":"sa-lint","mode":"detached","status":"failed","tokens":260,"updated":… runs pipeline-01M0C0 running 3/7 ◐ notify a2a.send running ● triage extract done 1.0s ● fetch mcp.tool done 141ms subagents / children sub sa-review running 4120 tok sub sa-lint failed 260 tok log (debug.events) — ● live http://127.0.0.1:8420 [debug] tab:screens esc:cancel /:cmd ^c:quit
The feed is the raw observation stream, sequence-numbered. Under runs, each
run carries its steps: ◐ running, ● done, ○ pruned (a branch nobody
took), ◌ waiting, ✗ failed — so a run that is stuck shows the step it is
stuck on, rather than a count that says three of seven and leaves you guessing
which three.
The web UI renders the same information with a severity stripe per row and a master–detail split for subagents, so the list stays on screen while you read one child — a tree is watched while it moves, and losing sight of the siblings is exactly the wrong thing when a second one starts misbehaving.
5. Everything the clients can do
Both clients speak the same surface:
- Chat — natural-language turns with the agent. The transcript shows every
attached client's prompts (labelled by principal when not you), command
invocations, and the agent's replies; an
input-requiredtask renders as an answerable row, and a plain reply answers it.Esccancels the newest working task. - The composer speaks four prefixes (suggestions appear as you type; Tab
accepts):
/— commands:/help /new /chat /tasks /subagents /debug /status /config [path] /set <path> <value> /workflow <name> /signal <name> [run] /send <handle> <text> /pause [run] /resume [run] /plan /cancel [task] /conversations /pair /drain /quit— plus every workflow as a shortcut (/deployruns thedeployworkflow; system names win).@— skills:@skill:release-notesautocompletes from the daemon's catalogue and stays in the text (agentd preloads referenced skills). The completion inserts the full@skill:form because that is what the daemon matches on —skills.reference_prefix, default@skill:. A bare@nameis ordinary prose and means whatever your deployment decides.#— targets: start a message with#task-…to answer/continue that task (the way to answer a specific input-required question), or#<ctx>to address that conversation. Inline#…is plain text.$— live values:$model $instance $version $turns $tokens $tasksinterpolate daemon state into your message;$$escapes a dollar.
- The working row — while the agent is busy you see what it is doing,
live:
⣾ thinking · 12s · 1.2k tok · round 2,⣾ read_file · 3s · 1.2k tok,⣾ waiting · subagent · 40s. The daemon reports phase/tool/round/tokens on change; elapsed ticks in the client, so a long think costs no traffic. (This is deliberately not token-by-token streaming: one small event per phase change keeps every attached surface in sync for a fixed cost, where a token stream would multiply the daemon's outbound traffic by the number of watchers.) - Tasks — every task your principal may see, live states, cancel.
- Approvals & questions (human-in-the-loop) — when the agent (or a
workflow's
humanstep) needs you, the transcript shows the question as an answerable row ([reply to continue]); just type your answer (it targets the newest gate;#task-…targets a specific one). Gates on workflow runs survive daemon restarts, addressee and answer schema included. A gate declaringto:is for a named decider: a reply from anyone else is refused with an explanation and the gate stays open, and an operator answering one is recorded as an override rather than as the addressee deciding. Configure what happens when NOBODY can answer withagent.ask_human_fallback:fail(default),wait(park until the ask timeout), orauto— an LLM judge answers on the operator's behalf, conservatively, always marked as auto (it also fires when a rendered gate times out unanswered). - Steering —
/signal <name> [run]fires a workflow signal;/send <handle> <text>messages a warm subagent;/pause [run]//resume [run]hold one run or the whole instance (reversible — intake continues, execution parks; the status bar shows PAUSED);/planreads a conversation's working plan. - Subagents — the live list (handle · mode · status · tokens); select/click
one for the detail view (instruction, result, attempts, errors — needs
debug) and step back to the list. TUI:
↑/↓+ Enter,Escback. - The status bar shows the connection (
● live= feed;◐ polling= a daemon without the feed — the clients degrade to polling automatically), counters, and a prominent DRAINING notice.
5.1 Configure the chrome (interface.display)
The daemon decides what its clients render in the top (header) and bottom (status bar) edges — every attached surface lays out the same:
interface:
display:
top: [name, model, instance, debug]
bottom: [conn, tokens, turns, runs, subagents, clock]
Items: name version instance model endpoint conn debug
draining (the lifecycle notice — shows DRAINING or PAUSED) active
turns tokens tool_calls runs subagents conversations screen
keys clock. Unknown items are skipped (a warning
at config validation); screen/keys are TUI-only. Omit display for the
defaults. The layout is also runtime-shapeable:
/set interface.display.bottom ["conn","model","tokens"] re-shapes every
connected client at once.
5.2 Status values a workflow maintains (memory:<key>)
The chrome's vocabulary is fixed, because a client has to know how to render
each item. But a memory:<key> item renders whatever a workflow wrote to
that key — which makes the status line extensible without the daemon learning to
compute anything.
That distinction matters. agentd executes nothing locally, so it cannot shell
out to git to find your branch. It does not have to: a workflow reads the
value from wherever it actually lives — an MCP server, an HTTP endpoint, a
webhook — and writes it to a key the chrome names.
interface:
display:
top: [name, model, "memory:git.branch", "memory:git.pr"]
bottom: [conn, tokens, "memory:deploy.state"]
workflows:
- name: repo-status
steps:
tick: { kind: schedule, every: 30s }
read: { kind: mcp.tool, depends_on: [tick], server: git, tool: status }
branch: { kind: memory.set, depends_on: [read], key: "git.branch",
value: "{{ steps.read.output.branch }}", ttl: 2m }
fin: { kind: finish, depends_on: [branch], status: completed }
Two behaviours are deliberate:
- An unset key renders nothing, not an empty slot — a blank status reads as broken, an absent one reads as not-yet-filled.
- TTL is honoured. Give the key a TTL slightly longer than the schedule that refreshes it, and the slot empties when the workflow stops running. A branch name still sitting there after its producer died is worse than no branch name, because it looks current.
The same mechanism carries anything worth watching: a PR number, a deploy state, a queue depth, an on-call name. The client renders the value without knowing what it means.
5.3 Runtime settings (/set) — and their deliberate limit
config.set (operator) updates a whitelisted set of knobs in the running
daemon, no restart:
/set interface.debug true— open up the debug surface live (and back off);/set interface.display.top …/…bottom …— reshape the chrome (§4.1).
Everything else answers with the whitelist and stays where it belongs: the
config file + SIGHUP hot reload (see configuration.md §11) — the daemon never
mutates config it doesn't own. /config prints the full effective document,
/config a2a.listen one value.
5.4 Pairing-code login (interface.pairing)
The no-copy way to connect a browser or a remote TUI:
a2a:
listen: http://127.0.0.1:8420 # the interface is served on this listener
interface:
enabled: true
pairing:
enabled: true # default off
role: operator # what a paired client becomes (operator | user)
ttl: 12h # session lifetime
Flow: the operator runs /pair in their TUI (or web UI) — it prints a
6-digit code that rotates every minute plus a ready-made connect command.
The joiner enters the code — agentd-tui --endpoint … --code 483921, or the
code field on the web connect form — and receives a session token used
automatically from then on. The code is only a bootstrap: verification is
constant-time and rate-limited (5 misses per minute lock it out), the minted
token is 32 bytes of OS randomness, and sessions live in memory — restarting
the daemon revokes everything. On a non-loopback listener, pairing counts as
client auth (you can run TLS + pairing with no static bearer at all).
6. Debug mode — the extra information
interface:
enabled: true
debug: true # or: agentd tui -c … --debug
debug is a daemon-side switch: clients learn it from interface.info
and only then render their debug screens. It unlocks:
- the feed tail — every observation event as it happens (tasks, messages, runs, subagents, audit records);
- runs with per-step detail (
run.get: status, attempts, timings, errors, waits, outputs); - conversation transcripts with message bodies (
conversation.get) — the one read that exposes content, which is why it rides this gate; - the live log ring (
debug.events) — the daemon's own JSON-lines telemetry, tailed in the client.
Treat debug: true as operator-grade exposure; leave it off in production
unless you need it.
7. The protocol (for other clients)
Everything above is plain A2A JSON-RPC plus the interface additions below — any program can be a display client:
SubscribeToEvents {fromSeq}— the SSE feed:hello→event* →goodbye; reconnect with the goodbye cursor;hello.resyncmeans re-bootstrap via thestatuscommand. Events are principal-scoped.- Taskless reads (command DataParts):
interface.info, and under debugconversation.get,run.get,debug.events. - The reply to any prompt arrives as its task's terminal artifact on the feed — the same event every other client folds in.
The reference implementation is the shared TypeScript core in
@agentd-dev/cli (wire + state mirror + observation driver
with poll fallback, exported as the package's library entry point); both
shipped UIs are ~thin renderers over it.
8. Building the clients
cd interface
npm install
npm run build # the client core + the TUI, then the web bundle
npm test # unit + render tests
Node ≥ 20. One package, @agentd-dev/cli, provides both binaries
(agentd-tui, agentd-ui) and the client library. The clients are not
part of the Rust workspace or its release artifact, so the daemon keeps its
3-dependency default build.