Backend Servers
How to write a plugin backend server — lifecycle, readiness protocol, secrets, environment, and advanced patterns.
Plugins can include a backend server that runs as a managed Node.js subprocess on the host machine. This gives your plugin access to the filesystem, databases, external APIs, and anything else Node.js can do — while the host handles process management, authentication, and secret injection.
New to plugins? Start with the Getting Started tutorial first.
When Do You Need a Server?
| Use Case | Server Needed? |
|---|---|
| Display static UI, read context | No |
| Access the filesystem (scan files, read configs) | Yes |
| Call external APIs with secret keys | Yes |
| Run long computations or background tasks | Yes |
| Store state between tab opens | Yes |
| Only display info from context (project, session) | No |
The Readiness Protocol
The single most important requirement for your server: print a JSON ready signal to stdout.
When the host starts your server process, it reads stdout line by line, looking for:
{"ready": true, "port": 3456}readymust betrueportmust be the actual port number your server is listening on- This must be a single JSON line on stdout
- The host waits 10 seconds for this signal. If it doesn't arrive, the process is killed.
Minimal Server Template
import http from 'node:http';
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
res.setHeader('Content-Type', 'application/json');
if (url.pathname === '/health') {
res.writeHead(200);
res.end(JSON.stringify({ status: 'ok' }));
return;
}
res.writeHead(404);
res.end(JSON.stringify({ error: 'Not found' }));
});
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
// This line is REQUIRED — the host is waiting for it
console.log(JSON.stringify({ ready: true, port }));
});Common Mistakes
| Mistake | What Happens |
|---|---|
| Logging before the ready signal | Host may parse your log line as the ready signal and fail |
Listening on 0.0.0.0 | Server exposed to the network (security risk) |
| Hardcoding a port | Port conflicts with other plugins or services |
| Async initialization taking >10s | Process killed by timeout |
Process Lifecycle
Startup
- The host spawns
node <server-entry>in your plugin directory - Only
PATH,HOME,NODE_ENV, andPLUGIN_NAMEenvironment variables are set - The host reads stdout, waiting for the ready signal
- On ready: the process and port are registered; RPC calls are routed to this port
- On timeout (10s): the process is killed with SIGTERM
During Runtime
- Your server stays running as long as the plugin is enabled
- RPC requests arrive as standard HTTP requests on your port
- If your process crashes, it's removed from the registry
- The host may lazy-restart your server on the next RPC call
Shutdown
- SIGTERM is sent to your process
- You have 5 seconds to clean up gracefully
- If still running after 5 seconds, SIGKILL is sent (force kill)
Handle graceful shutdown:
process.on('SIGTERM', () => {
server.close(() => {
process.exit(0);
});
});When Servers Start and Stop
| Event | Server Action |
|---|---|
| Host boots | All enabled plugins with servers are started |
| Plugin installed (with server) | Server started immediately |
| Plugin enabled | Server started |
| Plugin disabled | Server stopped |
| Plugin updated | Server stopped, then restarted |
| Plugin uninstalled | Server stopped |
| Host shuts down | All plugin servers stopped |
| RPC call to stopped server | Server lazy-started (if plugin enabled) |
Environment Variables
Your server process receives a restricted set of environment variables:
| Variable | Value |
|---|---|
PATH | System PATH |
HOME | User home directory |
NODE_ENV | Current Node environment |
PLUGIN_NAME | Your plugin's name from the manifest |
All other environment variables from the host are stripped. This is intentional — see the Security Model for details.
Secrets Management
Secrets are configured per-plugin in ~/.claude-code-ui/plugins.json:
{
"my-plugin": {
"enabled": true,
"secrets": {
"apiKey": "sk-abc123",
"webhookUrl": "https://hooks.example.com/notify"
}
}
}How Secrets Reach Your Server
Secrets are not passed as environment variables. Instead, they're injected as HTTP headers on every RPC request:
x-plugin-secret-apikey: sk-abc123
x-plugin-secret-webhookurl: https://hooks.example.com/notifyHeader naming rules:
- Prefix:
x-plugin-secret- - Key is lowercased:
apiKey→x-plugin-secret-apikey - Values are passed as-is
Reading Secrets in Your Server
const server = http.createServer((req, res) => {
const apiKey = req.headers['x-plugin-secret-apikey'];
if (!apiKey) {
res.writeHead(401);
res.end(JSON.stringify({ error: 'API key not configured' }));
return;
}
// Use the secret to call an external API
// ...
});Why Headers Instead of Environment Variables?
- Per-request injection — Secrets don't sit in process memory permanently
- No restart needed — Updated secrets take effect on the next request
- No leakage — Child processes you spawn don't inherit secrets
Request Proxying Details
When the frontend calls api.rpc('POST', '/analyze', { data: 'test' }), your server receives:
POST /analyze HTTP/1.1
Host: 127.0.0.1:54321
Content-Type: application/json
x-plugin-secret-apikey: sk-abc123
{"data":"test"}What's preserved: HTTP method, URL path, query string, request body (as JSON).
What's added: x-plugin-secret-* headers.
What's NOT forwarded: Browser cookies, auth tokens, Origin/Referer headers.
Advanced Patterns
Using Express
import express from 'express';
const app = express();
app.use(express.json());
app.get('/stats', (req, res) => {
const projectPath = req.query.path;
res.json({ files: 42, lines: 1337 });
});
app.post('/analyze', (req, res) => {
const { files, depth } = req.body;
res.json({ results: [] });
});
const server = app.listen(0, '127.0.0.1', () => {
console.log(JSON.stringify({ ready: true, port: server.address().port }));
});Don't forget to add
expressto yourpackage.json.
Async Initialization
Do setup work before the ready signal:
import http from 'node:http';
import { initDatabase } from './db.js';
async function start() {
const db = await initDatabase();
const server = http.createServer((req, res) => {
// Use db here
});
server.listen(0, '127.0.0.1', () => {
console.log(JSON.stringify({ ready: true, port: server.address().port }));
});
}
start().catch(err => {
console.error('Failed to start:', err);
process.exit(1);
});Keep initialization under 10 seconds or the host will kill your process.
In-Memory Caching
const cache = new Map();
function handleStats(req, res, projectPath) {
const cached = cache.get(projectPath);
if (cached && Date.now() - cached.time < 30_000) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(cached.data));
return;
}
const stats = computeStats(projectPath);
cache.set(projectPath, { data: stats, time: Date.now() });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(stats));
}Dependencies
If your server needs npm packages:
- Add a
package.jsonto your plugin directory - List dependencies normally
- The host runs
npm install --production --ignore-scriptson install and update
{
"name": "my-plugin-server",
"type": "module",
"dependencies": {
"express": "^4.18.0"
}
}Important: devDependencies are NOT installed, and postinstall scripts are NOT run.
Next Steps
- Frontend API Reference — How the frontend calls your server via
api.rpc() - Security Model — Process isolation and secret security details
- Example: Project Stats — See a real server implementation