One shared browser window for mods: each mod adds a tab ($.browserPane), and a Guide tab lists them

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 105 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type {
5 BrowserPaneJson,
6 BrowserPaneRegisterResult,
7 BrowserPaneShowResult,
8 BrowserPaneTab,
9 BrowserPaneUpdateResult,
10} from '../types'
11import { ensure, isUp, post } from './link'
12
13const tabs = atom({ plugin: 'browser-pane', key: 'tabs' } as const, {} as Record<string, BrowserPaneTab>)
14const data = atom({ plugin: 'browser-pane', key: 'data' } as const, {} as Record<string, BrowserPaneJson>)
15
16const TAB_ID = /^[a-z0-9][a-z0-9-]{0,63}$/
17const GUIDE = 'guide'
18const NOT_ANSWERED = 'browser-pane did not answer'
19
20// Each tab the server has not been told about yet is told when it (re)starts, with its latest data.
21async function pushAll($: any) {
22 const all = await read($, tabs)
23 const latest = await read($, data)
24 for (const tab of Object.values(all)) {
25 await post($, '/control/register', tab)
26 if (tab.id in latest) await post($, '/control/data', { tab: tab.id, data: latest[tab.id] })
27 }
28}
29
30const isAbsolute = (p: unknown) => typeof p === 'string' && (p.startsWith('/') || /^[A-Za-z]:[\\/]/.test(p))
31
32function checkTab(tab: BrowserPaneTab): string | undefined {
33 if (!TAB_ID.test(String(tab.id)) || tab.id === GUIDE) return `tab id "${tab.id}" must be lowercase letters, digits and dashes, and not "${GUIDE}"`
34 if (typeof tab.title !== 'string' || tab.title.trim() === '') return 'a tab needs a title'
35 if (!isAbsolute(tab.root)) return 'a tab root must be an absolute folder'
36 if (tab.server !== undefined && !isAbsolute(tab.server)) return 'a tab server must be an absolute path'
37 return undefined
38}
39
40export const register: Register = on => {
41 // The methods' bodies stand beneath this mod's hooks: a caller reaches them only when the hooks
42 // didn't answer (one failed or ran out of time), so each says so in its own result.
43 on('engine.create', async (_$, e, next) => {
44 const built = await next(e)
45 return {
46 ...built,
47 browserPane: {
48 register: async (): Promise<BrowserPaneRegisterResult> => ({ isRegistered: false, reason: NOT_ANSWERED }),
49 show: async (): Promise<BrowserPaneShowResult> => ({ isOpened: false, reason: NOT_ANSWERED }),
50 update: async (): Promise<BrowserPaneUpdateResult> => ({ isSent: false, reason: NOT_ANSWERED }),
51 },
52 }
53 })
54
55 on('browserPane.register', async ($, e) => {
56 const tab: BrowserPaneTab = { id: e.id, title: e.title, root: e.root }
57 if (e.server !== undefined) tab.server = e.server
58 if (e.description !== undefined) tab.description = e.description
59 if (e.pinned !== undefined) tab.pinned = e.pinned === true
60 const problem = checkTab(tab)
61 if (problem) return { value: { isRegistered: false, reason: problem } }
62 await update($, tabs, all => ({ ...all, [tab.id]: tab }))
63 if (isUp()) await post($, '/control/register', tab)
64 return { value: { isRegistered: true } }
65 })
66
67 on('browserPane.update', async ($, e) => {
68 if (!(e.tab in (await read($, tabs)))) return { value: { isSent: false, reason: `no tab "${e.tab}"` } }
69 const value = e.data
70 await update($, data, all => ({ ...all, [e.tab]: value }))
71 if (!isUp()) return { value: { isSent: false, reason: 'the window is not open; kept for when it opens' } }
72 const answer = await post($, '/control/data', { tab: e.tab, data: value })
73 return { value: answer ? { isSent: true } : { isSent: false, reason: 'the server did not answer' } }
74 })
75
76 on('browserPane.show', async ($, e) => {
77 const tab = e.tab ?? GUIDE
78 if (tab !== GUIDE && !(tab in (await read($, tabs)))) return { value: { isOpened: false, reason: `no tab "${tab}"` } }
79 if (e.data !== undefined && tab !== GUIDE) await update($, data, all => ({ ...all, [tab]: e.data }))
80 // A server that starts now is told every tab's latest data as it comes up, this one's included.
81 const wasUp = isUp()
82 const failure = await ensure($, () => pushAll($))
83 if (failure) return { value: { isOpened: false, reason: failure } }
84 if (wasUp && e.data !== undefined && tab !== GUIDE) await post($, '/control/data', { tab, data: e.data })
85 const answer = await post($, '/control/show', { tab, select: e.select })
86 if (!answer) return { value: { isOpened: false, reason: 'the server did not answer' } }
87 return { value: answer.isOpened ? { isOpened: true, how: answer.how } : { isOpened: false, reason: answer.reason ?? 'no window opened' } }
88 })
89
90 on('session.start', async ($, e, next) => {
91 await $.command.register({
92 name: 'browser-pane',
93 description: 'Open the shared browser window: /browser-pane [tab]',
94 })
95 return next(e)
96 })
97
98 on('command.run', { command: 'browser-pane' }, async ($, e) => {
99 const tab = e.args.trim() || undefined
100 const r = await $.browserPane.show(tab ? { tab } : {})
101 if (r.isOpened) return { text: r.how === 'reused' ? 'browser-pane: switched the open window.' : 'browser-pane: window opened.' }
102 return { text: `browser-pane: no window (${r.reason}).` }
103 })
104}
105hooks/link.ts 79 lines1// The shared server (server/server.mjs): one per session, started on the first show and kept up by a
2// heartbeat. The child lives as long as the loop reading it, which ends with this module or the server.
3const HEARTBEAT_MS = 15_000
4const START_TIMEOUT_MS = 6_000
5
6let port: number | undefined
7let token = ''
8let starting: Promise<string | undefined> | undefined
9let hasHeartbeat = false
10
11export const isUp = () => port !== undefined
12
13// POSTs to the server's control API. Resolves the parsed answer, or undefined when it didn't answer.
14export async function post($: any, path: string, body: unknown = {}): Promise<any> {
15 if (port === undefined) return undefined
16 try {
17 const res = await $.http.fetch(`http://127.0.0.1:${port}${path}`, {
18 method: 'POST',
19 headers: { 'x-browser-pane-token': token, 'content-type': 'application/json' },
20 body: JSON.stringify(body),
21 })
22 if (!res.ok) return undefined
23 return res.text ? JSON.parse(res.text) : {}
24 } catch {
25 return undefined
26 }
27}
28
29// Starts the server unless it is up. Resolves undefined once it answers (after `onUp` has told it the
30// tabs), or the reason it can't start. The spawn runs from a timer, so the module owns the child and
31// not the dispatch that asked for it.
32export function ensure($: any, onUp: () => Promise<void>): Promise<string | undefined> {
33 if (port !== undefined) return Promise.resolve(undefined)
34 if (starting) return starting
35 starting = new Promise(resolve => {
36 let settled = false
37 const settle = (reason: string | undefined) => {
38 if (settled) return
39 settled = true
40 if (reason !== undefined) starting = undefined
41 resolve(reason)
42 }
43 $.clock.after(START_TIMEOUT_MS, () => settle('the browser-pane server did not start in time'))
44 $.clock.after(0, () => {
45 void (async () => {
46 let buffer = ''
47 let failure = 'the browser-pane server stopped'
48 try {
49 const child = $.process.spawn({ argv: ['node', `${$.plugin.root}/server/server.mjs`] })
50 for await (const piece of child) {
51 if (!('stream' in piece) || piece.stream !== 'stdout' || port !== undefined) continue
52 buffer += piece.text
53 const newline = buffer.indexOf('\n')
54 if (newline < 0) continue
55 const hello = JSON.parse(buffer.slice(0, newline))
56 token = hello.token
57 port = hello.port
58 if (!hasHeartbeat) {
59 hasHeartbeat = true
60 $.clock.every(HEARTBEAT_MS, () => void post($, '/control/ping'))
61 }
62 await onUp()
63 settle(undefined)
64 }
65 } catch (error) {
66 failure = `the browser-pane server can't run here: ${String(error)}`
67 $.ui.log(`browser-pane: ${failure}`, { to: 'debug' })
68 } finally {
69 port = undefined
70 token = ''
71 starting = undefined
72 settle(failure)
73 }
74 })()
75 })
76 })
77 return starting
78}
79types/index.d.ts 95 lines1// The browser-pane contract: what this mod adds to `$`, and the state it keeps.
2// A mod that lists "browser-pane" under "dependencies" gets this file laid into
3// its own .claude-plugin/types/browser-pane/, so `$.browserPane` is typed there.
4
5/**
6 * A tab's data: plain JSON (objects, arrays, strings, numbers, booleans, null), as it crosses to the
7 * tab's server module. Typed `unknown` because the engine freezes every argument deeply, and a
8 * recursive JSON type is too deep for that.
9 */
10export type BrowserPaneJson = unknown
11
12/**
13 * A tab in the shared browser window, as a mod registers it.
14 *
15 * `root` is a folder of static files (an `index.html` at least), served at
16 * `/tab/<id>/`. `server`, when given, is a Node ES module the shared server
17 * imports; its default export is a BrowserPaneServerModule factory, and it
18 * answers the tab's requests under `/tab/<id>/api/`.
19 */
20export type BrowserPaneTab = {
21 /** Lowercase letters, digits and dashes; unique across mods (use your mod's name). */
22 id: string
23 /** The label on the tab. */
24 title: string
25 /** Absolute folder of the tab's static files. Usually `$.plugin.root + '/tab'`. */
26 root: string
27 /** Absolute path of the tab's server module (`.mjs`), if it has one. */
28 server?: string
29 /** One line for the Guide tab. */
30 description?: string
31 /**
32 * Pinned tabs come first, in the Guide and the tab strip, with a pin. This is the tab's default;
33 * the person pins and unpins tabs in the Guide, and the window remembers their choice.
34 */
35 pinned?: boolean
36}
37
38export type BrowserPaneRegisterResult = { isRegistered: boolean; reason?: string }
39
40export type BrowserPaneShowArgs = {
41 /** The tab to bring forward; the Guide tab when left out. */
42 tab?: string
43 /** What the tab should select, handed to its page (`onSelect`). */
44 select?: string
45 /** New data for the tab's server module (`update`), sent before the tab shows. */
46 data?: BrowserPaneJson
47}
48
49/**
50 * `opened`: a new window was asked for. `reused`: an open window switched to
51 * the tab. `isOpened: false` comes with a reason: there is no `$.process`
52 * here (desktop app, IDE), Node or the server would not start, or the tab is
53 * unknown. A caller then shows the content its own way, say in a Pane.
54 */
55export type BrowserPaneShowResult = { isOpened: boolean; how?: 'opened' | 'reused'; reason?: string }
56
57export type BrowserPaneUpdateArgs = { tab: string; data: BrowserPaneJson }
58export type BrowserPaneUpdateResult = { isSent: boolean; reason?: string }
59
60export type BrowserPane = {
61 /** Adds or replaces a tab. Cheap: nothing starts until the first `show`. Call it from `session.start`. */
62 register: (tab: BrowserPaneTab) => Promise<BrowserPaneRegisterResult>
63 /** Opens the window (or reuses the open one) on a tab, optionally selecting something in it. */
64 show: (args: BrowserPaneShowArgs) => Promise<BrowserPaneShowResult>
65 /** Hands a tab's server module new data without bringing the window forward. Kept for the next start. */
66 update: (args: BrowserPaneUpdateArgs) => Promise<BrowserPaneUpdateResult>
67}
68
69/**
70 * What a tab's server module default-exports: called once per import with the
71 * tab, it answers the tab's requests. It runs in the shared Node server.
72 *
73 * `handle` gets the request below `/tab/<id>/api/` (`sub` without a leading
74 * slash) after the server checked its token and Host; it returns false to
75 * answer 404. `update` receives each `show`/`update` data, latest wins.
76 */
77export type BrowserPaneServerModule = (tab: { id: string; root: string }) => {
78 update?: (data: BrowserPaneJson) => void | Promise<void>
79 handle?: (req: unknown, res: unknown, sub: string) => boolean | Promise<boolean>
80}
81
82declare module 'claude-code' {
83 interface EngineInterface {
84 browserPane: BrowserPane
85 }
86 interface PluginState {
87 'browser-pane': {
88 /** Registered tabs by id; kept across reloads of this mod, which consumers don't repeat. */
89 tabs: Record<string, BrowserPaneTab>
90 /** Each tab's latest data, pushed again when the server (re)starts. */
91 data: Record<string, BrowserPaneJson>
92 }
93 }
94}
95