Files
claudecodeui/examples/plugins/hello-world
simosmik 23d59ec716 docs(plugins): update README for ES module frontend
Replace iframe-based plugin documentation with ES module approach.
Update architecture diagram, file structure, and manifest.json
examples to reflect the switch from index.html/iframe to
index.js/module with mount/unmount exports and api.rpc() helper.
2026-03-05 13:07:53 +00:00
..
2026-03-05 12:18:38 +00:00
2026-03-05 12:18:38 +00:00

Hello World Plugin

A minimal example showing how to build a plugin for Claude Code UI, covering both frontend (ES module) and backend (server subprocess) capabilities.

How plugins work

A plugin's UI is a plain ES module loaded directly into the host app — no iframe. Plugins can optionally declare a server entry in their manifest — a Node.js script that the host runs as a subprocess. The module calls the server through an api.rpc() helper (the host proxies the calls using its own 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 module (index.js)                               │
│    import(url) → mount(container, api)                  │
│    api.context          — theme / project / session     │
│    api.onContextChange  — subscribe to changes          │
│    api.rpc(method, path, body) → Promise                │
└─────────────────────────────────────────────────────────┘

Plugin structure

my-plugin/
  manifest.json   # Required — plugin metadata
  index.js        # Frontend entry point (ES module, mount/unmount exports)
  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/.

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": "module",             // "module" (default) or "iframe" (legacy)
  "slot": "tab",                // Where the plugin appears — only "tab" is supported today
  "entry": "index.js",          // 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.


Frontend — Module API

The host dynamically imports your entry file and calls mount(container, api). When the plugin tab is closed or the plugin is disabled, unmount(container) is called.

// index.js

export function mount(container, api) {
  // api.context — current snapshot: { theme, project, session }
  // api.onContextChange(cb) — subscribe, returns an unsubscribe function
  // api.rpc(method, path, body?) — call the plugin's server subprocess

  container.innerHTML = '<p>Hello!</p>';

  const unsub = api.onContextChange((ctx) => {
    container.style.background = ctx.theme === 'dark' ? '#111' : '#fff';
  });

  container._cleanup = unsub;
}

export function unmount(container) {
  if (typeof container._cleanup === 'function') container._cleanup();
  container.innerHTML = '';
}

Context object

api.context // always up to date
// {
//   theme:   "dark" | "light",
//   project: { name: string, path: string } | null,
//   session: { id: string, title: string } | null,
// }

RPC helper

// Calls /api/plugins/:name/rpc/hello via the host's authenticated fetch
const data = await api.rpc('GET', '/hello');

// With a JSON body
const result = await api.rpc('POST', '/echo', { greeting: 'hi' });

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:

{
  "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 — 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 isolation

Plugin modules run in the same JS context as the host app but have no access to auth tokens or internal state — only the api object passed to mount. They cannot make authenticated API calls directly; all server communication goes through api.rpc(), which the host proxies.

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

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.