# Hello World Plugin A minimal example showing how to build a plugin for Claude Code UI, covering both frontend (iframe) and backend (server subprocess) capabilities. ## How plugins work A plugin's UI runs client-side inside a sandboxed iframe. Plugins can optionally declare a `server` entry in their manifest — a Node.js script that the host runs as a subprocess. The iframe communicates with its server through a postMessage RPC bridge (the host proxies the calls because the sandboxed iframe has no auth token). ``` ┌─────────────────────────────────────────────────────────┐ │ Host server │ │ │ │ Lifecycle: │ │ git clone / git pull Install & update │ │ npm install Dependency setup │ │ │ │ Runtime: │ │ GET /api/plugins List plugins │ │ GET /api/plugins/:name/assets/* Serve static files │ │ ALL /api/plugins/:name/rpc/* Proxy → subprocess │ │ PUT /api/plugins/:name/enable Toggle + start/stop │ │ DELETE /api/plugins/:name Uninstall + stop │ │ │ │ Plugin subprocess (server.js): │ │ Runs as a child process with restricted env │ │ Listens on random local port │ │ Receives secrets via X-Plugin-Secret-* headers │ └───────────┬─────────────────────────┬───────────────────┘ │ serves static files │ proxies RPC ┌───────────▼─────────────────────────▼───────────────────┐ │ Frontend (browser) │ │ │ │ Plugin iframe ◄──postMessage──► Host app │ │ (sandboxed) ccui:context │ │ ccui:request-context │ │ ccui:rpc / ccui:rpc-response │ └─────────────────────────────────────────────────────────┘ ``` ## Plugin structure ``` my-plugin/ manifest.json # Required — plugin metadata index.html # Frontend entry point (rendered in iframe) server.js # Optional — backend entry point (runs as subprocess) package.json # Optional — npm dependencies for server.js ``` All files in the plugin directory are accessible via `/api/plugins/:name/assets/`. Use relative paths in your HTML (e.g., ``). ## manifest.json ```jsonc { "name": "hello-world", // Unique id — alphanumeric, hyphens, underscores only "displayName": "Hello World", // Shown in the UI "version": "1.0.0", "description": "Short description shown in settings.", "author": "Your Name", "icon": "Puzzle", // Lucide icon name (see available icons below) "type": "iframe", // Rendering method — only "iframe" is supported today "slot": "tab", // Where the plugin appears — only "tab" is supported today "entry": "index.html", // Frontend entry file, relative to plugin directory "server": "server.js", // Optional — backend entry file, runs as Node.js subprocess "permissions": [] // Reserved for future use } ``` ### Available icons `Puzzle` (default), `Box`, `Database`, `Globe`, `Terminal`, `Wrench`, `Zap`, `BarChart3`, `Folder`, `MessageSquare`, `GitBranch` ## Installation **Manual:** Copy your plugin folder into `~/.claude-code-ui/plugins/`. **From git:** In Settings > Plugins, paste a git URL and click Install. The repo is cloned into the plugins directory. --- ## Backend — Server subprocess Plugins that need to make authenticated API calls, use npm packages, or run Node.js logic can declare a `"server"` entry in their manifest. The host manages the full lifecycle: ### How it works 1. When the plugin is enabled, the host spawns `node server.js` as a child process 2. The subprocess **must** print a JSON line to stdout: `{"ready": true, "port": 12345}` 3. The host records the port and proxies requests from `/api/plugins/:name/rpc/*` to it 4. When the plugin is disabled or uninstalled, the host sends SIGTERM to the process ### Restricted environment The subprocess runs with a **minimal env** — only `PATH`, `HOME`, `NODE_ENV`, and `PLUGIN_NAME`. It does **not** inherit the host's API keys, database URLs, or other secrets from `process.env`. ### Secrets Per-plugin secrets are stored in `~/.claude-code-ui/plugins.json` and injected as HTTP headers on every proxied request: ```json { "hello-world": { "enabled": true, "secrets": { "apiKey": "sk-live-..." } } } ``` The plugin's server receives these as `x-plugin-secret-apikey` headers — they are per-call, never stored in the subprocess env. ### Example server.js ```js const http = require('http'); const server = http.createServer((req, res) => { res.setHeader('Content-Type', 'application/json'); // Read host-injected secrets const apiKey = req.headers['x-plugin-secret-apikey']; if (req.method === 'GET' && req.url === '/hello') { res.end(JSON.stringify({ message: 'Hello!', hasApiKey: Boolean(apiKey) })); return; } res.writeHead(404); res.end(JSON.stringify({ error: 'Not found' })); }); // Listen on a random port and signal readiness server.listen(0, '127.0.0.1', () => { const { port } = server.address(); console.log(JSON.stringify({ ready: true, port })); }); ``` --- ## Frontend — Context API The host app sends a `ccui:context` message to the iframe whenever the theme, project, or session changes: ```js window.addEventListener('message', (event) => { if (event.data?.type !== 'ccui:context') return; const { theme, project, session } = event.data; // theme — "dark" | "light" // project — { name, path } | null // session — { id, title } | null }); ``` To request the current context on load: ```js window.parent.postMessage({ type: 'ccui:request-context' }, '*'); ``` ## Frontend — RPC bridge The sandboxed iframe cannot make authenticated API calls directly. Instead, it sends RPC requests via `postMessage`, and the host proxies them to the plugin's server subprocess: ```js function callBackend(method, path, body) { return new Promise((resolve) => { const requestId = Math.random().toString(36).slice(2); function handler(event) { if (event.data?.type === 'ccui:rpc-response' && event.data.requestId === requestId) { window.removeEventListener('message', handler); resolve(event.data); } } window.addEventListener('message', handler); window.parent.postMessage({ type: 'ccui:rpc', requestId, method, path, body: body || undefined, }, '*'); }); } // Usage const result = await callBackend('GET', '/hello'); // result = { type: 'ccui:rpc-response', requestId, status: 200, data: { message: 'Hello!' } } ``` The host receives `ccui:rpc`, makes an authenticated fetch to `/api/plugins/:name/rpc/*`, and returns the response as `ccui:rpc-response`. ## Frontend — Mobile On desktop, each enabled plugin gets its own tab in the tab bar. On mobile, plugins are grouped under a single "More" button in the bottom navigation to save space. ## Security ### Frontend sandbox Plugins run in a sandboxed iframe with `allow-scripts allow-forms allow-popups`. They **cannot** access the host app's cookies, localStorage, or auth tokens (`allow-same-origin` is intentionally omitted). ### Server subprocess isolation The subprocess runs as a separate OS process with: - **Restricted env** — no host secrets inherited; only `PATH`, `HOME`, `NODE_ENV`, `PLUGIN_NAME` - **Per-call secrets** — injected as HTTP headers by the host proxy, never stored in process env - **Process boundary** — a crash in the plugin cannot crash the host - **Auth stripping** — the host removes `authorization` and `cookie` headers before proxying The subprocess runs as the same OS user, so it has the same filesystem/network access. This matches the trust model of VS Code extensions, Grafana backend plugins, and Terraform providers — the user explicitly installs the plugin. ### Install-time protections npm `postinstall` scripts are blocked during installation (`--ignore-scripts`). Plugins that need npm packages should ship pre-built or use packages that work without postinstall hooks. ## Try it ```bash cp -r examples/plugins/hello-world ~/.claude-code-ui/plugins/ ``` Then open Settings > Plugins — "Hello World" should appear. Enable it, open its tab, and click the RPC buttons to test the server subprocess.