Official Roam HQ channel plugin for OpenClaw.
Extracted from the in-tree extensions/roam plugin per the recommendation in
OpenClaw PR #64066: commercial messaging-service integrations like Roam belong
as standalone plugins on npm rather than in core.
| Plugin id | npm package | Channel id |
|---|---|---|
@roamhq/openclaw-roam |
@roamhq/openclaw-roam |
roam |
The channel id stays roam so existing user configs (channels.roam.*)
keep working.
openclaw plugin install @roamhq/openclaw-roamCreate a bot token in Roam Administration → Developer, then set it in your OpenClaw config:
{
channels: {
roam: {
apiKey: "your-roam-bot-token",
webhookUrl: "https://your-gateway.example.com/roam-webhook",
webhookSecret: "whsec_your-signing-key-from-roam-admin",
},
},
}Start the gateway:
openclaw gateway runRoam groups can add a group-specific system prompt snippet, matching the Discord channel behavior. The prompt is appended to the agent's normal system prompt for inbound group turns only.
channels:
roam:
groupPolicy: allowlist
groups:
"*":
systemPrompt: "Default instructions for Roam group chats."
"01234567-abcd-4000-8000-000000000000":
requireMention: false
systemPrompt: "Use the engineering triage persona in this group."Specific group prompts override the wildcard default. Blank prompts are treated
as unset. requireMention is also supported per group and defaults to true
when unset.
Roam delivers inbound messages via webhooks to a local HTTP route (default path:
/roam-webhook). Set webhookUrl to your gateway's public URL and OpenClaw
auto-subscribes via webhook.subscribe on startup.
webhookSecret is required. Roam uses the
Standard Webhooks scheme; OpenClaw verifies
the webhook-id, webhook-timestamp, and webhook-signature headers on every
request and rejects invalid or stale signatures. Startup fails fast if
webhookSecret is unset.
Two paths, configurable per account via streaming.nativeTransport:
- Default (
nativeTransportunset/false): draft path. Reply opens with onechat.post, then edits in place via repeatedchat.updatecalls (Telegram- style edit-in-place). Throttled to ~1 update/sec. Splits across multiple Roam messages when content crosses the 8 KB byte cap. - Native (
nativeTransport: true): three-call lifecycle.chat.startStreamopens a server-tracked stream (server assignsstreamId); each accumulated text snapshot is pushed viachat.appendStreamwithsnapshot: true;chat.stopStreamfreezes the final message. Bothkind: "text"(answer) andkind: "thinking"(reasoning) use this lifecycle.
Disable streaming entirely:
{ channels: { roam: { streaming: { mode: "off" } } } }The reasoning/thinking lane always uses the native lifecycle with
kind: "thinking" so Roam renders it as a collapsed thought-bubble.
Pulses chat.typing immediately on inbound webhook arrival (before
agent setup), then re-pulses every 2s while the agent runs. Cancels on the
first partial reply token (or on first chat.post if streaming is off) so the
indicator clears before the message text appears, not after.
See the upstream channel docs for the full configuration reference, multi- account setup, group policy, and access-control patterns: https://github.com/openclaw/openclaw/blob/main/docs/channels/roam.md
- Requires
openclaw >= 2026.4.0. - Node 20+.
- The plugin targets the public
openclaw/plugin-sdk/*surface only. See SDK audit below for the small set of helpers that are inlined locally because they are not yet on a public SDK subpath.
npm install
npm test # 130 vitest tests, all mocked
npm run typecheck # tsc --noEmitThe test suite is unit tests with vi.mock on the SDK barrel and on
fetchWithSsrFGuard — no Roam network calls. See
Testing live against Roam
for the manual end-to-end checklist before publishing.
Run this checklist against a real Roam workspace before tagging a release.
You'll need:
- A Roam workspace where you can create a bot.
- A bot API key — Roam Administration → Developer → create key.
- A webhook signing secret — same admin panel; required at startup.
- A public HTTPS URL that forwards to your gateway (
ngrok http 8080, Cloudflare Tunnel, etc.). The tunnel URL is what you put inwebhookUrl. - An OpenClaw checkout/install you can run locally.
npm pack the local tree and install the tarball into your OpenClaw config:
npm pack
openclaw plugin install ./roamhq-openclaw-roam-0.1.0.tgzFor iterative dev, you can also point at the absolute path of this repo
directly (the index.ts source loads via OpenClaw's jiti runtime):
openclaw plugin install /absolute/path/to/openclaw-roamchannels:
roam:
apiKey: rk_... # bot API key from Roam admin
webhookUrl: https://<your-tunnel>/roam-webhook # public URL → local gateway
webhookSecret: whsec_... # from Roam admin (required)
dmPolicy: pairing # default; safe for testingAdd streaming: { nativeTransport: true } to test the three-call native
lifecycle (chat.startStream/chat.appendStream/chat.stopStream) instead
of the draft chat.post/chat.update path.
openclaw gateway runOn startup, look for these log lines:
[default] Roam bot persona: <name> (<id>)—token.inforeturned the bot identity. Without this, self-message filtering is disabled.[default] Roam webhooks subscribed at https://...— auto-subscription succeeded.
If webhookSecret is missing you get a fast-fail with
Roam webhook mode requires a non-empty signing secret.
- DM the bot. First message produces a pairing challenge (default
dmPolicy: pairing). The challenge message contains the exactopenclaw pairing approve roam <code>command. - Approve the pairing, then DM again. The reply streams live — you'll
see one Roam message appear and grow in place as
chat.updaterewrites it (~1 update/sec). - Group test. Add the bot to a group. Default per-group
requireMention: truemeans a bare message stays silent; mentioning the bot triggers a reply. IfgroupPolicyisn't set explicitly, it defaults toallowlist— add the group togroups: { "*": {} }to allow all groups. - Long reply. Trigger a response longer than 8KB (e.g. ask for a long
summary). The reply should split into multiple consecutive Roam messages
at UTF-8-safe boundaries — one open message via
chat.update, then the next opens viachat.post. - Self-loop guard. Confirm the bot does not reply to its own
messages. (Verified by
botIdentity.idmatching the inbounduserIdfilter.) - Restart.
Ctrl-Cthe gateway. Logs should showwebhook.unsubscribethen re-subscribe on the nextgateway run. - Uninstall.
openclaw plugin uninstall @roamhq/openclaw-roam— runs thelogoutAccountpath that clearsapiKey/apiKeyFilefrom config.
Configure two accounts under channels.roam.accounts:, each with a distinct
webhookUrl (the path differs by account id — /roam-webhook for default,
/roam-webhook-<accountId> otherwise). Verify both subscribe at startup and
inbound messages route to the right account by webhookPath.
- Stale webhook subscription. If you swap the tunnel URL between runs,
Roam still has the previous subscription. Shutdown calls
webhook.unsubscribebest-effort, but if the process crashed the old URL is orphaned — re-register manually in Roam admin, or just keep the same tunnel hostname between runs. - Webhook signature 401s. Verify
webhookSecretmatches what Roam admin shows. Thewhsec_prefix is optional (the verifier accepts both forms). - Duplicate inbound messages. The local Roam appserver double-delivers
webhooks ~50ms apart with distinct
webhook-ids but identicalmessageId. The plugin dedups in-memory for 60s — visible asdrop duplicate messageId=...log lines. Public edge typically does not double-deliver. dmPolicy: openrequiresallowFrom: ["*"]. The Zod schemasuperRefinerejectsdmPolicy: "open"without*inallowFrom.
This plugin imports only from the public, non-channel-bundled subpaths of
the openclaw package. The mapping for every symbol the implementation uses is
in runtime-api.ts:
| Symbol(s) | Public subpath |
|---|---|
OpenClawConfig, ChannelPlugin, AllowlistMatch, PluginRuntime |
openclaw/plugin-sdk/core |
RuntimeEnv |
openclaw/plugin-sdk/runtime |
OutboundReplyPayload, deliverFormattedTextWithAttachments, resolveSendableOutboundReplyParts |
openclaw/plugin-sdk/reply-payload |
SecretInput, buildSecretInputSchema, hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString |
openclaw/plugin-sdk/secret-input |
DmPolicy, GroupPolicy, formatDocsLink, WizardPrompter, setSetupChannelEnabled, applyAccountNameToChannelSection, ... |
openclaw/plugin-sdk/setup |
createWebhookInFlightLimiter, readWebhookBodyOrReject, registerWebhookTargetWithPluginRoute, resolveWebhookPath, withResolvedWebhookRequestPipeline |
openclaw/plugin-sdk/webhook-ingress |
buildBaseChannelStatusSummary, buildRuntimeAccountStatusSnapshot |
openclaw/plugin-sdk/status-helpers |
DEFAULT_ACCOUNT_ID, normalizeAccountId |
openclaw/plugin-sdk/routing |
GROUP_POLICY_BLOCKED_LABEL, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce |
openclaw/plugin-sdk/runtime-group-policy |
createChannelPairingController, createLoggedPairingApprovalNotifier, createPairingPrefixStripper |
openclaw/plugin-sdk/channel-pairing |
dispatchInboundReplyWithBase |
openclaw/plugin-sdk/inbound-reply-dispatch |
logInboundDrop |
openclaw/plugin-sdk/channel-inbound |
logTypingFailure |
openclaw/plugin-sdk/channel-feedback |
readStoreAllowFromForDmPolicy, createAllowlistProviderRouteAllowlistWarningCollector |
openclaw/plugin-sdk/channel-policy |
resolveChannelPreviewStreamMode, resolveChannelStreamingBlockEnabled, resolveChannelStreamingNativeTransport |
openclaw/plugin-sdk/channel-streaming |
ToolPolicySchema, ReplyRuntimeConfigSchemaShape |
openclaw/plugin-sdk/agent-config-primitives |
createAccountListHelpers |
openclaw/plugin-sdk/account-helpers |
resolveAccountWithDefaultFallback |
openclaw/plugin-sdk/account-core |
evaluateMatchedGroupAccessForPolicy |
openclaw/plugin-sdk/group-access |
buildChannelConfigSchema |
openclaw/plugin-sdk/channel-plugin-common |
defineChannelPluginEntry, defineSetupPluginEntry, buildChannelOutboundSessionRoute |
openclaw/plugin-sdk/core |
formatAllowFromLowercase |
openclaw/plugin-sdk/allow-from |
createScopedChannelConfigAdapter, createScopedDmSecurityResolver |
openclaw/plugin-sdk/channel-config-helpers |
createAccountStatusSink |
openclaw/plugin-sdk/channel-lifecycle |
createAttachedChannelResultAdapter |
openclaw/plugin-sdk/channel-send-result |
runStoppablePassiveMonitor, requireChannelOpenAllowFrom, resolveLoggerBackedRuntime |
openclaw/plugin-sdk/extension-shared |
MAX_IMAGE_BYTES, fetchRemoteMedia, saveMediaBuffer |
openclaw/plugin-sdk/media-runtime |
fetchWithSsrFGuard |
openclaw/plugin-sdk/ssrf-runtime |
tryReadSecretFileSync |
openclaw/plugin-sdk/infra-runtime |
createPluginRuntimeStore |
openclaw/plugin-sdk/runtime-store |
These symbols are imported by the original in-tree extension via the
bundled-only openclaw/plugin-sdk/roam facade and have no public subpath as
of openclaw@2026.4.24. They are inlined verbatim from core; if/when the SDK
exposes them, drop the local copy and re-import.
| Symbol | Source in core (openclaw/openclaw) |
Suggested public subpath |
|---|---|---|
DmPolicySchema, GroupPolicySchema, MarkdownConfigSchema, requireOpenAllowFrom |
src/config/zod-schema.core.ts |
openclaw/plugin-sdk/channel-config-schema |
buildChannelKeyCandidates, normalizeChannelSlug, resolveChannelEntryMatchWithFallback, resolveNestedAllowlistDecision |
src/channels/channel-config.ts |
openclaw/plugin-sdk/channel-routing (new) |
resolveMentionGatingWithBypass |
src/channels/mention-gating.ts |
openclaw/plugin-sdk/mention-gating (new) |
resolveDmGroupAccessWithCommandGate (thin wrapper around public resolveDmGroupAccessWithLists) |
src/security/dm-policy-shared.ts |
openclaw/plugin-sdk/channel-policy (extend) |
clearAccountEntryFields |
src/channels/plugins/config-helpers.ts |
openclaw/plugin-sdk/channel-config-helpers (extend) |
ChannelGroupContext, GroupToolPolicyConfig, BlockStreamingCoalesceConfig, DmConfig (types) |
src/channels/plugins/types.public.ts, src/config/types.js |
openclaw/plugin-sdk/setup or new types-public |
The plugin drives chat.typing directly from handleRoamInbound
(see src/inbound.ts) rather than through the SDK's TypingController —
the published dispatchInboundReplyWithBase does not forward the typing
wiring on replyOptions to the buffered-block dispatcher in 2026.4.27.
Pulses fire immediately on inbound, every 2s while the agent runs, and
cancel on the first partial reply token.
Promote the helpers in src/_local-shim.ts to public SDK subpaths so
third-party plugins do not have to inline them. Concrete proposal table
above.
- Confirm
versioninpackage.json(currently0.1.0). npm install.npm run typecheck— silent.npm test— 130 passing tests.npm pack --dry-runand inspect the file list: must include the four root barrels (index.ts,api.ts,runtime-api.ts,setup-entry.ts), thesrc/directory,openclaw.plugin.json,LICENSE, andREADME.md. The tarball name will beroamhq-openclaw-roam-0.1.0.tgz.npm loginas a member of theroamhqnpm org (one-time).npm publish --access public—--access publicis required for scoped packages or npm defaults to private.- Verify install from a fresh OpenClaw checkout:
openclaw plugin install @roamhq/openclaw-roam. - Tag the release:
git tag v0.1.0 && git push --tags.
MIT. See LICENSE.