Agents & MCP
You don’t need any of this to use compose-ai-tools — rendering @Previews to
PNG already lets an agent see its work. This page is for when you want a
tighter, push-based loop than “run a command, read a file.”
Configuring an MCP client? The hosted server’s one URL is
https://preview.coo.ee/mcp; the local server is compose-preview mcp serve.
See Two MCP servers before adding either.
The agent loop
A token-frugal feedback loop over Compose UI — the way
Playwright gave web agents one over the DOM: act by
a stable reference, observe structure rather than pixels, and turn
exploration into durable tests. These are exposed by the preview daemon’s MCP
server (and the compose-preview CLI where noted).
- Target by semantic ref, not pixels.
interactive/inputandrecord_previewaccept atarget(testTag/role+text/ a stable noderef) that the daemon resolves to the node’s centre, so a click survives layout changes instead of breaking on a coordinate. Android (Robolectric) and Desktop (Skiko). - Token-frugal observation.
render_preview observe=semantics|hashreturns thecompose/semanticstree + a hash + dimensions instead of a base64 PNG — typically a few hundred tokens versus ~1.5k. Fetch pixels only when you actually need to look. - Semantics diff.
diff_semantics(MCP) andcompose-preview diff-semantics(CLI) diff two semantics trees and report what changed semantically (text, label, role, testTag, overflow…), matched by stable ref — a deterministic, pixel-free regression signal, the Compose analogue of Playwright’s aria-snapshot diff. - Matrix render.
render_matrix(andcompose-preview render-matrix) renders one preview across a cross-product ofdevice × locale × uiMode × fontScalein a single call, returning a per-cell hash and which cells changed — “does this survive small screen + RTL + large font?” without N screenshots. Opt into a stitched contact-sheet image when you want to eyeball every cell at once. - Recording → test.
record_preview emitTest=trueturns a scripted interaction into a runnable Compose UI test (semantic targets becomeonNodeWithTag(...).performClick()steps; eachrecording.probeis diffed against the previous probe’s captured semantics intoassertExists()/assertDoesNotExist()assertions). - Structured failures. A failed render reports a typed
kindplus a one-line fix hint for recognized signatures (classpath skew, Robolectric SDK mismatch, missing@Composable, …) instead of an opaque message.
Cost budget for these in
docs/TOKEN_USAGE.md.
Two MCP servers: local and hosted
There are two different MCP servers. Configure each at most once.
| Local daemon MCP | Hosted catalog MCP | |
|---|---|---|
| What it renders | Your project’s @Previews, on your machine |
Published design-system catalogs (Material 3, Wear M3, …) and the UI Builder |
| How it runs | stdio: compose-preview mcp serve |
Streamable HTTP at https://preview.coo.ee/mcp |
| Server name in configs | compose-preview-mcp |
compose-preview-catalog |
| Needs | The CLI on PATH, Java 17+, a Gradle project |
Nothing local |
https://ui.coo.ee/mcp is an alias of the same hosted server, not a second
one: same process, same tools. Never configure both. Two entries give the agent
every tool twice, and because the CLI keys access grants by origin they also
mean two separate grants to approve and refresh. If a config already has a
ui.coo.ee entry, replace its URL with https://preview.coo.ee/mcp rather than
adding a second server. (https://ui.coo.ee/start is the UI Builder start page
for people; it is not an MCP endpoint.)
The compose-preview plugin from
yschimke/compose-agent-plugins
wires both servers for you (install commands on the home page);
how each other host is configured is in its
host setup notes.
The rest of this page is about the local server.
The local MCP server
compose-preview mcp serve launches the local server (published from
compose-preview-mcp and
fetched on first use). It exposes the preview daemon’s JSON-RPC over stdio, so
MCP-aware agents can render previews on demand and be notified when bytes
change — instead of running Gradle (10s+ cold) or polling PNGs off disk with
no way to request a re-render.
Each @Preview becomes one MCP Resource under a stable
compose-preview://<workspaceId>/<module>/<previewFqn> URI. The server
supports subscribe (per-resource update notifications) and listChanged
(the set of resources mutated), plus notifications/progress for
long-running calls. Data products are read via list_data_products,
subscribe_preview_data, and get_preview_data.
For the full tool surface, URI scheme, and wire protocol see
docs/daemon/MCP.md.
Register the local server
Run compose-preview mcp install once from each Compose project’s root. It
writes daemon descriptors and runs discovery for every module with the plugin
applied, then registers compose-preview-mcp with each agent host it detects
(Claude Code, Codex, OpenCode, Antigravity; --<host> / --no-<host> force or
skip one). compose-preview mcp register does only the host registration,
without touching Gradle; compose-preview mcp doctor reports what is set up.
- One global entry per host. Each entry is
<stable launcher> mcp serve(for example~/.local/bin/compose-preview), never a versionedcompose-preview-<version>/bin/path that the next upgrade deletes, and has no--project: the server finds the project from the client’s roots or working directory. Only OpenCode’s--scope projectwrites a project-localopencode.jsonwith--project=<dir>. - Healthy entries are left alone.
mcp installonly adds a missing entry or repairs a broken one, preserving sibling keys and servers.compose-preview mcp repair(run bycompose-preview update) points existing entries at the stable launcher and drops a global--project, for example after an upgrade removed an old versioned directory. - Plugins win. With the Antigravity
compose-previewplugin installed (~/.gemini/config/plugins/compose-preview),mcp installandmcp registerwrite no global Antigravity entry, since the plugin already provides the server;mcp doctornames any older globalcompose-preview-mcpentry (in~/.gemini/antigravity/mcp_config.jsonor~/.gemini/config/mcp_config.json) to remove.--antigravitywrites the global entry anyway. When Antigravity, Claude Code or Codex is detected,mcp installprints the matching wiring-plugin commands;--no-plugin-hintsuppresses them. - Comments are never rewritten. An
opencode.jsonc, or any config with comments, gets the snippet and the file to merge it into printed instead. OpenCode entries use the v2mcp.serversshape; the oldermcp.compose-preview-mcpshape withenabled: trueis not v2 configuration.
--codex-config, --opencode-config and --antigravity-config name an
explicit config file. Full flag list: compose-preview mcp --help. Per-host
paths and verification steps (opencode mcp list, …):
host setup notes
and the OpenCode guide.
Connect a remote UI-builder session
The native MCP profile can expose eight additional tools backed by the same persisted preview-server UI-builder session a browser is editing. Supply the server URL as an argument and the temporary agent grant as an environment variable, keeping the secret out of process arguments:
COMPOSE_PREVIEW_UI_BUILDER_TOKEN='<grant>' \
compose-preview mcp serve --ui-builder-url https://preview.example/
Grants keep ui-builder-read, ui-builder-write, and ui-builder-export
independent. The adapter is a remote protocol client only: it does not copy the
server’s reducer, catalog, renderer, or persistence into this repository.
Shared settings
The MCP server’s settings_read / settings_update tools (the native
settings page in ChatGPT and Codex) persist their defaults to one file,
~/.compose-preview/settings.json — or wherever COMPOSE_PREVIEW_SETTINGS_FILE
points:
{ "schema": "compose-preview-settings/v1", "values": { "darkTheme": true, "locale": "fr" } }
The compose-preview CLI reads the same file (it never writes it) with the
server’s precedence: an explicit flag beats a setting, which beats the
built-in default.
| Setting | Where the CLI applies it | The flag that wins over it |
|---|---|---|
device (id:<device>, or preview for none) |
render-matrix device axis, record overrides |
--device, --overrides device=… |
darkTheme |
render-matrix ui-mode axis, record overrides |
--ui-mode, — |
fontScale (0 for none) |
render-matrix font-scale axis, record overrides |
--font-scale, --overrides fontScale=… |
locale (BCP-47, empty for none) |
render-matrix locale axis, record overrides |
--locale, --overrides localeTag=… |
A setting fills only what the command line leaves unset: render-matrix
--locale en,ar with darkTheme on renders both locales dark. show and
render drive the Gradle render, which draws each preview exactly as its
@Preview declares, so they cannot apply these four; when any is set they
say so on stderr rather than ignore it quietly. The server-only keys
(renderResult, imageToModel, replicasPerDaemon, uiBuilderMcpAppLayout)
and any key the CLI does not know are ignored. A bad value falls back to its
default with a warning and keeps the other keys; a file that is not valid JSON
warns once and every default applies.
Deep links into the ChatGPT / Codex sidebar
The local server’s previews_library tool is a sidebar app, and
compose-preview show --link prints a link per preview that opens it there,
rendered on open:
compose-preview show --id com.example.HomeKt.HomePreview --link \
--openai-plugin-id <plugin id> --openai-marketplace <marketplace>
| Flag | Opens in | Link |
|---|---|---|
--link (or --link=desktop) |
ChatGPT / Codex desktop | codex://plugins/<id>@<marketplace>/app/previews_library?path=… |
--link=mobile |
ChatGPT mobile | chatgpt://plugins/<id>@<marketplace>/app/previews_library?path=… |
--link=web |
chatgpt.com | https://chatgpt.com/plugins/<id>/app/previews_library?path=… |
The path is the library route /preview/<compose-preview URI>,
percent-encoded (RFC 3986: a space is %20, and / ? & = : @ inside a value
are escaped). The plugin id and marketplace depend on how the plugin was
published, so they are never guessed: pass the flags, or set
COMPOSE_PREVIEW_OPENAI_PLUGIN_ID (and COMPOSE_PREVIEW_OPENAI_MARKETPLACE
for a plugin installed from a custom marketplace; a flag beats the variable).
Without a plugin id, show --link prints that guidance instead of a link and
still shows the previews. Text output adds a link: line under each preview;
--json adds a link field to each one.
The link’s workspace segment is derived the way the server derives it for a
project registered by path, from the project directory’s name and canonical
path — a project registered under a custom rootProjectName has a different
id, and its links will not resolve.
What we tell agents
Point the agent at the
compose-preview skill.
It’s the install-and-iterate playbook: it checks for the CLI, bootstraps it if
missing (setup),
and walks the agent through rendering and verifying.