Skip to content

feat: OpenMax (CWS) channel plugin — full implementation - #1

Merged
athanbase merged 22 commits into
mainfrom
feat/openmax-channel-plugin
Jul 18, 2026
Merged

feat: OpenMax (CWS) channel plugin — full implementation#1
athanbase merged 22 commits into
mainfrom
feat/openmax-channel-plugin

Conversation

@athanbase

Copy link
Copy Markdown
Collaborator

What

Complete implementation of the openclaw-openmax channel plugin on top of @openmaxai/openmax-agent-sdk@0.1.0-alpha.1: CWS connection/auth via CwsAgentBridge, inbound delivery into OpenClaw agent sessions (group-context / quoted-reply / smart-hint blocks, media via ctx.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

  • Tested locally — verified end-to-end against cws-int: DM round-trip, owner auto-bind (+ persistence across the config-reload restart), group allowlist rejection / owner-@mention bypass / structured mentions, smart mode (answers relevant, [SKIP]-silent on chatter), image + file attachments read by the model, quoted-media, 4213-char reply chunked at a paragraph boundary, disconnect + /sync catch-up with cursor recovery, CLI openclaw message send (stateless path)
  • Self-review via 3-angle agent fan-out (SDK contract / OpenClaw API / security+resilience) — 12 findings fixed, incl. a priority-forgery interrupt-DoS gate (senderType === "SYSTEM"), structural-breakout escaping, and atomic 0600 storage writes
  • gitleaks clean over full history

Review checklist

  • No secrets or credentials in code
  • Changes match the PR title
  • Breaking changes documented (if any) — none; initial implementation

Notes for reviewers

  • queueModeOverride is 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).
  • Known follow-ups (non-blocking): owner display-name stays empty until the name-hint path fires; queueModeOverride live verification needs a platform SYSTEM message; multi-account and outbound media are post-MVP alignment items.

🤖 Generated with Claude Code

Zylos and others added 17 commits July 17, 2026 15:55
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 zylos-luna-coco left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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): CwsAgentBridge owns 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) and sendOutbound (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 escalate queueModeOverride — prevents DoS via forged interrupt.
  • Structural-breakout guard: escapeXml neutralizes </> on all user-controlled text entering XML context blocks.
  • Token protection: COCO_RPC_LOG=0 default, 0600 file perms, atomic tmp+rename writes, sensitive: true UI 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: &amp;lt; instead of &lt; 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 after dispatchReplyWithBufferedBlockDispatcher succeeds; 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).
  • stopBridge restart-race safety: old teardown only clears the singleton when it still points at the same bridge.
  • withTimeout with unref() prevents hung context fetches from stalling the delivery pipeline or blocking process exit.
  • REST-only fallback stack for CLI openclaw message send shares 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>
@athanbase

Copy link
Copy Markdown
Collaborator Author

Re-review findings addressed in e9b5297 (plus SDK 0.1.0-alpha.2 adoption):

  • [P2] serial attachment downloads → current + quoted attachments now download as one parallel batch (each self-guarded by a 30s timeout + catch, capped at 8 per message). Worst case is one timeout window, not N.
  • [P2] unbounded member-name cache → capped at 500 entries, insertion-order eviction.
  • [P3] duplicated manifest schemachannelConfigs.openmax.schema is the single canonical channel surface; the top-level configSchema (which validates plugins.entries.<id>.config — unused by this plugin) is now a permissive stub. Verified against the loader: validatePluginConfig consumes the top-level schema, channelConfigs validates channels.openmax.
  • [P3] stale design-doc status → both design docs now say alpha-line SDK + e2e-verified; MVP step 5 marked done with the verified scenario list.

Also in this push: SDK 0.1.0-alpha.2 removes the org slug concept (org_id keying everywhere). Adapted callbacks/orgConfig accordingly, fixed the hydrator loadConfig key (was still slug-keyed — member_id backfill would have missed), and added a one-time store migration (session-default.json / inbox-default.json → org_id-keyed) so the sync cursor and ledger watermark survive the upgrade. Verified live: warm-restart resumed at sync_seq=29 with no reseek.

🤖 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>
@athanbase

Copy link
Copy Markdown
Collaborator Author

Re-review P3 (doc drift) fixed in 773d91f:

  • README status line no longer pins a stale SDK version — it points at package.json (currently 0.1.0-alpha.2)
  • capability matrix (both design docs) now credits the SDK's createMentionRegistry (issue chore: release v1.2.0 #8) instead of a plugin-owned port
  • post-MVP list no longer says inbound media is label-only — current + quoted attachments download into ctx.MediaPaths; only outbound [MEDIA:] upload stays post-MVP
  • silent-mode line references the issue fix: own the access config record, and reconcile policy with cws-comm instead of pushing over it #7 confirmation (context-only)
  • bonus catch: the README sample connect logs still showed pre-alpha.2 org=default keying — updated to org=<org_id>

🤖 Generated with Claude Code

Zylos and others added 3 commits July 18, 2026 15:29
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>

@zylos0t zylos0t left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

R5 exact-head review CLEAN at 2862e40. No findings remain. Approval only; no merge/deploy.

@athanbase
athanbase merged commit b00475e into main Jul 18, 2026
3 checks passed
@athanbase
athanbase deleted the feat/openmax-channel-plugin branch July 18, 2026 08:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants