Hub protocol v1
This spec is the contract between the hub and an app that runs in one of its tabs. It covers the launch, the SDK, the postMessage protocol, the theme variables and the app manifest. For an overview, see Architecture.
Status
Section titled “Status”Stable. Protocol version 1, manifest format 1. The protocol before v1 (v0) is not supported (Compatibility).
Versioning
Section titled “Versioning”| Version | Where | Changes when |
|---|---|---|
| Protocol | hello.protocol, welcome.protocol |
A change that an app or hub of the same version cannot ignore |
| Manifest | hub-app.json manifest |
A field that the hub must read in a new way |
| SDK | version in packages/claude-hub-sdk/package.json, also in the stamp line of each synced copy. Semver, with each version in CHANGELOG.md |
Any change to the SDK source. A test fails when the source changes and the version does not |
- Additive changes keep the version. A new message type, topic, action, optional payload field or manifest field is not a new protocol version. The hub and the app ignore a type or field they do not know (Message rules).
- A breaking change bumps the version. The next version is
2. An app lists every version it speaks inhello, and the hub answers with the highest version that both sides speak (Handshake). - The hub supplies the SDK. Under the hub, an app runs the hub’s SDK, so the app and the hub always speak the same version (SDK).
| Term | Meaning |
|---|---|
| Hub | The hub server and the hub page that holds the iframes |
| App | A web app that the hub spawns and shows in an iframe |
| App id | A name that matches ^[a-z][a-z0-9-]{0,31}$, unique in the hub, for example cost. hub is reserved |
| Pool | The set of app processes for one Claude config dir |
| Framed | The app document is not the top window (window.top !== window) |
| Current document | The document that the iframe holds now. A reload or a config-dir switch starts a new one |
| Live | The iframe sent hub:hello after its last load event |
1. Launch
Section titled “1. Launch”The hub spawns each enabled app once per config dir. It runs run.entry from the manifest with Node and --max-http-header-size=65536, with the hub root as the working directory. An app must resolve its own files from its script path, not from the working directory. The env vars:
| Env | Value |
|---|---|
CLAUDE_HUB |
1 |
HUB_URL |
The hub origin, http://localhost:<hub port>, with the port the hub is bound to. The hub binds its port before it starts any app |
CLAUDE_CONFIG_DIR |
The Claude config dir of this pool |
PORT |
0. The app binds any free port |
HOST, ALLOWED_HOSTS |
The hub’s bind rules. The app must use them as they are |
HUB_SDK_SERVER |
The absolute path of the hub’s server SDK, packages/claude-hub-sdk/src/server.js. Always set (SDK) |
| Capability env | Only for the provider of that capability, for example CCK_TERMINAL and CCK_TERMINAL_TOKEN for terminal |
The app reads these once at start. A new config dir gets a new process. If an app exits, the hub starts it again, up to 5 times in 60 seconds.
Ready line
Section titled “Ready line”When the app listens, it prints one stdout line with its real port:
<Name> running at http://localhost:<port>- The hub matches it with
/running at http:\/\/localhost:(\d+)/i. Print no other line that matches. - End the line with a newline: the hub reads stdout line by line.
- The hub waits 20 s for the ready lines of a config dir’s apps, then shows the apps that are ready.
IPC channel
Section titled “IPC channel”The hub spawns the app with a Node IPC channel. The server SDK’s mount() does all of this:
- The app answers
{type: 'hub:ping', id}with{type: 'hub:pong', id}. - The app exits when the channel closes, and unrefs the channel.
After 3 failed connects to the app, the hub pings it:
| Answer | What the hub does |
|---|---|
| Pong in 3 s | The loopback dropped the connects. The hub keeps the app, and the request retries for up to 30 s more. |
| No pong | The hub replaces the app. |
| Pong, but connects still fail after 30 s | The hub replaces the app. |
2. Origin
Section titled “2. Origin”The hub serves each app through a proxy port per app id. The proxy origin is the app’s origin in the browser, so it keys the app’s localStorage. So each app must declare a fixed run.defaultPort, different from the other apps.
The hub takes the first port that is set:
- The
--<id>-portflag. - The
portof the app’s entry in theappslist of the hub’sconfig.json. The user sets it, not the app, so it overrides the manifest. run.defaultPortfrom the manifest.
When that port is taken, the hub uses a random free port for this run, and the app starts with empty localStorage for that run.
The app must allow framing by the hub origin: it must not send an X-Frame-Options header or a frame-ancestors rule that blocks it.
3. SDK
Section titled “3. SDK”The hub ships the SDK and hands it to each app it spawns, so the app runs the hub’s version. The SDK has three files in packages/claude-hub-sdk/src:
| File | Runs in | Does |
|---|---|---|
server.js |
The app server, under the hub | mount(app) answers hub:ping (Launch), and registers GET /hub-config and GET /vendor/claude-hub-sdk.js, which serves client.js |
client.js |
The app page, under the hub | The app side of this spec. It sets window.ClaudeHub |
stub.js |
The app page, standalone | The same API as client.js, with no hub. It never fetches /hub-config and sends no message |
- Mount under the hub only. The app server calls
require(process.env.HUB_SDK_SERVER).mount(app)whenHUB_SDK_SERVERis set, before its static files, so the route forclient.jswins over the stub. - Ship the stub. The app serves
stub.jsas its ownpublic/vendor/claude-hub-sdk.js.npm run sdk:sync -- <app id | dir>in the hub repo copies it there, with a stamp line that names the SDK version and hash. It also copiesclient.jstotest/vendor/claude-hub-sdk.js, for the app’s tests. The app does not ship that copy. - Load it first. The page loads
/vendor/claude-hub-sdk.jsas a classic script, the first element in<body>, with nodeferorasync(Theme, rule 3). - Same API. The stub and the client have the same functions. The hub’s tests check this.
The app’s page code calls ClaudeHub.connect() and uses the returned object: subscribe, publish, bindTheme, onThemes, onActive, onStatus, handle, invoke, can, forwards, closeGuard, openExternal and terminalToken. It works the same with the stub and the client.
| Route | Response |
|---|---|
GET /hub-config |
{enabled: <CLAUDE_HUB is set>, url: HUB_URL} |
mount() registers this route, so it exists only under the hub. The client fetches it once at start. It is how the app learns that it is under the hub and which origin to trust.
4. Message rules
Section titled “4. Message rules”Each message is {type: 'hub:<name>', …payload}, sent with postMessage.
App side:
- Until
/hub-configresolves withenabled: true, the app sends nothing and drops every message. - The app accepts a message only when
e.source === window.parentande.originis the origin of/hub-config’surl. - The app posts only to
window.parent, with that origin as the target origin. - Every apply must be idempotent. The hub can send the same state more than once.
Hub side:
- The hub names the sender by
e.source: the iframe whosecontentWindowsent it. It drops a message whene.sourceis not an app iframe, or whene.originis not that app’s origin. A payload never names the sender. - The hub posts to an iframe with that app’s exact origin.
- The hub ignores a type it does not know. The app must do the same.
5. Handshake
Section titled “5. Handshake”| Direction | Type | Payload |
|---|---|---|
| App → hub | hub:hello |
{protocol: [1], subscribes?: string[]} |
| Hub → app | hub:welcome |
{protocol: 1, forward: string[], themes: Theme[], actions: string[]} |
- The app sends
helloafter its ownloadevent, once/hub-configresolves withenabled: trueand its message listeners are in place. It sends it once per document. protocollists the versions the app speaks. The hub answers with the highest version in both lists. When there is none, the hub does not answer, and the app works as an app with nowelcome(No welcome).- A valid
hellomakes the app live. The hub clears the live state on each iframeloadevent, becausee.sourcestays the same object across reloads. Every new document, from a reload or from asrcthat the hub sets, ends in aload. Becausehellocomes after the app’sload, the hub always seesloadfirst, and a latehellofrom the old document is cleared by thatload. - After
welcome, the hub sends the current state: ahub:eventfor each sticky topic insubscribesthat has a value (Events), andhub:active. An app gets no theme or project for a topic it did not subscribe to. welcomefields:
6. Theme
Section titled “6. Theme”The hub is the theme source. Under the hub, an app takes its colors from the hub. Its own theme CSS is for standalone use only.
Messages
Section titled “Messages”| Direction | Type | Payload |
|---|---|---|
| App → hub | hub:theme |
{theme: 'light' | 'dark', colorTheme?}: the user changed the theme in the app |
| Hub → app | hub:event theme.changed |
{theme, colorTheme, vars?} |
vars: the core variables for the current theme and mode, for example{"--accent": "#e86f33", …}. The hub omits it when it has no theme registry. Atheme.changedwith novarsmeans: remove the inline vars and use the app’s own theme CSS.Themeinwelcome.themes:{id, label, swatch: {dark, light}}, where each swatch is{bg, accent, ink, border}.- The id of the default theme, Ember, is
ember.
Core variables
Section titled “Core variables”| Group | Variables |
|---|---|
| Accent | --accent, --accent-text, --accent-dim, --accent-glow |
| Background | --bg-deep, --bg-surface, --bg-elevated, --bg-hover |
| Border | --border |
| Text | --text-primary, --text-secondary, --text-tertiary, --text-muted |
| Sidebar | --sidebar-bg, --sidebar-item-bg. Only some themes set them |
The hub does not theme semantic colors (--success, --warning, --error), chart series colors or fonts. The app owns them.
- Apply on
body. The app writesvarsas inline custom properties ondocument.body, so they win over every selector rule. It also sets its own mode class and theme attribute for the rest of its styles. - Apply on change only. When
varsequal the last set it applied, the app does nothing. - Paint the cached set first. The app keeps the last
varsinlocalStorage.- When framed, it applies them synchronously at start, before the first paint and before
/hub-configresolves. So the code runs from a classic script that is the first element in<body>, before the app’s own scripts, with nodeferorasync. In<head>,document.bodydoes not exist yet. - Standalone, it never applies them: a standalone tab can share the origin with the proxy.
- When the hub sends no
vars, or sends nowelcomewithin 2 s ofhello, the app removes the cached set from the page and fromlocalStorage. Until then, it keeps the cached set.
- When framed, it applies them synchronously at start, before the first paint and before
- Derive extras. An app computes any extra variable from the core set in its own CSS, for example
--chart-fill: color-mix(in srgb, var(--accent) 32%, transparent), so it follows a theme it does not know. It declares the extra onbody, not:root:var()resolves where the property is declared, and the inlinevarsare onbody. - Picker. Under the hub, the app’s theme picker lists
welcome.themes, which include the user’s own themes, with the hub’s swatches. The SDK’sonThemes(fn)callsfnonce with[{id, label}]after it adds a style sheet of.theme-swatch-<id>andbody.light .theme-swatch-<id>rules that set--sw-bg,--sw-accent,--sw-inkand--sw-border. Standalone, it lists the app’s own themes. - Report. When the user picks a theme in the app, the app sends
hub:themeand applies what it has: its own CSS for a theme it knows, else the swatch only. The hub stores the theme and sends it astheme.changedwithvarsto every live app, the sender included. - Unknown id standalone. An app that stored a hub-only theme id shows its default theme when it runs standalone.
- Subscribe. An app that applies the theme lists
theme.changedinhello.subscribes, so it gets the theme as a stickyhub:eventafterwelcome. The SDK’sbindThemeadds the topic. Call it right afterconnect(): the SDK sendshelloafterload, with the topics it has then. An app that binds afterhellogets no theme from the hub.
7. Actions
Section titled “7. Actions”An action is a request with exactly one handler, for example session.cost: “show the cost of this session”. The caller does not know which app handles it. The actions of the built-in tools are listed in The built-in tools.
<noun>.<verb or view>, lowercase: session.cost, project.plugins, project.memory. An app-specific name uses the app id as its prefix, for example cost.refresh. hub.* is reserved for the hub’s own actions. v1 has none. The hub skips an app handler with a hub.* name, with a log line.
Declaration
Section titled “Declaration”An app declares what it handles in its manifest (Manifest), because the hub needs it before the app runs:
"actions": { "handles": { "session.cost": { "params": { "session": "string?" }, "url": "?view=detail&session={session}", "mode": "message" } }}| Field | Meaning |
|---|---|
params |
Param names and types. The only types are string (required) and string? (optional) |
url |
A path and query relative to the app origin, with no #. {name} is replaced by the param value. The hub skips an action whose url has a # |
mode |
url (default) or message |
Messages
Section titled “Messages”| Direction | Type | Payload |
|---|---|---|
| App → hub | hub:invoke |
{id, action, params}. id is a string that the caller picks, to match the result |
| Hub → app | hub:result |
{id, ok: true, handledBy: <app id>} or {id, ok: false, reason: 'unhandled' | 'bad-params'} |
| Hub → app | hub:action |
{id, action, params}, to the handler in message mode |
Routing
Section titled “Routing”- Handler. The first enabled app in tab order that declares the action. When there is none, the hub answers
unhandled. - Params. The hub answers
bad-paramswhen a required param is missing, a param is not declared, or a value is not a string. - Switch. The hub switches to the handler’s tab.
messagemode. When the handler is live, the hub postshub:actionand answershub:resultat once. It does not wait for the handler to finish, and it does not queue a call for an app that is not live.urlmode, andmessagemode when the handler is not live. When every param in the template is present, the hub fills it in withencodeURIComponenton each value and sets the iframesrc. It builds the URL the same way as the firstsrc, so the handler keeps what the hub adds, such as the#t=fragment. It refuses a result that is not on the handler’s origin. When a param is missing, or the handler hashub:closeGuardon, the hub only switches: settingsrcreloads the app.- Handler duty. An app must handle every action that it declares with
mode: "message". It logs an action it does not know. - Availability. The caller shows a link or runs a key for an action only when the action is in
welcome.actions. With nowelcome, see No welcome.
8. Keys
Section titled “8. Keys”A key press inside an iframe does not reach the hub page, so the app forwards the combos that the hub binds.
| Direction | Type | Payload |
|---|---|---|
| App → hub | hub:keydown |
{key, code, ctrl, alt, shift, meta} |
welcome.forwardlists the combos by name. Today they arectrl+alt+p,ctrl+alt+w,ctrl+alt+a,ctrl+alt+ArrowLeft,ctrl+alt+ArrowRightandalt+1toalt+Nfor the first nine tabs. An app takes the list fromwelcome, not from this page.- Combo name. The modifiers that are down, in the order
ctrl,alt,shift,meta, joined by+, then the key. The key ise.keylowercased when that isa–zor1–9. Else, whene.codeisKey<A-Z>orDigit<1-9>, it is that letter or digit lowercased, because macOS turnsOption+<key>into another character. Else it ise.keyas it is. - The app forwards a press only when its combo name is in the list, and then prevents its default action. Every other key stays in the app.
- The hub runs the binding for that combo.
An element that handles keys before the document sees them, such as a terminal, asks the SDK’s forwards(e) and lets a forwarded key through.
9. Manifest
Section titled “9. Manifest”hub-app.json at the app root, next to package.json, tells the hub how to run the app and what it handles. An npm package must list it in files.
{ "manifest": 1, "id": "cost", "name": "Cost", "icon": "dollar-sign", "run": { "entry": "server.js", "defaultPort": 3543 }, "loading": { "verbs": ["Counting tokens…"] }, "actions": { "handles": { "session.cost": { "params": { "session": "string?" }, "url": "?view=detail&session={session}", "mode": "message" } } }, "publishes": [], "provides": {}}| Field | Meaning |
|---|---|
manifest |
The manifest format version. Now 1 |
id |
The app id |
name, icon |
The tab name and a Lucide icon name |
run.entry |
The server script, relative to the app root |
run.defaultPort |
The first choice for the proxy port (Origin). Not a reservation |
loading.verbs |
Lines for the hub’s loading screen while this app loads. Optional |
actions.handles |
Actions |
publishes |
The topics the app publishes (Events). Optional |
provides |
Capabilities, for example {"projects": {"path": "/api/projects"}, "terminal": {"liveWork": "/api/terminals"}}. The hub finds a capability’s provider here |
- Discovery, not runtime. The manifest holds what the hub needs before the app runs. The protocol version comes from
helloonly, because the running document may be an older bundle. - Where the hub reads it. For an
appsentry with apath, the hub reads the manifest from that folder. For a built-in app, it reads it from the submodule, else from the installed package, and keeps no copy. The hub’s tests validate the manifest of each pinned package. See The apps entry and the launch. - Skip. The hub skips an app, with a log line, when its file is missing or not valid JSON,
manifestis a version it does not know,iddoes not match the id rule (Terms) or is not the app’s id,run.entryis not inside the app directory, aprovidespath does not start with/, or apublishestopic breaks the topic rule or is the hub’s (Events). It skips one action, with a log line, when the action is not valid (Actions). - Providers. For each capability, the first enabled app in tab order whose
providesdeclares it is the provider.projects.pathandterminal.liveWorkmust be paths on the provider’s origin.projects.pathanswers[{path, modifiedAt}].terminal.liveWorkanswers{sessions: []}, and a pool whose provider has sessions is not evicted. With no provider, the capability is off: no terminal, and the project palette has no list.
10. Events
Section titled “10. Events”An event is a fact that any number of apps can receive. The hub publishes some topics itself, and an app publishes the topics its manifest lists.
| Direction | Type | Payload |
|---|---|---|
| App → hub | hub:hello |
subscribes: string[], the topics the app wants |
| App → hub | hub:publish |
{topic, payload}, a topic in the app’s manifest publishes |
| Hub → app | hub:event |
{topic, payload} |
| Topic | From | Sticky | Payload |
|---|---|---|---|
project.changed |
Hub | Yes | {project, encoded, name}, or null when there is no project |
theme.changed |
Hub | Yes | {theme, colorTheme, vars?} |
| App topics | An app | Yes | See App topics |
- A sticky topic keeps its last value in the hub. After
welcome, the hub sends the last value of each sticky topic the app subscribes to, then every change. A topic that has had no value yet sends nothing. - The hub sends an event only to live apps that subscribe to its topic.
projectis the absolute path andencodedis the hub’s encoded form of it. An app does not encode a path itself.- The hub starts with no project, so that each app restores its own. It sends
nullonly when the user clears the project. Onnull, the app clears its project scope. - A document that the hub loaded from an action URL does not get the
project.changedreplay afterwelcome, so the link keeps its project. The next change goes to it as usual. - An event never carries a secret.
App topics
Section titled “App topics”- Names.
<noun>.<verb>, lowercase, matching^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+$.project.changed,theme.changedandhub.*are the hub’s, and a manifest cannot list them. - Declare. The hub drops a
hub:publishwhose topic is not in the sender’s manifestpublishes. More than one app can publish the same topic. - Payload. A plain object or
null, at most 16 KB as JSON. Keep it small: ids and names, and let the receiver fetch the rest. The hub drops a payload that breaks this rule. - Project. When the payload has a string
project, the hub addsencodedandprojectNameto it, as inproject.changed. - Delivery. Every app topic is sticky, and its last value is cleared on a config-dir switch. The hub does not send an event back to the app that published it.
- Before
welcome. The SDK keeps only the latest payload per topic and sends it onwelcome. With nowelcome, it drops them. - Hidden apps. The hub sends an app topic only to the app on screen. A hidden app gets the last value of each topic it missed when it comes on screen, before
hub:active.project.changedandtheme.changedgo to every app at once.
The topics, actions and capabilities of the built-in tools, such as Kanban’s session.changed, are not part of the protocol. See The built-in tools.
11. Compatibility
Section titled “11. Compatibility”| Pair | What happens |
|---|---|
App with no hello + v1 hub |
The hub sends the app no theme, project, keys or active state |
v1 app + hub with no welcome |
The app gets no welcome. It forwards no keys, calls no hub action, and uses its own theme CSS |
| v1 app standalone | The app serves the stub (SDK). It sends no message and uses its own URLs and themes |
v0 (hub:project, hub:keys, hub → app hub:theme, hub:navigate, hub:keydown with {key} only) is not supported. An app and a hub from before v1 do not work with a v1 hub or app. So the v1 releases of the hub and all four apps are breaking and ship together, and the hub pins the exact v1 app versions.
No welcome
Section titled “No welcome”- Wait. The app waits 2 s after
helloforwelcome. With none, its status isunanswered, and it removes the cached theme (Theme, rule 3). Awelcomethat comes later still applies. - Calls wait too. An action call made before
welcomeand before the 2 s end waits for one of them. After the 2 s,canis false for every action and a call resolvesunhandled.
12. Other messages
Section titled “12. Other messages”| Direction | Type | Payload | Meaning |
|---|---|---|---|
| Hub → app | hub:active |
{active: boolean} |
Whether this app is the tab on screen. Sent after welcome and on each tab switch. An app cannot find this itself: a hidden iframe is display: none, and visibilityState follows the top window |
| App → hub | hub:closeGuard |
{on: boolean} |
While any app has it on, the hub asks before the window closes. Cleared when the app document loads again |
| App → hub | hub:openExternal |
{url} |
The hub opens an http or https URL in a new window |
| App → hub | hub:terminalToken |
{} |
The terminal provider asks for a fresh token |
| Hub → app | hub:terminalToken |
{token} |
The answer. Sent only to the terminal provider |
The hub gives the terminal provider its first token in the iframe URL fragment, #t=<token>.
Hub token. The hub never sends its token to an app. Cookies on localhost are shared across ports, so the browser sends the hub_token cookie to every app origin. The proxy removes hub_token from the Cookie header of each request and WebSocket upgrade before it forwards them.
Planned
Section titled “Planned”These are not in v1. Each is additive (Versioning).
- A shared shortcut list: each app serves a
shortcuts.json, named by a manifest fieldshortcuts, and the hub shows all of them in one overlay on Ctrl+Alt+K through the hub actionhub.shortcuts. - More hub actions, such as
app.<id>andhub.nextApp, with a user keymap that can bind app actions to global keys. - A
providersmap in the user config to pick one handler or provider among several. v1 takes the first enabled app in tab order. - The manifest fields
invokesandreservedKeys. - Data events from an app server, sent over its IPC channel, such as a task that moved, so that an app hears them while its page is hidden.
hub.trace: a hub topic that mirrors every message the hub sends or receives, for tools such as an inspector.- A
projectparam type that gives a template{project.encoded}. run.readyin the manifest, and a fixed prefix for the ready line.- Added apps from npm packages, and external apps that the hub does not spawn.