Skip to content

Browser Bridge

Pi extensions and tools inside the container can delegate actions to the browser via the backend bridge endpoint.

How It Works

Extensions POST to http://host.containers.internal:<nginx_port>/api/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/browser-delegate {action, browser_id, ...}
  → Backend resolves browser_id → (workspace_id, target_connection)
  → 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 ToolPluginRegistry which routes to Dart plugin 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 after terminal_start. Stores the browser ID in the tmux global environment (or /tmp/.klangk-browser-id in non-tmux mode).
  • klangk-browser-id — Prints the current browser ID to stdout. Reads from tmux global environment first, falls back to $KLANGK_BROWSER_ID env 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 the result — it changes on browser refresh or tab switch.

import { execSync } from "child_process";

const BRIDGE_URL = process.env.KLANGK_BRIDGE_URL;
const WORKSPACE_TOKEN = process.env.KLANGK_WORKSPACE_TOKEN;

/**
 * Read the current browser ID from klangk-browser-id.
 *
 * Call this per-request, not once at module load — the ID changes
 * when the user refreshes the browser or switches tabs.
 */
function getBrowserId(): string {
  try {
    return execSync("klangk-browser-id", { 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 resp = await fetch(`${BRIDGE_URL}/api/browser-delegate`, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          ...(WORKSPACE_TOKEN
            ? { Authorization: `Bearer ${WORKSPACE_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_URL is set at container creation time and is stable — safe to read once at module load.
  • getBrowserId() must be called per-request (per tool execution), not at module load.
  • The POST body uses browser_id (snake_case), not browserId.
  • Include WORKSPACE_TOKEN in the Authorization header if set.

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 Plugins

  • celebrate (plugins/celebrate/): Triggers confetti animation in the browser
  • beep (plugins/beep/): Plays a beep sound in the browser
  • bobdobbs (plugins/bobdobbs/): Bob "J.R." Dobbs quote generator
  • browser-fetch (plugins/browser-fetch/): HTTP fetch using the browser's cookies/session
  • boingball (plugins/boingball/): Bouncing Boing Ball animation overlay
  • git-credential (plugins/git-credential/): Git credential helper that prompts for PAT in the browser