feat: OpenMax (CWS) channel plugin — full implementation - #1
Conversation
Group-conversation handling (access policy, owner auto-bind, dual-path mention detection, mention/smart modes, group/quote/thread context, media artifact_id, seq persistence + /sync catch-up) is specified by porting zylos-coco-workspace comm-bridge semantics. Adds a capability alignment matrix mapping each zylos-openmax capability to SDK vs plugin. Config schema and types extended to the aligned policy model. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Explored OpenClaw source: no per-message priority queue exists (command queue levels are trigger-derived; channel inbound is always foreground). The applicable mechanism is QueueMode (steer/followup/collect/interrupt) with a per-message replyOptions.queueModeOverride passthrough. Adopted mapping: normal=no override, high=steer, urgent=steer by default with interrupt behind a config flag. Caveat recorded: queueModeOverride is an internal-typed field, to be pinned by the connectivity test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SDK repo (openmaxai/cws-agent-sdk) settles sync/dedup/ack ownership and the InboundDelivery contract; transport layer under review in its PR#1. Remaining asks: systemEvent priority in the neutral message shape, outbound semantics placement, access-policy shape, StorageProvider coverage for last_seq and the mention registry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All four SDK asks are resolved: InboundMessage carries senderType + priority(1|2|3) into deliver(); sync/dedup/ack confirmed SDK-owned (SyncEngine + inbox-ledger with reserve/commit); access policy moves to SDK decideInbound with the decision riding on InboundMessage (group mode gains 'silent', owner auto-bind becomes the onOwnerBind callback); persistence goes through StorageProvider + loadSession/saveSession. Remaining gap: outbound mention-canonicalization registry is not in the SDK — suggest absorbing it, else the plugin ports zylos lib/mention.js. Config schema and types gain the 'silent' group mode. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wire CwsAgentBridge for connection/auth/sync/policy; the plugin now: - implements InboundDelivery.deliver(): group-context (before_seq, default 5) + quoted-reply + smart-hint blocks, media label fallback, and dispatch into the OpenClaw session; ok:true only after dispatch succeeds - maps System Member priority to a per-message queueModeOverride (high=steer, urgent=steer or interrupt via urgentQueueMode config) - treats group mode 'silent' as context-only (no agent wake; semantics to confirm with the SDK owner) - outbound: [SKIP] interception, mention canonicalization (ported zylos registry), splitMessage(3000) chunking, parent_id on first chunk only - persists owner auto-bind/name hints into openclaw.json; session cursor and SDK storage live under the plugin dataDir - config keys: coreUrl/wsUrl/agentToken/orgId (+ contextMessages, urgentQueueMode); agentId auto-fills from token-exchange member_id Dependency is pinned to the SDK PR#1 head commit as a git ref; switch to the npm version once released. Verified: esbuild syntax pass, npm install + SDK import, and all OpenClaw runtime API names against the openclaw source. Real-environment connectivity test (MVP step 5) pending. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Correctness vs SDK:
- onMemberId is (orgId, memberId) — was writing the org id into
self.member_id, breaking self-echo drop and structured-mention match
- supply syncSelf/loadConfig so the self-name hydration barrier can reach
nameReady (text @mention detection uses the authoritative display_name)
- pass a stable persisted deviceId + clientVersion so /sync cursors are
keyed per device instead of an empty id
- strip |reply:/|parent: endpoint suffixes for chunks 2..n (bridge.send
falls back to endpoint suffixes, re-threading every chunk)
Correctness vs OpenClaw:
- resolveAgentRoute takes {cfg, channel, accountId, peer} — the old shape
collapsed every conversation into the agent's main session; peer now
drives per-DM/per-group session isolation
- persistOwner clones the live config snapshot before mutating
- state dir via runtime.state.resolveStateDir() (runtime.dataDir does not
exist on current OpenClaw; tmpdir would lose ledger state on reboot)
- config.current() over deprecated loadConfig(); sendText returns
{channel, messageId, meta} per OutboundDeliveryResult
Security/resilience:
- gate priority→queueModeOverride on senderType===SYSTEM (metadata is
sender-forgeable; interrupt would abort in-flight agent work)
- escape the current message text and attachment file_name (structural
breakout into <group-context>/<replying-to> framing)
- atomic tmp+rename writes with 0600/0700 modes (kv.json holds JWTs;
truncated ledger replays ACKed messages), loud corrupt-store reads
- default COCO_RPC_LOG=0 (SDK logs token-exchange responses otherwise)
- restart-race-safe bridge lifecycle (identity-checked stopBridge)
- context-fetch timeouts, mention-registry conversation cap, replacement-
string-safe resolveMentions, reply-send failures logged without
re-running the inbound
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OpenClaw 2026.7.1 warns when a channel plugin manifest lacks channelConfigs, and once present its schema validates channels.openmax — which rejected the standard 'enabled' key under additionalProperties: false. Both found by the local gateway load smoke test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the git-ref pin. Published tarball vs the reviewed PR head differs only in internal hardening (atomic-dedupe enforcement for custom dedupers we don't pass, and redirect method/body semantics in token/http) — no API surface change. Local gateway load smoke re-run green on the npm dependency. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found live on cws-int: the SDK persists the ledger watermark on every record but writes sync_seq only on the periodic ack tick, and bridge.stop() doesn't flush it. A restart shortly after a delivery (our owner-bind config write triggers exactly such a gateway restart) finds no cursor, and the SDK's first-connect seek-to-inbox-end skips messages that arrived while down — an owner DM was lost this way. loadSession now falls back to the ledger's acked_seq so /sync catch-up recovers the gap. Depends on the SDK-internal inbox-<slug>.json storage key; remove once the SDK flushes the cursor on stop (reported upstream). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…notes) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n cursor seeding Images/files now reach the model: attachments resolve via AsService (artifact_id → presigned URL), download through http.getBytes, and are saved with core.channel.media.saveMediaBuffer so files land in an allowed media root (<configDir>/media/inbound, TTL-cleaned) — OpenClaw only inlines images from ctx.MediaPaths; a path in the body text is never read. Quoted-message media gets the same treatment and a caption-less quoted image no longer drops the quote block. Size-capped at OpenClaw's 10MB inline limit, 30s timeout, failures degrade to the [image]/[file] label without blocking delivery. Cursor seeding hardened per review: clamp a persisted sync_seq down to the ledger watermark (a /sync sweep can overrun a failed in-flight delivery; acked_seq only advances on genuine delivery, so clamping is always safe), and warn when the SDK's internal ledger key goes missing (upgrade drift guard). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
All sibling runtime adapters (claude-openmax, codex-openmax, hermes-openmax) and the SDK live under the openmaxai org. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gateway bridge 'openclaw message send' runs the plugin in a fresh CLI process where the WS bridge singleton doesn't exist, so sendText threw 'bridge not connected'. Outbound is REST-only: route it through CommService (markdown auto-detect + client_msg_id) over a lazily-built REST stack when no bridge is up, sharing the on-disk token cache. Per-chunk replyTo is now explicit, replacing the endpoint-suffix-stripping workaround. Verified live: 4213-char CLI send split into 2635+1577 chunks at a paragraph boundary, both delivered. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… tag) - drop the loadSession cursor seeding/clamp workaround: the orchestrator now seeds sync_seq from the ledger's durable acked_seq (#4) and floors gap sweeps at the watermark (#5) — verified live (deleted session file, SDK logged 'seeded sync_seq from ledger acked_seq=28') - replace the ported mention registry with the SDK's createMentionRegistry (#8), backed by the plugin kv store; call sites updated for the async API - delivery_state_update log noise gone (#6) Dependency pinned to the merged main commit 0225c5c; flip to the npm version once v0.1.0-alpha.1 is tagged (release workflow publishes on tag). CLI stateless send re-smoked green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Content-identical to the pinned main commit (0225c5c) the adaptation was verified against. Gateway restart green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Registering an agent, accepting the invitation, wiring the four config values, expected connect logs, first-DM owner bind, and group opt-in — with a pointer to docs/onboarding.md for the full walkthrough (incl. CF-Access environments). Status refreshed to e2e-verified on SDK 0.1.0-alpha.1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
zylos-luna-coco
left a comment
There was a problem hiding this comment.
Review: openclaw-openmax full implementation
Verdict: APPROVE — solid implementation, no P0/P1 blockers. Five P2 nits below.
Architecture alignment
The Bridge + Runtime Adapter pattern is correctly implemented:
- Layer 1 (SDK):
CwsAgentBridgeowns WS lifecycle, auth chain, sync/dedupe, access policy (decideInbound), mention registry (createMentionRegistry), and message splitting (splitMessage). - Layer 2 (Plugin):
deliverInbound(CWS → OpenClaw session with context blocks, media, priority mapping) andsendOutbound(agent reply → @mention canonicalization → chunking →bridge.send). - Behavioral semantics properly aligned with zylos-openmax: group context injection, smart-mode
[SKIP]interception, owner auto-bind persistence, reject-notice rules.
Security
Well handled:
- Priority-forgery gate: only
senderType === "SYSTEM"can escalatequeueModeOverride— prevents DoS via forged interrupt. - Structural-breakout guard:
escapeXmlneutralizes</>on all user-controlled text entering XML context blocks. - Token protection:
COCO_RPC_LOG=0default, 0600 file perms, atomic tmp+rename writes,sensitive: trueUI hint on agentToken. - No shell execution, no eval, no credential exposure paths.
Findings (all P2)
P2-1: Double-escaping in quoted media file names (index.ts, fetchQuoted + buildInboundBody)
When a quoted message has attachments but no text caption, fetchQuoted calls labelMedia which escapes file_name via escapeXml, then buildInboundBody escapes the entire blocks.quoted.text again. Result: &lt; instead of < for file names containing angle brackets. Fix: skip escapeXml on file_name inside labelMedia when called from fetchQuoted (the caller will escape), or mark the return as pre-escaped and skip the outer escape for that path.
P2-2: onOwnerNameHint missing guard for unbound owner (index.ts, ~L1270)
If the SDK calls onOwnerNameHint before onOwnerBind (unlikely but not contractually excluded), orgConfig.owner.member_id is undefined, and persistOwner(undefined, name) writes {memberId: undefined} to config. Self-healing on restart (falsy memberId → owner re-binds), but a guard is cleaner:
onOwnerNameHint: (_slug, name) => {
if (!orgConfig.owner?.member_id) return;
orgConfig.owner.name = name;
void persistOwner(orgConfig.owner.member_id, name);
},P2-3: Unbounded memberNames Map (index.ts, ~L1289)
The display-name cache in BridgeState.memberNames grows without limit. In a long-running gateway serving many conversations over days/weeks, this is a slow memory leak. Fix: cap at e.g. 5000 entries with LRU eviction, or clear on reconnect.
P2-4: Sequential attachment download (index.ts, downloadAttachments)
Attachments are downloaded one at a time in a for loop. For multi-attachment messages this adds unnecessary latency (up to 30s timeout × N). Fix: Promise.allSettled for parallel downloads with the same per-item timeout.
P2-5: Full schema duplication in plugin manifest (openclaw.plugin.json)
The channel config schema is duplicated verbatim between configSchema and channelConfigs.openmax.schema (~190 identical lines). Any future field addition requires updating both. If OpenClaw supports schema referencing, consolidate; otherwise add a comment noting the duplication to prevent drift.
What's done well
- Delivery invariant is respected:
{ok: true}only afterdispatchReplyWithBufferedBlockDispatchersucceeds; failures return{ok: false, failureClass, retryAfterMs}. - Reply-failure semantics are correct: a failed outbound send is logged but does NOT retry the inbound (avoids duplicate agent processing).
stopBridgerestart-race safety: old teardown only clears the singleton when it still points at the same bridge.withTimeoutwithunref()prevents hung context fetches from stalling the delivery pipeline or blocking process exit.- REST-only fallback stack for CLI
openclaw message sendshares the token cache without requiring a running gateway. - Design docs (both EN and CN) are thorough and the capability alignment matrix is a useful maintenance reference.
🤖 Generated with Claude Code
SDK alpha.2 removes the slug concept — orgs are keyed by org_id in callbacks and storage. Adapted: orgConfig drops slug, the hydrator loadConfig keys by org_id (was silently missing the member_id backfill), session/ledger callbacks renamed, and a one-time store migration renames session-default.json / the inbox-default.json kv key so the cursor and watermark survive the upgrade (verified: warm-restart sync_seq=29, no reseek). Review fixes (PR #1 re-review): - attachment downloads now run as one parallel batch (current + quoted, capped at 8) — N unavailable attachments cost one 30s timeout window, not N, so delivery/ACK is no longer stalled to minutes - member-name cache bounded (500, insertion-order eviction) - manifest de-duplicated: channelConfigs.openmax.schema is the canonical channel surface; top-level configSchema (validates plugins.entries config, unused here) shrunk to a permissive stub - design docs status refreshed (alpha.2, e2e-verified, MVP step 5 done) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Re-review findings addressed in e9b5297 (plus SDK 0.1.0-alpha.2 adoption):
Also in this push: SDK 0.1.0-alpha.2 removes the org 🤖 Generated with Claude Code |
Re-review P3: the SDK version claim is now version-agnostic (points at package.json), the capability matrix credits the SDK's createMentionRegistry instead of a plugin-owned port, the post-MVP list no longer calls inbound media label-only (it downloads current + quoted attachments into ctx.MediaPaths; outbound [MEDIA:] upload remains post-MVP), silent-mode semantics reference the issue #7 confirmation, and the sample connect logs use org_id keying (alpha.2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Re-review P3 (doc drift) fixed in 773d91f:
🤖 Generated with Claude Code |
Quoted-media labels are now built unescaped in fetchQuoted so buildInboundBody escapes the quoted text exactly once — the previous labelMedia reuse pre-escaped file_name and relied on escapeXml staying limited to </> to avoid double-escaping (latent trap, currently a no-op). onOwnerNameHint gains a defensive guard: never persist an owner name without a bound member_id (would drop the binding). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The label is the body caption / download-failure fallback — attachments reach the model via ctx.MediaPaths. The comment still described the pre-implementation label-only state. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rifts) R4 flagged the media contract lines (append-local-path / ---- suffix); instead of patching only those, this audits every behavioral claim in both design docs against index.ts and the SDK: - media: downloads land in ctx.MediaPaths (OpenClaw never reads body paths); body carries the [image]/[file] caption as download fallback - quoted media: same parallel batch into MediaPaths; label placeholder - threads: marked post-MVP (endpoint routing only, no thread-context block) — was described as implemented - dedup: count-based message-id deduper + inbox-ledger, not a 5-min TTL - cursor: per-org sync_seq (org_id-keyed) with SDK watermark seeding, not last_seq - reject notices: owned by the SDK orchestrator, not the plugin (matrix) - response modes: silent added alongside mention/smart - outbound [MEDIA:] upload: marked post-MVP (was described as behavior) - open-question #1 compressed to its resolved conclusion (stale terms removed from the historical narrative) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
What
Complete implementation of the openclaw-openmax channel plugin on top of
@openmaxai/openmax-agent-sdk@0.1.0-alpha.1: CWS connection/auth viaCwsAgentBridge, inbound delivery into OpenClaw agent sessions (group-context / quoted-reply / smart-hint blocks, media viactx.MediaPaths), stateless REST outbound (mention canonicalization, markdown auto-detect, 3000-char chunking), System-Member-priority → per-message queue-mode mapping, and owner auto-bind persistence.Why
Category A runtime adapter per the OpenMax Agent Runtime integration plan — connects OpenClaw agents to OpenMax/CWS with behavior aligned to zylos-openmax (see docs/design.en.md for the capability alignment matrix).
Test
[SKIP]-silent on chatter), image + file attachments read by the model, quoted-media, 4213-char reply chunked at a paragraph boundary, disconnect +/synccatch-up with cursor recovery, CLIopenclaw message send(stateless path)senderType === "SYSTEM"), structural-breakout escaping, and atomic 0600 storage writesReview checklist
Notes for reviewers
queueModeOverrideis an OpenClaw-internal-typed but runtime-effective reply option; docs/design.en.md records the decision and the mapping (normal=none, high=steer, urgent=steer or opt-in interrupt).🤖 Generated with Claude Code