Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
433ea41
feat: add command schema types and zero-dep validator (#942)
rx18-eng Jul 1, 2026
3a48e9c
feat: add CommandRegistry with schema-validated execute (#942)
rx18-eng Jul 1, 2026
80e6510
feat: add default command set wrapping existing Phoenix actions (#942)
rx18-eng Jul 1, 2026
285978a
feat: expose getCommandRegistry on EventDisplay (#942)
rx18-eng Jul 1, 2026
a7c1a69
docs: document CommandRegistry host field for 100% doc coverage (#942)
rx18-eng Jul 1, 2026
e50aaef
fix: reject inherited-name keys in command arg validation (#942)
rx18-eng Jul 1, 2026
efcf85c
feat: add Ctrl/Cmd+K command palette over the command registry (#942)
rx18-eng Jul 2, 2026
561d1eb
fix: close the palette before running a command so its overlay leaves…
rx18-eng Jul 3, 2026
715a1b2
fix: float command palette panel instead of a full-screen backdrop ov…
rx18-eng Jul 3, 2026
61cf72f
fix: run palette listeners outside NgZone and drop per-hover change d…
rx18-eng Jul 3, 2026
6ffd6c6
fix: drop the per-command success toast so MatSnackBar cannot stall t…
rx18-eng Jul 3, 2026
5ce1642
fix: precompute palette param-form fields to stop an infinite change-…
rx18-eng Jul 9, 2026
9a0fe36
feat: add the natural-language intent core for the command registry (…
rx18-eng Jul 9, 2026
f7fcbff
feat: add a natural-language orchestrator service for the command reg…
rx18-eng Jul 9, 2026
54ba25b
feat: add natural-language Ask mode to the command palette (#942)
rx18-eng Jul 10, 2026
77d64cd
feat: wire the in-browser WebLLM engine as the palette's language mod…
rx18-eng Jul 10, 2026
80fbe01
feat: improve NL command accuracy (few-shot prompt, live enum values,…
rx18-eng Jul 10, 2026
bcff54c
feat: add side-effect-free interpret() to the natural-language servic…
rx18-eng Jul 10, 2026
a20a43b
feat: add a vetted knowledge base so the Ask box answers without a mo…
rx18-eng Sep 23, 2026
5b23c30
feat: resolve command enum values from the live event display (#942)
rx18-eng Sep 23, 2026
148b9aa
fix: make camera projection idempotent and refuse unknown events and …
rx18-eng Sep 23, 2026
0718c51
fix: bound natural-language input and make the session decoder size c…
rx18-eng Sep 23, 2026
24353d9
feat: add a command palette toolbar button and make the palette keybo…
rx18-eng Sep 23, 2026
91f934b
feat: expose the command registry over JSON-RPC, opt-in and origin-al…
rx18-eng Sep 23, 2026
fa00447
fix: route questions apart from commands, and gate answers against th…
rx18-eng Sep 23, 2026
4bf1357
fix: cover the upstream histogram panel in the tutor and fix its stal…
rx18-eng Sep 23, 2026
dedeee2
test: move the warm-question budget so its negative control fires on …
rx18-eng Sep 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 164 additions & 0 deletions examples/agent-bridge-demo.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Phoenix agent bridge demo</title>
<style>
body {
margin: 0;
font-family: system-ui, sans-serif;
display: grid;
grid-template-columns: 360px 1fr;
height: 100vh;
color: #eee;
background: #1a1a1a;
}
#panel {
padding: 16px;
overflow-y: auto;
border-right: 1px solid #333;
}
h1 {
font-size: 16px;
margin: 0 0 4px;
}
p.hint {
font-size: 12px;
color: #aaa;
margin: 0 0 16px;
}
button {
display: block;
width: 100%;
margin: 4px 0;
padding: 8px 10px;
text-align: left;
border: 1px solid #444;
border-radius: 6px;
background: #242424;
color: #eee;
cursor: pointer;
font-size: 13px;
}
button:hover {
border-color: #00bcd4;
}
#log {
margin-top: 16px;
font-family: ui-monospace, monospace;
font-size: 11px;
white-space: pre-wrap;
color: #9fe;
}
iframe {
width: 100%;
height: 100%;
border: 0;
}
</style>
</head>
<body>
<div id="panel">
<h1>Phoenix agent bridge</h1>
<p class="hint">
This page embeds Phoenix and drives it over postMessage, the same way an
external application or AI agent would. Serve this page over http from
localhost on a different port than Phoenix (for example
<code>npx http-server examples -p 8080</code>). Opening it as a file://
page will not work: that origin is opaque ("null") and Phoenix refuses
it. Click initialize first; only an initialized client receives the
event stream.
</p>
<button id="init">initialize</button>
<button id="list">tools/list</button>
<div id="tools"></div>
<div id="log"></div>
</div>
<iframe id="phoenix"></iframe>

<script>
// The one Phoenix this page talks to. Everything below is checked against
// this origin, which is the part embedders should copy.
const PHOENIX_URL = new URL('http://localhost:4200/atlas?agent=1');
const PHOENIX_ORIGIN = PHOENIX_URL.origin;

const frame = document.getElementById('phoenix');
frame.src = PHOENIX_URL.href;
const logEl = document.getElementById('log');
const toolsEl = document.getElementById('tools');
let nextId = 1;

const log = (label, data) => {
logEl.textContent =
`${label} ${JSON.stringify(data)}\n` + logEl.textContent;
};

/** Send one JSON-RPC request to the embedded Phoenix, and to nobody else. */
const call = (method, params) => {
const message = { jsonrpc: '2.0', id: nextId++, method };
if (params) message.params = params;
// An exact targetOrigin, never '*': if the frame has navigated to some
// other site, the browser drops the message instead of delivering it.
frame.contentWindow.postMessage(message, PHOENIX_ORIGIN);
};

window.addEventListener('message', (event) => {
// Trust nothing until the sender is proven to be the Phoenix frame:
// right origin AND the very window this page embedded. Any other page
// or frame can post to this window too.
if (event.origin !== PHOENIX_ORIGIN) return;
if (event.source !== frame.contentWindow) return;

// Then validate the shape before using any of it.
const message = event.data;
if (!message || typeof message !== 'object') return;
if (message.jsonrpc !== '2.0') return;

if (message.method === 'phoenix/event') {
log('event', message.params);
return;
}
if (!('id' in message)) return;
log('reply', message);

// Render a button per tool when a tools/list reply arrives.
const tools = message.result && message.result.tools;
if (!Array.isArray(tools)) return;
toolsEl.replaceChildren();
for (const tool of tools) {
if (!tool || typeof tool.name !== 'string') continue;
const button = document.createElement('button');
// textContent, never innerHTML: tool text is data, not markup.
button.textContent = `${tool.name}${tool.mutates ? '' : ' (read-only)'}`;
button.onclick = () => {
const args = {};
const properties =
(tool.inputSchema && tool.inputSchema.properties) || {};
for (const [name, prop] of Object.entries(properties)) {
// tools/list carries the live allowed values in `enum`.
const choices = Array.isArray(prop.enum)
? ` (one of: ${prop.enum.join(', ')})`
: '';
const value = window.prompt(
`${tool.name}: ${name}${choices}`,
'',
);
if (value === null || value === '') continue;
args[name] =
prop.type === 'boolean'
? value === 'true'
: prop.type === 'number' || prop.type === 'integer'
? Number(value)
: value;
}
call('tools/call', { name: tool.name, arguments: args });
};
toolsEl.appendChild(button);
}
});

document.getElementById('init').onclick = () => call('initialize');
document.getElementById('list').onclick = () => call('tools/list');
</script>
</body>
</html>
1 change: 1 addition & 0 deletions guides/developers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
* [Convert GDML/ROOT Geometry to GLTF](./convert-gdml-to-gltf.md)
* [Geometry tips and tricks](./geometry-tips.md)
* [How to make a Phoenix release](./../release.md)
* [Controlling Phoenix from another page or an agent](./agent-bridge.md)
* [How Phoenix tests work](./test-setup.md)

## Phoenix architecture
Expand Down
220 changes: 220 additions & 0 deletions guides/developers/agent-bridge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
# Agent bridge: controlling Phoenix from another page or an agent

## Overview

Give Phoenix a structured, schema-described **inbound control API** so another
web context (a parent page, a Jupyter notebook, a dashboard, or an AI agent
embedding Phoenix in an iframe) can discover and run Phoenix's registered
commands, and observe what happens, over `window.postMessage`. This directly
advances the open maintainer issue #826 ("Allow easier integration of phoenix
in other applications") and completes the command API (#942): the registry and
palette gave Phoenix an internal action layer; this exposes that layer to the
outside, physics-safely.

The wire format is JSON-RPC 2.0 using MCP method names (`initialize`,
`tools/list`, `tools/call`, plus a `phoenix/event` notification), so it reads
like the Model Context Protocol and a future MCP server adapter maps onto it
with no rework.

## Goals

- A transport-agnostic bridge in the framework-agnostic core
(`phoenix-event-display`), zero new dependencies, reusable by every
experiment (ATLAS, CMS, LHCb, TrackML, Belle II).
- A `PostMessageTransport` so an iframe embedder can drive Phoenix.
- Live outbound event stream: every command that runs (from the bridge, the
Ctrl/K palette, or the natural-language Ask box) is forwarded to initialized
clients.
- Safe by default: no listener exists unless an app opts in, and then only
allowlisted origins are served, each client only receives its own replies.
- A minimal framework-free demo page and full tests.

## Non-goals

- The Node MCP stdio adapter for desktop agents (Claude Desktop). Deferred; the
transport abstraction makes it a later add with no rework.
- Voice input (cut).
- Any change to existing commands or the NL layer.

## Architecture

```
AgentTransport (abstract) -> createAgentBridge (core, 0 deps)
onMessage(message, peer) initialize / tools/list / tools/call
close() -> registry.list() + live enums / registry.execute()
<- COMMAND_EXECUTED_EVENT as phoenix/event
^ (initialized peers only)
| PostMessageTransport (browser): origin policy + per-window peers
parent page / notebook / iframe embedder
```

## Components

Folder: `packages/phoenix-event-display/src/managers/command-registry/agent-bridge/`

- `json-rpc.ts`: JSON-RPC 2.0 helpers: `isJsonRpcRequest`, `isNotification`,
`isValidRequestId`, `makeResponse`, `makeError`, `makeNotification`, error
codes `-32600/-32601/-32602/-32603`.
- `agent-transport.ts`: the transport contract.
- `AgentPeer { origin; send(message); isClosed() }`: one client, as
authenticated and addressed by the transport.
- `AgentTransport { onMessage(cb: (message, peer) => void); close() }`.
- There is deliberately no `send()` on the transport. A transport
authenticates senders and addresses replies; it never broadcasts. Session
state (who completed `initialize`) belongs to the bridge.
- Why peers and not an id-to-window map: JSON-RPC ids are only unique per
client (two clients both start at 1), so an id-keyed map misroutes replies.
- `origin-policy.ts`: `OriginRule = string | (origin) => boolean`,
`ANY_ORIGIN = '*'`, `isLoopbackHostname`, `isLoopbackOrigin`,
`isOriginAllowed`. All checks parse with `URL`; no substring or regex matching.
- `post-message-transport.ts`: `PostMessageTransport` over window `message`
events. Drops disallowed origins and messages without a usable
`event.source`; hands the bridge one peer per sender window (a `WeakMap`, so
closed or removed windows are never kept alive); a peer posts only to its own
window with the verified origin as `targetOrigin`.
- `agent-bridge.ts`: `createAgentBridge(host, options)` returning
`{ dispose(), resubscribe() }`. Options: `transport?`, `origins?:
OriginRule[]`, `window?`, `allowMutations?` (default true).
- Sibling `enum-sources.ts`: `resolveEnumSources(host)` returns
`{ collections, presetViews, eventKeys, geometryParts }` from the live
display. Never throws.

App wiring (`phoenix-ui-components`): `AgentBridgeService` (root) plus
`provideAgentBridge(config?)`, an `EnvironmentProviders` built with
`provideAppInitializer`. The phoenix-app adds `provideAgentBridge()` to
`AppModule.providers`. No component touches the bridge.

Demo: `examples/agent-bridge-demo.html`.

## Data flow

All messages are JSON-RPC 2.0. Every response goes to the requesting client
only.

**initialize** (required for the event stream; `tools/list` and `tools/call`
work without it):

```
-> {jsonrpc:"2.0", id, method:"initialize"}
<- {jsonrpc:"2.0", id, result:{ protocolVersion, serverInfo:{name:"phoenix", version},
capabilities:{tools:{}}, toolCount }}
```

**tools/list**: `inputSchema` is plain JSON Schema. The internal `enumSource`
keyword is never emitted; parameters backed by a live source (`collection`,
`view`, `eventKey`, `part`) carry a concrete `enum` of current values, or no
`enum` when that source is empty. The conversion is the NL layer's own
`toArgsSchema` (through `buildIntentSchema`), so tools/list and the model
grammar always agree.

```
-> {id, method:"tools/list"}
<- {id, result:{ tools:[ {name, description, inputSchema, mutates}, ... ] }}
```

**tools/call**:

```
-> {id, method:"tools/call", params:{ name, arguments }}
<- {id, result:{ isError:false, content:[{type:"text", text}], structuredContent:<result> }}
```

**phoenix/event** (notification on every command execution, from any source,
sent only to clients that completed `initialize`):

```
<- {jsonrpc:"2.0", method:"phoenix/event", params:{ name, args, result }}
```

## Error handling

- Origin not allowed, or no `event.source` to answer: dropped silently, no reply.
- Not a JSON-RPC request (including JSON-RPC responses): ignored, never answered
(so two bridges cannot ping-pong).
- Notification (no `id`): never answered and nothing runs (JSON-RPC 2.0 section
4.1; MCP defines every served method as a request).
- `id` present but not a string or finite number (including `null`): `-32600`
with `id: null`.
- Unknown method: `-32601`. Unknown tool or missing name: `-32602`.
- Command fails at runtime or fails validation: `tools/call` result with
`isError:true`.
- Mutation refused by policy: `isError:true` with a clear message.
- A reply the client cannot receive (for example a `DataCloneError`): `-32603`
to that client instead of leaving it waiting.

## Lifecycle

- `provideAgentBridge()` runs once at application startup, independent of the
command palette and of `uiConfig.showCommandPalette`. The bridge lives as long
as the app; `AgentBridgeService.ngOnDestroy` disposes it with the root
injector.
- `enable()` disposes any previous bridge first, so there is never a second
window listener. `enableFromLocation()` is a no-op when already enabled.
- `EventDisplay.init()` calls `cleanup()`, which clears every event-bus
subscriber. Every route change re-initialises the one root display, which
would silently cut `phoenix/event`. The service therefore calls
`handle.resubscribe()` from an `afterEveryRender` hook (idempotent, two map
operations); a client re-sending `initialize` also re-attaches it. A cleaner
long-term fix is in `EventDisplay` itself (keep app-lifetime subscribers
across `cleanup()`, or expose an init hook).
- Zones: the window listener is registered outside Angular's zone, so messages
from unknown origins never trigger change detection; accepted messages are
delivered inside the zone so commands that update Angular UI still render.

Why app-level and not library-level: other experiments embed
`phoenix-ui-components`. Making `PhoenixUIModule` start the bridge by itself
would give every embedder an inbound control channel it never asked for.
`provideAgentBridge` is the idiomatic Angular 20 opt-in (like
`provideHttpClient`): an app that does not add it has no listener at all.

## Security posture

- Default OFF: no listener exists unless the app adds `provideAgentBridge`.
- `provideAgentBridge({ origins })`: exactly those origins (an explicit `'*'` is
honoured only here, never produced by the URL switch).
- `provideAgentBridge()` with `?agent=1`: only when the page itself is served
from `localhost`, `127.0.0.1` or `[::1]`, and then only for loopback origins
(`http:`/`https:` on those hosts, any port). The switch must be
unambiguous: `agent=1&agent=0`, or a search and a hash query that disagree,
stay dormant. The app uses path routing, so the switch is normally in the
search string.
- The opaque `"null"` origin is never admitted, under any rule: a sandboxed
`srcdoc` iframe gets it for free, and a reply to it would need targetOrigin
`'*'`.
- A missing, empty or non-array allowlist admits nobody; a malformed entry
admits nothing; a matcher that throws denies.
- Responses go only to the requester; events only to initialized clients.
Sending a message enrolls nobody in anything.
- Every call still goes through `registry.execute`, so schema validation and
the registered-command whitelist apply unchanged.
- Residual: with `?agent=1` any local web app on any loopback port can drive the
dev server, including reading loaded data with `get-object`. That requires
code already running on the developer's machine.

## Physics safety

Unchanged and by construction: the bridge exposes the same registry with the
same validation. An external agent cannot invent a command, pass invalid
arguments, or reach any method not deliberately wrapped as a deterministic
command. It drives real reconstructed data and reports real numbers; it never
generates physics.

## Testing

1. `agent-bridge.test.ts`: MockTransport + MockPeer over a real
`CommandRegistry`: initialize, tools/list (valid JSON Schema, live enums,
empty sources), tools/call, error codes, notifications, invalid ids, falsy
ids, per-client replies with colliding ids, events only to initialized
clients, closed and failing clients, mutation policy, `resubscribe()` against
a real `EventDisplay.cleanup()`, dispose.
2. `post-message-transport.test.ts`: `isLoopbackOrigin` and `isOriginAllowed`
tables, per-window peers, no broadcast, weak holding of windows, lifecycle.
3. `coverage-gaps.test.ts`: fail-closed for `{}`, `{origins: undefined}`,
`{origins: []}` at the transport and through `createAgentBridge`.
4. `enum-sources.test.ts`: values not keys, never throws, de-duplication.
5. `agent-bridge.service.test.ts`: URL switch (search, hash, disagreement,
production host with no listener), loopback-only admission, no double
listener, survives a display re-init via the render hook, and
`provideAgentBridge` startup behaviour.
6. Live check (manual, `~/.phx-tools/bridge-verify.mjs`): a parent page on
another localhost port embeds `/atlas?agent=1`.
Loading
Loading