Write a new app
A new app is a separate web app that the hub runs in its own tab. Use it when the tool you want does a different job from the built-in tools.
This guide uses the Inspector as its example. The Inspector shows the turns, tool calls and token use of one Claude Code session. Its source is in examples/inspector in the hub repository.
What you make
Section titled “What you make”inspector/├── hub-app.json the manifest├── package.json├── server.js an Express server├── lib/ reads the transcripts└── public/ ├── index.html loads the SDK first ├── app.js the page, with a HUB_INTEGRATION region ├── style.css └── vendor/ └── claude-hub-sdk.js the SDK stub, for a run with no hubThe hub needs only hub-app.json and the server. The rest is a normal web app.
1. Write the manifest
Section titled “1. Write the manifest”Put hub-app.json in the app folder:
{ "manifest": 1, "id": "inspector", "name": "Inspector", "icon": "scan-search", "run": { "entry": "server.js", "defaultPort": 3545 }, "loading": { "verbs": ["Reading the transcript…", "Counting tokens per turn…", "Matching tool calls to results…"] }, "actions": { "handles": { "session.inspect": { "params": { "session": "string?" }, "url": "?session={session}", "mode": "message" } } }}idis new, so the app runs next to the built-in tools. It must be the same as theidof theappsentry.run.entryis the server script.run.defaultPortis the proxy port. Use a port that no other app uses, because the port keys the app’slocalStorage.loading.verbsare the lines of the hub’s loading screen.actions.handleslets other tools open a session in the Inspector withhub.invoke('session.inspect', { session }). Withmode: "message", the hub sends the action to the open page. When the page is not loaded yet, the hub loadsurlinstead.
See The app manifest for every field.
2. Write the server
Section titled “2. Write the server”The hub starts run.entry with its own Node. The working directory is the hub’s folder, so resolve your files from __dirname. The hub gives the app these variables:
| Variable | What the Inspector does with it |
|---|---|
PORT |
The hub sends 0. The server listens on any free port. Alone, it uses 3545 |
HOST, ALLOWED_HOSTS |
The server binds to HOST and accepts only these Host headers |
CLAUDE_CONFIG_DIR |
The server reads the transcripts from <CLAUDE_CONFIG_DIR>/projects. Alone, it uses ~/.claude |
HUB_SDK_SERVER |
The server mounts the server SDK from this path |
CLAUDE_HUB, HUB_URL |
Not used. The page finds the hub through the SDK |
The hub starts one copy of the app for each config dir. Read CLAUDE_CONFIG_DIR once at start.
The server of the Inspector, without its API routes:
const path = require('node:path');const express = require('express');
const HOST = process.env.HOST || '127.0.0.1';const PORT = Number(process.env.PORT ?? 3545);const ALLOWED = new Set( ['localhost', '127.0.0.1', '[::1]', HOST, ...(process.env.ALLOWED_HOSTS || '').split(',')] .map((h) => h.trim().toLowerCase()) .filter(Boolean),);
const app = express();
app.use((req, res, next) => { const host = (req.headers.host || '').toLowerCase().replace(/:\d+$/, ''); if (ALLOWED.has(host)) return next(); res.status(403).json({ error: `host ${host || '(none)'} is not allowed` });});
if (process.env.HUB_SDK_SERVER) require(process.env.HUB_SDK_SERVER).mount(app);
// API routes go here.
app.use(express.static(path.join(__dirname, 'public')));
const server = app.listen(PORT, HOST, () => { console.log(`Inspector running at http://localhost:${server.address().port}`);});server.keepAliveTimeout = 65_000;server.on('error', (err) => { console.error(`Inspector could not listen on ${HOST}:${PORT}: ${err.message}`); process.exit(1);});Four lines matter to the hub:
- The
Hostcheck. A page on another site can reach a loopback server through DNS rebinding. TheHostheader shows it. mount(app), before the static files. Under the hub, it serves the real SDK at/vendor/claude-hub-sdk.jsin place of the stub, adds/hub-config, answers the hub’s ping and stops the process when the hub stops.- The ready line. The hub reads the real port from the first stdout line that matches
/running at http:\/\/localhost:(\d+)/i. Print the port fromserver.address(), notPORT, becausePORTis0. Print no other line that matches. keepAliveTimeout. The hub drops an idle connection after 60 s. A server that keeps it for 65 s does not close it first.
When the server exits, the hub starts it again, up to 5 times in 60 seconds. See The apps entry and the launch for the full rules.
3. Ship the SDK stub
Section titled “3. Ship the SDK stub”When the app runs alone, the server serves public/vendor/claude-hub-sdk.js. This is the stub: the same API as the real SDK, and it does nothing. Copy packages/claude-hub-sdk/src/stub.js from the hub repository to that path. In a clone of the hub:
npm run sdk:sync -- examples/inspectorLoad the SDK as the first element in <body>, as a classic script, before the page script:
<head> <script defer src="/app.js"></script></head><body><script src="/vendor/claude-hub-sdk.js"></script>The SDK paints the hub’s theme before the first paint, so the tab does not flash.
4. Connect the page
Section titled “4. Connect the page”The Inspector keeps all its hub code in one HUB_INTEGRATION region of public/app.js. The rest of the page does not know about the hub.
// #region HUB_INTEGRATIONconst hub = ClaudeHub.connect();
(function initHubTheme() { const getTheme = () => (document.body.classList.contains('light') ? 'light' : 'dark'); const getColorTheme = () => document.body.dataset.colorTheme || 'ember'; const report = hub.bindTheme({ get: () => ({ theme: getTheme(), colorTheme: getColorTheme() }), set: ({ theme, colorTheme }) => { if (colorTheme !== getColorTheme()) setColorTheme(colorTheme); if (theme !== getTheme()) toggleTheme(); }, }); new MutationObserver(report).observe(document.body, { attributes: true, attributeFilter: ['class', 'data-color-theme'], });})();
hub.onThemes((themes) => buildThemeMenu(themes.map((t) => [t.id, t.label])));
let pinnedByUrl = new URLSearchParams(location.search).has('session');let settled = false;hub.subscribe('session.changed', (s) => { if (!s?.sessionId || pinnedByUrl) return; openSession(s.sessionId, s.encoded);});
hub.handle('session.inspect', ({ session }) => { if (session) openSession(session);});
hub.onActive((active) => { state.active = active; settled = true; pinnedByUrl = false; if (active && state.id) load(); else autoPick();});
function settleAlone(status) { if (settled || (status !== 'standalone' && status !== 'unanswered')) return; settled = true; autoPick();}
hub.onStatus((status) => { settleAlone(status); renderTopbar();});settleAlone(hub.status);
document.addEventListener('click', (e) => { const link = e.target.closest('a[target="_blank"]'); if (!link || !hub.inHub) return; e.preventDefault(); hub.openExternal(link.href);});// #endregion HUB_INTEGRATIONWhat each part does:
| Call | Why |
|---|---|
bindTheme |
Applies the hub theme. The MutationObserver calls report() when the user changes the theme in the Inspector, and the hub sends the change to every tool |
onThemes |
Fills the theme menu with the hub’s themes, which include the user’s own themes |
subscribe('session.changed') |
Kanban publishes the session that you open. The Inspector opens the same session. A page opened with ?session= keeps its own session, so it skips the first event |
handle('session.inspect') |
Opens the session that another tool asks for |
onActive |
Tells the page when its tab is on screen. The Inspector reloads the session when you come back to it |
onStatus, settleAlone |
When there is no hub, or the hub does not answer in 2 s, the page picks a session itself |
openExternal |
A framed page cannot open a new window in the installed hub. The SDK asks the hub to do it |
Subscribe before the page’s load event. The SDK sends the topic list to the hub at load, and the hub sends nothing for a topic that is not in it.
Call another tool
Section titled “Call another tool”The Inspector has a Cost button that opens the session in Cost. It shows the button only when an enabled tool handles the action:
$('costBtn').hidden = !(state.id && hub.can('session.cost'));
const r = await hub.invoke('session.cost', { session: state.id });if (!r.ok) toast(`Cost did not open: ${r.reason}`, 'error');See The built-in tools for the topics and actions of the built-in tools.
5. Add the app to the hub
Section titled “5. Add the app to the hub”Add an entry with a path to the apps list in ~/.claude-hub/config.json. List the built-in tools too, to keep them in the tab order:
{ "apps": [ { "id": "kanban" }, { "id": "marketplace" }, { "id": "cost" }, { "id": "memory" }, { "id": "inspector", "path": "C:/dev/claude-code-hub/examples/inspector" } ]}Run npm install in the app folder, then restart the hub. The startup log shows where the app runs from:
inspector runs from C:\dev\claude-code-hub\examples\inspectorThe Inspector is the fifth tab, Alt+5. It is also in the app launcher, Ctrl+Alt+A. When the app does not start, the log shows why: the manifest failed validation, the id does not match, or the server printed no ready line in 20 s.
6. Check it alone
Section titled “6. Check it alone”Run the app with no hub:
npm startOpen http://localhost:3545. The page must work: it picks a session, and the theme button works. The Cost button is hidden, because hub.can('session.cost') is false with the stub.
Reference
Section titled “Reference”- The apps entry and the launch: the env, the ready line and what the server must do.
- The app manifest: every field of
hub-app.json. - The SDK: the page API.
- Connect to other tools: how topics, actions and capabilities work.
- The built-in tools: what Kanban, Marketplace, Cost and Memory Diagnoser offer.