--- title: "Editor tools for browser agents (WebMCP)" description: "Let the agent built into the user's browser build, save and run the open workflow — reads run at once, changes wait for a click." source: https://flowdrop.io/docs/editor/browser-agents site: FlowDrop documentation --- # Editor tools for browser agents (WebMCP) **These are editor tools, not workflow tools.** They let an agent that lives in the browser — Chrome's, Edge's, Brave Leo, ChatGPT Desktop — edit the workflow that is open on the page. They have nothing to do with the tools a *running* workflow hands to an LLM node; that is a separate feature with its own vocabulary. [WebMCP](https://github.com/webmachinelearning/webmcp) is a W3C Community Group proposal: a page registers typed tools on `document.modelContext`, and a browser-resident agent discovers and calls them. FlowDrop's editor already has a typed command layer — add a node, connect two ports, set a config value, list, inspect, undo — so `@flowdrop/flowdrop/webmcp` registers those commands as tools, one per command, each with a JSON Schema. The agent gets structured arguments, structured errors, and argument completion — no text DSL to guess at. **The same tools power the built-in [AI Assistant](/editor/ai-assistant).** Since 2.8.0 the chat panel runs the model's tool calls through the very runtime described on this page — the same validation, the same approval dialog, the same one-transaction-one-undo-step — so a site user who never enables a browser agent gets the same abilities from the panel. ## What an agent can do with them An agent that only has the tools — no source, no admin access, no drush — can take a workflow from empty to a completed run. The tools are designed around that path, and every mutating tool's description ends with it: 1. **Read before you write.** `list_types` (or `search_types` with a substring) says which node types exist in *this* editor; a type not in that list is unavailable. `describe_type` then says everything about one type before it is used: input and output ports with data type and required flag, every config key with its type, enum, default and description, and — when the host publishes them — whether a run pauses for a person at this node (`confirmation`), what the user may do with it (`can`), and host-written guidance (`agent`). 2. **Build in one batch.** `batch` runs several commands as one transaction and one approval: add the nodes, `connect` the ports, `set_config` the keys. All succeed or none apply, and one undo reverts them all. 3. **Check what you built.** `list_nodes`, `list_edges`, `info` for a node instance, `get_config` for its current values *with the schema that explains them*. 4. **Save.** Nothing an agent changes is persisted until `save` runs or the person clicks Save. 5. **Run and watch.** `run` starts the saved workflow with optional inputs for its interface ports and returns a `runId`; `run_status` polls it. A run that pauses for a person comes back as `PENDING` with the node and message it waits on — the agent tells the user, and the user acts in the editor. No tool answers an interrupt. Every one of those facts travels in the tool descriptions and tool results. That is the entire interface the agent has, so when a call fails, **the error teaches**: an unknown node lists the workflow's nodes, an unknown type names near misses and points at `list_types`, an unknown port lists the node's ports on both sides, an unknown config key lists the keys. `set_config` on a key the type's schema does not declare is still stored — hosts keep config open — but carries an `unknown_key` warning naming the declared keys; under `strict: true` it fails instead. **The tools read only what the editor has already fetched.** The node type catalog and the open workflow are the two payloads; there are no per-tool requests and no discovery call. Whatever the agent should know about a node type, the host puts in the catalog payload (see [Host-published fields](#host-published-fields)). Freshness is the editor's: when it refetches, the tools see the new list. ## Setup ### Svelte host ```svelte ``` `attachWebMCP` returns `null` — silently — when the browser has no WebMCP runtime, so you can call it unconditionally. It detaches itself when the instance is destroyed (through `instance.onDestroy`, which your own integrations can use too). Registration is asynchronous in the spec. `handle.tools` lists the tools the runtime has accepted so far; `await handle.ready` if you need the final list. A tool the runtime refuses is warned about once and never listed. Every tool description carries the workflow's name so an agent facing two editors can tell them apart. The name is read when you attach, so attach once the workflow is loaded — the `App` component loads it on mount — and expect a later rename not to show until the next attach. ### Vanilla, Drupal, any framework ```ts import { mountFlowDropApp } from '@flowdrop/flowdrop/editor'; const app = await mountFlowDropApp(el, { endpointConfig: createEndpointConfig('/api/flowdrop'), webmcp: true }); app.webmcp?.tools; // ['flowdrop_add_node', 'flowdrop_connect', …] or undefined ``` The adapter is imported on demand, so the core bundle is unchanged when the option is off, and it attaches in the background once the workflow is loaded, so mounting is not delayed and no extra request is made — the tools read the node types the editor itself fetched. `app.webmcp` is therefore `undefined` for a moment after mount even in a browser that has the API. Pass an object instead of `true` to set the options below; `onSave` is supplied for you on this path, `onRun` and `onRunStatus` are yours to add. ## Options | Option | Default | What it does | | --------------- | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `nodeTypes` | _(the instance's)_ | The node type definitions the tools resolve types against. An array or a getter. Defaults to what the editor fetched. | | `prefix` | `'flowdrop'` | Tool names are `${prefix}_${verb}`. Two editors on one page need two prefixes; a second attach with the same prefix throws. | | `approval` | `'confirm'` | What happens before a change runs. See below. | | `rememberEdits` | `true` | Offer "apply further edits without asking" in the confirm dialog. Edits only; `save` and `run` always ask. | | `onUIAction` | _(none)_ | Handler for the `view` tool (select node, open config, zoom, pan). Without it the `view` tool is not registered. | | `onSave` | _(none)_ | Persist the workflow. When supplied, a `save` tool is registered, gated like a change. `mountFlowDropApp` supplies it automatically. May return an envelope. | | `onRun` | _(none)_ | Start a run with the given inputs; answers an envelope with `data.runId`. When supplied, a `run` tool is registered, gated like `save`. | | `onRunStatus` | _(none)_ | Report a run's status as an envelope. Registers `run_status` (read-only) when `onRun` is also given. | | `container` | `document.body` | Where the confirm dialog mounts. | | `messages` | English | Strings for the confirm dialog, as a partial override of `defaultMessages.webmcp` (or a getter for one) — the same shape as the `messages` prop. | ## Host hooks and the result envelope `onSave`, `onRun` and `onRunStatus` are how the tools reach your server, and they answer in one shape: ```ts interface HostEnvelope { ok: boolean; code?: 'FORBIDDEN' | 'UNAVAILABLE' | 'NEEDS_APPROVAL' | 'INVALID' | 'CONFLICT' | 'PENDING' | 'NOT_FOUND' | string; message?: string; data?: T; can?: Record; } ``` The tool relays `code`, `message`, `data` and `can` to the agent untouched. The codes mean: `FORBIDDEN` — the server refused, tell the user; `UNAVAILABLE` — nothing to act on yet (an unsaved workflow, say); `NEEDS_APPROVAL` — a person must approve in the UI first; `INVALID` — validation failed, the message says where; `CONFLICT` — the server copy changed since it was loaded, reload; `PENDING` — a run is paused for a person; `NOT_FOUND` — the run id is unknown. They line up with the FlowDrop Drupal module's `error_code` vocabulary where the two meet. ```ts attachWebMCP(fd, { onRun: async (inputs) => { const res = await fetch(`/api/flowdrop/workflow/${id}/run`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': token }, body: JSON.stringify({ inputs }) }); if (res.status === 403) return { ok: false, code: 'FORBIDDEN', message: 'Not permitted' }; const { data } = await res.json(); return { ok: true, data: { runId: String(data.pipelineId), status: data.status } }; }, onRunStatus: async (runId) => { const { data } = await (await fetch(`/api/flowdrop/pipeline/${runId}/status`)).json(); return { ok: true, data: { runId, status: data.status, // pending | running | paused | completed | failed | cancelled pending: data.pendingInterrupt ? { interruptId: data.pendingInterrupt.id, type: data.pendingInterrupt.type } : null } }; } }); ``` `onRunStatus` may return `status: 'paused'` with a `pending` block naming the `nodeId` and `message` the run waits on; the `run_status` tool turns that into an `ok` result with `code: 'PENDING'` and a message telling the agent a person must act. Once a run is `completed` or `failed`, put its result in `data.outputs` (host-shaped) and the agent reads it from the same call. `onSave` may still resolve to nothing (saved) or throw. A thrown error that is a 409 or carries `errorData.error_code === 'CONFLICT'` — what the library's `ApiError` does when the FlowDrop Drupal module refuses a stale write — is relayed as `CONFLICT` with a reload hint; anything else is `SAVE_FAILED`. ### Host-published fields The server stays the authority: `save` and `run` endpoints refuse what the user may not do, and the tools only pre-empt. Two optional fields on the workflow payload and three on each node type let a host say so up front: | Where | Field | What the tools do with it | | ------------ | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | Workflow | `can` | `{ save: boolean, run: boolean }`. `save`/`run` answer `FORBIDDEN` before opening a dialog when the key is `false`; absent means "ask the server". | | Workflow | `revision` | The revision the editor loaded. The save path sends it back; a server that checks it refuses a stale write with `CONFLICT`, and the editor takes the new value from the response. | | Node type | `confirmation` | `{ policy: 'ask' \| 'skip', source?, authorControls?, dynamicControls? }`. Shown by `describe_type` so the agent can warn the user a run will pause here. | | Node type | `can` | `{ add: boolean, … }`. Passed through by `describe_type`. | | Node type | `agent` | `{ usage?, examples?, pairsWith? }` — guidance you write for agents. Passed through verbatim; the library never interprets it. | The FlowDrop Drupal module publishes all five. Any host can, by adding the keys to its `NodeMetadata` and `Workflow` payloads; no library release is needed to improve what an agent reads about a node. ## The approval gate WebMCP's permission model decides which *origins* may see a page's tools. It says nothing about whether a given call may change the user's document, and any agent or extension on the page can call a registered tool. So consent is the editor's job: Calls fall into three classes: - **Reads run at once**: `list_types`, `search_types`, `describe_type`, `list_nodes`, `list_edges`, `info`, `get_config`, `run_status`. They carry `readOnlyHint: true`. - **View changes run at once too.** `view` — select a node, open its config panel, fit, zoom, pan — changes what the editor shows and nothing in the workflow; there is nothing to undo, and a dialog for it would only teach people to click Apply unread. - **Changes wait.** A dialog inside the page lists exactly what the agent wants to run — "Add node http_request", "Connect http_request.1:body → html_to_markdown.1:html" — and nothing touches the workflow until the person at the keyboard clicks **Apply**. Reject returns a `REJECTED` error to the agent and changes nothing. `undo` and `redo` are changes. A `batch` asks once, for the whole batch, and above the line list shows a summary of what changes: "4 changes — adds 2 nodes, connects 1 edge, sets 1 config key." `save` and `run` ask too, with their own wording, and carry `consequentialHint: true`. - **"Don't ask again" covers edits only.** The dialog offers _Apply further edits from any agent on this page without asking, until this page is closed_ — the gate cannot tell one agent from another, so the wording says what the tick does. Ticked and approved, later edits run without the dialog until the adapter detaches; `save` and `run` always ask and never offer the box. `rememberEdits: false` removes it. - **One decision at a time.** A second mutating call while a dialog is open is refused with `BUSY`; dialogs never stack. - **Every call is one transaction and one undo step**, exactly like an approved batch in the AI Assistant panel. A `batch` whose third command fails leaves the workflow as it was and reports which command failed and why. `approval` takes three shapes: ```ts attachWebMCP(fd, { nodeTypes, approval: 'confirm' }); // default: the dialog attachWebMCP(fd, { nodeTypes, approval: 'auto' }); // no dialog — see below attachWebMCP(fd, { nodeTypes, approval: async (commands, { tool }) => myOwnGate(commands, tool) }); ``` A custom approval function receives the commands about to run and a second argument, `{ tool }`, naming the tool being called (`add_node`, `batch`, `save`, `run`, …). For `save` and `run` there are no commands to describe — `commands` is an empty array. **`'auto'` removes the only consent step there is.** Use it when the host already trusts every agent that can reach the page — a kiosk, a test harness — and nowhere else. It is not a convenience setting. The layout opt-out in Settings (**AI layout changes**) applies here too: with it off, `auto_layout` and `beautify_layout` are skipped with the same wording the chat panel uses, and the rest of a batch still applies. ## What is exposed | Tool | Gated | Notes | | ------------------------------------------------ | ----- | ---------------------------------------------------------------------------------------------------------------------------------- | | `list_types`, `search_types` | no | The `typeId`s `add_node` accepts, with name, category, description, tags. A type not listed is unavailable. | | `describe_type` | no | Ports, config schema with enums and defaults, and the host's `confirmation` / `can` / `agent`. Call before `add_node` and `set_config`. | | `list_nodes`, `list_edges` | no | Short ids (`http_request.1`) everywhere. | | `info` | no | Ports, current config, connected edges of one node instance. | | `get_config` | no | One value with its schema entry, or every value plus the whole schema when `key` is omitted. | | `add_node`, `delete_node` | yes | Unknown type ids fail and name near misses. | | `rename_node`, `move_node` | yes | | | `swap_node` | yes | Reports which edges and config were dropped. | | `set_config` | yes | Strings stay strings; numbers, booleans, objects stay typed. Warns (or fails under `strict`) on enum, type and unknown-key problems. | | `connect`, `disconnect_ports`, `disconnect_node` | yes | An unknown port lists the ports on both sides. | | `undo`, `redo` | yes | A save cannot be undone. | | `auto_layout`, `beautify_layout` | yes | Skipped when layout changes are off in Settings. | | `view` | no | Only with `onUIAction`. Select, open config, fit, zoom, pan — changes the view, not the workflow. | | `save` | yes | Only with `onSave`, supplied automatically by `mountFlowDropApp`. `FORBIDDEN` / `CONFLICT` / `INVALID` from the host. | | `run` | yes | Only with `onRun`. Starts the saved workflow with optional `inputs`; returns `runId`. | | `run_status` | no | Only with `onRun` and `onRunStatus`. `PENDING` for a paused run; `outputs` once completed. | | `batch` | yes | Several commands, one transaction, one approval, one undo. | **Not exposed, on purpose:** `clear`. An agent can delete nodes one at a time, and each deletion is gated; wiping the whole workflow in one call is not something a browser agent should be able to ask for. `help` is not exposed either — the schemas are the help. And no tool answers an interrupt: a paused run is reported, not resolved. ## Browser support From the spec repository's `implementation-status.md`, read on 2026-09-02: | Client | Status | | --------------- | ---------------------------------------- | | Chrome 149 | Origin trial | | Edge 150 | Origin trial | | Brave Leo | Experimental | | ChatGPT Desktop | Supported | | Firefox, Safari | Under standards review | The API is a moving target: it has already moved from `navigator.modelContext` to `document.modelContext`, and `unregisterTool` has been replaced by an `AbortSignal`. The adapter feature-detects against the minimal shape it needs and keeps every runtime-touching line in one file; nothing in the core library depends on it. For local development, Chrome exposes the API behind `chrome://flags/#enable-webmcp-testing`; note that Chrome 151's `executeTool` takes the arguments as a JSON *string*. ## A desktop MCP client instead of a browser agent Until browsers ship the API, the same tools can be reached from a desktop MCP client (Claude Code, Claude Desktop, Cursor, …) through a relay: the page registers its tools with a small widget that talks to a localhost MCP server the user runs and authorises with a one-time token. `@flowdrop/flowdrop/webmcp` ships an adapter for the widget shape of [`@jason.today/webmcp`](https://github.com/jasonjmcghee/webmcp) — install it before the editor mounts and the adapter treats it as the runtime: ```ts import { installBridgedModelContext } from '@flowdrop/flowdrop/webmcp'; const widget = new WebMCP(); // the bridge's page-side script, loaded by the host installBridgedModelContext(widget); // no-op when a real document.modelContext exists const app = await mountFlowDropApp(el, { endpointConfig, webmcp: true }); ``` On the client side, `npx -y @jason.today/webmcp@latest --mcp` is the MCP server; the user asks their client for a token and pastes it into the widget. Tools arrive prefixed with the site host (`flowdrop_example_com-flowdrop_describe_type`). Nothing about the gate changes: reads run, every change still asks the person in the page, and save and run are still checked by the server. Three caveats: the widget cannot unregister a tool, so after the editor is destroyed its tools answer a structured `UNAVAILABLE` until the page unloads; the relay's MCP server times a tool call out after 30 seconds, so a confirm dialog left open longer returns a timeout to the agent while the dialog stays up (approving it then still applies the change); and this is a developer facility — each user needs the local server and a token per session — not a way to give ordinary site users an agent. **Drupal.** The FlowDrop module exposes both switches under *Browser agents* at `/admin/flowdrop/config/ui-settings`: **Expose editor tools to browser agents** (`webmcp_tools`, on by default — the site-wide off-switch) and **Load the desktop bridge widget** (`webmcp_bridge`, off by default, only meaningful with tools on). The widget ships vendored with the module; no CDN request is made. ## Security notes - **Untrusted text in, gated changes out.** Read tools return node labels and config values verbatim. An agent reading a workflow someone else authored is reading untrusted text, and prompt injection through it is possible. The gate is what contains it: injected instructions can make the agent *ask* for a change, not *make* one — and never make a save or a run without a click. - **Denials stay coarse.** A `FORBIDDEN` says "not permitted" and nothing about the rule; the tools never list what the user cannot see. - **Same origin, top level.** The adapter never sets `exposedTo` and FlowDrop does not add `allow="tools"` anywhere. The tools are visible to agents on this origin only. - **One prefix per editor.** Registering a second editor under the same prefix throws rather than silently shadowing the first. ## Testing without a browser agent CI has no agent and neither does most laptops yet. `createFakeModelContext()` returns a runtime that records registrations and lets you invoke a tool by name: ```ts import { attachWebMCP, createFakeModelContext } from '@flowdrop/flowdrop/webmcp'; const runtime = createFakeModelContext(); attachWebMCP(fd, { nodeTypes, approval: 'auto', modelContext: runtime }); const type = await runtime.call('flowdrop_describe_type', { nodeTypeId: 'http_request' }); // { ok: true, data: { typeId, inputs: [...], outputs: [...], config: [{ key, type, enum, default }], … } } const out = await runtime.call('flowdrop_add_node', { nodeTypeId: 'http_request' }); // { ok: true, message: '…', data: { nodeId: 'http_request.1', … } } ``` The library's Storybook has a **WebMCP** story with a fake-agent side panel that does the same thing interactively, dialog included.