The Claude Code runtime adapter for OpenMax / CWS. A thin Category-B
(bare runtime) adapter: it owns none of the CWS protocol — that all comes from
@openmaxai/openmax-agent-sdk (CwsAgentBridge) — and does
only the two runtime-specific translations plus capability exposure:
- Inbound — bring a workspace message into Claude Code's visible context
via an experimental
claude/channelMCP push (raft-channel-wake.v1). - Outbound — send Claude's reply back to cws-core via the SDK's
CommService. - Capability exposure — the SDK's six service clients
(
tm/kb/as/comm/core/conn) as MCP tools, so the agent can operate the workspace (create issues/tasks, query the KB, upload files, reply).
It follows the proven reference implementation,
raft-external-agents v0.3.1
— the only one of the four external runtimes with a shipped Claude Code plugin —
and the claude-openmax adapter design.
Layer 1 @openmaxai/openmax-agent-sdk (CWS HTTP/WS contract only)
CwsAgentBridge: per-org WS lifecycle · auth/heartbeat/reconnect · atomic dedupe
· /sync + inbox-ledger · frame dispatch · access-policy · normalized InboundMessage
services: tm / kb / as / comm / core / conn (one CwsHttpClient)
providers: StorageProvider · RuntimeStateProvider · InboundDelivery(★) · Logger
▲ import + inject
Layer 2 claude-openmax (this repo)
┌ bridge host (Node) ────────────────┐ ┌ Claude Code (agent) ──────────┐
│ new CwsAgentBridge({providers,cbs}) │ │ MCP `openmax` server: │
│ providers.inbound.deliver ─────────┼─wake──▶│ experimental claude/channel │
│ = derive WakeRequest → push │ │ → pushes notice into context │
│ storage=local data dir · logger │ │ MCP tools: tm kb as comm core │
│ holds 6 SDK service clients ◀──────┼─call───┤ conn + comm_send │
└─────────────────────────────────────┘ └───────────────────────────────┘
│ CommService.send() / bridge.send() → cws-core
▼
cws-core REST ◀── cws-comm WS (inbound frames) ── COCO Workspace (user)
- In-process (MVP, default) — one Node process is both the stdio MCP server
(
claude/channel+ tools) and the host ofCwsAgentBridge.InboundDeliverypushes wakes straight to the channel; the/wakeHTTP hop is skipped but theWakeRequestwire shape is preserved. Run: Claude Code loads the plugin. - Split (design topology 1,
CLAUDE_OPENMAX_MODE=channel-only) — the MCP plugin runs only the channel + an HTTPPOST /wakeserver; a separate residentbridge.jsholds the WS and POSTs wakes. The bridge survives Claude Code session restarts and redelivers via the SDK's/sync+ inbox-ledger.
The single most important rule (from the SDK's wake-result schema and
CwsAgentBridge): ok:true MUST mean the message genuinely entered the
runtime's visible context. On ok:true the SDK commits dedupe + ledger +
read markers and stops /sync retry for that message — so a false ok:true
loses the message forever.
This adapter returns ok:true only when the wake injection resolved
(ClaudeChannel.notifyWake / POST /wake succeeded). Anything else —
channel not connected, notification write failed, malformed inbound — returns
{ok:false, failureClass, retryAfterMs}, so the SDK holds all markers and
redelivers on the next /sync sweep. See src/inbound-delivery.js and its
tests.
| File | Purpose |
|---|---|
src/index.js |
MCP channel plugin entrypoint (Claude Code loads this over stdio); default in-process bridge host. |
src/bridge.js |
Standalone resident bridge for the split topology; POSTs wakes over HTTP /wake. |
src/channel.js |
MCP Server declaring the experimental claude/channel capability; notifyWake pushes notifications/claude/channel. |
src/wake.js |
Pure raft-channel-wake.v1 derivation + validation + the human-visible wake notice/meta builders. |
src/inbound-delivery.js |
InboundDelivery.deliver() — derive WakeRequest, inject, gate ok:true. |
src/notifier.js |
Debounced wake coalescing (raft EAB-8): leading-edge inject + window merge. |
src/wake-server.js |
HTTP POST /wake server for the split topology (token-guarded). |
src/mcp-tools.js |
Wraps the six SDK service clients as MCP tools (one dispatch tool per service + comm_send). |
src/config.js |
Loads adapter config; builds CwsHttpClient + TokenManager + services; SDK callback seams (session/config/owner persistence). |
src/create-bridge.js |
Assembles CwsAgentBridge from the runtime + providers. |
src/storage.js |
File-backed StorageProvider under a local data dir (XDG); no ~/zylos coupling. |
src/providers.js |
stderr logger + empty RuntimeStateProvider (Cat.B degraded metrics). |
.claude-plugin/plugin.json |
Registers the openmax MCP server for Claude Code. |
hooks/hooks.json + hooks/session-hook.js + hooks/orientation.js |
SessionStart orientation injection (survives resume/compaction). |
CLAUDE.md |
Agent-facing instructions: how wakes arrive, how to read/reply, tool map. |
test/*.test.js |
node --test unit tests (frame derivation, ok:true gating, coalescing, tool dispatch, orientation). |
Uses Claude Code's built-in autocompact (and /clear / /compact). This
adapter implements no extra compression logic — by design.
Install as a Claude Code plugin from this repo's marketplace:
claude plugin marketplace add openmaxai/claude-openmax # register the marketplace (once)
claude plugin install openmax-channel@openmax # install the pluginClaude Code fetches the plugin, installs it into its managed plugin directory,
and keeps it updated — no manual git clone needed. Then create your config
(see Configuration) and start Claude Code.
Experimental wake — one caveat. The MCP tools (
tm/kb/as/comm/core/conn+comm_send) work from a plain plugin install with no flags. The inbound wake (claude/channel, how workspace messages reach the agent) is still an experimental Claude Code capability and currently requires launching with the development-channels flag:claude --dangerously-load-development-channels plugin:openmax-channel@openmaxReference the plugin, not a bare server name. When installed via
claude plugin install, Claude Code namespaces this MCP server —/mcpshows it asplugin:openmax-channel:openmax, notopenmax. So the bare--dangerously-load-development-channels server:openmaxdoes not match a plugin install (it only works if you register the server directly in a.mcp.jsonunder the literal nameopenmax); use theplugin:…@…form above. If in doubt, run/mcpand reference the exact server name it prints. Onceclaude/channelgraduates from experimental, install alone will be enough.
npm install # resolves @openmaxai/openmax-agent-sdk@alpha from npm
cp config.example.json ~/.config/claude-openmax/config.json # fill in real values
npm test # node --testLoad into Claude Code as a plugin (dev):
claude plugin marketplace add --scope local /path/to/claude-openmax # local checkout
# or point Claude Code at .claude-plugin/plugin.json directlyThe plugin ships a dependency-free bundle. Claude Code installs a marketplace
plugin by cloning the repo and does not run npm install, so the MCP server
must run with zero node_modules. scripts/build.js (esbuild) inlines every
dependency into dist/index.mjs (the MCP server, referenced by
.claude-plugin/plugin.json) and dist/bridge.mjs (the split-topology bridge).
npm run build # rebuild dist/ after changing src/ or bumping a dependencydist/ is committed (it is the shipped artifact); CI rebuilds it and fails if the
committed bundle is stale, and smoke-tests that it loads with no node_modules.
Split topology (resident bridge + channel-only plugin):
# terminal A: Claude Code loads the plugin with
CLAUDE_OPENMAX_MODE=channel-only CLAUDE_OPENMAX_WAKE_PORT=47600 CLAUDE_OPENMAX_WAKE_TOKEN=... claude ...
# terminal B: resident bridge (config.wake.endpoint = http://127.0.0.1:47600/wake)
CLAUDE_OPENMAX_WAKE_TOKEN=... node src/bridge.jsConfig file at $CLAUDE_OPENMAX_CONFIG (or ~/.config/claude-openmax/config.json);
see config.example.json. As of the config-parity refactor the on-disk shape is a
1:1 structural mirror of the OpenMax (zylos-openmax) component's config — see
the migration note below. The shape:
enabled?: bool
server: { bff_url, ws_url, frontend_base_path } // frontend_base_path default "/workspace"
agent: { identity_id, api_key, device_id, app_version }
cf_access: { client_id, client_secret }
orgs: { "<org_id>": { enabled?, org_id, org_name?,
owner: { member_id, name },
self: { member_id, name, display_name },
access:{ dmPolicy, dmAllowFrom?, groupPolicy?, groups?:{ "<convId>": { mode, allowFrom } } } } }
wake: { endpoint } // claude-openmax ONLY (openmax has no wake)
metricsReport?: { dashboardApiKey } // RESERVED / forward-compat — inert (no reporter yet)
ws?: { reconnectMaxMs?, heartbeatIntervalMs?, pingIntervalMs? } // claude-openmax WS tuning knobs
Env fallbacks (map onto the nested fields): COCO_API_URL→server.bff_url,
COCO_WS_URL→server.ws_url, COCO_FRONTEND_BASE_PATH→server.frontend_base_path,
COCO_API_KEY→agent.api_key, COCO_DEVICE_ID→agent.device_id,
COCO_CLIENT_VERSION→agent.app_version, COCO_ORG_ID→default org. Other knobs:
CLAUDE_OPENMAX_DATA_DIR, CLAUDE_OPENMAX_MODE, CLAUDE_OPENMAX_DEBOUNCE_MS,
CLAUDE_OPENMAX_CONTENT_FREE, CLAUDE_OPENMAX_WAKE_{HOST,PORT,TOKEN}.
orgs is keyed by org_id (openmax-style), end to end: the SDK orchestrator
keys its per-org runtime records by org_id too, so the adapter hands it an
org_id-keyed map directly — there is no separate per-org key to derive. Every
self-healing write-back (self.member_id, self.name, owner bind) resolves the
org by org_id and lands back in the org_id-keyed on-disk structure.
agent.identity_id is the agent's global identity. Leave it empty and the
adapter resolves it from cws-core GET /me at startup and caches it back to
config.json. It is the leadAgentId for the guided-autonomy flow (an Issue's
Lead agent = the agent itself).
server.frontend_base_path is wired into the SDK's CwsHttpClient.frontendUrl()
so the agent can build clickable workspace links (<bff_url><frontend_base_path>/…,
default /workspace).
The claude-openmax config is now structurally identical to the openmax
component's config.json — you can drop an openmax config in as-is. The only
differences are additive and claude-openmax-specific:
wake.endpoint— required for the split-topology bridge; openmax has no wake block.metricsReport— accepted for parity but inert (claude-openmax has no metrics reporter yet); it round-trips untouched.ws— optional WS tuning knobs (reconnectMaxMs,heartbeatIntervalMs,pingIntervalMs) that openmax hardcodes;ws_url/device_id/app_versionlive underserver.*/agent.*, NOT here.
The old claude-openmax shape (top-level http/auth + an array orgs) is
still accepted: it is translated to the new shape on load with a one-time warning,
so an existing live config won't break — but you should migrate it.
Session files auto-migrate. Per-org state (incl. the /sync cursor) is now
keyed by org_id (sessions/<org_id>.json), where earlier builds used a derived
slug (sessions/<slug>.json). On first load, if only a legacy sessions/<slug>.json
exists (matching an explicit slug or slugify(org_name)), it is copied forward
to the org_id key so the cursor is preserved — no duplicate message delivery after
upgrade. The old file is left in place (harmless); no manual step is needed.
Each org also honors an enabled: false flag — such orgs are kept on disk but
not connected to (parity with the openmax component).
Verified locally (node --test + MCP client smoke):
- WakeRequest derivation/validation,
ok:truegating, coalescing, tool dispatch — 32 unit tests green. - The MCP server boots, advertises
capabilities.experimental["claude/channel"], and lists all 7 tools to a real MCP client. POST /wake(token-guarded) →server.notification({method:"notifications/claude/channel", ...})is actually transmitted over the MCP transport and received by the connected client with correct content + routing meta, and the server returns{ok:true, runtimeSession}.server.notificationwith the customclaude/channelmethod does not throw on@modelcontextprotocol/sdk1.22+.
claude/channelrendering. We proved the notification reaches an MCP client; we have not proved Claude Code (a) enables this experimental capability, (b) renders the pushed notice into the agent's visible context, and (c) does so promptly / can steer an in-progress turn. Until confirmed,ok:truemeans "notification written to the MCP transport", which is the strongest local signal but weaker than "the model has seen it". If Claude Code proves fire-and-forget here, we should fall back to a more conservativeok:truegate (and lean harder on/sync).runtimeSessionbinding. We mint a stable per-process id; the canonical value is Claude Code's real session id, which must be sourced from the live runtime.- Session lifecycle / auth jitter under the in-process topology (WS drops when the Claude Code session exits) needs a live soak test; the split topology is the mitigation and also needs end-to-end restart verification.
- Tool budget. We collapsed ~150 sub-commands into 6 dispatch tools +
comm_sendto stay within Claude Code's tool budget; the exact budget and whether dispatch-style tools are ergonomic for the model is unconfirmed.
- Consumes the SDK for all protocol/transport/sync/dedup/access-policy logic;
reimplements none of it. Does not pass a custom
callbacks.dedupe(uses the SDK's built-in atomic deduper). - No dependency on
zylos-openmax; independent local data dir (no~/zylos).