Attach TUI
core-agent-tui is the operator-facing terminal UI for attach-mode — the remote client for an agent running elsewhere (workstation, K8s pod, peer-registered fleet member). It ships as a separate binary so the default core-agent stays distroless-clean (no terminal-rendering deps land in production K8s images).
Related references:
core-agent-tuiCLI reference — flag table, env vars, exit codes, install.- Attach HTTP endpoints — the daemon-side protocol the TUI speaks.
- Configuration → attach — daemon-side listener config (
attach.listen, tokens,multi_session, peer-hub).
For local interactive use, run core-agent directly — its in-process TUI is the default when stdin is a terminal. core-agent-tui is the remote client only.
Why a separate binary
Section titled “Why a separate binary”core-agent-tui is a thin shell over go-steer/core-tui (Bubble Tea + Glamour + Lipgloss live there now); the core-agent binary itself pulls in zero terminal-rendering deps. For the K8s use case — a long-running headless agent with --attach-listen — that distroless image stays tight. Splitting the operator surface into its own binary keeps both pieces single-purpose.
Two release artifacts:
core-agent_<os>_<arch> # default — K8s, distroless, headlesscore-agent-tui_<os>_<arch> # for laptop operatorsIf you have Go installed: go install github.com/go-steer/core-agent/v2/cmd/core-agent-tui@latest.
Quick start
Section titled “Quick start”# 1. Bare invocation — stdin prompts for an attach URL.core-agent-tui
# 2. Remote — point at a running agent's --attach-listen.ATTACH_TOKEN=$(openssl rand -hex 32) \ core-agent --no-repl --session-db --attach-listen=:7777 \ --attach-token=ATTACH_TOKEN
core-agent-tui http://localhost:7777 --token=ATTACH_TOKEN--no-repl runs core-agent as an attach-only daemon (no stdin REPL, no in-process TUI). Pair with --session-db so the eventlog persists — attach mode requires it for the live-tail broadcaster.
URL forms (same grammar as core-agent attach):
| URL | Behavior |
|---|---|
http(s)://host:port | Hub form — TUI opens the session picker, enumerating local + peer sessions in parallel |
http(s)://host:port/sessions/<sid> | Direct-jump — TUI skips the picker and enters that session |
http(s)://host:port/sessions/<app>/<sid> | Qualified direct-jump |
unix:///path/to/socket | Unix-socket hub |
unix:///path/to/socket/sessions/<sid> | Unix-socket direct-jump |
| Flag | Purpose |
|---|---|
--token=<ENVVAR> | Name of the env var holding the bearer token (same indirection as --attach-token on the listener side). The secret never appears on the command line. |
--auth=<strategy> | Auth strategy for outbound attach requests. bearer (default) sends the attach token in Authorization: Bearer — the direct-attach path. google-id-token (recommended for Cloud Run IAM / IAP) mints a Google ID token via Application Default Credentials, audience-bound to the connection URL, and stamps both Authorization: Bearer <ID-token> + X-Attach-Token. google-oauth is an alternative that uses OAuth access tokens via google.FindDefaultCredentials (matches MCP’s pattern for Google APIs) — Cloud Run IAM rejects this in many deployments, prefer google-id-token unless you specifically need OAuth scope behavior. See “Behind an identity gateway” below. |
--theme=auto|dark|light | Force a glamour theme for markdown rendering. Empty = auto (terminal background detection via OSC 11). |
--alias=<label> | Display label for the agent identity in the status bar. Defaults to the session ID. |
--version | Print build identity (core-agent-tui v2.2.0 (commit a1b2c3d4, built 2026-06-01T…)) and exit. |
Behind an identity gateway (Cloud Run IAM, IAP, Cloudflare Access, …)
Section titled “Behind an identity gateway (Cloud Run IAM, IAP, Cloudflare Access, …)”Deployments behind an identity gateway have a single-Authorization-header problem: the gateway wants to validate the caller’s identity token in Authorization: Bearer, and core-agent’s listener wants the attach token in the same header. Both can’t ride there at once.
The fix is two-sided:
- Server side: core-agent accepts
X-Attach-Tokenas a side-channel header for the attach token, leavingAuthorizationfor whatever the gateway needs. Available unconditionally — no flag to enable. - Client side:
core-agent-tuiknows how to mint the gateway-appropriate credential and stamp both headers. The strategy is selected via--auth.
Server-side header precedence (whichever ride the attach token uses, compared in constant time):
| Headers a request carries | Outcome |
|---|---|
X-Attach-Token: <correct> | 200 — Authorization is left for the gateway |
X-Attach-Token: <wrong> | 401 — does not fall through to Authorization, since the operator explicitly sent it |
Authorization: Bearer <correct> (no X-Attach-Token) | 200 — the direct-attach path, unchanged |
| Neither, or both wrong | 401 |
Client-side: --auth=google-id-token (recommended for Cloud Run IAM / IAP)
Section titled “Client-side: --auth=google-id-token (recommended for Cloud Run IAM / IAP)”The TUI mints a Google ID token via idtoken.NewTokenSource (Application Default Credentials), audience-bound to the connection URL, and stamps both headers automatically. No manual gcloud auth print-identity-token invocation; no gcloud run services proxy hop.
# One-time setup on the operator's machine (skip on GCE/GKE/Cloud Run/Cloud Shell —# ADC picks up the runtime's service account automatically):gcloud auth application-default login
# Attach. Audience derives from the connection URL automatically.core-agent-tui --auth=google-id-token \ --token=ATTACH_TOKEN \ https://my-svc-abc123-uc.a.run.appBehavior:
- The TUI calls
idtoken.NewTokenSource(ctx, serviceURL)— token source caches the ID token until expiry (~1 hour). - Per request:
Authorization: Bearer <ID-token>(gateway validates against the service’s IAM bindings — operator must haveroles/run.invoker) +X-Attach-Token: <attach-token>(core-agent validates against--attach-token). - Cloud Run forwards the request to the container with the operator’s identity attached as
X-Goog-Authenticated-User-Email/X-Goog-Authenticated-User-Idheaders. Core-agent doesn’t consume these today; tracked separately under #142.
Common failure modes:
| Symptom | Cause | Fix |
|---|---|---|
Application Default Credentials unavailable at startup | ADC isn’t configured | gcloud auth application-default login |
unsupported credentials type: "authorized_user" at startup | idtoken.NewTokenSource requires service-account-shaped ADC; end-user ADC isn’t accepted. Most common on local workstations after a plain gcloud auth application-default login. | Re-login ADC with service-account impersonation: gcloud auth application-default login --impersonate-service-account=SA_EMAIL (operator needs roles/iam.serviceAccountTokenCreator on SA_EMAIL). Alternatively, set GOOGLE_APPLICATION_CREDENTIALS=/path/to/sa-key.json. |
| Gateway 401 from Cloud Run | Operator (or impersonated SA) lacks roles/run.invoker on the service | gcloud run services add-iam-policy-binding <svc> --member="user:$(gcloud config get-value account)" --role=roles/run.invoker (or the same for the impersonated SA) |
| Core-agent 401 after gateway passes | Wrong ATTACH_TOKEN or daemon running without --attach-token | Verify env var resolves to the right value; if daemon is in Posture B, omit --token= entirely (see below) |
Client-side: --auth=google-oauth (alternative; not recommended for Cloud Run IAM)
Section titled “Client-side: --auth=google-oauth (alternative; not recommended for Cloud Run IAM)”Uses google.FindDefaultCredentials to source a Google OAuth2 access token, stamps it on Authorization. Mirrors MCP’s google_oauth pattern (pkg/mcp/lifecycle.go), which is the right shape for Google APIs that take OAuth access tokens (Vertex, GKE, etc.).
Cloud Run IAM rejects OAuth access tokens in many deployments (response: error_description="The access token could not be verified"). Use --auth=google-id-token for Cloud Run IAM instead. This strategy stays available for any future gateway that specifically accepts OAuth access tokens with a cloud-platform scope.
Two postures the daemon can run in:
- Posture A — IAM + ATTACH_TOKEN (default-recommended, belt-and-suspenders): server launched with
--attach-token=ATTACH_TOKEN, client passes--token=ATTACH_TOKEN. Defense in depth against IAM misconfig (accidental grant toallAuthenticatedUsers, leaked invoker service account, future org-policy changes). - Posture B — IAM only (simpler, trusts IAM as the sole gate): server launched without
--attach-token, client omits--token=entirely. Removes a managed secret. Sensible when IAM bindings are tightly scoped to a small group of named principals.
IAP / other gateways
Section titled “IAP / other gateways”IAP specifically requires ID tokens with the OAuth client ID as audience (not the service URL). Today --auth=google-id-token derives the audience from the connection URL — fine for Cloud Run, wrong for IAP. An explicit --auth-audience=<oauth-client-id> override flag is the planned addition once an IAP target is available to validate against.
For other gateways (Cloudflare Access, AWS ALB+Cognito, …), today’s workaround is to mint the gateway credential out-of-band and pipe it in via a shell wrapper; first-class support depends on the same future --auth-audience flag plus a “generic header-cmd” escape hatch that’s been floated but not scoped.
Until then, the documented attach path for non-IAM gateways remains a wrapper around gcloud run services proxy or the equivalent.
Operator surface (slash parity with the in-process TUI)
Section titled “Operator surface (slash parity with the in-process TUI)”core-agent-tui shares its operator surface with the in-process TUI — all the slash commands from the in-process slash reference work end-to-end against a remote agent. Highlights:
| Command | Effect |
|---|---|
/help, /quit, /clear | Standard housekeeping. |
/stats | Cumulative token + cost totals, per-model breakdown. Pulls from the remote’s usage.Tracker. |
/usage | Extended attribution: cached-vs-uncached input tokens, per-turn history, cache-savings vs uncached-reference cost. Fetches GET /sessions/<id>/usage. Companion to /stats — /stats is the terse aggregate, /usage is where you look when digging into where the tokens went. |
/context | Compactions, checkpoints, summarized chars, subtask cost. |
/memory | Current AGENTS.md chain (project + user-global). |
/skills | Loaded skills with trigger descriptions. |
/mcp | Configured MCP servers and their status. |
/perms, /permissions | Gate mode + active allow/deny patterns + per-session approval log. |
/allow <pattern>, /deny <pattern> | Add patterns to the live gate (and to .agents/config.json if writable on the daemon side). |
/pricing, /pricing refresh, /pricing set <id> <in> <out> | Inspect or override the pricing layer. |
/reload | Re-walk memory + skills + MCP config on the daemon; surfaces per-surface results (Memory: ✓, Skills: ✓, MCP: ✗ with errors inline). |
/compact [focus], /done [note] | Trigger summarization or task-boundary checkpoints on the remote agent. The TUI shows an in-chat preamble row during the 5–30 s round-trip. |
/btw <question> | One-shot context-grounded side question. |
/subagent <goal> | Spawn a background subagent on the remote agent (requires --no-background-agents=false daemon side). |
/tools, /subagents | List the daemon’s tool palette and the configured subagent roster. /tools tags each entry’s source (declarative subagents wired as parent tools show subagent; MCP/skill tools currently show other); /subagents shows the roster the daemon loaded — name, model, root, and sync/async modes — from GET /subagents (distinct from /agents, which lists running instances). |
/interrupt | Cancel the in-flight model turn on the remote. |
/reconnect | Force-reconnect the SSE stream (resumes from ?since=<lastSeq> — lossless). |
/wake | Pierce a scheduler sleep on the remote. |
/sessions | Pop back to the startup session picker (kills the TUI, re-launches). |
/switch [<sid>], /sess | Detach + reattach to a different session in place. Bare form opens an in-chat picker (local + peer sessions fanned in parallel via GET /peers → per-peer GET /sessions; peer rows tagged [peer:<name>]); /switch <sid> direct-jumps to a local session. Chat wipes; local SSE reader closes; the outgoing daemon session keeps running for later re-attach. |
/new | POST /sessions on the current daemon (per-caller bearer auth, ACL-isolated) and detach + reattach to the fresh session in place. Companion to /switch for the “I need a clean slate” flow. |
/attach <url>, /attach <url> <sid> | Escape hatch for reaching a daemon that isn’t peer-registered on the current one (issue #246). Bare form enumerates that daemon’s sessions into a system message; /attach <url> <sid> direct-jumps in place. An operator-typed URL is explicit intent, so it inherits the operator’s startup --auth mode + --token. (Hub-advertised peer endpoints do NOT — see the credential-forwarding note below.) |
/transcript [path] | Save the local scrollback to a markdown file (default /tmp/<sid>.md). |
/theme dark|light | Switch glamour theme; re-renders existing assistant messages. |
Sync slashes (/context, /pricing, /reload, /perms) hit the corresponding attach read/mutation endpoints directly. Async slashes (/compact, /done, /btw, /subagent) flow through synchronous POSTs that block until the underlying agent operation completes; the remote TUI renders an in-chat preamble row at dispatch to bridge the 5–30 s gap.
Multi-daemon workflow
Section titled “Multi-daemon workflow”The /switch + /attach + peer-hub trio (issue #246) turns one TUI window into a control plane for a fleet of daemons. The story:
One laptop → one TUI → many daemons. The operator launches core-agent-tui against any one daemon in the fleet, then hops between daemons — and between sessions inside each daemon — from the running TUI. No re-launch. No second terminal.
Three pieces make it work:
-
Peer-hub registration (daemon-side) — each daemon can register itself with a hub daemon at startup via
--attach-peer-hub+--attach-register-to=<hub-url>. The hub keeps a live view of registered peers via heartbeats (see Attach HTTP → peer endpoints). Every daemon that participates knows about every other; there’s no central controller. -
/switch(TUI, in-process) — inside a running TUI,/switchopens an in-chat picker that fansGET /sessionsin parallel across the current daemon’s hub PLUS every registered peer (5 s per-peer timeout so a slow peer doesn’t block the list). Pick a row, the TUI detaches from the current session and reattaches to the picked one in place — chat wipes, but the outgoing session keeps running on its daemon for later re-attach. Peer rows tag as[peer:<name>]; local rows are unadorned. Bare/switchopens the picker;/switch <sid>direct-jumps to a local session. -
/attach <url>(TUI, escape hatch) — for reaching a daemon that ISN’T peer-registered on the current one (fresh laptop-local daemon, operator-typed URL from a Slack link, ad-hoc jump to a peer that hadn’t checked in yet). Bare/attach <url>enumerates that daemon’s sessions into a system message so the operator can pick manually;/attach <url> <sid>direct-jumps in place. An operator-typed/attachURL is explicit intent, so it inherits the operator’s startup--authmode +--tokenenv var.
Credential forwarding to peers. Hub-advertised peer endpoints (the rows /switch fans in from GET /peers) are attacker-influenceable: any registrant on the hub can publish an arbitrary endpoint, and connecting to it with the operator’s bearer/OAuth token would hand that token to whoever registered the row (#384). The TUI therefore only forwards the operator’s credentials to a peer endpoint when its host matches the hub’s own host or is listed in --trusted-peers (comma-separated hostnames, no port); every other peer endpoint is contacted credential-less. Server-side, the peer hub validates endpoints (absolute http/https URL required), scopes each registration to its owner (only the owner or an admin can deregister), and hides registration_id from non-owners. Operator-typed /attach <url> targets are exempt — an explicitly typed URL is intent, not attacker-supplied data.
Worked example. One operator laptop, three daemons: local dev daemon on localhost:7777, staging daemon on Cloud Run, prod daemon on GKE. Staging + prod are peer-registered on the same hub daemon (say, staging is the hub); local isn’t in the peer graph.
# Terminal setup — one TUI, connected to staging (the hub daemon):core-agent-tui --auth=google-id-token \ --token=ATTACH_TOKEN \ https://staging.example.com
# Inside the TUI:/switch # → picker shows staging + prod sessions (peer)/switch <prod-sid> # → direct-jump to a known prod session/attach http://localhost:7777 --token=DEV_TOKEN # → escape hatch to the local daemon (not peer-registered)/new # → fresh session on whichever daemon you're currently onEvery hop keeps the same TUI process alive. Cost totals, theme, and keybindings persist; only the session-scoped chat + status bar update.
Auth propagation. The TUI captures --auth + the token env var name at startup. When /switch or /attach spawns a subordinate client to reach a peer, the same auth strategy runs against the new endpoint (audience-bound per endpoint for google-id-token; trivial passthrough for bearer). Operators need roles/run.invoker (or equivalent) on every peer they intend to hop into — --auth=google-id-token doesn’t magically grant access, it just relays the credential correctly.
When to use what:
| Scenario | Right slash |
|---|---|
| Same daemon, different session I own | /switch <sid> |
| Same daemon, pick from live sessions | /switch (bare) |
| Same daemon, fresh isolated session | /new |
| Registered peer daemon, direct-jump | /switch <sid> (picker also works) |
| Registered peer daemon, pick from live | /switch (bare) |
| Unregistered daemon (ad-hoc) | /attach <url> |
| Kill TUI + repick from startup | /sessions |
/switch and /attach are the ergonomic difference between “I manage a fleet” and “I manage one daemon at a time.” Both landed in v2.7.0-dev.1 (#246 Phase 1).
Observer mode (LiveAgent)
Section titled “Observer mode (LiveAgent)”When the remote agent is running on its own — autonomous.Run, scheduled background subagents, MCP-server-triggered activity, other attached operators’ injects — the TUI surfaces every event in the chat scrollback as it happens. You don’t have to type anything to see what the agent is doing; attaching is enough.
Operator typing still works: the prompt goes through POST /inject and the agent’s response streams back through the same observer feed. The scrollback shows the full mixture — your prompts, autonomous turns, subagent activity — in order.
Reconnection is automatic. If the daemon dies (restart, SIGHUP, network drop), the TUI shows a transient error row, retries with exponential backoff (5 s → 30 s cap), and resumes from the last-seen event sequence when the daemon comes back. An operator typing during a backoff window pre-empts the sleep so the next attempt happens immediately. No need to kill the TUI and reattach.
The Live session — your messages drive the agent; events stream as they happen. row at the top of the chat marks the start of the live feed. Read-only attachments (viewer identities without session-write) instead see Attached as observer — agent runs autonomously; events stream below. — same feed, honest disclosure that typing won’t inject.
Permission prompts
Section titled “Permission prompts”If the remote agent runs in ask mode (the default), tool calls that aren’t pre-allowed pop a modal in the TUI:
┌────────────────────────────────────────────────────────────────┐│ bash wants to run: ││ ││ git push origin main ││ ││ [y] allow once [s] allow session [v] allow `git *` ││ [t] allow tool [a] allow always [n] deny │└────────────────────────────────────────────────────────────────┘The decision round-trips to the daemon via POST /perms/respond; the tool call resumes on the remote side. Picking a (allow-always) also persists the pattern to the daemon’s .agents/config.json so subsequent sessions don’t re-prompt.
Operators who want zero prompts can pass --yolo to the daemon or pre-populate .agents/config.json.
Layout
Section titled “Layout”┌─────────────────────────────────────────────────────────────────┐│ core-agent-tui ● scion · ◇ gemini-3.1-pro-customtools │ status bar├─────────────────────────────────────────────────────────────────┤│ user │ what's the status of the canary? ││ ││ asst │ The canary deployment in prod is healthy. │ scrollback│ │ • 3/3 pods Ready │ (viewport)│ │ • last rollout: 2026-05-22 14:03 UTC ││ ││ ⚙ kubectl get pods (12.4 KB, 200 OK) │ tool call│ │├─────────────────────────────────────────────────────────────────┤│ ↻ "redeploy the canary" │ queue panel│ ↻ "check the rollout log" │ (only when non-empty)├─────────────────────────────────────────────────────────────────┤│ > _ │ input box└─────────────────────────────────────────────────────────────────┘ /help in: 12.4K out: 1.9K $0.12 ↳ this turn $0.03 footerQueue panel
Section titled “Queue panel”The strip between the scrollback and the input box renders any operator messages typed while the agent is mid-turn. On turn-end, all queued entries get auto-submitted as a single follow-up turn (with a ↻ marker), wrapped in a system-note framing block so the model knows they arrived mid-task. Soft cap of 10 consecutive auto-continues.
Status bar
Section titled “Status bar”<alias> · ◇ <model> (or <wordmark> · ◇ <model> when no alias was set). The diamond marks the current model; switch with /model.
Footer
Section titled “Footer”/help shortcut + cumulative tokens + cumulative cost + last-turn cost. The last-turn cost is computed client-side from the daemon’s cached pricing rates so the footer updates per event without an extra round-trip.
Keybindings
Section titled “Keybindings”| Key | Effect |
|---|---|
| Enter | Submit input (or run slash command). Mid-turn: queue for after current turn finishes. |
| Shift+Enter | Insert a newline in the input |
| Esc | Contextual: dismiss a modal if one’s open; otherwise interrupt the in-flight turn. |
| Ctrl+C (once) | Cancel the in-flight turn |
| Ctrl+C (twice within 1s) | Quit the TUI |
| Ctrl+D | EOF — quit the TUI |
| PgUp / PgDn | Scroll the scrollback |
| Ctrl+E | Open $EDITOR with the current input buffer (fallback: $VISUAL → vi) |
| r (in picker) | Refresh the session list |
Read-only mode
Section titled “Read-only mode”When connected to a listener started with --attach-readonly, the TUI still works for everything except writes:
- ✅ Session enumeration, live tail, observer mode,
/tools,/stats,/context,/memory,/skills,/mcp,/perms,/transcript - ❌ Sending messages (typing + Enter),
/wake,/inject,/interrupt,/allow,/deny,/reload,/compact,/done,/subagent,/pricing refresh|set
Writes surface as red ✗ error lines in the scrollback (the server returns 403; the TUI shows the error rather than failing silently).
Composition
Section titled “Composition”- Live stream: SSE over
GET /sessions/<sid>/events. Lossless replay via?since=<seq>so reconnects don’t lose history. The adapter exposescoretui.LiveAgent— core-tui’s optional capability for hosts whose agent is observed via a continuous event stream rather than driven by per-turnRuncalls. - Hub-and-spoke: when the launch URL targets a peer-registration hub, the picker fans
GET /sessionscalls in parallel across the hub + every registered peer, with a 5-second per-peer timeout so a slow peer doesn’t block the list. - Permissions bridge: a background goroutine subscribes to
GET /perms/stream(SSE) for pending prompts; each frame becomes a modal; the operator’s decision posts toPOST /perms/respondand the daemon’s blockedAskApprovalcall unblocks. - Usage panel: feeds from the same
CustomMetadata.input_tokens/output_tokensshape thatusage.Trackerconsumes for headless runs. Updates on every model event.
For the full design rationale see docs/remote-tui-on-core-tui.md and docs/remote-tui-observer-mode.md.
Debug logging
Section titled “Debug logging”For diagnosing connection / render issues:
CORE_AGENT_TUI_DEBUG=/tmp/coreagent-tui.log core-agent-tui http://localhost:7777# in another terminal:tail -f /tmp/coreagent-tui.logPairs with CORE_AGENT_DEBUG=<path> on the daemon side for a two-file view of an attach session — adapter / bridge / broadcaster / SSE handler all log to whichever file each env var names. Silent unless the env var is set.