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 CaseServer Needed?
Display static UI, read contextNo
Access the filesystem (scan files, read configs)Yes
Call external APIs with secret keysYes
Run long computations or background tasksYes
Store state between tab opensYes
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:

json
{"ready": true, "port": 3456}
  • ready must be true
  • port must 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

javascript
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

MistakeWhat Happens
Logging before the ready signalHost may parse your log line as the ready signal and fail
Listening on 0.0.0.0Server exposed to the network (security risk)
Hardcoding a portPort conflicts with other plugins or services
Async initialization taking >10sProcess killed by timeout

Process Lifecycle

Startup

  1. The host spawns node <server-entry> in your plugin directory
  2. Only PATH, HOME, NODE_ENV, and PLUGIN_NAME environment variables are set
  3. The host reads stdout, waiting for the ready signal
  4. On ready: the process and port are registered; RPC calls are routed to this port
  5. 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

  1. SIGTERM is sent to your process
  2. You have 5 seconds to clean up gracefully
  3. If still running after 5 seconds, SIGKILL is sent (force kill)

Handle graceful shutdown:

javascript
process.on('SIGTERM', () => {
  server.close(() => {
    process.exit(0);
  });
});

When Servers Start and Stop

EventServer Action
Host bootsAll enabled plugins with servers are started
Plugin installed (with server)Server started immediately
Plugin enabledServer started
Plugin disabledServer stopped
Plugin updatedServer stopped, then restarted
Plugin uninstalledServer stopped
Host shuts downAll plugin servers stopped
RPC call to stopped serverServer lazy-started (if plugin enabled)

Environment Variables

Your server process receives a restricted set of environment variables:

VariableValue
PATHSystem PATH
HOMEUser home directory
NODE_ENVCurrent Node environment
PLUGIN_NAMEYour 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:

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/notify

Header naming rules:

  • Prefix: x-plugin-secret-
  • Key is lowercased: apiKeyx-plugin-secret-apikey
  • Values are passed as-is

Reading Secrets in Your Server

javascript
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

javascript
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 express to your package.json.

Async Initialization

Do setup work before the ready signal:

javascript
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

javascript
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:

  1. Add a package.json to your plugin directory
  2. List dependencies normally
  3. The host runs npm install --production --ignore-scripts on install and update
json
{
  "name": "my-plugin-server",
  "type": "module",
  "dependencies": {
    "express": "^4.18.0"
  }
}

Important: devDependencies are NOT installed, and postinstall scripts are NOT run.

Next Steps

Last updated March 9, 2026