UI Bridge
The UI bridge lets a script or an agent drive the desktop app: open a channel, switch layout tabs, arrange panes, open a file, and type into, read and wait on agent terminals. Whatever it does happens in a real window, in front of the user.
Packages: internal/uibridge (hub), internal/api (handlers), cmd/loop (CLI), internal/mcpserver (MCP tools), app/src/uiBridge (the window’s side)
Related docs: API: UI Bridge | Layouts | Terminal | MCP Server
How It Works
loop ui:run ─┐
HTTP client ─┼─► daemon ── /api/ws/ui ──► app window ──► steps run in the UI
ui_run (MCP)─┘ ▲ │
└──── state, results ──────┘- Each app window keeps a WebSocket open to the daemon. It says hello with a client id, then sends its state every time what it shows changes: the open channel, the layout tabs and panes, and each agent terminal’s status and whether it’s busy.
- A command is a list of steps. The daemon checks it, sends it to one window, and waits for that window’s results. The window runs the steps itself, in order, until one fails. The daemon doesn’t change the layout itself.
- A command goes to the window the user last focused, unless it names another by its
client_id.ui:statelists the windows.
Three Ways In
CLI. The steps are a JSON array, given as an argument or on stdin. The results print as JSON, and the command exits non-zero when a step fails.
loop ui:state # the windows and what each shows
loop ui:state --watch # one JSON line per change
loop ui:run '[
{"op": "select_channel", "channel_id": "ch-1"},
{"op": "add_pane", "panel": "docker-shell", "next_to": "chat"},
{"op": "send_input", "pane": "docker-shell", "text": "make test", "submit": true},
{"op": "wait_for", "pane": "docker-shell", "match": "^(ok|FAIL)", "lines": 200}
]'--client picks a window and --timeout sets how long to wait for it (default 1m, at most 10m).
HTTP. POST /api/ui/commands takes {client_id?, timeout?, steps}. GET /api/ui/state?after=N waits for a state newer than version N, which is how --watch follows changes.
MCP. Agents get the ui_state and ui_run tools. ui_run’s steps are typed, so a step with a bad op or field is refused before it reaches the window. Without a leading select_channel, the command runs in the agent’s own channel.
Steps
There are steps for channels (select_channel), tabs (set_tab, create_tab, rename_tab, remove_tab), panes (add_pane, replace_pane, remove_pane, maximize_pane, restore_pane), the editor (open_file), the chat (show_message, which scrolls to a message), the side view beside the chat (open_side, close_side) and terminals and chats (send_input, read_output, wait_for). Pane steps work on canvas tabs too, where the panes are the tiles. The API reference
lists every field.
A pane is a pane id from the state, such as docker-agent-1, or a panel type, which means the first pane of that type.
Rules Worth Knowing
- Steps stop at the first failure. A failed step has
ok: falseand anerror, and the steps after it don’t run. - A command stays in one channel. That’s the channel its
select_channelopened, or else the one the window showed when the command started. If the user opens another channel while the command runs, the next step fails rather than read from or type into that channel’s terminals. wait_forwaits for new output. It waits until the terminal’s lastlinesmatchmatch, or, withoutmatch, until the terminal has been quiet forquiet_ms. After asend_inputto the same pane in the same command, only output printed since then counts. A session that ended counts as done, and a pane that closes fails the step instead of waiting out the timeout.matchuses RE2. It’s a multiline regular expression in RE2 syntax , which matches in linear time, so a pattern can’t stall the window. It has no lookarounds or backreferences.- In a chat pane,
send_inputqueues a message. On achatpane (the channel’s chat) or achat-sidepane (a fork or fresh side chat), the text is queued as the chat’s next message, as its composer sends it;submit: falseis refused. It’s refused, too, while the chat waits on an answer to a question, plan or approval card.read_outputreturns the chat’s lastlinesof messages, each asauthor: text.wait_forafter asend_inputto that pane waits until a reply came and the chat has nothing left to run, or, withmatch, until its replies since match. Without asend_input, it waits until the chat has been idle forquiet_ms. - The side view is the pane
side.open_sidewithviewlearn,explainorshellopens it (shellonly while the chat’s container is running), andui_stateshows the open one asside.read_outputon it returns the Learn view’s proposals and learn thread, the Explain view’s explanations, or the Shell view’s terminal.send_inputtypes into the Shell view, a shell in the chat’s own container, or queues a message in the Learn view’s learn thread; the Explain view takes no input.wait_foron the Explain view waits until no explanation is pending. - Input to a
docker-agentpane goes in as one paste, so a multi-line prompt stays one message. Text containing a bracketed-paste marker (ESC [200~orESC [201~) is refused, because it would end the paste early.
Security
- No host shells. The terminal steps work only on
docker-agentanddocker-shellpanes, which run in containers, and onchatandchat-sidepanes and the side view, whose Shell view runs in the chat’s container. Typing into a host shell would run commands on the host, and reading one could show what the host has. This applies to everyone, including the owner: the daemon refuses such a step, and the window refuses it again. - Agents stay in their project.
- An agent’s command must start with a
select_channel, and everyselect_channelmust name a channel, thread or worktree thread of the agent’s project. Otherwise the daemon answers403. ui_stateshows an agent nothing for windows on other projects.
- An agent’s command must start with a
- The window socket is owner-only. Only the app itself connects to
/api/ws/ui. - The window is shared with the user. A command moves what the user sees, so agents are told to use it when the user asked to be shown something.