GUI editors as browser-pane tabs: Plugins (plugins and marketplaces), MCP (MCP servers) and Cockpit (this session live). /editors [plugins | mcp | cockpit]

Claude Code mods are no longer limited to what a terminal can draw. browser-pane gives every mod a tab in one shared browser window, and with it the whole web platform: 3D with Three.js, CAD with JSCAD, rich editors, live charts, Web Audio and MIDI, speech in and out, WebRTC, and whatever else a web page can do. The terminal stays where the conversation happens; the browser is where a mod shows and works with what the conversation is about.
A mod adds its tab through $.browserPane, a Guide tab lists them all, and browser-pane runs the one local server and window behind them, securely. The integration guide explains the idea and walks you through building your own.

api/ requests to its module.At the prompt of a terminal session:
/plugin install browser-pane --marketplace sandipchitale/browser-pane
From a shell: claude plugin install browser-pane --marketplace sandipchitale/browser-pane. Answer y to add the marketplace, then choose a scope.
You usually don't install it yourself: a mod that uses it lists it as a dependency, and installing that mod installs browser-pane too. To try it, install one of the examples:
/plugin install browser-pane-template --marketplace sandipchitale/browser-pane
Commands: /browser-pane opens the window on the Guide tab, /browser-pane <tab> on a tab.
It needs Node.js on your PATH and Google Chrome, which opens the window as an app window in a profile of its own. Without Chrome it opens your default browser.
examples/images/ is a complete mod that uses it; this section walks through the same steps.
In your mod's .claude-plugin/plugin.json:
"dependencies": ["browser-pane"]
Claude Code then loads your mod only when browser-pane is installed, and lays browser-pane's types into your mod's .claude-plugin/types/, so $.browserPane is typed.
A bare name is looked up in your own marketplace, so list browser-pane there too. Then one install line installs both:
{
"name": "browser-pane",
"source": { "source": "github", "repo": "sandipchitale/browser-pane" }
}
Two mods that each list it share one copy: the second is skipped, as it has the same name.
From session.start. This is cheap: nothing starts until the first show.
on('session.start', async ($, e, next) => {
await $.browserPane.register({
id: 'images', // lowercase letters, digits, dashes
title: 'Images',
root: `${$.plugin.root}/tab`, // your static files: index.html at least
server: `${$.plugin.root}/tab-server/server.mjs`, // optional
description: 'One line for the Guide tab.',
pinned: true, // optional: first in the Guide and the tab strip
})
return next(e)
})
const r = await $.browserPane.show({ tab: 'images', select: '2', data: { any: 'json' } })
if (!r.isOpened) {
// r.reason says why: no $.process here (desktop app, IDE), no Node, ...
// Show the content your own way, say in a Pane.
}
select reaches the tab's page (see onSelect below).data reaches the tab's server module (update) before the tab shows.r.how is 'opened' or 'reused'.$.browserPane.update({ tab, data }) hands new data to the server module without bringing the window forward. The latest data is kept, and sent again if the server restarts.
Your root is served at /tab/<id>/. Load the client in <head>, so the window's theme applies before the page paints:
<head>
<script src="/bp/client.js"></script>
</head>
<script>
browserPane.onSelect(select => { /* show it */ }) // the one it opened with first
browserPane.onRefresh(() => { /* reload your data */ }) // the window's Refresh button; without one, the page reloads
browserPane.api('info').then(r => r.json()).then(info => { /* ... */ }) // /tab/<id>/api/info
</script>
The window draws the controls every tab would otherwise repeat, as icons at the right of the tab strip: Refresh (the tab showing) and Theme (one button each for System, Light and Dark, remembered). A tab draws neither. It styles itself from <html data-theme="light|dark">, which the client keeps in step, and can read browserPane.theme or listen with browserPane.onTheme(theme => ...).
Dotfiles, and links that lead outside root, are never served.
A Node ES module whose default export makes the tab's handlers. It runs inside browser-pane's server, with full Node:
export default function create({ id, root }) {
let data = {}
return {
update(next) { data = next }, // from show({ data }) and update()
handle(req, res, sub) { // requests under /tab/<id>/api/; sub = 'info'
if (sub === 'info') { res.end(JSON.stringify(data)); return true }
return false // 404
},
}
}
If the module fails to load or throws, only that tab is affected: its requests answer 404 or 500, and the Guide tab shows the error. A changed module is imported afresh the next time the tab registers.
Run commands as argv arrays (spawn(cmd, args)), never through a shell, and serve only files you chose, never a path the page sent.
Each example is an independent mod: its own folder, its own plugin.json with browser-pane as a dependency, and its own .claude-plugin/marketplace.json. This repository's marketplace lists them too, so each installs from GitHub in one line, browser-pane with it. Each folder is also shaped like a repository of its own, the way a mod you write against browser-pane would be: its own marketplace lists the mod (./) and browser-pane (from GitHub).
| Mod | Tab | What it does | ||
|---|---|---|---|---|
browser-pane-template (examples/template) | Template | A minimal starter: a page, a server module, and the hooks between them. Copy it to start your own mod; its integration.md explains the integration with browser-pane, and its CLAUDE.md points Claude Code there. /template [text]. | ||
browser-pane-images (examples/images) | Images | Browse the images pasted or dropped into the prompt: 🖼 Image #N chips above the prompt, a master/detail tab with preview, open in the OS viewer, show in Finder, copy path. `/images [n \ | pane]`. | |
browser-pane-editors (examples/editors) | Plugins, MCP, Cockpit | Three tabs from one server module. Plugins: install, enable, update plugins and manage marketplaces. MCP: servers by scope with live health, add, edit, enable, disable. Cockpit: this session live (tokens and cost, subagents, search), with meters for context fill and the 5-hour, weekly and spend limits (how far through each window, when it resets). `/editors [plugins \ | mcp \ | cockpit]`. |
Install one, inside Claude Code:
/plugin install browser-pane-images --marketplace sandipchitale/browser-pane
The integration guide explains why browser-pane is shaped the way it is, and walks you step by step from a copy of the template to a published mod, with Claude Code.
In short: copy examples/template to a folder of your own, open Claude Code there, and ask it to build what you want. Its integration.md covers the dependency and marketplace, the $.browserPane calls, the page's client, the server module, the safety rules, testing and the development loop, and lists what to rename.
your mod ── $.browserPane.show ──▶ browser-pane's hook ── POST /control/* (token header) ──▶ server.mjs
│
Chrome app window ◀── /?t=token → cookie bp_<port> ── shell: tab strip + Guide ─┘
/tab/<id>/… your static files
/tab/<id>/api/… your server module
show, and stops a minute after the session's heartbeat stops.show comes while it polls, the open window switches tabs. This matters because Chrome hands a second launch with the same profile to the running process, which would open a new window../test.sh # everything: the servers (node --test), then each mod's hooks (claude plugin test)
claude plugin validate . # the manifest, the contract and the hooks module
claude plugin test <dir> runs every *.test.ts under <dir>, so each mod is tested from its own folder: test.sh tests browser-pane from a copy without examples/.
MIT
hooks/register.ts 108 lines1import type { Register } from 'claude-code'
2
3// Three tabs, one server module: each tab answers its own routes.
4const TABS = {
5 plugins: { title: 'Plugins', description: 'Plugins and marketplaces: install, enable, disable, update; add, update and remove marketplaces.' },
6 mcp: { title: 'MCP', description: 'MCP servers by scope with live health: add, edit, remove, enable and disable.' },
7 cockpit: { title: 'Cockpit', description: 'This session, live: tokens and cost, subagents, and transcript search.' },
8} as const
9type TabId = keyof typeof TABS
10const isTab = (s: string): s is TabId => s in TABS
11
12// Live feed for the Cockpit: the saved transcript can lag the running session, so the hooks below
13// report prompts, tool calls and turn usage as they happen. Each event carries a key that is new for
14// every event, since browser-pane resends a tab's latest data when its server restarts.
15const BOOT = Math.random().toString(36).slice(2)
16let seq = 0
17let where: { session: string; cwd: string } | undefined
18// What the session last measured (context fill, rate-limit windows, cost), for the Cockpit's meters.
19// It rides with every Cockpit update, since browser-pane keeps only a tab's latest data.
20let usage: unknown = null
21
22async function context($: any) {
23 where ??= { session: await $.session.id(), cwd: await $.session.cwd() }
24 return where
25}
26
27// Fire and forget: a failure here never touches the session.
28function emit($: any, event: Record<string, unknown>) {
29 void (async () => {
30 try {
31 const data = { ...(await context($)), usage, key: `${BOOT}-${++seq}`, event: { at: Date.now(), ...event } }
32 await $.browserPane.update({ tab: 'cockpit', data })
33 } catch {}
34 })()
35}
36
37// Fire and forget, like emit.
38function measured($: any, snapshot: unknown) {
39 usage = snapshot
40 void (async () => {
41 try {
42 await $.browserPane.update({ tab: 'cockpit', data: { ...(await context($)), usage } })
43 } catch {}
44 })()
45}
46
47function summary(input: Record<string, unknown>) {
48 const pick = ['command', 'file_path', 'path', 'pattern', 'query', 'url', 'description', 'prompt']
49 const hit = pick.map(k => input[k]).find(v => typeof v === 'string') ?? Object.values(input).find(v => typeof v === 'string')
50 return String(hit ?? '').replace(/\s+/g, ' ').trim().slice(0, 160)
51}
52
53export const register: Register = on => {
54 on('session.start', async ($, e, next) => {
55 const data = await context($)
56 for (const [id, tab] of Object.entries(TABS)) {
57 await $.browserPane.register({ id, ...tab, root: `${$.plugin.root}/tabs/${id}`, server: `${$.plugin.root}/tab-server/server.mjs` })
58 await $.browserPane.update({ tab: id, data })
59 }
60 await $.command.register({
61 name: 'editors',
62 description: 'Open an editor tab in the browser-pane window: /editors [plugins | mcp | cockpit]',
63 })
64 const result = await next(e)
65 try {
66 const { context: fill, rateLimits, cost } = await $.session.usage()
67 measured($, { context: fill, rateLimits, cost })
68 } catch {
69 // No figures yet; session.measure fills them in after the first turn.
70 }
71 return result
72 })
73
74 on('session.measure', ($, e, next) => {
75 measured($, { context: e.context, rateLimits: e.rateLimits, cost: e.cost })
76 return next(e)
77 })
78
79 on('command.run', { command: 'editors' }, async ($, e) => {
80 const arg = e.args.trim() || 'plugins'
81 if (!isTab(arg)) return { text: `editors: no "${arg}" tab. Use /editors plugins, /editors mcp or /editors cockpit.` }
82 const r = await $.browserPane.show({ tab: arg, data: { ...(await context($)), usage } })
83 if (r.isOpened) return { text: `editors: ${TABS[arg].title} tab ${r.how === 'reused' ? 'shown in the open window' : 'opened'}.` }
84 return { text: `editors: the browser window can't open (${r.reason}).` }
85 })
86
87 on('prompt.submit', ($, e, next) => {
88 emit($, { kind: 'prompt', text: e.text })
89 return next(e)
90 })
91
92 on('tool.call', async ($, e, next) => {
93 const { tool, tool_use_id: id, agentId, ...args } = e as Record<string, unknown> & { tool: string; tool_use_id?: string; agentId?: string }
94 if (id) emit($, { kind: 'tool-start', id, tool, agentId, summary: summary(args) })
95 const ran = await next(e)
96 if (id) {
97 const denied = 'deny' in ran && ran.deny !== undefined
98 emit($, { kind: 'tool-end', id, agentId, isError: denied || ('isError' in ran && ran.isError === true), text: denied ? String((ran as { deny: string }).deny) : String(('text' in ran && ran.text) || '') })
99 }
100 return ran
101 })
102
103 on('turn.complete', ($, e, next) => {
104 emit($, { kind: 'turn', turnId: e.turnId, agentId: e.agentId, answer: e.answer, usage: e.usage, model: e.usage?.model })
105 return next(e)
106 })
107}
108