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., <link href="styles.css">).
manifest.json
{
"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
- When the plugin is enabled, the host spawns
node server.jsas a child process - The subprocess must print a JSON line to stdout:
{"ready": true, "port": 12345} - The host records the port and proxies requests from
/api/plugins/:name/rpc/*to it - 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:
{
"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
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:
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:
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:
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
authorizationandcookieheaders 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
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.