Install
openclaw plugins install clawhub:octoopenclaw-channel-octo
OpenClaw channel plugin for Octo. Connects via WebSocket for real-time messaging.
Prerequisites
- Node.js >= 22 (OpenClaw >= 2026.4.15 requires Node 22)
- OpenClaw installed and configured (
npm i -g openclaw) - A bot created via BotFather in Octo (send
/newbotto BotFather)
Install
This plugin is published exclusively on ClawHub for fresh installs:
openclaw plugins install clawhub:octo
Configure a bot account
After installing, use OpenClaw's standard channels add flow.
Non-interactive (recommended for scripts and CI):
openclaw channels add --channel octo \
--account my_bot \
--bot-token bf_your_token_here \
--http-url https://your-server.example/api
Interactive (prompts for token and API URL):
openclaw channels add
After the account is written, restart the gateway (openclaw gateway run --force)
or wait for the next auto-reload — the plugin watches channels.octo and
reconnects on changes.
Configuration
Bot accounts are stored in ~/.openclaw/openclaw.json under channels.octo.accounts:
{
"channels": {
"octo": {
"enabled": true,
"accounts": {
"my_bot": {
"enabled": true,
"botToken": "bf_your_token_here",
"apiUrl": "https://your-server.example/api"
}
}
}
}
}
Configuration fields per account:
Card policy is configured per Bot on the Octo server, not in openclaw.json. The plugin reads the effective card_enabled, display_enabled, interaction_enabled, reasoning_enabled, and reasoning_template_ref values from GET /v1/bot/card/profile. Legacy local fields (cardProgress, reasoningCardTemplateMode, cardDisplay, and cardInteraction) are ignored.
botToken(required): Bot token. Either a User Bot token from BotFather (bf_prefix, full group + thread access) or an App Bot token from the Octo admin console (app_prefix, direct-message only — server-enforced).apiUrl(required): Octo server REST API base URL (e.g.https://your-server/api). The defaulthttp://localhost:8090/apionly works for a local Octo dev server with the standard/apimount.wsUrl(optional): WebSocket URL. Auto-detected fromapiUrlif omitted.cdnUrl(optional): CDN base URL for media filesrequireMention(optional): Only respond when @mentioned in groupspollIntervalMs(optional): Short-poll interval forcard_actioncallbacks after this account sends an interactive card (default2000, minimum500).eventWaitSeconds(optional): Seconds to let the server hold an empty/v1/bot/eventsqueue open, so a card action reaches the bot as soon as it is clicked instead of on the next poll tick (default0= plain short polling atpollIntervalMs; a non-zero value is clamped to 5–30 — below 5 a hold issues more requests than the short polling it replaces, and 30 matches the server's own clamp). Requires a server that supports the long poll; older servers answer immediately and the poller falls back topollIntervalMspacing, so setting it is safe but gives no benefit. Lower it if a reverse proxy in front of the server has an idle timeout below ~40s, since the client request timeout is derived aseventWaitSeconds + 10s.historyLimit(optional): Group chat history message limit (default: 20)dispatchTimeoutMs(optional): Per-inbound dispatch timeout in milliseconds — an infrastructure backstop that releases the per-group message queue if an upstream dispatch hangs. When unset, it is derived from OpenClaw'sagents.defaults.timeoutSeconds(600 if unset) astimeoutSeconds * 1000 + 60000, so it always fires after the agent-run timeout: the agent terminates gracefully first, and this timeout only catches genuinely hung dispatches. Set explicitly only if you need to decouple it from the agent timeout.
Automatic reasoning progress uses only the exact Registry template ref selected by the server and advertised in the same profile response. If reasoning is disabled, the ref is missing/incompatible, or the profile cannot be read, no progress card is sent; the plugin never falls back to the old locally rendered Model B. Profile results are cached privately per Bot and invalidated immediately by the server's bot_setting_updated event.
Agent tools
This plugin registers three agent tools:
octo_managementcovers group/thread/member management, GROUP.md and THREAD.md, voice-correction context, andwrite-secret.octo_send_display_cardsends structured, non-callbackocto/v1cards to the current trusted Octo conversation.octo_send_cardsendsocto/v2confirmation, menu, or short-form cards. Controlledsection/optionsblocks produce structured body sections andInput.ChoiceSetchoices instead of one dense text paragraph. A submit click is polled from/v1/bot/events, preserves the original card body while showing the selected result, and continues the same conversation as a new agent turn. Unsupported deployments receive the choices as plain text.
These are plugin tools, and OpenClaw's tools.profile presets
(minimal, coding, messaging, full) decide which tools the model sees
before it sees them. Only full (allow: ["*"]) admits plugin tools; the
three restrictive presets exclude plugin tools by default. So under minimal,
coding, or messaging, they are filtered out unless explicitly allowed.
This matters because a fresh OpenClaw install defaults tools.profile to
coding, not full — so out of the box an Octo bot cannot use management,
display-card, or interactive-card tools until they are allowed.
To keep the Octo tools available under a restricted profile, add them via
tools.alsoAllow (additive on top of the profile, the same way the bundled
browser tool is enabled):
{
tools: {
profile: "coding",
alsoAllow: ["octo_management", "octo_send_display_card", "octo_send_card"],
},
}
For a single agent, use the same names under agents.list[].tools.alsoAllow.
When octo_management is filtered out, the plugin injects a short system-prompt
note so the agent attributes the gap correctly (a tools-profile restriction, not
a missing Octo feature) instead of suggesting another platform or asking the user
to paste a secret in plaintext. Whether to adjust the configuration is up to you.
Security note:
write-secretexists precisely so users never have to paste a plaintext key into chat. If the tool is unavailable because of the profile, enableocto_managementas above — do not work around it by pasting the secret in plaintext.
What it does
- Registers the bot with the Octo server via REST API
- Connects to WebSocket for real-time message receiving
- Auto-reconnects on disconnection
- Sends a greeting to the bot owner on connect
- Dispatches incoming messages to OpenClaw's message handler
- Supports typing indicators and read receipts
- Sends display and submit-interactive cards with negotiated fallback
- Polls durable
card_actionevents only after an interactive card is sent
A scheme-less user:pass@host is reduced even when the host has no dot
postgres://user:pw@host/db, user:pw@db.example.com, a single-label host
(user:pw@localhost), an IPv6 literal (user:pw@[::1]), a password containing /
(user:pa/ss@db.example.com) and a leading slash (/user:pass@localhost) all lose
their userinfo. The last four were open gaps on main; they are what made the
tail-scan bound unsafe (the "premise" section below), so closing them is part of
this change, not deferred.
The mechanism that makes widening the reduction safe is a single check, not the
shape of the regex. After pass 3 picks a host and originDomain reduces it, the
substitution is refused unless the reduced host appears verbatim in the host
segment (host.includes(...), not the whole match). That one check closes both
ways widening had gone wrong across review rounds:
new URL()fabricates. It normalises1.2.3→1.2.0.3,1.0.0→1.0.0.0,0x7f.1→127.0.0.1— addresses absent from the input. An earlier attempt tried to exclude these by requiring a letter in single-label hosts, on the theory that dotted hosts and IPv4 "take a different branch"; review disproved it — the token-boundary lookahead backtracks the dotted branch to a shorter all-numeric host, so that branch reachesnew URL()too. The verbatim check needs no such case analysis: a normalised host is not a substring of the input, so it is dropped.new URL()lowercases the host (WHATWG), andAKIA…/AIza…are case-sensitive detectors, soa:b@AKIAIOSFODNN7EXAMPLEhad been reducing tohttps://akiaiosfodnn7example— past a guard that could no longer see it. The verbatim check is case-sensitive, so the lowercased host fails it. It compares against the host segment only: comparing against the whole match let a lowercase copy of the host planted in the password (a:akiaiosfodnn7example@AKIAIOSFODNN7EXAMPLE) satisfy the check and reopen the leak. Both are pinned inLEAK_CORPUS, and that group's substring assertion is case-insensitive so a lowercased secret cannot slip it.
The shape is genuinely ambiguous — sed 's:a:b@c:g' and user:pw@localhost are the
same string to a matcher — so this reduces or deletes word:x@y broadly in ordinary
tool output, not only the two rows first recorded: npm/maven coordinates
(pkg:1.2.3@latest), timestamps (10:30@venue), even sed scripts. A mixed-case
host is deleted rather than reduced (time 12:00@GMT → time ), because the
verbatim check fails on it.
This reaches non-ASCII text, which for a CJK-first product is the larger cost.
The host class is \p{L}\p{N} (Unicode letters/digits) plus . and -, so an
IDN host (user:pw@例子.测试) reaches the verbatim check and is deleted — but so is
the host-shaped run in a word:x@host false positive written in Chinese, where the
"host" runs until the next CJK punctuation or space. 报警 level:warn@数据库连接池耗尽。建议扩容。
renders as 报警 。建议扩容。 — the clause 数据库连接池耗尽 between @ and the first
。 is gone. It is bounded to that run (the class deliberately excludes CJK
punctuation and the whitespace-like code points, so it no longer swallows the whole
sentence as an earlier all-non-ASCII class did), and it never fabricates, but a
clause disappearing with no marker is a real legibility cost in the product's
primary language. All of this is safe-direction (renders less), and the breadth is a
product call — the corpus (REWRITE_CORPUS for the CJK sentence, COST_CORPUS
for a minified-JSON token that is blanked by the residual-userinfo choke point) pins the exact current
behaviour of every shape so whatever revisits it cannot change anything else
silently.
The reduction step bounds its own input
The URL reduction runs on untrusted text — a submitted form value, a display name, tool arguments — on synchronous paths with no error boundary, and its passes are quadratic, so an unbounded input stalls the plugin's event loop for every account at once. The bound is 4000 characters.
One bound is not enough, because the pipeline is not the only work that scales with input. Three limits sit around it:
| limit | what it bounds | why the 4000-character bound misses it |
|---|---|---|
RAW_INPUT_MAX (64 KiB) | raw tool-summary extraction, pre-reduction whitespace collapse, and any discarded-tail scan | trim/path split/URL parse and collapse all happen outside the reduction body |
REDUCE_BUDGET_PER_CARD (120 000) | one display card's metered sanitization across blocks | per-call and block-count limits say nothing about the sum |
the exec summary's own cut (4000) | picking a program name out of a command | it runs above the reduction pipeline |
The JWT detector is no longer a separate bounded tier. Its old regex was
quadratic on dotless eyJ-dense input; a run-based scanner now implements the
same match language in linear time. A deterministic 20 000-case comparison
checks random background against the old regex; another 2 304 structured cases
cross the 7/8-character segment boundary, run-local start offsets and invalid
separators. The test requires substantial positive and negative populations, so
an implementation that always returns one side cannot pass. Adversarial tests
also cover both dense starts and many short runs before a distant match.
Tool summaries apply RAW_INPUT_MAX before choosing a non-empty parameter, so
trim(), path split() and URL parsing never see an unbounded raw value. A tool
argument above 64 KiB omits its summary instead of trying to recover a prefix;
the tool name still renders. This is fail-closed and affects only legibility,
not tool execution.
The per-card budget meters the reduction body and discarded-tail predicates,
not every bounded step. The reduction body charges up to 4000 units per block.
Discarded-tail scans are metered at one unit per 128 UTF-16 code units; a tail
over 64 KiB is not scanned and immediately exhausts the budget. A 100 KB prose
block therefore costs about 4751 units, so 25 such blocks fit and the 26th
produces the marker. The initial whitespace collapse over at most 64 KiB happens
before that charge and is not separately metered. RAW_INPUT_MAX and the
200-block limit still bound it, but the budget is not an exact CPU-work ledger.
The budget is an availability limit, not a time guarantee. The measured worst
reachable shape is a 4000-character period-separated run (a.a.a…) followed by
a protocol-relative URL containing @: every dot creates a new word-boundary
start for the quadratic scheme-less pass, and the URL makes the evidence
preflight necessary. Warmed local measurements for 200 inputs are about 0.93
seconds for text blocks and 1.86 seconds for all-rich blocks (observed
0.92–0.95 / 1.85–1.89 seconds). The active reducer is about 1.78× the frozen
merge base; a ///@ substring pair that pass 2 does not rewrite stays near
base at about 1.34×. The rich comparison pass and initial collapse are not
metered. The visible cost is equally important: 200 benign 4 KB blocks become
30 content blocks plus one explicit budget notice. On payload-capped profiles,
limit fitting reserves that notice and reports its own additional dropped-group
count separately. This is part of the needs-human-review product trade-off,
not a security regression; absolute timings are indicative, while in-process
base ratios and structural output assertions guard the mechanism.
Discarded tails have no partial-visibility window. If the entire tail is at most 64 KiB, the caller's complete sensitivity predicate scans it. If it is one code unit longer, the operation fails closed before allocating a slice. This restores the invariant that no keyword, known prefix, JWT, or generic secret shape used by the merge base becomes invisible merely because it sits farther from the cut, while keeping the reduction and discarded-tail work independent of original input size.
The distinction matters because a retained prefix may itself contain a credential
shape the downstream guard does not recognize. A distant token or short JWT can
be the only reason the merge base withheld that prefix. Windowing either signal
reopened a plaintext leak; the full-or-fail-closed rule removes the distance axis
instead of arguing that the prefix must already be safe.
The reduction pass still recognizes common schemeless forms — single-label,
IPv6, numeric and IDN hosts, leading /, and / inside passwords — and the
verbatim-host check prevents new URL() normalization from fabricating output.
Sensitivity evidence is saved before the protocol-relative pass can rewrite a
candidate: after complete scheme URLs are consumed, a preflight uses the same
scheme-less userinfo matcher and poison predicate as the final userinfo pass.
That prevents a protocol-relative JWT prefix from disappearing and exposing a
later copy of its password. The final pass still repeats the same check for
shapes formed by earlier rewrites. Pass 2 is computed first without mutating the
preflight input; the extra scan runs only when pass 2 actually changed the string
and the original contained @. Both scans stay inside the 4000-character
reduction bound. The generated performance corpus includes the period-separated
shape alone, with inert ///@ substrings, and with a real protocol-relative
rewrite containing @.
After those passes, one default-deny choke point withholds any remaining
scheme-less whitespace token whose first : precedes its last @. That closes
non-ASCII and punctuation-terminated usernames, IPv6 zone IDs and empty hosts
without adding another host or username character class.
This is intentionally broad. Regex-like email searches, minified JSON containing
an address, package coordinates, timestamps and some prose can share the same
name:…@ shape and are withheld too. COST_CORPUS records representative base
outputs beside the new empty result; the generated parity space pins the exact
over-hide counts. Deciding whether that legibility cost is acceptable is the
remaining product-owner review.
Two residual disclosure classes are explicit rather than hidden in test-only notes:
- One
read-summary truncation-alignment comparison remains. Base already emits at least the first 12 characters of the external secret and ends in…; head's shorter, correct reduction lets the full external copy fit in the 64-character window. The generated differential guard admits exactly this one mechanism-matched case. A future read-sink external-copy guard is required to remove it without reverting the safer host reduction. - Passes 1, 2 and 4 can delete a keyword that caused the whole line to be withheld, then expose an external low-entropy copy. Those passes are identical to the merge base, so this is base-identical rather than a regression from this branch; it remains a separate sanitizer-hardening follow-up.
Relaxing that pass is where this branch has historically broken things. How the
failure modes are closed — by the verbatim-host check, not by the shape of the
regex — is described under "A scheme-less user:pass@host"
above, together with the two attempts that traded one defect for another (a
mid-token cut that fabricated https://sha256abcd, and a "single-label must contain
a letter" rule that review disproved because the dotted branch backtracks into
new URL() too). The sentinels for both — nginx:1.21@sha256:1234abcd, 3:4@2/x,
a:b@1.2.3, scope:name@1.0.0, a:b@0x7f.1, and the two AKIA case-folding rows —
stay in the corpus.
The password segment in the reducing regex remains capped at 256 characters; an
uncapped /-accepting pattern becomes quadratic. hasOverlongUserinfo provides a
linear fail-closed check for the over-cap form, while the residual-userinfo choke
point covers forms whose username or host does not match that check. The
interactive echo path also applies the same sensitivity predicate after reduction,
so a reduce-only caller cannot bypass the guard.
The JWT scanner is the sole implementation of that rule in production code; the old regex exists only as a test oracle. It splits the input into base64url runs and keeps the last two runs plus dot adjacency, so each character is read a constant number of times. This is a replacement of the quadratic authority, not a second live detector that can drift from it.
RAW_INPUT_MAX is not output-neutral, and an earlier revision of this section
claimed it was. That claim then justified truncating at exactly 64 KiB with no
regard for token boundaries — which cut through a user:pass@host and rendered
the password in full, the very defect the whitespace rule exists to prevent. The
truncation now lands on whitespace like the reduction's own does. Where the
collapse ratio is high, 64 KiB of raw text may collapse to only a few thousand
characters and later content is dropped. The complete discarded part is inspected
only when it is at most another 64 KiB; a longer remainder causes an immediate
fail-closed result. In display cards that also exhausts the per-card budget, so one
very large block is replaced by the explicit budget notice rather than a partial
prefix.
The cut lands on whitespace, never inside a token. That is what makes the
bound safe rather than merely fast. Several passes locate what they neutralise by
an anchor — the reduction that removes a user:pass@host needs the @host to be
there — so a cut through the middle of a token can remove the anchor and leave
the credential rendering in the clear. Cutting on whitespace keeps every token
whole, which also means a secret near the cut is either kept entirely (and caught
by the guards) or dropped entirely, and that a UTF-16 surrogate pair is never
split.
Text with no whitespace anywhere in the first 4001 characters has no safe cut and is not rendered. (4001, not 4000: whitespace sitting at exactly index 4000 means the first 4000 characters are already a token-whole prefix, and that prefix is kept.) The limit counts UTF-16 code units, not characters — CJK prose carries no ASCII whitespace, so a long Chinese message is a single unbroken token, and an astral character such as an emoji spends two units, halving the effective threshold.
The cut also may not delete evidence the caller's guard was reading. Severing a token is not the only harm a bound can do. The guards downstream inspect the truncated string, so a bound that discards the keyword suppressing a credential turns an input that would have failed closed into one that looks clean — with the credential at offset 0, where no downstream render cap reaches it. So every truncation runs the caller's own sensitivity predicate over the segment it is about to discard, and withholds everything if it fires.
Both the 64 KiB collapse and the 4000-unit reduction cut call the same
cutOnWhitespace and tailIsSensitive implementations. The exec program-name
path reuses the cut rule as well. There is no second scan-window algorithm: an
in-budget tail is passed whole, and an over-budget tail fails closed. This removes
the recurring class where one copy of a boundary or detector silently diverged
from another.
That predicate is passed in rather than fixed, because there is no single right
answer: main's own behaviour forks by strategy, withholding a trailing long hex
run under grep and rendering it under read. Hard-coding the strict form blanks
ordinary text ending in a git SHA; hard-coding the lenient form misses a
high-entropy tail. Taking the caller's predicate makes this guard identical to
the one downstream by construction, which is the only version that cannot drift.
It checks only the discarded segment: checking the whole string blanks content
the reduction exists to make safe, such as a webhook URL followed by a page of
prose.
That guard is deliberately stricter than the downstream one because it runs
before reduction and reads raw text. An ordinary documentation link in an
in-budget discarded tail can trip the entropy check even though reduction would
have made it safe. A tail too large to inspect is withheld wholesale. Both are
safe-direction availability costs and are pinned in COST_CORPUS.
Every input/output pair for all of this lives in src/card-render.corpus.ts,
not here. That is deliberate: this section and the corpus and the PR description
were three hand-maintained descriptions of one function, and they drifted out of
sync with each other and with the code in successive rounds — a claim here was
falsified by a one-line change in the same commit. Prose explains why; the
corpus records what, is executable, and fails when it stops being true. The
COST group carries the availability price with each row's measured main output
beside it.
One argument does belong here, because it is reasoning rather than a snapshot.
The obvious repair for the availability cost is to run the first reduction pass —
new URL()-based, and it shrinks its input — above the bound, and bound only
what is left. That does not work. The first pass's regex is quadratic whenever
the text contains no ://, because the scheme character class consumes the whole
token at every start position before backtracking to look for it: measured alone,
18 ms at 4000 characters, 415 ms at 20 000, 10 877 ms at 100 000. Only shapes
that do contain :// are cheap (120 000 characters in 1.25 ms), and gating on
input.includes("://") does not rescue it — "a"×100000 + " http://x.com"
contains :// and still takes 10 580 ms, because the backtracking happens before
the match is reached. Hoisting would restore the same 9–11 second stall this bound
exists to prevent, on a wider trigger: any long unbroken token, no URL syntax
required.
Three sinks apply no render cap of their own, so what the bound does is directly visible at them:
- A display-card text block has no output-length cap of its own, so it relies on the shared collapse/reduction bounds. A tail beyond the scan allowance exhausts the card budget and produces the explicit budget marker.
- A copy-to-clipboard block over the bound is refused rather than truncated, with a message that says characters — its other limit, 4 KiB, is a byte limit and gets its own message. A reader pastes that content somewhere, so a partial value is worse than an honest refusal.
- The status card that echoes an interactive submission is the lowest-trust
boundary of the three. It uses the default strict predicate during reduction
and checks the reduced value again, returning
[redacted]when either path withholds the input.
Those three are where the bound is visible, not where it acts: it lives inside
reduceUrlsInText, so all eleven callers change at long input. Three change in a way
"long text gets truncated" does not predict — a rich text block loses per-segment
styling (the safer output, and it closes a card ⊋ plain divergence main has),
a short authored title emptied by poison/default-deny receives the neutral reason
“withheld by sanitization”, and a debug value and a reasoning step both yield ""
rather than a truncation. Over-long nonblank titles still report the length or a
sensitive discarded tail specifically. An all-whitespace value beyond 4000
characters cannot be proven blank inside the bound, so authored titles use the
neutral withheld reason and action-status display names render [redacted].
Architecture
index.ts is a standard OpenClaw plugin entry. When loaded:
api.registerChannel(octoPlugin)registers the Octo channel runtime- The bundled
setupEntryexposesdefineBundledChannelSetupEntry(...)soopenclaw channels addworks without first enabling the plugin setupWizard+setupadapters onoctoPlugincover both interactive and CLI-flag setup paths- Configuration is read from
channels.octoin OpenClaw's config; the plugin hot-reloads when that block changes
Development
Run the real container E2E for yielded progress cards with a configured Octo
DM user. The runner builds this checkout, installs a test-only inbound bridge in
the ocprobe container, restarts the real OpenClaw gateway, exercises
sessions_spawn + sessions_yield + protected completion, and removes the
bridge/config again even when the test fails.
The bridge includes a loopback OpenAI-compatible scripted provider so tool choices are deterministic. OpenClaw's tool execution, subagent scheduler, lifecycle stream, and Octo send/edit HTTP requests remain the real host paths.
OCTO_E2E_TARGET_UID=<octo-user-uid> npm run test:e2e:openclaw
Override the default container with OCTO_E2E_CONTAINER when needed. The
target user receives progress-card messages during the test.
Disconnect
To disconnect a bot, send /disconnect to BotFather in Octo. This invalidates
the IM token and kicks the WebSocket connection.
