Agent control interface (--mcp)
The dashboard, editor, scope and switchboard all ship with a control endpoint that lets an agent run them headless, look at them, and drive them. It is always compiled in and only listens when --mcp is supplied.
Claude Code
| MCP (stdio)
v
tools/mcp_dashboard/ Python, uv. Lifecycle + tool schemas + PNG encoding.
| newline-delimited JSON-RPC 2.0 over an AF_UNIX socket
+--> dashboard --mcp=/path/a.sock -c configs/dashboard/...
+--> editor --mcp=/path/b.sock [-c ...]
+--> scope --mcp=/path/c.sock [-c configs/scope/...]
+--> switchboard --mcp=/path/d.sock
The C++ side (libs/agent_control) deliberately does not speak MCP. It exposes methods; tool names, descriptions and argument schemas live in Python where they change without a rebuild.
The library is
agent_control, notmcp_*:libs/mcp2221ais the Microchip USB-to-I2C bridge driver and the collision would be confusing. The user-facing flag is still--mcp.
Running it by hand
cmake --build build --target dashboard editor
./build/apps/dashboard/dashboard --mcp=/tmp/a.sock -c configs/dashboard/mercedes_190e_dash.yaml
# prints: AGENT_READY /tmp/a.sock <pid> (and no window: the platform is forced to offscreen)
printf '{"jsonrpc":"2.0","id":1,"method":"ui.snapshot","params":{}}\n' | nc -U /tmp/a.sock
--mcp takes its value with =. --mcp /tmp/a.sock does not bind the path — cxxopts options with an implicit value do not consume a space-separated argument — so the apps refuse to start rather than listening somewhere other than where you asked. Bare --mcp uses /tmp/redline_agent_<pid>.sock.
--mcp also replaces the rotating file sink with a stderr sink, keeping stdout clean for the AGENT_READY handshake. The companion server captures that stderr, so a crash comes back with the log tail that preceded it.
Under Claude Code
.mcp.json in the repo root registers the server, so a fresh clone gets it with no setup beyond having uv installed:
{ "mcpServers": { "redline": {
"command": "uv",
"args": ["run", "--directory", "tools/mcp_dashboard", "redline-mcp"] } } }
It lives in the repo on purpose. The server’s tool descriptions encode the selector grammar, the coordinate contract and the widget types — all of which change with the code they describe, so a copy kept anywhere else drifts silently. uv.lock is committed alongside it, so dependency versions are pinned rather than resolved fresh on each machine.
Two assumptions worth knowing:
- The
--directorypath is relative, so the client must launch the server with the repo root as its working directory. Claude Code does. Another client that does not would fail atuvbefore Python starts — use an absolute path in that client’s own config if so. - The server locates the repo by walking up for
.git+CMakeLists.txt, not by counting directories, so it works regardless of where it is launched from once running.REDLINE_REPO_ROOToverrides it;REDLINE_BUILD_DIRoverrides where it looks for binaries.
Then:
app_launch(app="dashboard", config="configs/dashboard/mercedes_190e_dash.yaml")
ui_snapshot(interactive_only=True) # what exists, and what to call it
ui_screenshot(target="#speedo") # look at it
input_click(target="#speedo", x=125, y=125)
At most one dashboard and one editor at a time. Running two dashboards is not supported: they share a zenoh bus, so a second would observe samples injected at the first. app_launch on a type that is already running replaces it.
Addressing widgets
Selectors, in the order they are tried:
| Form | Example | Notes |
|---|---|---|
| ref | w7 | From a previous ui.snapshot. Stable while the widget lives. |
| id | #speedo | The optional id: key in the widget’s YAML entry. |
| derived name | mercedes_190e_speedometer#0 | Automatic fallback; also just an objectName. Shifts when widgets are reordered — set an id: for anything you address repeatedly. |
| path | MainWindow/Mercedes190ETelltale[1] | Class names, [n] for identical siblings. Each segment is a strict parent→child step. |
| bare token | CarPlayWidget | Class or id, anywhere in the tree. |
A selector matching nothing or more than one widget is always an error, and the error lists the candidates. Never a silent first match: driving the wrong widget produces a confident wrong conclusion, which is worse than failing.
Both apps name widgets by the same rule (libs/dashboard_widgets/include/dashboard/widget_identity.h), so one selector addresses the same widget in the dashboard and in the editor.
A config with several windows (see displays.md) gives the dashboard several MainWindow roots. Each window’s objectName is its name: from the config, so #carplay addresses the window. Derived names count per window, so static_text#0 can exist in two windows. A selector that matches both is an ambiguity error, which lists the candidates. Give such widgets an id:. The editor shows one window at a time, so its selectors only ever see the window on the canvas. Switch windows with editor.select_window.
The coordinate contract
Every coordinate is a logical pixel, local to the widget you name. There are no screen coordinates anywhere in this interface.
ui.screenshot returns scale, logical_rect, image_size and dpr with the image, so an image pixel converts to a click position with x = px/scale + logical_rect[0].
This is why the CarPlay widget needs no special handling. It renders the phone’s video into a QImage and normalises pos.x()/width() itself before publishing a touch, so a coordinate read off its screenshot is already the right thing to send to input.click.
The debugging loop that matters most
The dashboard is a data-driven display, so most questions are answered by setting a value and looking at the gauge:
zenoh_describe_schema(schema="VehicleSpeed") # what fields exist
zenoh_publish("vehicle/speed_mps", "VehicleSpeed", {"speedMps": 27.0})
ui_screenshot(target="#speedo") # needle sits at 60 mph
No test_data_publisher, no CAN bus, no car. It works for every schema in the registry with no per-schema code, because it goes through Cap’n Proto’s dynamic API — a schema added to schemas/CMakeLists.txt is publishable immediately.
Two zenoh properties that will otherwise cost you time, and which the error messages now state outright:
- Discovery only sees live traffic.
zenoh_listsubscribes for a window and reports what arrives. An empty result means “nothing published during the window”, not “nothing exists” — a slow publisher looks identical to an absent one. - There are no retained messages. Reading back a value you published once will always time out, because the read subscribes after that sample is gone. Read from a continuous publisher instead. (This is the same property behind the CarPlay video-config black screen.)
Gotchas worth knowing
-
Screenshots need rendering to reach Qt’s backing store — but not every GPU widget breaks that, and the common belief here was wrong. Measured on Qt 6.11/cocoa: a
QRhiWidget’s content does come back through a parent’sQWidget::grab(), and a child widget over it composites correctly on top.QRhiWidget,QOpenGLWidgetandQQuickWidgetare render-to-texture widgets that Qt composites itself, and all three exposegrabFramebuffer().What genuinely captures black is a native widget such as
QVideoWidget, where the window system composites and Qt never sees the pixels. The CarPlay widget was reverted fromQVideoWidgetto aQImageblit (commit4d143ae) for z-ordering, and that revert is the only reason video screenshots work.The render-to-texture classes are still on
capture.cpp’s list because of how this process runs: underQT_QPA_PLATFORM=offscreenthe platform reports no RHI support, so such a widget never initialises and draws nothing at all.WA_NativeWindowreads FALSE on all of them, so the obvious runtime check detects none of this;capture.cppclass-checks the subtree and puts awarningin the screenshot metadata instead of returning a plausible image. Note the check is==, notinherits(), so a SUBCLASS of any of them would slip past it silently. -
Why
offscreenand not a virtual display.--mcpsetsQT_QPA_PLATFORM=offscreen(apps/dashboard/main.cpp); without it the app uses the platform’s own plugin, so production and the agent loop already run two different configurations. The alternative – Xvfb plusxcb, oreglfson a board – gives a real GL stack with no monitor, and is how Qt GUI tests normally get a GPU in CI. It is what aQRhiWidgetwould need, since the offscreen plugin refusesRhiBasedRenderingon every platform.It is not adopted because of where the tests run. There is no CI:
ctestruns on developers’ machines, and macOS has no Xvfb, so moving the headless platform would stop the gui tests and the agent loop working there. On a GPU-less Linux runner it would also fall back to llvmpipe and render the map slower than offscreen QRhi does today. Revisit if Linux CI ever exists – and note the map is designed so this choice stays open, since its renderer asks the platform for nothing. GUI_THREAD_BUSYis an answer, not a bug. Handlers are posted to the GUI thread with a timeout rather than a blocking connection, precisely so that a wedged UI can still be diagnosed instead of hanging the caller too.mouse_transparentin a snapshot explains a click that lands correctly and does nothing:Canvas::setEditorMode()appliesWA_TransparentForMouseEventsrecursively when editor mode is off.accepted: falsefrominput.clickmeans the event was delivered but nothing consumed it. Normal for a decorative gauge; a real clue for a widget you expected to react.- A single screenshot proves very little on an animated widget. The CarPlay simulator’s test pattern moves, so two frames legitimately look wildly different and a colour “regression” can be pure frame timing. Compare hashes across several frames, or check pixel statistics, before concluding anything about rendering.
- Editor edits are undoable, including the ones you make.
editor.add_widget,editor.delete,editor.move,editor.resizeandwidget.set_configall go onto the same history the GUI’s Ctrl+Z uses, soeditor.undo/editor.redowill walk back a sequence an agent built. Both reportcan_undo,can_redoanddirty, so you can tell whether the layout still differs from the file on disk without saving it to find out. Widget names survive an undo — a selector you are holding stays valid. - The editor does not prompt about unsaved work under
--mcp. A modal dialog with nobody at the screen is a hang, not a question, soapp_launchreplacing an editor discards unsaved changes with a log line and no dialog.editor.savefirst if you care about them. - Use
carplay --simulatefor anything CarPlay-shaped. It publishes a synthetic session — H.264 video, PCM audio, rotating metadata — on the real zenoh topics, so the whole dashboard side is exercisable with no iPhone attached. Launch it throughapp_launch’s sibling process handling or by hand.
Adding a method
AgentServer::registerMethod(name, handler, kind). Handlers always run on the GUI thread and may touch widgets directly. Mark a handler kMutating if it changes state — the dispatcher then drains the event loop before returning, so a following screenshot observes the effect rather than the previous frame. See the editor.save / editor.load registrations in apps/editor/main.cpp for the shape.
New methods are reachable from Claude Code immediately via app_call(method, params) — no Python change needed. app_methods lists what a running build supports.
Dragging, and the one thing that cannot be synthesized
input.drag sends press, interpolated moves and release. It makes sure the first move clears QApplication::startDragDistance() whenever the overall distance does — Qt reads shorter motion as jitter, so a widget filtering on it would otherwise ignore the whole gesture. A drag too short to register at all comes back with a warning rather than appearing to work. Use it for CarPlay swipes and for moving or resizing on the editor canvas.
It does not drive Qt’s QDrag. QDrag::exec() runs a nested event loop that grabs the mouse and reads real platform events; synthesized events cannot advance it, and on the offscreen platform it may not run at all. So the palette-to-canvas drag goes through input.drop / editor.palette_drag, which send the QDragEnter → QDragMove → QDrop triple straight to the drop target. That bypasses the drag source but runs the entire receiving side — the accept/reject logic and the drop handler — which is where the behaviour worth testing lives. The only thing left uncovered is the handful of lines inside exec().
Logs
--mcp replaces the rotating file sink with an in-process ring plus stderr. app.logs takes a since_seq cursor, so polling returns only what is new:
app_logs(limit=20) # -> records + next_seq
app_logs(since_seq=<next_seq>) # -> only what arrived since
app_logs(level="warn", grep="carplay")
Qt’s own diagnostics are bridged into the same stream under the logger qt. Nothing captured them before — they went to stderr and vanished — and a QPA or layout complaint is often the explanation for a screenshot that came back wrong.
A dropped field means the ring wrapped and records were lost before you read them. The stderr sink stays because the ring dies with the process: a crash would otherwise take the entire log history with it, and the companion server captures that stderr for exactly that case.
Tests
ctest --test-dir build -R agent_control --output-on-failure
agent_control_test_framing(unit) — the JSON-RPC envelope, weighted towards malformed input: truncated JSON, wrong-typed fields, throwing handlers, and the invariant that a response never contains a raw newline.agent_control_test_selector(gui) — selector resolution against a real widget tree, weighted towards ambiguity, staleness and out-of-range indices.agent_control_test_log_ring(unit) — the cursor, filtering, eviction reporting and the Qt message bridge. This one caught a real off-by-one: an exclusive lower bound against a field namednext_seqsilently dropped one record per poll.
Status
Everything in the plan is implemented:
| Area | Methods |
|---|---|
| App | app.info, app.logs, app.quit |
| Inspect | ui.snapshot, ui.find, ui.screenshot (with annotate, if_changed_from), ui.wait_for |
| Input | input.click, input.key, input.type, input.drag, input.drop |
| Widget config | widget.describe_config, widget.get_config, widget.set_config |
| Pages | pages.list, pages.command (dashboard only; see Pages) |
| Zenoh | zenoh.list, zenoh.read, zenoh.publish, zenoh.rate, zenoh.describe_schema |
| Editor | editor.palette, editor.items, editor.add_widget, editor.palette_drag, editor.select, editor.move, editor.resize, editor.delete, editor.set_mode, editor.undo, editor.redo, editor.save, editor.load, editor.windows, editor.select_window, editor.add_window, editor.remove_window, editor.pages, editor.add_page, editor.remove_page, editor.rename_page, editor.set_page, editor.move_page, editor.show_page, editor.move_to_page, editor.scope |
| Scope | scope.panels, scope.add_panel, scope.remove_panel, scope.add_signal, scope.remove_signal, scope.browser, scope.browser_drag, scope.time_base, scope.panel_get_config, scope.panel_set_config, scope.panel_describe_config, scope.save, scope.load, scope.sample_stats (see docs/scope.md) |
| Switchboard | switchboard.services, switchboard.select, switchboard.form, switchboard.set_fields, switchboard.reset, switchboard.submit, switchboard.result, switchboard.history (see docs/switchboard.md) |
| Meta | rpc.methods |
Known limits, all deliberate: one instance per app type (scope and switchboard included); QDrag::exec() is not driven; Data fields are reported as a byte count rather than inlined (they are H.264 access units and PCM audio); and screenshots depend on rendering going through Qt’s backing store.