Compare commits

...

103 Commits

Author SHA1 Message Date
Tak Hoffman
949c09f408 fix: satisfy check formatting and lint for plugin tool-call hooks 2026-02-12 18:19:38 -06:00
Patrick Barletta
59f9da02b4 feat: dispatch before_tool_call and after_tool_call hooks from both tool execution paths
The typed plugin hook system (registry.typedHooks) supports before_tool_call
and after_tool_call hooks, but they were only partially wired up:

- pi-tool-definition-adapter: had before_tool_call via runBeforeToolCallHook
  but was missing after_tool_call entirely
- pi-embedded-subscribe: had neither hook dispatched

This adds full before_tool_call and after_tool_call dispatch to both code
paths, enabling plugins registered via api.on() to observe all tool
executions. The after_tool_call hook fires on both success and error paths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 16:35:31 -08:00
vignesh07
b094491cf5 fix(config): restore schema.ts schema helper types after hints refactor 2026-02-12 01:19:46 -08:00
vignesh07
fa427f63b8 refactor(config): restore schema.ts to use schema.hints 2026-02-12 01:19:46 -08:00
0xRain
d8016c30cd fix: add types condition to plugin-sdk export for moduleResolution NodeNext (#14485)
Co-authored-by: 0xRaini <0xRaini@users.noreply.github.com>
2026-02-12 17:31:48 +09:00
hyf0-agent
5c32989f53 perf: use JSON.parse instead of JSON5.parse for sessions.json (~35x faster) (#14530)
Co-authored-by: hyf0-agent <hyf0-agent@users.noreply.github.com>
2026-02-12 17:10:21 +09:00
Tak Hoffman
16f2492547 changelog: add telegram reaction warning fix entry 2026-02-12 00:30:39 -06:00
0xRain
4b86c9e555 fix(telegram): surface REACTION_INVALID as non-fatal warning (#14340)
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-12 00:28:47 -06:00
Tak Hoffman
4094cef233 changelog: add telegram and slack fix entries 2026-02-12 00:14:31 -06:00
J. Brandon Johnson
472808a207 fix(tests): update thread ID handling in Slack message collection tests (#14108)
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-12 00:13:15 -06:00
NM
3696b15abc fix(slack): change default replyToMode from "off" to "all" (#14364)
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-12 00:13:07 -06:00
0xRain
bbfaac88ff fix(telegram): handle no-text message in model picker editMessageText (#14397)
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-12 00:12:46 -06:00
Tak Hoffman
338bc90f82 changelog: add cron and whatsapp fix entries with contributor thanks 2026-02-11 23:44:56 -06:00
WalterSumbon
39e3d58fe1 fix: prevent cron jobs from skipping execution when nextRunAtMs advances (#14068)
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-11 23:33:22 -06:00
大猫子
a88ea42ec7 fix(cron): prevent one-shot at jobs from re-firing on restart after skip/error (#13845) (#13878)
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-11 23:33:15 -06:00
0xRain
b0dfb83952 fix(cron): use requested agentId for isolated job auth resolution (#13983)
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-11 23:32:45 -06:00
Karim Naguib
7a0591ef87 fix(whatsapp): allow media-only sends and normalize leading blank payloads (#14408)
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-11 23:21:21 -06:00
Marcus Castro
186dc0363f fix: default MIME type for WhatsApp voice messages when Baileys omits it (#14444) 2026-02-11 23:09:09 -06:00
Ajay Rajnikanth
e24d023080 fix(whatsapp): convert Markdown bold/strikethrough to WhatsApp formatting (#14285)
* fix(whatsapp): convert Markdown bold/strikethrough to WhatsApp formatting

* refactor: Move `escapeRegExp` utility function to `utils.js`.

---------

Co-authored-by: Luna AI <luna@coredirection.ai>
2026-02-11 23:09:02 -06:00
Tak Hoffman
2e03131712 chore: add feishu contributor thanks to changelog (openclaw#14448)
Co-authored-by: zhiyi <7426274+youngerstyle@users.noreply.github.com>
Co-authored-by: 王春跃 <80630709+openperf@users.noreply.github.com>
Co-authored-by: Wei Wang <1019875+onevcat@users.noreply.github.com>
Co-authored-by: Cynosure159 <29699738+Cynosure159@users.noreply.github.com>
Co-authored-by: jack-cooper <10961327+jackcooper2015@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-11 22:53:40 -06:00
Tak Hoffman
18610a587d chore: refresh lockfile after feishu dep removal (openclaw#14423) thanks @jackcooper2015
Co-authored-by: jack-cooper <10961327+jackcooper2015@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-11 22:53:40 -06:00
Tak Hoffman
7ca8d936d5 fix: remove workspace dev dependency in feishu plugin (openclaw#14423) thanks @jackcooper2015
Co-authored-by: jack-cooper <10961327+jackcooper2015@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-11 22:53:40 -06:00
Tak Hoffman
cf6e8e18d2 fix: preserve top-level feishu doc block order (openclaw#13994) thanks @Cynosure159
Co-authored-by: Cynosure159 <29699738+Cynosure159@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-11 22:53:40 -06:00
Tak Hoffman
a028c0512c fix: use resolved feishu account in status probe (openclaw#11233) thanks @onevcat
Co-authored-by: Wei Wang <1019875+onevcat@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-11 22:53:40 -06:00
Tak Hoffman
3d771afe79 fix: tighten feishu mention trigger matching (openclaw#11088) thanks @openperf
Co-authored-by: 王春跃 <80630709+openperf@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-11 22:53:40 -06:00
Tak Hoffman
8fdb2e64a7 fix: buffer upload path for feishu SDK (openclaw#10345) thanks @youngerstyle
Co-authored-by: zhiyi <7426274+youngerstyle@users.noreply.github.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-11 22:53:40 -06:00
石川 諒
04e3a66f90 fix(cron): pass agentId to runHeartbeatOnce for main-session jobs (#14140)
* fix(cron): pass agentId to runHeartbeatOnce for main-session jobs

Main-session cron jobs with agentId always ran the heartbeat under
the default agent, ignoring the job's agent binding. enqueueSystemEvent
correctly routed the system event to the bound agent's session, but
runHeartbeatOnce was called without agentId, so the heartbeat ran under
the default agent and never picked up the event.

Thread agentId from job.agentId through the CronServiceDeps type,
timer execution, and the gateway wrapper so heartbeat-runner uses the
correct agent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* cron: add heartbeat agentId propagation regression test (#14140) (thanks @ishikawa-pro)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-11 22:22:29 -06:00
MarvinDontPanic
04f695e562 fix(cron): isolate schedule errors to prevent one bad job from breaking all jobs (#14385)
Previously, if one cron job had a malformed schedule expression (e.g. invalid cron syntax),
the error would propagate up and break the entire scheduler loop. This meant one misconfigured
job could prevent ALL cron jobs from running.

Changes:
- Wrap per-job schedule computation in try/catch in recomputeNextRuns()
- Track consecutive schedule errors via new scheduleErrorCount field
- Log warnings for schedule errors with job ID and name
- Auto-disable jobs after 3 consecutive schedule errors (with error-level log)
- Clear error count when schedule computation succeeds
- Continue processing other jobs even when one fails

This ensures the scheduler is resilient to individual job misconfigurations while still
providing visibility into problems through logging.

Co-authored-by: Marvin <numegilagent@gmail.com>
2026-02-11 22:17:07 -06:00
Tom Ron
ace5e33cee fix(cron): re-arm timer when onTimer fires during active job execution (#14233)
* fix(cron): re-arm timer when onTimer fires during active job execution

When a cron job takes longer than MAX_TIMER_DELAY_MS (60s), the clamped
timer fires while state.running is still true.  The early return in
onTimer() previously exited without re-arming the timer, leaving no
setTimeout scheduled.  This silently kills the cron scheduler until the
next gateway restart.

The fix calls armTimer(state) before the early return so the scheduler
continues ticking even when a job is in progress.

This is the likely root cause of recurring cron jobs silently skipping,
as reported in #12025.  One-shot (kind: 'at') jobs were unaffected
because they typically complete within a single timer cycle.

Includes a regression test that simulates a slow job exceeding the
timer clamp period and verifies the next occurrence still fires.

* fix: update tests for timer re-arm behavior

- Update existing regression test to expect timer re-arm with non-zero
  delay instead of no timer at all
- Simplify new test to directly verify state.timer is set after onTimer
  returns early due to running guard

* fix: use fixed 60s delay for re-arm to prevent zero-delay hot-loop

When the running guard re-arms the timer, use MAX_TIMER_DELAY_MS
directly instead of calling armTimer() which can compute a zero delay
for past-due jobs.  This prevents a tight spin while still keeping the
scheduler alive.

* style: add curly braces to satisfy eslint(curly) rule
2026-02-11 22:13:27 -06:00
Xinhua Gu
dd6047d998 fix(cron): prevent duplicate fires when multiple jobs trigger simultaneously (#14256)
The `computeNextRunAtMs` function used `nowSecondMs - 1` as the
reference time for croner's `nextRun()`, which caused it to return the
current second as a valid next-run time. When a job fired at e.g.
11:00:00.500, computing the next run still yielded 11:00:00.000 (same
second, already elapsed), causing the scheduler to immediately re-fire
the job in a tight loop (15-21x observed in the wild).

Fix: use `nowSecondMs` directly (no `-1` lookback) and change the
return guard from `>=` to `>` so next-run is always strictly after
the current second.

Fixes #14164
2026-02-11 22:04:17 -06:00
Rodrigo Uroz
b912d3992d (fix): handle Cloudflare 521 and transient 5xx errors gracefully (#13500)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: a8347e95c5
Co-authored-by: rodrigouroz <384037+rodrigouroz@users.noreply.github.com>
Co-authored-by: Takhoffman <781889+Takhoffman@users.noreply.github.com>
Reviewed-by: @Takhoffman
2026-02-11 21:42:33 -06:00
Tak Hoffman
c28cbac512 CI: add PR size autolabel workflow (#14410) 2026-02-11 21:12:27 -06:00
Jake
631102e714 fix(agents): scope process/exec tools to sessionKey for isolation (#4887)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 5d30672e75
Co-authored-by: mcinteerj <3613653+mcinteerj@users.noreply.github.com>
Co-authored-by: Takhoffman <781889+Takhoffman@users.noreply.github.com>
Reviewed-by: @Takhoffman
2026-02-11 19:55:12 -06:00
Vignesh Natarajan
36e27ad561 Memory: make qmd search-mode flags compatible 2026-02-11 17:51:08 -08:00
Vignesh Natarajan
6d9d4d04ed Memory/QMD: add configurable search mode 2026-02-11 17:51:08 -08:00
cpojer
c2f9f2e1cd chore: Remove accidentally committed .md file. 2026-02-12 09:45:58 +09:00
cpojer
018ebb35ba chore: Clean up pre-commit hook. 2026-02-12 09:44:30 +09:00
cpojer
c2178e2522 chore: Cleanup useless CI job. 2026-02-12 09:37:45 +09:00
cpojer
9df89ceda2 chore: Update deps. 2026-02-12 09:15:12 +09:00
0xRain
1d2c5783fd fix(agents): enable tool call ID sanitization for Anthropic provider (#13830)
Co-authored-by: 0xRaini <0xRaini@users.noreply.github.com>
2026-02-12 08:42:24 +09:00
0xRain
43818e1583 fix(agents): re-run tool_use pairing repair after history truncation (#13926)
Co-authored-by: 0xRaini <0xRaini@users.noreply.github.com>
2026-02-12 08:42:05 +09:00
0xRain
bebba124e8 fix(ui): escape raw HTML in chat messages instead of rendering it (#13952)
Co-authored-by: 0xRaini <0xRaini@users.noreply.github.com>
2026-02-12 08:40:40 +09:00
0xRain
729181bd06 fix(agents): exclude rate limit errors from context overflow classification (#13747)
Co-authored-by: 0xRaini <rain@0xRaini.dev>
2026-02-12 08:40:09 +09:00
Vignesh Natarajan
2f1f82674a Memory/QMD: harden no-results parsing 2026-02-11 15:39:28 -08:00
Vignesh Natarajan
3d343932cf Memory/QMD: treat plain-text no-results as empty 2026-02-11 15:39:28 -08:00
buddyh
4baa43384a fix(media): guard local media reads + accept all path types in MEDIA directive 2026-02-11 15:01:18 -08:00
ENCHIGO
029b77c85b onboard: support custom provider in non-interactive flow (#14223)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 5b98d6514e
Co-authored-by: ENCHIGO <38551565+ENCHIGO@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-11 14:48:45 -05:00
Shadow
c8d9733e41 Changelog: add #13262 entry 2026-02-11 13:27:05 -06:00
Claude Code
a67752e6be fix(discord): use partial mock for @buape/carbon in slash test
Replace the full manual mock with importOriginal spread so new SDK
exports are available automatically. Only ChannelType, MessageType,
and Client are overridden — the rest come from the real module.

Prevents CI breakage when @buape/carbon adds exports (e.g. the
recent StringSelectMenu failure that blocked unrelated PRs).

Closes #13244
2026-02-11 13:10:37 -06:00
Gustavo Madeira Santana
940ce424c8 chore: make review mode switching idempotent 2026-02-11 14:09:16 -05:00
Kyle Tse
4200782a5d fix(heartbeat): honor heartbeat.model config for heartbeat turns (#14103)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: f46080b0ad
Co-authored-by: shtse8 <8020099+shtse8@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-11 14:00:40 -05:00
Gustavo Madeira Santana
72fbfaa755 chore: making PR review chores deterministic + less token hungry 2026-02-11 13:21:00 -05:00
0xRain
93411b74a0 fix(cli): exit with non-zero code when configure/agents-add wizards are cancelled (#14156)
* fix(cli): exit with non-zero code when configure/agents-add wizards are cancelled

Follow-up to the onboard cancel fix. The configure wizard and
agents add wizard also caught WizardCancelledError and exited with
code 0, which signals success to callers. Change to exit(1) for
consistency — user cancellation is not a successful completion.

This ensures scripts that chain these commands with set -e will
correctly stop when the user cancels.

* fix(cli): make wizard cancellations exit non-zero (#14156) (thanks @0xRaini)

---------

Co-authored-by: Rain <rain@Rains-MBA-M4.local>
Co-authored-by: Sebastian <19554889+sebslight@users.noreply.github.com>
2026-02-11 13:07:30 -05:00
Seb Slight
6758b6bfe4 docs(channels): modernize imessage docs page (#14213) 2026-02-11 12:58:02 -05:00
Dario Zhang
e85bbe01f2 fix: report subagent timeout as 'timed out' instead of 'completed successfully' (#13996)
* fix: report subagent timeout as 'timed out' instead of 'completed successfully'

* fix: propagate subagent timeout status across agent.wait (#13996) (thanks @dario-github)

---------

Co-authored-by: Sebastian <19554889+sebslight@users.noreply.github.com>
2026-02-11 12:55:30 -05:00
Seb Slight
2c6569a488 docs(channels): modernize slack docs page (#14205) 2026-02-11 12:49:10 -05:00
0xRain
6d723c9f8a fix(agents): honor heartbeat.model override instead of session model (#14181)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: f19b789057
Co-authored-by: 0xRaini <190923101+0xRaini@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-11 12:46:51 -05:00
Seb Slight
8c963dc5a6 docs(channels): modernize whatsapp docs page (#14202) 2026-02-11 12:31:56 -05:00
Shadow
e95f41b5df Discord: honor explicit thread type 2026-02-11 11:23:19 -06:00
Rain
9e92fc8fa1 fix(discord): default standalone threads to public type (#14147)
When creating a Discord thread without a messageId (standalone thread),
the Discord API defaults to type 12 (private). Most users expect public.

- Default standalone non-forum threads to ChannelType.PublicThread (11)
- Add optional type field to DiscordThreadCreate for explicit control

Closes #14147
2026-02-11 11:23:19 -06:00
Rain
42f7538320 fix(discord): default standalone threads to public type (#14147)
When creating a Discord thread without a messageId, the API defaults
to type 12 (private thread) which is unexpected. This adds an explicit
type: PublicThread (11) for standalone thread creation in non-forum
channels, matching user expectations.

Closes #14147
2026-02-11 11:23:19 -06:00
Seb Slight
b90610c099 docs(nav): move grammy page to technical reference (#14198) 2026-02-11 12:19:44 -05:00
Seb Slight
6bee638648 docs(channels): modernize discord docs page (#14190) 2026-02-11 12:12:31 -05:00
Shadow
c4018a9c57 PI: assign landpr to self 2026-02-11 11:12:04 -06:00
Seb Slight
a98d7c26df docs(channels): fix telegram card icon (#14193) 2026-02-11 12:10:52 -05:00
Seb Slight
880f92c9e4 docs(channels): modernize telegram docs page (#14168) 2026-02-11 11:58:06 -05:00
J young Lee
2aa9570465 fix(slack): detect control commands when message starts with @mention (#14142)
Merged via /review-pr-v2 -> /prepare-pr-v2 -> /merge-pr-v2.

Prepared head SHA: cb0b4f6a3b
Co-authored-by: beefiker <55247450+beefiker@users.noreply.github.com>
Co-authored-by: gumadeiras <gumadeiras@gmail.com>
Reviewed-by: @gumadeiras
2026-02-11 11:41:48 -05:00
Kyle Tse
50a60b8be6 fix: use configured base URL for Ollama model discovery (#14131)
Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 2292d2de6d
Co-authored-by: shtse8 <8020099+shtse8@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-02-11 10:51:59 -05:00
Seb Slight
3ed06c6f36 docs: modernize gateway configuration page (Phase 1) (#14111)
* docs(configuration): split into overview + full reference with Mintlify components

* docs(configuration): use tooltip for JSON5 format note

* docs(configuration): fix Accordion closing tags inside list contexts

* docs(configuration): expand intro to reflect full config surface

* docs(configuration): trim intro to three concise bullets

* docs(configuration-examples): revert all branch changes

* docs(configuration): improve hot-reload section with tabs and accordion

* docs(configuration): uncramp hot-reload — subheadings, bullet list, warning

* docs(configuration): restore hot-apply vs restart table

* docs(configuration): fix hot-reload table against codebase

* docs: add configuration-reference.md — full field-by-field reference

* docs(gateway): refresh runbook and align config reference

* docs: include pending docs updates and install graphic
2026-02-11 10:44:34 -05:00
Seb Slight
4625da476a docs(skills): update mintlify skill to reference docs/ directory (#14125) 2026-02-11 09:46:52 -05:00
Seb Slight
f093ea1ed6 chore: update AGENTS.md and add mintlify skill (#14123) 2026-02-11 09:41:46 -05:00
Sebastian
a1a61f35df chore(irc): sync plugin version to 2026.2.10 2026-02-11 08:38:33 -05:00
Sebastian
f32214ea27 fix(cli): drop logs --localTime alias noise 2026-02-11 08:35:49 -05:00
constansino
66ca5746ce fix(config): avoid redacting maxTokens-like fields (#14006)
* fix(config): avoid redacting maxTokens-like fields

* fix(config): finalize redaction prep items (#14006) (thanks @constansino)

---------

Co-authored-by: Sebastian <19554889+sebslight@users.noreply.github.com>
2026-02-11 08:32:00 -05:00
Peter Lee
851fcb2617 feat: Add --localTime option to logs command for local timezone display (#13818)
* feat: add --localTime options to make logs to show time with local time zone

fix #12447

* fix: prep logs local-time option and docs (#13818) (thanks @xialonglee)

---------

Co-authored-by: xialonglee <li.xialong@xydigit.com>
Co-authored-by: Sebastian <19554889+sebslight@users.noreply.github.com>
2026-02-11 08:24:08 -05:00
Sk Akram
620cf381ff fix: don't lowercase Slack channel IDs (#14055) 2026-02-11 20:53:58 +09:00
Peter Steinberger
92702af7a2 fix(plugins): ignore install scripts during plugin/hook install 2026-02-11 12:04:30 +01:00
Peter Steinberger
cfd112952e fix(gateway): default-deny missing connect scopes 2026-02-11 12:04:30 +01:00
Rain
27453f5a31 fix(web-search): handle xAI Responses API format in Grok provider
The xAI /v1/responses API returns content in a structured format with
typed output blocks (type: 'message') containing typed content blocks
(type: 'output_text') and url_citation annotations. The previous code
only checked output[0].content[0].text without filtering by type,
which could miss content in responses with multiple output entries.

Changes:
- Update GrokSearchResponse type to include annotations on content blocks
- Filter output blocks by type='message' and content by type='output_text'
- Extract url_citation annotations as fallback citations when top-level
  citations array is empty
- Deduplicate annotation-derived citation URLs
- Update tests for the new structured return type

Closes #13520
2026-02-11 11:59:07 +01:00
ryan-crabbe
a36b9be245 Feat/litellm provider (#12823)
* feat: add LiteLLM provider types, env var, credentials, and auth choice

Add litellm-api-key auth choice, LITELLM_API_KEY env var mapping,
setLitellmApiKey() credential storage, and LITELLM_DEFAULT_MODEL_REF.

* feat: add LiteLLM onboarding handler and provider config

Add applyLitellmProviderConfig which properly registers
models.providers.litellm with baseUrl, api type, and model definitions.
This fixes the critical bug from PR #6488 where the provider entry was
never created, causing model resolution to fail at runtime.

* docs: add LiteLLM provider documentation

Add setup guide covering onboarding, manual config, virtual keys,
model routing, and usage tracking. Link from provider index.

* docs: add LiteLLM to sidebar navigation in docs.json

Add providers/litellm to both English and Chinese provider page lists
so the docs page appears in the sidebar navigation.

* test: add LiteLLM non-interactive onboarding test

Wire up litellmApiKey flag inference and auth-choice handler for the
non-interactive onboarding path, and add an integration test covering
profile, model default, and credential storage.

* fix: register --litellm-api-key CLI flag and add preferred provider mapping

Wire up the missing Commander CLI option, action handler mapping, and
help text for --litellm-api-key. Add litellm-api-key to the preferred
provider map for consistency with other providers.

* fix: remove zh-CN sidebar entry for litellm (no localized page yet)

* style: format buildLitellmModelDefinition return type

* fix(onboarding): harden LiteLLM provider setup (#12823)

* refactor(onboarding): keep auth-choice provider dispatcher under size limit

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-02-11 11:46:56 +01:00
Peter Steinberger
5741b6cb3f docs: start 2026.2.10 changelog section 2026-02-11 11:27:58 +01:00
Peter Steinberger
1872d0c592 chore: bump version to 2026.2.10 2026-02-11 11:27:23 +01:00
andreesg
fb84e18bc3 docs: remove outdated pricing information
- Remove specific machine type (CX22) and pricing
- Hetzner pricing and server types change frequently
- Keep focus on technical approach rather than costs
2026-02-11 10:50:07 +01:00
andreesg
75f5da78f0 docs: add Terraform IaC approach to Hetzner guide
- Add Infrastructure as Code section to Hetzner installation docs
- Links to community-maintained Terraform repositories
- Provides alternative for users preferring IaC workflows
- Includes cost estimate and feature overview

Related: Discussion #12532
2026-02-11 10:50:07 +01:00
Marcus Castro
841dbeee0a fix(ui): coerce form values to schema types before config.set (#13468)
Co-authored-by: Gustavo Madeira Santana <gumadeiras@users.noreply.github.com>
2026-02-11 01:25:07 -05:00
Gustavo Madeira Santana
78eca155ac chore: make merge PR comment mandatory + skill name fix 2026-02-11 00:45:54 -05:00
the sun gif man
aade133978 🤖 memory-lancedb: avoid plugin-sdk enum helper in local TypeBox schema (#13897) 2026-02-10 21:28:32 -08:00
the sun gif man
80b56cabc2 🤖 macos: force session preview submenu repaint after async load (#13890) 2026-02-10 21:11:04 -08:00
Rodrigo Uroz
7f1712c1ba (fix): enforce embedding model token limit to prevent overflow (#13455)
* fix: enforce embedding model token limit to prevent 8192 overflow

- Replace EMBEDDING_APPROX_CHARS_PER_TOKEN=1 with UTF-8 byte length
  estimation (safe upper bound for tokenizer output)
- Add EMBEDDING_MODEL_MAX_TOKENS=8192 hard cap
- Add splitChunkToTokenLimit() that binary-searches for the largest
  safe split point, with surrogate pair handling
- Add enforceChunkTokenLimit() wrapper called in indexFile() after
  chunkMarkdown(), before any embedding API call
- Fixes: session files with large JSONL entries could produce chunks
  exceeding text-embedding-3-small's 8192 token limit

Tests: 2 new colocated tests in manager.embedding-token-limit.test.ts
- Verifies oversized ASCII chunks are split to <=8192 bytes each
- Verifies multibyte (emoji) content batching respects byte limits

* fix: make embedding token limit provider-aware

- Add optional maxInputTokens to EmbeddingProvider interface
- Each provider (openai, gemini, voyage) reports its own limit
- Known-limits map as fallback: openai 8192, gemini 2048, voyage 32K
- Resolution: provider field > known map > default 8192
- Backward compatible: local/llama uses fallback

* fix: enforce embedding input size limits (#13455) (thanks @rodrigouroz)

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-10 20:10:17 -06:00
Tak Hoffman
c95b3783ef Changelog: note gateway thinking/tool WS streaming (#10568) (thanks @nk1tz) 2026-02-10 19:30:20 -06:00
Nate
2b02e8a7a8 feat(gateway): stream thinking events and decouple tool events from verbose level (#10568) 2026-02-10 19:17:21 -06:00
Tak Hoffman
d2c2f4185b Heartbeat: inject cron-style current time into prompts (#13733)
* Heartbeat: inject cron-style current time into prompts

* Tests: fix type for web heartbeat timestamp test

* Infra: inline heartbeat current-time injection
2026-02-10 18:58:45 -06:00
Gustavo Madeira Santana
a853ded782 fix(pairing): use actual code in pairing approval text 2026-02-10 19:48:02 -05:00
0xRain
74273d62d0 fix(pairing): show actual code in approval command instead of placeholder (#13723)
* fix(pairing): show actual code in approval command instead of placeholder

The pairing reply shown to new users included the approval command with
a literal '<code>' placeholder. Users had to manually copy the code
from one line and substitute it into the command.

Now shows the ready-to-copy command with the real pairing code:

Before: openclaw pairing approve telegram <code>
After:  openclaw pairing approve telegram abc123

Fixed in both the shared pairing message builder and the Telegram
inline pairing reply.

* test(pairing): update test to expect actual code instead of placeholder

---------

Co-authored-by: Echo Ito <echoito@MacBook-Air.local>
2026-02-10 19:45:16 -05:00
Omair Afzal
88428260ce fix(web_search): remove unsupported include param from Grok API calls (#12910) (#12945)
xAI's /v1/responses endpoint does not support the 'include' parameter,
returning 400 'Argument not supported: include'. Inline citations are
returned automatically when available — no explicit request needed.

Closes #12910

Co-authored-by: Luna AI <luna@coredirection.ai>
2026-02-10 18:27:35 -06:00
Bill Chirico
ca629296c6 feat(hooks): add agentId support to webhook mappings (#13672)
* feat(hooks): add agentId support to webhook mappings

Allow webhook mappings to route hook runs to a specific agent via
the new `agentId` field. This enables lightweight agents with minimal
bootstrap files to handle webhooks, reducing token cost per hook run.

The agentId is threaded through:
- HookMappingConfig (config type + zod schema)
- HookMappingResolved + HookAction (mapping types)
- normalizeHookMapping + buildActionFromMapping (mapping logic)
- mergeAction (transform override support)
- HookAgentPayload + normalizeAgentPayload (direct /hooks/agent endpoint)
- dispatchAgentHook → CronJob.agentId (server dispatch)

The existing runCronIsolatedAgentTurn already supports agentId on
CronJob — this change simply wires it through from webhook mappings.

Usage in config:
  hooks.mappings[].agentId = "my-agent"

Usage via POST /hooks/agent:
  { "message": "...", "agentId": "my-agent" }

Includes tests for mapping passthrough and payload normalization.
Includes doc updates for webhook.md.

* fix(hooks): enforce webhook agent routing policy + docs/changelog updates (#13672) (thanks @BillChirico)

* fix(hooks): harden explicit agent allowlist semantics (#13672) (thanks @BillChirico)

---------

Co-authored-by: Pip <pip@openclaw.ai>
Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-10 19:23:58 -05:00
Marcus Castro
45488e4ec9 fix: remap session JSONL chunk line numbers to original source positions (#12102)
* fix: remap session JSONL chunk line numbers to original source positions

buildSessionEntry() flattens JSONL messages into plain text before
chunkMarkdown() assigns line numbers. The stored startLine/endLine
values therefore reference positions in the flattened text, not the
original JSONL file.

- Add lineMap to SessionFileEntry tracking which JSONL line each
  extracted message came from
- Add remapChunkLines() to translate chunk positions back to original
  JSONL lines after chunking
- Guard remap with source === "sessions" to prevent misapplication
- Include lineMap in content hash so existing sessions get re-indexed

Fixes #12044

* memory: dedupe session JSONL parsing

---------

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
2026-02-10 18:09:24 -06:00
Onur
424d2dddf5 fix: prevent act:evaluate hangs from getting browser tool stuck/killed (#13498)
* fix(browser): prevent permanent timeout after stuck evaluate

Thread AbortSignal from client-fetch through dispatcher to Playwright
operations. When a timeout fires, force-disconnect the Playwright CDP
connection to unblock the serialized command queue, allowing the next
call to reconnect transparently.

Key changes:
- client-fetch.ts: proper AbortController with signal propagation
- pw-session.ts: new forceDisconnectPlaywrightForTarget()
- pw-tools-core.interactions.ts: accept signal, align inner timeout
  to outer-500ms, inject in-browser Promise.race for async evaluates
- routes/dispatcher.ts + types.ts: propagate signal through dispatch
- server.ts + bridge-server.ts: Express middleware creates AbortSignal
  from request lifecycle
- client-actions-core.ts: add timeoutMs to evaluate type

Fixes #10994

* fix(browser): v2 - force-disconnect via Connection.close() instead of browser.close()

When page.evaluate() is stuck on a hung CDP transport, browser.close() also
hangs because it tries to send a close command through the same stuck pipe.

v2 fix: forceDisconnectPlaywrightForTarget now directly calls Playwright's
internal Connection.close() which locally rejects all pending callbacks and
emits 'disconnected' without touching the network. This instantly unblocks
all stuck Playwright operations.

closePlaywrightBrowserConnection (clean shutdown) now also has a 3s timeout
fallback that drops to forceDropConnection if browser.close() hangs.

Fixes permanent browser timeout after stuck evaluate.

* fix(browser): v3 - fire-and-forget browser.close() instead of Connection.close()

v2's forceDropConnection called browser._connection.close() which corrupts
the entire Playwright instance because Connection is shared across all
objects (BrowserType, Browser, Page, etc.). This prevented reconnection
with cascading 'connectOverCDP: Force-disconnected' errors.

v3 fix: forceDisconnectPlaywrightForTarget now:
1. Nulls cached connection immediately
2. Fire-and-forgets browser.close() (doesn't await — it may hang)
3. Next connectBrowser() creates a fresh connectOverCDP WebSocket

Each connectOverCDP creates an independent WebSocket to the CDP endpoint,
so the new connection is unaffected by the old one's pending close.
The old browser.close() eventually resolves when the in-browser evaluate
timeout fires, or the old connection gets GC'd.

* fix(browser): v4 - clear connecting state and remove stale disconnect listeners

The reconnect was failing because:
1. forceDisconnectPlaywrightForTarget nulled cached but not connecting,
   so subsequent calls could await a stale promise
2. The old browser's 'disconnected' event handler raced with new
   connections, nulling the fresh cached reference

Fix: null both cached and connecting, and removeAllListeners on the
old browser before fire-and-forget close.

* fix(browser): v5 - use raw CDP Runtime.terminateExecution to kill stuck evaluate

When forceDisconnectPlaywrightForTarget fires, open a raw WebSocket
to the stuck page's CDP endpoint and send Runtime.terminateExecution.
This kills running JS without navigating away or crashing the page.
Also clear connecting state and remove stale disconnect listeners.

* fix(browser): abort cancels stuck evaluate

* Browser: always cleanup evaluate abort listener

* Chore: remove Playwright debug scripts

* Docs: add CDP evaluate refactor plan

* Browser: refactor Playwright force-disconnect

* Browser: abort stops evaluate promptly

* Node host: extract withTimeout helper

* Browser: remove disconnected listener safely

* Changelog: note act:evaluate hang fix

---------

Co-authored-by: Bob <bob@dutifulbob.com>
2026-02-11 07:54:48 +08:00
Riccardo Giorato
be6de9bb75 Update Together default model to together/moonshotai/Kimi-K2.5 (#13324) 2026-02-11 08:39:15 +09:00
Vignesh
fa906b26ad feat: IRC — add first-class channel support
Adds IRC as a first-class channel with core config surfaces (schema/hints/dock), plugin auto-enable detection, routing/policy alignment, and docs/tests.

Co-authored-by: Vignesh <vigneshnatarajan92@gmail.com>
2026-02-10 17:33:57 -06:00
Sebastian
90f58333e9 test(docker): make bash 3.2 compatibility check portable 2026-02-10 18:04:48 -05:00
Daniel Olshansky
31f616d45b feat: ClawDock - shell docker helpers for OpenClaw development (#12817)
Discussion: https://github.com/openclaw/openclaw/discussions/13528

## Checklist

- [x] **Mark as AI-assisted in the PR title or description** - Implemented by 🤖, reviewed by 👨‍💻 
- [x] **Note the degree of testing** - fully tested and I use it myself
- [x] **Include prompts or session logs if possible (super helpful!)** - I can try doing a "resume" on a few sessions, but don't think it'll provide value. Lmk if this is a blocker.
- [x] **Confirm you understand what the code does** - It's simple :)

## Summary of changes

- **ClawDock** - Shell helpers replace verbose `docker-compose` commands with simple `clawdock-*` shortcuts
- **Zero-config setup** - First run auto-detects the OpenClaw project directory from common paths and saves the config for future use
- **No extra dependencies** - Just bash
- **Built-in auth & device pairing helpers** - `clawdock-fix-token`, `clawdock-dashboard`, etc to handle gateay setup, streamline web UI, etc...
- **Updated Docker docs** - Installation docs now include the optional ClawDock helper setup for users who want simplified container management

## Example Usage

```bash
$ clawdock-help

🦞 ClawDock - Docker Helpers for OpenClaw

 Basic Operations
  clawdock-start       Start the gateway
  clawdock-stop        Stop the gateway
  clawdock-restart     Restart the gateway
  clawdock-status      Check container status
  clawdock-logs        View live logs (follows)

🐚 Container Access
  clawdock-shell       Shell into container (openclaw alias ready)
  clawdock-cli         Run CLI commands (e.g., clawdock-cli status)
  clawdock-exec <cmd>  Execute command in gateway container

🌐 Web UI & Devices
  clawdock-dashboard   Open web UI in browser (auto-guides you)
  clawdock-devices     List device pairings (auto-guides you)
  clawdock-approve <id> Approve device pairing (with examples)

⚙️  Setup & Configuration
  clawdock-fix-token   Configure gateway token (run once)

🔧 Maintenance
  clawdock-rebuild     Rebuild Docker image
  clawdock-clean       ⚠️  Remove containers & volumes (nuclear)

🛠️  Utilities
  clawdock-health      Run health check
  clawdock-token       Show gateway auth token
  clawdock-cd          Jump to openclaw project directory
  clawdock-config      Open config directory (~/.openclaw)
  clawdock-workspace   Open workspace directory

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🚀 First Time Setup
  1. clawdock-start          # Start the gateway
  2. clawdock-fix-token      # Configure token
  3. clawdock-dashboard      # Open web UI
  4. clawdock-devices        # If pairing needed
  5. clawdock-approve <id>   # Approve pairing

💬 WhatsApp Setup
  clawdock-shell
    > openclaw channels login --channel whatsapp
    > openclaw status

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

💡 All commands guide you through next steps!
📚 Docs: https://docs.openclaw.ai
```\n\nCo-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
2026-02-10 16:04:41 -05:00
Gustavo Madeira Santana
2dcaaa4b61 chore: add format:fix to AGENTS 2026-02-10 15:46:03 -05:00
345 changed files with 22506 additions and 9451 deletions

View File

@@ -0,0 +1,181 @@
# PR Workflow for Maintainers
Please read this in full and do not skip sections.
This is the single source of truth for the maintainer PR workflow.
## Triage order
Process PRs **oldest to newest**. Older PRs are more likely to have merge conflicts and stale dependencies; resolving them first keeps the queue healthy and avoids snowballing rebase pain.
## Working rule
Skills execute workflow. Maintainers provide judgment.
Always pause between skills to evaluate technical direction, not just command success.
These three skills must be used in order:
1. `review-pr` — review only, produce findings
2. `prepare-pr` — rebase, fix, gate, push to PR head branch
3. `merge-pr` — squash-merge, verify MERGED state, clean up
They are necessary, but not sufficient. Maintainers must steer between steps and understand the code before moving forward.
Treat PRs as reports first, code second.
If submitted code is low quality, ignore it and implement the best solution for the problem.
Do not continue if you cannot verify the problem is real or test the fix.
## Coding Agent
Use ChatGPT 5.3 Codex High. Fall back to 5.2 Codex High or 5.3 Codex Medium if necessary.
## PR quality bar
- Do not trust PR code by default.
- Do not merge changes you cannot validate with a reproducible problem and a tested fix.
- Keep types strict. Do not use `any` in implementation code.
- Keep external-input boundaries typed and validated, including CLI input, environment variables, network payloads, and tool output.
- Keep implementations properly scoped. Fix root causes, not local symptoms.
- Identify and reuse canonical sources of truth so behavior does not drift across the codebase.
- Harden changes. Always evaluate security impact and abuse paths.
- Understand the system before changing it. Never make the codebase messier just to clear a PR queue.
## Rebase and conflict resolution
Before any substantive review or prep work, **always rebase the PR branch onto current `main` and resolve merge conflicts first**. A PR that cannot cleanly rebase is not ready for review — fix conflicts before evaluating correctness.
- During `prepare-pr`: rebase onto `main` as the first step, before fixing findings or running gates.
- If conflicts are complex or touch areas you do not understand, stop and escalate.
- Prefer **rebase** for linear history; **squash** when commit history is messy or unhelpful.
## Commit and changelog rules
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
- During `prepare-pr`, use this commit subject format: `fix: <summary> (openclaw#<PR>) thanks @<pr-author>`.
- Group related changes; avoid bundling unrelated refactors.
- Changelog workflow: keep the latest released version at the top (no `Unreleased`); after publishing, bump the version and start a new top section.
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
- When working on an issue: reference the issue in the changelog entry.
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
## Co-contributor and clawtributors
- If we squash, add the PR author as a co-contributor in the commit body using a `Co-authored-by:` trailer.
- When maintainer prepares and merges the PR, add the maintainer as an additional `Co-authored-by:` trailer too.
- Avoid `--auto` merges for maintainer landings. Merge only after checks are green so the maintainer account is the actor and attribution is deterministic.
- For squash merges, set `--author-email` to a reviewer-owned email with fallback candidates; if merge fails due to author-email validation, retry once with the next candidate.
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
- When merging a PR: leave a PR comment that explains exactly what we did, include the SHA hashes, and record the comment URL in the final report.
- When merging a PR from a new contributor: run `bun scripts/update-clawtributors.ts` to add their avatar to the README "Thanks to all clawtributors" list, then commit the regenerated README.
## Review mode vs landing mode
- **Review mode (PR link only):** read `gh pr view`/`gh pr diff`; **do not** switch branches; **do not** change code.
- **Landing mode (exception path):** use only when normal `review-pr -> prepare-pr -> merge-pr` flow cannot safely preserve attribution or cannot satisfy branch protection. Create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm build && pnpm check && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: the contributor needs to be in the git graph after this!
## Pre-review safety checks
- Before starting a review when a GH Issue/PR is pasted: use an isolated `.worktrees/pr-<PR>` checkout from `origin/main`. Do not require a clean main checkout, and do not run `git pull` in a dirty main checkout.
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
- Read `docs/help/submitting-a-pr.md` ([Submitting a PR](https://docs.openclaw.ai/help/submitting-a-pr)) for what we expect from contributors.
## Unified workflow
Entry criteria:
- PR URL/number is known.
- Problem statement is clear enough to attempt reproduction.
- A realistic verification path exists (tests, integration checks, or explicit manual validation).
### 1) `review-pr`
Purpose:
- Review only: correctness, value, security risk, tests, docs, and changelog impact.
- Produce structured findings and a recommendation.
Expected output:
- Recommendation: ready, needs work, needs discussion, or close.
- `.local/review.md` with actionable findings.
Maintainer checkpoint before `prepare-pr`:
```
What problem are they trying to solve?
What is the most optimal implementation?
Can we fix up everything?
Do we have any questions?
```
Stop and escalate instead of continuing if:
- The problem cannot be reproduced or confirmed.
- The proposed PR scope does not match the stated problem.
- The design introduces unresolved security or trust-boundary concerns.
### 2) `prepare-pr`
Purpose:
- Make the PR merge-ready on its head branch.
- Rebase onto current `main` first, then fix blocker/important findings, then run gates.
- In fresh worktrees, bootstrap dependencies before local gates (`pnpm install --frozen-lockfile`).
Expected output:
- Updated code and tests on the PR head branch.
- `.local/prep.md` with changes, verification, and current HEAD SHA.
- Final status: `PR is ready for /mergepr`.
Maintainer checkpoint before `merge-pr`:
```
Is this the most optimal implementation?
Is the code properly scoped?
Is the code properly reusing existing logic in the codebase?
Is the code properly typed?
Is the code hardened?
Do we have enough tests?
Do we need regression tests?
Are tests using fake timers where appropriate? (e.g., debounce/throttle, retry backoff, timeout branches, delayed callbacks, polling loops)
Do not add performative tests, ensure tests are real and there are no regressions.
Do you see any follow-up refactors we should do?
Take your time, fix it properly, refactor if necessary.
Did any changes introduce any potential security vulnerabilities?
```
Stop and escalate instead of continuing if:
- You cannot verify behavior changes with meaningful tests or validation.
- Fixing findings requires broad architecture changes outside safe PR scope.
- Security hardening requirements remain unresolved.
### 3) `merge-pr`
Purpose:
- Merge only after review and prep artifacts are present and checks are green.
- Use deterministic squash merge flow (`--match-head-commit` + explicit subject/body with co-author trailer), then verify the PR ends in `MERGED` state.
- If no required checks are configured on the PR, treat that as acceptable and continue after branch-up-to-date validation.
Go or no-go checklist before merge:
- All BLOCKER and IMPORTANT findings are resolved.
- Verification is meaningful and regression risk is acceptably low.
- Docs and changelog are updated when required.
- Required CI checks are green and the branch is not behind `main`.
Expected output:
- Successful merge commit and recorded merge SHA.
- Worktree cleanup after successful merge.
- Comment on PR indicating merge was successful.
Maintainer checkpoint after merge:
- Were any refactors intentionally deferred and now need follow-up issue(s)?
- Did this reveal broader architecture or test gaps we should address?
- Run `bun scripts/update-clawtributors.ts` if the contributor is new.

View File

@@ -0,0 +1,304 @@
---
name: merge-pr
description: Merge a GitHub PR via squash after /prepare-pr. Use when asked to merge a ready PR. Do not push to main or modify code. Ensure the PR ends in MERGED state and clean up worktrees after success.
---
# Merge PR
## Overview
Merge a prepared PR via deterministic squash merge (`--match-head-commit` + explicit co-author trailer), then clean up the worktree after success.
## Inputs
- Ask for PR number or URL.
- If missing, use `.local/prep.env` from the worktree if present.
- If ambiguous, ask.
## Safety
- Use `gh pr merge --squash` as the only path to `main`.
- Do not run `git push` at all during merge.
- Do not use `gh pr merge --auto` for maintainer landings.
- Do not run gateway stop commands. Do not kill processes. Do not touch port 18792.
## Execution Rule
- Execute the workflow. Do not stop after printing the TODO checklist.
- If delegating, require the delegate to run commands and capture outputs.
## Known Footguns
- If you see "fatal: not a git repository", you are in the wrong directory. Move to the repo root and retry.
- Read `.local/review.md`, `.local/prep.md`, and `.local/prep.env` in the worktree. Do not skip.
- Always merge with `--match-head-commit "$PREP_HEAD_SHA"` to prevent racing stale or changed heads.
- Clean up `.worktrees/pr-<PR>` only after confirmed `MERGED`.
## Completion Criteria
- Ensure `gh pr merge` succeeds.
- Ensure PR state is `MERGED`, never `CLOSED`.
- Record the merge SHA.
- Leave a PR comment with merge SHA and prepared head SHA, and capture the comment URL.
- Run cleanup only after merge success.
## First: Create a TODO Checklist
Create a checklist of all merge steps, print it, then continue and execute the commands.
## Setup: Use a Worktree
Use an isolated worktree for all merge work.
```sh
repo_root=$(git rev-parse --show-toplevel)
cd "$repo_root"
gh auth status
WORKTREE_DIR=".worktrees/pr-<PR>"
cd "$WORKTREE_DIR"
```
Run all commands inside the worktree directory.
## Load Local Artifacts (Mandatory)
Expect these files from earlier steps:
- `.local/review.md` from `/review-pr`
- `.local/prep.md` from `/prepare-pr`
- `.local/prep.env` from `/prepare-pr`
```sh
ls -la .local || true
for required in .local/review.md .local/prep.md .local/prep.env; do
if [ ! -f "$required" ]; then
echo "Missing $required. Stop and run /review-pr then /prepare-pr."
exit 1
fi
done
sed -n '1,120p' .local/review.md
sed -n '1,120p' .local/prep.md
source .local/prep.env
```
## Steps
1. Identify PR meta and verify prepared SHA still matches
```sh
pr_meta_json=$(gh pr view <PR> --json number,title,state,isDraft,author,headRefName,headRefOid,baseRefName,headRepository,body)
printf '%s\n' "$pr_meta_json" | jq '{number,title,state,isDraft,author:.author.login,head:.headRefName,headSha:.headRefOid,base:.baseRefName,headRepo:.headRepository.nameWithOwner,body}'
pr_title=$(printf '%s\n' "$pr_meta_json" | jq -r .title)
pr_number=$(printf '%s\n' "$pr_meta_json" | jq -r .number)
pr_head_sha=$(printf '%s\n' "$pr_meta_json" | jq -r .headRefOid)
contrib=$(printf '%s\n' "$pr_meta_json" | jq -r .author.login)
is_draft=$(printf '%s\n' "$pr_meta_json" | jq -r .isDraft)
if [ "$is_draft" = "true" ]; then
echo "ERROR: PR is draft. Stop and run /prepare-pr after draft is cleared."
exit 1
fi
if [ "$pr_head_sha" != "$PREP_HEAD_SHA" ]; then
echo "ERROR: PR head changed after /prepare-pr (expected $PREP_HEAD_SHA, got $pr_head_sha). Re-run /prepare-pr."
exit 1
fi
```
2. Run sanity checks
Stop if any are true:
- PR is a draft.
- Required checks are failing.
- Branch is behind main.
If checks are pending, wait for completion before merging. Do not use `--auto`.
If no required checks are configured, continue.
```sh
gh pr checks <PR> --required --watch --fail-fast || true
checks_json=$(gh pr checks <PR> --required --json name,bucket,state 2>/tmp/gh-checks.err || true)
if [ -z "$checks_json" ]; then
checks_json='[]'
fi
required_count=$(printf '%s\n' "$checks_json" | jq 'length')
if [ "$required_count" -eq 0 ]; then
echo "No required checks configured for this PR."
fi
printf '%s\n' "$checks_json" | jq -r '.[] | "\(.bucket)\t\(.name)\t\(.state)"'
failed_required=$(printf '%s\n' "$checks_json" | jq '[.[] | select(.bucket=="fail")] | length')
pending_required=$(printf '%s\n' "$checks_json" | jq '[.[] | select(.bucket=="pending")] | length')
if [ "$failed_required" -gt 0 ]; then
echo "Required checks are failing, run /prepare-pr."
exit 1
fi
if [ "$pending_required" -gt 0 ]; then
echo "Required checks are still pending, retry /merge-pr when green."
exit 1
fi
git fetch origin main
git fetch origin pull/<PR>/head:pr-<PR> --force
git merge-base --is-ancestor origin/main pr-<PR> || (echo "PR branch is behind main, run /prepare-pr" && exit 1)
```
If anything is failing or behind, stop and say to run `/prepare-pr`.
3. Merge PR with explicit attribution metadata
```sh
reviewer=$(gh api user --jq .login)
reviewer_id=$(gh api user --jq .id)
coauthor_email=${COAUTHOR_EMAIL:-"$contrib@users.noreply.github.com"}
if [ -z "$coauthor_email" ] || [ "$coauthor_email" = "null" ]; then
contrib_id=$(gh api users/$contrib --jq .id)
coauthor_email="${contrib_id}+${contrib}@users.noreply.github.com"
fi
gh_email=$(gh api user --jq '.email // ""' || true)
git_email=$(git config user.email || true)
mapfile -t reviewer_email_candidates < <(
printf '%s\n' \
"$gh_email" \
"$git_email" \
"${reviewer_id}+${reviewer}@users.noreply.github.com" \
"${reviewer}@users.noreply.github.com" | awk 'NF && !seen[$0]++'
)
[ "${#reviewer_email_candidates[@]}" -gt 0 ] || { echo "ERROR: could not resolve reviewer author email"; exit 1; }
reviewer_email="${reviewer_email_candidates[0]}"
cat > .local/merge-body.txt <<EOF
Merged via /review-pr -> /prepare-pr -> /merge-pr.
Prepared head SHA: $PREP_HEAD_SHA
Co-authored-by: $contrib <$coauthor_email>
Co-authored-by: $reviewer <$reviewer_email>
Reviewed-by: @$reviewer
EOF
run_merge() {
local email="$1"
local stderr_file
stderr_file=$(mktemp)
if gh pr merge <PR> \
--squash \
--delete-branch \
--match-head-commit "$PREP_HEAD_SHA" \
--author-email "$email" \
--subject "$pr_title (#$pr_number)" \
--body-file .local/merge-body.txt \
2> >(tee "$stderr_file" >&2)
then
rm -f "$stderr_file"
return 0
fi
merge_err=$(cat "$stderr_file")
rm -f "$stderr_file"
return 1
}
merge_err=""
selected_merge_author_email="$reviewer_email"
if ! run_merge "$selected_merge_author_email"; then
if printf '%s\n' "$merge_err" | rg -qi 'author.?email|email.*associated|associated.*email|invalid.*email' && [ "${#reviewer_email_candidates[@]}" -ge 2 ]; then
selected_merge_author_email="${reviewer_email_candidates[1]}"
echo "Retrying once with fallback author email: $selected_merge_author_email"
run_merge "$selected_merge_author_email" || { echo "ERROR: merge failed after fallback retry"; exit 1; }
else
echo "ERROR: merge failed"
exit 1
fi
fi
```
Retry is allowed exactly once when the error is clearly author-email validation.
4. Verify PR state and capture merge SHA
```sh
state=$(gh pr view <PR> --json state --jq .state)
if [ "$state" != "MERGED" ]; then
echo "Merge not finalized yet (state=$state), waiting up to 15 minutes..."
for _ in $(seq 1 90); do
sleep 10
state=$(gh pr view <PR> --json state --jq .state)
if [ "$state" = "MERGED" ]; then
break
fi
done
fi
if [ "$state" != "MERGED" ]; then
echo "ERROR: PR state is $state after waiting. Leave worktree and retry /merge-pr later."
exit 1
fi
merge_sha=$(gh pr view <PR> --json mergeCommit --jq '.mergeCommit.oid')
if [ -z "$merge_sha" ] || [ "$merge_sha" = "null" ]; then
echo "ERROR: merge commit SHA missing."
exit 1
fi
commit_body=$(gh api repos/:owner/:repo/commits/$merge_sha --jq .commit.message)
contrib=${contrib:-$(gh pr view <PR> --json author --jq .author.login)}
reviewer=${reviewer:-$(gh api user --jq .login)}
printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $contrib <" || { echo "ERROR: missing PR author co-author trailer"; exit 1; }
printf '%s\n' "$commit_body" | rg -q "^Co-authored-by: $reviewer <" || { echo "ERROR: missing reviewer co-author trailer"; exit 1; }
echo "merge_sha=$merge_sha"
```
5. PR comment
Use a multiline heredoc with interpolation enabled.
```sh
ok=0
comment_output=""
for _ in 1 2 3; do
if comment_output=$(gh pr comment <PR> -F - <<EOF
Merged via squash.
- Prepared head SHA: $PREP_HEAD_SHA
- Merge commit: $merge_sha
Thanks @$contrib!
EOF
); then
ok=1
break
fi
sleep 2
done
[ "$ok" -eq 1 ] || { echo "ERROR: failed to post PR comment after retries"; exit 1; }
comment_url=$(printf '%s\n' "$comment_output" | rg -o 'https://github.com/[^ ]+/pull/[0-9]+#issuecomment-[0-9]+' -m1 || true)
[ -n "$comment_url" ] || comment_url="unresolved"
echo "comment_url=$comment_url"
```
6. Clean up worktree only on success
Run cleanup only if step 4 returned `MERGED`.
```sh
cd "$repo_root"
git worktree remove ".worktrees/pr-<PR>" --force
git branch -D temp/pr-<PR> 2>/dev/null || true
git branch -D pr-<PR> 2>/dev/null || true
git branch -D pr-<PR>-prep 2>/dev/null || true
```
## Guardrails
- Worktree only.
- Do not close PRs.
- End in MERGED state.
- Clean up only after merge success.
- Never push to main. Use `gh pr merge --squash` only.
- Do not run `git push` at all in this command.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Merge PR"
short_description: "Merge GitHub PRs via squash"
default_prompt: "Use $merge-pr to merge a GitHub PR via squash after preparation."

View File

@@ -0,0 +1,336 @@
---
name: prepare-pr
description: Prepare a GitHub PR for merge by rebasing onto main, fixing review findings, running gates, committing fixes, and pushing to the PR head branch. Use after /review-pr. Never merge or push to main.
---
# Prepare PR
## Overview
Prepare a PR head branch for merge with review fixes, green gates, and deterministic merge handoff artifacts.
## Inputs
- Ask for PR number or URL.
- If missing, use `.local/pr-meta.env` from the PR worktree if present.
- If ambiguous, ask.
## Safety
- Never push to `main` or `origin/main`. Push only to the PR head branch.
- Never run `git push` without explicit remote and branch. Do not run bare `git push`.
- Do not run gateway stop commands. Do not kill processes. Do not touch port 18792.
- Do not run `git clean -fdx`.
- Do not run `git add -A` or `git add .`.
## Execution Rule
- Execute the workflow. Do not stop after printing the TODO checklist.
- If delegating, require the delegate to run commands and capture outputs.
## Completion Criteria
- Rebase PR commits onto `origin/main`.
- Fix all BLOCKER and IMPORTANT items from `.local/review.md`.
- Commit prep changes with required subject format.
- Run required gates and pass (`pnpm test` may be skipped only for high-confidence docs-only changes).
- Push the updated HEAD back to the PR head branch.
- Write `.local/prep.md` and `.local/prep.env`.
- Output exactly: `PR is ready for /mergepr`.
## First: Create a TODO Checklist
Create a checklist of all prep steps, print it, then continue and execute the commands.
## Setup: Use a Worktree
Use an isolated worktree for all prep work.
```sh
repo_root=$(git rev-parse --show-toplevel)
cd "$repo_root"
gh auth status
WORKTREE_DIR=".worktrees/pr-<PR>"
if [ ! -d "$WORKTREE_DIR" ]; then
git fetch origin main
git worktree add "$WORKTREE_DIR" -b temp/pr-<PR> origin/main
fi
cd "$WORKTREE_DIR"
mkdir -p .local
```
Run all commands inside the worktree directory.
## Load Review Artifacts (Mandatory)
```sh
if [ ! -f .local/review.md ]; then
echo "Missing .local/review.md. Run /review-pr first and save findings."
exit 1
fi
if [ ! -f .local/pr-meta.env ]; then
echo "Missing .local/pr-meta.env. Run /review-pr first and save metadata."
exit 1
fi
sed -n '1,220p' .local/review.md
source .local/pr-meta.env
```
## Steps
1. Identify PR meta with one API call
```sh
pr_meta_json=$(gh pr view <PR> --json number,title,author,headRefName,headRefOid,baseRefName,headRepository,headRepositoryOwner,body)
printf '%s\n' "$pr_meta_json" | jq '{number,title,author:.author.login,head:.headRefName,headSha:.headRefOid,base:.baseRefName,headRepo:.headRepository.nameWithOwner,headRepoOwner:.headRepositoryOwner.login,headRepoName:.headRepository.name,body}'
pr_number=$(printf '%s\n' "$pr_meta_json" | jq -r .number)
contrib=$(printf '%s\n' "$pr_meta_json" | jq -r .author.login)
head=$(printf '%s\n' "$pr_meta_json" | jq -r .headRefName)
pr_head_sha_before=$(printf '%s\n' "$pr_meta_json" | jq -r .headRefOid)
head_owner=$(printf '%s\n' "$pr_meta_json" | jq -r '.headRepositoryOwner.login // empty')
head_repo_name=$(printf '%s\n' "$pr_meta_json" | jq -r '.headRepository.name // empty')
head_repo_url=$(printf '%s\n' "$pr_meta_json" | jq -r '.headRepository.url // empty')
if [ -n "${PR_HEAD:-}" ] && [ "$head" != "$PR_HEAD" ]; then
echo "ERROR: PR head branch changed from $PR_HEAD to $head. Re-run /review-pr."
exit 1
fi
```
2. Fetch PR head and rebase on latest `origin/main`
```sh
git fetch origin pull/<PR>/head:pr-<PR> --force
git checkout -B pr-<PR>-prep pr-<PR>
git fetch origin main
git rebase origin/main
```
If conflicts happen:
- Resolve each conflicted file.
- Run `git add <resolved_file>` for each file.
- Run `git rebase --continue`.
If the rebase gets confusing or you resolve conflicts 3 or more times, stop and report.
3. Fix issues from `.local/review.md`
- Fix all BLOCKER and IMPORTANT items.
- NITs are optional.
- Keep scope tight.
Keep a running log in `.local/prep.md`:
- List which review items you fixed.
- List which files you touched.
- Note behavior changes.
4. Optional quick feedback tests before full gates
Targeted tests are optional quick feedback, not a substitute for full gates.
If running targeted tests in a fresh worktree:
```sh
if [ ! -x node_modules/.bin/vitest ]; then
pnpm install --frozen-lockfile
fi
```
5. Commit prep fixes with required subject format
Use `scripts/committer` with explicit file paths.
Required subject format:
- `fix: <summary> (openclaw#<PR>) thanks @<author>`
```sh
commit_msg="fix: <summary> (openclaw#$pr_number) thanks @$contrib"
scripts/committer "$commit_msg" <changed file 1> <changed file 2> ...
```
If there are no local changes, do not create a no-op commit.
Post-commit validation (mandatory):
```sh
subject=$(git log -1 --pretty=%s)
echo "$subject" | rg -q "openclaw#$pr_number" || { echo "ERROR: commit subject missing openclaw#$pr_number"; exit 1; }
echo "$subject" | rg -q "thanks @$contrib" || { echo "ERROR: commit subject missing thanks @$contrib"; exit 1; }
```
6. Decide verification mode and run required gates before pushing
If you are highly confident the change is docs-only, you may skip `pnpm test`.
High-confidence docs-only criteria (all must be true):
- Every changed file is documentation-only (`docs/**`, `README*.md`, `CHANGELOG.md`, `*.md`, `*.mdx`, `mintlify.json`, `docs.json`).
- No code, runtime, test, dependency, or build config files changed (`src/**`, `extensions/**`, `apps/**`, `package.json`, lockfiles, TS/JS config, test files, scripts).
- `.local/review.md` does not call for non-doc behavior fixes.
Suggested check:
```sh
changed_files=$(git diff --name-only origin/main...HEAD)
non_docs=$(printf "%s\n" "$changed_files" | grep -Ev '^(docs/|README.*\.md$|CHANGELOG\.md$|.*\.md$|.*\.mdx$|mintlify\.json$|docs\.json$)' || true)
docs_only=false
if [ -n "$changed_files" ] && [ -z "$non_docs" ]; then
docs_only=true
fi
echo "docs_only=$docs_only"
```
Bootstrap dependencies in a fresh worktree before gates:
```sh
if [ ! -d node_modules ]; then
pnpm install --frozen-lockfile
fi
```
Run required gates:
```sh
pnpm build
pnpm check
if [ "$docs_only" = "true" ]; then
echo "Docs-only change detected with high confidence; skipping pnpm test." | tee -a .local/prep.md
else
pnpm test
fi
```
Require all required gates to pass. If something fails, fix, commit, and rerun. Allow at most 3 fix-and-rerun cycles.
7. Push safely to the PR head branch
Build `prhead` from owner/name first, then validate remote branch SHA before push.
```sh
if [ -n "$head_owner" ] && [ -n "$head_repo_name" ]; then
head_repo_push_url="https://github.com/$head_owner/$head_repo_name.git"
elif [ -n "$head_repo_url" ] && [ "$head_repo_url" != "null" ]; then
case "$head_repo_url" in
*.git) head_repo_push_url="$head_repo_url" ;;
*) head_repo_push_url="$head_repo_url.git" ;;
esac
else
echo "ERROR: unable to determine PR head repo push URL"
exit 1
fi
git remote add prhead "$head_repo_push_url" 2>/dev/null || git remote set-url prhead "$head_repo_push_url"
echo "Pushing to branch: $head"
if [ "$head" = "main" ] || [ "$head" = "master" ]; then
echo "ERROR: head branch is main/master. This is wrong. Stopping."
exit 1
fi
remote_sha=$(git ls-remote prhead "refs/heads/$head" | awk '{print $1}')
if [ -z "$remote_sha" ]; then
echo "ERROR: remote branch refs/heads/$head not found on prhead"
exit 1
fi
if [ "$remote_sha" != "$pr_head_sha_before" ]; then
echo "ERROR: expected remote SHA $pr_head_sha_before, got $remote_sha. Re-fetch metadata and rebase first."
exit 1
fi
git push --force-with-lease=refs/heads/$head:$pr_head_sha_before prhead HEAD:$head || push_failed=1
```
If lease push fails because head moved, perform one automatic retry:
```sh
if [ "${push_failed:-0}" = "1" ]; then
echo "Lease push failed, retrying once with fresh PR head..."
pr_head_sha_before=$(gh pr view <PR> --json headRefOid --jq .headRefOid)
git fetch origin pull/<PR>/head:pr-<PR>-latest --force
git rebase pr-<PR>-latest
pnpm build
pnpm check
if [ "$docs_only" != "true" ]; then
pnpm test
fi
git push --force-with-lease=refs/heads/$head:$pr_head_sha_before prhead HEAD:$head
fi
```
8. Verify PR head and base relation (Mandatory)
```sh
prep_head_sha=$(git rev-parse HEAD)
pr_head_sha_after=$(gh pr view <PR> --json headRefOid --jq .headRefOid)
if [ "$prep_head_sha" != "$pr_head_sha_after" ]; then
echo "ERROR: pushed head SHA does not match PR head SHA."
exit 1
fi
git fetch origin main
git fetch origin pull/<PR>/head:pr-<PR>-verify --force
git merge-base --is-ancestor origin/main pr-<PR>-verify && echo "PR is up to date with main" || (echo "ERROR: PR is still behind main, rebase again" && exit 1)
git branch -D pr-<PR>-verify 2>/dev/null || true
```
9. Write prep summary artifacts (Mandatory)
Write `.local/prep.md` and `.local/prep.env` for merge handoff.
```sh
contrib_id=$(gh api users/$contrib --jq .id)
coauthor_email="${contrib_id}+${contrib}@users.noreply.github.com"
cat > .local/prep.env <<EOF_ENV
PR_NUMBER=$pr_number
PR_AUTHOR=$contrib
PR_HEAD=$head
PR_HEAD_SHA_BEFORE=$pr_head_sha_before
PREP_HEAD_SHA=$prep_head_sha
COAUTHOR_EMAIL=$coauthor_email
EOF_ENV
ls -la .local/prep.md .local/prep.env
wc -l .local/prep.md .local/prep.env
```
10. Output
Include a diff stat summary:
```sh
git diff --stat origin/main..HEAD
git diff --shortstat origin/main..HEAD
```
Report totals: X files changed, Y insertions(+), Z deletions(-).
If gates passed and push succeeded, print exactly:
```
PR is ready for /mergepr
```
Otherwise, list remaining failures and stop.
## Guardrails
- Worktree only.
- Do not delete the worktree on success. `/mergepr` may reuse it.
- Do not run `gh pr merge`.
- Never push to main. Only push to the PR head branch.
- Run and pass all required gates before pushing. `pnpm test` may be skipped only for high-confidence docs-only changes, and the skip must be explicitly recorded in `.local/prep.md`.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Prepare PR"
short_description: "Prepare GitHub PRs for merge"
default_prompt: "Use $prepare-pr to prep a GitHub PR for merge without merging."

View File

@@ -0,0 +1,253 @@
---
name: review-pr
description: Review-only GitHub pull request analysis with the gh CLI. Use when asked to review a PR, provide structured feedback, or assess readiness to land. Do not merge, push, or make code changes you intend to keep.
---
# Review PR
## Overview
Perform a thorough review-only PR assessment and return a structured recommendation on readiness for /prepare-pr.
## Inputs
- Ask for PR number or URL.
- If missing, always ask. Never auto-detect from conversation.
- If ambiguous, ask.
## Safety
- Never push to `main` or `origin/main`, not during review, not ever.
- Do not run `git push` at all during review. Treat review as read only.
- Do not stop or kill the gateway. Do not run gateway stop commands. Do not kill processes on port 18792.
## Execution Rule
- Execute the workflow. Do not stop after printing the TODO checklist.
- If delegating, require the delegate to run commands and capture outputs, not a plan.
## Known Failure Modes
- If you see "fatal: not a git repository", you are in the wrong directory. Move to the repository root and retry.
- Do not stop after printing the checklist. That is not completion.
## Writing Style for Output
- Write casual and direct.
- Avoid em dashes and en dashes. Use commas or separate sentences.
## Completion Criteria
- Run the commands in the worktree and inspect the PR directly.
- Produce the structured review sections A through J.
- Save the full review to `.local/review.md` inside the worktree.
- Save PR metadata handoff to `.local/pr-meta.env` inside the worktree.
## First: Create a TODO Checklist
Create a checklist of all review steps, print it, then continue and execute the commands.
## Setup: Use a Worktree
Use an isolated worktree for all review work.
```sh
repo_root=$(git rev-parse --show-toplevel)
cd "$repo_root"
gh auth status
WORKTREE_DIR=".worktrees/pr-<PR>"
git fetch origin main
# Reuse existing worktree if it exists, otherwise create new
if [ -d "$WORKTREE_DIR" ]; then
git worktree list
cd "$WORKTREE_DIR"
git fetch origin main
git checkout -B temp/pr-<PR> origin/main
else
git worktree add "$WORKTREE_DIR" -b temp/pr-<PR> origin/main
cd "$WORKTREE_DIR"
fi
# Create local scratch space that persists across /review-pr to /prepare-pr to /merge-pr
mkdir -p .local
```
Run all commands inside the worktree directory.
Start on `origin/main` so you can check for existing implementations before looking at PR code.
## Steps
1. Identify PR meta and context
```sh
pr_meta_json=$(gh pr view <PR> --json number,title,state,isDraft,author,baseRefName,headRefName,headRefOid,headRepository,url,body,labels,assignees,reviewRequests,files,additions,deletions,statusCheckRollup)
printf '%s\n' "$pr_meta_json" | jq '{number,title,url,state,isDraft,author:.author.login,base:.baseRefName,head:.headRefName,headSha:.headRefOid,headRepo:.headRepository.nameWithOwner,additions,deletions,files:(.files|length),body}'
cat > .local/pr-meta.env <<EOF
PR_NUMBER=$(printf '%s\n' "$pr_meta_json" | jq -r .number)
PR_URL=$(printf '%s\n' "$pr_meta_json" | jq -r .url)
PR_AUTHOR=$(printf '%s\n' "$pr_meta_json" | jq -r .author.login)
PR_BASE=$(printf '%s\n' "$pr_meta_json" | jq -r .baseRefName)
PR_HEAD=$(printf '%s\n' "$pr_meta_json" | jq -r .headRefName)
PR_HEAD_SHA=$(printf '%s\n' "$pr_meta_json" | jq -r .headRefOid)
PR_HEAD_REPO=$(printf '%s\n' "$pr_meta_json" | jq -r .headRepository.nameWithOwner)
EOF
ls -la .local/pr-meta.env
```
2. Check if this already exists in main before looking at the PR branch
- Identify the core feature or fix from the PR title and description.
- Search for existing implementations using keywords from the PR title, changed file paths, and function or component names from the diff.
```sh
# Use keywords from the PR title and changed files
rg -n "<keyword_from_pr_title>" -S src packages apps ui || true
rg -n "<function_or_component_name>" -S src packages apps ui || true
git log --oneline --all --grep="<keyword_from_pr_title>" | head -20
```
If it already exists, call it out as a BLOCKER or at least IMPORTANT.
3. Claim the PR
Assign yourself so others know someone is reviewing. Skip if the PR looks like spam or is a draft you plan to recommend closing.
```sh
gh_user=$(gh api user --jq .login)
gh pr edit <PR> --add-assignee "$gh_user" || echo "Could not assign reviewer, continuing"
```
4. Read the PR description carefully
Use the body from step 1. Summarize goal, scope, and missing context.
5. Read the diff thoroughly
Minimum:
```sh
gh pr diff <PR>
```
If you need full code context locally, fetch the PR head to a local ref and diff it. Do not create a merge commit.
```sh
git fetch origin pull/<PR>/head:pr-<PR> --force
mb=$(git merge-base origin/main pr-<PR>)
# Show only this PR patch relative to merge-base, not total branch drift
git diff --stat "$mb"..pr-<PR>
git diff "$mb"..pr-<PR>
```
If you want to browse the PR version of files directly, temporarily check out `pr-<PR>` in the worktree. Do not commit or push. Return to `temp/pr-<PR>` and reset to `origin/main` afterward.
```sh
# Use only if needed
# git checkout pr-<PR>
# git branch --show-current
# ...inspect files...
git checkout temp/pr-<PR>
git checkout -B temp/pr-<PR> origin/main
git branch --show-current
```
6. Validate the change is needed and valuable
Be honest. Call out low value AI slop.
7. Evaluate implementation quality
Review correctness, design, performance, and ergonomics.
8. Perform a security review
Assume OpenClaw subagents run with full disk access, including git, gh, and shell. Check auth, input validation, secrets, dependencies, tool safety, and privacy.
9. Review tests and verification
Identify what exists, what is missing, and what would be a minimal regression test.
If you run local tests in the worktree, bootstrap dependencies first:
```sh
if [ ! -x node_modules/.bin/vitest ]; then
pnpm install --frozen-lockfile
fi
```
10. Check docs
Check if the PR touches code with related documentation such as README, docs, inline API docs, or config examples.
- If docs exist for the changed area and the PR does not update them, flag as IMPORTANT.
- If the PR adds a new feature or config option with no docs, flag as IMPORTANT.
- If the change is purely internal with no user-facing impact, skip this.
11. Check changelog
Check if `CHANGELOG.md` exists and whether the PR warrants an entry.
- If the project has a changelog and the PR is user-facing, flag missing entry as IMPORTANT.
- Leave the change for /prepare-pr, only flag it here.
12. Answer the key question
Decide if /prepare-pr can fix issues or the contributor must update the PR.
13. Save findings to the worktree
Write the full structured review sections A through J to `.local/review.md`.
Create or overwrite the file and verify it exists and is non-empty.
```sh
ls -la .local/review.md
wc -l .local/review.md
```
14. Output the structured review
Produce a review that matches what you saved to `.local/review.md`.
A) TL;DR recommendation
- One of: READY FOR /prepare-pr | NEEDS WORK | NEEDS DISCUSSION | NOT USEFUL (CLOSE)
- 1 to 3 sentences.
B) What changed
C) What is good
D) Security findings
E) Concerns or questions (actionable)
- Numbered list.
- Mark each item as BLOCKER, IMPORTANT, or NIT.
- For each, point to file or area and propose a concrete fix.
F) Tests
G) Docs status
- State if related docs are up to date, missing, or not applicable.
H) Changelog
- State if `CHANGELOG.md` needs an entry and which category.
I) Follow ups (optional)
J) Suggested PR comment (optional)
## Guardrails
- Worktree only.
- Do not delete the worktree after review.
- Review only, do not merge, do not push.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "Review PR"
short_description: "Review GitHub PRs without merging"
default_prompt: "Use $review-pr to perform a thorough, review-only GitHub PR review."

View File

@@ -9,7 +9,7 @@ Process PRs **oldest to newest**. Older PRs are more likely to have merge confli
## Working rule
Skills execute workflow, maintainers provide judgment.
Skills execute workflow. Maintainers provide judgment.
Always pause between skills to evaluate technical direction, not just command success.
These three skills must be used in order:
@@ -25,6 +25,65 @@ If submitted code is low quality, ignore it and implement the best solution for
Do not continue if you cannot verify the problem is real or test the fix.
## Script-first contract
Skill runs should invoke these wrappers automatically. You only need to run them manually when debugging or doing an explicit script-only run:
- `scripts/pr-review <PR>`
- `scripts/pr review-checkout-main <PR>` or `scripts/pr review-checkout-pr <PR>` while reviewing
- `scripts/pr review-guard <PR>` before writing review outputs
- `scripts/pr review-validate-artifacts <PR>` after writing outputs
- `scripts/pr-prepare init <PR>`
- `scripts/pr-prepare validate-commit <PR>`
- `scripts/pr-prepare gates <PR>`
- `scripts/pr-prepare push <PR>`
- Optional one-shot prepare: `scripts/pr-prepare run <PR>`
- `scripts/pr-merge <PR>` (verify-only; short form remains backward compatible)
- `scripts/pr-merge verify <PR>` (verify-only)
- Optional one-shot merge: `scripts/pr-merge run <PR>`
These wrappers run shared preflight checks and generate deterministic artifacts. They are designed to work from repo root or PR worktree cwd.
## Required artifacts
- `.local/pr-meta.json` and `.local/pr-meta.env` from review init.
- `.local/review.md` and `.local/review.json` from review output.
- `.local/prep-context.env` and `.local/prep.md` from prepare.
- `.local/prep.env` from prepare completion.
## Structured review handoff
`review-pr` must write `.local/review.json`.
In normal skill runs this is handled automatically. Use `scripts/pr review-artifacts-init <PR>` and `scripts/pr review-tests <PR> ...` manually only for debugging or explicit script-only runs.
Minimum schema:
```json
{
"recommendation": "READY FOR /prepare-pr",
"findings": [
{
"id": "F1",
"severity": "IMPORTANT",
"title": "Missing changelog entry",
"area": "CHANGELOG.md",
"fix": "Add a Fixes entry for PR #<PR>"
}
],
"tests": {
"ran": ["pnpm test -- ..."],
"gaps": ["..."],
"result": "pass"
}
}
```
`prepare-pr` resolves all `BLOCKER` and `IMPORTANT` findings from this file.
## Coding Agent
Use ChatGPT 5.3 Codex High. Fall back to 5.2 Codex High or 5.3 Codex Medium if necessary.
## PR quality bar
- Do not trust PR code by default.
@@ -40,35 +99,52 @@ Do not continue if you cannot verify the problem is real or test the fix.
Before any substantive review or prep work, **always rebase the PR branch onto current `main` and resolve merge conflicts first**. A PR that cannot cleanly rebase is not ready for review — fix conflicts before evaluating correctness.
- During `prepare-pr`: rebase onto `main` is the first step, before fixing findings or running gates.
- During `prepare-pr`: rebase onto `main` as the first step, before fixing findings or running gates.
- If conflicts are complex or touch areas you do not understand, stop and escalate.
- Prefer **rebase** for linear history; **squash** when commit history is messy or unhelpful.
## Commit and changelog rules
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
- In normal `prepare-pr` runs, commits are created via `scripts/committer "<msg>" <file...>`. Use it manually only when operating outside the skill flow; avoid manual `git add`/`git commit` so staging stays scoped.
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
- During `prepare-pr`, use this commit subject format: `fix: <summary> (openclaw#<PR>) thanks @<pr-author>`.
- Group related changes; avoid bundling unrelated refactors.
- Changelog workflow: keep latest released version at top (no `Unreleased`); after publishing, bump version and start a new top section.
- Changelog workflow: keep the latest released version at the top (no `Unreleased`); after publishing, bump the version and start a new top section.
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
- When working on an issue: reference the issue in the changelog entry.
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
## Gate policy
In fresh worktrees, dependency bootstrap is handled by wrappers before local gates. Manual equivalent:
```sh
pnpm install --frozen-lockfile
```
Gate set:
- Always: `pnpm build`, `pnpm check`
- `pnpm test` required unless high-confidence docs-only criteria pass.
## Co-contributor and clawtributors
- If we squash, add the PR author as a co-contributor in the commit.
- If we squash, add the PR author as a co-contributor in the commit body using a `Co-authored-by:` trailer.
- When maintainer prepares and merges the PR, add the maintainer as an additional `Co-authored-by:` trailer too.
- Avoid `--auto` merges for maintainer landings. Merge only after checks are green so the maintainer account is the actor and attribution is deterministic.
- For squash merges, set `--author-email` to a reviewer-owned email with fallback candidates; if merge fails due to author-email validation, retry once with the next candidate.
- If you review a PR and later do work on it, land via merge/squash (no direct-main commits) and always add the PR author as a co-contributor.
- When merging a PR: leave a PR comment that explains exactly what we did and include the SHA hashes.
- When merging a PR from a new contributor: run `bun scripts/update-clawtributors.ts` to add their avatar to the README "Thanks to all clawtributors" list, then commit the regenerated README.
- When merging a PR: leave a PR comment that explains exactly what we did, include the SHA hashes, and record the comment URL in the final report.
- Manual post-merge step for new contributors: run `bun scripts/update-clawtributors.ts` to add their avatar to the README "Thanks to all clawtributors" list, then commit the regenerated README.
## Review mode vs landing mode
- **Review mode (PR link only):** read `gh pr view`/`gh pr diff`; **do not** switch branches; **do not** change code.
- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm build && pnpm check && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this!
- **Landing mode (exception path):** use only when normal `review-pr -> prepare-pr -> merge-pr` flow cannot safely preserve attribution or cannot satisfy branch protection. Create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm build && pnpm check && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: the contributor needs to be in the git graph after this!
## Pre-review safety checks
- Before starting a review when a GH Issue/PR is pasted: run `git pull`; if there are local changes or unpushed commits, stop and alert the user before reviewing.
- Before starting a review when a GH Issue/PR is pasted: `review-pr`/`scripts/pr-review` should create and use an isolated `.worktrees/pr-<PR>` checkout from `origin/main` automatically. Do not require a clean main checkout, and do not run `git pull` in a dirty main checkout.
- PR review calls: prefer a single `gh pr view --json ...` to batch metadata/comments; run `gh pr diff` only when needed.
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
- Read `docs/help/submitting-a-pr.md` ([Submitting a PR](https://docs.openclaw.ai/help/submitting-a-pr)) for what we expect from contributors.
@@ -98,7 +174,6 @@ Maintainer checkpoint before `prepare-pr`:
```
What problem are they trying to solve?
What is the most optimal implementation?
Is the code properly scoped?
Can we fix up everything?
Do we have any questions?
```
@@ -115,26 +190,29 @@ Purpose:
- Make the PR merge-ready on its head branch.
- Rebase onto current `main` first, then fix blocker/important findings, then run gates.
- In fresh worktrees, bootstrap dependencies before local gates (`pnpm install --frozen-lockfile`).
Expected output:
- Updated code and tests on the PR head branch.
- `.local/prep.md` with changes, verification, and current HEAD SHA.
- Final status: `PR is ready for /mergepr`.
- Final status: `PR is ready for /merge-pr`.
Maintainer checkpoint before `merge-pr`:
```
Is this the most optimal implementation?
Is the code properly scoped?
Is the code properly reusing existing logic in the codebase?
Is the code properly typed?
Is the code hardened?
Do we have enough tests?
Are tests using fake timers where relevant? (e.g., debounce/throttle, retry backoff, timeout branches, delayed callbacks, polling loops)
Do we need regression tests?
Are tests using fake timers where appropriate? (e.g., debounce/throttle, retry backoff, timeout branches, delayed callbacks, polling loops)
Do not add performative tests, ensure tests are real and there are no regressions.
Take your time, fix it properly, refactor if necessary.
Do you see any follow-up refactors we should do?
Did any changes introduce any potential security vulnerabilities?
Take your time, fix it properly, refactor if necessary.
```
Stop and escalate instead of continuing if:
@@ -148,7 +226,8 @@ Stop and escalate instead of continuing if:
Purpose:
- Merge only after review and prep artifacts are present and checks are green.
- Use squash merge flow and verify the PR ends in `MERGED` state.
- Use deterministic squash merge flow (`--match-head-commit` + explicit subject/body with co-author trailer), then verify the PR ends in `MERGED` state.
- If no required checks are configured on the PR, treat that as acceptable and continue after branch-up-to-date validation.
Go or no-go checklist before merge:
@@ -161,6 +240,7 @@ Expected output:
- Successful merge commit and recorded merge SHA.
- Worktree cleanup after successful merge.
- Comment on PR indicating merge was successful.
Maintainer checkpoint after merge:

View File

@@ -1,187 +1,98 @@
---
name: merge-pr
description: Merge a GitHub PR via squash after /preparepr. Use when asked to merge a ready PR. Do not push to main or modify code. Ensure the PR ends in MERGED state and clean up worktrees after success.
description: Script-first deterministic squash merge with strict required-check gating, head-SHA pinning, and reliable attribution/commenting.
---
# Merge PR
## Overview
Merge a prepared PR via `gh pr merge --squash` and clean up the worktree after success.
Merge a prepared PR only after deterministic validation.
## Inputs
- Ask for PR number or URL.
- If missing, auto-detect from conversation.
- If ambiguous, ask.
- If missing, use `.local/prep.env` from the PR worktree.
## Safety
- Use `gh pr merge --squash` as the only path to `main`.
- Do not run `git push` at all during merge.
- Do not run gateway stop commands. Do not kill processes. Do not touch port 18792.
- Never use `gh pr merge --auto` in this flow.
- Never run `git push` directly.
- Require `--match-head-commit` during merge.
## Execution Rule
## Execution Contract
- Execute the workflow. Do not stop after printing the TODO checklist.
- If delegating, require the delegate to run commands and capture outputs.
## Known Footguns
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/dev/openclaw` if available; otherwise ask user.
- Read `.local/review.md` and `.local/prep.md` in the worktree. Do not skip.
- Clean up the real worktree directory `.worktrees/pr-<PR>` only after a successful merge.
- Expect cleanup to remove `.local/` artifacts.
## Completion Criteria
- Ensure `gh pr merge` succeeds.
- Ensure PR state is `MERGED`, never `CLOSED`.
- Record the merge SHA.
- Run cleanup only after merge success.
## First: Create a TODO Checklist
Create a checklist of all merge steps, print it, then continue and execute the commands.
## Setup: Use a Worktree
Use an isolated worktree for all merge work.
1. Validate merge readiness:
```sh
cd ~/dev/openclaw
# Sanity: confirm you are in the repo
git rev-parse --show-toplevel
WORKTREE_DIR=".worktrees/pr-<PR>"
scripts/pr-merge verify <PR>
```
Run all commands inside the worktree directory.
## Load Local Artifacts (Mandatory)
Expect these files from earlier steps:
- `.local/review.md` from `/reviewpr`
- `.local/prep.md` from `/preparepr`
Backward-compatible verify form also works:
```sh
ls -la .local || true
if [ -f .local/review.md ]; then
echo "Found .local/review.md"
sed -n '1,120p' .local/review.md
else
echo "Missing .local/review.md. Stop and run /reviewpr, then /preparepr."
exit 1
fi
if [ -f .local/prep.md ]; then
echo "Found .local/prep.md"
sed -n '1,120p' .local/prep.md
else
echo "Missing .local/prep.md. Stop and run /preparepr first."
exit 1
fi
scripts/pr-merge <PR>
```
2. Run one-shot deterministic merge:
```sh
scripts/pr-merge run <PR>
```
3. Ensure output reports:
- `merge_sha=<sha>`
- `merge_author_email=<email>`
- `comment_url=<url>`
## Steps
1. Identify PR meta
1. Validate artifacts
```sh
gh pr view <PR> --json number,title,state,isDraft,author,headRefName,baseRefName,headRepository,body --jq '{number,title,state,isDraft,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner,body}'
contrib=$(gh pr view <PR> --json author --jq .author.login)
head=$(gh pr view <PR> --json headRefName --jq .headRefName)
head_repo_url=$(gh pr view <PR> --json headRepository --jq .headRepository.url)
require=(.local/review.md .local/review.json .local/prep.md .local/prep.env)
for f in "${require[@]}"; do
[ -s "$f" ] || { echo "Missing artifact: $f"; exit 1; }
done
```
2. Run sanity checks
Stop if any are true:
- PR is a draft.
- Required checks are failing.
- Branch is behind main.
If `.local/prep.md` contains `Docs-only change detected with high confidence; skipping pnpm test.`, that local test skip is allowed. CI checks still must be green.
2. Validate checks and branch status
```sh
# Checks
gh pr checks <PR>
# Check behind main
git fetch origin main
git fetch origin pull/<PR>/head:pr-<PR>
git merge-base --is-ancestor origin/main pr-<PR> || echo "PR branch is behind main, run /preparepr"
scripts/pr-merge verify <PR>
source .local/prep.env
```
If anything is failing or behind, stop and say to run `/preparepr`.
`scripts/pr-merge` treats “no required checks configured” as acceptable (`[]`), but fails on any required `fail` or `pending`.
3. Merge PR and delete branch
If checks are still running, use `--auto` to queue the merge.
3. Merge deterministically (wrapper-managed)
```sh
# Check status first
check_status=$(gh pr checks <PR> 2>&1)
if echo "$check_status" | grep -q "pending\|queued"; then
echo "Checks still running, using --auto to queue merge"
gh pr merge <PR> --squash --delete-branch --auto
echo "Merge queued. Monitor with: gh pr checks <PR> --watch"
else
gh pr merge <PR> --squash --delete-branch
fi
scripts/pr-merge run <PR>
```
If merge fails, report the error and stop. Do not retry in a loop.
If the PR needs changes beyond what `/preparepr` already did, stop and say to run `/preparepr` again.
`scripts/pr-merge run` performs:
4. Get merge SHA
- deterministic squash merge pinned to `PREP_HEAD_SHA`
- reviewer merge author email selection with fallback candidates
- one retry only when merge fails due to author-email validation
- co-author trailers for PR author and reviewer
- post-merge verification of both co-author trailers on commit message
- PR comment retry (3 attempts), then comment URL extraction
- cleanup after confirmed `MERGED`
4. Manual fallback (only if wrapper is unavailable)
```sh
merge_sha=$(gh pr view <PR> --json mergeCommit --jq '.mergeCommit.oid')
echo "merge_sha=$merge_sha"
scripts/pr merge-run <PR>
```
5. Optional comment
5. Cleanup
Use a literal multiline string or heredoc for newlines.
```sh
gh pr comment <PR> -F - <<'EOF'
Merged via squash.
- Merge commit: $merge_sha
Thanks @$contrib!
EOF
```
6. Verify PR state is MERGED
```sh
gh pr view <PR> --json state --jq .state
```
7. Clean up worktree only on success
Run cleanup only if step 6 returned `MERGED`.
```sh
cd ~/dev/openclaw
git worktree remove ".worktrees/pr-<PR>" --force
git branch -D temp/pr-<PR> 2>/dev/null || true
git branch -D pr-<PR> 2>/dev/null || true
```
Cleanup is handled by `run` after merge success.
## Guardrails
- Worktree only.
- Do not close PRs.
- End in MERGED state.
- Clean up only after merge success.
- Never push to main. Use `gh pr merge --squash` only.
- Do not run `git push` at all in this command.
- End in `MERGED`, never `CLOSED`.
- Cleanup only after confirmed merge.

View File

@@ -0,0 +1,345 @@
---
name: mintlify
description: Build and maintain documentation sites with Mintlify. Use when
creating docs pages, configuring navigation, adding components, or setting up
API references.
license: MIT
compatibility: Requires Node.js for CLI. Works with any Git-based workflow.
metadata:
author: mintlify
version: "1.0"
mintlify-proj: mintlify
---
# Mintlify best practices
**Always consult [mintlify.com/docs](https://mintlify.com/docs) for components, configuration, and latest features.**
**Always** favor searching the current Mintlify documentation over whatever is in your training data about Mintlify.
Mintlify is a documentation platform that transforms MDX files into documentation sites. Configure site-wide settings in the `docs.json` file, write content in MDX with YAML frontmatter, and favor built-in components over custom components.
Full schema at [mintlify.com/docs.json](https://mintlify.com/docs.json).
## Before you write
### Understand the project
All documentation lives in the `docs/` directory in this repo. Read `docs.json` in that directory (`docs/docs.json`). This file defines the entire site: navigation structure, theme, colors, links, API and specs.
Understanding the project tells you:
- What pages exist and how they're organized
- What navigation groups are used (and their naming conventions)
- How the site navigation is structured
- What theme and configuration the site uses
### Check for existing content
Search the docs before creating new pages. You may need to:
- Update an existing page instead of creating a new one
- Add a section to an existing page
- Link to existing content rather than duplicating
### Read surrounding content
Before writing, read 2-3 similar pages to understand the site's voice, structure, formatting conventions, and level of detail.
### Understand Mintlify components
Review the Mintlify [components](https://www.mintlify.com/docs/components) to select and use any relevant components for the documentation request that you are working on.
## Quick reference
### CLI commands
- `npm i -g mint` - Install the Mintlify CLI
- `mint dev` - Local preview at localhost:3000
- `mint broken-links` - Check internal links
- `mint a11y` - Check for accessibility issues in content
- `mint rename` - Rename/move files and update references
- `mint validate` - Validate documentation builds
### Required files
- `docs.json` - Site configuration (navigation, theme, integrations, etc.). See [global settings](https://mintlify.com/docs/settings/global) for all options.
- `*.mdx` files - Documentation pages with YAML frontmatter
### Example file structure
```
project/
├── docs.json # Site configuration
├── introduction.mdx
├── quickstart.mdx
├── guides/
│ └── example.mdx
├── openapi.yml # API specification
├── images/ # Static assets
│ └── example.png
└── snippets/ # Reusable components
└── component.jsx
```
## Page frontmatter
Every page requires `title` in its frontmatter. Include `description` for SEO and navigation.
```yaml theme={null}
---
title: "Clear, descriptive title"
description: "Concise summary for SEO and navigation."
---
```
Optional frontmatter fields:
- `sidebarTitle`: Short title for sidebar navigation.
- `icon`: Lucide or Font Awesome icon name, URL, or file path.
- `tag`: Label next to the page title in the sidebar (for example, "NEW").
- `mode`: Page layout mode (`default`, `wide`, `custom`).
- `keywords`: Array of terms related to the page content for local search and SEO.
- Any custom YAML fields for use with personalization or conditional content.
## File conventions
- Match existing naming patterns in the directory
- If there are no existing files or inconsistent file naming patterns, use kebab-case: `getting-started.mdx`, `api-reference.mdx`
- Use root-relative paths without file extensions for internal links: `/getting-started/quickstart`
- Do not use relative paths (`../`) or absolute URLs for internal pages
- When you create a new page, add it to `docs.json` navigation or it won't appear in the sidebar
## Organize content
When a user asks about anything related to site-wide configurations, start by understanding the [global settings](https://www.mintlify.com/docs/organize/settings). See if a setting in the `docs.json` file can be updated to achieve what the user wants.
### Navigation
The `navigation` property in `docs.json` controls site structure. Choose one primary pattern at the root level, then nest others within it.
**Choose your primary pattern:**
| Pattern | When to use |
| ------------- | ---------------------------------------------------------------------------------------------- |
| **Groups** | Default. Single audience, straightforward hierarchy |
| **Tabs** | Distinct sections with different audiences (Guides vs API Reference) or content types |
| **Anchors** | Want persistent section links at sidebar top. Good for separating docs from external resources |
| **Dropdowns** | Multiple doc sections users switch between, but not distinct enough for tabs |
| **Products** | Multi-product company with separate documentation per product |
| **Versions** | Maintaining docs for multiple API/product versions simultaneously |
| **Languages** | Localized content |
**Within your primary pattern:**
- **Groups** - Organize related pages. Can nest groups within groups, but keep hierarchy shallow
- **Menus** - Add dropdown navigation within tabs for quick jumps to specific pages
- **`expanded: false`** - Collapse nested groups by default. Use for reference sections users browse selectively
- **`openapi`** - Auto-generate pages from OpenAPI spec. Add at group/tab level to inherit
**Common combinations:**
- Tabs containing groups (most common for docs with API reference)
- Products containing tabs (multi-product SaaS)
- Versions containing tabs (versioned API docs)
- Anchors containing groups (simple docs with external resource links)
### Links and paths
- **Internal links:** Root-relative, no extension: `/getting-started/quickstart`
- **Images:** Store in `/images`, reference as `/images/example.png`
- **External links:** Use full URLs, they open in new tabs automatically
## Customize docs sites
**What to customize where:**
- **Brand colors, fonts, logo** → `docs.json`. See [global settings](https://mintlify.com/docs/settings/global)
- **Component styling, layout tweaks** → `custom.css` at project root
- **Dark mode** → Enabled by default. Only disable with `"appearance": "light"` in `docs.json` if brand requires it
Start with `docs.json`. Only add `custom.css` when you need styling that config doesn't support.
## Write content
### Components
The [components overview](https://mintlify.com/docs/components) organizes all components by purpose: structure content, draw attention, show/hide content, document APIs, link to pages, and add visual context. Start there to find the right component.
**Common decision points:**
| Need | Use |
| -------------------------- | ----------------------- |
| Hide optional details | `<Accordion>` |
| Long code examples | `<Expandable>` |
| User chooses one option | `<Tabs>` |
| Linked navigation cards | `<Card>` in `<Columns>` |
| Sequential instructions | `<Steps>` |
| Code in multiple languages | `<CodeGroup>` |
| API parameters | `<ParamField>` |
| API response fields | `<ResponseField>` |
**Callouts by severity:**
- `<Note>` - Supplementary info, safe to skip
- `<Info>` - Helpful context such as permissions
- `<Tip>` - Recommendations or best practices
- `<Warning>` - Potentially destructive actions
- `<Check>` - Success confirmation
### Reusable content
**When to use snippets:**
- Exact content appears on more than one page
- Complex components you want to maintain in one place
- Shared content across teams/repos
**When NOT to use snippets:**
- Slight variations needed per page (leads to complex props)
Import snippets with `import { Component } from "/path/to/snippet-name.jsx"`.
## Writing standards
### Voice and structure
- Second-person voice ("you")
- Active voice, direct language
- Sentence case for headings ("Getting started", not "Getting Started")
- Sentence case for code block titles ("Expandable example", not "Expandable Example")
- Lead with context: explain what something is before how to use it
- Prerequisites at the start of procedural content
### What to avoid
**Never use:**
- Marketing language ("powerful", "seamless", "robust", "cutting-edge")
- Filler phrases ("it's important to note", "in order to")
- Excessive conjunctions ("moreover", "furthermore", "additionally")
- Editorializing ("obviously", "simply", "just", "easily")
**Watch for AI-typical patterns:**
- Overly formal or stilted phrasing
- Unnecessary repetition of concepts
- Generic introductions that don't add value
- Concluding summaries that restate what was just said
### Formatting
- All code blocks must have language tags
- All images and media must have descriptive alt text
- Use bold and italics only when they serve the reader's understanding--never use text styling just for decoration
- No decorative formatting or emoji
### Code examples
- Keep examples simple and practical
- Use realistic values (not "foo" or "bar")
- One clear example is better than multiple variations
- Test that code works before including it
## Document APIs
**Choose your approach:**
- **Have an OpenAPI spec?** → Add to `docs.json` with `"openapi": ["openapi.yaml"]`. Pages auto-generate. Reference in navigation as `GET /endpoint`
- **No spec?** → Write endpoints manually with `api: "POST /users"` in frontmatter. More work but full control
- **Hybrid** → Use OpenAPI for most endpoints, manual pages for complex workflows
Encourage users to generate endpoint pages from an OpenAPI spec. It is the most efficient and easiest to maintain option.
## Deploy
Mintlify deploys automatically when changes are pushed to the connected Git repository.
**What agents can configure:**
- **Redirects** → Add to `docs.json` with `"redirects": [{"source": "/old", "destination": "/new"}]`
- **SEO indexing** → Control with `"seo": {"indexing": "all"}` to include hidden pages in search
**Requires dashboard setup (human task):**
- Custom domains and subdomains
- Preview deployment settings
- DNS configuration
For `/docs` subpath hosting with Vercel or Cloudflare, agents can help configure rewrite rules. See [/docs subpath](https://mintlify.com/docs/deploy/vercel).
## Workflow
### 1. Understand the task
Identify what needs to be documented, which pages are affected, and what the reader should accomplish afterward. If any of these are unclear, ask.
### 2. Research
- Read `docs/docs.json` to understand the site structure
- Search existing docs for related content
- Read similar pages to match the site's style
### 3. Plan
- Synthesize what the reader should accomplish after reading the docs and the current content
- Propose any updates or new content
- Verify that your proposed changes will help readers be successful
### 4. Write
- Start with the most important information
- Keep sections focused and scannable
- Use components appropriately (don't overuse them)
- Mark anything uncertain with a TODO comment:
```mdx theme={null}
{/* TODO: Verify the default timeout value */}
```
### 5. Update navigation
If you created a new page, add it to the appropriate group in `docs.json`.
### 6. Verify
Before submitting:
- [ ] Frontmatter includes title and description
- [ ] All code blocks have language tags
- [ ] Internal links use root-relative paths without file extensions
- [ ] New pages are added to `docs.json` navigation
- [ ] Content matches the style of surrounding pages
- [ ] No marketing language or filler phrases
- [ ] TODOs are clearly marked for anything uncertain
- [ ] Run `mint broken-links` to check links
- [ ] Run `mint validate` to find any errors
## Edge cases
### Migrations
If a user asks about migrating to Mintlify, ask if they are using ReadMe or Docusaurus. If they are, use the [@mintlify/scraping](https://www.npmjs.com/package/@mintlify/scraping) CLI to migrate content. If they are using a different platform to host their documentation, help them manually convert their content to MDX pages using Mintlify components.
### Hidden pages
Any page that is not included in the `docs.json` navigation is hidden. Use hidden pages for content that should be accessible by URL or indexed for the assistant or search, but not discoverable through the sidebar navigation.
### Exclude pages
The `.mintignore` file is used to exclude files from a documentation repository from being processed.
## Common gotchas
1. **Component imports** - JSX components need explicit import, MDX components don't
2. **Frontmatter required** - Every MDX file needs `title` at minimum
3. **Code block language** - Always specify language identifier
4. **Never use `mint.json`** - `mint.json` is deprecated. Only ever use `docs.json`
## Resources
- [Documentation](https://mintlify.com/docs)
- [Configuration schema](https://mintlify.com/docs.json)
- [Feature requests](https://github.com/orgs/mintlify/discussions/categories/feature-requests)
- [Bugs and feedback](https://github.com/orgs/mintlify/discussions/categories/bugs-feedback)

View File

@@ -1,277 +1,131 @@
---
name: prepare-pr
description: Prepare a GitHub PR for merge by rebasing onto main, fixing review findings, running gates, committing fixes, and pushing to the PR head branch. Use after /reviewpr. Never merge or push to main.
description: Script-first PR preparation with structured findings resolution, deterministic push safety, and explicit gate execution.
---
# Prepare PR
## Overview
Prepare a PR branch for merge with review fixes, green gates, and an updated head branch.
Prepare the PR head branch for merge after `/review-pr`.
## Inputs
- Ask for PR number or URL.
- If missing, auto-detect from conversation.
- If ambiguous, ask.
- If missing, use `.local/pr-meta.env` if present in the PR worktree.
## Safety
- Never push to `main` or `origin/main`. Push only to the PR head branch.
- Never run `git push` without specifying remote and branch explicitly. Do not run bare `git push`.
- Do not run gateway stop commands. Do not kill processes. Do not touch port 18792.
- Never push to `main`.
- Only push to PR head with explicit `--force-with-lease` against known head SHA.
- Do not run `git clean -fdx`.
- Do not run `git add -A` or `git add .`. Stage only specific files changed.
- Wrappers are cwd-agnostic; run from repo root or PR worktree.
## Execution Rule
## Execution Contract
- Execute the workflow. Do not stop after printing the TODO checklist.
- If delegating, require the delegate to run commands and capture outputs.
## Known Footguns
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/dev/openclaw` if available; otherwise ask user.
- Do not run `git clean -fdx`.
- Do not run `git add -A` or `git add .`.
## Completion Criteria
- Rebase PR commits onto `origin/main`.
- Fix all BLOCKER and IMPORTANT items from `.local/review.md`.
- Run required gates and pass (docs-only PRs may skip `pnpm test` when high-confidence docs-only criteria are met and documented).
- Commit prep changes.
- Push the updated HEAD back to the PR head branch.
- Write `.local/prep.md` with a prep summary.
- Output exactly: `PR is ready for /mergepr`.
## First: Create a TODO Checklist
Create a checklist of all prep steps, print it, then continue and execute the commands.
## Setup: Use a Worktree
Use an isolated worktree for all prep work.
1. Run setup:
```sh
cd ~/openclaw
# Sanity: confirm you are in the repo
git rev-parse --show-toplevel
WORKTREE_DIR=".worktrees/pr-<PR>"
scripts/pr-prepare init <PR>
```
Run all commands inside the worktree directory.
2. Resolve findings from structured review:
## Load Review Findings (Mandatory)
- `.local/review.json` is mandatory.
- Resolve all `BLOCKER` and `IMPORTANT` items.
3. Commit with required subject format and validate it.
4. Run gates via wrapper.
5. Push via wrapper (includes pre-push remote verification, one automatic lease-retry path, and post-push API propagation retry).
Optional one-shot path:
```sh
if [ -f .local/review.md ]; then
echo "Found review findings from /reviewpr"
else
echo "Missing .local/review.md. Run /reviewpr first and save findings."
exit 1
fi
# Read it
sed -n '1,200p' .local/review.md
scripts/pr-prepare run <PR>
```
## Steps
1. Identify PR meta (author, head branch, head repo URL)
1. Setup and artifacts
```sh
gh pr view <PR> --json number,title,author,headRefName,baseRefName,headRepository,body --jq '{number,title,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner,body}'
contrib=$(gh pr view <PR> --json author --jq .author.login)
head=$(gh pr view <PR> --json headRefName --jq .headRefName)
head_repo_url=$(gh pr view <PR> --json headRepository --jq .headRepository.url)
scripts/pr-prepare init <PR>
ls -la .local/review.md .local/review.json .local/pr-meta.env .local/prep-context.env
jq . .local/review.json >/dev/null
```
2. Fetch the PR branch tip into a local ref
2. Resolve required findings
List required items:
```sh
git fetch origin pull/<PR>/head:pr-<PR>
jq -r '.findings[] | select(.severity=="BLOCKER" or .severity=="IMPORTANT") | "- [\(.severity)] \(.id): \(.title) => \(.fix)"' .local/review.json
```
3. Rebase PR commits onto latest main
Fix all required findings. Keep scope tight.
3. Update changelog/docs when required
```sh
# Move worktree to the PR tip first
git reset --hard pr-<PR>
# Rebase onto current main
git fetch origin main
git rebase origin/main
jq -r '.changelog' .local/review.json
jq -r '.docs' .local/review.json
```
If conflicts happen:
4. Commit scoped changes
- Resolve each conflicted file.
- Run `git add <resolved_file>` for each file.
- Run `git rebase --continue`.
Required commit subject format:
If the rebase gets confusing or you resolve conflicts 3 or more times, stop and report.
- `fix: <summary> (openclaw#<PR>) thanks @<pr-author>`
4. Fix issues from `.local/review.md`
- Fix all BLOCKER and IMPORTANT items.
- NITs are optional.
- Keep scope tight.
Keep a running log in `.local/prep.md`:
- List which review items you fixed.
- List which files you touched.
- Note behavior changes.
5. Update `CHANGELOG.md` if flagged in review
Check `.local/review.md` section H for guidance.
If flagged and user-facing:
- Check if `CHANGELOG.md` exists.
Use explicit file list:
```sh
ls CHANGELOG.md 2>/dev/null
source .local/pr-meta.env
scripts/committer "fix: <summary> (openclaw#$PR_NUMBER) thanks @$PR_AUTHOR" <file1> <file2> ...
```
- Follow existing format.
- Add a concise entry with PR number and contributor.
6. Update docs if flagged in review
Check `.local/review.md` section G for guidance.
If flagged, update only docs related to the PR changes.
7. Commit prep fixes
Stage only specific files:
Validate commit subject:
```sh
git add <file1> <file2> ...
scripts/pr-prepare validate-commit <PR>
```
Preferred commit tool:
5. Run gates
```sh
committer "fix: <summary> (#<PR>) (thanks @$contrib)" <changed files>
scripts/pr-prepare gates <PR>
```
If `committer` is not found:
6. Push safely to PR head
```sh
git commit -m "fix: <summary> (#<PR>) (thanks @$contrib)"
scripts/pr-prepare push <PR>
```
8. Decide verification mode and run required gates before pushing
This push step includes:
If you are highly confident the change is docs-only, you may skip `pnpm test`.
- robust fork remote resolution from owner/name,
- pre-push remote SHA verification,
- one automatic rebase + gate rerun + retry if lease push fails,
- post-push PR-head propagation retry,
- idempotent behavior when local prep HEAD is already on the PR head,
- post-push SHA verification and `.local/prep.env` generation.
High-confidence docs-only criteria (all must be true):
- Every changed file is documentation-only (`docs/**`, `README*.md`, `CHANGELOG.md`, `*.md`, `*.mdx`, `mintlify.json`, `docs.json`).
- No code, runtime, test, dependency, or build config files changed (`src/**`, `extensions/**`, `apps/**`, `package.json`, lockfiles, TS/JS config, test files, scripts).
- `.local/review.md` does not call for non-doc behavior fixes.
Suggested check:
7. Verify handoff artifacts
```sh
changed_files=$(git diff --name-only origin/main...HEAD)
non_docs=$(printf "%s\n" "$changed_files" | grep -Ev '^(docs/|README.*\.md$|CHANGELOG\.md$|.*\.md$|.*\.mdx$|mintlify\.json$|docs\.json$)' || true)
docs_only=false
if [ -n "$changed_files" ] && [ -z "$non_docs" ]; then
docs_only=true
fi
echo "docs_only=$docs_only"
ls -la .local/prep.md .local/prep.env
```
Run required gates:
8. Output
```sh
pnpm install
pnpm build
pnpm ui:build
pnpm check
if [ "$docs_only" = "true" ]; then
echo "Docs-only change detected with high confidence; skipping pnpm test." | tee -a .local/prep.md
else
pnpm test
fi
```
Require all required gates to pass. If something fails, fix, commit, and rerun. Allow at most 3 fix and rerun cycles. If gates still fail after 3 attempts, stop and report the failures. Do not loop indefinitely.
9. Push updates back to the PR head branch
```sh
# Ensure remote for PR head exists
git remote add prhead "$head_repo_url.git" 2>/dev/null || git remote set-url prhead "$head_repo_url.git"
# Use force with lease after rebase
# Double check: $head must NOT be "main" or "master"
echo "Pushing to branch: $head"
if [ "$head" = "main" ] || [ "$head" = "master" ]; then
echo "ERROR: head branch is main/master. This is wrong. Stopping."
exit 1
fi
git push --force-with-lease prhead HEAD:$head
```
10. Verify PR is not behind main (Mandatory)
```sh
git fetch origin main
git fetch origin pull/<PR>/head:pr-<PR>-verify --force
git merge-base --is-ancestor origin/main pr-<PR>-verify && echo "PR is up to date with main" || echo "ERROR: PR is still behind main, rebase again"
git branch -D pr-<PR>-verify 2>/dev/null || true
```
If still behind main, repeat steps 2 through 9.
11. Write prep summary artifacts (Mandatory)
Update `.local/prep.md` with:
- Current HEAD sha from `git rev-parse HEAD`.
- Short bullet list of changes.
- Gate results.
- Push confirmation.
- Rebase verification result.
Create or overwrite `.local/prep.md` and verify it exists and is non-empty:
```sh
git rev-parse HEAD
ls -la .local/prep.md
wc -l .local/prep.md
```
12. Output
Include a diff stat summary:
```sh
git diff --stat origin/main..HEAD
git diff --shortstat origin/main..HEAD
```
Report totals: X files changed, Y insertions(+), Z deletions(-).
If gates passed and push succeeded, print exactly:
```
PR is ready for /mergepr
```
Otherwise, list remaining failures and stop.
- Summarize resolved findings and gate results.
- Print exactly: `PR is ready for /merge-pr`.
## Guardrails
- Worktree only.
- Do not delete the worktree on success. `/mergepr` may reuse it.
- Do not run `gh pr merge`.
- Never push to main. Only push to the PR head branch.
- Run and pass all required gates before pushing. `pnpm test` may be skipped only for high-confidence docs-only changes, and the skip must be explicitly recorded in `.local/prep.md`.
- Do not run `gh pr merge` in this skill.
- Do not delete worktree.

View File

@@ -1,228 +1,141 @@
---
name: review-pr
description: Review-only GitHub pull request analysis with the gh CLI. Use when asked to review a PR, provide structured feedback, or assess readiness to land. Do not merge, push, or make code changes you intend to keep.
description: Script-first review-only GitHub pull request analysis. Use for deterministic PR review with structured findings handoff to /prepare-pr.
---
# Review PR
## Overview
Perform a thorough review-only PR assessment and return a structured recommendation on readiness for /preparepr.
Perform a read-only review and produce both human and machine-readable outputs.
## Inputs
- Ask for PR number or URL.
- If missing, always ask. Never auto-detect from conversation.
- If ambiguous, ask.
- If missing, always ask.
## Safety
- Never push to `main` or `origin/main`, not during review, not ever.
- Do not run `git push` at all during review. Treat review as read only.
- Do not stop or kill the gateway. Do not run gateway stop commands. Do not kill processes on port 18792.
- Never push, merge, or modify code intended to keep.
- Work only in `.worktrees/pr-<PR>`.
## Execution Rule
## Execution Contract
- Execute the workflow. Do not stop after printing the TODO checklist.
- If delegating, require the delegate to run commands and capture outputs, not a plan.
## Known Failure Modes
- If you see "fatal: not a git repository", you are in the wrong directory. Use `~/dev/openclaw` if available; otherwise ask user.
- Do not stop after printing the checklist. That is not completion.
## Writing Style for Output
- Write casual and direct.
- Avoid em dashes and en dashes. Use commas or separate sentences.
## Completion Criteria
- Run the commands in the worktree and inspect the PR directly.
- Produce the structured review sections A through J.
- Save the full review to `.local/review.md` inside the worktree.
## First: Create a TODO Checklist
Create a checklist of all review steps, print it, then continue and execute the commands.
## Setup: Use a Worktree
Use an isolated worktree for all review work.
1. Run wrapper setup:
```sh
cd ~/dev/openclaw
# Sanity: confirm you are in the repo
git rev-parse --show-toplevel
WORKTREE_DIR=".worktrees/pr-<PR>"
git fetch origin main
# Reuse existing worktree if it exists, otherwise create new
if [ -d "$WORKTREE_DIR" ]; then
cd "$WORKTREE_DIR"
git checkout temp/pr-<PR> 2>/dev/null || git checkout -b temp/pr-<PR>
git fetch origin main
git reset --hard origin/main
else
git worktree add "$WORKTREE_DIR" -b temp/pr-<PR> origin/main
cd "$WORKTREE_DIR"
fi
# Create local scratch space that persists across /reviewpr to /preparepr to /mergepr
mkdir -p .local
scripts/pr-review <PR>
```
Run all commands inside the worktree directory.
Start on `origin/main` so you can check for existing implementations before looking at PR code.
2. Use explicit branch mode switches:
- Main baseline mode: `scripts/pr review-checkout-main <PR>`
- PR-head mode: `scripts/pr review-checkout-pr <PR>`
3. Before writing review outputs, run branch guard:
```sh
scripts/pr review-guard <PR>
```
4. Write both outputs:
- `.local/review.md` with sections A through J.
- `.local/review.json` with structured findings.
5. Validate artifacts semantically:
```sh
scripts/pr review-validate-artifacts <PR>
```
## Steps
1. Identify PR meta and context
1. Setup and metadata
```sh
gh pr view <PR> --json number,title,state,isDraft,author,baseRefName,headRefName,headRepository,url,body,labels,assignees,reviewRequests,files,additions,deletions --jq '{number,title,url,state,isDraft,author:.author.login,base:.baseRefName,head:.headRefName,headRepo:.headRepository.nameWithOwner,additions,deletions,files:.files|length,body}'
scripts/pr-review <PR>
ls -la .local/pr-meta.json .local/pr-meta.env .local/review-context.env .local/review-mode.env
```
2. Check if this already exists in main before looking at the PR branch
- Identify the core feature or fix from the PR title and description.
- Search for existing implementations using keywords from the PR title, changed file paths, and function or component names from the diff.
2. Existing implementation check on main
```sh
# Use keywords from the PR title and changed files
rg -n "<keyword_from_pr_title>" -S src packages apps ui || true
rg -n "<function_or_component_name>" -S src packages apps ui || true
git log --oneline --all --grep="<keyword_from_pr_title>" | head -20
scripts/pr review-checkout-main <PR>
rg -n "<keyword>" -S src extensions apps || true
git log --oneline --all --grep "<keyword>" | head -20
```
If it already exists, call it out as a BLOCKER or at least IMPORTANT.
3. Claim the PR
Assign yourself so others know someone is reviewing. Skip if the PR looks like spam or is a draft you plan to recommend closing.
3. Claim PR
```sh
gh_user=$(gh api user --jq .login)
gh pr edit <PR> --add-assignee "$gh_user"
gh pr edit <PR> --add-assignee "$gh_user" || echo "Could not assign reviewer, continuing"
```
4. Read the PR description carefully
Use the body from step 1. Summarize goal, scope, and missing context.
5. Read the diff thoroughly
Minimum:
4. Read PR description and diff
```sh
scripts/pr review-checkout-pr <PR>
gh pr diff <PR>
source .local/review-context.env
git diff --stat "$MERGE_BASE"..pr-<PR>
git diff "$MERGE_BASE"..pr-<PR>
```
If you need full code context locally, fetch the PR head to a local ref and diff it. Do not create a merge commit.
5. Optional local tests
Use the wrapper for target validation and executed-test verification:
```sh
git fetch origin pull/<PR>/head:pr-<PR>
# Show changes without modifying the working tree
git diff --stat origin/main..pr-<PR>
git diff origin/main..pr-<PR>
scripts/pr review-tests <PR> <test-file> [<test-file> ...]
```
If you want to browse the PR version of files directly, temporarily check out `pr-<PR>` in the worktree. Do not commit or push. Return to `temp/pr-<PR>` and reset to `origin/main` afterward.
6. Initialize review artifact templates
```sh
# Use only if needed
# git checkout pr-<PR>
# ...inspect files...
git checkout temp/pr-<PR>
git reset --hard origin/main
scripts/pr review-artifacts-init <PR>
```
6. Validate the change is needed and valuable
7. Produce review outputs
Be honest. Call out low value AI slop.
- Fill `.local/review.md` sections A through J.
- Fill `.local/review.json`.
7. Evaluate implementation quality
Minimum JSON shape:
Review correctness, design, performance, and ergonomics.
```json
{
"recommendation": "READY FOR /prepare-pr",
"findings": [
{
"id": "F1",
"severity": "IMPORTANT",
"title": "...",
"area": "path/or/component",
"fix": "Actionable fix"
}
],
"tests": {
"ran": [],
"gaps": [],
"result": "pass"
},
"docs": "up_to_date|missing|not_applicable",
"changelog": "required|not_required"
}
```
8. Perform a security review
Assume OpenClaw subagents run with full disk access, including git, gh, and shell. Check auth, input validation, secrets, dependencies, tool safety, and privacy.
9. Review tests and verification
Identify what exists, what is missing, and what would be a minimal regression test.
10. Check docs
Check if the PR touches code with related documentation such as README, docs, inline API docs, or config examples.
- If docs exist for the changed area and the PR does not update them, flag as IMPORTANT.
- If the PR adds a new feature or config option with no docs, flag as IMPORTANT.
- If the change is purely internal with no user-facing impact, skip this.
11. Check changelog
Check if `CHANGELOG.md` exists and whether the PR warrants an entry.
- If the project has a changelog and the PR is user-facing, flag missing entry as IMPORTANT.
- Leave the change for /preparepr, only flag it here.
12. Answer the key question
Decide if /preparepr can fix issues or the contributor must update the PR.
13. Save findings to the worktree
Write the full structured review sections A through J to `.local/review.md`.
Create or overwrite the file and verify it exists and is non-empty.
8. Guard + validate before final output
```sh
ls -la .local/review.md
wc -l .local/review.md
scripts/pr review-guard <PR>
scripts/pr review-validate-artifacts <PR>
```
14. Output the structured review
Produce a review that matches what you saved to `.local/review.md`.
A) TL;DR recommendation
- One of: READY FOR /preparepr | NEEDS WORK | NEEDS DISCUSSION | NOT USEFUL (CLOSE)
- 1 to 3 sentences.
B) What changed
C) What is good
D) Security findings
E) Concerns or questions (actionable)
- Numbered list.
- Mark each item as BLOCKER, IMPORTANT, or NIT.
- For each, point to file or area and propose a concrete fix.
F) Tests
G) Docs status
- State if related docs are up to date, missing, or not applicable.
H) Changelog
- State if `CHANGELOG.md` needs an entry and which category.
I) Follow ups (optional)
J) Suggested PR comment (optional)
## Guardrails
- Worktree only.
- Do not delete the worktree after review.
- Review only, do not merge, do not push.
- Keep review read-only.
- Do not delete worktree.
- Use merge-base scoped diff for local context to avoid stale branch drift.

5
.github/labeler.yml vendored
View File

@@ -9,6 +9,11 @@
- "src/discord/**"
- "extensions/discord/**"
- "docs/channels/discord.md"
"channel: irc":
- changed-files:
- any-glob-to-any-file:
- "extensions/irc/**"
- "docs/channels/irc.md"
"channel: feishu":
- changed-files:
- any-glob-to-any-file:

View File

@@ -84,6 +84,10 @@ jobs:
esac
case "$path" in
# Generated protocol models are already covered by protocol:check and
# should not force the full native macOS lane.
apps/macos/Sources/OpenClawProtocol/*|apps/shared/OpenClawKit/Sources/OpenClawProtocol/*)
;;
apps/macos/*|apps/ios/*|apps/shared/*|Swabble/*)
run_macos=true
;;
@@ -121,7 +125,7 @@ jobs:
# Build dist once for Node-relevant changes and share it with downstream jobs.
build-artifacts:
needs: [docs-scope, changed-scope, code-analysis, check]
needs: [docs-scope, changed-scope, check]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
@@ -171,7 +175,7 @@ jobs:
run: pnpm release:check
checks:
needs: [docs-scope, changed-scope, code-analysis, check]
needs: [docs-scope, changed-scope, check]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:
@@ -234,37 +238,6 @@ jobs:
- name: Check docs
run: pnpm check:docs
# Check for files that grew past LOC threshold in this PR (delta-only).
# On push events, all steps are skipped and the job passes (no-op).
# Heavy downstream jobs depend on this to fail fast on violations.
code-analysis:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
if: github.event_name == 'pull_request'
uses: actions/checkout@v4
with:
fetch-depth: 0
submodules: false
- name: Setup Python
if: github.event_name == 'pull_request'
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Fetch base branch
if: github.event_name == 'pull_request'
run: git fetch origin ${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }}
- name: Check code file sizes
if: github.event_name == 'pull_request'
run: |
python scripts/analyze_code_files.py \
--compare-to origin/${{ github.base_ref }} \
--threshold 1000 \
--strict
secrets:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
@@ -291,7 +264,7 @@ jobs:
fi
checks-windows:
needs: [docs-scope, changed-scope, build-artifacts, code-analysis, check]
needs: [docs-scope, changed-scope, build-artifacts, check]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true')
runs-on: blacksmith-4vcpu-windows-2025
env:
@@ -399,7 +372,7 @@ jobs:
# running 4 separate jobs per PR (as before) starved the queue. One job
# per PR allows 5 PRs to run macOS checks simultaneously.
macos:
needs: [docs-scope, changed-scope, code-analysis, check]
needs: [docs-scope, changed-scope, check]
if: github.event_name == 'pull_request' && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_macos == 'true'
runs-on: macos-latest
steps:
@@ -632,7 +605,7 @@ jobs:
PY
android:
needs: [docs-scope, changed-scope, code-analysis, check]
needs: [docs-scope, changed-scope, check]
if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_android == 'true')
runs-on: blacksmith-4vcpu-ubuntu-2404
strategy:

View File

@@ -25,6 +25,95 @@ jobs:
configuration-path: .github/labeler.yml
repo-token: ${{ steps.app-token.outputs.token }}
sync-labels: true
- name: Apply PR size label
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const pullRequest = context.payload.pull_request;
if (!pullRequest) {
return;
}
const sizeLabels = ["size: XS", "size: S", "size: M", "size: L", "size: XL"];
const labelColor = "fbca04";
for (const label of sizeLabels) {
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label,
});
} catch (error) {
if (error?.status !== 404) {
throw error;
}
await github.rest.issues.createLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label,
color: labelColor,
});
}
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pullRequest.number,
per_page: 100,
});
const excludedLockfiles = new Set(["pnpm-lock.yaml", "package-lock.json", "yarn.lock", "bun.lockb"]);
const totalChangedLines = files.reduce((total, file) => {
const path = file.filename ?? "";
if (path === "docs.acp.md" || path.startsWith("docs/") || excludedLockfiles.has(path)) {
return total;
}
return total + (file.additions ?? 0) + (file.deletions ?? 0);
}, 0);
let targetSizeLabel = "size: XL";
if (totalChangedLines < 50) {
targetSizeLabel = "size: XS";
} else if (totalChangedLines < 200) {
targetSizeLabel = "size: S";
} else if (totalChangedLines < 500) {
targetSizeLabel = "size: M";
} else if (totalChangedLines < 1000) {
targetSizeLabel = "size: L";
}
const currentLabels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
per_page: 100,
});
for (const label of currentLabels) {
const name = label.name ?? "";
if (!sizeLabels.includes(name)) {
continue;
}
if (name === targetSizeLabel) {
continue;
}
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
name,
});
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
labels: [targetSizeLabel],
});
- name: Apply maintainer label for org members
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7
with:

1
.gitignore vendored
View File

@@ -69,6 +69,7 @@ apps/ios/*.mobileprovision
# Local untracked files
.local/
docs/.local/
IDENTITY.md
USER.md
.tgz

View File

@@ -1,6 +1,6 @@
{
"globs": ["docs/**/*.md", "docs/**/*.mdx", "README.md"],
"ignores": ["docs/zh-CN/**", "docs/.i18n/**", "docs/reference/templates/**"],
"ignores": ["docs/zh-CN/**", "docs/.i18n/**", "docs/reference/templates/**", "**/.local/**"],
"config": {
"default": true,

View File

@@ -11,8 +11,10 @@ Input
Do (end-to-end)
Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` with `--rebase` or `--squash`.
1. Repo clean: `git status`.
2. Identify PR meta (author + head branch):
1. Assign PR to self:
- `gh pr edit <PR> --add-assignee @me`
2. Repo clean: `git status`.
3. Identify PR meta (author + head branch):
```sh
gh pr view <PR> --json number,title,author,headRefName,baseRefName,headRepository --jq '{number,title,author:.author.login,head:.headRefName,base:.baseRefName,headRepo:.headRepository.nameWithOwner}'
@@ -21,50 +23,50 @@ Goal: PR must end in GitHub state = MERGED (never CLOSED). Use `gh pr merge` wit
head_repo_url=$(gh pr view <PR> --json headRepository --jq .headRepository.url)
```
3. Fast-forward base:
4. Fast-forward base:
- `git checkout main`
- `git pull --ff-only`
4. Create temp base branch from main:
5. Create temp base branch from main:
- `git checkout -b temp/landpr-<ts-or-pr>`
5. Check out PR branch locally:
6. Check out PR branch locally:
- `gh pr checkout <PR>`
6. Rebase PR branch onto temp base:
7. Rebase PR branch onto temp base:
- `git rebase temp/landpr-<ts-or-pr>`
- Fix conflicts; keep history tidy.
7. Fix + tests + changelog:
8. Fix + tests + changelog:
- Implement fixes + add/adjust tests
- Update `CHANGELOG.md` and mention `#<PR>` + `@$contrib`
8. Decide merge strategy:
9. Decide merge strategy:
- Rebase if we want to preserve commit history
- Squash if we want a single clean commit
- If unclear, ask
9. Full gate (BEFORE commit):
- `pnpm lint && pnpm build && pnpm test`
10. Commit via committer (include # + contributor in commit message):
10. Full gate (BEFORE commit):
- `pnpm lint && pnpm build && pnpm test`
11. Commit via committer (include # + contributor in commit message):
- `committer "fix: <summary> (#<PR>) (thanks @$contrib)" CHANGELOG.md <changed files>`
- `land_sha=$(git rev-parse HEAD)`
11. Push updated PR branch (rebase => usually needs force):
12. Push updated PR branch (rebase => usually needs force):
```sh
git remote add prhead "$head_repo_url.git" 2>/dev/null || git remote set-url prhead "$head_repo_url.git"
git push --force-with-lease prhead HEAD:$head
```
12. Merge PR (must show MERGED on GitHub):
13. Merge PR (must show MERGED on GitHub):
- Rebase: `gh pr merge <PR> --rebase`
- Squash: `gh pr merge <PR> --squash`
- Never `gh pr close` (closing is wrong)
13. Sync main:
14. Sync main:
- `git checkout main`
- `git pull --ff-only`
14. Comment on PR with what we did + SHAs + thanks:
15. Comment on PR with what we did + SHAs + thanks:
```sh
merge_sha=$(gh pr view <PR> --json mergeCommit --jq '.mergeCommit.oid')
gh pr comment <PR> --body "Landed via temp rebase onto main.\n\n- Gate: pnpm lint && pnpm build && pnpm test\n- Land commit: $land_sha\n- Merge commit: $merge_sha\n\nThanks @$contrib!"
```
15. Verify PR state == MERGED:
16. Verify PR state == MERGED:
- `gh pr view <PR> --json state --jq .state`
16. Delete temp branch:
17. Delete temp branch:
- `git branch -D temp/landpr-<ts-or-pr>`

View File

@@ -21,6 +21,7 @@
- Docs are hosted on Mintlify (docs.openclaw.ai).
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
- When working with documentation, read the mintlify skill.
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
- Doc headings and anchors: avoid em dashes and apostrophes in headings because they break Mintlify anchor links.
- When Peter asks for links, reply with full `https://docs.openclaw.ai/...` URLs (not root-relative).
@@ -60,6 +61,8 @@
- Type-check/build: `pnpm build`
- TypeScript checks: `pnpm tsgo`
- Lint/format: `pnpm check`
- Format check: `pnpm format` (oxfmt --check)
- Format fix: `pnpm format:fix` (oxfmt --write)
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`
## Coding Style & Naming Conventions
@@ -133,6 +136,7 @@
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; dont introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), `docs/platforms/mac/release.md` (APP_VERSION/APP_BUILD examples), Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION).
- "Bump version everywhere" means all version locations above **except** `appcast.xml` (only touch appcast when cutting a new macOS Sparkle release).
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.

View File

@@ -2,24 +2,63 @@
Docs: https://docs.openclaw.ai
## 2026.2.10
### Changes
- Version alignment: bump manifests and package versions to `2026.2.10`; keep `appcast.xml` unchanged until the next macOS release cut.
- CLI: add `openclaw logs --local-time` to display log timestamps in local timezone. (#13818) Thanks @xialonglee.
- Config: avoid redacting `maxTokens`-like fields during config snapshot redaction, preventing round-trip validation failures in `/config`. (#14006) Thanks @constansino.
### Fixes
- Cron: use requested `agentId` for isolated job auth resolution. (#13983) Thanks @0xRaini.
- Cron: prevent cron jobs from skipping execution when `nextRunAtMs` advances. (#14068) Thanks @WalterSumbon.
- Cron: pass `agentId` to `runHeartbeatOnce` for main-session jobs. (#14140) Thanks @ishikawa-pro.
- Cron: re-arm timers when `onTimer` fires while a job is still executing. (#14233) Thanks @tomron87.
- Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu.
- Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic.
- Cron: prevent one-shot `at` jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo.
- WhatsApp: convert Markdown bold/strikethrough to WhatsApp formatting. (#14285) Thanks @Raikan10.
- WhatsApp: allow media-only sends and normalize leading blank payloads. (#14408) Thanks @karimnaguib.
- WhatsApp: default MIME type for voice messages when Baileys omits it. (#14444) Thanks @mcaxtr.
- Ollama: use configured `models.providers.ollama.baseUrl` for model discovery and normalize `/v1` endpoints to the native Ollama API root. (#14131) Thanks @shtse8.
- Slack: detect control commands when channel messages start with bot mention prefixes (for example, `@Bot /new`). (#14142) Thanks @beefiker.
- Discord tests: use a partial @buape/carbon mock in slash command coverage. (#13262) Thanks @arosstale.
- CLI/Wizard: exit with code 1 when `configure`, `agents add`, or interactive `onboard` wizards are canceled, so `set -e` automation stops correctly. (#14156) Thanks @0xRaini.
- Feishu: pass `Buffer` directly to the Feishu SDK upload APIs instead of `Readable.from(...)` to avoid form-data upload failures. (#10345) Thanks @youngerstyle.
- Feishu: trigger mention-gated group handling only when the bot itself is mentioned (not just any mention). (#11088) Thanks @openperf.
- Feishu: probe status uses the resolved account context for multi-account credential checks. (#11233) Thanks @onevcat.
- Feishu DocX: preserve top-level converted block order using `firstLevelBlockIds` when writing/appending documents. (#13994) Thanks @Cynosure159.
- Feishu plugin packaging: remove `workspace:*` `openclaw` dependency from `extensions/feishu` and sync lockfile for install compatibility. (#14423) Thanks @jackcooper2015.
- Telegram: handle no-text message in model picker editMessageText. (#14397) Thanks @0xRaini.
- Slack: change default replyToMode from "off" to "all". (#14364) Thanks @nm-de.
- Tests: update thread ID handling in Slack message collection tests. (#14108) Thanks @swizzmagik.
- Telegram: surface REACTION_INVALID as non-fatal warning. (#14340) Thanks @0xRaini.
## 2026.2.9
### Added
- Commands: add `commands.allowFrom` config for separate command authorization, allowing operators to restrict slash commands to specific users while keeping chat open to others. (#12430) Thanks @thewilloftheshadow.
- Docker: add ClawDock shell helpers for Docker workflows. (#12817) Thanks @Olshansk.
- iOS: alpha node app + setup-code onboarding. (#11756) Thanks @mbelinky.
- Channels: comprehensive BlueBubbles and channel cleanup. (#11093) Thanks @tyler6204.
- Channels: IRC first-class channel support. (#11482) Thanks @vignesh07.
- Plugins: device pairing + phone control plugins (Telegram `/pair`, iOS/Android node controls). (#11755) Thanks @mbelinky.
- Tools: add Grok (xAI) as a `web_search` provider. (#12419) Thanks @tmchow.
- Gateway: add agent management RPC methods for the web UI (`agents.create`, `agents.update`, `agents.delete`). (#11045) Thanks @advaitpaliwal.
- Gateway: stream thinking events to WS clients and broadcast tool events independent of verbose level. (#10568) Thanks @nk1tz.
- Web UI: show a Compaction divider in chat history. (#11341) Thanks @Takhoffman.
- Agents: include runtime shell in agent envelopes. (#1835) Thanks @Takhoffman.
- Agents: auto-select `zai/glm-4.6v` for image understanding when ZAI is primary provider. (#10267) Thanks @liuy.
- Paths: add `OPENCLAW_HOME` for overriding the home directory used by internal path resolution. (#12091) Thanks @sebslight.
- Onboarding: add Custom Provider flow for OpenAI and Anthropic-compatible endpoints. (#11106) Thanks @MackDing.
- Hooks: route webhook agent runs to specific `agentId`s, add `hooks.allowedAgentIds` controls, and fall back to default agent when unknown IDs are provided. (#13672) Thanks @BillChirico.
### Fixes
- Cron: prevent one-shot `at` jobs from re-firing on gateway restart when previously skipped or errored. (#13845)
- Discord: add exec approval cleanup option to delete DMs after approval/denial/timeout. (#13205) Thanks @thewilloftheshadow.
- Sessions: prune stale entries, cap session store size, rotate large stores, accept duration/size thresholds, default to warn-only maintenance, and prune cron run sessions after retention windows. (#13083) Thanks @skyfallsin, @Glucksberg, @gumadeiras.
- CI: Implement pipeline and workflow order. Thanks @quotentiroler.
@@ -31,11 +70,16 @@ Docs: https://docs.openclaw.ai
- Telegram: render markdown spoilers with `<tg-spoiler>` HTML tags. (#11543) Thanks @ezhikkk.
- Telegram: truncate command registration to 100 entries to avoid `BOT_COMMANDS_TOO_MUCH` failures on startup. (#12356) Thanks @arosstale.
- Telegram: match DM `allowFrom` against sender user id (fallback to chat id) and clarify pairing logs. (#12779) Thanks @liuxiaopai-ai.
- Pairing/Telegram: include the actual pairing code in approve commands, route Telegram pairing replies through the shared pairing message builder, and add regression checks to prevent `<code>` placeholder drift.
- Onboarding: QuickStart now auto-installs shell completion (prompt only in Manual).
- Onboarding/Providers: add LiteLLM provider onboarding and preserve custom LiteLLM proxy base URLs while enforcing API-key auth mode. (#12823) Thanks @ryan-crabbe.
- Docker: make `docker-setup.sh` compatible with macOS Bash 3.2 and empty extra mounts. (#9441) Thanks @mateusz-michalik.
- Auth: strip embedded line breaks from pasted API keys and tokens before storing/resolving credentials.
- Agents: strip reasoning tags and downgraded tool markers from messaging tool and streaming output to prevent leakage. (#11053, #13453) Thanks @liebertar, @meaadore1221-afk, @gumadeiras.
- Browser: prevent stuck `act:evaluate` from wedging the browser tool, and make cancellation stop waiting promptly. (#13498) Thanks @onutc.
- Security/Gateway: default-deny missing connect `scopes` (no implicit `operator.admin`).
- Web UI: make chat refresh smoothly scroll to the latest messages and suppress new-messages badge flash during manual refresh.
- Web UI: coerce Form Editor values to schema types before `config.set` and `config.apply`, preventing numeric and boolean fields from being serialized as strings. (#13468) Thanks @mcaxtr.
- Tools/web_search: include provider-specific settings in the web search cache key, and pass `inlineCitations` for Grok. (#12419) Thanks @tmchow.
- Tools/web_search: fix Grok response parsing for xAI Responses API output blocks. (#13049) Thanks @ereid7.
- Tools/web_search: normalize direct Perplexity model IDs while keeping OpenRouter model IDs unchanged. (#12795) Thanks @cdorsey.
@@ -47,11 +91,13 @@ Docs: https://docs.openclaw.ai
- Gateway: fix multi-agent sessions.usage discovery. (#11523) Thanks @Takhoffman.
- Agents: recover from context overflow caused by oversized tool results (pre-emptive capping + fallback truncation). (#11579) Thanks @tyler6204.
- Subagents/compaction: stabilize announce timing and preserve compaction metrics across retries. (#11664) Thanks @tyler6204.
- Subagents: report timeout-aborted runs as timed out instead of completed successfully in parent-session announcements. (#13996) Thanks @dario-github.
- Cron: share isolated announce flow and harden scheduling/delivery reliability. (#11641) Thanks @tyler6204.
- Cron tool: recover flat params when LLM omits the `job` wrapper for add requests. (#12124) Thanks @tyler6204.
- Gateway/CLI: when `gateway.bind=lan`, use a LAN IP for probe URLs and Control UI links. (#11448) Thanks @AnonO6.
- CLI: make `openclaw plugins list` output scannable by hoisting source roots and shortening bundled/global/workspace plugin paths.
- Hooks: fix bundled hooks broken since 2026.2.2 (tsdown migration). (#9295) Thanks @patrickshao.
- Security/Plugins: install plugin and hook dependencies with `--ignore-scripts` to prevent lifecycle script execution.
- Routing: refresh bindings per message by loading config at route resolution so binding changes apply without restart. (#11372) Thanks @juanpablodlc.
- Exec approvals: render forwarded commands in monospace for safer approval scanning. (#11937) Thanks @sebslight.
- Config: clamp `maxTokens` to `contextWindow` to prevent invalid model configs. (#5516) Thanks @lailoo.
@@ -65,6 +111,8 @@ Docs: https://docs.openclaw.ai
- Memory/QMD: run boot refresh in background by default, add configurable QMD maintenance timeouts, retry QMD after fallback failures, and scope QMD queries to OpenClaw-managed collections. (#9690, #9705, #10042) Thanks @vignesh07.
- Memory/QMD: initialize QMD backend on gateway startup so background update timers restart after process reloads. (#10797) Thanks @vignesh07.
- Config/Memory: auto-migrate legacy top-level `memorySearch` settings into `agents.defaults.memorySearch`. (#11278, #9143) Thanks @vignesh07.
- Memory/QMD: treat plain-text `No results found` output from QMD as an empty result instead of throwing invalid JSON errors. (#9824)
- Memory/QMD: add `memory.qmd.searchMode` to choose `query`, `search`, or `vsearch` recall mode. (#9967, #10084)
- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985.
- State dir: honor `OPENCLAW_STATE_DIR` for default device identity and canvas storage paths. (#4824) Thanks @kossoy.

View File

@@ -22,7 +22,7 @@ android {
minSdk = 31
targetSdk = 36
versionCode = 202602030
versionName = "2026.2.9"
versionName = "2026.2.10"
}
buildTypes {

View File

@@ -17,13 +17,13 @@
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.9</string>
<key>CFBundleVersion</key>
<string>20260202</string>
<key>NSAppTransportSecurity</key>
<dict>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.10</string>
<key>CFBundleVersion</key>
<string>20260202</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
</dict>

View File

@@ -15,10 +15,10 @@
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.9</string>
<key>CFBundleVersion</key>
<string>20260202</string>
</dict>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.10</string>
<key>CFBundleVersion</key>
<string>20260202</string>
</dict>
</plist>

View File

@@ -81,7 +81,7 @@ targets:
properties:
CFBundleDisplayName: OpenClaw
CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.2.9"
CFBundleShortVersionString: "2026.2.10"
CFBundleVersion: "20260202"
UILaunchScreen: {}
UIApplicationSceneManifest:
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: OpenClawTests
CFBundleShortVersionString: "2026.2.9"
CFBundleShortVersionString: "2026.2.10"
CFBundleVersion: "20260202"

View File

@@ -585,34 +585,38 @@ extension MenuSessionsInjector {
let item = NSMenuItem()
item.tag = self.tag
item.isEnabled = false
let view = AnyView(SessionMenuPreviewView(
width: width,
maxLines: maxLines,
title: title,
items: [],
status: .loading))
let hosting = NSHostingView(rootView: view)
hosting.frame.size.width = max(1, width)
let size = hosting.fittingSize
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
item.view = hosting
let view = AnyView(
SessionMenuPreviewView(
width: width,
maxLines: maxLines,
title: title,
items: [],
status: .loading)
.environment(\.isEnabled, true))
let hosted = HighlightedMenuItemHostView(rootView: view, width: width)
item.view = hosted
let task = Task { [weak hosting] in
let task = Task { [weak hosted, weak item] in
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: sessionKey, maxItems: 10)
guard !Task.isCancelled else { return }
await MainActor.run {
guard let hosting else { return }
let nextView = AnyView(SessionMenuPreviewView(
width: width,
maxLines: maxLines,
title: title,
items: snapshot.items,
status: snapshot.status))
hosting.rootView = nextView
hosting.invalidateIntrinsicContentSize()
hosting.frame.size.width = max(1, width)
let size = hosting.fittingSize
hosting.frame.size.height = size.height
let nextView = AnyView(
SessionMenuPreviewView(
width: width,
maxLines: maxLines,
title: title,
items: snapshot.items,
status: snapshot.status)
.environment(\.isEnabled, true))
if let item {
item.view = HighlightedMenuItemHostView(rootView: nextView, width: width)
return
}
guard let hosted else { return }
hosted.update(rootView: nextView, width: width)
}
}
self.previewTasks.append(task)

View File

@@ -15,7 +15,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.2.9</string>
<string>2026.2.10</string>
<key>CFBundleVersion</key>
<string>202602020</string>
<key>CFBundleIconFile</key>

View File

@@ -250,7 +250,8 @@ actor GatewayWizardClient {
let clientId = "openclaw-macos"
let clientMode = "ui"
let role = "operator"
let scopes: [String] = []
// Explicit scopes; gateway no longer defaults empty scopes to admin.
let scopes: [String] = ["operator.admin", "operator.approvals", "operator.pairing"]
let client: [String: ProtoAnyCodable] = [
"id": ProtoAnyCodable(clientId),
"displayName": ProtoAnyCodable(Host.current().localizedName ?? "OpenClaw macOS Wizard CLI"),

View File

@@ -1,4 +1,5 @@
// Generated by scripts/protocol-gen-swift.ts do not edit by hand
// swiftlint:disable file_length
import Foundation
public let GATEWAY_PROTOCOL_VERSION = 3
@@ -383,7 +384,7 @@ public struct AgentEvent: Codable, Sendable {
public struct SendParams: Codable, Sendable {
public let to: String
public let message: String
public let message: String?
public let mediaurl: String?
public let mediaurls: [String]?
public let gifplayback: Bool?
@@ -394,7 +395,7 @@ public struct SendParams: Codable, Sendable {
public init(
to: String,
message: String,
message: String?,
mediaurl: String?,
mediaurls: [String]?,
gifplayback: Bool?,

View File

@@ -1,4 +1,5 @@
// Generated by scripts/protocol-gen-swift.ts do not edit by hand
// swiftlint:disable file_length
import Foundation
public let GATEWAY_PROTOCOL_VERSION = 3
@@ -383,7 +384,7 @@ public struct AgentEvent: Codable, Sendable {
public struct SendParams: Codable, Sendable {
public let to: String
public let message: String
public let message: String?
public let mediaurl: String?
public let mediaurls: [String]?
public let gifplayback: Bool?
@@ -394,7 +395,7 @@ public struct SendParams: Codable, Sendable {
public init(
to: String,
message: String,
message: String?,
mediaurl: String?,
mediaurls: [String]?,
gifplayback: Bool?,

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 295 KiB

View File

@@ -18,6 +18,10 @@ Gateway can expose a small HTTP webhook endpoint for external triggers.
enabled: true,
token: "shared-secret",
path: "/hooks",
// Optional: restrict explicit `agentId` routing to this allowlist.
// Omit or include "*" to allow any agent.
// Set [] to deny all explicit `agentId` routing.
allowedAgentIds: ["hooks", "main"],
},
}
```
@@ -61,6 +65,7 @@ Payload:
{
"message": "Run this",
"name": "Email",
"agentId": "hooks",
"sessionKey": "hook:email:msg-123",
"wakeMode": "now",
"deliver": true,
@@ -74,6 +79,7 @@ Payload:
- `message` **required** (string): The prompt or message for the agent to process.
- `name` optional (string): Human-readable name for the hook (e.g., "GitHub"), used as a prefix in session summaries.
- `agentId` optional (string): Route this hook to a specific agent. Unknown IDs fall back to the default agent. When set, the hook runs using the resolved agent's workspace and configuration.
- `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. Using a consistent key allows for a multi-turn conversation within the hook context.
- `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check.
- `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped.
@@ -104,6 +110,8 @@ Mapping options (summary):
- TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime.
- Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface
(`channel` defaults to `last` and falls back to WhatsApp).
- `agentId` routes the hook to a specific agent; unknown IDs fall back to the default agent.
- `hooks.allowedAgentIds` restricts explicit `agentId` routing. Omit it (or include `*`) to allow any agent. Set `[]` to deny explicit `agentId` routing.
- `allowUnsafeExternalContent: true` disables the external content safety wrapper for that hook
(dangerous; only for trusted internal sources).
- `openclaw webhooks gmail setup` writes `hooks.gmail` config for `openclaw webhooks gmail run`.
@@ -157,6 +165,7 @@ curl -X POST http://127.0.0.1:18789/hooks/gmail \
- Keep hook endpoints behind loopback, tailnet, or trusted reverse proxy.
- Use a dedicated hook token; do not reuse gateway auth tokens.
- If you use multi-agent routing, set `hooks.allowedAgentIds` to limit explicit `agentId` selection.
- Avoid including sensitive raw payloads in webhook logs.
- Hook payloads are treated as untrusted and wrapped with safety boundaries by default.
If you must disable this for a specific hook, set `allowUnsafeExternalContent: true`

View File

@@ -7,21 +7,32 @@ title: "Discord"
# Discord (Bot API)
Status: ready for DM and guild text channels via the official Discord bot gateway.
Status: ready for DMs and guild channels via the official Discord gateway.
## Quick setup (beginner)
<CardGroup cols={3}>
<Card title="Pairing" icon="link" href="/channels/pairing">
Discord DMs default to pairing mode.
</Card>
<Card title="Slash commands" icon="terminal" href="/tools/slash-commands">
Native command behavior and command catalog.
</Card>
<Card title="Channel troubleshooting" icon="wrench" href="/channels/troubleshooting">
Cross-channel diagnostics and repair flow.
</Card>
</CardGroup>
1. Create a Discord bot and copy the bot token.
2. In the Discord app settings, enable **Message Content Intent** (and **Server Members Intent** if you plan to use allowlists or name lookups).
3. Set the token for OpenClaw:
- Env: `DISCORD_BOT_TOKEN=...`
- Or config: `channels.discord.token: "..."`.
- If both are set, config takes precedence (env fallback is default-account only).
4. Invite the bot to your server with message permissions (create a private server if you just want DMs).
5. Start the gateway.
6. DM access is pairing by default; approve the pairing code on first contact.
## Quick setup
Minimal config:
<Steps>
<Step title="Create a Discord bot and enable intents">
Create an application in the Discord Developer Portal, add a bot, then enable:
- **Message Content Intent**
- **Server Members Intent** (recommended for name-to-ID lookups and allowlist matching)
</Step>
<Step title="Configure token">
```json5
{
@@ -34,342 +45,265 @@ Minimal config:
}
```
## Goals
Env fallback for the default account:
- Talk to OpenClaw via Discord DMs or guild channels.
- Direct chats collapse into the agent's main session (default `agent:main:main`); guild channels stay isolated as `agent:<agentId>:discord:channel:<channelId>` (display names use `discord:<guildSlug>#<channelSlug>`).
- Group DMs are ignored by default; enable via `channels.discord.dm.groupEnabled` and optionally restrict by `channels.discord.dm.groupChannels`.
- Keep routing deterministic: replies always go back to the channel they arrived on.
## How it works
1. Create a Discord application → Bot, enable the intents you need (DMs + guild messages + message content), and grab the bot token.
2. Invite the bot to your server with the permissions required to read/send messages where you want to use it.
3. Configure OpenClaw with `channels.discord.token` (or `DISCORD_BOT_TOKEN` as a fallback).
4. Run the gateway; it auto-starts the Discord channel when a token is available (config first, env fallback) and `channels.discord.enabled` is not `false`.
- If you prefer env vars, set `DISCORD_BOT_TOKEN` (a config block is optional).
5. Direct chats: use `user:<id>` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session. Bare numeric IDs are ambiguous and rejected.
6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default and can be set per guild or per channel.
7. Direct chats: secure by default via `channels.discord.dm.policy` (default: `"pairing"`). Unknown senders get a pairing code (expires after 1 hour); approve via `openclaw pairing approve discord <code>`.
- To keep old “open to anyone” behavior: set `channels.discord.dm.policy="open"` and `channels.discord.dm.allowFrom=["*"]`.
- To hard-allowlist: set `channels.discord.dm.policy="allowlist"` and list senders in `channels.discord.dm.allowFrom`.
- To ignore all DMs: set `channels.discord.dm.enabled=false` or `channels.discord.dm.policy="disabled"`.
8. Group DMs are ignored by default; enable via `channels.discord.dm.groupEnabled` and optionally restrict by `channels.discord.dm.groupChannels`.
9. Optional guild rules: set `channels.discord.guilds` keyed by guild id (preferred) or slug, with per-channel rules.
10. Optional native commands: `commands.native` defaults to `"auto"` (on for Discord/Telegram, off for Slack). Override with `channels.discord.commands.native: true|false|"auto"`; `false` clears previously registered commands. Text commands are controlled by `commands.text` and must be sent as standalone `/...` messages. Use `commands.useAccessGroups: false` to bypass access-group checks for commands.
- Full command list + config: [Slash commands](/tools/slash-commands)
11. Optional guild context history: set `channels.discord.historyLimit` (default 20, falls back to `messages.groupChat.historyLimit`) to include the last N guild messages as context when replying to a mention. Set `0` to disable.
12. Reactions: the agent can trigger reactions via the `discord` tool (gated by `channels.discord.actions.*`).
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
- The `discord` tool is only exposed when the current channel is Discord.
13. Native commands use isolated session keys (`agent:<agentId>:discord:slash:<userId>`) rather than the shared `main` session.
Note: Name → id resolution uses guild member search and requires Server Members Intent; if the bot cant search members, use ids or `<@id>` mentions.
Note: Slugs are lowercase with spaces replaced by `-`. Channel names are slugged without the leading `#`.
Note: Guild context `[from:]` lines include `author.tag` + `id` to make ping-ready replies easy.
## Config writes
By default, Discord is allowed to write config updates triggered by `/config set|unset` (requires `commands.config: true`).
Disable with:
```json5
{
channels: { discord: { configWrites: false } },
}
```bash
DISCORD_BOT_TOKEN=...
```
## How to create your own bot
</Step>
This is the “Discord Developer Portal” setup for running OpenClaw in a server (guild) channel like `#help`.
<Step title="Invite the bot and start gateway">
Invite the bot to your server with message permissions.
### 1) Create the Discord app + bot user
```bash
openclaw gateway
```
1. Discord Developer Portal → **Applications****New Application**
2. In your app:
- **Bot** → **Add Bot**
- Copy the **Bot Token** (this is what you put in `DISCORD_BOT_TOKEN`)
</Step>
### 2) Enable the gateway intents OpenClaw needs
<Step title="Approve first DM pairing">
Discord blocks “privileged intents” unless you explicitly enable them.
```bash
openclaw pairing list discord
openclaw pairing approve discord <CODE>
```
In **Bot****Privileged Gateway Intents**, enable:
Pairing codes expire after 1 hour.
- **Message Content Intent** (required to read message text in most guilds; without it youll see “Used disallowed intents” or the bot will connect but not react to messages)
- **Server Members Intent** (recommended; required for some member/user lookups and allowlist matching in guilds)
</Step>
</Steps>
You usually do **not** need **Presence Intent**. Setting the bot's own presence (`setPresence` action) uses gateway OP3 and does not require this intent; it is only needed if you want to receive presence updates about other guild members.
<Note>
Token resolution is account-aware. Config token values win over env fallback. `DISCORD_BOT_TOKEN` is only used for the default account.
</Note>
### 3) Generate an invite URL (OAuth2 URL Generator)
## Runtime model
In your app: **OAuth2****URL Generator**
- Gateway owns the Discord connection.
- Reply routing is deterministic: Discord inbound replies back to Discord.
- By default (`session.dmScope=main`), direct chats share the agent main session (`agent:main:main`).
- Guild channels are isolated session keys (`agent:<agentId>:discord:channel:<channelId>`).
- Group DMs are ignored by default (`channels.discord.dm.groupEnabled=false`).
- Native slash commands run in isolated command sessions (`agent:<agentId>:discord:slash:<userId>`), while still carrying `CommandTargetSessionKey` to the routed conversation session.
**Scopes**
## Access control and routing
-`bot`
-`applications.commands` (required for native commands)
<Tabs>
<Tab title="DM policy">
`channels.discord.dm.policy` controls DM access:
**Bot Permissions** (minimal baseline)
- `pairing` (default)
- `allowlist`
- `open` (requires `channels.discord.dm.allowFrom` to include `"*"`)
- `disabled`
- ✅ View Channels
- ✅ Send Messages
- ✅ Read Message History
- ✅ Embed Links
- ✅ Attach Files
- ✅ Add Reactions (optional but recommended)
- ✅ Use External Emojis / Stickers (optional; only if you want them)
If DM policy is not open, unknown users are blocked (or prompted for pairing in `pairing` mode).
Avoid **Administrator** unless youre debugging and fully trust the bot.
DM target format for delivery:
Copy the generated URL, open it, pick your server, and install the bot.
- `user:<id>`
- `<@id>` mention
### 4) Get the ids (guild/user/channel)
Bare numeric IDs are ambiguous and rejected unless an explicit user/channel target kind is provided.
Discord uses numeric ids everywhere; OpenClaw config prefers ids.
</Tab>
1. Discord (desktop/web) → **User Settings****Advanced** → enable **Developer Mode**
2. Right-click:
- Server name → **Copy Server ID** (guild id)
- Channel (e.g. `#help`) → **Copy Channel ID**
- Your user → **Copy User ID**
<Tab title="Guild policy">
Guild handling is controlled by `channels.discord.groupPolicy`:
### 5) Configure OpenClaw
- `open`
- `allowlist`
- `disabled`
#### Token
Secure baseline when `channels.discord` exists is `allowlist`.
Set the bot token via env var (recommended on servers):
`allowlist` behavior:
- `DISCORD_BOT_TOKEN=...`
- guild must match `channels.discord.guilds` (`id` preferred, slug accepted)
- if a guild has `channels` configured, non-listed channels are denied
- if a guild has no `channels` block, all channels in that allowlisted guild are allowed
Or via config:
Example:
```json5
{
channels: {
discord: {
enabled: true,
token: "YOUR_BOT_TOKEN",
},
},
}
```
Multi-account support: use `channels.discord.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
#### Allowlist + channel routing
Example “single server, only allow me, only allow #help”:
```json5
{
channels: {
discord: {
enabled: true,
dm: { enabled: false },
groupPolicy: "allowlist",
guilds: {
YOUR_GUILD_ID: {
users: ["YOUR_USER_ID"],
"123456789012345678": {
requireMention: true,
users: ["987654321098765432"],
channels: {
general: { allow: true },
help: { allow: true, requireMention: true },
},
},
},
retry: {
attempts: 3,
minDelayMs: 500,
maxDelayMs: 30000,
jitter: 0.1,
},
},
},
}
```
Notes:
If you only set `DISCORD_BOT_TOKEN` and do not create a `channels.discord` block, runtime fallback is `groupPolicy="open"` (with a warning in logs).
- `requireMention: true` means the bot only replies when mentioned (recommended for shared channels).
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions for guild messages.
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
- If `channels` is present, any channel not listed is denied by default.
- Use a `"*"` channel entry to apply defaults across all channels; explicit channel entries override the wildcard.
- Threads inherit parent channel config (allowlist, `requireMention`, skills, prompts, etc.) unless you add the thread channel id explicitly.
- Owner hint: when a per-guild or per-channel `users` allowlist matches the sender, OpenClaw treats that sender as the owner in the system prompt. For a global owner across channels, set `commands.ownerAllowFrom`.
- Bot-authored messages are ignored by default; set `channels.discord.allowBots=true` to allow them (own messages remain filtered).
- Warning: If you allow replies to other bots (`channels.discord.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.discord.guilds.*.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
</Tab>
### 6) Verify it works
<Tab title="Mentions and group DMs">
Guild messages are mention-gated by default.
1. Start the gateway.
2. In your server channel, send: `@Krill hello` (or whatever your bot name is).
3. If nothing happens: check **Troubleshooting** below.
Mention detection includes:
### Troubleshooting
- explicit bot mention
- configured mention patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`)
- implicit reply-to-bot behavior in supported cases
- First: run `openclaw doctor` and `openclaw channels status --probe` (actionable warnings + quick audits).
- **“Used disallowed intents”**: enable **Message Content Intent** (and likely **Server Members Intent**) in the Developer Portal, then restart the gateway.
- **Bot connects but never replies in a guild channel**:
- Missing **Message Content Intent**, or
- The bot lacks channel permissions (View/Send/Read History), or
- Your config requires mentions and you didnt mention it, or
- Your guild/channel allowlist denies the channel/user.
- **`requireMention: false` but still no replies**:
- `channels.discord.groupPolicy` defaults to **allowlist**; set it to `"open"` or add a guild entry under `channels.discord.guilds` (optionally list channels under `channels.discord.guilds.<id>.channels` to restrict).
- If you only set `DISCORD_BOT_TOKEN` and never create a `channels.discord` section, the runtime
defaults `groupPolicy` to `open`. Add `channels.discord.groupPolicy`,
`channels.defaults.groupPolicy`, or a guild/channel allowlist to lock it down.
- `requireMention` must live under `channels.discord.guilds` (or a specific channel). `channels.discord.requireMention` at the top level is ignored.
- **Permission audits** (`channels status --probe`) only check numeric channel IDs. If you use slugs/names as `channels.discord.guilds.*.channels` keys, the audit cant verify permissions.
- **DMs dont work**: `channels.discord.dm.enabled=false`, `channels.discord.dm.policy="disabled"`, or you havent been approved yet (`channels.discord.dm.policy="pairing"`).
- **Exec approvals in Discord**: Discord supports a **button UI** for exec approvals in DMs (Allow once / Always allow / Deny). `/approve <id> ...` is only for forwarded approvals and wont resolve Discords button prompts. If you see `❌ Failed to submit approval: Error: unknown approval id` or the UI never shows up, check:
- `channels.discord.execApprovals.enabled: true` in your config.
- Your Discord user ID is listed in `channels.discord.execApprovals.approvers` (the UI is only sent to approvers).
- Use the buttons in the DM prompt (**Allow once**, **Always allow**, **Deny**).
- See [Exec approvals](/tools/exec-approvals) and [Slash commands](/tools/slash-commands) for the broader approvals and command flow.
`requireMention` is configured per guild/channel (`channels.discord.guilds...`).
## Capabilities & limits
Group DMs:
- DMs and guild text channels (threads are treated as separate channels; voice not supported).
- Typing indicators sent best-effort; message chunking uses `channels.discord.textChunkLimit` (default 2000) and splits tall replies by line count (`channels.discord.maxLinesPerMessage`, default 17).
- Optional newline chunking: set `channels.discord.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking.
- File uploads supported up to the configured `channels.discord.mediaMaxMb` (default 8 MB).
- Mention-gated guild replies by default to avoid noisy bots.
- Reply context is injected when a message references another message (quoted content + ids).
- Native reply threading is **off by default**; enable with `channels.discord.replyToMode` and reply tags.
- default: ignored (`dm.groupEnabled=false`)
- optional allowlist via `dm.groupChannels` (channel IDs or slugs)
## Retry policy
</Tab>
</Tabs>
Outbound Discord API calls retry on rate limits (429) using Discord `retry_after` when available, with exponential backoff and jitter. Configure via `channels.discord.retry`. See [Retry policy](/concepts/retry).
## Developer Portal setup
## Config
<AccordionGroup>
<Accordion title="Create app and bot">
1. Discord Developer Portal -> **Applications** -> **New Application**
2. **Bot** -> **Add Bot**
3. Copy bot token
</Accordion>
<Accordion title="Privileged intents">
In **Bot -> Privileged Gateway Intents**, enable:
- Message Content Intent
- Server Members Intent (recommended)
Presence intent is optional and only required if you want to receive presence updates. Setting bot presence (`setPresence`) does not require enabling presence updates for members.
</Accordion>
<Accordion title="OAuth scopes and baseline permissions">
OAuth URL generator:
- scopes: `bot`, `applications.commands`
Typical baseline permissions:
- View Channels
- Send Messages
- Read Message History
- Embed Links
- Attach Files
- Add Reactions (optional)
Avoid `Administrator` unless explicitly needed.
</Accordion>
<Accordion title="Copy IDs">
Enable Discord Developer Mode, then copy:
- server ID
- channel ID
- user ID
Prefer numeric IDs in OpenClaw config for reliable audits and probes.
</Accordion>
</AccordionGroup>
## Native commands and command auth
- `commands.native` defaults to `"auto"` and is enabled for Discord.
- Per-channel override: `channels.discord.commands.native`.
- `commands.native=false` explicitly clears previously registered Discord native commands.
- Native command auth uses the same Discord allowlists/policies as normal message handling.
- Commands may still be visible in Discord UI for users who are not authorized; execution still enforces OpenClaw auth and returns "not authorized".
See [Slash commands](/tools/slash-commands) for command catalog and behavior.
## Feature details
<AccordionGroup>
<Accordion title="Reply tags and native replies">
Discord supports reply tags in agent output:
- `[[reply_to_current]]`
- `[[reply_to:<id>]]`
Controlled by `channels.discord.replyToMode`:
- `off` (default)
- `first`
- `all`
Message IDs are surfaced in context/history so agents can target specific messages.
</Accordion>
<Accordion title="History, context, and thread behavior">
Guild history context:
- `channels.discord.historyLimit` default `20`
- fallback: `messages.groupChat.historyLimit`
- `0` disables
DM history controls:
- `channels.discord.dmHistoryLimit`
- `channels.discord.dms["<user_id>"].historyLimit`
Thread behavior:
- Discord threads are routed as channel sessions
- parent thread metadata can be used for parent-session linkage
- thread config inherits parent channel config unless a thread-specific entry exists
Channel topics are injected as **untrusted** context (not as system prompt).
</Accordion>
<Accordion title="Reaction notifications">
Per-guild reaction notification mode:
- `off`
- `own` (default)
- `all`
- `allowlist` (uses `guilds.<id>.users`)
Reaction events are turned into system events and attached to the routed Discord session.
</Accordion>
<Accordion title="Config writes">
Channel-initiated config writes are enabled by default.
This affects `/config set|unset` flows (when command features are enabled).
Disable:
```json5
{
channels: {
discord: {
enabled: true,
token: "abc.123",
groupPolicy: "allowlist",
guilds: {
"*": {
channels: {
general: { allow: true },
},
},
},
mediaMaxMb: 8,
actions: {
reactions: true,
stickers: true,
emojiUploads: true,
stickerUploads: true,
polls: true,
permissions: true,
messages: true,
threads: true,
pins: true,
search: true,
memberInfo: true,
roleInfo: true,
roles: false,
channelInfo: true,
channels: true,
voiceStatus: true,
events: true,
moderation: false,
presence: false,
},
replyToMode: "off",
dm: {
enabled: true,
policy: "pairing", // pairing | allowlist | open | disabled
allowFrom: ["123456789012345678", "steipete"],
groupEnabled: false,
groupChannels: ["openclaw-dm"],
},
guilds: {
"*": { requireMention: true },
"123456789012345678": {
slug: "friends-of-openclaw",
requireMention: false,
reactionNotifications: "own",
users: ["987654321098765432", "steipete"],
channels: {
general: { allow: true },
help: {
allow: true,
requireMention: true,
users: ["987654321098765432"],
skills: ["search", "docs"],
systemPrompt: "Keep answers short.",
},
},
},
},
configWrites: false,
},
},
}
```
Ack reactions are controlled globally via `messages.ackReaction` +
`messages.ackReactionScope`. Use `messages.removeAckAfterReply` to clear the
ack reaction after the bot replies.
</Accordion>
- `dm.enabled`: set `false` to ignore all DMs (default `true`).
- `dm.policy`: DM access control (`pairing` recommended). `"open"` requires `dm.allowFrom=["*"]`.
- `dm.allowFrom`: DM allowlist (user ids or names). Used by `dm.policy="allowlist"` and for `dm.policy="open"` validation. The wizard accepts usernames and resolves them to ids when the bot can search members.
- `dm.groupEnabled`: enable group DMs (default `false`).
- `dm.groupChannels`: optional allowlist for group DM channel ids or slugs.
- `groupPolicy`: controls guild channel handling (`open|disabled|allowlist`); `allowlist` requires channel allowlists.
- `guilds`: per-guild rules keyed by guild id (preferred) or slug.
- `guilds."*"`: default per-guild settings applied when no explicit entry exists.
- `guilds.<id>.slug`: optional friendly slug used for display names.
- `guilds.<id>.users`: optional per-guild user allowlist (ids or names).
- `guilds.<id>.tools`: optional per-guild tool policy overrides (`allow`/`deny`/`alsoAllow`) used when the channel override is missing.
- `guilds.<id>.toolsBySender`: optional per-sender tool policy overrides at the guild level (applies when the channel override is missing; `"*"` wildcard supported).
- `guilds.<id>.channels.<channel>.allow`: allow/deny the channel when `groupPolicy="allowlist"`.
- `guilds.<id>.channels.<channel>.requireMention`: mention gating for the channel.
- `guilds.<id>.channels.<channel>.tools`: optional per-channel tool policy overrides (`allow`/`deny`/`alsoAllow`).
- `guilds.<id>.channels.<channel>.toolsBySender`: optional per-sender tool policy overrides within the channel (`"*"` wildcard supported).
- `guilds.<id>.channels.<channel>.users`: optional per-channel user allowlist.
- `guilds.<id>.channels.<channel>.skills`: skill filter (omit = all skills, empty = none).
- `guilds.<id>.channels.<channel>.systemPrompt`: extra system prompt for the channel. Discord channel topics are injected as **untrusted** context (not system prompt).
- `guilds.<id>.channels.<channel>.enabled`: set `false` to disable the channel.
- `guilds.<id>.channels`: channel rules (keys are channel slugs or ids).
- `guilds.<id>.requireMention`: per-guild mention requirement (overridable per channel).
- `guilds.<id>.reactionNotifications`: reaction system event mode (`off`, `own`, `all`, `allowlist`).
- `textChunkLimit`: outbound text chunk size (chars). Default: 2000.
- `chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on blank lines (paragraph boundaries) before length chunking.
- `maxLinesPerMessage`: soft max line count per message. Default: 17.
- `mediaMaxMb`: clamp inbound media saved to disk.
- `historyLimit`: number of recent guild messages to include as context when replying to a mention (default 20; falls back to `messages.groupChat.historyLimit`; `0` disables).
- `dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `dms["<user_id>"].historyLimit`.
- `retry`: retry policy for outbound Discord API calls (attempts, minDelayMs, maxDelayMs, jitter).
- `pluralkit`: resolve PluralKit proxied messages so system members appear as distinct senders.
- `actions`: per-action tool gates; omit to allow all (set `false` to disable).
- `reactions` (covers react + read reactions)
- `stickers`, `emojiUploads`, `stickerUploads`, `polls`, `permissions`, `messages`, `threads`, `pins`, `search`
- `memberInfo`, `roleInfo`, `channelInfo`, `voiceStatus`, `events`
- `channels` (create/edit/delete channels + categories + permissions)
- `roles` (role add/remove, default `false`)
- `moderation` (timeout/kick/ban, default `false`)
- `presence` (bot status/activity, default `false`)
- `execApprovals`: Discord-only exec approval DMs (button UI). Supports `enabled`, `approvers`, `agentFilter`, `sessionFilter`, `cleanupAfterResolve`.
Reaction notifications use `guilds.<id>.reactionNotifications`:
- `off`: no reaction events.
- `own`: reactions on the bot's own messages (default).
- `all`: all reactions on all messages.
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).
### PluralKit (PK) support
Enable PK lookups so proxied messages resolve to the underlying system + member.
When enabled, OpenClaw uses the member identity for allowlists and labels the
sender as `Member (PK:System)` to avoid accidental Discord pings.
<Accordion title="PluralKit support">
Enable PluralKit resolution to map proxied messages to system member identity:
```json5
{
@@ -377,100 +311,146 @@ sender as `Member (PK:System)` to avoid accidental Discord pings.
discord: {
pluralkit: {
enabled: true,
token: "pk_live_...", // optional; required for private systems
token: "pk_live_...", // optional; needed for private systems
},
},
},
}
```
Allowlist notes (PK-enabled):
Notes:
- Use `pk:<memberId>` in `dm.allowFrom`, `guilds.<id>.users`, or per-channel `users`.
- Member display names are also matched by name/slug.
- Lookups use the **original** Discord message ID (the pre-proxy message), so
the PK API only resolves it within its 30-minute window.
- If PK lookups fail (e.g., private system without a token), proxied messages
are treated as bot messages and are dropped unless `channels.discord.allowBots=true`.
- allowlists can use `pk:<memberId>`
- member display names are matched by name/slug
- lookups use original message ID and are time-window constrained
- if lookup fails, proxied messages are treated as bot messages and dropped unless `allowBots=true`
### Tool action defaults
</Accordion>
| Action group | Default | Notes |
| -------------- | -------- | ---------------------------------- |
| reactions | enabled | React + list reactions + emojiList |
| stickers | enabled | Send stickers |
| emojiUploads | enabled | Upload emojis |
| stickerUploads | enabled | Upload stickers |
| polls | enabled | Create polls |
| permissions | enabled | Channel permission snapshot |
| messages | enabled | Read/send/edit/delete |
| threads | enabled | Create/list/reply |
| pins | enabled | Pin/unpin/list |
| search | enabled | Message search (preview feature) |
| memberInfo | enabled | Member info |
| roleInfo | enabled | Role list |
| channelInfo | enabled | Channel info + list |
| channels | enabled | Channel/category management |
| voiceStatus | enabled | Voice state lookup |
| events | enabled | List/create scheduled events |
| roles | disabled | Role add/remove |
| moderation | disabled | Timeout/kick/ban |
| presence | disabled | Bot status/activity (setPresence) |
<Accordion title="Exec approvals in Discord">
Discord supports button-based exec approvals in DMs.
- `replyToMode`: `off` (default), `first`, or `all`. Applies only when the model includes a reply tag.
Config path:
## Reply tags
- `channels.discord.execApprovals.enabled`
- `channels.discord.execApprovals.approvers`
- `agentFilter`, `sessionFilter`, `cleanupAfterResolve`
To request a threaded reply, the model can include one tag in its output:
If approvals fail with unknown approval IDs, verify approver list and feature enablement.
- `[[reply_to_current]]` — reply to the triggering Discord message.
- `[[reply_to:<id>]]` — reply to a specific message id from context/history.
Current message ids are appended to prompts as `[message_id: …]`; history entries already include ids.
Related docs: [Exec approvals](/tools/exec-approvals)
Behavior is controlled by `channels.discord.replyToMode`:
</Accordion>
</AccordionGroup>
- `off`: ignore tags.
- `first`: only the first outbound chunk/attachment is a reply.
- `all`: every outbound chunk/attachment is a reply.
## Tools and action gates
Allowlist matching notes:
Discord message actions include messaging, channel admin, moderation, presence, and metadata actions.
- `allowFrom`/`users`/`groupChannels` accept ids, names, tags, or mentions like `<@id>`.
- Prefixes like `discord:`/`user:` (users) and `channel:` (group DMs) are supported.
- Use `*` to allow any sender/channel.
- When `guilds.<id>.channels` is present, channels not listed are denied by default.
- When `guilds.<id>.channels` is omitted, all channels in the allowlisted guild are allowed.
- To allow **no channels**, set `channels.discord.groupPolicy: "disabled"` (or keep an empty allowlist).
- The configure wizard accepts `Guild/Channel` names (public + private) and resolves them to IDs when possible.
- On startup, OpenClaw resolves channel/user names in allowlists to IDs (when the bot can search members)
and logs the mapping; unresolved entries are kept as typed.
Core examples:
Native command notes:
- messaging: `sendMessage`, `readMessages`, `editMessage`, `deleteMessage`, `threadReply`
- reactions: `react`, `reactions`, `emojiList`
- moderation: `timeout`, `kick`, `ban`
- presence: `setPresence`
- The registered commands mirror OpenClaws chat commands.
- Native commands honor the same allowlists as DMs/guild messages (`channels.discord.dm.allowFrom`, `channels.discord.guilds`, per-channel rules).
- Slash commands may still be visible in Discord UI to users who arent allowlisted; OpenClaw enforces allowlists on execution and replies “not authorized”.
Action gates live under `channels.discord.actions.*`.
## Tool actions
Default gate behavior:
The agent can call `discord` with actions like:
| Action group | Default |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- |
| reactions, messages, threads, pins, polls, search, memberInfo, roleInfo, channelInfo, channels, voiceStatus, events, stickers, emojiUploads, stickerUploads, permissions | enabled |
| roles | disabled |
| moderation | disabled |
| presence | disabled |
- `react` / `reactions` (add or list reactions)
- `sticker`, `poll`, `permissions`
- `readMessages`, `sendMessage`, `editMessage`, `deleteMessage`
- Read/search/pin tool payloads include normalized `timestampMs` (UTC epoch ms) and `timestampUtc` alongside raw Discord `timestamp`.
- `threadCreate`, `threadList`, `threadReply`
- `pinMessage`, `unpinMessage`, `listPins`
- `searchMessages`, `memberInfo`, `roleInfo`, `roleAdd`, `roleRemove`, `emojiList`
- `channelInfo`, `channelList`, `voiceStatus`, `eventList`, `eventCreate`
- `timeout`, `kick`, `ban`
- `setPresence` (bot activity and online status)
## Troubleshooting
Discord message ids are surfaced in the injected context (`[discord message id: …]` and history lines) so the agent can target them.
Emoji can be unicode (e.g., `✅`) or custom emoji syntax like `<:party_blob:1234567890>`.
<AccordionGroup>
<Accordion title="Used disallowed intents or bot sees no guild messages">
## Safety & ops
- enable Message Content Intent
- enable Server Members Intent when you depend on user/member resolution
- restart gateway after changing intents
- Treat the bot token like a password; prefer the `DISCORD_BOT_TOKEN` env var on supervised hosts or lock down the config file permissions.
- Only grant the bot permissions it needs (typically Read/Send Messages).
- If the bot is stuck or rate limited, restart the gateway (`openclaw gateway --force`) after confirming no other processes own the Discord session.
</Accordion>
<Accordion title="Guild messages blocked unexpectedly">
- verify `groupPolicy`
- verify guild allowlist under `channels.discord.guilds`
- if guild `channels` map exists, only listed channels are allowed
- verify `requireMention` behavior and mention patterns
Useful checks:
```bash
openclaw doctor
openclaw channels status --probe
openclaw logs --follow
```
</Accordion>
<Accordion title="Require mention false but still blocked">
Common causes:
- `groupPolicy="allowlist"` without matching guild/channel allowlist
- `requireMention` configured in the wrong place (must be under `channels.discord.guilds` or channel entry)
- sender blocked by guild/channel `users` allowlist
</Accordion>
<Accordion title="Permissions audit mismatches">
`channels status --probe` permission checks only work for numeric channel IDs.
If you use slug keys, runtime matching can still work, but probe cannot fully verify permissions.
</Accordion>
<Accordion title="DM and pairing issues">
- DM disabled: `channels.discord.dm.enabled=false`
- DM policy disabled: `channels.discord.dm.policy="disabled"`
- awaiting pairing approval in `pairing` mode
</Accordion>
<Accordion title="Bot to bot loops">
By default bot-authored messages are ignored.
If you set `channels.discord.allowBots=true`, use strict mention and allowlist rules to avoid loop behavior.
</Accordion>
</AccordionGroup>
## Configuration reference pointers
Primary reference:
- [Configuration reference - Discord](/gateway/configuration-reference#discord)
High-signal Discord fields:
- startup/auth: `enabled`, `token`, `accounts.*`, `allowBots`
- policy: `groupPolicy`, `dm.*`, `guilds.*`, `guilds.*.channels.*`
- command: `commands.native`, `commands.useAccessGroups`, `configWrites`
- reply/history: `replyToMode`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit`
- delivery: `textChunkLimit`, `chunkMode`, `maxLinesPerMessage`
- media/retry: `mediaMaxMb`, `retry`
- actions: `actions.*`
- features: `pluralkit`, `execApprovals`, `intents`, `agentComponents`, `heartbeat`, `responsePrefix`
## Safety and operations
- Treat bot tokens as secrets (`DISCORD_BOT_TOKEN` preferred in supervised environments).
- Grant least-privilege Discord permissions.
- If command deploy/state is stale, restart gateway and re-check with `openclaw channels status --probe`.
## Related
- [Pairing](/channels/pairing)
- [Channel routing](/channels/channel-routing)
- [Troubleshooting](/channels/troubleshooting)
- [Slash commands](/tools/slash-commands)

View File

@@ -3,26 +3,46 @@ summary: "Legacy iMessage support via imsg (JSON-RPC over stdio). New setups sho
read_when:
- Setting up iMessage support
- Debugging iMessage send/receive
title: iMessage
title: "iMessage"
---
# iMessage (legacy: imsg)
> **Recommended:** Use [BlueBubbles](/channels/bluebubbles) for new iMessage setups.
>
> The `imsg` channel is a legacy external-CLI integration and may be removed in a future release.
<Warning>
For new iMessage deployments, use <a href="/channels/bluebubbles">BlueBubbles</a>.
Status: legacy external CLI integration. Gateway spawns `imsg rpc` (JSON-RPC over stdio).
The `imsg` integration is legacy and may be removed in a future release.
</Warning>
## Quick setup (beginner)
Status: legacy external CLI integration. Gateway spawns `imsg rpc` and communicates over JSON-RPC on stdio (no separate daemon/port).
1. Ensure Messages is signed in on this Mac.
2. Install `imsg`:
- `brew install steipete/tap/imsg`
3. Configure OpenClaw with `channels.imessage.cliPath` and `channels.imessage.dbPath`.
4. Start the gateway and approve any macOS prompts (Automation + Full Disk Access).
<CardGroup cols={3}>
<Card title="BlueBubbles (recommended)" icon="message-circle" href="/channels/bluebubbles">
Preferred iMessage path for new setups.
</Card>
<Card title="Pairing" icon="link" href="/channels/pairing">
iMessage DMs default to pairing mode.
</Card>
<Card title="Configuration reference" icon="settings" href="/gateway/configuration-reference#imessage">
Full iMessage field reference.
</Card>
</CardGroup>
Minimal config:
## Quick setup
<Tabs>
<Tab title="Local Mac (fast path)">
<Steps>
<Step title="Install and verify imsg">
```bash
brew install steipete/tap/imsg
imsg rpc --help
```
</Step>
<Step title="Configure OpenClaw">
```json5
{
@@ -36,45 +56,65 @@ Minimal config:
}
```
## What it is
</Step>
- iMessage channel backed by `imsg` on macOS.
- Deterministic routing: replies always go back to iMessage.
- DMs share the agent's main session; groups are isolated (`agent:<agentId>:imessage:group:<chat_id>`).
- If a multi-participant thread arrives with `is_group=false`, you can still isolate it by `chat_id` using `channels.imessage.groups` (see “Group-ish threads” below).
<Step title="Start gateway">
## Config writes
```bash
openclaw gateway
```
By default, iMessage is allowed to write config updates triggered by `/config set|unset` (requires `commands.config: true`).
</Step>
Disable with:
<Step title="Approve first DM pairing (default dmPolicy)">
```bash
openclaw pairing list imessage
openclaw pairing approve imessage <CODE>
```
Pairing requests expire after 1 hour.
</Step>
</Steps>
</Tab>
<Tab title="Remote Mac over SSH">
OpenClaw only requires a stdio-compatible `cliPath`, so you can point `cliPath` at a wrapper script that SSHes to a remote Mac and runs `imsg`.
```bash
#!/usr/bin/env bash
exec ssh -T gateway-host imsg "$@"
```
Recommended config when attachments are enabled:
```json5
{
channels: { imessage: { configWrites: false } },
channels: {
imessage: {
enabled: true,
cliPath: "~/.openclaw/scripts/imsg-ssh",
remoteHost: "user@gateway-host", // used for SCP attachment fetches
includeAttachments: true,
},
},
}
```
## Requirements
If `remoteHost` is not set, OpenClaw attempts to auto-detect it by parsing the SSH wrapper script.
- macOS with Messages signed in.
- Full Disk Access for OpenClaw + `imsg` (Messages DB access).
- Automation permission when sending.
- `channels.imessage.cliPath` can point to any command that proxies stdin/stdout (for example, a wrapper script that SSHes to another Mac and runs `imsg rpc`).
</Tab>
</Tabs>
## Troubleshooting macOS Privacy and Security TCC
## Requirements and permissions (macOS)
If sending/receiving fails (for example, `imsg rpc` exits non-zero, times out, or the gateway appears to hang), a common cause is a macOS permission prompt that was never approved.
- Messages must be signed in on the Mac running `imsg`.
- Full Disk Access is required for the process context running OpenClaw/`imsg` (Messages DB access).
- Automation permission is required to send messages through Messages.app.
macOS grants TCC permissions per app/process context. Approve prompts in the same context that runs `imsg` (for example, Terminal/iTerm, a LaunchAgent session, or an SSH-launched process).
Checklist:
- **Full Disk Access**: allow access for the process running OpenClaw (and any shell/SSH wrapper that executes `imsg`). This is required to read the Messages database (`chat.db`).
- **Automation → Messages**: allow the process running OpenClaw (and/or your terminal) to control **Messages.app** for outbound sends.
- **`imsg` CLI health**: verify `imsg` is installed and supports RPC (`imsg rpc --help`).
Tip: If OpenClaw is running headless (LaunchAgent/systemd/SSH) the macOS prompt can be easy to miss. Run a one-time interactive command in a GUI terminal to force the prompt, then retry:
<Tip>
Permissions are granted per process context. If gateway runs headless (LaunchAgent/SSH), run a one-time interactive command in that same context to trigger prompts:
```bash
imsg chats --limit 1
@@ -82,128 +122,87 @@ imsg chats --limit 1
imsg send <handle> "test"
```
Related macOS folder permissions (Desktop/Documents/Downloads): [/platforms/mac/permissions](/platforms/mac/permissions).
</Tip>
## Setup (fast path)
## Access control and routing
1. Ensure Messages is signed in on this Mac.
2. Configure iMessage and start the gateway.
<Tabs>
<Tab title="DM policy">
`channels.imessage.dmPolicy` controls direct messages:
### Dedicated bot macOS user (for isolated identity)
- `pairing` (default)
- `allowlist`
- `open` (requires `allowFrom` to include `"*"`)
- `disabled`
If you want the bot to send from a **separate iMessage identity** (and keep your personal Messages clean), use a dedicated Apple ID + a dedicated macOS user.
Allowlist field: `channels.imessage.allowFrom`.
1. Create a dedicated Apple ID (example: `my-cool-bot@icloud.com`).
- Apple may require a phone number for verification / 2FA.
2. Create a macOS user (example: `openclawhome`) and sign into it.
3. Open Messages in that macOS user and sign into iMessage using the bot Apple ID.
4. Enable Remote Login (System Settings → General → Sharing → Remote Login).
5. Install `imsg`:
- `brew install steipete/tap/imsg`
6. Set up SSH so `ssh <bot-macos-user>@localhost true` works without a password.
7. Point `channels.imessage.accounts.bot.cliPath` at an SSH wrapper that runs `imsg` as the bot user.
Allowlist entries can be handles or chat targets (`chat_id:*`, `chat_guid:*`, `chat_identifier:*`).
First-run note: sending/receiving may require GUI approvals (Automation + Full Disk Access) in the _bot macOS user_. If `imsg rpc` looks stuck or exits, log into that user (Screen Sharing helps), run a one-time `imsg chats --limit 1` / `imsg send ...`, approve prompts, then retry. See [Troubleshooting macOS Privacy and Security TCC](#troubleshooting-macos-privacy-and-security-tcc).
</Tab>
Example wrapper (`chmod +x`). Replace `<bot-macos-user>` with your actual macOS username:
<Tab title="Group policy + mentions">
`channels.imessage.groupPolicy` controls group handling:
```bash
#!/usr/bin/env bash
set -euo pipefail
- `allowlist` (default when configured)
- `open`
- `disabled`
# Run an interactive SSH once first to accept host keys:
# ssh <bot-macos-user>@localhost true
exec /usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=5 -T <bot-macos-user>@localhost \
"/usr/local/bin/imsg" "$@"
```
Group sender allowlist: `channels.imessage.groupAllowFrom`.
Example config:
Runtime fallback: if `groupAllowFrom` is unset, iMessage group sender checks fall back to `allowFrom` when available.
```json5
{
channels: {
imessage: {
enabled: true,
accounts: {
bot: {
name: "Bot",
enabled: true,
cliPath: "/path/to/imsg-bot",
dbPath: "/Users/<bot-macos-user>/Library/Messages/chat.db",
},
},
},
},
}
```
Mention gating for groups:
For single-account setups, use flat options (`channels.imessage.cliPath`, `channels.imessage.dbPath`) instead of the `accounts` map.
- iMessage has no native mention metadata
- mention detection uses regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`)
- with no configured patterns, mention gating cannot be enforced
### Remote/SSH variant (optional)
Control commands from authorized senders can bypass mention gating in groups.
If you want iMessage on another Mac, set `channels.imessage.cliPath` to a wrapper that runs `imsg` on the remote macOS host over SSH. OpenClaw only needs stdio.
</Tab>
Example wrapper:
<Tab title="Sessions and deterministic replies">
- DMs use direct routing; groups use group routing.
- With default `session.dmScope=main`, iMessage DMs collapse into the agent main session.
- Group sessions are isolated (`agent:<agentId>:imessage:group:<chat_id>`).
- Replies route back to iMessage using originating channel/target metadata.
```bash
#!/usr/bin/env bash
exec ssh -T gateway-host imsg "$@"
```
Group-ish thread behavior:
**Remote attachments:** When `cliPath` points to a remote host via SSH, attachment paths in the Messages database reference files on the remote machine. OpenClaw can automatically fetch these over SCP by setting `channels.imessage.remoteHost`:
Some multi-participant iMessage threads can arrive with `is_group=false`.
If that `chat_id` is explicitly configured under `channels.imessage.groups`, OpenClaw treats it as group traffic (group gating + group session isolation).
```json5
{
channels: {
imessage: {
cliPath: "~/imsg-ssh", // SSH wrapper to remote Mac
remoteHost: "user@gateway-host", // for SCP file transfer
includeAttachments: true,
},
},
}
```
</Tab>
</Tabs>
If `remoteHost` is not set, OpenClaw attempts to auto-detect it by parsing the SSH command in your wrapper script. Explicit configuration is recommended for reliability.
## Deployment patterns
#### Remote Mac via Tailscale (example)
<AccordionGroup>
<Accordion title="Dedicated bot macOS user (separate iMessage identity)">
Use a dedicated Apple ID and macOS user so bot traffic is isolated from your personal Messages profile.
If the Gateway runs on a Linux host/VM but iMessage must run on a Mac, Tailscale is the simplest bridge: the Gateway talks to the Mac over the tailnet, runs `imsg` via SSH, and SCPs attachments back.
Typical flow:
Architecture:
1. Create/sign in a dedicated macOS user.
2. Sign into Messages with the bot Apple ID in that user.
3. Install `imsg` in that user.
4. Create SSH wrapper so OpenClaw can run `imsg` in that user context.
5. Point `channels.imessage.accounts.<id>.cliPath` and `.dbPath` to that user profile.
```mermaid
%%{init: {
'theme': 'base',
'themeVariables': {
'primaryColor': '#ffffff',
'primaryTextColor': '#000000',
'primaryBorderColor': '#000000',
'lineColor': '#000000',
'secondaryColor': '#f9f9fb',
'tertiaryColor': '#ffffff',
'clusterBkg': '#f9f9fb',
'clusterBorder': '#000000',
'nodeBorder': '#000000',
'mainBkg': '#ffffff',
'edgeLabelBackground': '#ffffff'
}
}}%%
flowchart TB
subgraph T[" "]
subgraph Tailscale[" "]
direction LR
Gateway["<b>Gateway host (Linux/VM)<br></b><br>openclaw gateway<br>channels.imessage.cliPath"]
Mac["<b>Mac with Messages + imsg<br></b><br>Messages signed in<br>Remote Login enabled"]
end
Gateway -- SSH (imsg rpc) --> Mac
Mac -- SCP (attachments) --> Gateway
direction BT
User["user@gateway-host"] -- "Tailscale tailnet (hostname or 100.x.y.z)" --> Gateway
end
```
First run may require GUI approvals (Automation + Full Disk Access) in that bot user session.
Concrete config example (Tailscale hostname):
</Accordion>
<Accordion title="Remote Mac over Tailscale (example)">
Common topology:
- gateway runs on Linux/VM
- iMessage + `imsg` runs on a Mac in your tailnet
- `cliPath` wrapper uses SSH to run `imsg`
- `remoteHost` enables SCP attachment fetches
Example:
```json5
{
@@ -219,122 +218,134 @@ Concrete config example (Tailscale hostname):
}
```
Example wrapper (`~/.openclaw/scripts/imsg-ssh`):
```bash
#!/usr/bin/env bash
exec ssh -T bot@mac-mini.tailnet-1234.ts.net imsg "$@"
```
Notes:
Use SSH keys so both SSH and SCP are non-interactive.
- Ensure the Mac is signed in to Messages, and Remote Login is enabled.
- Use SSH keys so `ssh bot@mac-mini.tailnet-1234.ts.net` works without prompts.
- `remoteHost` should match the SSH target so SCP can fetch attachments.
</Accordion>
Multi-account support: use `channels.imessage.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. Don't commit `~/.openclaw/openclaw.json` (it often contains tokens).
<Accordion title="Multi-account pattern">
iMessage supports per-account config under `channels.imessage.accounts`.
## Access control (DMs + groups)
Each account can override fields such as `cliPath`, `dbPath`, `allowFrom`, `groupPolicy`, `mediaMaxMb`, and history settings.
DMs:
</Accordion>
</AccordionGroup>
- Default: `channels.imessage.dmPolicy = "pairing"`.
- Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour).
- Approve via:
- `openclaw pairing list imessage`
- `openclaw pairing approve imessage <CODE>`
- Pairing is the default token exchange for iMessage DMs. Details: [Pairing](/channels/pairing)
## Media, chunking, and delivery targets
Groups:
<AccordionGroup>
<Accordion title="Attachments and media">
- inbound attachment ingestion is optional: `channels.imessage.includeAttachments`
- remote attachment paths can be fetched via SCP when `remoteHost` is set
- outbound media size uses `channels.imessage.mediaMaxMb` (default 16 MB)
</Accordion>
- `channels.imessage.groupPolicy = open | allowlist | disabled`.
- `channels.imessage.groupAllowFrom` controls who can trigger in groups when `allowlist` is set.
- Mention gating uses `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) because iMessage has no native mention metadata.
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
<Accordion title="Outbound chunking">
- text chunk limit: `channels.imessage.textChunkLimit` (default 4000)
- chunk mode: `channels.imessage.chunkMode`
- `length` (default)
- `newline` (paragraph-first splitting)
</Accordion>
## How it works (behavior)
<Accordion title="Addressing formats">
Preferred explicit targets:
- `imsg` streams message events; the gateway normalizes them into the shared channel envelope.
- Replies always route back to the same chat id or handle.
- `chat_id:123` (recommended for stable routing)
- `chat_guid:...`
- `chat_identifier:...`
## Group-ish threads (`is_group=false`)
Handle targets are also supported:
Some iMessage threads can have multiple participants but still arrive with `is_group=false` depending on how Messages stores the chat identifier.
- `imessage:+1555...`
- `sms:+1555...`
- `user@example.com`
If you explicitly configure a `chat_id` under `channels.imessage.groups`, OpenClaw treats that thread as a “group” for:
```bash
imsg chats --limit 20
```
- session isolation (separate `agent:<agentId>:imessage:group:<chat_id>` session key)
- group allowlisting / mention gating behavior
</Accordion>
</AccordionGroup>
Example:
## Config writes
iMessage allows channel-initiated config writes by default (for `/config set|unset` when `commands.config: true`).
Disable:
```json5
{
channels: {
imessage: {
groupPolicy: "allowlist",
groupAllowFrom: ["+15555550123"],
groups: {
"42": { requireMention: false },
},
configWrites: false,
},
},
}
```
This is useful when you want an isolated personality/model for a specific thread (see [Multi-agent routing](/concepts/multi-agent)). For filesystem isolation, see [Sandboxing](/gateway/sandboxing).
## Troubleshooting
## Media + limits
<AccordionGroup>
<Accordion title="imsg not found or RPC unsupported">
Validate the binary and RPC support:
- Optional attachment ingestion via `channels.imessage.includeAttachments`.
- Media cap via `channels.imessage.mediaMaxMb`.
## Limits
- Outbound text is chunked to `channels.imessage.textChunkLimit` (default 4000).
- Optional newline chunking: set `channels.imessage.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking.
- Media uploads are capped by `channels.imessage.mediaMaxMb` (default 16).
## Addressing / delivery targets
Prefer `chat_id` for stable routing:
- `chat_id:123` (preferred)
- `chat_guid:...`
- `chat_identifier:...`
- direct handles: `imessage:+1555` / `sms:+1555` / `user@example.com`
List chats:
```
imsg chats --limit 20
```bash
imsg rpc --help
openclaw channels status --probe
```
## Configuration reference (iMessage)
If probe reports RPC unsupported, update `imsg`.
Full configuration: [Configuration](/gateway/configuration)
</Accordion>
Provider options:
<Accordion title="DMs are ignored">
Check:
- `channels.imessage.enabled`: enable/disable channel startup.
- `channels.imessage.cliPath`: path to `imsg`.
- `channels.imessage.dbPath`: Messages DB path.
- `channels.imessage.remoteHost`: SSH host for SCP attachment transfer when `cliPath` points to a remote Mac (e.g., `user@gateway-host`). Auto-detected from SSH wrapper if not set.
- `channels.imessage.service`: `imessage | sms | auto`.
- `channels.imessage.region`: SMS region.
- `channels.imessage.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.imessage.allowFrom`: DM allowlist (handles, emails, E.164 numbers, or `chat_id:*`). `open` requires `"*"`. iMessage has no usernames; use handles or chat targets.
- `channels.imessage.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
- `channels.imessage.groupAllowFrom`: group sender allowlist.
- `channels.imessage.historyLimit` / `channels.imessage.accounts.*.historyLimit`: max group messages to include as context (0 disables).
- `channels.imessage.dmHistoryLimit`: DM history limit in user turns. Per-user overrides: `channels.imessage.dms["<handle>"].historyLimit`.
- `channels.imessage.groups`: per-group defaults + allowlist (use `"*"` for global defaults).
- `channels.imessage.includeAttachments`: ingest attachments into context.
- `channels.imessage.mediaMaxMb`: inbound/outbound media cap (MB).
- `channels.imessage.textChunkLimit`: outbound chunk size (chars).
- `channels.imessage.chunkMode`: `length` (default) or `newline` to split on blank lines (paragraph boundaries) before length chunking.
- `channels.imessage.dmPolicy`
- `channels.imessage.allowFrom`
- pairing approvals (`openclaw pairing list imessage`)
Related global options:
</Accordion>
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`).
- `messages.responsePrefix`.
<Accordion title="Group messages are ignored">
Check:
- `channels.imessage.groupPolicy`
- `channels.imessage.groupAllowFrom`
- `channels.imessage.groups` allowlist behavior
- mention pattern configuration (`agents.list[].groupChat.mentionPatterns`)
</Accordion>
<Accordion title="Remote attachments fail">
Check:
- `channels.imessage.remoteHost`
- SSH/SCP key auth from the gateway host
- remote path readability on the Mac running Messages
</Accordion>
<Accordion title="macOS permission prompts were missed">
Re-run in an interactive GUI terminal in the same user/session context and approve prompts:
```bash
imsg chats --limit 1
imsg send <handle> "test"
```
Confirm Full Disk Access + Automation are granted for the process context that runs OpenClaw/`imsg`.
</Accordion>
</AccordionGroup>
## Configuration reference pointers
- [Configuration reference - iMessage](/gateway/configuration-reference#imessage)
- [Gateway configuration](/gateway/configuration)
- [Pairing](/channels/pairing)
- [BlueBubbles](/channels/bluebubbles)

View File

@@ -16,6 +16,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [WhatsApp](/channels/whatsapp) — Most popular; uses Baileys and requires QR pairing.
- [Telegram](/channels/telegram) — Bot API via grammY; supports groups.
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
- [IRC](/channels/irc) — Classic IRC servers; channels + DMs with pairing/allowlist controls.
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
- [Feishu](/channels/feishu) — Feishu/Lark bot via WebSocket (plugin, installed separately).
- [Google Chat](/channels/googlechat) — Google Chat API app via HTTP webhook.

234
docs/channels/irc.md Normal file
View File

@@ -0,0 +1,234 @@
---
title: IRC
description: Connect OpenClaw to IRC channels and direct messages.
---
Use IRC when you want OpenClaw in classic channels (`#room`) and direct messages.
IRC ships as an extension plugin, but it is configured in the main config under `channels.irc`.
## Quick start
1. Enable IRC config in `~/.openclaw/openclaw.json`.
2. Set at least:
```json
{
"channels": {
"irc": {
"enabled": true,
"host": "irc.libera.chat",
"port": 6697,
"tls": true,
"nick": "openclaw-bot",
"channels": ["#openclaw"]
}
}
}
```
3. Start/restart gateway:
```bash
openclaw gateway run
```
## Security defaults
- `channels.irc.dmPolicy` defaults to `"pairing"`.
- `channels.irc.groupPolicy` defaults to `"allowlist"`.
- With `groupPolicy="allowlist"`, set `channels.irc.groups` to define allowed channels.
- Use TLS (`channels.irc.tls=true`) unless you intentionally accept plaintext transport.
## Access control
There are two separate “gates” for IRC channels:
1. **Channel access** (`groupPolicy` + `groups`): whether the bot accepts messages from a channel at all.
2. **Sender access** (`groupAllowFrom` / per-channel `groups["#channel"].allowFrom`): who is allowed to trigger the bot inside that channel.
Config keys:
- DM allowlist (DM sender access): `channels.irc.allowFrom`
- Group sender allowlist (channel sender access): `channels.irc.groupAllowFrom`
- Per-channel controls (channel + sender + mention rules): `channels.irc.groups["#channel"]`
- `channels.irc.groupPolicy="open"` allows unconfigured channels (**still mention-gated by default**)
Allowlist entries can use nick or `nick!user@host` forms.
### Common gotcha: `allowFrom` is for DMs, not channels
If you see logs like:
- `irc: drop group sender alice!ident@host (policy=allowlist)`
…it means the sender wasnt allowed for **group/channel** messages. Fix it by either:
- setting `channels.irc.groupAllowFrom` (global for all channels), or
- setting per-channel sender allowlists: `channels.irc.groups["#channel"].allowFrom`
Example (allow anyone in `#tuirc-dev` to talk to the bot):
```json5
{
channels: {
irc: {
groupPolicy: "allowlist",
groups: {
"#tuirc-dev": { allowFrom: ["*"] },
},
},
},
}
```
## Reply triggering (mentions)
Even if a channel is allowed (via `groupPolicy` + `groups`) and the sender is allowed, OpenClaw defaults to **mention-gating** in group contexts.
That means you may see logs like `drop channel … (missing-mention)` unless the message includes a mention pattern that matches the bot.
To make the bot reply in an IRC channel **without needing a mention**, disable mention gating for that channel:
```json5
{
channels: {
irc: {
groupPolicy: "allowlist",
groups: {
"#tuirc-dev": {
requireMention: false,
allowFrom: ["*"],
},
},
},
},
}
```
Or to allow **all** IRC channels (no per-channel allowlist) and still reply without mentions:
```json5
{
channels: {
irc: {
groupPolicy: "open",
groups: {
"*": { requireMention: false, allowFrom: ["*"] },
},
},
},
}
```
## Security note (recommended for public channels)
If you allow `allowFrom: ["*"]` in a public channel, anyone can prompt the bot.
To reduce risk, restrict tools for that channel.
### Same tools for everyone in the channel
```json5
{
channels: {
irc: {
groups: {
"#tuirc-dev": {
allowFrom: ["*"],
tools: {
deny: ["group:runtime", "group:fs", "gateway", "nodes", "cron", "browser"],
},
},
},
},
},
}
```
### Different tools per sender (owner gets more power)
Use `toolsBySender` to apply a stricter policy to `"*"` and a looser one to your nick:
```json5
{
channels: {
irc: {
groups: {
"#tuirc-dev": {
allowFrom: ["*"],
toolsBySender: {
"*": {
deny: ["group:runtime", "group:fs", "gateway", "nodes", "cron", "browser"],
},
eigen: {
deny: ["gateway", "nodes", "cron"],
},
},
},
},
},
},
}
```
Notes:
- `toolsBySender` keys can be a nick (e.g. `"eigen"`) or a full hostmask (`"eigen!~eigen@174.127.248.171"`) for stronger identity matching.
- The first matching sender policy wins; `"*"` is the wildcard fallback.
For more on group access vs mention-gating (and how they interact), see: [/channels/groups](/channels/groups).
## NickServ
To identify with NickServ after connect:
```json
{
"channels": {
"irc": {
"nickserv": {
"enabled": true,
"service": "NickServ",
"password": "your-nickserv-password"
}
}
}
}
```
Optional one-time registration on connect:
```json
{
"channels": {
"irc": {
"nickserv": {
"register": true,
"registerEmail": "bot@example.com"
}
}
}
}
```
Disable `register` after the nick is registered to avoid repeated REGISTER attempts.
## Environment variables
Default account supports:
- `IRC_HOST`
- `IRC_PORT`
- `IRC_TLS`
- `IRC_NICK`
- `IRC_USERNAME`
- `IRC_REALNAME`
- `IRC_PASSWORD`
- `IRC_CHANNELS` (comma-separated)
- `IRC_NICKSERV_PASSWORD`
- `IRC_NICKSERV_REGISTER_EMAIL`
## Troubleshooting
- If the bot connects but never replies in channels, verify `channels.irc.groups` **and** whether mention-gating is dropping messages (`missing-mention`). If you want it to reply without pings, set `requireMention:false` for the channel.
- If login fails, verify nick availability and server password.
- If TLS fails on a custom network, verify host/port and certificate setup.

View File

@@ -1,26 +1,47 @@
---
summary: "Slack setup for socket or HTTP webhook mode"
read_when: "Setting up Slack or debugging Slack socket/HTTP mode"
summary: "Slack setup and runtime behavior (Socket Mode + HTTP Events API)"
read_when:
- Setting up Slack or debugging Slack socket/HTTP mode
title: "Slack"
---
# Slack
## Socket mode (default)
Status: production-ready for DMs + channels via Slack app integrations. Default mode is Socket Mode; HTTP Events API mode is also supported.
### Quick setup (beginner)
<CardGroup cols={3}>
<Card title="Pairing" icon="link" href="/channels/pairing">
Slack DMs default to pairing mode.
</Card>
<Card title="Slash commands" icon="terminal" href="/tools/slash-commands">
Native command behavior and command catalog.
</Card>
<Card title="Channel troubleshooting" icon="wrench" href="/channels/troubleshooting">
Cross-channel diagnostics and repair playbooks.
</Card>
</CardGroup>
1. Create a Slack app and enable **Socket Mode**.
2. Create an **App Token** (`xapp-...`) and **Bot Token** (`xoxb-...`).
3. Set tokens for OpenClaw and start the gateway.
## Quick setup
Minimal config:
<Tabs>
<Tab title="Socket Mode (default)">
<Steps>
<Step title="Create Slack app and tokens">
In Slack app settings:
- enable **Socket Mode**
- create **App Token** (`xapp-...`) with `connections:write`
- install app and copy **Bot Token** (`xoxb-...`)
</Step>
<Step title="Configure OpenClaw">
```json5
{
channels: {
slack: {
enabled: true,
mode: "socket",
appToken: "xapp-...",
botToken: "xoxb-...",
},
@@ -28,121 +49,50 @@ Minimal config:
}
```
### Setup
Env fallback (default account only):
1. Create a Slack app (From scratch) in [https://api.slack.com/apps](https://api.slack.com/apps).
2. **Socket Mode** → toggle on. Then go to **Basic Information****App-Level Tokens****Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
3. **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
4. Optional: **OAuth & Permissions** → add **User Token Scopes** (see the read-only list below). Reinstall the app and copy the **User OAuth Token** (`xoxp-...`).
5. **Event Subscriptions** → enable events and subscribe to:
- `message.*` (includes edits/deletes/thread broadcasts)
- `app_mention`
- `reaction_added`, `reaction_removed`
- `member_joined_channel`, `member_left_channel`
- `channel_rename`
- `pin_added`, `pin_removed`
6. Invite the bot to channels you want it to read.
7. Slash Commands → create `/openclaw` if you use `channels.slack.slashCommand`. If you enable native commands, add one slash command per built-in command (same names as `/help`). Native defaults to off for Slack unless you set `channels.slack.commands.native: true` (global `commands.native` is `"auto"` which leaves Slack off).
8. App Home → enable the **Messages Tab** so users can DM the bot.
Use the manifest below so scopes and events stay in sync.
Multi-account support: use `channels.slack.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
### OpenClaw config (Socket mode)
Set tokens via env vars (recommended):
- `SLACK_APP_TOKEN=xapp-...`
- `SLACK_BOT_TOKEN=xoxb-...`
Or via config:
```json5
{
channels: {
slack: {
enabled: true,
appToken: "xapp-...",
botToken: "xoxb-...",
},
},
}
```bash
SLACK_APP_TOKEN=xapp-...
SLACK_BOT_TOKEN=xoxb-...
```
### User token (optional)
</Step>
OpenClaw can use a Slack user token (`xoxp-...`) for read operations (history,
pins, reactions, emoji, member info). By default this stays read-only: reads
prefer the user token when present, and writes still use the bot token unless
you explicitly opt in. Even with `userTokenReadOnly: false`, the bot token stays
preferred for writes when it is available.
<Step title="Subscribe app events">
Subscribe bot events for:
User tokens are configured in the config file (no env var support). For
multi-account, set `channels.slack.accounts.<id>.userToken`.
- `app_mention`
- `message.channels`, `message.groups`, `message.im`, `message.mpim`
- `reaction_added`, `reaction_removed`
- `member_joined_channel`, `member_left_channel`
- `channel_rename`
- `pin_added`, `pin_removed`
Example with bot + app + user tokens:
Also enable App Home **Messages Tab** for DMs.
</Step>
```json5
{
channels: {
slack: {
enabled: true,
appToken: "xapp-...",
botToken: "xoxb-...",
userToken: "xoxp-...",
},
},
}
<Step title="Start gateway">
```bash
openclaw gateway
```
Example with userTokenReadOnly explicitly set (allow user token writes):
</Step>
</Steps>
```json5
{
channels: {
slack: {
enabled: true,
appToken: "xapp-...",
botToken: "xoxb-...",
userToken: "xoxp-...",
userTokenReadOnly: false,
},
},
}
```
</Tab>
#### Token usage
<Tab title="HTTP Events API mode">
<Steps>
<Step title="Configure Slack app for HTTP">
- Read operations (history, reactions list, pins list, emoji list, member info,
search) prefer the user token when configured, otherwise the bot token.
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
file uploads) use the bot token by default. If `userTokenReadOnly: false` and
no bot token is available, OpenClaw falls back to the user token.
- set mode to HTTP (`channels.slack.mode="http"`)
- copy Slack **Signing Secret**
- set Event Subscriptions + Interactivity + Slash command Request URL to the same webhook path (default `/slack/events`)
### History context
</Step>
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
## HTTP mode (Events API)
Use HTTP webhook mode when your Gateway is reachable by Slack over HTTPS (typical for server deployments).
HTTP mode uses the Events API + Interactivity + Slash Commands with a shared request URL.
### Setup (HTTP mode)
1. Create a Slack app and **disable Socket Mode** (optional if you only use HTTP).
2. **Basic Information** → copy the **Signing Secret**.
3. **OAuth & Permissions** → install the app and copy the **Bot User OAuth Token** (`xoxb-...`).
4. **Event Subscriptions** → enable events and set the **Request URL** to your gateway webhook path (default `/slack/events`).
5. **Interactivity & Shortcuts** → enable and set the same **Request URL**.
6. **Slash Commands** → set the same **Request URL** for your command(s).
Example request URL:
`https://gateway-host/slack/events`
### OpenClaw config (minimal)
<Step title="Configure OpenClaw HTTP mode">
```json5
{
@@ -158,13 +108,184 @@ Example request URL:
}
```
Multi-account HTTP mode: set `channels.slack.accounts.<id>.mode = "http"` and provide a unique
`webhookPath` per account so each Slack app can point to its own URL.
</Step>
### Manifest (optional)
<Step title="Use unique webhook paths for multi-account HTTP">
Per-account HTTP mode is supported.
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
user scopes if you plan to configure a user token.
Give each account a distinct `webhookPath` so registrations do not collide.
</Step>
</Steps>
</Tab>
</Tabs>
## Token model
- `botToken` + `appToken` are required for Socket Mode.
- HTTP mode requires `botToken` + `signingSecret`.
- Config tokens override env fallback.
- `SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` env fallback applies only to the default account.
- `userToken` (`xoxp-...`) is config-only (no env fallback) and defaults to read-only behavior (`userTokenReadOnly: true`).
<Tip>
For actions/directory reads, user token can be preferred when configured. For writes, bot token remains preferred; user-token writes are only allowed when `userTokenReadOnly: false` and bot token is unavailable.
</Tip>
## Access control and routing
<Tabs>
<Tab title="DM policy">
`channels.slack.dm.policy` controls DM access:
- `pairing` (default)
- `allowlist`
- `open` (requires `dm.allowFrom` to include `"*"`)
- `disabled`
DM flags:
- `dm.enabled` (default true)
- `dm.allowFrom`
- `dm.groupEnabled` (group DMs default false)
- `dm.groupChannels` (optional MPIM allowlist)
Pairing in DMs uses `openclaw pairing approve slack <code>`.
</Tab>
<Tab title="Channel policy">
`channels.slack.groupPolicy` controls channel handling:
- `open`
- `allowlist`
- `disabled`
Channel allowlist lives under `channels.slack.channels`.
Runtime note: if `channels.slack` is completely missing (env-only setup) and `channels.defaults.groupPolicy` is unset, runtime falls back to `groupPolicy="open"` and logs a warning.
Name/ID resolution:
- channel allowlist entries and DM allowlist entries are resolved at startup when token access allows
- unresolved entries are kept as configured
</Tab>
<Tab title="Mentions and channel users">
Channel messages are mention-gated by default.
Mention sources:
- explicit app mention (`<@botId>`)
- mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`)
- implicit reply-to-bot thread behavior
Per-channel controls (`channels.slack.channels.<id|name>`):
- `requireMention`
- `users` (allowlist)
- `allowBots`
- `skills`
- `systemPrompt`
- `tools`, `toolsBySender`
</Tab>
</Tabs>
## Commands and slash behavior
- Native command auto-mode is **off** for Slack (`commands.native: "auto"` does not enable Slack native commands).
- Enable native Slack command handlers with `channels.slack.commands.native: true` (or global `commands.native: true`).
- When native commands are enabled, register matching slash commands in Slack (`/<command>` names).
- If native commands are not enabled, you can run a single configured slash command via `channels.slack.slashCommand`.
Default slash command settings:
- `enabled: false`
- `name: "openclaw"`
- `sessionPrefix: "slack:slash"`
- `ephemeral: true`
Slash sessions use isolated keys:
- `agent:<agentId>:slack:slash:<userId>`
and still route command execution against the target conversation session (`CommandTargetSessionKey`).
## Threading, sessions, and reply tags
- DMs route as `direct`; channels as `channel`; MPIMs as `group`.
- With default `session.dmScope=main`, Slack DMs collapse to agent main session.
- Channel sessions: `agent:<agentId>:slack:channel:<channelId>`.
- Thread replies can create thread session suffixes (`:thread:<threadTs>`) when applicable.
- `channels.slack.thread.historyScope` default is `thread`; `thread.inheritParent` default is `false`.
Reply threading controls:
- `channels.slack.replyToMode`: `off|first|all` (default `off`)
- `channels.slack.replyToModeByChatType`: per `direct|group|channel`
- legacy fallback for direct chats: `channels.slack.dm.replyToMode`
Manual reply tags are supported:
- `[[reply_to_current]]`
- `[[reply_to:<id>]]`
## Media, chunking, and delivery
<AccordionGroup>
<Accordion title="Inbound attachments">
Slack file attachments are downloaded from Slack-hosted private URLs (token-authenticated request flow) and written to the media store when fetch succeeds and size limits permit.
Runtime inbound size cap defaults to `20MB` unless overridden by `channels.slack.mediaMaxMb`.
</Accordion>
<Accordion title="Outbound text and files">
- text chunks use `channels.slack.textChunkLimit` (default 4000)
- `channels.slack.chunkMode="newline"` enables paragraph-first splitting
- file sends use Slack upload APIs and can include thread replies (`thread_ts`)
- outbound media cap follows `channels.slack.mediaMaxMb` when configured; otherwise channel sends use MIME-kind defaults from media pipeline
</Accordion>
<Accordion title="Delivery targets">
Preferred explicit targets:
- `user:<id>` for DMs
- `channel:<id>` for channels
Slack DMs are opened via Slack conversation APIs when sending to user targets.
</Accordion>
</AccordionGroup>
## Actions and gates
Slack actions are controlled by `channels.slack.actions.*`.
Available action groups in current Slack tooling:
| Group | Default |
| ---------- | ------- |
| messages | enabled |
| reactions | enabled |
| pins | enabled |
| memberInfo | enabled |
| emojiList | enabled |
## Events and operational behavior
- Message edits/deletes/thread broadcasts are mapped into system events.
- Reaction add/remove events are mapped into system events.
- Member join/leave, channel created/renamed, and pin add/remove events are mapped into system events.
- `channel_id_changed` can migrate channel config keys when `configWrites` is enabled.
- Channel topic/purpose metadata is treated as untrusted context and can be injected into routing context.
## Manifest and scope checklist
<AccordionGroup>
<Accordion title="Slack app manifest example">
```json
{
@@ -196,14 +317,8 @@ user scopes if you plan to configure a user token.
"channels:history",
"channels:read",
"groups:history",
"groups:read",
"groups:write",
"im:history",
"im:read",
"im:write",
"mpim:history",
"mpim:read",
"mpim:write",
"users:read",
"app_mentions:read",
"reactions:read",
@@ -214,21 +329,6 @@ user scopes if you plan to configure a user token.
"commands",
"files:read",
"files:write"
],
"user": [
"channels:history",
"channels:read",
"groups:history",
"groups:read",
"im:history",
"im:read",
"mpim:history",
"mpim:read",
"users:read",
"reactions:read",
"pins:read",
"emoji:read",
"search:read"
]
}
},
@@ -254,321 +354,100 @@ user scopes if you plan to configure a user token.
}
```
If you enable native commands, add one `slash_commands` entry per command you want to expose (matching the `/help` list). Override with `channels.slack.commands.native`.
</Accordion>
## Scopes (current vs optional)
<Accordion title="Optional user-token scopes (read operations)">
If you configure `channels.slack.userToken`, typical read scopes are:
Slack's Conversations API is type-scoped: you only need the scopes for the
conversation types you actually touch (channels, groups, im, mpim). See
[https://docs.slack.dev/apis/web-api/using-the-conversations-api/](https://docs.slack.dev/apis/web-api/using-the-conversations-api/) for the overview.
- `channels:history`, `groups:history`, `im:history`, `mpim:history`
- `channels:read`, `groups:read`, `im:read`, `mpim:read`
- `users:read`
- `reactions:read`
- `pins:read`
- `emoji:read`
- `search:read` (if you depend on Slack search reads)
### Bot token scopes (required)
- `chat:write` (send/update/delete messages via `chat.postMessage`)
[https://docs.slack.dev/reference/methods/chat.postMessage](https://docs.slack.dev/reference/methods/chat.postMessage)
- `im:write` (open DMs via `conversations.open` for user DMs)
[https://docs.slack.dev/reference/methods/conversations.open](https://docs.slack.dev/reference/methods/conversations.open)
- `channels:history`, `groups:history`, `im:history`, `mpim:history`
[https://docs.slack.dev/reference/methods/conversations.history](https://docs.slack.dev/reference/methods/conversations.history)
- `channels:read`, `groups:read`, `im:read`, `mpim:read`
[https://docs.slack.dev/reference/methods/conversations.info](https://docs.slack.dev/reference/methods/conversations.info)
- `users:read` (user lookup)
[https://docs.slack.dev/reference/methods/users.info](https://docs.slack.dev/reference/methods/users.info)
- `reactions:read`, `reactions:write` (`reactions.get` / `reactions.add`)
[https://docs.slack.dev/reference/methods/reactions.get](https://docs.slack.dev/reference/methods/reactions.get)
[https://docs.slack.dev/reference/methods/reactions.add](https://docs.slack.dev/reference/methods/reactions.add)
- `pins:read`, `pins:write` (`pins.list` / `pins.add` / `pins.remove`)
[https://docs.slack.dev/reference/scopes/pins.read](https://docs.slack.dev/reference/scopes/pins.read)
[https://docs.slack.dev/reference/scopes/pins.write](https://docs.slack.dev/reference/scopes/pins.write)
- `emoji:read` (`emoji.list`)
[https://docs.slack.dev/reference/scopes/emoji.read](https://docs.slack.dev/reference/scopes/emoji.read)
- `files:write` (uploads via `files.uploadV2`)
[https://docs.slack.dev/messaging/working-with-files/#upload](https://docs.slack.dev/messaging/working-with-files/#upload)
### User token scopes (optional, read-only by default)
Add these under **User Token Scopes** if you configure `channels.slack.userToken`.
- `channels:history`, `groups:history`, `im:history`, `mpim:history`
- `channels:read`, `groups:read`, `im:read`, `mpim:read`
- `users:read`
- `reactions:read`
- `pins:read`
- `emoji:read`
- `search:read`
### Not needed today (but likely future)
- `mpim:write` (only if we add group-DM open/DM start via `conversations.open`)
- `groups:write` (only if we add private-channel management: create/rename/invite/archive)
- `chat:write.public` (only if we want to post to channels the bot isn't in)
[https://docs.slack.dev/reference/scopes/chat.write.public](https://docs.slack.dev/reference/scopes/chat.write.public)
- `users:read.email` (only if we need email fields from `users.info`)
[https://docs.slack.dev/changelog/2017-04-narrowing-email-access](https://docs.slack.dev/changelog/2017-04-narrowing-email-access)
- `files:read` (only if we start listing/reading file metadata)
## Config
Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens:
```json
{
"slack": {
"enabled": true,
"botToken": "xoxb-...",
"appToken": "xapp-...",
"groupPolicy": "allowlist",
"dm": {
"enabled": true,
"policy": "pairing",
"allowFrom": ["U123", "U456", "*"],
"groupEnabled": false,
"groupChannels": ["G123"],
"replyToMode": "all"
},
"channels": {
"C123": { "allow": true, "requireMention": true },
"#general": {
"allow": true,
"requireMention": true,
"users": ["U123"],
"skills": ["search", "docs"],
"systemPrompt": "Keep answers short."
}
},
"reactionNotifications": "own",
"reactionAllowlist": ["U123"],
"replyToMode": "off",
"actions": {
"reactions": true,
"messages": true,
"pins": true,
"memberInfo": true,
"emojiList": true
},
"slashCommand": {
"enabled": true,
"name": "openclaw",
"sessionPrefix": "slack:slash",
"ephemeral": true
},
"textChunkLimit": 4000,
"mediaMaxMb": 20
}
}
```
Tokens can also be supplied via env vars:
- `SLACK_BOT_TOKEN`
- `SLACK_APP_TOKEN`
Ack reactions are controlled globally via `messages.ackReaction` +
`messages.ackReactionScope`. Use `messages.removeAckAfterReply` to clear the
ack reaction after the bot replies.
## Limits
- Outbound text is chunked to `channels.slack.textChunkLimit` (default 4000).
- Optional newline chunking: set `channels.slack.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking.
- Media uploads are capped by `channels.slack.mediaMaxMb` (default 20).
## Reply threading
By default, OpenClaw replies in the main channel. Use `channels.slack.replyToMode` to control automatic threading:
| Mode | Behavior |
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `off` | **Default.** Reply in main channel. Only thread if the triggering message was already in a thread. |
| `first` | First reply goes to thread (under the triggering message), subsequent replies go to main channel. Useful for keeping context visible while avoiding thread clutter. |
| `all` | All replies go to thread. Keeps conversations contained but may reduce visibility. |
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
### Per-chat-type threading
You can configure different threading behavior per chat type by setting `channels.slack.replyToModeByChatType`:
```json5
{
channels: {
slack: {
replyToMode: "off", // default for channels
replyToModeByChatType: {
direct: "all", // DMs always thread
group: "first", // group DMs/MPIM thread first reply
},
},
},
}
```
Supported chat types:
- `direct`: 1:1 DMs (Slack `im`)
- `group`: group DMs / MPIMs (Slack `mpim`)
- `channel`: standard channels (public/private)
Precedence:
1. `replyToModeByChatType.<chatType>`
2. `replyToMode`
3. Provider default (`off`)
Legacy `channels.slack.dm.replyToMode` is still accepted as a fallback for `direct` when no chat-type override is set.
Examples:
Thread DMs only:
```json5
{
channels: {
slack: {
replyToMode: "off",
replyToModeByChatType: { direct: "all" },
},
},
}
```
Thread group DMs but keep channels in the root:
```json5
{
channels: {
slack: {
replyToMode: "off",
replyToModeByChatType: { group: "first" },
},
},
}
```
Make channels thread, keep DMs in the root:
```json5
{
channels: {
slack: {
replyToMode: "first",
replyToModeByChatType: { direct: "off", group: "off" },
},
},
}
```
### Manual threading tags
For fine-grained control, use these tags in agent responses:
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
- `[[reply_to:<id>]]` — reply to a specific message id.
## Sessions + routing
- DMs share the `main` session (like WhatsApp/Telegram).
- Channels map to `agent:<agentId>:slack:channel:<channelId>` sessions.
- Slash commands use `agent:<agentId>:slack:slash:<userId>` sessions (prefix configurable via `channels.slack.slashCommand.sessionPrefix`).
- If Slack doesnt provide `channel_type`, OpenClaw infers it from the channel ID prefix (`D`, `C`, `G`) and defaults to `channel` to keep session keys stable.
- Native command registration uses `commands.native` (global default `"auto"` → Slack off) and can be overridden per-workspace with `channels.slack.commands.native`. Text commands require standalone `/...` messages and can be disabled with `commands.text: false`. Slack slash commands are managed in the Slack app and are not removed automatically. Use `commands.useAccessGroups: false` to bypass access-group checks for commands.
- Full command list + config: [Slash commands](/tools/slash-commands)
## DM security (pairing)
- Default: `channels.slack.dm.policy="pairing"` — unknown DM senders get a pairing code (expires after 1 hour).
- Approve via: `openclaw pairing approve slack <code>`.
- To allow anyone: set `channels.slack.dm.policy="open"` and `channels.slack.dm.allowFrom=["*"]`.
- `channels.slack.dm.allowFrom` accepts user IDs, @handles, or emails (resolved at startup when tokens allow). The wizard accepts usernames and resolves them to ids during setup when tokens allow.
## Group policy
- `channels.slack.groupPolicy` controls channel handling (`open|disabled|allowlist`).
- `allowlist` requires channels to be listed in `channels.slack.channels`.
- If you only set `SLACK_BOT_TOKEN`/`SLACK_APP_TOKEN` and never create a `channels.slack` section,
the runtime defaults `groupPolicy` to `open`. Add `channels.slack.groupPolicy`,
`channels.defaults.groupPolicy`, or a channel allowlist to lock it down.
- The configure wizard accepts `#channel` names and resolves them to IDs when possible
(public + private); if multiple matches exist, it prefers the active channel.
- On startup, OpenClaw resolves channel/user names in allowlists to IDs (when tokens allow)
and logs the mapping; unresolved entries are kept as typed.
- To allow **no channels**, set `channels.slack.groupPolicy: "disabled"` (or keep an empty allowlist).
Channel options (`channels.slack.channels.<id>` or `channels.slack.channels.<name>`):
- `allow`: allow/deny the channel when `groupPolicy="allowlist"`.
- `requireMention`: mention gating for the channel.
- `tools`: optional per-channel tool policy overrides (`allow`/`deny`/`alsoAllow`).
- `toolsBySender`: optional per-sender tool policy overrides within the channel (keys are sender ids/@handles/emails; `"*"` wildcard supported).
- `allowBots`: allow bot-authored messages in this channel (default: false).
- `users`: optional per-channel user allowlist.
- `skills`: skill filter (omit = all skills, empty = none).
- `systemPrompt`: extra system prompt for the channel (combined with topic/purpose).
- `enabled`: set `false` to disable the channel.
## Delivery targets
Use these with cron/CLI sends:
- `user:<id>` for DMs
- `channel:<id>` for channels
## Tool actions
Slack tool actions can be gated with `channels.slack.actions.*`:
| Action group | Default | Notes |
| ------------ | ------- | ---------------------- |
| reactions | enabled | React + list reactions |
| messages | enabled | Read/send/edit/delete |
| pins | enabled | Pin/unpin/list |
| memberInfo | enabled | Member info |
| emojiList | enabled | Custom emoji list |
## Security notes
- Writes default to the bot token so state-changing actions stay scoped to the
app's bot permissions and identity.
- Setting `userTokenReadOnly: false` allows the user token to be used for write
operations when a bot token is unavailable, which means actions run with the
installing user's access. Treat the user token as highly privileged and keep
action gates and allowlists tight.
- If you enable user-token writes, make sure the user token includes the write
scopes you expect (`chat:write`, `reactions:write`, `pins:write`,
`files:write`) or those operations will fail.
</Accordion>
</AccordionGroup>
## Troubleshooting
Run this ladder first:
<AccordionGroup>
<Accordion title="No replies in channels">
Check, in order:
- `groupPolicy`
- channel allowlist (`channels.slack.channels`)
- `requireMention`
- per-channel `users` allowlist
Useful commands:
```bash
openclaw status
openclaw gateway status
openclaw channels status --probe
openclaw logs --follow
openclaw doctor
openclaw channels status --probe
```
Then confirm DM pairing state if needed:
</Accordion>
<Accordion title="DM messages ignored">
Check:
- `channels.slack.dm.enabled`
- `channels.slack.dm.policy`
- pairing approvals / allowlist entries
```bash
openclaw pairing list slack
```
Common failures:
</Accordion>
- Connected but no channel replies: channel blocked by `groupPolicy` or not in `channels.slack.channels` allowlist.
- DMs ignored: sender not approved when `channels.slack.dm.policy="pairing"`.
- API errors (`missing_scope`, `not_in_channel`, auth failures): bot/app tokens or Slack scopes are incomplete.
<Accordion title="Socket mode not connecting">
Validate bot + app tokens and Socket Mode enablement in Slack app settings.
</Accordion>
For triage flow: [/channels/troubleshooting](/channels/troubleshooting).
<Accordion title="HTTP mode not receiving events">
Validate:
## Notes
- signing secret
- webhook path
- Slack Request URLs (Events + Interactivity + Slash Commands)
- unique `webhookPath` per HTTP account
- Mention gating is controlled via `channels.slack.channels` (set `requireMention` to `true`); `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions.
- Multi-agent override: set per-agent patterns on `agents.list[].groupChat.mentionPatterns`.
- Reaction notifications follow `channels.slack.reactionNotifications` (use `reactionAllowlist` with mode `allowlist`).
- Bot-authored messages are ignored by default; enable via `channels.slack.allowBots` or `channels.slack.channels.<id>.allowBots`.
- Warning: If you allow replies to other bots (`channels.slack.allowBots=true` or `channels.slack.channels.<id>.allowBots=true`), prevent bot-to-bot reply loops with `requireMention`, `channels.slack.channels.<id>.users` allowlists, and/or clear guardrails in `AGENTS.md` and `SOUL.md`.
- For the Slack tool, reaction removal semantics are in [/tools/reactions](/tools/reactions).
- Attachments are downloaded to the media store when permitted and under the size limit.
</Accordion>
<Accordion title="Native/slash commands not firing">
Verify whether you intended:
- native command mode (`channels.slack.commands.native: true`) with matching slash commands registered in Slack
- or single slash command mode (`channels.slack.slashCommand.enabled: true`)
Also check `commands.useAccessGroups` and channel/user allowlists.
</Accordion>
</AccordionGroup>
## Configuration reference pointers
Primary reference:
- [Configuration reference - Slack](/gateway/configuration-reference#slack)
High-signal Slack fields:
- mode/auth: `mode`, `botToken`, `appToken`, `signingSecret`, `webhookPath`, `accounts.*`
- DM access: `dm.enabled`, `dm.policy`, `dm.allowFrom`, `dm.groupEnabled`, `dm.groupChannels`
- channel access: `groupPolicy`, `channels.*`, `channels.*.users`, `channels.*.requireMention`
- threading/history: `replyToMode`, `replyToModeByChatType`, `thread.*`, `historyLimit`, `dmHistoryLimit`, `dms.*.historyLimit`
- delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`
- ops/features: `configWrites`, `commands.native`, `slashCommand.*`, `actions.*`, `userToken`, `userTokenReadOnly`
## Related
- [Pairing](/channels/pairing)
- [Channel routing](/channels/channel-routing)
- [Troubleshooting](/channels/troubleshooting)
- [Configuration](/gateway/configuration)
- [Slash commands](/tools/slash-commands)

File diff suppressed because it is too large Load Diff

View File

@@ -1,406 +1,434 @@
---
summary: "WhatsApp (web channel) integration: login, inbox, replies, media, and ops"
summary: "WhatsApp channel support, access controls, delivery behavior, and operations"
read_when:
- Working on WhatsApp/web channel behavior or inbox routing
title: "WhatsApp"
---
# WhatsApp (web channel)
# WhatsApp (Web channel)
Status: WhatsApp Web via Baileys only. Gateway owns the session(s).
Status: production-ready via WhatsApp Web (Baileys). Gateway owns linked session(s).
## Quick setup (beginner)
<CardGroup cols={3}>
<Card title="Pairing" icon="link" href="/channels/pairing">
Default DM policy is pairing for unknown senders.
</Card>
<Card title="Channel troubleshooting" icon="wrench" href="/channels/troubleshooting">
Cross-channel diagnostics and repair playbooks.
</Card>
<Card title="Gateway configuration" icon="settings" href="/gateway/configuration">
Full channel config patterns and examples.
</Card>
</CardGroup>
1. Use a **separate phone number** if possible (recommended).
2. Configure WhatsApp in `~/.openclaw/openclaw.json`.
3. Run `openclaw channels login` to scan the QR code (Linked Devices).
4. Start the gateway.
## Quick setup
Minimal config:
<Steps>
<Step title="Configure WhatsApp access policy">
```json5
{
channels: {
whatsapp: {
dmPolicy: "allowlist",
dmPolicy: "pairing",
allowFrom: ["+15551234567"],
groupPolicy: "allowlist",
groupAllowFrom: ["+15551234567"],
},
},
}
```
## Goals
</Step>
- Multiple WhatsApp accounts (multi-account) in one Gateway process.
- Deterministic routing: replies return to WhatsApp, no model routing.
- Model sees enough context to understand quoted replies.
<Step title="Link WhatsApp (QR)">
## Config writes
By default, WhatsApp is allowed to write config updates triggered by `/config set|unset` (requires `commands.config: true`).
Disable with:
```json5
{
channels: { whatsapp: { configWrites: false } },
}
```bash
openclaw channels login --channel whatsapp
```
## Architecture (who owns what)
For a specific account:
- **Gateway** owns the Baileys socket and inbox loop.
- **CLI / macOS app** talk to the gateway; no direct Baileys use.
- **Active listener** is required for outbound sends; otherwise send fails fast.
```bash
openclaw channels login --channel whatsapp --account work
```
## Getting a phone number (two modes)
</Step>
WhatsApp requires a real mobile number for verification. VoIP and virtual numbers are usually blocked. There are two supported ways to run OpenClaw on WhatsApp:
<Step title="Start the gateway">
### Dedicated number (recommended)
```bash
openclaw gateway
```
Use a **separate phone number** for OpenClaw. Best UX, clean routing, no self-chat quirks. Ideal setup: **spare/old Android phone + eSIM**. Leave it on WiFi and power, and link it via QR.
</Step>
**WhatsApp Business:** You can use WhatsApp Business on the same device with a different number. Great for keeping your personal WhatsApp separate — install WhatsApp Business and register the OpenClaw number there.
<Step title="Approve first pairing request (if using pairing mode)">
**Sample config (dedicated number, single-user allowlist):**
```bash
openclaw pairing list whatsapp
openclaw pairing approve whatsapp <CODE>
```
Pairing requests expire after 1 hour. Pending requests are capped at 3 per channel.
</Step>
</Steps>
<Note>
OpenClaw recommends running WhatsApp on a separate number when possible. (The channel metadata and onboarding flow are optimized for that setup, but personal-number setups are also supported.)
</Note>
## Deployment patterns
<AccordionGroup>
<Accordion title="Dedicated number (recommended)">
This is the cleanest operational mode:
- separate WhatsApp identity for OpenClaw
- clearer DM allowlists and routing boundaries
- lower chance of self-chat confusion
Minimal policy pattern:
```json5
{
channels: {
whatsapp: {
dmPolicy: "allowlist",
allowFrom: ["+15551234567"],
},
},
}
```
</Accordion>
<Accordion title="Personal-number fallback">
Onboarding supports personal-number mode and writes a self-chat-friendly baseline:
- `dmPolicy: "allowlist"`
- `allowFrom` includes your personal number
- `selfChatMode: true`
In runtime, self-chat protections key off the linked self number and `allowFrom`.
</Accordion>
<Accordion title="WhatsApp Web-only channel scope">
The messaging platform channel is WhatsApp Web-based (`Baileys`) in current OpenClaw channel architecture.
There is no separate Twilio WhatsApp messaging channel in the built-in chat-channel registry.
</Accordion>
</AccordionGroup>
## Runtime model
- Gateway owns the WhatsApp socket and reconnect loop.
- Outbound sends require an active WhatsApp listener for the target account.
- Status and broadcast chats are ignored (`@status`, `@broadcast`).
- Direct chats use DM session rules (`session.dmScope`; default `main` collapses DMs to the agent main session).
- Group sessions are isolated (`agent:<agentId>:whatsapp:group:<jid>`).
## Access control and activation
<Tabs>
<Tab title="DM policy">
`channels.whatsapp.dmPolicy` controls direct chat access:
- `pairing` (default)
- `allowlist`
- `open` (requires `allowFrom` to include `"*"`)
- `disabled`
`allowFrom` accepts E.164-style numbers (normalized internally).
Runtime behavior details:
- pairings are persisted in channel allow-store and merged with configured `allowFrom`
- if no allowlist is configured, the linked self number is allowed by default
- outbound `fromMe` DMs are never auto-paired
</Tab>
<Tab title="Group policy + allowlists">
Group access has two layers:
1. **Group membership allowlist** (`channels.whatsapp.groups`)
- if `groups` is omitted, all groups are eligible
- if `groups` is present, it acts as a group allowlist (`"*"` allowed)
2. **Group sender policy** (`channels.whatsapp.groupPolicy` + `groupAllowFrom`)
- `open`: sender allowlist bypassed
- `allowlist`: sender must match `groupAllowFrom` (or `*`)
- `disabled`: block all group inbound
Sender allowlist fallback:
- if `groupAllowFrom` is unset, runtime falls back to `allowFrom` when available
Note: if no `channels.whatsapp` block exists at all, runtime group-policy fallback is effectively `open`.
</Tab>
<Tab title="Mentions + /activation">
Group replies require mention by default.
Mention detection includes:
- explicit WhatsApp mentions of the bot identity
- configured mention regex patterns (`agents.list[].groupChat.mentionPatterns`, fallback `messages.groupChat.mentionPatterns`)
- implicit reply-to-bot detection (reply sender matches bot identity)
Session-level activation command:
- `/activation mention`
- `/activation always`
`activation` updates session state (not global config). It is owner-gated.
</Tab>
</Tabs>
## Personal-number and self-chat behavior
When the linked self number is also present in `allowFrom`, WhatsApp self-chat safeguards activate:
- skip read receipts for self-chat turns
- ignore mention-JID auto-trigger behavior that would otherwise ping yourself
- if `messages.responsePrefix` is unset, self-chat replies default to `[{identity.name}]` or `[openclaw]`
## Message normalization and context
<AccordionGroup>
<Accordion title="Inbound envelope + reply context">
Incoming WhatsApp messages are wrapped in the shared inbound envelope.
If a quoted reply exists, context is appended in this form:
```text
[Replying to <sender> id:<stanzaId>]
<quoted body or media placeholder>
[/Replying]
```
Reply metadata fields are also populated when available (`ReplyToId`, `ReplyToBody`, `ReplyToSender`, sender JID/E.164).
</Accordion>
<Accordion title="Media placeholders and location/contact extraction">
Media-only inbound messages are normalized with placeholders such as:
- `<media:image>`
- `<media:video>`
- `<media:audio>`
- `<media:document>`
- `<media:sticker>`
Location and contact payloads are normalized into textual context before routing.
</Accordion>
<Accordion title="Pending group history injection">
For groups, unprocessed messages can be buffered and injected as context when the bot is finally triggered.
- default limit: `50`
- config: `channels.whatsapp.historyLimit`
- fallback: `messages.groupChat.historyLimit`
- `0` disables
Injection markers:
- `[Chat messages since your last reply - for context]`
- `[Current message - respond to this]`
</Accordion>
<Accordion title="Read receipts">
Read receipts are enabled by default for accepted inbound WhatsApp messages.
Disable globally:
```json5
{
channels: {
whatsapp: {
sendReadReceipts: false,
},
},
}
```
Per-account override:
```json5
{
channels: {
whatsapp: {
accounts: {
work: {
sendReadReceipts: false,
},
},
},
},
}
```
Self-chat turns skip read receipts even when globally enabled.
</Accordion>
</AccordionGroup>
## Delivery, chunking, and media
<AccordionGroup>
<Accordion title="Text chunking">
- default chunk limit: `channels.whatsapp.textChunkLimit = 4000`
- `channels.whatsapp.chunkMode = "length" | "newline"`
- `newline` mode prefers paragraph boundaries (blank lines), then falls back to length-safe chunking
</Accordion>
<Accordion title="Outbound media behavior">
- supports image, video, audio (PTT voice-note), and document payloads
- `audio/ogg` is rewritten to `audio/ogg; codecs=opus` for voice-note compatibility
- animated GIF playback is supported via `gifPlayback: true` on video sends
- captions are applied to the first media item when sending multi-media reply payloads
- media source can be HTTP(S), `file://`, or local paths
</Accordion>
<Accordion title="Media size limits and fallback behavior">
- inbound media save cap: `channels.whatsapp.mediaMaxMb` (default `50`)
- outbound media cap for auto-replies: `agents.defaults.mediaMaxMb` (default `5MB`)
- images are auto-optimized (resize/quality sweep) to fit limits
- on media send failure, first-item fallback sends text warning instead of dropping the response silently
</Accordion>
</AccordionGroup>
## Acknowledgment reactions
WhatsApp supports immediate ack reactions on inbound receipt via `channels.whatsapp.ackReaction`.
```json5
{
channels: {
whatsapp: {
dmPolicy: "allowlist",
allowFrom: ["+15551234567"],
},
},
}
```
**Pairing mode (optional):**
If you want pairing instead of allowlist, set `channels.whatsapp.dmPolicy` to `pairing`. Unknown senders get a pairing code; approve with:
`openclaw pairing approve whatsapp <code>`
### Personal number (fallback)
Quick fallback: run OpenClaw on **your own number**. Message yourself (WhatsApp “Message yourself”) for testing so you dont spam contacts. Expect to read verification codes on your main phone during setup and experiments. **Must enable self-chat mode.**
When the wizard asks for your personal WhatsApp number, enter the phone you will message from (the owner/sender), not the assistant number.
**Sample config (personal number, self-chat):**
```json
{
"whatsapp": {
"selfChatMode": true,
"dmPolicy": "allowlist",
"allowFrom": ["+15551234567"]
}
}
```
Self-chat replies default to `[{identity.name}]` when set (otherwise `[openclaw]`)
if `messages.responsePrefix` is unset. Set it explicitly to customize or disable
the prefix (use `""` to remove it).
### Number sourcing tips
- **Local eSIM** from your country's mobile carrier (most reliable)
- Austria: [hot.at](https://www.hot.at)
- UK: [giffgaff](https://www.giffgaff.com) — free SIM, no contract
- **Prepaid SIM** — cheap, just needs to receive one SMS for verification
**Avoid:** TextNow, Google Voice, most "free SMS" services — WhatsApp blocks these aggressively.
**Tip:** The number only needs to receive one verification SMS. After that, WhatsApp Web sessions persist via `creds.json`.
## Why Not Twilio?
- Early OpenClaw builds supported Twilios WhatsApp Business integration.
- WhatsApp Business numbers are a poor fit for a personal assistant.
- Meta enforces a 24hour reply window; if you havent responded in the last 24 hours, the business number cant initiate new messages.
- High-volume or “chatty” usage triggers aggressive blocking, because business accounts arent meant to send dozens of personal assistant messages.
- Result: unreliable delivery and frequent blocks, so support was removed.
## Login + credentials
- Login command: `openclaw channels login` (QR via Linked Devices).
- Multi-account login: `openclaw channels login --account <id>` (`<id>` = `accountId`).
- Default account (when `--account` is omitted): `default` if present, otherwise the first configured account id (sorted).
- Credentials stored in `~/.openclaw/credentials/whatsapp/<accountId>/creds.json`.
- Backup copy at `creds.json.bak` (restored on corruption).
- Legacy compatibility: older installs stored Baileys files directly in `~/.openclaw/credentials/`.
- Logout: `openclaw channels logout` (or `--account <id>`) deletes WhatsApp auth state (but keeps shared `oauth.json`).
- Logged-out socket => error instructs re-link.
## Inbound flow (DM + group)
- WhatsApp events come from `messages.upsert` (Baileys).
- Inbox listeners are detached on shutdown to avoid accumulating event handlers in tests/restarts.
- Status/broadcast chats are ignored.
- Direct chats use E.164; groups use group JID.
- **DM policy**: `channels.whatsapp.dmPolicy` controls direct chat access (default: `pairing`).
- Pairing: unknown senders get a pairing code (approve via `openclaw pairing approve whatsapp <code>`; codes expire after 1 hour).
- Open: requires `channels.whatsapp.allowFrom` to include `"*"`.
- Your linked WhatsApp number is implicitly trusted, so self messages skip `channels.whatsapp.dmPolicy` and `channels.whatsapp.allowFrom` checks.
### Personal-number mode (fallback)
If you run OpenClaw on your **personal WhatsApp number**, enable `channels.whatsapp.selfChatMode` (see sample above).
Behavior:
- Outbound DMs never trigger pairing replies (prevents spamming contacts).
- Inbound unknown senders still follow `channels.whatsapp.dmPolicy`.
- Self-chat mode (allowFrom includes your number) avoids auto read receipts and ignores mention JIDs.
- Read receipts sent for non-self-chat DMs.
## Read receipts
By default, the gateway marks inbound WhatsApp messages as read (blue ticks) once they are accepted.
Disable globally:
```json5
{
channels: { whatsapp: { sendReadReceipts: false } },
}
```
Disable per account:
```json5
{
channels: {
whatsapp: {
accounts: {
personal: { sendReadReceipts: false },
ackReaction: {
emoji: "👀",
direct: true,
group: "mentions", // always | mentions | never
},
},
},
}
```
Notes:
Behavior notes:
- Self-chat mode always skips read receipts.
- sent immediately after inbound is accepted (pre-reply)
- failures are logged but do not block normal reply delivery
- group mode `mentions` reacts on mention-triggered turns; group activation `always` acts as bypass for this check
- WhatsApp uses `channels.whatsapp.ackReaction` (legacy `messages.ackReaction` is not used here)
## WhatsApp FAQ: sending messages + pairing
## Multi-account and credentials
**Will OpenClaw message random contacts when I link WhatsApp?**
No. Default DM policy is **pairing**, so unknown senders only get a pairing code and their message is **not processed**. OpenClaw only replies to chats it receives, or to sends you explicitly trigger (agent/CLI).
<AccordionGroup>
<Accordion title="Account selection and defaults">
- account ids come from `channels.whatsapp.accounts`
- default account selection: `default` if present, otherwise first configured account id (sorted)
- account ids are normalized internally for lookup
</Accordion>
**How does pairing work on WhatsApp?**
Pairing is a DM gate for unknown senders:
<Accordion title="Credential paths and legacy compatibility">
- current auth path: `~/.openclaw/credentials/whatsapp/<accountId>/creds.json`
- backup file: `creds.json.bak`
- legacy default auth in `~/.openclaw/credentials/` is still recognized/migrated for default-account flows
</Accordion>
- First DM from a new sender returns a short code (message is not processed).
- Approve with: `openclaw pairing approve whatsapp <code>` (list with `openclaw pairing list whatsapp`).
- Codes expire after 1 hour; pending requests are capped at 3 per channel.
<Accordion title="Logout behavior">
`openclaw channels logout --channel whatsapp [--account <id>]` clears WhatsApp auth state for that account.
**Can multiple people use different OpenClaw instances on one WhatsApp number?**
Yes, by routing each sender to a different agent via `bindings` (peer `kind: "direct"`, sender E.164 like `+15551234567`). Replies still come from the **same WhatsApp account**, and direct chats collapse to each agent's main session, so use **one agent per person**. DM access control (`dmPolicy`/`allowFrom`) is global per WhatsApp account. See [Multi-Agent Routing](/concepts/multi-agent).
In legacy auth directories, `oauth.json` is preserved while Baileys auth files are removed.
**Why do you ask for my phone number in the wizard?**
The wizard uses it to set your **allowlist/owner** so your own DMs are permitted. Its not used for auto-sending. If you run on your personal WhatsApp number, use that same number and enable `channels.whatsapp.selfChatMode`.
</Accordion>
</AccordionGroup>
## Message normalization (what the model sees)
## Tools, actions, and config writes
- `Body` is the current message body with envelope.
- Quoted reply context is **always appended**:
- Agent tool support includes WhatsApp reaction action (`react`).
- Action gates:
- `channels.whatsapp.actions.reactions`
- `channels.whatsapp.actions.polls`
- Channel-initiated config writes are enabled by default (disable via `channels.whatsapp.configWrites=false`).
```
[Replying to +1555 id:ABC123]
<quoted text or <media:...>>
[/Replying]
```
## Troubleshooting
- Reply metadata also set:
- `ReplyToId` = stanzaId
- `ReplyToBody` = quoted body or media placeholder
- `ReplyToSender` = E.164 when known
- Media-only inbound messages use placeholders:
- `<media:image|video|audio|document|sticker>`
<AccordionGroup>
<Accordion title="Not linked (QR required)">
Symptom: channel status reports not linked.
## Groups
Fix:
- Groups map to `agent:<agentId>:whatsapp:group:<jid>` sessions.
- Group policy: `channels.whatsapp.groupPolicy = open|disabled|allowlist` (default `allowlist`).
- Activation modes:
- `mention` (default): requires @mention or regex match.
- `always`: always triggers.
- `/activation mention|always` is owner-only and must be sent as a standalone message.
- Owner = `channels.whatsapp.allowFrom` (or self E.164 if unset).
- **History injection** (pending-only):
- Recent _unprocessed_ messages (default 50) inserted under:
`[Chat messages since your last reply - for context]` (messages already in the session are not re-injected)
- Current message under:
`[Current message - respond to this]`
- Sender suffix appended: `[from: Name (+E164)]`
- Group metadata cached 5 min (subject + participants).
```bash
openclaw channels login --channel whatsapp
openclaw channels status
```
## Reply delivery (threading)
</Accordion>
- WhatsApp Web sends standard messages (no quoted reply threading in the current gateway).
- Reply tags are ignored on this channel.
<Accordion title="Linked but disconnected / reconnect loop">
Symptom: linked account with repeated disconnects or reconnect attempts.
## Acknowledgment reactions (auto-react on receipt)
Fix:
WhatsApp can automatically send emoji reactions to incoming messages immediately upon receipt, before the bot generates a reply. This provides instant feedback to users that their message was received.
```bash
openclaw doctor
openclaw logs --follow
```
**Configuration:**
If needed, re-link with `channels login`.
```json
{
"whatsapp": {
"ackReaction": {
"emoji": "👀",
"direct": true,
"group": "mentions"
}
}
}
```
</Accordion>
**Options:**
<Accordion title="No active listener when sending">
Outbound sends fail fast when no active gateway listener exists for the target account.
- `emoji` (string): Emoji to use for acknowledgment (e.g., "👀", "✅", "📨"). Empty or omitted = feature disabled.
- `direct` (boolean, default: `true`): Send reactions in direct/DM chats.
- `group` (string, default: `"mentions"`): Group chat behavior:
- `"always"`: React to all group messages (even without @mention)
- `"mentions"`: React only when bot is @mentioned
- `"never"`: Never react in groups
Make sure gateway is running and the account is linked.
**Per-account override:**
</Accordion>
```json
{
"whatsapp": {
"accounts": {
"work": {
"ackReaction": {
"emoji": "✅",
"direct": false,
"group": "always"
}
}
}
}
}
```
<Accordion title="Group messages unexpectedly ignored">
Check in this order:
**Behavior notes:**
- `groupPolicy`
- `groupAllowFrom` / `allowFrom`
- `groups` allowlist entries
- mention gating (`requireMention` + mention patterns)
- Reactions are sent **immediately** upon message receipt, before typing indicators or bot replies.
- In groups with `requireMention: false` (activation: always), `group: "mentions"` will react to all messages (not just @mentions).
- Fire-and-forget: reaction failures are logged but don't prevent the bot from replying.
- Participant JID is automatically included for group reactions.
- WhatsApp ignores `messages.ackReaction`; use `channels.whatsapp.ackReaction` instead.
</Accordion>
## Agent tool (reactions)
<Accordion title="Bun runtime warning">
WhatsApp gateway runtime should use Node. Bun is flagged as incompatible for stable WhatsApp/Telegram gateway operation.
</Accordion>
</AccordionGroup>
- Tool: `whatsapp` with `react` action (`chatJid`, `messageId`, `emoji`, optional `remove`).
- Optional: `participant` (group sender), `fromMe` (reacting to your own message), `accountId` (multi-account).
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
- Tool gating: `channels.whatsapp.actions.reactions` (default: enabled).
## Configuration reference pointers
## Limits
Primary reference:
- Outbound text is chunked to `channels.whatsapp.textChunkLimit` (default 4000).
- Optional newline chunking: set `channels.whatsapp.chunkMode="newline"` to split on blank lines (paragraph boundaries) before length chunking.
- Inbound media saves are capped by `channels.whatsapp.mediaMaxMb` (default 50 MB).
- Outbound media items are capped by `agents.defaults.mediaMaxMb` (default 5 MB).
- [Configuration reference - WhatsApp](/gateway/configuration-reference#whatsapp)
## Outbound send (text + media)
High-signal WhatsApp fields:
- Uses active web listener; error if gateway not running.
- Text chunking: 4k max per message (configurable via `channels.whatsapp.textChunkLimit`, optional `channels.whatsapp.chunkMode`).
- Media:
- Image/video/audio/document supported.
- Audio sent as PTT; `audio/ogg` => `audio/ogg; codecs=opus`.
- Caption only on first media item.
- Media fetch supports HTTP(S) and local paths.
- Animated GIFs: WhatsApp expects MP4 with `gifPlayback: true` for inline looping.
- CLI: `openclaw message send --media <mp4> --gif-playback`
- Gateway: `send` params include `gifPlayback: true`
- access: `dmPolicy`, `allowFrom`, `groupPolicy`, `groupAllowFrom`, `groups`
- delivery: `textChunkLimit`, `chunkMode`, `mediaMaxMb`, `sendReadReceipts`, `ackReaction`
- multi-account: `accounts.<id>.enabled`, `accounts.<id>.authDir`, account-level overrides
- operations: `configWrites`, `debounceMs`, `web.enabled`, `web.heartbeatSeconds`, `web.reconnect.*`
- session behavior: `session.dmScope`, `historyLimit`, `dmHistoryLimit`, `dms.<id>.historyLimit`
## Voice notes (PTT audio)
## Related
WhatsApp sends audio as **voice notes** (PTT bubble).
- Best results: OGG/Opus. OpenClaw rewrites `audio/ogg` to `audio/ogg; codecs=opus`.
- `[[audio_as_voice]]` is ignored for WhatsApp (audio already ships as voice note).
## Media limits + optimization
- Default outbound cap: 5 MB (per media item).
- Override: `agents.defaults.mediaMaxMb`.
- Images are auto-optimized to JPEG under cap (resize + quality sweep).
- Oversize media => error; media reply falls back to text warning.
## Heartbeats
- **Gateway heartbeat** logs connection health (`web.heartbeatSeconds`, default 60s).
- **Agent heartbeat** can be configured per agent (`agents.list[].heartbeat`) or globally
via `agents.defaults.heartbeat` (fallback when no per-agent entries are set).
- Uses the configured heartbeat prompt (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`) + `HEARTBEAT_OK` skip behavior.
- Delivery defaults to the last used channel (or configured target).
## Reconnect behavior
- Backoff policy: `web.reconnect`:
- `initialMs`, `maxMs`, `factor`, `jitter`, `maxAttempts`.
- If maxAttempts reached, web monitoring stops (degraded).
- Logged-out => stop and require re-link.
## Config quick map
- `channels.whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled).
- `channels.whatsapp.selfChatMode` (same-phone setup; bot uses your personal WhatsApp number).
- `channels.whatsapp.allowFrom` (DM allowlist). WhatsApp uses E.164 phone numbers (no usernames).
- `channels.whatsapp.mediaMaxMb` (inbound media save cap).
- `channels.whatsapp.ackReaction` (auto-reaction on message receipt: `{emoji, direct, group}`).
- `channels.whatsapp.accounts.<accountId>.*` (per-account settings + optional `authDir`).
- `channels.whatsapp.accounts.<accountId>.mediaMaxMb` (per-account inbound media cap).
- `channels.whatsapp.accounts.<accountId>.ackReaction` (per-account ack reaction override).
- `channels.whatsapp.groupAllowFrom` (group sender allowlist).
- `channels.whatsapp.groupPolicy` (group policy).
- `channels.whatsapp.historyLimit` / `channels.whatsapp.accounts.<accountId>.historyLimit` (group history context; `0` disables).
- `channels.whatsapp.dmHistoryLimit` (DM history limit in user turns). Per-user overrides: `channels.whatsapp.dms["<phone>"].historyLimit`.
- `channels.whatsapp.groups` (group allowlist + mention gating defaults; use `"*"` to allow all)
- `channels.whatsapp.actions.reactions` (gate WhatsApp tool reactions).
- `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`)
- `messages.groupChat.historyLimit`
- `channels.whatsapp.messagePrefix` (inbound prefix; per-account: `channels.whatsapp.accounts.<accountId>.messagePrefix`; deprecated: `messages.messagePrefix`)
- `messages.responsePrefix` (outbound prefix)
- `agents.defaults.mediaMaxMb`
- `agents.defaults.heartbeat.every`
- `agents.defaults.heartbeat.model` (optional override)
- `agents.defaults.heartbeat.target`
- `agents.defaults.heartbeat.to`
- `agents.defaults.heartbeat.session`
- `agents.list[].heartbeat.*` (per-agent overrides)
- `session.*` (scope, idle, store, mainKey)
- `web.enabled` (disable channel startup when false)
- `web.heartbeatSeconds`
- `web.reconnect.*`
## Logs + troubleshooting
- Subsystems: `whatsapp/inbound`, `whatsapp/outbound`, `web-heartbeat`, `web-reconnect`.
- Log file: `/tmp/openclaw/openclaw-YYYY-MM-DD.log` (configurable).
- Troubleshooting guide: [Gateway troubleshooting](/gateway/troubleshooting).
## Troubleshooting (quick)
**Not linked / QR login required**
- Symptom: `channels status` shows `linked: false` or warns “Not linked”.
- Fix: run `openclaw channels login` on the gateway host and scan the QR (WhatsApp → Settings → Linked Devices).
**Linked but disconnected / reconnect loop**
- Symptom: `channels status` shows `running, disconnected` or warns “Linked but disconnected”.
- Fix: `openclaw doctor` (or restart the gateway). If it persists, relink via `channels login` and inspect `openclaw logs --follow`.
**Bun runtime**
- Bun is **not recommended**. WhatsApp (Baileys) and Telegram are unreliable on Bun.
Run the gateway with **Node**. (See Getting Started runtime note.)
- [Pairing](/channels/pairing)
- [Channel routing](/channels/channel-routing)
- [Troubleshooting](/channels/troubleshooting)

View File

@@ -32,18 +32,6 @@ Jobs are ordered so cheap checks fail before expensive ones run:
2. `build-artifacts` (blocked on above)
3. `checks`, `checks-windows`, `macos`, `android` (blocked on build)
## Code Analysis
The `code-analysis` job runs `scripts/analyze_code_files.py` on PRs to enforce code quality:
- **LOC threshold**: Files that grow past 1000 lines fail the build
- **Delta-only**: Only checks files changed in the PR, not the entire codebase
- **Push to main**: Skipped (job passes as no-op) so merges aren't blocked
When `--strict` is set, violations block all downstream jobs. This catches bloated files early before expensive tests run.
Excluded directories: `node_modules`, `dist`, `vendor`, `.git`, `coverage`, `Swabble`, `skills`, `.pi`
## Runners
| Runner | Jobs |

View File

@@ -303,7 +303,7 @@ Options:
- `--non-interactive`
- `--mode <local|remote>`
- `--flow <quickstart|advanced|manual>` (manual is an alias for advanced)
- `--auth-choice <setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip>`
- `--auth-choice <setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|custom-api-key|skip>`
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
- `--token <token>` (non-interactive; used with `--auth-choice token`)
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
@@ -318,6 +318,11 @@ Options:
- `--zai-api-key <key>`
- `--minimax-api-key <key>`
- `--opencode-zen-api-key <key>`
- `--custom-base-url <url>` (non-interactive; used with `--auth-choice custom-api-key`)
- `--custom-model-id <id>` (non-interactive; used with `--auth-choice custom-api-key`)
- `--custom-api-key <key>` (non-interactive; optional; used with `--auth-choice custom-api-key`; falls back to `CUSTOM_API_KEY` when omitted)
- `--custom-provider-id <id>` (non-interactive; optional custom provider id)
- `--custom-compatibility <openai|anthropic>` (non-interactive; optional; default `openai`)
- `--gateway-port <port>`
- `--gateway-bind <loopback|lan|tailnet|auto|custom>`
- `--gateway-auth <token|password>`

View File

@@ -21,4 +21,8 @@ openclaw logs
openclaw logs --follow
openclaw logs --json
openclaw logs --limit 500
openclaw logs --local-time
openclaw logs --follow --local-time
```
Use `--local-time` to render timestamps in your local timezone.

View File

@@ -26,6 +26,19 @@ openclaw onboard --flow manual
openclaw onboard --mode remote --remote-url ws://gateway-host:18789
```
Non-interactive custom provider:
```bash
openclaw onboard --non-interactive \
--auth-choice custom-api-key \
--custom-base-url "https://llm.example.com/v1" \
--custom-model-id "foo-large" \
--custom-api-key "$CUSTOM_API_KEY" \
--custom-compatibility openai
```
`--custom-api-key` is optional in non-interactive mode. If omitted, onboarding checks `CUSTOM_API_KEY`.
Flow notes:
- `quickstart`: minimal prompts, auto-generates a gateway token.

View File

@@ -139,10 +139,11 @@ out to QMD for retrieval. Key points:
- Boot refresh now runs in the background by default so chat startup is not
blocked; set `memory.qmd.update.waitForBootSync = true` to keep the previous
blocking behavior.
- Searches run via `qmd query --json`, scoped to OpenClaw-managed collections.
If QMD fails or the binary is missing,
OpenClaw automatically falls back to the builtin SQLite manager so memory tools
keep working.
- Searches run via `memory.qmd.searchMode` (default `qmd query --json`; also
supports `search` and `vsearch`). If the selected mode rejects flags on your
QMD build, OpenClaw retries with `qmd query`. If QMD fails or the binary is
missing, OpenClaw automatically falls back to the builtin SQLite manager so
memory tools keep working.
- OpenClaw does not expose QMD embed batch-size tuning today; batch behavior is
controlled by QMD itself.
- **First search may be slow**: QMD may download local GGUF models (reranker/query
@@ -177,6 +178,8 @@ out to QMD for retrieval. Key points:
**Config surface (`memory.qmd.*`)**
- `command` (default `qmd`): override the executable path.
- `searchMode` (default `query`): pick which QMD command backs
`memory_search` (`query`, `search`, `vsearch`).
- `includeDefaultMemory` (default `true`): auto-index `MEMORY.md` + `memory/**/*.md`.
- `paths[]`: add extra directories/files (`path`, optional `pattern`, optional
stable `name`).

View File

@@ -24,6 +24,14 @@
"dark": "#FF5A36",
"light": "#FF8A6B"
},
"styling": {
"codeblocks": {
"theme": {
"dark": "min-dark",
"light": "min-light"
}
}
},
"navbar": {
"links": [
{
@@ -861,8 +869,8 @@
"pages": [
"channels/whatsapp",
"channels/telegram",
"channels/grammy",
"channels/discord",
"channels/irc",
"channels/slack",
"channels/feishu",
"channels/googlechat",
@@ -1035,6 +1043,7 @@
"providers/anthropic",
"providers/openai",
"providers/openrouter",
"providers/litellm",
"providers/bedrock",
"providers/vercel-ai-gateway",
"providers/moonshot",
@@ -1098,6 +1107,7 @@
"group": "Configuration and operations",
"pages": [
"gateway/configuration",
"gateway/configuration-reference",
"gateway/configuration-examples",
"gateway/authentication",
"gateway/health",
@@ -1218,7 +1228,7 @@
},
{
"group": "Technical reference",
"pages": ["reference/wizard", "reference/token-use"]
"pages": ["reference/wizard", "reference/token-use", "channels/grammy"]
},
{
"group": "Concept internals",
@@ -1376,7 +1386,6 @@
"pages": [
"zh-CN/channels/whatsapp",
"zh-CN/channels/telegram",
"zh-CN/channels/grammy",
"zh-CN/channels/discord",
"zh-CN/channels/slack",
"zh-CN/channels/feishu",

View File

@@ -0,0 +1,229 @@
---
summary: "Plan: isolate browser act:evaluate from Playwright queue using CDP, with end-to-end deadlines and safer ref resolution"
owner: "openclaw"
status: "draft"
last_updated: "2026-02-10"
title: "Browser Evaluate CDP Refactor"
---
# Browser Evaluate CDP Refactor Plan
## Context
`act:evaluate` executes user provided JavaScript in the page. Today it runs via Playwright
(`page.evaluate` or `locator.evaluate`). Playwright serializes CDP commands per page, so a
stuck or long running evaluate can block the page command queue and make every later action
on that tab look "stuck".
PR #13498 adds a pragmatic safety net (bounded evaluate, abort propagation, and best-effort
recovery). This document describes a larger refactor that makes `act:evaluate` inherently
isolated from Playwright so a stuck evaluate cannot wedge normal Playwright operations.
## Goals
- `act:evaluate` cannot permanently block later browser actions on the same tab.
- Timeouts are single source of truth end to end so a caller can rely on a budget.
- Abort and timeout are treated the same way across HTTP and in-process dispatch.
- Element targeting for evaluate is supported without switching everything off Playwright.
- Maintain backward compatibility for existing callers and payloads.
## Non-goals
- Replace all browser actions (click, type, wait, etc.) with CDP implementations.
- Remove the existing safety net introduced in PR #13498 (it remains a useful fallback).
- Introduce new unsafe capabilities beyond the existing `browser.evaluateEnabled` gate.
- Add process isolation (worker process/thread) for evaluate. If we still see hard to recover
stuck states after this refactor, that is a follow-up idea.
## Current Architecture (Why It Gets Stuck)
At a high level:
- Callers send `act:evaluate` to the browser control service.
- The route handler calls into Playwright to execute the JavaScript.
- Playwright serializes page commands, so an evaluate that never finishes blocks the queue.
- A stuck queue means later click/type/wait operations on the tab can appear to hang.
## Proposed Architecture
### 1. Deadline Propagation
Introduce a single budget concept and derive everything from it:
- Caller sets `timeoutMs` (or a deadline in the future).
- The outer request timeout, route handler logic, and the execution budget inside the page
all use the same budget, with small headroom where needed for serialization overhead.
- Abort is propagated as an `AbortSignal` everywhere so cancellation is consistent.
Implementation direction:
- Add a small helper (for example `createBudget({ timeoutMs, signal })`) that returns:
- `signal`: the linked AbortSignal
- `deadlineAtMs`: absolute deadline
- `remainingMs()`: remaining budget for child operations
- Use this helper in:
- `src/browser/client-fetch.ts` (HTTP and in-process dispatch)
- `src/node-host/runner.ts` (proxy path)
- browser action implementations (Playwright and CDP)
### 2. Separate Evaluate Engine (CDP Path)
Add a CDP based evaluate implementation that does not share Playwright's per page command
queue. The key property is that the evaluate transport is a separate WebSocket connection
and a separate CDP session attached to the target.
Implementation direction:
- New module, for example `src/browser/cdp-evaluate.ts`, that:
- Connects to the configured CDP endpoint (browser level socket).
- Uses `Target.attachToTarget({ targetId, flatten: true })` to get a `sessionId`.
- Runs either:
- `Runtime.evaluate` for page level evaluate, or
- `DOM.resolveNode` plus `Runtime.callFunctionOn` for element evaluate.
- On timeout or abort:
- Sends `Runtime.terminateExecution` best-effort for the session.
- Closes the WebSocket and returns a clear error.
Notes:
- This still executes JavaScript in the page, so termination can have side effects. The win
is that it does not wedge the Playwright queue, and it is cancelable at the transport
layer by killing the CDP session.
### 3. Ref Story (Element Targeting Without A Full Rewrite)
The hard part is element targeting. CDP needs a DOM handle or `backendDOMNodeId`, while
today most browser actions use Playwright locators based on refs from snapshots.
Recommended approach: keep existing refs, but attach an optional CDP resolvable id.
#### 3.1 Extend Stored Ref Info
Extend the stored role ref metadata to optionally include a CDP id:
- Today: `{ role, name, nth }`
- Proposed: `{ role, name, nth, backendDOMNodeId?: number }`
This keeps all existing Playwright based actions working and allows CDP evaluate to accept
the same `ref` value when the `backendDOMNodeId` is available.
#### 3.2 Populate backendDOMNodeId At Snapshot Time
When producing a role snapshot:
1. Generate the existing role ref map as today (role, name, nth).
2. Fetch the AX tree via CDP (`Accessibility.getFullAXTree`) and compute a parallel map of
`(role, name, nth) -> backendDOMNodeId` using the same duplicate handling rules.
3. Merge the id back into the stored ref info for the current tab.
If mapping fails for a ref, leave `backendDOMNodeId` undefined. This makes the feature
best-effort and safe to roll out.
#### 3.3 Evaluate Behavior With Ref
In `act:evaluate`:
- If `ref` is present and has `backendDOMNodeId`, run element evaluate via CDP.
- If `ref` is present but has no `backendDOMNodeId`, fall back to the Playwright path (with
the safety net).
Optional escape hatch:
- Extend the request shape to accept `backendDOMNodeId` directly for advanced callers (and
for debugging), while keeping `ref` as the primary interface.
### 4. Keep A Last Resort Recovery Path
Even with CDP evaluate, there are other ways to wedge a tab or a connection. Keep the
existing recovery mechanisms (terminate execution + disconnect Playwright) as a last resort
for:
- legacy callers
- environments where CDP attach is blocked
- unexpected Playwright edge cases
## Implementation Plan (Single Iteration)
### Deliverables
- A CDP based evaluate engine that runs outside the Playwright per-page command queue.
- A single end-to-end timeout/abort budget used consistently by callers and handlers.
- Ref metadata that can optionally carry `backendDOMNodeId` for element evaluate.
- `act:evaluate` prefers the CDP engine when possible and falls back to Playwright when not.
- Tests that prove a stuck evaluate does not wedge later actions.
- Logs/metrics that make failures and fallbacks visible.
### Implementation Checklist
1. Add a shared "budget" helper to link `timeoutMs` + upstream `AbortSignal` into:
- a single `AbortSignal`
- an absolute deadline
- a `remainingMs()` helper for downstream operations
2. Update all caller paths to use that helper so `timeoutMs` means the same thing everywhere:
- `src/browser/client-fetch.ts` (HTTP and in-process dispatch)
- `src/node-host/runner.ts` (node proxy path)
- CLI wrappers that call `/act` (add `--timeout-ms` to `browser evaluate`)
3. Implement `src/browser/cdp-evaluate.ts`:
- connect to the browser-level CDP socket
- `Target.attachToTarget` to get a `sessionId`
- run `Runtime.evaluate` for page evaluate
- run `DOM.resolveNode` + `Runtime.callFunctionOn` for element evaluate
- on timeout/abort: best-effort `Runtime.terminateExecution` then close the socket
4. Extend stored role ref metadata to optionally include `backendDOMNodeId`:
- keep existing `{ role, name, nth }` behavior for Playwright actions
- add `backendDOMNodeId?: number` for CDP element targeting
5. Populate `backendDOMNodeId` during snapshot creation (best-effort):
- fetch AX tree via CDP (`Accessibility.getFullAXTree`)
- compute `(role, name, nth) -> backendDOMNodeId` and merge into the stored ref map
- if mapping is ambiguous or missing, leave the id undefined
6. Update `act:evaluate` routing:
- if no `ref`: always use CDP evaluate
- if `ref` resolves to a `backendDOMNodeId`: use CDP element evaluate
- otherwise: fall back to Playwright evaluate (still bounded and abortable)
7. Keep the existing "last resort" recovery path as a fallback, not the default path.
8. Add tests:
- stuck evaluate times out within budget and the next click/type succeeds
- abort cancels evaluate (client disconnect or timeout) and unblocks subsequent actions
- mapping failures cleanly fall back to Playwright
9. Add observability:
- evaluate duration and timeout counters
- terminateExecution usage
- fallback rate (CDP -> Playwright) and reasons
### Acceptance Criteria
- A deliberately hung `act:evaluate` returns within the caller budget and does not wedge the
tab for later actions.
- `timeoutMs` behaves consistently across CLI, agent tool, node proxy, and in-process calls.
- If `ref` can be mapped to `backendDOMNodeId`, element evaluate uses CDP; otherwise the
fallback path is still bounded and recoverable.
## Testing Plan
- Unit tests:
- `(role, name, nth)` matching logic between role refs and AX tree nodes.
- Budget helper behavior (headroom, remaining time math).
- Integration tests:
- CDP evaluate timeout returns within budget and does not block the next action.
- Abort cancels evaluate and triggers termination best-effort.
- Contract tests:
- Ensure `BrowserActRequest` and `BrowserActResponse` remain compatible.
## Risks And Mitigations
- Mapping is imperfect:
- Mitigation: best-effort mapping, fallback to Playwright evaluate, and add debug tooling.
- `Runtime.terminateExecution` has side effects:
- Mitigation: only use on timeout/abort and document the behavior in errors.
- Extra overhead:
- Mitigation: only fetch AX tree when snapshots are requested, cache per target, and keep
CDP session short lived.
- Extension relay limitations:
- Mitigation: use browser level attach APIs when per page sockets are not available, and
keep the current Playwright path as fallback.
## Open Questions
- Should the new engine be configurable as `playwright`, `cdp`, or `auto`?
- Do we want to expose a new "nodeRef" format for advanced users, or keep `ref` only?
- How should frame snapshots and selector scoped snapshots participate in AX mapping?

View File

@@ -67,7 +67,11 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
// Auth profile metadata (secrets live in auth-profiles.json)
auth: {
profiles: {
"anthropic:me@example.com": { provider: "anthropic", mode: "oauth", email: "me@example.com" },
"anthropic:me@example.com": {
provider: "anthropic",
mode: "oauth",
email: "me@example.com",
},
"anthropic:work": { provider: "anthropic", mode: "api_key" },
"openai:default": { provider: "openai", mode: "api_key" },
"openai-codex:default": { provider: "openai-codex", mode: "oauth" },
@@ -375,7 +379,10 @@ Save to `~/.openclaw/openclaw.json` and you can DM the bot from that number.
to: "+15555550123",
thinking: "low",
timeoutSeconds: 300,
transform: { module: "./transforms/gmail.js", export: "transformGmail" },
transform: {
module: "./transforms/gmail.js",
export: "transformGmail",
},
},
],
gmail: {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -5,120 +5,173 @@ read_when:
title: "Gateway Runbook"
---
# Gateway service runbook
# Gateway runbook
Last updated: 2025-12-09
Use this page for day-1 startup and day-2 operations of the Gateway service.
## What it is
<CardGroup cols={2}>
<Card title="Deep troubleshooting" icon="siren" href="/gateway/troubleshooting">
Symptom-first diagnostics with exact command ladders and log signatures.
</Card>
<Card title="Configuration" icon="sliders" href="/gateway/configuration">
Task-oriented setup guide + full configuration reference.
</Card>
</CardGroup>
- The always-on process that owns the single Baileys/Telegram connection and the control/event plane.
- Replaces the legacy `gateway` command. CLI entry point: `openclaw gateway`.
- Runs until stopped; exits non-zero on fatal errors so the supervisor restarts it.
## 5-minute local startup
## How to run (local)
<Steps>
<Step title="Start the Gateway">
```bash
openclaw gateway --port 18789
# for full debug/trace logs in stdio:
# debug/trace mirrored to stdio
openclaw gateway --port 18789 --verbose
# if the port is busy, terminate listeners then start:
# force-kill listener on selected port, then start
openclaw gateway --force
# dev loop (auto-reload on TS changes):
pnpm gateway:watch
```
- Config hot reload watches `~/.openclaw/openclaw.json` (or `OPENCLAW_CONFIG_PATH`).
- Default mode: `gateway.reload.mode="hybrid"` (hot-apply safe changes, restart on critical).
- Hot reload uses in-process restart via **SIGUSR1** when needed.
- Disable with `gateway.reload.mode="off"`.
- Binds WebSocket control plane to `127.0.0.1:<port>` (default 18789).
- The same port also serves HTTP (control UI, hooks, A2UI). Single-port multiplex.
- OpenAI Chat Completions (HTTP): [`/v1/chat/completions`](/gateway/openai-http-api).
- OpenResponses (HTTP): [`/v1/responses`](/gateway/openresponses-http-api).
- Tools Invoke (HTTP): [`/tools/invoke`](/gateway/tools-invoke-http-api).
- Starts a Canvas file server by default on `canvasHost.port` (default `18793`), serving `http://<gateway-host>:18793/__openclaw__/canvas/` from `~/.openclaw/workspace/canvas`. Disable with `canvasHost.enabled=false` or `OPENCLAW_SKIP_CANVAS_HOST=1`.
- Logs to stdout; use launchd/systemd to keep it alive and rotate logs.
- Pass `--verbose` to mirror debug logging (handshakes, req/res, events) from the log file into stdio when troubleshooting.
- `--force` uses `lsof` to find listeners on the chosen port, sends SIGTERM, logs what it killed, then starts the gateway (fails fast if `lsof` is missing).
- If you run under a supervisor (launchd/systemd/mac app child-process mode), a stop/restart typically sends **SIGTERM**; older builds may surface this as `pnpm` `ELIFECYCLE` exit code **143** (SIGTERM), which is a normal shutdown, not a crash.
- **SIGUSR1** triggers an in-process restart when authorized (gateway tool/config apply/update, or enable `commands.restart` for manual restarts).
- Gateway auth is required by default: set `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`) or `gateway.auth.password`. Clients must send `connect.params.auth.token/password` unless using Tailscale Serve identity.
- The wizard now generates a token by default, even on loopback.
- Port precedence: `--port` > `OPENCLAW_GATEWAY_PORT` > `gateway.port` > default `18789`.
</Step>
<Step title="Verify service health">
```bash
openclaw gateway status
openclaw status
openclaw logs --follow
```
Healthy baseline: `Runtime: running` and `RPC probe: ok`.
</Step>
<Step title="Validate channel readiness">
```bash
openclaw channels status --probe
```
</Step>
</Steps>
<Note>
Gateway config reload watches the active config file path (resolved from profile/state defaults, or `OPENCLAW_CONFIG_PATH` when set).
Default mode is `gateway.reload.mode="hybrid"`.
</Note>
## Runtime model
- One always-on process for routing, control plane, and channel connections.
- Single multiplexed port for:
- WebSocket control/RPC
- HTTP APIs (OpenAI-compatible, Responses, tools invoke)
- Control UI and hooks
- Default bind mode: `loopback`.
- Auth is required by default (`gateway.auth.token` / `gateway.auth.password`, or `OPENCLAW_GATEWAY_TOKEN` / `OPENCLAW_GATEWAY_PASSWORD`).
### Port and bind precedence
| Setting | Resolution order |
| ------------ | ------------------------------------------------------------- |
| Gateway port | `--port``OPENCLAW_GATEWAY_PORT``gateway.port``18789` |
| Bind mode | CLI/override → `gateway.bind``loopback` |
### Hot reload modes
| `gateway.reload.mode` | Behavior |
| --------------------- | ------------------------------------------ |
| `off` | No config reload |
| `hot` | Apply only hot-safe changes |
| `restart` | Restart on reload-required changes |
| `hybrid` (default) | Hot-apply when safe, restart when required |
## Operator command set
```bash
openclaw gateway status
openclaw gateway status --deep
openclaw gateway status --json
openclaw gateway install
openclaw gateway restart
openclaw gateway stop
openclaw logs --follow
openclaw doctor
```
## Remote access
- Tailscale/VPN preferred; otherwise SSH tunnel:
```bash
ssh -N -L 18789:127.0.0.1:18789 user@host
```
- Clients then connect to `ws://127.0.0.1:18789` through the tunnel.
- If a token is configured, clients must include it in `connect.params.auth.token` even over the tunnel.
## Multiple gateways (same host)
Usually unnecessary: one Gateway can serve multiple messaging channels and agents. Use multiple Gateways only for redundancy or strict isolation (ex: rescue bot).
Supported if you isolate state + config and use unique ports. Full guide: [Multiple gateways](/gateway/multiple-gateways).
Service names are profile-aware:
- macOS: `bot.molt.<profile>` (legacy `com.openclaw.*` may still exist)
- Linux: `openclaw-gateway-<profile>.service`
- Windows: `OpenClaw Gateway (<profile>)`
Install metadata is embedded in the service config:
- `OPENCLAW_SERVICE_MARKER=openclaw`
- `OPENCLAW_SERVICE_KIND=gateway`
- `OPENCLAW_SERVICE_VERSION=<version>`
Rescue-Bot Pattern: keep a second Gateway isolated with its own profile, state dir, workspace, and base port spacing. Full guide: [Rescue-bot guide](/gateway/multiple-gateways#rescue-bot-guide).
### Dev profile (`--dev`)
Fast path: run a fully-isolated dev instance (config/state/workspace) without touching your primary setup.
Preferred: Tailscale/VPN.
Fallback: SSH tunnel.
```bash
openclaw --dev setup
openclaw --dev gateway --allow-unconfigured
# then target the dev instance:
openclaw --dev status
openclaw --dev health
ssh -N -L 18789:127.0.0.1:18789 user@host
```
Defaults (can be overridden via env/flags/config):
Then connect clients to `ws://127.0.0.1:18789` locally.
- `OPENCLAW_STATE_DIR=~/.openclaw-dev`
- `OPENCLAW_CONFIG_PATH=~/.openclaw-dev/openclaw.json`
- `OPENCLAW_GATEWAY_PORT=19001` (Gateway WS + HTTP)
- browser control service port = `19003` (derived: `gateway.port+2`, loopback only)
- `canvasHost.port=19005` (derived: `gateway.port+4`)
- `agents.defaults.workspace` default becomes `~/.openclaw/workspace-dev` when you run `setup`/`onboard` under `--dev`.
<Warning>
If gateway auth is configured, clients still must send auth (`token`/`password`) even over SSH tunnels.
</Warning>
Derived ports (rules of thumb):
See: [Remote Gateway](/gateway/remote), [Authentication](/gateway/authentication), [Tailscale](/gateway/tailscale).
- Base port = `gateway.port` (or `OPENCLAW_GATEWAY_PORT` / `--port`)
- browser control service port = base + 2 (loopback only)
- `canvasHost.port = base + 4` (or `OPENCLAW_CANVAS_HOST_PORT` / config override)
- Browser profile CDP ports auto-allocate from `browser.controlPort + 9 .. + 108` (persisted per profile).
## Supervision and service lifecycle
Use supervised runs for production-like reliability.
<Tabs>
<Tab title="macOS (launchd)">
```bash
openclaw gateway install
openclaw gateway status
openclaw gateway restart
openclaw gateway stop
```
LaunchAgent labels are `ai.openclaw.gateway` (default) or `ai.openclaw.<profile>` (named profile). `openclaw doctor` audits and repairs service config drift.
</Tab>
<Tab title="Linux (systemd user)">
```bash
openclaw gateway install
systemctl --user enable --now openclaw-gateway[-<profile>].service
openclaw gateway status
```
For persistence after logout, enable lingering:
```bash
sudo loginctl enable-linger <user>
```
</Tab>
<Tab title="Linux (system service)">
Use a system unit for multi-user/always-on hosts.
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now openclaw-gateway[-<profile>].service
```
</Tab>
</Tabs>
## Multiple gateways on one host
Most setups should run **one** Gateway.
Use multiple only for strict isolation/redundancy (for example a rescue profile).
Checklist per instance:
- unique `gateway.port`
- unique `OPENCLAW_CONFIG_PATH`
- unique `OPENCLAW_STATE_DIR`
- unique `agents.defaults.workspace`
- separate WhatsApp numbers (if using WA)
Service install per profile:
```bash
openclaw --profile main gateway install
openclaw --profile rescue gateway install
```
- Unique `gateway.port`
- Unique `OPENCLAW_CONFIG_PATH`
- Unique `OPENCLAW_STATE_DIR`
- Unique `agents.defaults.workspace`
Example:
@@ -127,204 +180,75 @@ OPENCLAW_CONFIG_PATH=~/.openclaw/a.json OPENCLAW_STATE_DIR=~/.openclaw-a opencla
OPENCLAW_CONFIG_PATH=~/.openclaw/b.json OPENCLAW_STATE_DIR=~/.openclaw-b openclaw gateway --port 19002
```
## Protocol (operator view)
See: [Multiple gateways](/gateway/multiple-gateways).
- Full docs: [Gateway protocol](/gateway/protocol) and [Bridge protocol (legacy)](/gateway/bridge-protocol).
- Mandatory first frame from client: `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{id,displayName?,version,platform,deviceFamily?,modelIdentifier?,mode,instanceId?}, caps, auth?, locale?, userAgent? } }`.
- Gateway replies `res {type:"res", id, ok:true, payload:hello-ok }` (or `ok:false` with an error, then closes).
- After handshake:
- Requests: `{type:"req", id, method, params}` → `{type:"res", id, ok, payload|error}`
- Events: `{type:"event", event, payload, seq?, stateVersion?}`
- Structured presence entries: `{host, ip, version, platform?, deviceFamily?, modelIdentifier?, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId? }` (for WS clients, `instanceId` comes from `connect.client.instanceId`).
- `agent` responses are two-stage: first `res` ack `{runId,status:"accepted"}`, then a final `res` `{runId,status:"ok"|"error",summary}` after the run finishes; streamed output arrives as `event:"agent"`.
## Methods (initial set)
- `health` — full health snapshot (same shape as `openclaw health --json`).
- `status` — short summary.
- `system-presence` — current presence list.
- `system-event` — post a presence/system note (structured).
- `send` — send a message via the active channel(s).
- `agent` — run an agent turn (streams events back on same connection).
- `node.list` — list paired + currently-connected nodes (includes `caps`, `deviceFamily`, `modelIdentifier`, `paired`, `connected`, and advertised `commands`).
- `node.describe` — describe a node (capabilities + supported `node.invoke` commands; works for paired nodes and for currently-connected unpaired nodes).
- `node.invoke` — invoke a command on a node (e.g. `canvas.*`, `camera.*`).
- `node.pair.*` — pairing lifecycle (`request`, `list`, `approve`, `reject`, `verify`).
See also: [Presence](/concepts/presence) for how presence is produced/deduped and why a stable `client.instanceId` matters.
## Events
- `agent` — streamed tool/output events from the agent run (seq-tagged).
- `presence` — presence updates (deltas with stateVersion) pushed to all connected clients.
- `tick` — periodic keepalive/no-op to confirm liveness.
- `shutdown` — Gateway is exiting; payload includes `reason` and optional `restartExpectedMs`. Clients should reconnect.
## WebChat integration
- WebChat is a native SwiftUI UI that talks directly to the Gateway WebSocket for history, sends, abort, and events.
- Remote use goes through the same SSH/Tailscale tunnel; if a gateway token is configured, the client includes it during `connect`.
- macOS app connects via a single WS (shared connection); it hydrates presence from the initial snapshot and listens for `presence` events to update the UI.
## Typing and validation
- Server validates every inbound frame with AJV against JSON Schema emitted from the protocol definitions.
- Clients (TS/Swift) consume generated types (TS directly; Swift via the repos generator).
- Protocol definitions are the source of truth; regenerate schema/models with:
- `pnpm protocol:gen`
- `pnpm protocol:gen:swift`
## Connection snapshot
- `hello-ok` includes a `snapshot` with `presence`, `health`, `stateVersion`, and `uptimeMs` plus `policy {maxPayload,maxBufferedBytes,tickIntervalMs}` so clients can render immediately without extra requests.
- `health`/`system-presence` remain available for manual refresh, but are not required at connect time.
## Error codes (res.error shape)
- Errors use `{ code, message, details?, retryable?, retryAfterMs? }`.
- Standard codes:
- `NOT_LINKED` — WhatsApp not authenticated.
- `AGENT_TIMEOUT` — agent did not respond within the configured deadline.
- `INVALID_REQUEST` — schema/param validation failed.
- `UNAVAILABLE` — Gateway is shutting down or a dependency is unavailable.
## Keepalive behavior
- `tick` events (or WS ping/pong) are emitted periodically so clients know the Gateway is alive even when no traffic occurs.
- Send/agent acknowledgements remain separate responses; do not overload ticks for sends.
## Replay / gaps
- Events are not replayed. Clients detect seq gaps and should refresh (`health` + `system-presence`) before continuing. WebChat and macOS clients now auto-refresh on gap.
## Supervision (macOS example)
- Use launchd to keep the service alive:
- Program: path to `openclaw`
- Arguments: `gateway`
- KeepAlive: true
- StandardOut/Err: file paths or `syslog`
- On failure, launchd restarts; fatal misconfig should keep exiting so the operator notices.
- LaunchAgents are per-user and require a logged-in session; for headless setups use a custom LaunchDaemon (not shipped).
- `openclaw gateway install` writes `~/Library/LaunchAgents/bot.molt.gateway.plist`
(or `bot.molt.<profile>.plist`; legacy `com.openclaw.*` is cleaned up).
- `openclaw doctor` audits the LaunchAgent config and can update it to current defaults.
## Gateway service management (CLI)
Use the Gateway CLI for install/start/stop/restart/status:
### Dev profile quick path
```bash
openclaw gateway status
openclaw gateway install
openclaw gateway stop
openclaw gateway restart
openclaw logs --follow
openclaw --dev setup
openclaw --dev gateway --allow-unconfigured
openclaw --dev status
```
Notes:
Defaults include isolated state/config and base gateway port `19001`.
- `gateway status` probes the Gateway RPC by default using the services resolved port/config (override with `--url`).
- `gateway status --deep` adds system-level scans (LaunchDaemons/system units).
- `gateway status --no-probe` skips the RPC probe (useful when networking is down).
- `gateway status --json` is stable for scripts.
- `gateway status` reports **supervisor runtime** (launchd/systemd running) separately from **RPC reachability** (WS connect + status RPC).
- `gateway status` prints config path + probe target to avoid “localhost vs LAN bind” confusion and profile mismatches.
- `gateway status` includes the last gateway error line when the service looks running but the port is closed.
- `logs` tails the Gateway file log via RPC (no manual `tail`/`grep` needed).
- If other gateway-like services are detected, the CLI warns unless they are OpenClaw profile services.
We still recommend **one gateway per machine** for most setups; use isolated profiles/ports for redundancy or a rescue bot. See [Multiple gateways](/gateway/multiple-gateways).
- Cleanup: `openclaw gateway uninstall` (current service) and `openclaw doctor` (legacy migrations).
- `gateway install` is a no-op when already installed; use `openclaw gateway install --force` to reinstall (profile/env/path changes).
## Protocol quick reference (operator view)
Bundled mac app:
- First client frame must be `connect`.
- Gateway returns `hello-ok` snapshot (`presence`, `health`, `stateVersion`, `uptimeMs`, limits/policy).
- Requests: `req(method, params)``res(ok/payload|error)`.
- Common events: `connect.challenge`, `agent`, `chat`, `presence`, `tick`, `health`, `heartbeat`, `shutdown`.
- OpenClaw.app can bundle a Node-based gateway relay and install a per-user LaunchAgent labeled
`bot.molt.gateway` (or `bot.molt.<profile>`; legacy `com.openclaw.*` labels still unload cleanly).
- To stop it cleanly, use `openclaw gateway stop` (or `launchctl bootout gui/$UID/bot.molt.gateway`).
- To restart, use `openclaw gateway restart` (or `launchctl kickstart -k gui/$UID/bot.molt.gateway`).
- `launchctl` only works if the LaunchAgent is installed; otherwise use `openclaw gateway install` first.
- Replace the label with `bot.molt.<profile>` when running a named profile.
Agent runs are two-stage:
## Supervision (systemd user unit)
1. Immediate accepted ack (`status:"accepted"`)
2. Final completion response (`status:"ok"|"error"`), with streamed `agent` events in between.
OpenClaw installs a **systemd user service** by default on Linux/WSL2. We
recommend user services for single-user machines (simpler env, per-user config).
Use a **system service** for multi-user or always-on servers (no lingering
required, shared supervision).
`openclaw gateway install` writes the user unit. `openclaw doctor` audits the
unit and can update it to match the current recommended defaults.
Create `~/.config/systemd/user/openclaw-gateway[-<profile>].service`:
```
[Unit]
Description=OpenClaw Gateway (profile: <profile>, v<version>)
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/openclaw gateway --port 18789
Restart=always
RestartSec=5
Environment=OPENCLAW_GATEWAY_TOKEN=
WorkingDirectory=/home/youruser
[Install]
WantedBy=default.target
```
Enable lingering (required so the user service survives logout/idle):
```
sudo loginctl enable-linger youruser
```
Onboarding runs this on Linux/WSL2 (may prompt for sudo; writes `/var/lib/systemd/linger`).
Then enable the service:
```
systemctl --user enable --now openclaw-gateway[-<profile>].service
```
**Alternative (system service)** - for always-on or multi-user servers, you can
install a systemd **system** unit instead of a user unit (no lingering needed).
Create `/etc/systemd/system/openclaw-gateway[-<profile>].service` (copy the unit above,
switch `WantedBy=multi-user.target`, set `User=` + `WorkingDirectory=`), then:
```
sudo systemctl daemon-reload
sudo systemctl enable --now openclaw-gateway[-<profile>].service
```
## Windows (WSL2)
Windows installs should use **WSL2** and follow the Linux systemd section above.
See full protocol docs: [Gateway Protocol](/gateway/protocol).
## Operational checks
- Liveness: open WS and send `req:connect` → expect `res` with `payload.type="hello-ok"` (with snapshot).
- Readiness: call `health` → expect `ok: true` and a linked channel in `linkChannel` (when applicable).
- Debug: subscribe to `tick` and `presence` events; ensure `status` shows linked/auth age; presence entries show Gateway host and connected clients.
### Liveness
- Open WS and send `connect`.
- Expect `hello-ok` response with snapshot.
### Readiness
```bash
openclaw gateway status
openclaw channels status --probe
openclaw health
```
### Gap recovery
Events are not replayed. On sequence gaps, refresh state (`health`, `system-presence`) before continuing.
## Common failure signatures
| Signature | Likely issue |
| -------------------------------------------------------------- | ---------------------------------------- |
| `refusing to bind gateway ... without auth` | Non-loopback bind without token/password |
| `another gateway instance is already listening` / `EADDRINUSE` | Port conflict |
| `Gateway start blocked: set gateway.mode=local` | Config set to remote mode |
| `unauthorized` during connect | Auth mismatch between client and gateway |
For full diagnosis ladders, use [Gateway Troubleshooting](/gateway/troubleshooting).
## Safety guarantees
- Assume one Gateway per host by default; if you run multiple profiles, isolate ports/state and target the right instance.
- No fallback to direct Baileys connections; if the Gateway is down, sends fail fast.
- Non-connect first frames or malformed JSON are rejected and the socket is closed.
- Graceful shutdown: emit `shutdown` event before closing; clients must handle close + reconnect.
- Gateway protocol clients fail fast when Gateway is unavailable (no implicit direct-channel fallback).
- Invalid/non-connect first frames are rejected and closed.
- Graceful shutdown emits `shutdown` event before socket close.
## CLI helpers
---
- `openclaw gateway health|status` — request health/status over the Gateway WS.
- `openclaw message send --target <num> --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp).
- `openclaw agent --message "hi" --to <num>` — run an agent turn (waits for final by default).
- `openclaw gateway call <method> --params '{"k":"v"}'` — raw method invoker for debugging.
- `openclaw gateway stop|restart` — stop/restart the supervised gateway service (launchd/systemd).
- Gateway helper subcommands assume a running gateway on `--url`; they no longer auto-spawn one.
Related:
## Migration guidance
- Retire uses of `openclaw gateway` and the legacy TCP control port.
- Update clients to speak the WS protocol with mandatory connect and structured presence.
- [Troubleshooting](/gateway/troubleshooting)
- [Background Process](/gateway/background-process)
- [Configuration](/gateway/configuration)
- [Health](/gateway/health)
- [Doctor](/gateway/doctor)
- [Authentication](/gateway/authentication)

View File

@@ -65,6 +65,24 @@ It writes config/workspace on the host:
Running on a VPS? See [Hetzner (Docker VPS)](/install/hetzner).
### Shell Helpers (optional)
For easier day-to-day Docker management, install `ClawDock`:
```bash
mkdir -p ~/.clawdock && curl -sL https://raw.githubusercontent.com/openclaw/openclaw/main/scripts/shell-helpers/clawdock-helpers.sh -o ~/.clawdock/clawdock-helpers.sh
```
**Add to your shell config (zsh):**
```bash
echo 'source ~/.clawdock/clawdock-helpers.sh' >> ~/.zshrc && source ~/.zshrc
```
Then use `clawdock-start`, `clawdock-stop`, `clawdock-dashboard`, etc. Run `clawdock-help` for all commands.
See [`ClawDock` Helper README](https://github.com/openclaw/openclaw/blob/main/scripts/shell-helpers/README.md) for details.
### Manual flow (compose)
```bash

View File

@@ -329,3 +329,24 @@ All long-lived state must survive restarts, rebuilds, and reboots.
| Node runtime | Container filesystem | Docker image | Rebuilt every image build |
| OS packages | Container filesystem | Docker image | Do not install at runtime |
| Docker container | Ephemeral | Restartable | Safe to destroy |
---
## Infrastructure as Code (Terraform)
For teams preferring infrastructure-as-code workflows, a community-maintained Terraform setup provides:
- Modular Terraform configuration with remote state management
- Automated provisioning via cloud-init
- Deployment scripts (bootstrap, deploy, backup/restore)
- Security hardening (firewall, UFW, SSH-only access)
- SSH tunnel configuration for gateway access
**Repositories:**
- Infrastructure: [openclaw-terraform-hetzner](https://github.com/andreesg/openclaw-terraform-hetzner)
- Docker config: [openclaw-docker-config](https://github.com/andreesg/openclaw-docker-config)
This approach complements the Docker setup above with reproducible deployments, version-controlled infrastructure, and automated disaster recovery.
> **Note:** Community-maintained. For issues or contributions, see the repository links above.

View File

@@ -34,17 +34,17 @@ Notes:
# From repo root; set release IDs so Sparkle feed is enabled.
# APP_BUILD must be numeric + monotonic for Sparkle compare.
BUNDLE_ID=bot.molt.mac \
APP_VERSION=2026.2.9 \
APP_VERSION=2026.2.10 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-app.sh
# Zip for distribution (includes resource forks for Sparkle delta support)
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.9.zip
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.2.10.zip
# Optional: also build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.9.dmg
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.10.dmg
# Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once:
@@ -52,14 +52,14 @@ scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.2.9.dmg
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
BUNDLE_ID=bot.molt.mac \
APP_VERSION=2026.2.9 \
APP_VERSION=2026.2.10 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.9.dSYM.zip
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.2.10.dSYM.zip
```
## Appcast entry
@@ -67,7 +67,7 @@ ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenCl
Use the release note generator so Sparkle renders formatted HTML notes:
```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.9.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.2.10.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
@@ -75,7 +75,7 @@ Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when
## Publish & verify
- Upload `OpenClaw-2026.2.9.zip` (and `OpenClaw-2026.2.9.dSYM.zip`) to the GitHub release for tag `v2026.2.9`.
- Upload `OpenClaw-2026.2.10.zip` (and `OpenClaw-2026.2.10.dSYM.zip`) to the GitHub release for tag `v2026.2.10`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`.
- Sanity checks:
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200.

View File

@@ -39,6 +39,7 @@ See [Venice AI](/providers/venice).
- [Anthropic (API + Claude Code CLI)](/providers/anthropic)
- [Qwen (OAuth)](/providers/qwen)
- [OpenRouter](/providers/openrouter)
- [LiteLLM (unified gateway)](/providers/litellm)
- [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Together AI](/providers/together)
- [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)

153
docs/providers/litellm.md Normal file
View File

@@ -0,0 +1,153 @@
---
summary: "Run OpenClaw through LiteLLM Proxy for unified model access and cost tracking"
read_when:
- You want to route OpenClaw through a LiteLLM proxy
- You need cost tracking, logging, or model routing through LiteLLM
---
# LiteLLM
[LiteLLM](https://litellm.ai) is an open-source LLM gateway that provides a unified API to 100+ model providers. Route OpenClaw through LiteLLM to get centralized cost tracking, logging, and the flexibility to switch backends without changing your OpenClaw config.
## Why use LiteLLM with OpenClaw?
- **Cost tracking** — See exactly what OpenClaw spends across all models
- **Model routing** — Switch between Claude, GPT-4, Gemini, Bedrock without config changes
- **Virtual keys** — Create keys with spend limits for OpenClaw
- **Logging** — Full request/response logs for debugging
- **Fallbacks** — Automatic failover if your primary provider is down
## Quick start
### Via onboarding
```bash
openclaw onboard --auth-choice litellm-api-key
```
### Manual setup
1. Start LiteLLM Proxy:
```bash
pip install 'litellm[proxy]'
litellm --model claude-opus-4-6
```
2. Point OpenClaw to LiteLLM:
```bash
export LITELLM_API_KEY="your-litellm-key"
openclaw
```
That's it. OpenClaw now routes through LiteLLM.
## Configuration
### Environment variables
```bash
export LITELLM_API_KEY="sk-litellm-key"
```
### Config file
```json5
{
models: {
providers: {
litellm: {
baseUrl: "http://localhost:4000",
apiKey: "${LITELLM_API_KEY}",
api: "openai-completions",
models: [
{
id: "claude-opus-4-6",
name: "Claude Opus 4.6",
reasoning: true,
input: ["text", "image"],
contextWindow: 200000,
maxTokens: 64000,
},
{
id: "gpt-4o",
name: "GPT-4o",
reasoning: false,
input: ["text", "image"],
contextWindow: 128000,
maxTokens: 8192,
},
],
},
},
},
agents: {
defaults: {
model: { primary: "litellm/claude-opus-4-6" },
},
},
}
```
## Virtual keys
Create a dedicated key for OpenClaw with spend limits:
```bash
curl -X POST "http://localhost:4000/key/generate" \
-H "Authorization: Bearer $LITELLM_MASTER_KEY" \
-H "Content-Type: application/json" \
-d '{
"key_alias": "openclaw",
"max_budget": 50.00,
"budget_duration": "monthly"
}'
```
Use the generated key as `LITELLM_API_KEY`.
## Model routing
LiteLLM can route model requests to different backends. Configure in your LiteLLM `config.yaml`:
```yaml
model_list:
- model_name: claude-opus-4-6
litellm_params:
model: claude-opus-4-6
api_key: os.environ/ANTHROPIC_API_KEY
- model_name: gpt-4o
litellm_params:
model: gpt-4o
api_key: os.environ/OPENAI_API_KEY
```
OpenClaw keeps requesting `claude-opus-4-6` — LiteLLM handles the routing.
## Viewing usage
Check LiteLLM's dashboard or API:
```bash
# Key info
curl "http://localhost:4000/key/info" \
-H "Authorization: Bearer sk-litellm-key"
# Spend logs
curl "http://localhost:4000/spend/logs" \
-H "Authorization: Bearer $LITELLM_MASTER_KEY"
```
## Notes
- LiteLLM runs on `http://localhost:4000` by default
- OpenClaw connects via the OpenAI-compatible `/v1/chat/completions` endpoint
- All OpenClaw features work through LiteLLM — no limitations
## See also
- [LiteLLM Docs](https://docs.litellm.ai)
- [Model Providers](/concepts/model-providers)

View File

@@ -27,7 +27,7 @@ openclaw onboard --auth-choice together-api-key
{
agents: {
defaults: {
model: { primary: "together/zai-org/GLM-4.7" },
model: { primary: "together/moonshotai/Kimi-K2.5" },
},
},
}
@@ -42,7 +42,7 @@ openclaw onboard --non-interactive \
--together-api-key "$TOGETHER_API_KEY"
```
This will set `together/zai-org/GLM-4.7` as the default model.
This will set `together/moonshotai/Kimi-K2.5` as the default model.
## Environment note

View File

@@ -34,6 +34,11 @@ Check your Node version with `node --version` if you are unsure.
```bash
curl -fsSL https://openclaw.ai/install.sh | bash
```
<img
src="/assets/install-script.svg"
alt="Install Script Process"
className="rounded-lg"
/>
</Tab>
<Tab title="Windows (PowerShell)">
```powershell

View File

@@ -106,6 +106,23 @@ Add `--json` for a machine-readable summary.
--gateway-bind loopback
```
</Accordion>
<Accordion title="Custom provider example">
```bash
openclaw onboard --non-interactive \
--mode local \
--auth-choice custom-api-key \
--custom-base-url "https://llm.example.com/v1" \
--custom-model-id "foo-large" \
--custom-api-key "$CUSTOM_API_KEY" \
--custom-provider-id "my-custom" \
--custom-compatibility anthropic \
--gateway-port 18789 \
--gateway-bind loopback
```
`--custom-api-key` is optional. If omitted, onboarding checks `CUSTOM_API_KEY`.
</Accordion>
</AccordionGroup>
## Add another agent

View File

@@ -175,6 +175,18 @@ What you set:
Moonshot (Kimi K2) and Kimi Coding configs are auto-written.
More detail: [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot).
</Accordion>
<Accordion title="Custom provider">
Works with OpenAI-compatible and Anthropic-compatible endpoints.
Non-interactive flags:
- `--auth-choice custom-api-key`
- `--custom-base-url`
- `--custom-model-id`
- `--custom-api-key` (optional; falls back to `CUSTOM_API_KEY`)
- `--custom-provider-id` (optional)
- `--custom-compatibility <openai|anthropic>` (optional; default `openai`)
</Accordion>
<Accordion title="Skip">
Leaves auth unconfigured.
</Accordion>

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/bluebubbles",
"version": "2026.2.9",
"version": "2026.2.10",
"description": "OpenClaw BlueBubbles channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/copilot-proxy",
"version": "2026.2.9",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw Copilot Proxy provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/diagnostics-otel",
"version": "2026.2.9",
"version": "2026.2.10",
"description": "OpenClaw diagnostics OpenTelemetry exporter",
"type": "module",
"dependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/discord",
"version": "2026.2.9",
"version": "2026.2.10",
"description": "OpenClaw Discord channel plugin",
"type": "module",
"devDependencies": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/feishu",
"version": "2026.2.9",
"version": "2026.2.10",
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
"type": "module",
"dependencies": {
@@ -8,9 +8,6 @@
"@sinclair/typebox": "0.34.48",
"zod": "^4.3.6"
},
"devDependencies": {
"openclaw": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"

View File

@@ -0,0 +1,64 @@
import { describe, it, expect } from "vitest";
import { parseFeishuMessageEvent } from "./bot.js";
// Helper to build a minimal FeishuMessageEvent for testing
function makeEvent(
chatType: "p2p" | "group",
mentions?: Array<{ key: string; name: string; id: { open_id?: string } }>,
) {
return {
sender: {
sender_id: { user_id: "u1", open_id: "ou_sender" },
},
message: {
message_id: "msg_1",
chat_id: "oc_chat1",
chat_type: chatType,
message_type: "text",
content: JSON.stringify({ text: "hello" }),
mentions,
},
};
}
describe("parseFeishuMessageEvent mentionedBot", () => {
const BOT_OPEN_ID = "ou_bot_123";
it("returns mentionedBot=false when there are no mentions", () => {
const event = makeEvent("group", []);
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
expect(ctx.mentionedBot).toBe(false);
});
it("returns mentionedBot=true when bot is mentioned", () => {
const event = makeEvent("group", [
{ key: "@_user_1", name: "Bot", id: { open_id: BOT_OPEN_ID } },
]);
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
expect(ctx.mentionedBot).toBe(true);
});
it("returns mentionedBot=false when only other users are mentioned", () => {
const event = makeEvent("group", [
{ key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
]);
const ctx = parseFeishuMessageEvent(event as any, BOT_OPEN_ID);
expect(ctx.mentionedBot).toBe(false);
});
it("returns mentionedBot=false when botOpenId is undefined (unknown bot)", () => {
const event = makeEvent("group", [
{ key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
]);
const ctx = parseFeishuMessageEvent(event as any, undefined);
expect(ctx.mentionedBot).toBe(false);
});
it("returns mentionedBot=false when botOpenId is empty string (probe failed)", () => {
const event = makeEvent("group", [
{ key: "@_user_1", name: "Alice", id: { open_id: "ou_alice" } },
]);
const ctx = parseFeishuMessageEvent(event as any, "");
expect(ctx.mentionedBot).toBe(false);
});
});

View File

@@ -216,7 +216,7 @@ function parseMessageContent(content: string, messageType: string): string {
function checkBotMentioned(event: FeishuMessageEvent, botOpenId?: string): boolean {
const mentions = event.message.mentions ?? [];
if (mentions.length === 0) return false;
if (!botOpenId) return mentions.length > 0;
if (!botOpenId) return false;
return mentions.some((m) => m.id.open_id === botOpenId);
}

View File

@@ -0,0 +1,48 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { describe, expect, it, vi } from "vitest";
const probeFeishuMock = vi.hoisted(() => vi.fn());
vi.mock("./probe.js", () => ({
probeFeishu: probeFeishuMock,
}));
import { feishuPlugin } from "./channel.js";
describe("feishuPlugin.status.probeAccount", () => {
it("uses current account credentials for multi-account config", async () => {
const cfg = {
channels: {
feishu: {
enabled: true,
accounts: {
main: {
appId: "cli_main",
appSecret: "secret_main",
enabled: true,
},
},
},
},
} as OpenClawConfig;
const account = feishuPlugin.config.resolveAccount(cfg, "main");
probeFeishuMock.mockResolvedValueOnce({ ok: true, appId: "cli_main" });
const result = await feishuPlugin.status?.probeAccount?.({
account,
timeoutMs: 1_000,
cfg,
});
expect(probeFeishuMock).toHaveBeenCalledTimes(1);
expect(probeFeishuMock).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "main",
appId: "cli_main",
appSecret: "secret_main",
}),
);
expect(result).toMatchObject({ ok: true, appId: "cli_main" });
});
});

View File

@@ -321,9 +321,7 @@ export const feishuPlugin: ChannelPlugin<ResolvedFeishuAccount> = {
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account }) => {
return await probeFeishu(account);
},
probeAccount: async ({ account }) => await probeFeishu(account),
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
enabled: account.enabled,

View File

@@ -92,6 +92,14 @@ async function convertMarkdown(client: Lark.Client, markdown: string) {
};
}
function sortBlocksByFirstLevel(blocks: any[], firstLevelIds: string[]): any[] {
if (!firstLevelIds || firstLevelIds.length === 0) return blocks;
const sorted = firstLevelIds.map((id) => blocks.find((b) => b.block_id === id)).filter(Boolean);
const sortedIds = new Set(firstLevelIds);
const remaining = blocks.filter((b) => !sortedIds.has(b.block_id));
return [...sorted, ...remaining];
}
/* eslint-disable @typescript-eslint/no-explicit-any -- SDK block types */
async function insertBlocks(
client: Lark.Client,
@@ -279,12 +287,13 @@ async function createDoc(client: Lark.Client, title: string, folderToken?: strin
async function writeDoc(client: Lark.Client, docToken: string, markdown: string) {
const deleted = await clearDocumentContent(client, docToken);
const { blocks } = await convertMarkdown(client, markdown);
const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
if (blocks.length === 0) {
return { success: true, blocks_deleted: deleted, blocks_added: 0, images_processed: 0 };
}
const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
const { children: inserted, skipped } = await insertBlocks(client, docToken, blocks);
const { children: inserted, skipped } = await insertBlocks(client, docToken, sortedBlocks);
const imagesProcessed = await processImages(client, docToken, markdown, inserted);
return {
@@ -299,12 +308,13 @@ async function writeDoc(client: Lark.Client, docToken: string, markdown: string)
}
async function appendDoc(client: Lark.Client, docToken: string, markdown: string) {
const { blocks } = await convertMarkdown(client, markdown);
const { blocks, firstLevelBlockIds } = await convertMarkdown(client, markdown);
if (blocks.length === 0) {
throw new Error("Content is empty");
}
const sortedBlocks = sortBlocksByFirstLevel(blocks, firstLevelBlockIds);
const { children: inserted, skipped } = await insertBlocks(client, docToken, blocks);
const { children: inserted, skipped } = await insertBlocks(client, docToken, sortedBlocks);
const imagesProcessed = await processImages(client, docToken, markdown, inserted);
return {

View File

@@ -210,15 +210,16 @@ export async function uploadImageFeishu(params: {
const client = createFeishuClient(account);
// SDK expects a Readable stream, not a Buffer
// Use type assertion since SDK actually accepts any Readable at runtime
const imageStream = typeof image === "string" ? fs.createReadStream(image) : Readable.from(image);
// SDK accepts Buffer directly or fs.ReadStream for file paths
// Using Readable.from(buffer) causes issues with form-data library
// See: https://github.com/larksuite/node-sdk/issues/121
const imageData = typeof image === "string" ? fs.createReadStream(image) : image;
const response = await client.im.image.create({
data: {
image_type: imageType,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK stream type
image: imageStream as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK accepts Buffer or ReadStream
image: imageData as any,
},
});
@@ -258,16 +259,17 @@ export async function uploadFileFeishu(params: {
const client = createFeishuClient(account);
// SDK expects a Readable stream, not a Buffer
// Use type assertion since SDK actually accepts any Readable at runtime
const fileStream = typeof file === "string" ? fs.createReadStream(file) : Readable.from(file);
// SDK accepts Buffer directly or fs.ReadStream for file paths
// Using Readable.from(buffer) causes issues with form-data library
// See: https://github.com/larksuite/node-sdk/issues/121
const fileData = typeof file === "string" ? fs.createReadStream(file) : file;
const response = await client.im.file.create({
data: {
file_type: fileType,
file_name: fileName,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK stream type
file: fileStream as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK accepts Buffer or ReadStream
file: fileData as any,
...(duration !== undefined && { duration }),
},
});

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/google-antigravity-auth",
"version": "2026.2.9",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw Google Antigravity OAuth provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/google-gemini-cli-auth",
"version": "2026.2.9",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw Gemini CLI OAuth provider plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/googlechat",
"version": "2026.2.9",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw Google Chat channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/imessage",
"version": "2026.2.9",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw iMessage channel plugin",
"type": "module",

17
extensions/irc/index.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { ircPlugin } from "./src/channel.js";
import { setIrcRuntime } from "./src/runtime.js";
const plugin = {
id: "irc",
name: "IRC",
description: "IRC channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
setIrcRuntime(api.runtime);
api.registerChannel({ plugin: ircPlugin as ChannelPlugin });
},
};
export default plugin;

View File

@@ -0,0 +1,9 @@
{
"id": "irc",
"channels": ["irc"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,14 @@
{
"name": "@openclaw/irc",
"version": "2026.2.10",
"description": "OpenClaw IRC channel plugin",
"type": "module",
"devDependencies": {
"openclaw": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,268 @@
import { readFileSync } from "node:fs";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js";
const TRUTHY_ENV = new Set(["true", "1", "yes", "on"]);
export type ResolvedIrcAccount = {
accountId: string;
enabled: boolean;
name?: string;
configured: boolean;
host: string;
port: number;
tls: boolean;
nick: string;
username: string;
realname: string;
password: string;
passwordSource: "env" | "passwordFile" | "config" | "none";
config: IrcAccountConfig;
};
function parseTruthy(value?: string): boolean {
if (!value) {
return false;
}
return TRUTHY_ENV.has(value.trim().toLowerCase());
}
function parseIntEnv(value?: string): number | undefined {
if (!value?.trim()) {
return undefined;
}
const parsed = Number.parseInt(value.trim(), 10);
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 65535) {
return undefined;
}
return parsed;
}
function parseListEnv(value?: string): string[] | undefined {
if (!value?.trim()) {
return undefined;
}
const parsed = value
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
return parsed.length > 0 ? parsed : undefined;
}
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
const accounts = cfg.channels?.irc?.accounts;
if (!accounts || typeof accounts !== "object") {
return [];
}
const ids = new Set<string>();
for (const key of Object.keys(accounts)) {
if (key.trim()) {
ids.add(normalizeAccountId(key));
}
}
return [...ids];
}
function resolveAccountConfig(cfg: CoreConfig, accountId: string): IrcAccountConfig | undefined {
const accounts = cfg.channels?.irc?.accounts;
if (!accounts || typeof accounts !== "object") {
return undefined;
}
const direct = accounts[accountId] as IrcAccountConfig | undefined;
if (direct) {
return direct;
}
const normalized = normalizeAccountId(accountId);
const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized);
return matchKey ? (accounts[matchKey] as IrcAccountConfig | undefined) : undefined;
}
function mergeIrcAccountConfig(cfg: CoreConfig, accountId: string): IrcAccountConfig {
const { accounts: _ignored, ...base } = (cfg.channels?.irc ?? {}) as IrcAccountConfig & {
accounts?: unknown;
};
const account = resolveAccountConfig(cfg, accountId) ?? {};
const merged: IrcAccountConfig = { ...base, ...account };
if (base.nickserv || account.nickserv) {
merged.nickserv = {
...base.nickserv,
...account.nickserv,
};
}
return merged;
}
function resolvePassword(accountId: string, merged: IrcAccountConfig) {
if (accountId === DEFAULT_ACCOUNT_ID) {
const envPassword = process.env.IRC_PASSWORD?.trim();
if (envPassword) {
return { password: envPassword, source: "env" as const };
}
}
if (merged.passwordFile?.trim()) {
try {
const filePassword = readFileSync(merged.passwordFile.trim(), "utf-8").trim();
if (filePassword) {
return { password: filePassword, source: "passwordFile" as const };
}
} catch {
// Ignore unreadable files here; status will still surface missing configuration.
}
}
const configPassword = merged.password?.trim();
if (configPassword) {
return { password: configPassword, source: "config" as const };
}
return { password: "", source: "none" as const };
}
function resolveNickServConfig(accountId: string, nickserv?: IrcNickServConfig): IrcNickServConfig {
const base = nickserv ?? {};
const envPassword =
accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_NICKSERV_PASSWORD?.trim() : undefined;
const envRegisterEmail =
accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_NICKSERV_REGISTER_EMAIL?.trim() : undefined;
const passwordFile = base.passwordFile?.trim();
let resolvedPassword = base.password?.trim() || envPassword || "";
if (!resolvedPassword && passwordFile) {
try {
resolvedPassword = readFileSync(passwordFile, "utf-8").trim();
} catch {
// Ignore unreadable files; monitor/probe status will surface failures.
}
}
const merged: IrcNickServConfig = {
...base,
service: base.service?.trim() || undefined,
passwordFile: passwordFile || undefined,
password: resolvedPassword || undefined,
registerEmail: base.registerEmail?.trim() || envRegisterEmail || undefined,
};
return merged;
}
export function listIrcAccountIds(cfg: CoreConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) {
return [DEFAULT_ACCOUNT_ID];
}
return ids.toSorted((a, b) => a.localeCompare(b));
}
export function resolveDefaultIrcAccountId(cfg: CoreConfig): string {
const ids = listIrcAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
export function resolveIrcAccount(params: {
cfg: CoreConfig;
accountId?: string | null;
}): ResolvedIrcAccount {
const hasExplicitAccountId = Boolean(params.accountId?.trim());
const baseEnabled = params.cfg.channels?.irc?.enabled !== false;
const resolve = (accountId: string) => {
const merged = mergeIrcAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const tls =
typeof merged.tls === "boolean"
? merged.tls
: accountId === DEFAULT_ACCOUNT_ID && process.env.IRC_TLS
? parseTruthy(process.env.IRC_TLS)
: true;
const envPort =
accountId === DEFAULT_ACCOUNT_ID ? parseIntEnv(process.env.IRC_PORT) : undefined;
const port = merged.port ?? envPort ?? (tls ? 6697 : 6667);
const envChannels =
accountId === DEFAULT_ACCOUNT_ID ? parseListEnv(process.env.IRC_CHANNELS) : undefined;
const host = (
merged.host?.trim() ||
(accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_HOST?.trim() : "") ||
""
).trim();
const nick = (
merged.nick?.trim() ||
(accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_NICK?.trim() : "") ||
""
).trim();
const username = (
merged.username?.trim() ||
(accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_USERNAME?.trim() : "") ||
nick ||
"openclaw"
).trim();
const realname = (
merged.realname?.trim() ||
(accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_REALNAME?.trim() : "") ||
"OpenClaw"
).trim();
const passwordResolution = resolvePassword(accountId, merged);
const nickserv = resolveNickServConfig(accountId, merged.nickserv);
const config: IrcAccountConfig = {
...merged,
channels: merged.channels ?? envChannels,
tls,
port,
host,
nick,
username,
realname,
nickserv,
};
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
configured: Boolean(host && nick),
host,
port,
tls,
nick,
username,
realname,
password: passwordResolution.password,
passwordSource: passwordResolution.source,
config,
} satisfies ResolvedIrcAccount;
};
const normalized = normalizeAccountId(params.accountId);
const primary = resolve(normalized);
if (hasExplicitAccountId) {
return primary;
}
if (primary.configured) {
return primary;
}
const fallbackId = resolveDefaultIrcAccountId(params.cfg);
if (fallbackId === primary.accountId) {
return primary;
}
const fallback = resolve(fallbackId);
if (!fallback.configured) {
return primary;
}
return fallback;
}
export function listEnabledIrcAccounts(cfg: CoreConfig): ResolvedIrcAccount[] {
return listIrcAccountIds(cfg)
.map((accountId) => resolveIrcAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}

View File

@@ -0,0 +1,367 @@
import {
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
formatPairingApproveHint,
getChatChannelMeta,
PAIRING_APPROVED_MESSAGE,
setAccountEnabledInConfigSection,
deleteAccountFromConfigSection,
type ChannelPlugin,
} from "openclaw/plugin-sdk";
import type { CoreConfig, IrcProbe } from "./types.js";
import {
listIrcAccountIds,
resolveDefaultIrcAccountId,
resolveIrcAccount,
type ResolvedIrcAccount,
} from "./accounts.js";
import { IrcConfigSchema } from "./config-schema.js";
import { monitorIrcProvider } from "./monitor.js";
import {
normalizeIrcMessagingTarget,
looksLikeIrcTargetId,
isChannelTarget,
normalizeIrcAllowEntry,
} from "./normalize.js";
import { ircOnboardingAdapter } from "./onboarding.js";
import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js";
import { probeIrc } from "./probe.js";
import { getIrcRuntime } from "./runtime.js";
import { sendMessageIrc } from "./send.js";
const meta = getChatChannelMeta("irc");
function normalizePairingTarget(raw: string): string {
const normalized = normalizeIrcAllowEntry(raw);
if (!normalized) {
return "";
}
return normalized.split(/[!@]/, 1)[0]?.trim() ?? "";
}
export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
id: "irc",
meta: {
...meta,
quickstartAllowFrom: true,
},
onboarding: ircOnboardingAdapter,
pairing: {
idLabel: "ircUser",
normalizeAllowEntry: (entry) => normalizeIrcAllowEntry(entry),
notifyApproval: async ({ id }) => {
const target = normalizePairingTarget(id);
if (!target) {
throw new Error(`invalid IRC pairing id: ${id}`);
}
await sendMessageIrc(target, PAIRING_APPROVED_MESSAGE);
},
},
capabilities: {
chatTypes: ["direct", "group"],
media: true,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.irc"] },
configSchema: buildChannelConfigSchema(IrcConfigSchema),
config: {
listAccountIds: (cfg) => listIrcAccountIds(cfg as CoreConfig),
resolveAccount: (cfg, accountId) => resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultIrcAccountId(cfg as CoreConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg as CoreConfig,
sectionKey: "irc",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg as CoreConfig,
sectionKey: "irc",
accountId,
clearBaseFields: [
"name",
"host",
"port",
"tls",
"nick",
"username",
"realname",
"password",
"passwordFile",
"channels",
],
}),
isConfigured: (account) => account.configured,
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
host: account.host,
port: account.port,
tls: account.tls,
nick: account.nick,
passwordSource: account.passwordSource,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom.map((entry) => normalizeIrcAllowEntry(String(entry))).filter(Boolean),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(cfg.channels?.irc?.accounts?.[resolvedAccountId]);
const basePath = useAccountPath
? `channels.irc.accounts.${resolvedAccountId}.`
: "channels.irc.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: `${basePath}allowFrom`,
approveHint: formatPairingApproveHint("irc"),
normalizeEntry: (raw) => normalizeIrcAllowEntry(raw),
};
},
collectWarnings: ({ account, cfg }) => {
const warnings: string[] = [];
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy === "open") {
warnings.push(
'- IRC channels: groupPolicy="open" allows all channels and senders (mention-gated). Prefer channels.irc.groupPolicy="allowlist" with channels.irc.groups.',
);
}
if (!account.config.tls) {
warnings.push(
"- IRC TLS is disabled (channels.irc.tls=false); traffic and credentials are plaintext.",
);
}
if (account.config.nickserv?.register) {
warnings.push(
'- IRC NickServ registration is enabled (channels.irc.nickserv.register=true); this sends "REGISTER" on every connect. Disable after first successful registration.',
);
if (!account.config.nickserv.password?.trim()) {
warnings.push(
"- IRC NickServ registration is enabled but no NickServ password is resolved; set channels.irc.nickserv.password, channels.irc.nickserv.passwordFile, or IRC_NICKSERV_PASSWORD.",
);
}
}
return warnings;
},
},
groups: {
resolveRequireMention: ({ cfg, accountId, groupId }) => {
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
if (!groupId) {
return true;
}
const match = resolveIrcGroupMatch({ groups: account.config.groups, target: groupId });
return resolveIrcRequireMention({
groupConfig: match.groupConfig,
wildcardConfig: match.wildcardConfig,
});
},
resolveToolPolicy: ({ cfg, accountId, groupId }) => {
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
if (!groupId) {
return undefined;
}
const match = resolveIrcGroupMatch({ groups: account.config.groups, target: groupId });
return match.groupConfig?.tools ?? match.wildcardConfig?.tools;
},
},
messaging: {
normalizeTarget: normalizeIrcMessagingTarget,
targetResolver: {
looksLikeId: looksLikeIrcTargetId,
hint: "<#channel|nick>",
},
},
resolver: {
resolveTargets: async ({ inputs, kind }) => {
return inputs.map((input) => {
const normalized = normalizeIrcMessagingTarget(input);
if (!normalized) {
return {
input,
resolved: false,
note: "invalid IRC target",
};
}
if (kind === "group") {
const groupId = isChannelTarget(normalized) ? normalized : `#${normalized}`;
return {
input,
resolved: true,
id: groupId,
name: groupId,
};
}
if (isChannelTarget(normalized)) {
return {
input,
resolved: false,
note: "expected user target",
};
}
return {
input,
resolved: true,
id: normalized,
name: normalized,
};
});
},
},
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
const q = query?.trim().toLowerCase() ?? "";
const ids = new Set<string>();
for (const entry of account.config.allowFrom ?? []) {
const normalized = normalizePairingTarget(String(entry));
if (normalized && normalized !== "*") {
ids.add(normalized);
}
}
for (const entry of account.config.groupAllowFrom ?? []) {
const normalized = normalizePairingTarget(String(entry));
if (normalized && normalized !== "*") {
ids.add(normalized);
}
}
for (const group of Object.values(account.config.groups ?? {})) {
for (const entry of group.allowFrom ?? []) {
const normalized = normalizePairingTarget(String(entry));
if (normalized && normalized !== "*") {
ids.add(normalized);
}
}
}
return Array.from(ids)
.filter((id) => (q ? id.includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "user", id }));
},
listGroups: async ({ cfg, accountId, query, limit }) => {
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
const q = query?.trim().toLowerCase() ?? "";
const groupIds = new Set<string>();
for (const channel of account.config.channels ?? []) {
const normalized = normalizeIrcMessagingTarget(channel);
if (normalized && isChannelTarget(normalized)) {
groupIds.add(normalized);
}
}
for (const group of Object.keys(account.config.groups ?? {})) {
if (group === "*") {
continue;
}
const normalized = normalizeIrcMessagingTarget(group);
if (normalized && isChannelTarget(normalized)) {
groupIds.add(normalized);
}
}
return Array.from(groupIds)
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "group", id, name: id }));
},
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getIrcRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 350,
sendText: async ({ to, text, accountId, replyToId }) => {
const result = await sendMessageIrc(to, text, {
accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined,
});
return { channel: "irc", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => {
const combined = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text;
const result = await sendMessageIrc(to, combined, {
accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined,
});
return { channel: "irc", ...result };
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
buildChannelSummary: ({ account, snapshot }) => ({
configured: snapshot.configured ?? false,
host: account.host,
port: snapshot.port,
tls: account.tls,
nick: account.nick,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ cfg, account, timeoutMs }) =>
probeIrc(cfg as CoreConfig, { accountId: account.accountId, timeoutMs }),
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
host: account.host,
port: account.port,
tls: account.tls,
nick: account.nick,
passwordSource: account.passwordSource,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
}),
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
if (!account.configured) {
throw new Error(
`IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`,
);
}
ctx.log?.info(
`[${account.accountId}] starting IRC provider (${account.host}:${account.port}${account.tls ? " tls" : ""})`,
);
const { stop } = await monitorIrcProvider({
accountId: account.accountId,
config: ctx.cfg as CoreConfig,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
});
return { stop };
},
},
};

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { buildIrcNickServCommands } from "./client.js";
describe("irc client nickserv", () => {
it("builds IDENTIFY command when password is set", () => {
expect(
buildIrcNickServCommands({
password: "secret",
}),
).toEqual(["PRIVMSG NickServ :IDENTIFY secret"]);
});
it("builds REGISTER command when enabled with email", () => {
expect(
buildIrcNickServCommands({
password: "secret",
register: true,
registerEmail: "bot@example.com",
}),
).toEqual([
"PRIVMSG NickServ :IDENTIFY secret",
"PRIVMSG NickServ :REGISTER secret bot@example.com",
]);
});
it("rejects register without registerEmail", () => {
expect(() =>
buildIrcNickServCommands({
password: "secret",
register: true,
}),
).toThrow(/registerEmail/);
});
it("sanitizes outbound NickServ payloads", () => {
expect(
buildIrcNickServCommands({
service: "NickServ\n",
password: "secret\r\nJOIN #bad",
}),
).toEqual(["PRIVMSG NickServ :IDENTIFY secret JOIN #bad"]);
});
});

View File

@@ -0,0 +1,439 @@
import net from "node:net";
import tls from "node:tls";
import {
parseIrcLine,
parseIrcPrefix,
sanitizeIrcOutboundText,
sanitizeIrcTarget,
} from "./protocol.js";
const IRC_ERROR_CODES = new Set(["432", "464", "465"]);
const IRC_NICK_COLLISION_CODES = new Set(["433", "436"]);
export type IrcPrivmsgEvent = {
senderNick: string;
senderUser?: string;
senderHost?: string;
target: string;
text: string;
rawLine: string;
};
export type IrcClientOptions = {
host: string;
port: number;
tls: boolean;
nick: string;
username: string;
realname: string;
password?: string;
nickserv?: IrcNickServOptions;
channels?: string[];
connectTimeoutMs?: number;
messageChunkMaxChars?: number;
abortSignal?: AbortSignal;
onPrivmsg?: (event: IrcPrivmsgEvent) => void | Promise<void>;
onNotice?: (text: string, target?: string) => void;
onError?: (error: Error) => void;
onLine?: (line: string) => void;
};
export type IrcNickServOptions = {
enabled?: boolean;
service?: string;
password?: string;
register?: boolean;
registerEmail?: string;
};
export type IrcClient = {
nick: string;
isReady: () => boolean;
sendRaw: (line: string) => void;
join: (channel: string) => void;
sendPrivmsg: (target: string, text: string) => void;
quit: (reason?: string) => void;
close: () => void;
};
function toError(err: unknown): Error {
if (err instanceof Error) {
return err;
}
return new Error(typeof err === "string" ? err : JSON.stringify(err));
}
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
return new Promise((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
timeoutMs,
);
promise
.then((result) => {
clearTimeout(timer);
resolve(result);
})
.catch((error) => {
clearTimeout(timer);
reject(error);
});
});
}
function buildFallbackNick(nick: string): string {
const normalized = nick.replace(/\s+/g, "");
const safe = normalized.replace(/[^A-Za-z0-9_\-\[\]\\`^{}|]/g, "");
const base = safe || "openclaw";
const suffix = "_";
const maxNickLen = 30;
if (base.length >= maxNickLen) {
return `${base.slice(0, maxNickLen - suffix.length)}${suffix}`;
}
return `${base}${suffix}`;
}
export function buildIrcNickServCommands(options?: IrcNickServOptions): string[] {
if (!options || options.enabled === false) {
return [];
}
const password = sanitizeIrcOutboundText(options.password ?? "");
if (!password) {
return [];
}
const service = sanitizeIrcTarget(options.service?.trim() || "NickServ");
const commands = [`PRIVMSG ${service} :IDENTIFY ${password}`];
if (options.register) {
const registerEmail = sanitizeIrcOutboundText(options.registerEmail ?? "");
if (!registerEmail) {
throw new Error("IRC NickServ register requires registerEmail");
}
commands.push(`PRIVMSG ${service} :REGISTER ${password} ${registerEmail}`);
}
return commands;
}
export async function connectIrcClient(options: IrcClientOptions): Promise<IrcClient> {
const timeoutMs = options.connectTimeoutMs != null ? options.connectTimeoutMs : 15000;
const messageChunkMaxChars =
options.messageChunkMaxChars != null ? options.messageChunkMaxChars : 350;
if (!options.host.trim()) {
throw new Error("IRC host is required");
}
if (!options.nick.trim()) {
throw new Error("IRC nick is required");
}
const desiredNick = options.nick.trim();
let currentNick = desiredNick;
let ready = false;
let closed = false;
let nickServRecoverAttempted = false;
let fallbackNickAttempted = false;
const socket = options.tls
? tls.connect({
host: options.host,
port: options.port,
servername: options.host,
})
: net.connect({ host: options.host, port: options.port });
socket.setEncoding("utf8");
let resolveReady: (() => void) | null = null;
let rejectReady: ((error: Error) => void) | null = null;
const readyPromise = new Promise<void>((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
const fail = (err: unknown) => {
const error = toError(err);
if (options.onError) {
options.onError(error);
}
if (!ready && rejectReady) {
rejectReady(error);
rejectReady = null;
resolveReady = null;
}
};
const sendRaw = (line: string) => {
const cleaned = line.replace(/[\r\n]+/g, "").trim();
if (!cleaned) {
throw new Error("IRC command cannot be empty");
}
socket.write(`${cleaned}\r\n`);
};
const tryRecoverNickCollision = (): boolean => {
const nickServEnabled = options.nickserv?.enabled !== false;
const nickservPassword = sanitizeIrcOutboundText(options.nickserv?.password ?? "");
if (nickServEnabled && !nickServRecoverAttempted && nickservPassword) {
nickServRecoverAttempted = true;
try {
const service = sanitizeIrcTarget(options.nickserv?.service?.trim() || "NickServ");
sendRaw(`PRIVMSG ${service} :GHOST ${desiredNick} ${nickservPassword}`);
sendRaw(`NICK ${desiredNick}`);
return true;
} catch (err) {
fail(err);
}
}
if (!fallbackNickAttempted) {
fallbackNickAttempted = true;
const fallbackNick = buildFallbackNick(desiredNick);
if (fallbackNick.toLowerCase() !== currentNick.toLowerCase()) {
try {
sendRaw(`NICK ${fallbackNick}`);
currentNick = fallbackNick;
return true;
} catch (err) {
fail(err);
}
}
}
return false;
};
const join = (channel: string) => {
const target = sanitizeIrcTarget(channel);
if (!target.startsWith("#") && !target.startsWith("&")) {
throw new Error(`IRC JOIN target must be a channel: ${channel}`);
}
sendRaw(`JOIN ${target}`);
};
const sendPrivmsg = (target: string, text: string) => {
const normalizedTarget = sanitizeIrcTarget(target);
const cleaned = sanitizeIrcOutboundText(text);
if (!cleaned) {
return;
}
let remaining = cleaned;
while (remaining.length > 0) {
let chunk = remaining;
if (chunk.length > messageChunkMaxChars) {
let splitAt = chunk.lastIndexOf(" ", messageChunkMaxChars);
if (splitAt < Math.floor(messageChunkMaxChars / 2)) {
splitAt = messageChunkMaxChars;
}
chunk = chunk.slice(0, splitAt).trim();
}
if (!chunk) {
break;
}
sendRaw(`PRIVMSG ${normalizedTarget} :${chunk}`);
remaining = remaining.slice(chunk.length).trimStart();
}
};
const quit = (reason?: string) => {
if (closed) {
return;
}
closed = true;
const safeReason = sanitizeIrcOutboundText(reason != null ? reason : "bye");
try {
if (safeReason) {
sendRaw(`QUIT :${safeReason}`);
} else {
sendRaw("QUIT");
}
} catch {
// Ignore quit failures while shutting down.
}
socket.end();
};
const close = () => {
if (closed) {
return;
}
closed = true;
socket.destroy();
};
let buffer = "";
socket.on("data", (chunk: string) => {
buffer += chunk;
let idx = buffer.indexOf("\n");
while (idx !== -1) {
const rawLine = buffer.slice(0, idx).replace(/\r$/, "");
buffer = buffer.slice(idx + 1);
idx = buffer.indexOf("\n");
if (!rawLine) {
continue;
}
if (options.onLine) {
options.onLine(rawLine);
}
const line = parseIrcLine(rawLine);
if (!line) {
continue;
}
if (line.command === "PING") {
const payload =
line.trailing != null ? line.trailing : line.params[0] != null ? line.params[0] : "";
sendRaw(`PONG :${payload}`);
continue;
}
if (line.command === "NICK") {
const prefix = parseIrcPrefix(line.prefix);
if (prefix.nick && prefix.nick.toLowerCase() === currentNick.toLowerCase()) {
const next =
line.trailing != null
? line.trailing
: line.params[0] != null
? line.params[0]
: currentNick;
currentNick = String(next).trim();
}
continue;
}
if (!ready && IRC_NICK_COLLISION_CODES.has(line.command)) {
if (tryRecoverNickCollision()) {
continue;
}
const detail =
line.trailing != null ? line.trailing : line.params.join(" ") || "nickname in use";
fail(new Error(`IRC login failed (${line.command}): ${detail}`));
close();
return;
}
if (!ready && IRC_ERROR_CODES.has(line.command)) {
const detail =
line.trailing != null ? line.trailing : line.params.join(" ") || "login rejected";
fail(new Error(`IRC login failed (${line.command}): ${detail}`));
close();
return;
}
if (line.command === "001") {
ready = true;
const nickParam = line.params[0];
if (nickParam && nickParam.trim()) {
currentNick = nickParam.trim();
}
try {
const nickServCommands = buildIrcNickServCommands(options.nickserv);
for (const command of nickServCommands) {
sendRaw(command);
}
} catch (err) {
fail(err);
}
for (const channel of options.channels || []) {
const trimmed = channel.trim();
if (!trimmed) {
continue;
}
try {
join(trimmed);
} catch (err) {
fail(err);
}
}
if (resolveReady) {
resolveReady();
}
resolveReady = null;
rejectReady = null;
continue;
}
if (line.command === "NOTICE") {
if (options.onNotice) {
options.onNotice(line.trailing != null ? line.trailing : "", line.params[0]);
}
continue;
}
if (line.command === "PRIVMSG") {
const targetParam = line.params[0];
const target = targetParam ? targetParam.trim() : "";
const text = line.trailing != null ? line.trailing : "";
const prefix = parseIrcPrefix(line.prefix);
const senderNick = prefix.nick ? prefix.nick.trim() : "";
if (!target || !senderNick || !text.trim()) {
continue;
}
if (options.onPrivmsg) {
void Promise.resolve(
options.onPrivmsg({
senderNick,
senderUser: prefix.user ? prefix.user.trim() : undefined,
senderHost: prefix.host ? prefix.host.trim() : undefined,
target,
text,
rawLine,
}),
).catch((error) => {
fail(error);
});
}
}
}
});
socket.once("connect", () => {
try {
if (options.password && options.password.trim()) {
sendRaw(`PASS ${options.password.trim()}`);
}
sendRaw(`NICK ${options.nick.trim()}`);
sendRaw(`USER ${options.username.trim()} 0 * :${sanitizeIrcOutboundText(options.realname)}`);
} catch (err) {
fail(err);
close();
}
});
socket.once("error", (err) => {
fail(err);
});
socket.once("close", () => {
if (!closed) {
closed = true;
if (!ready) {
fail(new Error("IRC connection closed before ready"));
}
}
});
if (options.abortSignal) {
const abort = () => {
quit("shutdown");
};
if (options.abortSignal.aborted) {
abort();
} else {
options.abortSignal.addEventListener("abort", abort, { once: true });
}
}
await withTimeout(readyPromise, timeoutMs, "IRC connect");
return {
get nick() {
return currentNick;
},
isReady: () => ready && !closed,
sendRaw,
join,
sendPrivmsg,
quit,
close,
};
}

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { IrcConfigSchema } from "./config-schema.js";
describe("irc config schema", () => {
it("accepts numeric allowFrom and groupAllowFrom entries", () => {
const parsed = IrcConfigSchema.parse({
dmPolicy: "allowlist",
allowFrom: [12345, "alice"],
groupAllowFrom: [67890, "alice!ident@example.org"],
});
expect(parsed.allowFrom).toEqual([12345, "alice"]);
expect(parsed.groupAllowFrom).toEqual([67890, "alice!ident@example.org"]);
});
it("accepts numeric per-channel allowFrom entries", () => {
const parsed = IrcConfigSchema.parse({
groups: {
"#ops": {
allowFrom: [42, "alice"],
},
},
});
expect(parsed.groups?.["#ops"]?.allowFrom).toEqual([42, "alice"]);
});
});

View File

@@ -0,0 +1,97 @@
import {
BlockStreamingCoalesceSchema,
DmConfigSchema,
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
ToolPolicySchema,
requireOpenAllowFrom,
} from "openclaw/plugin-sdk";
import { z } from "zod";
const IrcGroupSchema = z
.object({
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,
toolsBySender: z.record(z.string(), ToolPolicySchema).optional(),
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
systemPrompt: z.string().optional(),
})
.strict();
const IrcNickServSchema = z
.object({
enabled: z.boolean().optional(),
service: z.string().optional(),
password: z.string().optional(),
passwordFile: z.string().optional(),
register: z.boolean().optional(),
registerEmail: z.string().optional(),
})
.strict()
.superRefine((value, ctx) => {
if (value.register && !value.registerEmail?.trim()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["registerEmail"],
message: "channels.irc.nickserv.register=true requires channels.irc.nickserv.registerEmail",
});
}
});
export const IrcAccountSchemaBase = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
host: z.string().optional(),
port: z.number().int().min(1).max(65535).optional(),
tls: z.boolean().optional(),
nick: z.string().optional(),
username: z.string().optional(),
realname: z.string().optional(),
password: z.string().optional(),
passwordFile: z.string().optional(),
nickserv: IrcNickServSchema.optional(),
dmPolicy: DmPolicySchema.optional().default("pairing"),
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
groups: z.record(z.string(), IrcGroupSchema.optional()).optional(),
channels: z.array(z.string()).optional(),
mentionPatterns: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
responsePrefix: z.string().optional(),
mediaMaxMb: z.number().positive().optional(),
})
.strict();
export const IrcAccountSchema = IrcAccountSchemaBase.superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message: 'channels.irc.dmPolicy="open" requires channels.irc.allowFrom to include "*"',
});
});
export const IrcConfigSchema = IrcAccountSchemaBase.extend({
accounts: z.record(z.string(), IrcAccountSchema.optional()).optional(),
}).superRefine((value, ctx) => {
requireOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
path: ["allowFrom"],
message: 'channels.irc.dmPolicy="open" requires channels.irc.allowFrom to include "*"',
});
});

View File

@@ -0,0 +1,22 @@
export function isIrcControlChar(charCode: number): boolean {
return charCode <= 0x1f || charCode === 0x7f;
}
export function hasIrcControlChars(value: string): boolean {
for (const char of value) {
if (isIrcControlChar(char.charCodeAt(0))) {
return true;
}
}
return false;
}
export function stripIrcControlChars(value: string): string {
let out = "";
for (const char of value) {
if (!isIrcControlChar(char.charCodeAt(0))) {
out += char;
}
}
return out;
}

View File

@@ -0,0 +1,334 @@
import {
createReplyPrefixOptions,
logInboundDrop,
resolveControlCommandGate,
type OpenClawConfig,
type RuntimeEnv,
} from "openclaw/plugin-sdk";
import type { ResolvedIrcAccount } from "./accounts.js";
import type { CoreConfig, IrcInboundMessage } from "./types.js";
import { normalizeIrcAllowlist, resolveIrcAllowlistMatch } from "./normalize.js";
import {
resolveIrcMentionGate,
resolveIrcGroupAccessGate,
resolveIrcGroupMatch,
resolveIrcGroupSenderAllowed,
resolveIrcRequireMention,
} from "./policy.js";
import { getIrcRuntime } from "./runtime.js";
import { sendMessageIrc } from "./send.js";
const CHANNEL_ID = "irc" as const;
const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
async function deliverIrcReply(params: {
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string };
target: string;
accountId: string;
sendReply?: (target: string, text: string, replyToId?: string) => Promise<void>;
statusSink?: (patch: { lastOutboundAt?: number }) => void;
}) {
const text = params.payload.text ?? "";
const mediaList = params.payload.mediaUrls?.length
? params.payload.mediaUrls
: params.payload.mediaUrl
? [params.payload.mediaUrl]
: [];
if (!text.trim() && mediaList.length === 0) {
return;
}
const mediaBlock = mediaList.length
? mediaList.map((url) => `Attachment: ${url}`).join("\n")
: "";
const combined = text.trim()
? mediaBlock
? `${text.trim()}\n\n${mediaBlock}`
: text.trim()
: mediaBlock;
if (params.sendReply) {
await params.sendReply(params.target, combined, params.payload.replyToId);
} else {
await sendMessageIrc(params.target, combined, {
accountId: params.accountId,
replyTo: params.payload.replyToId,
});
}
params.statusSink?.({ lastOutboundAt: Date.now() });
}
export async function handleIrcInbound(params: {
message: IrcInboundMessage;
account: ResolvedIrcAccount;
config: CoreConfig;
runtime: RuntimeEnv;
connectedNick?: string;
sendReply?: (target: string, text: string, replyToId?: string) => Promise<void>;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
}): Promise<void> {
const { message, account, config, runtime, connectedNick, statusSink } = params;
const core = getIrcRuntime();
const rawBody = message.text?.trim() ?? "";
if (!rawBody) {
return;
}
statusSink?.({ lastInboundAt: message.timestamp });
const senderDisplay = message.senderHost
? `${message.senderNick}!${message.senderUser ?? "?"}@${message.senderHost}`
: message.senderNick;
const dmPolicy = account.config.dmPolicy ?? "pairing";
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const configAllowFrom = normalizeIrcAllowlist(account.config.allowFrom);
const configGroupAllowFrom = normalizeIrcAllowlist(account.config.groupAllowFrom);
const storeAllowFrom = await core.channel.pairing.readAllowFromStore(CHANNEL_ID).catch(() => []);
const storeAllowList = normalizeIrcAllowlist(storeAllowFrom);
const groupMatch = resolveIrcGroupMatch({
groups: account.config.groups,
target: message.target,
});
if (message.isGroup) {
const groupAccess = resolveIrcGroupAccessGate({ groupPolicy, groupMatch });
if (!groupAccess.allowed) {
runtime.log?.(`irc: drop channel ${message.target} (${groupAccess.reason})`);
return;
}
}
const directGroupAllowFrom = normalizeIrcAllowlist(groupMatch.groupConfig?.allowFrom);
const wildcardGroupAllowFrom = normalizeIrcAllowlist(groupMatch.wildcardConfig?.allowFrom);
const groupAllowFrom =
directGroupAllowFrom.length > 0 ? directGroupAllowFrom : wildcardGroupAllowFrom;
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean);
const effectiveGroupAllowFrom = [...configGroupAllowFrom, ...storeAllowList].filter(Boolean);
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg: config as OpenClawConfig,
surface: CHANNEL_ID,
});
const useAccessGroups = config.commands?.useAccessGroups !== false;
const senderAllowedForCommands = resolveIrcAllowlistMatch({
allowFrom: message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom,
message,
}).allowed;
const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig);
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{
configured: (message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom).length > 0,
allowed: senderAllowedForCommands,
},
],
allowTextCommands,
hasControlCommand,
});
const commandAuthorized = commandGate.commandAuthorized;
if (message.isGroup) {
const senderAllowed = resolveIrcGroupSenderAllowed({
groupPolicy,
message,
outerAllowFrom: effectiveGroupAllowFrom,
innerAllowFrom: groupAllowFrom,
});
if (!senderAllowed) {
runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`);
return;
}
} else {
if (dmPolicy === "disabled") {
runtime.log?.(`irc: drop DM sender=${senderDisplay} (dmPolicy=disabled)`);
return;
}
if (dmPolicy !== "open") {
const dmAllowed = resolveIrcAllowlistMatch({
allowFrom: effectiveAllowFrom,
message,
}).allowed;
if (!dmAllowed) {
if (dmPolicy === "pairing") {
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: CHANNEL_ID,
id: senderDisplay.toLowerCase(),
meta: { name: message.senderNick || undefined },
});
if (created) {
try {
const reply = core.channel.pairing.buildPairingReply({
channel: CHANNEL_ID,
idLine: `Your IRC id: ${senderDisplay}`,
code,
});
await deliverIrcReply({
payload: { text: reply },
target: message.senderNick,
accountId: account.accountId,
sendReply: params.sendReply,
statusSink,
});
} catch (err) {
runtime.error?.(`irc: pairing reply failed for ${senderDisplay}: ${String(err)}`);
}
}
}
runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
return;
}
}
}
if (message.isGroup && commandGate.shouldBlock) {
logInboundDrop({
log: (line) => runtime.log?.(line),
channel: CHANNEL_ID,
reason: "control command (unauthorized)",
target: senderDisplay,
});
return;
}
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config as OpenClawConfig);
const mentionNick = connectedNick?.trim() || account.nick;
const explicitMentionRegex = mentionNick
? new RegExp(`\\b${escapeIrcRegexLiteral(mentionNick)}\\b[:,]?`, "i")
: null;
const wasMentioned =
core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes) ||
(explicitMentionRegex ? explicitMentionRegex.test(rawBody) : false);
const requireMention = message.isGroup
? resolveIrcRequireMention({
groupConfig: groupMatch.groupConfig,
wildcardConfig: groupMatch.wildcardConfig,
})
: false;
const mentionGate = resolveIrcMentionGate({
isGroup: message.isGroup,
requireMention,
wasMentioned,
hasControlCommand,
allowTextCommands,
commandAuthorized,
});
if (mentionGate.shouldSkip) {
runtime.log?.(`irc: drop channel ${message.target} (${mentionGate.reason})`);
return;
}
const peerId = message.isGroup ? message.target : message.senderNick;
const route = core.channel.routing.resolveAgentRoute({
cfg: config as OpenClawConfig,
channel: CHANNEL_ID,
accountId: account.accountId,
peer: {
kind: message.isGroup ? "group" : "direct",
id: peerId,
},
});
const fromLabel = message.isGroup ? message.target : senderDisplay;
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config as OpenClawConfig);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "IRC",
from: fromLabel,
timestamp: message.timestamp,
previousTimestamp,
envelope: envelopeOptions,
body: rawBody,
});
const groupSystemPrompt = groupMatch.groupConfig?.systemPrompt?.trim() || undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
RawBody: rawBody,
CommandBody: rawBody,
From: message.isGroup ? `irc:channel:${message.target}` : `irc:${senderDisplay}`,
To: `irc:${peerId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: message.isGroup ? "group" : "direct",
ConversationLabel: fromLabel,
SenderName: message.senderNick || undefined,
SenderId: senderDisplay,
GroupSubject: message.isGroup ? message.target : undefined,
GroupSystemPrompt: message.isGroup ? groupSystemPrompt : undefined,
Provider: CHANNEL_ID,
Surface: CHANNEL_ID,
WasMentioned: message.isGroup ? wasMentioned : undefined,
MessageSid: message.messageId,
Timestamp: message.timestamp,
OriginatingChannel: CHANNEL_ID,
OriginatingTo: `irc:${peerId}`,
CommandAuthorized: commandAuthorized,
});
await core.channel.session.recordInboundSession({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
onRecordError: (err) => {
runtime.error?.(`irc: failed updating session meta: ${String(err)}`);
},
});
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg: config as OpenClawConfig,
agentId: route.agentId,
channel: CHANNEL_ID,
accountId: account.accountId,
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config as OpenClawConfig,
dispatcherOptions: {
...prefixOptions,
deliver: async (payload) => {
await deliverIrcReply({
payload: payload as {
text?: string;
mediaUrls?: string[];
mediaUrl?: string;
replyToId?: string;
},
target: peerId,
accountId: account.accountId,
sendReply: params.sendReply,
statusSink,
});
},
onError: (err, info) => {
runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`);
},
},
replyOptions: {
skillFilter: groupMatch.groupConfig?.skills,
onModelSelected,
disableBlockStreaming:
typeof account.config.blockStreaming === "boolean"
? !account.config.blockStreaming
: undefined,
},
});
}

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { resolveIrcInboundTarget } from "./monitor.js";
describe("irc monitor inbound target", () => {
it("keeps channel target for group messages", () => {
expect(
resolveIrcInboundTarget({
target: "#openclaw",
senderNick: "alice",
}),
).toEqual({
isGroup: true,
target: "#openclaw",
rawTarget: "#openclaw",
});
});
it("maps DM target to sender nick and preserves raw target", () => {
expect(
resolveIrcInboundTarget({
target: "openclaw-bot",
senderNick: "alice",
}),
).toEqual({
isGroup: false,
target: "alice",
rawTarget: "openclaw-bot",
});
});
it("falls back to raw target when sender nick is empty", () => {
expect(
resolveIrcInboundTarget({
target: "openclaw-bot",
senderNick: " ",
}),
).toEqual({
isGroup: false,
target: "openclaw-bot",
rawTarget: "openclaw-bot",
});
});
});

View File

@@ -0,0 +1,158 @@
import type { RuntimeEnv } from "openclaw/plugin-sdk";
import type { CoreConfig, IrcInboundMessage } from "./types.js";
import { resolveIrcAccount } from "./accounts.js";
import { connectIrcClient, type IrcClient } from "./client.js";
import { handleIrcInbound } from "./inbound.js";
import { isChannelTarget } from "./normalize.js";
import { makeIrcMessageId } from "./protocol.js";
import { getIrcRuntime } from "./runtime.js";
export type IrcMonitorOptions = {
accountId?: string;
config?: CoreConfig;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
onMessage?: (message: IrcInboundMessage, client: IrcClient) => void | Promise<void>;
};
export function resolveIrcInboundTarget(params: { target: string; senderNick: string }): {
isGroup: boolean;
target: string;
rawTarget: string;
} {
const rawTarget = params.target;
const isGroup = isChannelTarget(rawTarget);
if (isGroup) {
return { isGroup: true, target: rawTarget, rawTarget };
}
const senderNick = params.senderNick.trim();
return { isGroup: false, target: senderNick || rawTarget, rawTarget };
}
export async function monitorIrcProvider(opts: IrcMonitorOptions): Promise<{ stop: () => void }> {
const core = getIrcRuntime();
const cfg = opts.config ?? (core.config.loadConfig() as CoreConfig);
const account = resolveIrcAccount({
cfg,
accountId: opts.accountId,
});
const runtime: RuntimeEnv = opts.runtime ?? {
log: (message: string) => core.logging.getChildLogger().info(message),
error: (message: string) => core.logging.getChildLogger().error(message),
exit: () => {
throw new Error("Runtime exit not available");
},
};
if (!account.configured) {
throw new Error(
`IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`,
);
}
const logger = core.logging.getChildLogger({
channel: "irc",
accountId: account.accountId,
});
let client: IrcClient | null = null;
client = await connectIrcClient({
host: account.host,
port: account.port,
tls: account.tls,
nick: account.nick,
username: account.username,
realname: account.realname,
password: account.password,
nickserv: {
enabled: account.config.nickserv?.enabled,
service: account.config.nickserv?.service,
password: account.config.nickserv?.password,
register: account.config.nickserv?.register,
registerEmail: account.config.nickserv?.registerEmail,
},
channels: account.config.channels,
abortSignal: opts.abortSignal,
onLine: (line) => {
if (core.logging.shouldLogVerbose()) {
logger.debug?.(`[${account.accountId}] << ${line}`);
}
},
onNotice: (text, target) => {
if (core.logging.shouldLogVerbose()) {
logger.debug?.(`[${account.accountId}] notice ${target ?? ""}: ${text}`);
}
},
onError: (error) => {
logger.error(`[${account.accountId}] IRC error: ${error.message}`);
},
onPrivmsg: async (event) => {
if (!client) {
return;
}
if (event.senderNick.toLowerCase() === client.nick.toLowerCase()) {
return;
}
const inboundTarget = resolveIrcInboundTarget({
target: event.target,
senderNick: event.senderNick,
});
const message: IrcInboundMessage = {
messageId: makeIrcMessageId(),
target: inboundTarget.target,
rawTarget: inboundTarget.rawTarget,
senderNick: event.senderNick,
senderUser: event.senderUser,
senderHost: event.senderHost,
text: event.text,
timestamp: Date.now(),
isGroup: inboundTarget.isGroup,
};
core.channel.activity.record({
channel: "irc",
accountId: account.accountId,
direction: "inbound",
at: message.timestamp,
});
if (opts.onMessage) {
await opts.onMessage(message, client);
return;
}
await handleIrcInbound({
message,
account,
config: cfg,
runtime,
connectedNick: client.nick,
sendReply: async (target, text) => {
client?.sendPrivmsg(target, text);
opts.statusSink?.({ lastOutboundAt: Date.now() });
core.channel.activity.record({
channel: "irc",
accountId: account.accountId,
direction: "outbound",
});
},
statusSink: opts.statusSink,
});
},
});
logger.info(
`[${account.accountId}] connected to ${account.host}:${account.port}${account.tls ? " (tls)" : ""} as ${client.nick}`,
);
return {
stop: () => {
client?.quit("shutdown");
client = null;
},
};
}

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
buildIrcAllowlistCandidates,
normalizeIrcAllowEntry,
normalizeIrcMessagingTarget,
resolveIrcAllowlistMatch,
} from "./normalize.js";
describe("irc normalize", () => {
it("normalizes targets", () => {
expect(normalizeIrcMessagingTarget("irc:channel:openclaw")).toBe("#openclaw");
expect(normalizeIrcMessagingTarget("user:alice")).toBe("alice");
expect(normalizeIrcMessagingTarget("\n")).toBeUndefined();
});
it("normalizes allowlist entries", () => {
expect(normalizeIrcAllowEntry("IRC:User:Alice!u@h")).toBe("alice!u@h");
});
it("matches senders by nick/user/host candidates", () => {
const message = {
messageId: "m1",
target: "#chan",
senderNick: "Alice",
senderUser: "ident",
senderHost: "example.org",
text: "hi",
timestamp: Date.now(),
isGroup: true,
};
expect(buildIrcAllowlistCandidates(message)).toContain("alice!ident@example.org");
expect(
resolveIrcAllowlistMatch({
allowFrom: ["alice!ident@example.org"],
message,
}).allowed,
).toBe(true);
expect(
resolveIrcAllowlistMatch({
allowFrom: ["bob"],
message,
}).allowed,
).toBe(false);
});
});

View File

@@ -0,0 +1,117 @@
import type { IrcInboundMessage } from "./types.js";
import { hasIrcControlChars } from "./control-chars.js";
const IRC_TARGET_PATTERN = /^[^\s:]+$/u;
export function isChannelTarget(target: string): boolean {
return target.startsWith("#") || target.startsWith("&");
}
export function normalizeIrcMessagingTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) {
return undefined;
}
let target = trimmed;
const lowered = target.toLowerCase();
if (lowered.startsWith("irc:")) {
target = target.slice("irc:".length).trim();
}
if (target.toLowerCase().startsWith("channel:")) {
target = target.slice("channel:".length).trim();
if (!target.startsWith("#") && !target.startsWith("&")) {
target = `#${target}`;
}
}
if (target.toLowerCase().startsWith("user:")) {
target = target.slice("user:".length).trim();
}
if (!target || !looksLikeIrcTargetId(target)) {
return undefined;
}
return target;
}
export function looksLikeIrcTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
if (hasIrcControlChars(trimmed)) {
return false;
}
return IRC_TARGET_PATTERN.test(trimmed);
}
export function normalizeIrcAllowEntry(raw: string): string {
let value = raw.trim().toLowerCase();
if (!value) {
return "";
}
if (value.startsWith("irc:")) {
value = value.slice("irc:".length);
}
if (value.startsWith("user:")) {
value = value.slice("user:".length);
}
return value.trim();
}
export function normalizeIrcAllowlist(entries?: Array<string | number>): string[] {
return (entries ?? []).map((entry) => normalizeIrcAllowEntry(String(entry))).filter(Boolean);
}
export function formatIrcSenderId(message: IrcInboundMessage): string {
const base = message.senderNick.trim();
const user = message.senderUser?.trim();
const host = message.senderHost?.trim();
if (user && host) {
return `${base}!${user}@${host}`;
}
if (user) {
return `${base}!${user}`;
}
if (host) {
return `${base}@${host}`;
}
return base;
}
export function buildIrcAllowlistCandidates(message: IrcInboundMessage): string[] {
const nick = message.senderNick.trim().toLowerCase();
const user = message.senderUser?.trim().toLowerCase();
const host = message.senderHost?.trim().toLowerCase();
const candidates = new Set<string>();
if (nick) {
candidates.add(nick);
}
if (nick && user) {
candidates.add(`${nick}!${user}`);
}
if (nick && host) {
candidates.add(`${nick}@${host}`);
}
if (nick && user && host) {
candidates.add(`${nick}!${user}@${host}`);
}
return [...candidates];
}
export function resolveIrcAllowlistMatch(params: {
allowFrom: string[];
message: IrcInboundMessage;
}): { allowed: boolean; source?: string } {
const allowFrom = new Set(
params.allowFrom.map((entry) => entry.trim().toLowerCase()).filter(Boolean),
);
if (allowFrom.has("*")) {
return { allowed: true, source: "wildcard" };
}
const candidates = buildIrcAllowlistCandidates(params.message);
for (const candidate of candidates) {
if (allowFrom.has(candidate)) {
return { allowed: true, source: candidate };
}
}
return { allowed: false };
}

View File

@@ -0,0 +1,118 @@
import type { RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk";
import { describe, expect, it, vi } from "vitest";
import type { CoreConfig } from "./types.js";
import { ircOnboardingAdapter } from "./onboarding.js";
describe("irc onboarding", () => {
it("configures host and nick via onboarding prompts", async () => {
const prompter: WizardPrompter = {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async () => {}),
select: vi.fn(async () => "allowlist"),
multiselect: vi.fn(async () => []),
text: vi.fn(async ({ message }: { message: string }) => {
if (message === "IRC server host") {
return "irc.libera.chat";
}
if (message === "IRC server port") {
return "6697";
}
if (message === "IRC nick") {
return "openclaw-bot";
}
if (message === "IRC username") {
return "openclaw";
}
if (message === "IRC real name") {
return "OpenClaw Bot";
}
if (message.startsWith("Auto-join IRC channels")) {
return "#openclaw, #ops";
}
if (message.startsWith("IRC channels allowlist")) {
return "#openclaw, #ops";
}
throw new Error(`Unexpected prompt: ${message}`);
}) as WizardPrompter["text"],
confirm: vi.fn(async ({ message }: { message: string }) => {
if (message === "Use TLS for IRC?") {
return true;
}
if (message === "Configure IRC channels access?") {
return true;
}
return false;
}),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
};
const runtime: RuntimeEnv = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
const result = await ircOnboardingAdapter.configure({
cfg: {} as CoreConfig,
runtime,
prompter,
options: {},
accountOverrides: {},
shouldPromptAccountIds: false,
forceAllowFrom: false,
});
expect(result.accountId).toBe("default");
expect(result.cfg.channels?.irc?.enabled).toBe(true);
expect(result.cfg.channels?.irc?.host).toBe("irc.libera.chat");
expect(result.cfg.channels?.irc?.nick).toBe("openclaw-bot");
expect(result.cfg.channels?.irc?.tls).toBe(true);
expect(result.cfg.channels?.irc?.channels).toEqual(["#openclaw", "#ops"]);
expect(result.cfg.channels?.irc?.groupPolicy).toBe("allowlist");
expect(Object.keys(result.cfg.channels?.irc?.groups ?? {})).toEqual(["#openclaw", "#ops"]);
});
it("writes DM allowFrom to top-level config for non-default account prompts", async () => {
const prompter: WizardPrompter = {
intro: vi.fn(async () => {}),
outro: vi.fn(async () => {}),
note: vi.fn(async () => {}),
select: vi.fn(async () => "allowlist"),
multiselect: vi.fn(async () => []),
text: vi.fn(async ({ message }: { message: string }) => {
if (message === "IRC allowFrom (nick or nick!user@host)") {
return "Alice, Bob!ident@example.org";
}
throw new Error(`Unexpected prompt: ${message}`);
}) as WizardPrompter["text"],
confirm: vi.fn(async () => false),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
};
const promptAllowFrom = ircOnboardingAdapter.dmPolicy?.promptAllowFrom;
expect(promptAllowFrom).toBeTypeOf("function");
const cfg: CoreConfig = {
channels: {
irc: {
accounts: {
work: {
host: "irc.libera.chat",
nick: "openclaw-work",
},
},
},
},
};
const updated = (await promptAllowFrom?.({
cfg,
prompter,
accountId: "work",
})) as CoreConfig;
expect(updated.channels?.irc?.allowFrom).toEqual(["alice", "bob!ident@example.org"]);
expect(updated.channels?.irc?.accounts?.work?.allowFrom).toBeUndefined();
});
});

View File

@@ -0,0 +1,479 @@
import {
addWildcardAllowFrom,
DEFAULT_ACCOUNT_ID,
formatDocsLink,
promptAccountId,
promptChannelAccessConfig,
type ChannelOnboardingAdapter,
type ChannelOnboardingDmPolicy,
type DmPolicy,
type WizardPrompter,
} from "openclaw/plugin-sdk";
import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js";
import { listIrcAccountIds, resolveDefaultIrcAccountId, resolveIrcAccount } from "./accounts.js";
import {
isChannelTarget,
normalizeIrcAllowEntry,
normalizeIrcMessagingTarget,
} from "./normalize.js";
const channel = "irc" as const;
function parseListInput(raw: string): string[] {
return raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
function parsePort(raw: string, fallback: number): number {
const trimmed = raw.trim();
if (!trimmed) {
return fallback;
}
const parsed = Number.parseInt(trimmed, 10);
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
return fallback;
}
return parsed;
}
function normalizeGroupEntry(raw: string): string | null {
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
if (trimmed === "*") {
return "*";
}
const normalized = normalizeIrcMessagingTarget(trimmed) ?? trimmed;
if (isChannelTarget(normalized)) {
return normalized;
}
return `#${normalized.replace(/^#+/, "")}`;
}
function updateIrcAccountConfig(
cfg: CoreConfig,
accountId: string,
patch: Partial<IrcAccountConfig>,
): CoreConfig {
const current = cfg.channels?.irc ?? {};
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
irc: {
...current,
...patch,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
irc: {
...current,
accounts: {
...current.accounts,
[accountId]: {
...current.accounts?.[accountId],
...patch,
},
},
},
},
};
}
function setIrcDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig {
const allowFrom =
dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.irc?.allowFrom) : undefined;
return {
...cfg,
channels: {
...cfg.channels,
irc: {
...cfg.channels?.irc,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
};
}
function setIrcAllowFrom(cfg: CoreConfig, allowFrom: string[]): CoreConfig {
return {
...cfg,
channels: {
...cfg.channels,
irc: {
...cfg.channels?.irc,
allowFrom,
},
},
};
}
function setIrcNickServ(
cfg: CoreConfig,
accountId: string,
nickserv?: IrcNickServConfig,
): CoreConfig {
return updateIrcAccountConfig(cfg, accountId, { nickserv });
}
function setIrcGroupAccess(
cfg: CoreConfig,
accountId: string,
policy: "open" | "allowlist" | "disabled",
entries: string[],
): CoreConfig {
if (policy !== "allowlist") {
return updateIrcAccountConfig(cfg, accountId, { enabled: true, groupPolicy: policy });
}
const normalizedEntries = [
...new Set(entries.map((entry) => normalizeGroupEntry(entry)).filter(Boolean)),
];
const groups = Object.fromEntries(normalizedEntries.map((entry) => [entry, {}]));
return updateIrcAccountConfig(cfg, accountId, {
enabled: true,
groupPolicy: "allowlist",
groups,
});
}
async function noteIrcSetupHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"IRC needs server host + bot nick.",
"Recommended: TLS on port 6697.",
"Optional: NickServ identify/register can be configured in onboarding.",
'Set channels.irc.groupPolicy="allowlist" and channels.irc.groups for tighter channel control.',
'Note: IRC channels are mention-gated by default. To allow unmentioned replies, set channels.irc.groups["#channel"].requireMention=false (or "*" for all).',
"Env vars supported: IRC_HOST, IRC_PORT, IRC_TLS, IRC_NICK, IRC_USERNAME, IRC_REALNAME, IRC_PASSWORD, IRC_CHANNELS, IRC_NICKSERV_PASSWORD, IRC_NICKSERV_REGISTER_EMAIL.",
`Docs: ${formatDocsLink("/channels/irc", "channels/irc")}`,
].join("\n"),
"IRC setup",
);
}
async function promptIrcAllowFrom(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<CoreConfig> {
const existing = params.cfg.channels?.irc?.allowFrom ?? [];
await params.prompter.note(
[
"Allowlist IRC DMs by sender.",
"Examples:",
"- alice",
"- alice!ident@example.org",
"Multiple entries: comma-separated.",
].join("\n"),
"IRC allowlist",
);
const raw = await params.prompter.text({
message: "IRC allowFrom (nick or nick!user@host)",
placeholder: "alice, bob!ident@example.org",
initialValue: existing[0] ? String(existing[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parsed = parseListInput(String(raw));
const normalized = [
...new Set(
parsed
.map((entry) => normalizeIrcAllowEntry(entry))
.map((entry) => entry.trim())
.filter(Boolean),
),
];
return setIrcAllowFrom(params.cfg, normalized);
}
async function promptIrcNickServConfig(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<CoreConfig> {
const resolved = resolveIrcAccount({ cfg: params.cfg, accountId: params.accountId });
const existing = resolved.config.nickserv;
const hasExisting = Boolean(existing?.password || existing?.passwordFile);
const wants = await params.prompter.confirm({
message: hasExisting ? "Update NickServ settings?" : "Configure NickServ identify/register?",
initialValue: hasExisting,
});
if (!wants) {
return params.cfg;
}
const service = String(
await params.prompter.text({
message: "NickServ service nick",
initialValue: existing?.service || "NickServ",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim();
const useEnvPassword =
params.accountId === DEFAULT_ACCOUNT_ID &&
Boolean(process.env.IRC_NICKSERV_PASSWORD?.trim()) &&
!(existing?.password || existing?.passwordFile)
? await params.prompter.confirm({
message: "IRC_NICKSERV_PASSWORD detected. Use env var?",
initialValue: true,
})
: false;
const password = useEnvPassword
? undefined
: String(
await params.prompter.text({
message: "NickServ password (blank to disable NickServ auth)",
validate: () => undefined,
}),
).trim();
if (!password && !useEnvPassword) {
return setIrcNickServ(params.cfg, params.accountId, {
enabled: false,
service,
});
}
const register = await params.prompter.confirm({
message: "Send NickServ REGISTER on connect?",
initialValue: existing?.register ?? false,
});
const registerEmail = register
? String(
await params.prompter.text({
message: "NickServ register email",
initialValue:
existing?.registerEmail ||
(params.accountId === DEFAULT_ACCOUNT_ID
? process.env.IRC_NICKSERV_REGISTER_EMAIL
: undefined),
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim()
: undefined;
return setIrcNickServ(params.cfg, params.accountId, {
enabled: true,
service,
...(password ? { password } : {}),
register,
...(registerEmail ? { registerEmail } : {}),
});
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "IRC",
channel,
policyKey: "channels.irc.dmPolicy",
allowFromKey: "channels.irc.allowFrom",
getCurrent: (cfg) => (cfg as CoreConfig).channels?.irc?.dmPolicy ?? "pairing",
setPolicy: (cfg, policy) => setIrcDmPolicy(cfg as CoreConfig, policy),
promptAllowFrom: promptIrcAllowFrom,
};
export const ircOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const coreCfg = cfg as CoreConfig;
const configured = listIrcAccountIds(coreCfg).some(
(accountId) => resolveIrcAccount({ cfg: coreCfg, accountId }).configured,
);
return {
channel,
configured,
statusLines: [`IRC: ${configured ? "configured" : "needs host + nick"}`],
selectionHint: configured ? "configured" : "needs host + nick",
quickstartScore: configured ? 1 : 0,
};
},
configure: async ({
cfg,
prompter,
accountOverrides,
shouldPromptAccountIds,
forceAllowFrom,
}) => {
let next = cfg as CoreConfig;
const ircOverride = accountOverrides.irc?.trim();
const defaultAccountId = resolveDefaultIrcAccountId(next);
let accountId = ircOverride || defaultAccountId;
if (shouldPromptAccountIds && !ircOverride) {
accountId = await promptAccountId({
cfg: next,
prompter,
label: "IRC",
currentId: accountId,
listAccountIds: listIrcAccountIds,
defaultAccountId,
});
}
const resolved = resolveIrcAccount({ cfg: next, accountId });
const isDefaultAccount = accountId === DEFAULT_ACCOUNT_ID;
const envHost = isDefaultAccount ? process.env.IRC_HOST?.trim() : "";
const envNick = isDefaultAccount ? process.env.IRC_NICK?.trim() : "";
const envReady = Boolean(envHost && envNick);
if (!resolved.configured) {
await noteIrcSetupHelp(prompter);
}
let useEnv = false;
if (envReady && isDefaultAccount && !resolved.config.host && !resolved.config.nick) {
useEnv = await prompter.confirm({
message: "IRC_HOST and IRC_NICK detected. Use env vars?",
initialValue: true,
});
}
if (useEnv) {
next = updateIrcAccountConfig(next, accountId, { enabled: true });
} else {
const host = String(
await prompter.text({
message: "IRC server host",
initialValue: resolved.config.host || envHost || undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim();
const tls = await prompter.confirm({
message: "Use TLS for IRC?",
initialValue: resolved.config.tls ?? true,
});
const defaultPort = resolved.config.port ?? (tls ? 6697 : 6667);
const portInput = await prompter.text({
message: "IRC server port",
initialValue: String(defaultPort),
validate: (value) => {
const parsed = Number.parseInt(String(value ?? "").trim(), 10);
return Number.isFinite(parsed) && parsed >= 1 && parsed <= 65535
? undefined
: "Use a port between 1 and 65535";
},
});
const port = parsePort(String(portInput), defaultPort);
const nick = String(
await prompter.text({
message: "IRC nick",
initialValue: resolved.config.nick || envNick || undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim();
const username = String(
await prompter.text({
message: "IRC username",
initialValue: resolved.config.username || nick || "openclaw",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim();
const realname = String(
await prompter.text({
message: "IRC real name",
initialValue: resolved.config.realname || "OpenClaw",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
}),
).trim();
const channelsRaw = await prompter.text({
message: "Auto-join IRC channels (optional, comma-separated)",
placeholder: "#openclaw, #ops",
initialValue: (resolved.config.channels ?? []).join(", "),
});
const channels = [
...new Set(
parseListInput(String(channelsRaw))
.map((entry) => normalizeGroupEntry(entry))
.filter((entry): entry is string => Boolean(entry && entry !== "*"))
.filter((entry) => isChannelTarget(entry)),
),
];
next = updateIrcAccountConfig(next, accountId, {
enabled: true,
host,
port,
tls,
nick,
username,
realname,
channels: channels.length > 0 ? channels : undefined,
});
}
const afterConfig = resolveIrcAccount({ cfg: next, accountId });
const accessConfig = await promptChannelAccessConfig({
prompter,
label: "IRC channels",
currentPolicy: afterConfig.config.groupPolicy ?? "allowlist",
currentEntries: Object.keys(afterConfig.config.groups ?? {}),
placeholder: "#openclaw, #ops, *",
updatePrompt: Boolean(afterConfig.config.groups),
});
if (accessConfig) {
next = setIrcGroupAccess(next, accountId, accessConfig.policy, accessConfig.entries);
// Mention gating: groups/channels are mention-gated by default. Make this explicit in onboarding.
const wantsMentions = await prompter.confirm({
message: "Require @mention to reply in IRC channels?",
initialValue: true,
});
if (!wantsMentions) {
const resolvedAfter = resolveIrcAccount({ cfg: next, accountId });
const groups = resolvedAfter.config.groups ?? {};
const patched = Object.fromEntries(
Object.entries(groups).map(([key, value]) => [key, { ...value, requireMention: false }]),
);
next = updateIrcAccountConfig(next, accountId, { groups: patched });
}
}
if (forceAllowFrom) {
next = await promptIrcAllowFrom({ cfg: next, prompter, accountId });
}
next = await promptIrcNickServConfig({
cfg: next,
prompter,
accountId,
});
await prompter.note(
[
"Next: restart gateway and verify status.",
"Command: openclaw channels status --probe",
`Docs: ${formatDocsLink("/channels/irc", "channels/irc")}`,
].join("\n"),
"IRC next steps",
);
return { cfg: next, accountId };
},
dmPolicy,
disable: (cfg) => ({
...(cfg as CoreConfig),
channels: {
...(cfg as CoreConfig).channels,
irc: {
...(cfg as CoreConfig).channels?.irc,
enabled: false,
},
},
}),
};

View File

@@ -0,0 +1,132 @@
import { describe, expect, it } from "vitest";
import { resolveChannelGroupPolicy } from "../../../src/config/group-policy.js";
import {
resolveIrcGroupAccessGate,
resolveIrcGroupMatch,
resolveIrcGroupSenderAllowed,
resolveIrcMentionGate,
resolveIrcRequireMention,
} from "./policy.js";
describe("irc policy", () => {
it("matches direct and wildcard group entries", () => {
const direct = resolveIrcGroupMatch({
groups: {
"#ops": { requireMention: false },
},
target: "#ops",
});
expect(direct.allowed).toBe(true);
expect(resolveIrcRequireMention({ groupConfig: direct.groupConfig })).toBe(false);
const wildcard = resolveIrcGroupMatch({
groups: {
"*": { requireMention: true },
},
target: "#random",
});
expect(wildcard.allowed).toBe(true);
expect(resolveIrcRequireMention({ wildcardConfig: wildcard.wildcardConfig })).toBe(true);
});
it("enforces allowlist by default in groups", () => {
const message = {
messageId: "m1",
target: "#ops",
senderNick: "alice",
senderUser: "ident",
senderHost: "example.org",
text: "hi",
timestamp: Date.now(),
isGroup: true,
};
expect(
resolveIrcGroupSenderAllowed({
groupPolicy: "allowlist",
message,
outerAllowFrom: [],
innerAllowFrom: [],
}),
).toBe(false);
expect(
resolveIrcGroupSenderAllowed({
groupPolicy: "allowlist",
message,
outerAllowFrom: ["alice"],
innerAllowFrom: [],
}),
).toBe(true);
});
it('allows unconfigured channels when groupPolicy is "open"', () => {
const groupMatch = resolveIrcGroupMatch({
groups: undefined,
target: "#random",
});
const gate = resolveIrcGroupAccessGate({
groupPolicy: "open",
groupMatch,
});
expect(gate.allowed).toBe(true);
expect(gate.reason).toBe("open");
});
it("honors explicit group disable even in open mode", () => {
const groupMatch = resolveIrcGroupMatch({
groups: {
"#ops": { enabled: false },
},
target: "#ops",
});
const gate = resolveIrcGroupAccessGate({
groupPolicy: "open",
groupMatch,
});
expect(gate.allowed).toBe(false);
expect(gate.reason).toBe("disabled");
});
it("allows authorized control commands without mention", () => {
const gate = resolveIrcMentionGate({
isGroup: true,
requireMention: true,
wasMentioned: false,
hasControlCommand: true,
allowTextCommands: true,
commandAuthorized: true,
});
expect(gate.shouldSkip).toBe(false);
});
it("keeps case-insensitive group matching aligned with shared channel policy resolution", () => {
const groups = {
"#Ops": { requireMention: false },
"#Hidden": { enabled: false },
"*": { requireMention: true },
};
const inboundDirect = resolveIrcGroupMatch({ groups, target: "#ops" });
const sharedDirect = resolveChannelGroupPolicy({
cfg: { channels: { irc: { groups } } },
channel: "irc",
groupId: "#ops",
groupIdCaseInsensitive: true,
});
expect(sharedDirect.allowed).toBe(inboundDirect.allowed);
expect(sharedDirect.groupConfig?.requireMention).toBe(
inboundDirect.groupConfig?.requireMention,
);
const inboundDisabled = resolveIrcGroupMatch({ groups, target: "#hidden" });
const sharedDisabled = resolveChannelGroupPolicy({
cfg: { channels: { irc: { groups } } },
channel: "irc",
groupId: "#hidden",
groupIdCaseInsensitive: true,
});
expect(sharedDisabled.allowed).toBe(inboundDisabled.allowed);
expect(sharedDisabled.groupConfig?.enabled).toBe(inboundDisabled.groupConfig?.enabled);
});
});

View File

@@ -0,0 +1,157 @@
import type { IrcAccountConfig, IrcChannelConfig } from "./types.js";
import type { IrcInboundMessage } from "./types.js";
import { normalizeIrcAllowlist, resolveIrcAllowlistMatch } from "./normalize.js";
export type IrcGroupMatch = {
allowed: boolean;
groupConfig?: IrcChannelConfig;
wildcardConfig?: IrcChannelConfig;
hasConfiguredGroups: boolean;
};
export type IrcGroupAccessGate = {
allowed: boolean;
reason: string;
};
export function resolveIrcGroupMatch(params: {
groups?: Record<string, IrcChannelConfig>;
target: string;
}): IrcGroupMatch {
const groups = params.groups ?? {};
const hasConfiguredGroups = Object.keys(groups).length > 0;
// IRC channel targets are case-insensitive, but config keys are plain strings.
// To avoid surprising drops (e.g. "#TUIRC-DEV" vs "#tuirc-dev"), match
// group config keys case-insensitively.
const direct = groups[params.target];
if (direct) {
return {
// "allowed" means the target matched an allowlisted key.
// Explicit disables are handled later by resolveIrcGroupAccessGate.
allowed: true,
groupConfig: direct,
wildcardConfig: groups["*"],
hasConfiguredGroups,
};
}
const targetLower = params.target.toLowerCase();
const directKey = Object.keys(groups).find((key) => key.toLowerCase() === targetLower);
if (directKey) {
const matched = groups[directKey];
if (matched) {
return {
// "allowed" means the target matched an allowlisted key.
// Explicit disables are handled later by resolveIrcGroupAccessGate.
allowed: true,
groupConfig: matched,
wildcardConfig: groups["*"],
hasConfiguredGroups,
};
}
}
const wildcard = groups["*"];
if (wildcard) {
return {
// "allowed" means the target matched an allowlisted key.
// Explicit disables are handled later by resolveIrcGroupAccessGate.
allowed: true,
wildcardConfig: wildcard,
hasConfiguredGroups,
};
}
return {
allowed: false,
hasConfiguredGroups,
};
}
export function resolveIrcGroupAccessGate(params: {
groupPolicy: IrcAccountConfig["groupPolicy"];
groupMatch: IrcGroupMatch;
}): IrcGroupAccessGate {
const policy = params.groupPolicy ?? "allowlist";
if (policy === "disabled") {
return { allowed: false, reason: "groupPolicy=disabled" };
}
// In open mode, unconfigured channels are allowed (mention-gated) but explicit
// per-channel/wildcard disables still apply.
if (policy === "allowlist") {
if (!params.groupMatch.hasConfiguredGroups) {
return {
allowed: false,
reason: "groupPolicy=allowlist and no groups configured",
};
}
if (!params.groupMatch.allowed) {
return { allowed: false, reason: "not allowlisted" };
}
}
if (
params.groupMatch.groupConfig?.enabled === false ||
params.groupMatch.wildcardConfig?.enabled === false
) {
return { allowed: false, reason: "disabled" };
}
return { allowed: true, reason: policy === "open" ? "open" : "allowlisted" };
}
export function resolveIrcRequireMention(params: {
groupConfig?: IrcChannelConfig;
wildcardConfig?: IrcChannelConfig;
}): boolean {
if (params.groupConfig?.requireMention !== undefined) {
return params.groupConfig.requireMention;
}
if (params.wildcardConfig?.requireMention !== undefined) {
return params.wildcardConfig.requireMention;
}
return true;
}
export function resolveIrcMentionGate(params: {
isGroup: boolean;
requireMention: boolean;
wasMentioned: boolean;
hasControlCommand: boolean;
allowTextCommands: boolean;
commandAuthorized: boolean;
}): { shouldSkip: boolean; reason: string } {
if (!params.isGroup) {
return { shouldSkip: false, reason: "direct" };
}
if (!params.requireMention) {
return { shouldSkip: false, reason: "mention-not-required" };
}
if (params.wasMentioned) {
return { shouldSkip: false, reason: "mentioned" };
}
if (params.hasControlCommand && params.allowTextCommands && params.commandAuthorized) {
return { shouldSkip: false, reason: "authorized-command" };
}
return { shouldSkip: true, reason: "missing-mention" };
}
export function resolveIrcGroupSenderAllowed(params: {
groupPolicy: IrcAccountConfig["groupPolicy"];
message: IrcInboundMessage;
outerAllowFrom: string[];
innerAllowFrom: string[];
}): boolean {
const policy = params.groupPolicy ?? "allowlist";
const inner = normalizeIrcAllowlist(params.innerAllowFrom);
const outer = normalizeIrcAllowlist(params.outerAllowFrom);
if (inner.length > 0) {
return resolveIrcAllowlistMatch({ allowFrom: inner, message: params.message }).allowed;
}
if (outer.length > 0) {
return resolveIrcAllowlistMatch({ allowFrom: outer, message: params.message }).allowed;
}
return policy === "open";
}

View File

@@ -0,0 +1,64 @@
import type { CoreConfig, IrcProbe } from "./types.js";
import { resolveIrcAccount } from "./accounts.js";
import { connectIrcClient } from "./client.js";
function formatError(err: unknown): string {
if (err instanceof Error) {
return err.message;
}
return typeof err === "string" ? err : JSON.stringify(err);
}
export async function probeIrc(
cfg: CoreConfig,
opts?: { accountId?: string; timeoutMs?: number },
): Promise<IrcProbe> {
const account = resolveIrcAccount({ cfg, accountId: opts?.accountId });
const base: IrcProbe = {
ok: false,
host: account.host,
port: account.port,
tls: account.tls,
nick: account.nick,
};
if (!account.configured) {
return {
...base,
error: "missing host or nick",
};
}
const started = Date.now();
try {
const client = await connectIrcClient({
host: account.host,
port: account.port,
tls: account.tls,
nick: account.nick,
username: account.username,
realname: account.realname,
password: account.password,
nickserv: {
enabled: account.config.nickserv?.enabled,
service: account.config.nickserv?.service,
password: account.config.nickserv?.password,
register: account.config.nickserv?.register,
registerEmail: account.config.nickserv?.registerEmail,
},
connectTimeoutMs: opts?.timeoutMs ?? 8000,
});
const elapsed = Date.now() - started;
client.quit("probe");
return {
...base,
ok: true,
latencyMs: elapsed,
};
} catch (err) {
return {
...base,
error: formatError(err),
};
}
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
parseIrcLine,
parseIrcPrefix,
sanitizeIrcOutboundText,
sanitizeIrcTarget,
splitIrcText,
} from "./protocol.js";
describe("irc protocol", () => {
it("parses PRIVMSG lines with prefix and trailing", () => {
const parsed = parseIrcLine(":alice!u@host PRIVMSG #room :hello world");
expect(parsed).toEqual({
raw: ":alice!u@host PRIVMSG #room :hello world",
prefix: "alice!u@host",
command: "PRIVMSG",
params: ["#room"],
trailing: "hello world",
});
expect(parseIrcPrefix(parsed?.prefix)).toEqual({
nick: "alice",
user: "u",
host: "host",
});
});
it("sanitizes outbound text to prevent command injection", () => {
expect(sanitizeIrcOutboundText("hello\\r\\nJOIN #oops")).toBe("hello JOIN #oops");
expect(sanitizeIrcOutboundText("\\u0001test\\u0000")).toBe("test");
});
it("validates targets and rejects control characters", () => {
expect(sanitizeIrcTarget("#openclaw")).toBe("#openclaw");
expect(() => sanitizeIrcTarget("#bad\\nPING")).toThrow(/Invalid IRC target/);
expect(() => sanitizeIrcTarget(" user")).toThrow(/Invalid IRC target/);
});
it("splits long text on boundaries", () => {
const chunks = splitIrcText("a ".repeat(300), 120);
expect(chunks.length).toBeGreaterThan(2);
expect(chunks.every((chunk) => chunk.length <= 120)).toBe(true);
});
});

View File

@@ -0,0 +1,169 @@
import { randomUUID } from "node:crypto";
import { hasIrcControlChars, stripIrcControlChars } from "./control-chars.js";
const IRC_TARGET_PATTERN = /^[^\s:]+$/u;
export type ParsedIrcLine = {
raw: string;
prefix?: string;
command: string;
params: string[];
trailing?: string;
};
export type ParsedIrcPrefix = {
nick?: string;
user?: string;
host?: string;
server?: string;
};
export function parseIrcLine(line: string): ParsedIrcLine | null {
const raw = line.replace(/[\r\n]+/g, "").trim();
if (!raw) {
return null;
}
let cursor = raw;
let prefix: string | undefined;
if (cursor.startsWith(":")) {
const idx = cursor.indexOf(" ");
if (idx <= 1) {
return null;
}
prefix = cursor.slice(1, idx);
cursor = cursor.slice(idx + 1).trimStart();
}
if (!cursor) {
return null;
}
const firstSpace = cursor.indexOf(" ");
const command = (firstSpace === -1 ? cursor : cursor.slice(0, firstSpace)).trim();
if (!command) {
return null;
}
cursor = firstSpace === -1 ? "" : cursor.slice(firstSpace + 1);
const params: string[] = [];
let trailing: string | undefined;
while (cursor.length > 0) {
cursor = cursor.trimStart();
if (!cursor) {
break;
}
if (cursor.startsWith(":")) {
trailing = cursor.slice(1);
break;
}
const spaceIdx = cursor.indexOf(" ");
if (spaceIdx === -1) {
params.push(cursor);
break;
}
params.push(cursor.slice(0, spaceIdx));
cursor = cursor.slice(spaceIdx + 1);
}
return {
raw,
prefix,
command: command.toUpperCase(),
params,
trailing,
};
}
export function parseIrcPrefix(prefix?: string): ParsedIrcPrefix {
if (!prefix) {
return {};
}
const nickPart = prefix.match(/^([^!@]+)!([^@]+)@(.+)$/);
if (nickPart) {
return {
nick: nickPart[1],
user: nickPart[2],
host: nickPart[3],
};
}
const nickHostPart = prefix.match(/^([^@]+)@(.+)$/);
if (nickHostPart) {
return {
nick: nickHostPart[1],
host: nickHostPart[2],
};
}
if (prefix.includes("!")) {
const [nick, user] = prefix.split("!", 2);
return { nick, user };
}
if (prefix.includes(".")) {
return { server: prefix };
}
return { nick: prefix };
}
function decodeLiteralEscapes(input: string): string {
// Defensive: this is not a full JS string unescaper.
// It's just enough to catch common "\r\n" / "\u0001" style payloads.
return input
.replace(/\\r/g, "\r")
.replace(/\\n/g, "\n")
.replace(/\\t/g, "\t")
.replace(/\\0/g, "\0")
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
}
export function sanitizeIrcOutboundText(text: string): string {
const decoded = decodeLiteralEscapes(text);
return stripIrcControlChars(decoded.replace(/\r?\n/g, " ")).trim();
}
export function sanitizeIrcTarget(raw: string): string {
const decoded = decodeLiteralEscapes(raw);
if (!decoded) {
throw new Error("IRC target is required");
}
// Reject any surrounding whitespace instead of trimming it away.
if (decoded !== decoded.trim()) {
throw new Error(`Invalid IRC target: ${raw}`);
}
if (hasIrcControlChars(decoded)) {
throw new Error(`Invalid IRC target: ${raw}`);
}
if (!IRC_TARGET_PATTERN.test(decoded)) {
throw new Error(`Invalid IRC target: ${raw}`);
}
return decoded;
}
export function splitIrcText(text: string, maxChars = 350): string[] {
const cleaned = sanitizeIrcOutboundText(text);
if (!cleaned) {
return [];
}
if (cleaned.length <= maxChars) {
return [cleaned];
}
const chunks: string[] = [];
let remaining = cleaned;
while (remaining.length > maxChars) {
let splitAt = remaining.lastIndexOf(" ", maxChars);
if (splitAt < Math.floor(maxChars * 0.5)) {
splitAt = maxChars;
}
chunks.push(remaining.slice(0, splitAt).trim());
remaining = remaining.slice(splitAt).trimStart();
}
if (remaining) {
chunks.push(remaining);
}
return chunks.filter(Boolean);
}
export function makeIrcMessageId() {
return randomUUID();
}

View File

@@ -0,0 +1,14 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setIrcRuntime(next: PluginRuntime) {
runtime = next;
}
export function getIrcRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("IRC runtime not initialized");
}
return runtime;
}

View File

@@ -0,0 +1,99 @@
import type { IrcClient } from "./client.js";
import type { CoreConfig } from "./types.js";
import { resolveIrcAccount } from "./accounts.js";
import { connectIrcClient } from "./client.js";
import { normalizeIrcMessagingTarget } from "./normalize.js";
import { makeIrcMessageId } from "./protocol.js";
import { getIrcRuntime } from "./runtime.js";
type SendIrcOptions = {
accountId?: string;
replyTo?: string;
target?: string;
client?: IrcClient;
};
export type SendIrcResult = {
messageId: string;
target: string;
};
function resolveTarget(to: string, opts?: SendIrcOptions): string {
const fromArg = normalizeIrcMessagingTarget(to);
if (fromArg) {
return fromArg;
}
const fromOpt = normalizeIrcMessagingTarget(opts?.target ?? "");
if (fromOpt) {
return fromOpt;
}
throw new Error(`Invalid IRC target: ${to}`);
}
export async function sendMessageIrc(
to: string,
text: string,
opts: SendIrcOptions = {},
): Promise<SendIrcResult> {
const runtime = getIrcRuntime();
const cfg = runtime.config.loadConfig() as CoreConfig;
const account = resolveIrcAccount({
cfg,
accountId: opts.accountId,
});
if (!account.configured) {
throw new Error(
`IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`,
);
}
const target = resolveTarget(to, opts);
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
cfg,
channel: "irc",
accountId: account.accountId,
});
const prepared = runtime.channel.text.convertMarkdownTables(text.trim(), tableMode);
const payload = opts.replyTo ? `${prepared}\n\n[reply:${opts.replyTo}]` : prepared;
if (!payload.trim()) {
throw new Error("Message must be non-empty for IRC sends");
}
const client = opts.client;
if (client?.isReady()) {
client.sendPrivmsg(target, payload);
} else {
const transient = await connectIrcClient({
host: account.host,
port: account.port,
tls: account.tls,
nick: account.nick,
username: account.username,
realname: account.realname,
password: account.password,
nickserv: {
enabled: account.config.nickserv?.enabled,
service: account.config.nickserv?.service,
password: account.config.nickserv?.password,
register: account.config.nickserv?.register,
registerEmail: account.config.nickserv?.registerEmail,
},
connectTimeoutMs: 12000,
});
transient.sendPrivmsg(target, payload);
transient.quit("sent");
}
runtime.channel.activity.record({
channel: "irc",
accountId: account.accountId,
direction: "outbound",
});
return {
messageId: makeIrcMessageId(),
target,
};
}

View File

@@ -0,0 +1,94 @@
import type {
BlockStreamingCoalesceConfig,
DmConfig,
DmPolicy,
GroupPolicy,
GroupToolPolicyBySenderConfig,
GroupToolPolicyConfig,
MarkdownConfig,
OpenClawConfig,
} from "openclaw/plugin-sdk";
export type IrcChannelConfig = {
requireMention?: boolean;
tools?: GroupToolPolicyConfig;
toolsBySender?: GroupToolPolicyBySenderConfig;
skills?: string[];
enabled?: boolean;
allowFrom?: Array<string | number>;
systemPrompt?: string;
};
export type IrcNickServConfig = {
enabled?: boolean;
service?: string;
password?: string;
passwordFile?: string;
register?: boolean;
registerEmail?: string;
};
export type IrcAccountConfig = {
name?: string;
enabled?: boolean;
host?: string;
port?: number;
tls?: boolean;
nick?: string;
username?: string;
realname?: string;
password?: string;
passwordFile?: string;
nickserv?: IrcNickServConfig;
dmPolicy?: DmPolicy;
allowFrom?: Array<string | number>;
groupPolicy?: GroupPolicy;
groupAllowFrom?: Array<string | number>;
groups?: Record<string, IrcChannelConfig>;
channels?: string[];
mentionPatterns?: string[];
markdown?: MarkdownConfig;
historyLimit?: number;
dmHistoryLimit?: number;
dms?: Record<string, DmConfig>;
textChunkLimit?: number;
chunkMode?: "length" | "newline";
blockStreaming?: boolean;
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
responsePrefix?: string;
mediaMaxMb?: number;
};
export type IrcConfig = IrcAccountConfig & {
accounts?: Record<string, IrcAccountConfig>;
};
export type CoreConfig = OpenClawConfig & {
channels?: OpenClawConfig["channels"] & {
irc?: IrcConfig;
};
};
export type IrcInboundMessage = {
messageId: string;
/** Conversation peer id: channel name for groups, sender nick for DMs. */
target: string;
/** Raw IRC PRIVMSG target (bot nick for DMs, channel for groups). */
rawTarget?: string;
senderNick: string;
senderUser?: string;
senderHost?: string;
text: string;
timestamp: number;
isGroup: boolean;
};
export type IrcProbe = {
ok: boolean;
host: string;
port: number;
tls: boolean;
nick: string;
latencyMs?: number;
error?: string;
};

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/line",
"version": "2026.2.9",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw LINE channel plugin",
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/llm-task",
"version": "2026.2.9",
"version": "2026.2.10",
"private": true,
"description": "OpenClaw JSON-only LLM task plugin",
"type": "module",

Some files were not shown because too many files have changed in this diff Show More