Browser Bridge¶
Pi extensions and tools inside the container can delegate actions to the browser via the backend bridge endpoint.
Disabling the bridge¶
KLANGKD_BROWSER_DELEGATE_ENABLED=false turns the bridge off for the whole deployment: both endpoints return 403, browser tabs are no longer registered for bridge routing, no browser ID is attached into terminals started after the change, and /api/v1/config advertises browser_delegate_enabled: false so the web UI stops answering bridge requests. Reloadable on SIGHUP.
Two caveats when flipping the switch on a running deployment:
- Terminals already running keep the ID they were attached with — nothing
clears the tmux env, so
klangk-browser-idstill prints it — but their bridge POSTs get the same 403, so the channel is dead either way. - A re-enable arms the server from the next
terminal_start, but browser tabs only resume answering after a page reload — the tab decides at connect time (from/config) whether to start itsBrowserDelegate, and until it reloads, container helpers dispatched to it wait out the bridge timeout instead of failing fast.
How It Works¶
Extensions POST to http://host.containers.internal:<egress_port>/api/v1/browser-delegate with a browser ID. Each browser tab generates a UUID stored in sessionStorage (survives refresh, unique per tab). When a terminal starts, the frontend sends this ID with the terminal_start WebSocket message. The backend maps the ID to the tab's WebSocket. The container reads the current browser ID dynamically via klangk-browser-id (which reads from tmux's global environment, updated on every attach/reattach).
Flow¶
LLM calls tool → Pi extension execute()
→ shell out to klangk-browser-id to get current browser ID
→ HTTP POST to /api/v1/browser-delegate {action, browser_id, ...}
→ Backend resolves browser_id → (workspace_id, target_connection)
and rejects unless workspace_id matches the caller's workspace token
→ WebSocket message to target only: {"type":"browser_request","id":"...","action":"..."}
→ Flutter BrowserDelegate handles action (fetch, celebrate, etc.)
→ WebSocket message: {"cmd":"browser_response","id":"...","data":"..."}
→ Backend verifies sender matches target, returns HTTP response to extension
→ Extension returns result to LLM
Built-in actions: fetch (HTTP request with browser cookies). All other actions are dispatched to the ToolFeatureRegistry which routes to Dart feature handlers registered by klangk/ subdirectories.
Browser ID¶
The browser ID is a UUID generated by each browser tab (crypto.randomUUID()) and stored in sessionStorage. It:
- Survives page refresh (same tab, same ID)
- Is unique per tab (new tab = new ID)
- Is registered with the backend on every
terminal_start(including after reconnect) - Is stored in the container's tmux global environment via
klangk-attach-browser - Is read dynamically per-request via
klangk-browser-id(never cached in process env)
Container-side scripts¶
klangk-attach-browser <browser-id>— Called by the backend afterterminal_start. Stores the browser ID in the tmux global environment (or/tmp/.klangk-browser-idin non-tmux mode).klangk-browser-id— Prints the current browser ID to stdout. Reads from tmux global environment first, falls back to$KLANGKWS_BROWSER_IDenv var, then to the file fallback. Call this per-request — do not cache the result.
Writing a Pi Extension That Uses the Bridge¶
Pi extensions that use the bridge must read the browser ID dynamically by shelling out to klangk-browser-id — do not cache it, as it changes on browser refresh or tab switch. The workspace token should also be read per-request via klangk-workspace-token.
import { execSync } from "child_process";
const BRIDGE_URL = process.env.KLANGKWS_BRIDGE_URL;
function getBrowserId(): string {
try {
return execSync("klangk-browser-id", { encoding: "utf-8" }).trim();
} catch {
return "";
}
}
function getWorkspaceToken(): string {
try {
return execSync("klangk-workspace-token", { encoding: "utf-8" }).trim();
} catch {
return "";
}
}
export default function (pi: any) {
if (!BRIDGE_URL) return;
pi.registerTool({
name: "my-tool",
description: "Does something via the browser",
parameters: {},
async execute() {
const browserId = getBrowserId();
if (!browserId) {
return { content: [{ type: "text", text: "No browser connected." }] };
}
const token = getWorkspaceToken();
const resp = await fetch(`${BRIDGE_URL}/api/v1/browser-delegate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({
action: "my_action",
browser_id: browserId,
}),
});
const data = await resp.json();
return { content: [{ type: "text", text: JSON.stringify(data) }] };
},
});
}
Key points:
BRIDGE_URLis set at container creation time and is stable — safe to read once at module load.getBrowserId()andgetWorkspaceToken()must be called per-request (per tool execution), not at module load.- The POST body uses
browser_id(snake_case), notbrowserId.
Writing a Bridge Client (Python)¶
Python tools (like git-credential-klangk) should call klangk-browser-id via subprocess:
import subprocess
def get_browser_id():
try:
result = subprocess.run(
["klangk-browser-id"],
capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
return result.stdout.strip()
except (OSError, subprocess.TimeoutExpired):
pass
return ""
Current Client-Side Features¶
- celebrate (
features/celebrate/): Triggers confetti animation in the browser - beep (
features/beep/): Plays a beep sound in the browser - bobdobbs (
features/bobdobbs/): Bob "J.R." Dobbs quote generator - browser-fetch (
features/browser-fetch/): HTTP fetch using the browser's cookies/session - boingball (
features/boingball/): Bouncing Boing Ball animation overlay (dormant by default since #3149 — activate withKLANGKD_FEATURES_ENABLE) - git-credential (
features/git-credential/): Git credential helper that prompts for PAT in the browser