diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index adf5045728a..1d4a0bbb53a 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -11,7 +11,7 @@ Describe the problem and fix in 2β5 bullets:
- [ ] Bug fix
- [ ] Feature
-- [ ] Refactor
+- [ ] Refactor required for the fix
- [ ] Docs
- [ ] Security hardening
- [ ] Chore/infra
diff --git a/AGENTS.md b/AGENTS.md
index daaa0b1ebd5..6df75f20ad2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -9,7 +9,8 @@
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
- Tests: colocated `*.test.ts`.
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
-- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
+- Nomenclature: use "plugin" / "plugins" in docs, UI, changelogs, and contributor guidance. `extensions/*` remains the internal directory/package path to avoid repo-wide churn from a rename.
+- Plugins: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
- Plugins: install runs `npm install --omit=dev` in plugin dir; runtime deps must live in `dependencies`. Avoid `workspace:*` in `dependencies` (npm install breaks); put `openclaw` in `devDependencies` or `peerDependencies` instead (runtime resolves `openclaw/plugin-sdk` via jiti alias).
- Import boundaries: extension production code should treat `openclaw/plugin-sdk/*` plus local `api.ts` / `runtime-api.ts` barrels as the public surface. Do not import core `src/**`, `src/plugin-sdk-internal/**`, or another extension's `src/**` directly.
- Installers served from `https://openclaw.ai/*`: live in the sibling repo `../openclaw.ai` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8e33a2d82a6..c2e2f7521ac 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
+- Models/Anthropic Vertex: add core `anthropic-vertex` provider support for Claude via Google Vertex AI, including GCP auth/discovery and main run-path routing. (#43356) Thanks @sallyom and @yossiovadia.
- Commands/btw: add `/btw` side questions for quick tool-less answers about the current session without changing future session context, with dismissible in-session TUI answers and explicit BTW replies on external channels. (#45444) Thanks @ngutman.
- Gateway/docs: clarify that empty URL input allowlists are treated as unset, document `allowUrl: false` as the deny-all switch, and add regression coverage for the normalization path.
- Sandbox/runtime: add pluggable sandbox backends, ship an OpenShell backend with `mirror` and `remote` workspace modes, and make sandbox list/recreate/prune backend-aware instead of Docker-only.
@@ -52,6 +53,8 @@ Docs: https://docs.openclaw.ai
- Docs/plugins: add the community DingTalk plugin listing to the docs catalog. (#29913) Thanks @sliverp.
- Docs/plugins: add the community QQbot plugin listing to the docs catalog. (#29898) Thanks @sliverp.
- Plugins/context engines: pass the embedded runner `modelId` into context-engine `assemble()` so plugins can adapt context formatting per model. (#47437) thanks @jscianna.
+- Plugins/context engines: add transcript maintenance rewrites for context engines, preserve active-branch transcript metadata during rewrites, and harden overflow-recovery truncation to rewrite sessions under the normal session write lock. (#51191) Thanks @jalehman.
+- Telegram/apiRoot: add per-account custom Bot API endpoint support across send, probe, setup, doctor repair, and inbound media download paths so proxied or self-hosted Telegram deployments work end to end. (#48842) Thanks @Cypherm.
### Fixes
@@ -152,6 +155,9 @@ Docs: https://docs.openclaw.ai
- Telegram: stabilize pairing/session/forum routing and reply formatting tests (#50155) Thanks @joshavant.
- Hardening: refresh stale device pairing requests and pending metadata (#50695) Thanks @smaeljaish771 and @joshavant.
- Gateway: harden OpenResponses file-context escaping (#50782) Thanks @YLChen-007 and @joshavant.
+- LINE: harden Express webhook parsing to verified raw body (#51202) Thanks @gladiator9797 and @joshavant.
+- Exec: harden host env override handling across gateway and node (#51207) Thanks @gladiator9797 and @joshavant.
+- xAI/models: rename the bundled Grok 4.20 catalog entries to the GA IDs and normalize saved deprecated beta IDs at runtime so existing configs and sessions keep resolving. (#50772) thanks @Jaaneek
### Fixes
@@ -182,6 +188,13 @@ Docs: https://docs.openclaw.ai
- Tests/CLI: reduce command-secret gateway test import pressure while keeping the real protocol payload validator in place, so the isolated lane no longer carries the heavier runtime-web and message-channel graphs. (#50663) Thanks @huntharo.
- Gateway/plugins: share plugin interactive callback routing and plugin bind approval state across duplicate module graphs so Telegram Codex picker buttons and plugin bind approvals no longer fall through to normal inbound message routing. (#50722) Thanks @huntharo.
- Agents/compaction: add an opt-in post-compaction session JSONL truncation step that drops summarized transcript entries while preserving the retained branch tail and live session metadata. (#41021) thanks @thirumaleshp.
+- Telegram/routing: fail loud when `message send` targets an unknown non-default Telegram `accountId`, instead of silently falling back to the channel-level bot token and sending through the wrong bot. (#50853) Thanks @hclsys.
+- Web search: align onboarding, configure, and finalize with plugin-owned provider contracts, including disabled-provider recovery, config-aware credential hooks, and runtime-visible summaries. (#50935) Thanks @gumadeiras.
+- Agents/replay: sanitize malformed assistant tool-call replay blocks before provider replay so follow-up Anthropic requests do not inherit the downstream `replace` crash. (#50005) Thanks @jalehman.
+- Plugins/context engines: retry strict legacy `assemble()` calls without the new `prompt` field when older engines reject it, preserving prompt-aware retrieval compatibility for pre-prompt plugins. (#50848) thanks @danhdoan.
+- Agents/embedded transport errors: distinguish common network failures like connection refused, DNS lookup failure, and interrupted sockets from true timeouts in embedded-run user messaging and lifecycle diagnostics. (#51419) Thanks @scoootscooob.
+- Discord/startup logging: report client initialization while the gateway is still connecting instead of claiming Discord is logged in before readiness is reached. (#51425) Thanks @scoootscooob.
+- Gateway/probe: honor caller `--timeout` for active local loopback probes in `gateway status`, keep inactive remote-mode loopback probes fast, and clamp probe timers to JS-safe bounds so slow local/container gateways stop reporting false timeouts. (#47533) Thanks @MonkeyLeeT.
### Breaking
@@ -195,6 +208,7 @@ Docs: https://docs.openclaw.ai
- Exec/env sandbox: block build-tool JVM injection (`MAVEN_OPTS`, `SBT_OPTS`, `GRADLE_OPTS`, `ANT_OPTS`), glibc tunable exploitation (`GLIBC_TUNABLES`), and .NET dependency resolution hijack (`DOTNET_ADDITIONAL_DEPS`) from the host exec environment, and restrict Gradle init script redirect (`GRADLE_USER_HOME`) as an override-only block so user-configured Gradle homes still propagate. (#49702)
- Plugins/Matrix: add a new Matrix plugin backed by the official `matrix-js-sdk`. If you are upgrading from the previous public Matrix plugin, follow the migration guide: https://docs.openclaw.ai/install/migrating-matrix Thanks @gumadeiras.
- Discord/commands: switch native command deployment to Carbon reconcile by default so Discord restarts stop churning slash commands through OpenClawβs local deploy path. (#46597) Thanks @huntharo and @thewilloftheshadow.
+- Plugins/Matrix: durably dedupe inbound room events across gateway restarts so previously handled Matrix messages are not replayed as new, while preserving clean-restart backlog delivery for unseen events. (#50922) thanks @gumadeiras
## 2026.3.13
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8914ffc1f31..1968040e3e0 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -83,8 +83,9 @@ Welcome to the lobster tank! π¦
1. **Bugs & small fixes** β Open a PR!
2. **New features / architecture** β Start a [GitHub Discussion](https://github.com/openclaw/openclaw/discussions) or ask in Discord first
-3. **Test/CI-only PRs for known `main` failures** β Don't open a PR, the Maintainer team is already tracking it and such PRs will be closed automatically. If you've spotted a _new_ regression not yet shown in main CI, report it as an issue first.
-4. **Questions** β Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
+3. **Refactor-only PRs** β Don't open a PR. We are not accepting refactor-only changes unless a maintainer explicitly asks for them as part of a concrete fix.
+4. **Test/CI-only PRs for known `main` failures** β Don't open a PR. The Maintainer team is already tracking those failures, and PRs that only tweak tests or CI to chase them will be closed unless they are required to validate a new fix.
+5. **Questions** β Discord [#help](https://discord.com/channels/1456350064065904867/1459642797895319552) / [#users-helping-users](https://discord.com/channels/1456350064065904867/1459007081603403828)
## Before You PR
@@ -97,7 +98,9 @@ Welcome to the lobster tank! π¦
- For targeted shared-surface work, use `pnpm test:contracts:channels` or `pnpm test:contracts:plugins`
- If you changed broader runtime behavior, still run the relevant wider lanes (`pnpm test:extensions`, `pnpm test:channels`, or `pnpm test`) before asking for review
- If you have access to Codex, run `codex review --base origin/main` locally before opening or updating your PR. Treat this as the current highest standard of AI review, even if GitHub Codex review also runs.
+- Do not submit refactor-only PRs unless a maintainer explicitly requested that refactor for an active fix or deliverable.
- Do not submit test or CI-config fixes for failures already red on `main` CI. If a failure is already visible in the [main branch CI runs](https://github.com/openclaw/openclaw/actions), it's a known issue the Maintainer team is tracking, and a PR that only addresses those failures will be closed automatically. If you spot a _new_ regression not yet shown in main CI, report it as an issue first.
+- Do not submit test-only PRs that just try to make known `main` CI failures pass. Test changes are acceptable when they are required to validate a new fix or cover new behavior in the same PR.
- Ensure CI checks pass
- Keep PRs focused (one thing per PR; do not mix unrelated concerns)
- Describe what & why
diff --git a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift
index d5d27a212f5..a3d92efa3f1 100644
--- a/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift
+++ b/apps/macos/Sources/OpenClaw/HostEnvSanitizer.swift
@@ -1,5 +1,10 @@
import Foundation
+struct HostEnvOverrideDiagnostics: Equatable {
+ var blockedKeys: [String]
+ var invalidKeys: [String]
+}
+
enum HostEnvSanitizer {
/// Generated from src/infra/host-env-security-policy.json via scripts/generate-host-env-security-policy-swift.mjs.
/// Parity is validated by src/infra/host-env-security.policy-parity.test.ts.
@@ -41,6 +46,67 @@ enum HostEnvSanitizer {
return filtered.isEmpty ? nil : filtered
}
+ private static func isPortableHead(_ scalar: UnicodeScalar) -> Bool {
+ let value = scalar.value
+ return value == 95 || (65...90).contains(value) || (97...122).contains(value)
+ }
+
+ private static func isPortableTail(_ scalar: UnicodeScalar) -> Bool {
+ let value = scalar.value
+ return self.isPortableHead(scalar) || (48...57).contains(value)
+ }
+
+ private static func normalizeOverrideKey(_ rawKey: String) -> String? {
+ let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !key.isEmpty else { return nil }
+ guard let first = key.unicodeScalars.first, self.isPortableHead(first) else {
+ return nil
+ }
+ for scalar in key.unicodeScalars.dropFirst() {
+ if self.isPortableTail(scalar) || scalar == "(" || scalar == ")" {
+ continue
+ }
+ return nil
+ }
+ return key
+ }
+
+ private static func sortedUnique(_ values: [String]) -> [String] {
+ Array(Set(values)).sorted()
+ }
+
+ static func inspectOverrides(
+ overrides: [String: String]?,
+ blockPathOverrides: Bool = true) -> HostEnvOverrideDiagnostics
+ {
+ guard let overrides else {
+ return HostEnvOverrideDiagnostics(blockedKeys: [], invalidKeys: [])
+ }
+
+ var blocked: [String] = []
+ var invalid: [String] = []
+ for (rawKey, _) in overrides {
+ let candidate = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard let normalized = self.normalizeOverrideKey(rawKey) else {
+ invalid.append(candidate.isEmpty ? rawKey : candidate)
+ continue
+ }
+ let upper = normalized.uppercased()
+ if blockPathOverrides, upper == "PATH" {
+ blocked.append(upper)
+ continue
+ }
+ if self.isBlockedOverride(upper) || self.isBlocked(upper) {
+ blocked.append(upper)
+ continue
+ }
+ }
+
+ return HostEnvOverrideDiagnostics(
+ blockedKeys: self.sortedUnique(blocked),
+ invalidKeys: self.sortedUnique(invalid))
+ }
+
static func sanitize(overrides: [String: String]?, shellWrapper: Bool = false) -> [String: String] {
var merged: [String: String] = [:]
for (rawKey, value) in ProcessInfo.processInfo.environment {
@@ -57,8 +123,7 @@ enum HostEnvSanitizer {
guard let effectiveOverrides else { return merged }
for (rawKey, value) in effectiveOverrides {
- let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !key.isEmpty else { continue }
+ guard let key = self.normalizeOverrideKey(rawKey) else { continue }
let upper = key.uppercased()
// PATH is part of the security boundary (command resolution + safe-bin checks). Never
// allow request-scoped PATH overrides from agents/gateways.
diff --git a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift
index 40db384b226..e45261cda2e 100644
--- a/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift
+++ b/apps/macos/Sources/OpenClaw/HostEnvSecurityPolicy.generated.swift
@@ -63,7 +63,23 @@ enum HostEnvSecurityPolicy {
"OPENSSL_ENGINES",
"PYTHONSTARTUP",
"WGETRC",
- "CURL_HOME"
+ "CURL_HOME",
+ "CLASSPATH",
+ "CGO_CFLAGS",
+ "CGO_LDFLAGS",
+ "GOFLAGS",
+ "CORECLR_PROFILER_PATH",
+ "PHPRC",
+ "PHP_INI_SCAN_DIR",
+ "DENO_DIR",
+ "BUN_CONFIG_REGISTRY",
+ "LUA_PATH",
+ "LUA_CPATH",
+ "GEM_HOME",
+ "GEM_PATH",
+ "BUNDLE_GEMFILE",
+ "COMPOSER_HOME",
+ "XDG_CONFIG_HOME"
]
static let blockedOverridePrefixes: [String] = [
diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift
index c24f5d0f1b8..956abf94ad6 100644
--- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift
+++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift
@@ -465,6 +465,23 @@ actor MacNodeRuntime {
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
: self.mainSessionKey
let runId = UUID().uuidString
+ let envOverrideDiagnostics = HostEnvSanitizer.inspectOverrides(
+ overrides: params.env,
+ blockPathOverrides: true)
+ if !envOverrideDiagnostics.blockedKeys.isEmpty || !envOverrideDiagnostics.invalidKeys.isEmpty {
+ var details: [String] = []
+ if !envOverrideDiagnostics.blockedKeys.isEmpty {
+ details.append("blocked override keys: \(envOverrideDiagnostics.blockedKeys.joined(separator: ", "))")
+ }
+ if !envOverrideDiagnostics.invalidKeys.isEmpty {
+ details.append(
+ "invalid non-portable override keys: \(envOverrideDiagnostics.invalidKeys.joined(separator: ", "))")
+ }
+ return Self.errorResponse(
+ req,
+ code: .invalidRequest,
+ message: "SYSTEM_RUN_DENIED: environment override rejected (\(details.joined(separator: "; ")))")
+ }
let evaluation = await ExecApprovalEvaluator.evaluate(
command: command,
rawCommand: params.rawCommand,
diff --git a/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift b/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift
index 1e9da910b2a..55a15419576 100644
--- a/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/HostEnvSanitizerTests.swift
@@ -33,4 +33,24 @@ struct HostEnvSanitizerTests {
let env = HostEnvSanitizer.sanitize(overrides: ["OPENCLAW_TOKEN": "secret"])
#expect(env["OPENCLAW_TOKEN"] == "secret")
}
+
+ @Test func `inspect overrides rejects blocked and invalid keys`() {
+ let diagnostics = HostEnvSanitizer.inspectOverrides(overrides: [
+ "CLASSPATH": "/tmp/evil-classpath",
+ "BAD-KEY": "x",
+ "ProgramFiles(x86)": "C:\\Program Files (x86)",
+ ])
+
+ #expect(diagnostics.blockedKeys == ["CLASSPATH"])
+ #expect(diagnostics.invalidKeys == ["BAD-KEY"])
+ }
+
+ @Test func `sanitize accepts Windows-style override key names`() {
+ let env = HostEnvSanitizer.sanitize(overrides: [
+ "ProgramFiles(x86)": "D:\\SDKs",
+ "CommonProgramFiles(x86)": "D:\\Common",
+ ])
+ #expect(env["ProgramFiles(x86)"] == "D:\\SDKs")
+ #expect(env["CommonProgramFiles(x86)"] == "D:\\Common")
+ }
}
diff --git a/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift b/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift
index 20b4184f5c9..38c4211f014 100644
--- a/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift
+++ b/apps/macos/Tests/OpenClawIPCTests/MacNodeRuntimeTests.swift
@@ -21,6 +21,32 @@ struct MacNodeRuntimeTests {
#expect(response.ok == false)
}
+ @Test func `handle invoke rejects blocked system run env override before execution`() async throws {
+ let runtime = MacNodeRuntime()
+ let params = OpenClawSystemRunParams(
+ command: ["/bin/sh", "-lc", "echo ok"],
+ env: ["CLASSPATH": "/tmp/evil-classpath"])
+ let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
+ let response = await runtime.handleInvoke(
+ BridgeInvokeRequest(id: "req-2c", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json))
+ #expect(response.ok == false)
+ #expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true)
+ #expect(response.error?.message.contains("CLASSPATH") == true)
+ }
+
+ @Test func `handle invoke rejects invalid system run env override key before execution`() async throws {
+ let runtime = MacNodeRuntime()
+ let params = OpenClawSystemRunParams(
+ command: ["/bin/sh", "-lc", "echo ok"],
+ env: ["BAD-KEY": "x"])
+ let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
+ let response = await runtime.handleInvoke(
+ BridgeInvokeRequest(id: "req-2d", command: OpenClawSystemCommand.run.rawValue, paramsJSON: json))
+ #expect(response.ok == false)
+ #expect(response.error?.message.contains("SYSTEM_RUN_DENIED: environment override rejected") == true)
+ #expect(response.error?.message.contains("BAD-KEY") == true)
+ }
+
@Test func `handle invoke rejects empty system which`() async throws {
let runtime = MacNodeRuntime()
let params = OpenClawSystemWhichParams(bins: [])
diff --git a/docs/channels/line.md b/docs/channels/line.md
index a965dc6e991..079025e10ac 100644
--- a/docs/channels/line.md
+++ b/docs/channels/line.md
@@ -51,6 +51,7 @@ If you need a custom path, set `channels.line.webhookPath` or
Security note:
- LINE signature verification is body-dependent (HMAC over the raw body), so OpenClaw applies strict pre-auth body limits and timeout before verification.
+- OpenClaw processes webhook events from the verified raw request bytes. Upstream middleware-transformed `req.body` values are ignored for signature-integrity safety.
## Configure
diff --git a/docs/docs.json b/docs/docs.json
index 65e4ed25c1b..be9fa476ea7 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -64,6 +64,18 @@
"source": "/platforms/raspberry-pi",
"destination": "/install/raspberry-pi"
},
+ {
+ "source": "/plugins/building-extensions",
+ "destination": "/plugins/building-plugins"
+ },
+ {
+ "source": "/plugins/agent-tools",
+ "destination": "/plugins/building-plugins#registering-agent-tools"
+ },
+ {
+ "source": "/tools/capability-cookbook",
+ "destination": "/plugins/architecture"
+ },
{
"source": "/brave-search",
"destination": "/tools/brave-search"
@@ -948,6 +960,7 @@
"channels/telegram",
"channels/tlon",
"channels/twitch",
+ "plugins/voice-call",
"channels/whatsapp",
"channels/zalo",
"channels/zalouser"
@@ -1014,79 +1027,35 @@
]
},
{
- "tab": "Tools",
+ "tab": "Tools & Plugins",
"groups": [
{
"group": "Overview",
"pages": ["tools/index"]
},
{
- "group": "Built-in tools",
+ "group": "Plugins",
"pages": [
- "tools/apply-patch",
- "tools/brave-search",
- "tools/btw",
- "tools/diffs",
- "tools/elevated",
- "tools/exec",
- "tools/exec-approvals",
- "tools/firecrawl",
- "tools/tavily",
- "tools/llm-task",
- "tools/lobster",
- "tools/loop-detection",
- "tools/pdf",
- "tools/perplexity-search",
- "tools/reactions",
- "tools/thinking",
- "tools/web"
- ]
- },
- {
- "group": "Browser",
- "pages": [
- "tools/browser",
- "tools/browser-login",
- "tools/browser-linux-troubleshooting",
- "tools/browser-wsl2-windows-remote-cdp-troubleshooting"
- ]
- },
- {
- "group": "Agent coordination",
- "pages": [
- "tools/agent-send",
- "tools/subagents",
- "tools/acp-agents",
- "tools/multi-agent-sandbox-tools"
+ "tools/plugin",
+ "plugins/building-plugins",
+ "plugins/community",
+ "plugins/bundles",
+ "plugins/manifest",
+ "plugins/sdk-migration",
+ "plugins/architecture"
]
},
{
"group": "Skills",
"pages": [
- "tools/creating-skills",
- "tools/slash-commands",
"tools/skills",
+ "tools/creating-skills",
"tools/skills-config",
+ "tools/slash-commands",
"tools/clawhub",
- "tools/plugin",
"prose"
]
},
- {
- "group": "Extensions",
- "pages": [
- "plugins/building-extensions",
- "plugins/sdk-migration",
- "plugins/architecture",
- "plugins/community",
- "plugins/bundles",
- "plugins/voice-call",
- "plugins/zalouser",
- "plugins/manifest",
- "plugins/agent-tools",
- "tools/capability-cookbook"
- ]
- },
{
"group": "Automation",
"pages": [
@@ -1102,18 +1071,48 @@
]
},
{
- "group": "Media and devices",
+ "group": "Tools",
"pages": [
- "nodes/index",
- "nodes/troubleshooting",
- "nodes/media-understanding",
- "nodes/images",
- "nodes/audio",
- "nodes/camera",
- "nodes/talk",
- "nodes/voicewake",
- "nodes/location-command",
- "tools/tts"
+ "tools/apply-patch",
+ {
+ "group": "Browser",
+ "pages": [
+ "tools/browser",
+ "tools/browser-login",
+ "tools/browser-linux-troubleshooting",
+ "tools/browser-wsl2-windows-remote-cdp-troubleshooting"
+ ]
+ },
+ "tools/btw",
+ "tools/diffs",
+ "tools/elevated",
+ "tools/exec",
+ "tools/exec-approvals",
+ "tools/llm-task",
+ "tools/lobster",
+ "tools/loop-detection",
+ "tools/pdf",
+ "tools/reactions",
+ "tools/thinking",
+ {
+ "group": "Web and search",
+ "pages": [
+ "tools/web",
+ "tools/brave-search",
+ "tools/firecrawl",
+ "tools/perplexity-search",
+ "tools/tavily"
+ ]
+ }
+ ]
+ },
+ {
+ "group": "Agent coordination",
+ "pages": [
+ "tools/agent-send",
+ "tools/subagents",
+ "tools/acp-agents",
+ "tools/multi-agent-sandbox-tools"
]
}
]
@@ -1283,6 +1282,21 @@
"security/CONTRIBUTING-THREAT-MODEL"
]
},
+ {
+ "group": "Nodes and devices",
+ "pages": [
+ "nodes/index",
+ "nodes/troubleshooting",
+ "nodes/media-understanding",
+ "nodes/images",
+ "nodes/audio",
+ "nodes/camera",
+ "nodes/talk",
+ "nodes/voicewake",
+ "nodes/location-command",
+ "tools/tts"
+ ]
+ },
{
"group": "Web interfaces",
"pages": ["web/index", "web/control-ui", "web/dashboard", "web/webchat", "web/tui"]
diff --git a/docs/plugins/agent-tools.md b/docs/plugins/agent-tools.md
index 8740fd51fa4..930bdfbe629 100644
--- a/docs/plugins/agent-tools.md
+++ b/docs/plugins/agent-tools.md
@@ -1,99 +1,10 @@
---
-summary: "Write agent tools in a plugin (schemas, optional tools, allowlists)"
+summary: "Redirects to Building Plugins (registering tools section)"
read_when:
- - You want to add a new agent tool in a plugin
- - You need to make a tool opt-in via allowlists
-title: "Plugin Agent Tools"
+ - Legacy link to agent-tools
+title: "Registering Tools"
---
-# Plugin agent tools
+# Registering Tools in Plugins
-OpenClaw plugins can register **agent tools** (JSONβschema functions) that are exposed
-to the LLM during agent runs. Tools can be **required** (always available) or
-**optional** (optβin).
-
-Agent tools are configured under `tools` in the main config, or perβagent under
-`agents.list[].tools`. The allowlist/denylist policy controls which tools the agent
-can call.
-
-## Basic tool
-
-```ts
-import { Type } from "@sinclair/typebox";
-
-export default function (api) {
- api.registerTool({
- name: "my_tool",
- description: "Do a thing",
- parameters: Type.Object({
- input: Type.String(),
- }),
- async execute(_id, params) {
- return { content: [{ type: "text", text: params.input }] };
- },
- });
-}
-```
-
-## Optional tool (opt-in)
-
-Optional tools are **never** autoβenabled. Users must add them to an agent
-allowlist.
-
-```ts
-export default function (api) {
- api.registerTool(
- {
- name: "workflow_tool",
- description: "Run a local workflow",
- parameters: {
- type: "object",
- properties: {
- pipeline: { type: "string" },
- },
- required: ["pipeline"],
- },
- async execute(_id, params) {
- return { content: [{ type: "text", text: params.pipeline }] };
- },
- },
- { optional: true },
- );
-}
-```
-
-Enable optional tools in `agents.list[].tools.allow` (or global `tools.allow`):
-
-```json5
-{
- agents: {
- list: [
- {
- id: "main",
- tools: {
- allow: [
- "workflow_tool", // specific tool name
- "workflow", // plugin id (enables all tools from that plugin)
- "group:plugins", // all plugin tools
- ],
- },
- },
- ],
- },
-}
-```
-
-Other config knobs that affect tool availability:
-
-- Allowlists that only name plugin tools are treated as plugin opt-ins; core tools remain
- enabled unless you also include core tools or groups in the allowlist.
-- `tools.profile` / `agents.list[].tools.profile` (base allowlist)
-- `tools.byProvider` / `agents.list[].tools.byProvider` (providerβspecific allow/deny)
-- `tools.sandbox.tools.*` (sandbox tool policy when sandboxed)
-
-## Rules + tips
-
-- Tool names must **not** clash with core tool names; conflicting tools are skipped.
-- Plugin ids used in allowlists must not clash with core tool names.
-- Prefer `optional: true` for tools that trigger side effects or require extra
- binaries/credentials.
+This page has moved. See [Building Plugins: Registering agent tools](/plugins/building-plugins#registering-agent-tools).
diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md
index 4ffdeb29125..49aa6344ca9 100644
--- a/docs/plugins/architecture.md
+++ b/docs/plugins/architecture.md
@@ -1,17 +1,23 @@
---
-summary: "Plugin architecture internals: capability model, ownership, contracts, load pipeline, runtime helpers"
+summary: "Plugin internals: capability model, ownership, contracts, load pipeline, and runtime helpers"
read_when:
- Building or debugging native OpenClaw plugins
- Understanding the plugin capability model or ownership boundaries
- Working on the plugin load pipeline or registry
- Implementing provider runtime hooks or channel plugins
-title: "Plugin Architecture"
+title: "Plugin Internals"
+sidebarTitle: "Internals"
---
-# Plugin Architecture
+# Plugin Internals
-This page covers the internal architecture of the OpenClaw plugin system. For
-user-facing setup, discovery, and configuration, see [Plugins](/tools/plugin).
+
+ This page is for **plugin developers and contributors**. If you just want to
+ install and use plugins, see [Plugins](/tools/plugin). If you want to build
+ a plugin, see [Building Plugins](/plugins/building-plugins).
+
+
+This page covers the internal architecture of the OpenClaw plugin system.
## Public capability model
@@ -927,25 +933,31 @@ authoring plugins:
- `openclaw/plugin-sdk/core` for the generic shared plugin-facing contract.
- Stable channel primitives such as `openclaw/plugin-sdk/channel-setup`,
`openclaw/plugin-sdk/channel-pairing`,
+ `openclaw/plugin-sdk/channel-contract`,
+ `openclaw/plugin-sdk/channel-feedback`,
+ `openclaw/plugin-sdk/channel-inbound`,
+ `openclaw/plugin-sdk/channel-lifecycle`,
`openclaw/plugin-sdk/channel-reply-pipeline`,
+ `openclaw/plugin-sdk/command-auth`,
`openclaw/plugin-sdk/secret-input`, and
`openclaw/plugin-sdk/webhook-ingress` for shared setup/auth/reply/webhook
- wiring.
+ wiring. `channel-inbound` is the shared home for debounce, mention matching,
+ envelope formatting, and inbound envelope context helpers.
- Domain subpaths such as `openclaw/plugin-sdk/channel-config-helpers`,
+ `openclaw/plugin-sdk/allow-from`,
`openclaw/plugin-sdk/channel-config-schema`,
`openclaw/plugin-sdk/channel-policy`,
- `openclaw/plugin-sdk/channel-runtime`,
`openclaw/plugin-sdk/config-runtime`,
+ `openclaw/plugin-sdk/infra-runtime`,
`openclaw/plugin-sdk/agent-runtime`,
`openclaw/plugin-sdk/lazy-runtime`,
`openclaw/plugin-sdk/reply-history`,
`openclaw/plugin-sdk/routing`,
+ `openclaw/plugin-sdk/status-helpers`,
`openclaw/plugin-sdk/runtime-store`, and
`openclaw/plugin-sdk/directory-runtime` for shared runtime/config helpers.
-- Narrow channel-core subpaths such as `openclaw/plugin-sdk/discord-core`,
- `openclaw/plugin-sdk/telegram-core`, and `openclaw/plugin-sdk/whatsapp-core`
- for channel-specific primitives that should stay smaller than the full
- channel helper barrels.
+- `openclaw/plugin-sdk/channel-runtime` remains only as a compatibility shim.
+ New code should import the narrower primitives instead.
- Bundled extension internals remain private. External plugins should use only
`openclaw/plugin-sdk/*` subpaths. OpenClaw core/test code may use the repo
public entry points under `extensions//index.js`, `api.js`, `runtime-api.js`,
@@ -956,26 +968,25 @@ authoring plugins:
`extensions//runtime-api.js` is the runtime-only barrel,
`extensions//index.js` is the bundled plugin entry,
and `extensions//setup-entry.js` is the setup plugin entry.
-- `openclaw/plugin-sdk/telegram` for Telegram channel plugin types and shared channel-facing helpers. Built-in Telegram implementation internals stay private to the bundled extension.
-- `openclaw/plugin-sdk/discord` for Discord channel plugin types and shared channel-facing helpers. Built-in Discord implementation internals stay private to the bundled extension.
-- `openclaw/plugin-sdk/slack` for Slack channel plugin types and shared channel-facing helpers. Built-in Slack implementation internals stay private to the bundled extension.
-- `openclaw/plugin-sdk/imessage` for iMessage channel plugin types and shared channel-facing helpers. Built-in iMessage implementation internals stay private to the bundled extension.
-- `openclaw/plugin-sdk/whatsapp` for WhatsApp channel plugin types and shared channel-facing helpers. Built-in WhatsApp implementation internals stay private to the bundled extension.
-- `openclaw/plugin-sdk/bluebubbles` remains public because it carries a small
- focused helper surface that is shared intentionally.
+- No bundled channel-branded public subpaths remain. Channel-specific helper and
+ runtime seams live under `extensions//api.js` and `extensions//runtime-api.js`;
+ the public SDK contract is the generic shared primitives instead.
Compatibility note:
- Avoid the root `openclaw/plugin-sdk` barrel for new code.
- Prefer the narrow stable primitives first. The newer setup/pairing/reply/
- secret-input/webhook subpaths are the intended contract for new bundled and
- external plugin work.
+ feedback/contract/inbound/threading/command/secret-input/webhook/infra/
+ allowlist/status/message-tool subpaths are the intended contract for new
+ bundled and external plugin work.
+ Target parsing/matching belongs on `openclaw/plugin-sdk/channel-targets`.
+ Message action gates and reaction message-id helpers belong on
+ `openclaw/plugin-sdk/channel-actions`.
- Bundled extension-specific helper barrels are not stable by default. If a
helper is only needed by a bundled extension, keep it behind the extension's
local `api.js` or `runtime-api.js` seam instead of promoting it into
`openclaw/plugin-sdk/`.
-- Channel-branded bundled bars such as `feishu`, `googlechat`, `irc`, `line`,
- `nostr`, `twitch`, and `zalo` stay private unless they are explicitly added
+- Channel-branded bundled bars stay private unless they are explicitly added
back to the public contract.
- Capability-specific subpaths such as `image-generation`,
`media-understanding`, and `speech` exist because bundled/native plugins use
@@ -988,7 +999,7 @@ Plugins should own channel-specific `describeMessageTool(...)` schema
contributions. Keep provider-specific fields in the plugin, not in shared core.
For shared portable schema fragments, reuse the generic helpers exported through
-`openclaw/plugin-sdk/channel-runtime`:
+`openclaw/plugin-sdk/channel-actions`:
- `createMessageToolButtonsSchema()` for button-grid style payloads
- `createMessageToolCardSchema()` for structured card payloads
diff --git a/docs/plugins/building-extensions.md b/docs/plugins/building-extensions.md
index 7b4548194cd..f0db0f3173f 100644
--- a/docs/plugins/building-extensions.md
+++ b/docs/plugins/building-extensions.md
@@ -1,224 +1,10 @@
---
-title: "Building Extensions"
-summary: "Step-by-step guide for creating OpenClaw channel and provider extensions"
+title: "Building Plugins"
+summary: "Redirects to the current Building Plugins guide"
read_when:
- - You want to create a new OpenClaw plugin or extension
- - You need to understand the plugin SDK import patterns
- - You are adding a new channel or provider to OpenClaw
+ - Legacy link to building-extensions
---
-# Building Extensions
+# Building Plugins
-Extensions add channels, model providers, tools, or other capabilities to OpenClaw.
-This guide walks through creating one from scratch.
-
-## Prerequisites
-
-- OpenClaw repository cloned and dependencies installed (`pnpm install`)
-- Familiarity with TypeScript (ESM)
-
-## Extension structure
-
-Every extension lives under `extensions//` and follows this layout:
-
-```
-extensions/my-channel/
-βββ package.json # npm metadata + openclaw config
-βββ index.ts # Entry point (defineChannelPluginEntry)
-βββ setup-entry.ts # Setup wizard (optional)
-βββ api.ts # Public contract barrel (optional)
-βββ runtime-api.ts # Internal runtime barrel (optional)
-βββ src/
- βββ channel.ts # Channel adapter implementation
- βββ runtime.ts # Runtime wiring
- βββ *.test.ts # Colocated tests
-```
-
-## Create an extension
-
-
-
- Create `extensions/my-channel/package.json`:
-
- ```json
- {
- "name": "@openclaw/my-channel",
- "version": "2026.1.1",
- "description": "OpenClaw My Channel plugin",
- "type": "module",
- "dependencies": {},
- "openclaw": {
- "extensions": ["./index.ts"],
- "setupEntry": "./setup-entry.ts",
- "channel": {
- "id": "my-channel",
- "label": "My Channel",
- "selectionLabel": "My Channel (plugin)",
- "docsPath": "/channels/my-channel",
- "docsLabel": "my-channel",
- "blurb": "Short description of the channel.",
- "order": 80
- },
- "install": {
- "npmSpec": "@openclaw/my-channel",
- "localPath": "extensions/my-channel"
- }
- }
- }
- ```
-
- The `openclaw` field tells the plugin system what your extension provides.
- For provider plugins, use `providers` instead of `channel`.
-
-
-
-
- Create `extensions/my-channel/index.ts`:
-
- ```typescript
- import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
-
- export default defineChannelPluginEntry({
- id: "my-channel",
- name: "My Channel",
- description: "Connects OpenClaw to My Channel",
- plugin: {
- // Channel adapter implementation
- },
- });
- ```
-
- For provider plugins, use `definePluginEntry` instead.
-
-
-
-
- Always import from specific `openclaw/plugin-sdk/` paths rather than
- the monolithic root. The old `openclaw/plugin-sdk/compat` barrel is deprecated
- (see [SDK Migration](/plugins/sdk-migration)).
-
- ```typescript
- // Correct: focused subpaths
- import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
- import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
- import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
- import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
- import { createOptionalChannelSetupSurface } from "openclaw/plugin-sdk/channel-setup";
- import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy";
- import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth";
-
- // Wrong: monolithic root (lint will reject this)
- import { ... } from "openclaw/plugin-sdk";
- ```
-
-
- | Subpath | Purpose |
- | --- | --- |
- | `plugin-sdk/core` | Plugin entry definitions, base types |
- | `plugin-sdk/channel-setup` | Optional setup adapters/wizards |
- | `plugin-sdk/channel-pairing` | DM pairing primitives |
- | `plugin-sdk/channel-reply-pipeline` | Prefix + typing reply wiring |
- | `plugin-sdk/channel-config-schema` | Config schema builders |
- | `plugin-sdk/channel-policy` | Group/DM policy helpers |
- | `plugin-sdk/secret-input` | Secret input parsing/helpers |
- | `plugin-sdk/webhook-ingress` | Webhook request/target helpers |
- | `plugin-sdk/runtime-store` | Persistent plugin storage |
- | `plugin-sdk/allow-from` | Allowlist resolution |
- | `plugin-sdk/reply-payload` | Message reply types |
- | `plugin-sdk/provider-oauth` | OAuth login + PKCE helpers |
- | `plugin-sdk/provider-onboard` | Provider onboarding config patches |
- | `plugin-sdk/testing` | Test utilities |
-
-
- Use the narrowest primitive that matches the job. Reach for `channel-runtime`
- or other larger helper barrels only when a dedicated subpath does not exist yet.
-
-
-
-
- Within your extension, create barrel files for internal code sharing instead
- of importing through the plugin SDK:
-
- ```typescript
- // api.ts β public contract for this extension
- export { MyChannelConfig } from "./src/config.js";
- export { MyChannelRuntime } from "./src/runtime.js";
-
- // runtime-api.ts β internal-only exports (not for production consumers)
- export { internalHelper } from "./src/helpers.js";
- ```
-
-
- Never import your own extension back through its published SDK contract
- path from production files. Route internal imports through `./api.ts` or
- `./runtime-api.ts` instead. The SDK contract is for external consumers only.
-
-
-
-
-
- Create `openclaw.plugin.json` in your extension root:
-
- ```json
- {
- "id": "my-channel",
- "kind": "channel",
- "channels": ["my-channel"],
- "name": "My Channel Plugin",
- "description": "Connects OpenClaw to My Channel"
- }
- ```
-
- See [Plugin manifest](/plugins/manifest) for the full schema.
-
-
-
-
- OpenClaw runs contract tests against all registered plugins. After adding your
- extension, run:
-
- ```bash
- pnpm test:contracts:channels # channel plugins
- pnpm test:contracts:plugins # provider plugins
- ```
-
- Contract tests verify your plugin conforms to the expected interface (setup
- wizard, session binding, message handling, group policy, etc.).
-
- For unit tests, import test helpers from the public testing surface:
-
- ```typescript
- import { createTestRuntime } from "openclaw/plugin-sdk/testing";
- ```
-
-
-
-
-## Lint enforcement
-
-Three scripts enforce SDK boundaries:
-
-1. **No monolithic root imports** β `openclaw/plugin-sdk` root is rejected
-2. **No direct src/ imports** β extensions cannot import `../../src/` directly
-3. **No self-imports** β extensions cannot import their own `plugin-sdk/` subpath
-
-Run `pnpm check` to verify all boundaries before committing.
-
-## Pre-submission checklist
-
-**package.json** has correct `openclaw` metadata
-Entry point uses `defineChannelPluginEntry` or `definePluginEntry`
-All imports use focused `plugin-sdk/` paths
-Internal imports use local barrels, not SDK self-imports
-`openclaw.plugin.json` manifest is present and valid
-Contract tests pass (`pnpm test:contracts`)
-Unit tests colocated as `*.test.ts`
-`pnpm check` passes (lint + format)
-Doc page created under `docs/channels/` or `docs/plugins/`
-
-## Related
-
-- [Plugin SDK Migration](/plugins/sdk-migration) β migrating from compat to focused subpaths
-- [Plugin Architecture](/plugins/architecture) β internals and capability model
-- [Plugin Manifest](/plugins/manifest) β full manifest schema
-- [Community Plugins](/plugins/community) β existing community extensions
+This page has moved to [Building Plugins](/plugins/building-plugins).
diff --git a/docs/plugins/building-plugins.md b/docs/plugins/building-plugins.md
new file mode 100644
index 00000000000..121b673f5c6
--- /dev/null
+++ b/docs/plugins/building-plugins.md
@@ -0,0 +1,369 @@
+---
+title: "Building Plugins"
+sidebarTitle: "Building Plugins"
+summary: "Step-by-step guide for creating OpenClaw plugins with any combination of capabilities"
+read_when:
+ - You want to create a new OpenClaw plugin
+ - You need to understand the plugin SDK import patterns
+ - You are adding a new channel, provider, tool, or other capability to OpenClaw
+---
+
+# Building Plugins
+
+Plugins extend OpenClaw with new capabilities: channels, model providers, speech,
+image generation, web search, agent tools, or any combination. A single plugin
+can register multiple capabilities.
+
+OpenClaw encourages **external plugin development**. You do not need to add your
+plugin to the OpenClaw repository. Publish your plugin on npm, and users install
+it with `openclaw plugins install `. OpenClaw also maintains a set of
+core plugins in-repo, but the plugin system is designed for independent ownership
+and distribution.
+
+## Prerequisites
+
+- Node >= 22 and a package manager (npm or pnpm)
+- Familiarity with TypeScript (ESM)
+- For in-repo plugins: OpenClaw repository cloned and `pnpm install` done
+
+## Plugin capabilities
+
+A plugin can register one or more capabilities. The capability you register
+determines what your plugin provides to OpenClaw:
+
+| Capability | Registration method | What it adds |
+| ------------------- | --------------------------------------------- | ------------------------------ |
+| Text inference | `api.registerProvider(...)` | Model provider (LLM) |
+| Channel / messaging | `api.registerChannel(...)` | Chat channel (e.g. Slack, IRC) |
+| Speech | `api.registerSpeechProvider(...)` | Text-to-speech / STT |
+| Media understanding | `api.registerMediaUnderstandingProvider(...)` | Image/audio/video analysis |
+| Image generation | `api.registerImageGenerationProvider(...)` | Image generation |
+| Web search | `api.registerWebSearchProvider(...)` | Web search provider |
+| Agent tools | `api.registerTool(...)` | Tools callable by the agent |
+
+A plugin that registers zero capabilities but provides hooks or services is a
+**hook-only** plugin. That pattern is still supported.
+
+## Plugin structure
+
+Plugins follow this layout (whether in-repo or standalone):
+
+```
+my-plugin/
+βββ package.json # npm metadata + openclaw config
+βββ openclaw.plugin.json # Plugin manifest
+βββ index.ts # Entry point
+βββ setup-entry.ts # Setup wizard (optional)
+βββ api.ts # Public exports (optional)
+βββ runtime-api.ts # Internal exports (optional)
+βββ src/
+ βββ provider.ts # Capability implementation
+ βββ runtime.ts # Runtime wiring
+ βββ *.test.ts # Colocated tests
+```
+
+## Create a plugin
+
+
+
+ Create `package.json` with the `openclaw` metadata block. The structure
+ depends on what capabilities your plugin provides.
+
+ **Channel plugin example:**
+
+ ```json
+ {
+ "name": "@myorg/openclaw-my-channel",
+ "version": "1.0.0",
+ "type": "module",
+ "openclaw": {
+ "extensions": ["./index.ts"],
+ "channel": {
+ "id": "my-channel",
+ "label": "My Channel",
+ "blurb": "Short description of the channel."
+ }
+ }
+ }
+ ```
+
+ **Provider plugin example:**
+
+ ```json
+ {
+ "name": "@myorg/openclaw-my-provider",
+ "version": "1.0.0",
+ "type": "module",
+ "openclaw": {
+ "extensions": ["./index.ts"],
+ "providers": ["my-provider"]
+ }
+ }
+ ```
+
+ The `openclaw` field tells the plugin system what your plugin provides.
+ A plugin can declare both `channel` and `providers` if it provides multiple
+ capabilities.
+
+
+
+
+ The entry point registers your capabilities with the plugin API.
+
+ **Channel plugin:**
+
+ ```typescript
+ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
+
+ export default defineChannelPluginEntry({
+ id: "my-channel",
+ name: "My Channel",
+ description: "Connects OpenClaw to My Channel",
+ plugin: {
+ // Channel adapter implementation
+ },
+ });
+ ```
+
+ **Provider plugin:**
+
+ ```typescript
+ import { definePluginEntry } from "openclaw/plugin-sdk/core";
+
+ export default definePluginEntry({
+ id: "my-provider",
+ name: "My Provider",
+ register(api) {
+ api.registerProvider({
+ // Provider implementation
+ });
+ },
+ });
+ ```
+
+ **Multi-capability plugin** (provider + tool):
+
+ ```typescript
+ import { definePluginEntry } from "openclaw/plugin-sdk/core";
+
+ export default definePluginEntry({
+ id: "my-plugin",
+ name: "My Plugin",
+ register(api) {
+ api.registerProvider({ /* ... */ });
+ api.registerTool({ /* ... */ });
+ api.registerImageGenerationProvider({ /* ... */ });
+ },
+ });
+ ```
+
+ Use `defineChannelPluginEntry` for channel plugins and `definePluginEntry`
+ for everything else. A single plugin can register as many capabilities as needed.
+
+
+
+
+ Always import from specific `openclaw/plugin-sdk/\` paths. The old
+ monolithic import is deprecated (see [SDK Migration](/plugins/sdk-migration)).
+
+ If older plugin code still imports `openclaw/extension-api`, treat that as a
+ temporary compatibility bridge only. New code should use injected runtime
+ helpers such as `api.runtime.agent.*` instead of importing host-side agent
+ helpers directly.
+
+ ```typescript
+ // Correct: focused subpaths
+ import { definePluginEntry } from "openclaw/plugin-sdk/core";
+ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
+ import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth";
+
+ // Wrong: monolithic root (lint will reject this)
+ import { ... } from "openclaw/plugin-sdk";
+
+ // Deprecated: legacy host bridge
+ import { runEmbeddedPiAgent } from "openclaw/extension-api";
+ ```
+
+
+ | Subpath | Purpose |
+ | --- | --- |
+ | `plugin-sdk/core` | Plugin entry definitions and base types |
+ | `plugin-sdk/channel-setup` | Setup wizard adapters |
+ | `plugin-sdk/channel-pairing` | DM pairing primitives |
+ | `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring |
+ | `plugin-sdk/channel-config-schema` | Config schema builders |
+ | `plugin-sdk/channel-policy` | Group/DM policy helpers |
+ | `plugin-sdk/secret-input` | Secret input parsing/helpers |
+ | `plugin-sdk/webhook-ingress` | Webhook request/target helpers |
+ | `plugin-sdk/runtime-store` | Persistent plugin storage |
+ | `plugin-sdk/allow-from` | Allowlist resolution |
+ | `plugin-sdk/reply-payload` | Message reply types |
+ | `plugin-sdk/provider-oauth` | OAuth login + PKCE helpers |
+ | `plugin-sdk/provider-onboard` | Provider onboarding config patches |
+ | `plugin-sdk/testing` | Test utilities |
+
+
+ Use the narrowest subpath that matches the job.
+
+
+
+
+ Within your plugin, create local module files for internal code sharing
+ instead of re-importing through the plugin SDK:
+
+ ```typescript
+ // api.ts β public exports for this plugin
+ export { MyConfig } from "./src/config.js";
+ export { MyRuntime } from "./src/runtime.js";
+
+ // runtime-api.ts β internal-only exports
+ export { internalHelper } from "./src/helpers.js";
+ ```
+
+
+ Never import your own plugin back through its published SDK path from
+ production files. Route internal imports through local files like `./api.ts`
+ or `./runtime-api.ts`. The SDK path is for external consumers only.
+
+
+
+
+
+ Create `openclaw.plugin.json` in your plugin root:
+
+ ```json
+ {
+ "id": "my-plugin",
+ "kind": "provider",
+ "name": "My Plugin",
+ "description": "Adds My Provider to OpenClaw"
+ }
+ ```
+
+ For channel plugins, set `"kind": "channel"` and add `"channels": ["my-channel"]`.
+
+ See [Plugin Manifest](/plugins/manifest) for the full schema.
+
+
+
+
+ **External plugins:** run your own test suite against the plugin SDK contracts.
+
+ **In-repo plugins:** OpenClaw runs contract tests against all registered plugins:
+
+ ```bash
+ pnpm test:contracts:channels # channel plugins
+ pnpm test:contracts:plugins # provider plugins
+ ```
+
+ For unit tests, import test helpers from the testing surface:
+
+ ```typescript
+ import { createTestRuntime } from "openclaw/plugin-sdk/testing";
+ ```
+
+
+
+
+ **External plugins:** publish to npm, then install:
+
+ ```bash
+ npm publish
+ openclaw plugins install @myorg/openclaw-my-plugin
+ ```
+
+ **In-repo plugins:** place the plugin under `extensions/` and it is
+ automatically discovered during build.
+
+ Users can browse and install community plugins with:
+
+ ```bash
+ openclaw plugins search
+ openclaw plugins install
+ ```
+
+
+
+
+## Registering agent tools
+
+Plugins can register **agent tools** β typed functions the LLM can call. Tools
+can be required (always available) or optional (users opt in via allowlists).
+
+```typescript
+import { Type } from "@sinclair/typebox";
+
+export default definePluginEntry({
+ id: "my-plugin",
+ name: "My Plugin",
+ register(api) {
+ // Required tool (always available)
+ api.registerTool({
+ name: "my_tool",
+ description: "Do a thing",
+ parameters: Type.Object({ input: Type.String() }),
+ async execute(_id, params) {
+ return { content: [{ type: "text", text: params.input }] };
+ },
+ });
+
+ // Optional tool (user must add to allowlist)
+ api.registerTool(
+ {
+ name: "workflow_tool",
+ description: "Run a workflow",
+ parameters: Type.Object({ pipeline: Type.String() }),
+ async execute(_id, params) {
+ return { content: [{ type: "text", text: params.pipeline }] };
+ },
+ },
+ { optional: true },
+ );
+ },
+});
+```
+
+Enable optional tools in config:
+
+```json5
+{
+ tools: { allow: ["workflow_tool"] },
+}
+```
+
+Tips:
+
+- Tool names must not clash with core tool names (conflicts are skipped)
+- Use `optional: true` for tools that trigger side effects or require extra binaries
+- Users can enable all tools from a plugin by adding the plugin id to `tools.allow`
+
+## Lint enforcement (in-repo plugins)
+
+Three scripts enforce SDK boundaries for plugins in the OpenClaw repository:
+
+1. **No monolithic root imports** β `openclaw/plugin-sdk` root is rejected
+2. **No direct src/ imports** β plugins cannot import `../../src/` directly
+3. **No self-imports** β plugins cannot import their own `plugin-sdk/\` subpath
+
+Run `pnpm check` to verify all boundaries before committing.
+
+External plugins are not subject to these lint rules, but following the same
+patterns is strongly recommended.
+
+## Pre-submission checklist
+
+**package.json** has correct `openclaw` metadata
+Entry point uses `defineChannelPluginEntry` or `definePluginEntry`
+All imports use focused `plugin-sdk/\` paths
+Internal imports use local modules, not SDK self-imports
+`openclaw.plugin.json` manifest is present and valid
+Tests pass
+`pnpm check` passes (in-repo plugins)
+
+## Related
+
+- [Plugin SDK Migration](/plugins/sdk-migration) β migrating from deprecated compat surfaces
+- [Plugin Architecture](/plugins/architecture) β internals and capability model
+- [Plugin Manifest](/plugins/manifest) β full manifest schema
+- [Plugin Agent Tools](/plugins/building-plugins#registering-agent-tools) β adding agent tools in a plugin
+- [Community Plugins](/plugins/community) β listing and quality bar
diff --git a/docs/plugins/bundles.md b/docs/plugins/bundles.md
index 82a5605e099..b60b110e6b7 100644
--- a/docs/plugins/bundles.md
+++ b/docs/plugins/bundles.md
@@ -1,307 +1,181 @@
---
-summary: "Unified bundle format guide for Codex, Claude, and Cursor bundles in OpenClaw"
+summary: "Install and use Codex, Claude, and Cursor bundles as OpenClaw plugins"
read_when:
- - You want to install or debug a Codex, Claude, or Cursor-compatible bundle
+ - You want to install a Codex, Claude, or Cursor-compatible bundle
- You need to understand how OpenClaw maps bundle content into native features
- - You are documenting bundle compatibility or current support limits
+ - You are debugging bundle detection or missing capabilities
title: "Plugin Bundles"
---
-# Plugin bundles
+# Plugin Bundles
-OpenClaw supports one shared class of external plugin package: **bundle
-plugins**.
+OpenClaw can install plugins from three external ecosystems: **Codex**, **Claude**,
+and **Cursor**. These are called **bundles** β content and metadata packs that
+OpenClaw maps into native features like skills, hooks, and MCP tools.
-Today that means three closely related ecosystems:
+
+ Bundles are **not** the same as native OpenClaw plugins. Native plugins run
+ in-process and can register any capability. Bundles are content packs with
+ selective feature mapping and a narrower trust boundary.
+
-- Codex bundles
-- Claude bundles
-- Cursor bundles
+## Why bundles exist
-OpenClaw shows all of them as `Format: bundle` in `openclaw plugins list`.
-Verbose output and `openclaw plugins inspect ` also show the subtype
-(`codex`, `claude`, or `cursor`).
+Many useful plugins are published in Codex, Claude, or Cursor format. Instead
+of requiring authors to rewrite them as native OpenClaw plugins, OpenClaw
+detects these formats and maps their supported content into the native feature
+set. This means you can install a Claude command pack or a Codex skill bundle
+and use it immediately.
-Related:
+## Install a bundle
-- Plugin system overview: [Plugins](/tools/plugin)
-- CLI install/list flows: [plugins](/cli/plugins)
-- Native manifest schema: [Plugin manifest](/plugins/manifest)
+
+
+ ```bash
+ # Local directory
+ openclaw plugins install ./my-bundle
-## What a bundle is
+ # Archive
+ openclaw plugins install ./my-bundle.tgz
-A bundle is a **content/metadata pack**, not a native in-process OpenClaw
-plugin.
+ # Claude marketplace
+ openclaw plugins marketplace list
+ openclaw plugins install @
+ ```
-Today, OpenClaw does **not** execute bundle runtime code in-process. Instead,
-it detects known bundle files, reads the metadata, and maps supported bundle
-content into native OpenClaw surfaces such as skills, hook packs, MCP config,
-and embedded Pi settings.
+
-That is the main trust boundary:
+
+ ```bash
+ openclaw plugins list
+ openclaw plugins inspect
+ ```
-- native OpenClaw plugin: runtime module executes in-process
-- bundle: metadata/content pack, with selective feature mapping
+ Bundles show as `Format: bundle` with a subtype of `codex`, `claude`, or `cursor`.
-## Shared bundle model
+
-Codex, Claude, and Cursor bundles are similar enough that OpenClaw treats them
-as one normalized model.
+
+ ```bash
+ openclaw gateway restart
+ ```
-Shared idea:
+ Mapped features (skills, hooks, MCP tools) are available in the next session.
-- a small manifest file, or a default directory layout
-- one or more content roots such as `skills/` or `commands/`
-- optional tool/runtime metadata such as MCP, hooks, agents, or LSP
-- install as a directory or archive, then enable in the normal plugin list
+
+
-Common OpenClaw behavior:
+## What OpenClaw maps from bundles
-- detect the bundle subtype
-- normalize it into one internal bundle record
-- map supported parts into native OpenClaw features
-- report unsupported parts as detected-but-not-wired capabilities
-
-In practice, most users do not need to think about the vendor-specific format
-first. The more useful question is: which bundle surfaces does OpenClaw map
-today?
-
-## Detection order
-
-OpenClaw prefers native OpenClaw plugin/package layouts before bundle handling.
-
-Practical effect:
-
-- `openclaw.plugin.json` wins over bundle detection
-- package installs with valid `package.json` + `openclaw.extensions` use the
- native install path
-- if a directory contains both native and bundle metadata, OpenClaw treats it
- as native first
-
-That avoids partially installing a dual-format package as a bundle and then
-loading it later as a native plugin.
-
-## What works today
-
-OpenClaw normalizes bundle metadata into one internal bundle record, then maps
-supported surfaces into existing native behavior.
+Not every bundle feature runs in OpenClaw today. Here is what works and what
+is detected but not yet wired.
### Supported now
-#### Skill content
-
-- bundle skill roots load as normal OpenClaw skill roots
-- Claude `commands` roots are treated as additional skill roots
-- Cursor `.cursor/commands` roots are treated as additional skill roots
-
-This means Claude markdown command files work through the normal OpenClaw skill
-loader. Cursor command markdown works through the same path.
-
-#### Hook packs
-
-- bundle hook roots work **only** when they use the normal OpenClaw hook-pack
- layout. Today this is primarily the Codex-compatible case:
- - `HOOK.md`
- - `handler.ts` or `handler.js`
-
-#### MCP for Pi
-
-- enabled bundles can contribute MCP server config
-- OpenClaw merges bundle MCP config into the effective embedded Pi settings as
- `mcpServers`
-- OpenClaw also exposes supported bundle MCP tools during embedded Pi agent
- turns by launching supported stdio MCP servers as subprocesses
-- project-local Pi settings still apply after bundle defaults, so workspace
- settings can override bundle MCP entries when needed
-
-#### Embedded Pi settings
-
-- Claude `settings.json` is imported as default embedded Pi settings when the
- bundle is enabled
-- OpenClaw sanitizes shell override keys before applying them
-
-Sanitized keys:
-
-- `shellPath`
-- `shellCommandPrefix`
+| Feature | How it maps | Applies to |
+| ------------- | ---------------------------------------------------------------------------------------------------- | -------------- |
+| Skill content | Bundle skill roots load as normal OpenClaw skills | All formats |
+| Commands | `commands/` and `.cursor/commands/` treated as skill roots | Claude, Cursor |
+| Hook packs | OpenClaw-style `HOOK.md` + `handler.ts` layouts | Codex |
+| MCP tools | Bundle MCP config merged into embedded Pi settings; supported stdio servers launched as subprocesses | All formats |
+| Settings | Claude `settings.json` imported as embedded Pi defaults | Claude |
### Detected but not executed
-These surfaces are detected, shown in bundle capabilities, and may appear in
-diagnostics/info output, but OpenClaw does not run them yet:
+These are recognized and shown in diagnostics, but OpenClaw does not run them:
-- Claude `agents`
-- Claude `hooks.json` automation
-- Claude `lspServers`
-- Claude `outputStyles`
-- Cursor `.cursor/agents`
-- Cursor `.cursor/hooks.json`
-- Cursor `.cursor/rules`
+- Claude `agents`, `hooks.json` automation, `lspServers`, `outputStyles`
+- Cursor `.cursor/agents`, `.cursor/hooks.json`, `.cursor/rules`
- Codex inline/app metadata beyond capability reporting
-## Capability reporting
+## Bundle formats
-`openclaw plugins inspect ` shows bundle capabilities from the normalized
-bundle record.
+
+
+ Markers: `.codex-plugin/plugin.json`
-Supported capabilities are loaded quietly. Unsupported capabilities produce a
-warning such as:
+ Optional content: `skills/`, `hooks/`, `.mcp.json`, `.app.json`
-```text
-bundle capability detected but not wired into OpenClaw yet: agents
-```
+ Codex bundles fit OpenClaw best when they use skill roots and OpenClaw-style
+ hook-pack directories (`HOOK.md` + `handler.ts`).
-Current exceptions:
+
-- Claude `commands` is considered supported because it maps to skills
-- Claude `settings` is considered supported because it maps to embedded Pi settings
-- Cursor `commands` is considered supported because it maps to skills
-- bundle MCP is considered supported because it maps into embedded Pi settings
- and exposes supported stdio tools to embedded Pi
-- Codex `hooks` is considered supported only for OpenClaw hook-pack layouts
+
+ Two detection modes:
-## Format differences
+ - **Manifest-based:** `.claude-plugin/plugin.json`
+ - **Manifestless:** default Claude layout (`skills/`, `commands/`, `agents/`, `hooks/`, `.mcp.json`, `settings.json`)
-The formats are close, but not byte-for-byte identical. These are the practical
-differences that matter in OpenClaw.
+ Claude-specific behavior:
-### Codex
+ - `commands/` is treated as skill content
+ - `settings.json` is imported into embedded Pi settings (shell override keys are sanitized)
+ - `.mcp.json` exposes supported stdio tools to embedded Pi
+ - `hooks/hooks.json` is detected but not executed
+ - Custom component paths in the manifest are additive (they extend defaults, not replace them)
-Typical markers:
+
-- `.codex-plugin/plugin.json`
-- optional `skills/`
-- optional `hooks/`
-- optional `.mcp.json`
-- optional `.app.json`
+
+ Markers: `.cursor-plugin/plugin.json`
-Codex bundles fit OpenClaw best when they use skill roots and OpenClaw-style
-hook-pack directories.
+ Optional content: `skills/`, `.cursor/commands/`, `.cursor/agents/`, `.cursor/rules/`, `.cursor/hooks.json`, `.mcp.json`
-### Claude
+ - `.cursor/commands/` is treated as skill content
+ - `.cursor/rules/`, `.cursor/agents/`, and `.cursor/hooks.json` are detect-only
-OpenClaw supports both:
+
+
-- manifest-based Claude bundles: `.claude-plugin/plugin.json`
-- manifestless Claude bundles that use the default Claude layout
+## Detection precedence
-Default Claude layout markers OpenClaw recognizes:
+OpenClaw checks for native plugin format first:
-- `skills/`
-- `commands/`
-- `agents/`
-- `hooks/hooks.json`
-- `.mcp.json`
-- `.lsp.json`
-- `settings.json`
+1. `openclaw.plugin.json` or valid `package.json` with `openclaw.extensions` β treated as **native plugin**
+2. Bundle markers (`.codex-plugin/`, `.claude-plugin/`, or default Claude/Cursor layout) β treated as **bundle**
-Claude-specific notes:
+If a directory contains both, OpenClaw uses the native path. This prevents
+dual-format packages from being partially installed as bundles.
-- `commands/` is treated like skill content
-- `settings.json` is imported into embedded Pi settings
-- `.mcp.json` and manifest `mcpServers` can expose supported stdio tools to
- embedded Pi
-- `hooks/hooks.json` is detected, but not executed as Claude automation
+## Security
-### Cursor
+Bundles have a narrower trust boundary than native plugins:
-Typical markers:
+- OpenClaw does **not** load arbitrary bundle runtime modules in-process
+- Skills and hook-pack paths must stay inside the plugin root (boundary-checked)
+- Settings files are read with the same boundary checks
+- Supported stdio MCP servers may be launched as subprocesses
-- `.cursor-plugin/plugin.json`
-- optional `skills/`
-- optional `.cursor/commands/`
-- optional `.cursor/agents/`
-- optional `.cursor/rules/`
-- optional `.cursor/hooks.json`
-- optional `.mcp.json`
-
-Cursor-specific notes:
-
-- `.cursor/commands/` is treated like skill content
-- `.cursor/rules/`, `.cursor/agents/`, and `.cursor/hooks.json` are
- detect-only today
-
-## Claude custom paths
-
-Claude bundle manifests can declare custom component paths. OpenClaw treats
-those paths as **additive**, not replacing defaults.
-
-Currently recognized custom path keys:
-
-- `skills`
-- `commands`
-- `agents`
-- `hooks`
-- `mcpServers`
-- `lspServers`
-- `outputStyles`
-
-Examples:
-
-- default `commands/` plus manifest `commands: "extra-commands"` =>
- OpenClaw scans both
-- default `skills/` plus manifest `skills: ["team-skills"]` =>
- OpenClaw scans both
-
-## Security model
-
-Bundle support is intentionally narrower than native plugin support.
-
-Current behavior:
-
-- bundle discovery reads files inside the plugin root with boundary checks
-- skills and hook-pack paths must stay inside the plugin root
-- bundle settings files are read with the same boundary checks
-- supported stdio bundle MCP servers may be launched as subprocesses for
- embedded Pi tool calls
-- OpenClaw does not load arbitrary bundle runtime modules in-process
-
-This makes bundle support safer by default than native plugin modules, but you
-should still treat third-party bundles as trusted content for the features they
-do expose.
-
-## Install examples
-
-```bash
-openclaw plugins install ./my-codex-bundle
-openclaw plugins install ./my-claude-bundle
-openclaw plugins install ./my-cursor-bundle
-openclaw plugins install ./my-bundle.tgz
-openclaw plugins marketplace list
-openclaw plugins install @
-openclaw plugins inspect my-bundle
-```
-
-If the directory is a native OpenClaw plugin/package, the native install path
-still wins.
-
-For Claude marketplace names, OpenClaw reads the local Claude known-marketplace
-registry at `~/.claude/plugins/known_marketplaces.json`. Marketplace entries
-can resolve to bundle-compatible directories/archives or to native plugin
-sources; after resolution, the normal install rules still apply.
+This makes bundles safer by default, but you should still treat third-party
+bundles as trusted content for the features they do expose.
## Troubleshooting
-### Bundle is detected but capabilities do not run
+
+
+ Run `openclaw plugins inspect `. If a capability is listed but marked as
+ not wired, that is a product limit β not a broken install.
+
-Check `openclaw plugins inspect `.
+
+ Make sure the bundle is enabled and the markdown files are inside a detected
+ `commands/` or `skills/` root.
+
-If the capability is listed but OpenClaw says it is not wired yet, that is a
-real product limit, not a broken install.
+
+ Only embedded Pi settings from `settings.json` are supported. OpenClaw does
+ not treat bundle settings as raw config patches.
+
-### Claude command files do not appear
+
+ `hooks/hooks.json` is detect-only. If you need runnable hooks, use the
+ OpenClaw hook-pack layout or ship a native plugin.
+
+
-Make sure the bundle is enabled and the markdown files are inside a detected
-`commands` root or `skills` root.
+## Related
-### Claude settings do not apply
-
-Current support is limited to embedded Pi settings from `settings.json`.
-OpenClaw does not treat bundle settings as raw OpenClaw config patches.
-
-### Claude hooks do not execute
-
-`hooks/hooks.json` is only detected today.
-
-If you need runnable bundle hooks today, use the normal OpenClaw hook-pack
-layout through a supported Codex hook root or ship a native OpenClaw plugin.
+- [Install and Configure Plugins](/tools/plugin)
+- [Building Plugins](/plugins/building-plugins) β create a native plugin
+- [Plugin Manifest](/plugins/manifest) β native manifest schema
diff --git a/docs/plugins/community.md b/docs/plugins/community.md
index 12df6c3eee0..d6cbcd76301 100644
--- a/docs/plugins/community.md
+++ b/docs/plugins/community.md
@@ -1,60 +1,128 @@
---
-summary: "Community plugins: quality bar, hosting requirements, and PR submission path"
+summary: "Community-maintained OpenClaw plugins: browse, install, and submit your own"
read_when:
- - You want to publish a third-party OpenClaw plugin
- - You want to propose a plugin for docs listing
-title: "Community plugins"
+ - You want to find third-party OpenClaw plugins
+ - You want to publish or list your own plugin
+title: "Community Plugins"
---
-# Community plugins
+# Community Plugins
-This page tracks high-quality **community-maintained plugins** for OpenClaw.
+Community plugins are third-party packages that extend OpenClaw with new
+channels, tools, providers, or other capabilities. They are built and maintained
+by the community, published on npm, and installable with a single command.
-We accept PRs that add community plugins here when they meet the quality bar.
-
-## Required for listing
-
-- Plugin package is published on npmjs (installable via `openclaw plugins install `).
-- Source code is hosted on GitHub (public repository).
-- Repository includes setup/use docs and an issue tracker.
-- Plugin has a clear maintenance signal (active maintainer, recent updates, or responsive issue handling).
-
-## How to submit
-
-Open a PR that adds your plugin to this page with:
-
-- Plugin name
-- npm package name
-- GitHub repository URL
-- One-line description
-- Install command
-
-## Review bar
-
-We prefer plugins that are useful, documented, and safe to operate.
-Low-effort wrappers, unclear ownership, or unmaintained packages may be declined.
-
-## Candidate format
-
-Use this format when adding entries:
-
-- **Plugin Name** β short description
- npm: `@scope/package`
- repo: `https://github.com/org/repo`
- install: `openclaw plugins install @scope/package`
+```bash
+openclaw plugins install
+```
## Listed plugins
-- **openclaw-dingtalk** β The OpenClaw DingTalk channel plugin enables the integration of enterprise robots using the Stream mode. It supports text, images and file messages via any DingTalk client.
- npm: `@largezhou/ddingtalk`
- repo: `https://github.com/largezhou/openclaw-dingtalk`
- install: `openclaw plugins install @largezhou/ddingtalk`
-- **QQbot** β Connect OpenClaw to QQ via the QQ Bot API. Supports private chats, group mentions, channel messages, and rich media including voice, images, videos, and files.
- npm: `@sliverp/qqbot`
- repo: `https://github.com/sliverp/qqbot`
- install: `openclaw plugins install @sliverp/qqbot`
+### Codex App Server Bridge
-- **WeChat** β Connect OpenClaw to WeChat personal accounts via WeChatPadPro (iPad protocol). Supports text, image, and file exchange with keyword-triggered conversations.
- npm: `@icesword760/openclaw-wechat`
- repo: `https://github.com/icesword0760/openclaw-wechat`
- install: `openclaw plugins install @icesword760/openclaw-wechat`
+Independent OpenClaw bridge for Codex App Server conversations. Bind a chat to
+a Codex thread, talk to it with plain text, and control it with chat-native
+commands for resume, planning, review, model selection, compaction, and more.
+
+- **npm:** `openclaw-codex-app-server`
+- **repo:** [github.com/pwrdrvr/openclaw-codex-app-server](https://github.com/pwrdrvr/openclaw-codex-app-server)
+
+```bash
+openclaw plugins install openclaw-codex-app-server
+```
+
+### DingTalk
+
+Enterprise robot integration using Stream mode. Supports text, images, and
+file messages via any DingTalk client.
+
+- **npm:** `@largezhou/ddingtalk`
+- **repo:** [github.com/largezhou/openclaw-dingtalk](https://github.com/largezhou/openclaw-dingtalk)
+
+```bash
+openclaw plugins install @largezhou/ddingtalk
+```
+
+### Lossless Claw (LCM)
+
+Lossless Context Management plugin for OpenClaw. DAG-based conversation
+summarization with incremental compaction β preserves full context fidelity
+while reducing token usage.
+
+- **npm:** `@martian-engineering/lossless-claw`
+- **repo:** [github.com/Martian-Engineering/lossless-claw](https://github.com/Martian-Engineering/lossless-claw)
+
+```bash
+openclaw plugins install @martian-engineering/lossless-claw
+```
+
+### Opik
+
+Official plugin that exports agent traces to Opik. Monitor agent behavior,
+cost, tokens, errors, and more.
+
+- **npm:** `@opik/opik-openclaw`
+- **repo:** [github.com/comet-ml/opik-openclaw](https://github.com/comet-ml/opik-openclaw)
+
+```bash
+openclaw plugins install @opik/opik-openclaw
+```
+
+### QQbot
+
+Connect OpenClaw to QQ via the QQ Bot API. Supports private chats, group
+mentions, channel messages, and rich media including voice, images, videos,
+and files.
+
+- **npm:** `@sliverp/qqbot`
+- **repo:** [github.com/sliverp/qqbot](https://github.com/sliverp/qqbot)
+
+```bash
+openclaw plugins install @sliverp/qqbot
+```
+
+## Submit your plugin
+
+We welcome community plugins that are useful, documented, and safe to operate.
+
+
+
+ Your plugin must be installable via `openclaw plugins install \`.
+ See [Building Plugins](/plugins/building-plugins) for the full guide.
+
+
+
+
+ Source code must be in a public repository with setup docs and an issue
+ tracker.
+
+
+
+
+ Add your plugin to this page with:
+
+ - Plugin name
+ - npm package name
+ - GitHub repository URL
+ - One-line description
+ - Install command
+
+
+
+
+## Quality bar
+
+| Requirement | Why |
+| -------------------- | --------------------------------------------- |
+| Published on npm | Users need `openclaw plugins install` to work |
+| Public GitHub repo | Source review, issue tracking, transparency |
+| Setup and usage docs | Users need to know how to configure it |
+| Active maintenance | Recent updates or responsive issue handling |
+
+Low-effort wrappers, unclear ownership, or unmaintained packages may be declined.
+
+## Related
+
+- [Install and Configure Plugins](/tools/plugin) β how to install any plugin
+- [Building Plugins](/plugins/building-plugins) β create your own
+- [Plugin Manifest](/plugins/manifest) β manifest schema
diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md
index c02cc53cd3d..52501f5b9c7 100644
--- a/docs/plugins/sdk-migration.md
+++ b/docs/plugins/sdk-migration.md
@@ -1,144 +1,168 @@
---
title: "Plugin SDK Migration"
-summary: "Migrate from openclaw/plugin-sdk/compat to focused subpath imports"
+sidebarTitle: "SDK Migration"
+summary: "Migrate from the legacy backwards-compatibility layer to the modern plugin SDK"
read_when:
- You see the OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED warning
- - You are updating a plugin from the monolithic plugin-sdk import to scoped subpaths
+ - You see the OPENCLAW_EXTENSION_API_DEPRECATED warning
+ - You are updating a plugin to the modern plugin architecture
- You maintain an external OpenClaw plugin
---
# Plugin SDK Migration
-OpenClaw is migrating from a single monolithic `openclaw/plugin-sdk/compat` barrel
-to **focused subpath imports** (`openclaw/plugin-sdk/\`). This page explains
-what changed, why, and how to migrate.
+OpenClaw has moved from a broad backwards-compatibility layer to a modern plugin
+architecture with focused, documented imports. If your plugin was built before
+the new architecture, this guide helps you migrate.
-## Why this change
+## What is changing
-The monolithic compat barrel re-exported everything from a single entry point.
-This caused:
+The old plugin system provided two wide-open surfaces that let plugins import
+anything they needed from a single entry point:
-- **Slow startup**: importing one helper pulled in dozens of unrelated modules.
-- **Circular dependency risk**: broad re-exports made it easy to create import cycles.
-- **Unclear API surface**: no way to tell which exports were stable vs internal.
+- **`openclaw/plugin-sdk/compat`** β a single import that re-exported dozens of
+ helpers. It was introduced to keep older hook-based plugins working while the
+ new plugin architecture was being built.
+- **`openclaw/extension-api`** β a bridge that gave plugins direct access to
+ host-side helpers like the embedded agent runner.
-Focused subpaths fix all three: each subpath is a small, self-contained module
-with a clear purpose.
+Both surfaces are now **deprecated**. They still work at runtime, but new
+plugins must not use them, and existing plugins should migrate before the next
+major release removes them.
-## What triggers the warning
+
+ The backwards-compatibility layer will be removed in a future major release.
+ Plugins that still import from these surfaces will break when that happens.
+
-If your plugin imports from the compat barrel, you will see:
+## Why this changed
-```
-[OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED] Warning: openclaw/plugin-sdk/compat is
-deprecated for new plugins. Migrate to focused openclaw/plugin-sdk/\ imports.
-```
+The old approach caused problems:
-The compat barrel still works at runtime. This is a deprecation warning, not an
-error. But new plugins **must not** use it, and existing plugins should migrate
-before compat is removed.
+- **Slow startup** β importing one helper loaded dozens of unrelated modules
+- **Circular dependencies** β broad re-exports made it easy to create import cycles
+- **Unclear API surface** β no way to tell which exports were stable vs internal
+
+The modern plugin SDK fixes this: each import path (`openclaw/plugin-sdk/\`)
+is a small, self-contained module with a clear purpose and documented contract.
## How to migrate
-### Step 1: Find compat imports
+
+
+ Search your plugin for imports from either deprecated surface:
-Search your extension for imports from the compat path:
+ ```bash
+ grep -r "plugin-sdk/compat" my-plugin/
+ grep -r "openclaw/extension-api" my-plugin/
+ ```
-```bash
-grep -r "plugin-sdk/compat" extensions/my-plugin/
-```
+
-### Step 2: Replace with focused subpaths
+
+ Each export from the old surface maps to a specific modern import path:
-Each export from compat maps to a specific subpath. Replace the import source:
+ ```typescript
+ // Before (deprecated backwards-compatibility layer)
+ import {
+ createChannelReplyPipeline,
+ createPluginRuntimeStore,
+ resolveControlCommandGate,
+ } from "openclaw/plugin-sdk/compat";
-```typescript
-// Before (compat barrel)
-import {
- createChannelReplyPipeline,
- createPluginRuntimeStore,
- resolveControlCommandGate,
-} from "openclaw/plugin-sdk/compat";
+ // After (modern focused imports)
+ import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
+ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
+ import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
+ ```
-// After (focused subpaths)
-import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
-import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
-import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
-```
+ For host-side helpers, use the injected plugin runtime instead of importing
+ directly:
-### Step 3: Verify
+ ```typescript
+ // Before (deprecated extension-api bridge)
+ import { runEmbeddedPiAgent } from "openclaw/extension-api";
+ const result = await runEmbeddedPiAgent({ sessionId, prompt });
-Run the build and tests:
+ // After (injected runtime)
+ const result = await api.runtime.agent.runEmbeddedPiAgent({ sessionId, prompt });
+ ```
-```bash
-pnpm build
-pnpm test -- extensions/my-plugin/
-```
+ The same pattern applies to other legacy bridge helpers:
-## Subpath reference
+ | Old import | Modern equivalent |
+ | --- | --- |
+ | `resolveAgentDir` | `api.runtime.agent.resolveAgentDir` |
+ | `resolveAgentWorkspaceDir` | `api.runtime.agent.resolveAgentWorkspaceDir` |
+ | `resolveAgentIdentity` | `api.runtime.agent.resolveAgentIdentity` |
+ | `resolveThinkingDefault` | `api.runtime.agent.resolveThinkingDefault` |
+ | `resolveAgentTimeoutMs` | `api.runtime.agent.resolveAgentTimeoutMs` |
+ | `ensureAgentWorkspace` | `api.runtime.agent.ensureAgentWorkspace` |
+ | session store helpers | `api.runtime.agent.session.*` |
-| Subpath | Purpose | Key exports |
-| ----------------------------------- | ------------------------------------ | ---------------------------------------------------------------------- |
-| `plugin-sdk/core` | Plugin entry definitions, base types | `defineChannelPluginEntry`, `definePluginEntry` |
-| `plugin-sdk/channel-setup` | Setup wizard adapters | `createOptionalChannelSetupSurface` |
-| `plugin-sdk/channel-pairing` | DM pairing primitives | `createChannelPairingController` |
-| `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | `createChannelReplyPipeline` |
-| `plugin-sdk/channel-config-helpers` | Config adapter factories | `createHybridChannelConfigAdapter`, `createScopedChannelConfigAdapter` |
-| `plugin-sdk/channel-config-schema` | Config schema builders | Channel config schema types |
-| `plugin-sdk/channel-policy` | Group/DM policy resolution | `resolveChannelGroupRequireMention` |
-| `plugin-sdk/channel-lifecycle` | Account status tracking | `createAccountStatusSink` |
-| `plugin-sdk/channel-runtime` | Runtime wiring helpers | Channel runtime utilities |
-| `plugin-sdk/channel-send-result` | Send result types | Reply result types |
-| `plugin-sdk/runtime-store` | Persistent plugin storage | `createPluginRuntimeStore` |
-| `plugin-sdk/allow-from` | Allowlist formatting | `formatAllowFromLowercase`, `formatNormalizedAllowFromEntries` |
-| `plugin-sdk/allowlist-resolution` | Allowlist input mapping | `mapAllowlistResolutionInputs` |
-| `plugin-sdk/command-auth` | Command gating | `resolveControlCommandGate` |
-| `plugin-sdk/secret-input` | Secret input parsing | Secret input helpers |
-| `plugin-sdk/webhook-ingress` | Webhook request helpers | Webhook target utilities |
-| `plugin-sdk/reply-payload` | Message reply types | Reply payload types |
-| `plugin-sdk/provider-onboard` | Provider onboarding patches | Onboarding config helpers |
-| `plugin-sdk/keyed-async-queue` | Ordered async queue | `KeyedAsyncQueue` |
-| `plugin-sdk/testing` | Test utilities | Test helpers and mocks |
+
-Use the narrowest subpath that has what you need. If you cannot find an export,
+
+ ```bash
+ pnpm build
+ pnpm test -- my-plugin/
+ ```
+
+
+
+## Import path reference
+
+
+ | Import path | Purpose | Key exports |
+ | --- | --- | --- |
+ | `plugin-sdk/core` | Plugin entry definitions, base types | `defineChannelPluginEntry`, `definePluginEntry` |
+ | `plugin-sdk/channel-setup` | Setup wizard adapters | `createOptionalChannelSetupSurface` |
+ | `plugin-sdk/channel-pairing` | DM pairing primitives | `createChannelPairingController` |
+ | `plugin-sdk/channel-reply-pipeline` | Reply prefix + typing wiring | `createChannelReplyPipeline` |
+ | `plugin-sdk/channel-config-helpers` | Config adapter factories | `createHybridChannelConfigAdapter` |
+ | `plugin-sdk/channel-config-schema` | Config schema builders | Channel config schema types |
+ | `plugin-sdk/channel-policy` | Group/DM policy resolution | `resolveChannelGroupRequireMention` |
+ | `plugin-sdk/channel-lifecycle` | Account status tracking | `createAccountStatusSink` |
+ | `plugin-sdk/channel-runtime` | Runtime wiring helpers | Channel runtime utilities |
+ | `plugin-sdk/channel-send-result` | Send result types | Reply result types |
+ | `plugin-sdk/runtime-store` | Persistent plugin storage | `createPluginRuntimeStore` |
+ | `plugin-sdk/allow-from` | Allowlist formatting | `formatAllowFromLowercase` |
+ | `plugin-sdk/allowlist-resolution` | Allowlist input mapping | `mapAllowlistResolutionInputs` |
+ | `plugin-sdk/command-auth` | Command gating | `resolveControlCommandGate` |
+ | `plugin-sdk/secret-input` | Secret input parsing | Secret input helpers |
+ | `plugin-sdk/webhook-ingress` | Webhook request helpers | Webhook target utilities |
+ | `plugin-sdk/reply-payload` | Message reply types | Reply payload types |
+ | `plugin-sdk/provider-onboard` | Provider onboarding patches | Onboarding config helpers |
+ | `plugin-sdk/keyed-async-queue` | Ordered async queue | `KeyedAsyncQueue` |
+ | `plugin-sdk/testing` | Test utilities | Test helpers and mocks |
+
+
+Use the narrowest import that matches the job. If you cannot find an export,
check the source at `src/plugin-sdk/` or ask in Discord.
-## Compat barrel removal timeline
+## Removal timeline
-- **Now**: compat barrel emits a deprecation warning at runtime.
-- **Next major release**: compat barrel will be removed. Plugins still using it will
- fail to import.
+| When | What happens |
+| ---------------------- | ----------------------------------------------------------------------- |
+| **Now** | Deprecated surfaces emit runtime warnings |
+| **Next major release** | Deprecated surfaces will be removed; plugins still using them will fail |
-Bundled plugins (under `extensions/`) have already been migrated. External plugins
-should migrate before the next major release.
+All core plugins have already been migrated. External plugins should migrate
+before the next major release.
-## Suppressing the warning temporarily
+## Suppressing the warnings temporarily
-If you need to suppress the warning while migrating:
+Set these environment variables while you work on migrating:
```bash
OPENCLAW_SUPPRESS_PLUGIN_SDK_COMPAT_WARNING=1 openclaw gateway run
+OPENCLAW_SUPPRESS_EXTENSION_API_WARNING=1 openclaw gateway run
```
This is a temporary escape hatch, not a permanent solution.
-## Internal barrel pattern
-
-Within your extension, use local barrel files (`api.ts`, `runtime-api.ts`) for
-internal code sharing instead of importing through the plugin SDK:
-
-```typescript
-// extensions/my-plugin/api.ts β public contract for this extension
-export { MyConfig } from "./src/config.js";
-export { MyRuntime } from "./src/runtime.js";
-```
-
-Never import your own extension back through `openclaw/plugin-sdk/\`
-from production files. That path is for external consumers only. See
-[Building Extensions](/plugins/building-extensions#step-4-use-local-barrels-for-internal-imports).
-
## Related
-- [Building Extensions](/plugins/building-extensions)
-- [Plugin Architecture](/plugins/architecture)
+- [Building Plugins](/plugins/building-plugins)
+- [Plugin Internals](/plugins/architecture)
- [Plugin Manifest](/plugins/manifest)
diff --git a/docs/providers/xai.md b/docs/providers/xai.md
index ec491735e50..271eae0bc57 100644
--- a/docs/providers/xai.md
+++ b/docs/providers/xai.md
@@ -34,8 +34,7 @@ OpenClaw now includes these xAI model families out of the box:
- `grok-4`, `grok-4-0709`
- `grok-4-fast-reasoning`, `grok-4-fast-non-reasoning`
- `grok-4-1-fast-reasoning`, `grok-4-1-fast-non-reasoning`
-- `grok-4.20-experimental-beta-0304-reasoning`
-- `grok-4.20-experimental-beta-0304-non-reasoning`
+- `grok-4.20-reasoning`, `grok-4.20-non-reasoning`
- `grok-code-fast-1`
The plugin also forward-resolves newer `grok-4*` and `grok-code-fast*` ids when
diff --git a/docs/start/hubs.md b/docs/start/hubs.md
index 7e530f769b5..754957a96d6 100644
--- a/docs/start/hubs.md
+++ b/docs/start/hubs.md
@@ -164,9 +164,9 @@ Use these hubs to discover every page, including deep dives and reference docs t
## Extensions + plugins
- [Plugins overview](/tools/plugin)
-- [Building extensions](/plugins/building-extensions)
+- [Building plugins](/plugins/building-plugins)
- [Plugin manifest](/plugins/manifest)
-- [Agent tools](/plugins/agent-tools)
+- [Agent tools](/plugins/building-plugins#registering-agent-tools)
- [Plugin bundles](/plugins/bundles)
- [Community plugins](/plugins/community)
- [Capability cookbook](/tools/capability-cookbook)
diff --git a/docs/tools/agent-send.md b/docs/tools/agent-send.md
index e301feeea12..153a1e9b3c6 100644
--- a/docs/tools/agent-send.md
+++ b/docs/tools/agent-send.md
@@ -1,53 +1,100 @@
---
-summary: "Direct `openclaw agent` CLI runs (with optional delivery)"
+summary: "Run agent turns from the CLI and optionally deliver replies to channels"
read_when:
- - Adding or modifying the agent CLI entrypoint
+ - You want to trigger agent runs from scripts or the command line
+ - You need to deliver agent replies to a chat channel programmatically
title: "Agent Send"
---
-# `openclaw agent` (direct agent runs)
+# Agent Send
-`openclaw agent` runs a single agent turn without needing an inbound chat message.
-By default it goes **through the Gateway**; add `--local` to force the embedded
-runtime on the current machine.
+`openclaw agent` runs a single agent turn from the command line without needing
+an inbound chat message. Use it for scripted workflows, testing, and
+programmatic delivery.
+
+## Quick start
+
+
+
+ ```bash
+ openclaw agent --message "What is the weather today?"
+ ```
+
+ This sends the message through the Gateway and prints the reply.
+
+
+
+
+ ```bash
+ # Target a specific agent
+ openclaw agent --agent ops --message "Summarize logs"
+
+ # Target a phone number (derives session key)
+ openclaw agent --to +15555550123 --message "Status update"
+
+ # Reuse an existing session
+ openclaw agent --session-id abc123 --message "Continue the task"
+ ```
+
+
+
+
+ ```bash
+ # Deliver to WhatsApp (default channel)
+ openclaw agent --to +15555550123 --message "Report ready" --deliver
+
+ # Deliver to Slack
+ openclaw agent --agent ops --message "Generate report" \
+ --deliver --reply-channel slack --reply-to "#reports"
+ ```
+
+
+
+
+## Flags
+
+| Flag | Description |
+| ----------------------------- | ----------------------------------------------------------- |
+| `--message \` | Message to send (required) |
+| `--to \` | Derive session key from a target (phone, chat id) |
+| `--agent \` | Target a configured agent (uses its `main` session) |
+| `--session-id \` | Reuse an existing session by id |
+| `--local` | Force local embedded runtime (skip Gateway) |
+| `--deliver` | Send the reply to a chat channel |
+| `--channel \` | Delivery channel (whatsapp, telegram, discord, slack, etc.) |
+| `--reply-to \` | Delivery target override |
+| `--reply-channel \` | Delivery channel override |
+| `--reply-account \` | Delivery account id override |
+| `--thinking \` | Set thinking level (off, minimal, low, medium, high, xhigh) |
+| `--verbose \` | Set verbose level |
+| `--timeout \` | Override agent timeout |
+| `--json` | Output structured JSON |
## Behavior
-- Required: `--message `
-- Session selection:
- - `--to ` derives the session key (group/channel targets preserve isolation; direct chats collapse to `main`), **or**
- - `--session-id ` reuses an existing session by id, **or**
- - `--agent ` targets a configured agent directly (uses that agent's `main` session key)
-- Runs the same embedded agent runtime as normal inbound replies.
-- Thinking/verbose flags persist into the session store.
-- Output:
- - default: prints reply text (plus `MEDIA:` lines)
- - `--json`: prints structured payload + metadata
-- Optional delivery back to a channel with `--deliver` + `--channel` (target formats match `openclaw message --target`).
-- Use `--reply-channel`/`--reply-to`/`--reply-account` to override delivery without changing the session.
-
-If the Gateway is unreachable, the CLI **falls back** to the embedded local run.
+- By default, the CLI goes **through the Gateway**. Add `--local` to force the
+ embedded runtime on the current machine.
+- If the Gateway is unreachable, the CLI **falls back** to the local embedded run.
+- Session selection: `--to` derives the session key (group/channel targets
+ preserve isolation; direct chats collapse to `main`).
+- Thinking and verbose flags persist into the session store.
+- Output: plain text by default, or `--json` for structured payload + metadata.
## Examples
```bash
-openclaw agent --to +15555550123 --message "status update"
-openclaw agent --agent ops --message "Summarize logs"
-openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium
+# Simple turn with JSON output
openclaw agent --to +15555550123 --message "Trace logs" --verbose on --json
-openclaw agent --to +15555550123 --message "Summon reply" --deliver
-openclaw agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"
+
+# Turn with thinking level
+openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium
+
+# Deliver to a different channel than the session
+openclaw agent --agent ops --message "Alert" --deliver --reply-channel telegram --reply-to "@admin"
```
-## Flags
+## Related
-- `--local`: run locally (requires model provider API keys in your shell)
-- `--deliver`: send the reply to the chosen channel
-- `--channel`: delivery channel (`whatsapp|telegram|discord|googlechat|slack|signal|imessage`, default: `whatsapp`)
-- `--reply-to`: delivery target override
-- `--reply-channel`: delivery channel override
-- `--reply-account`: delivery account id override
-- `--thinking `: persist thinking level (GPT-5.2 + Codex models only)
-- `--verbose `: persist verbose level
-- `--timeout `: override agent timeout
-- `--json`: output structured JSON
+- [Agent CLI reference](/cli/agent)
+- [Sub-agents](/tools/subagents) β background sub-agent spawning
+- [Sessions](/concepts/session) β how session keys work
diff --git a/docs/tools/capability-cookbook.md b/docs/tools/capability-cookbook.md
index f439c362e89..7a5ab50611a 100644
--- a/docs/tools/capability-cookbook.md
+++ b/docs/tools/capability-cookbook.md
@@ -1,13 +1,20 @@
---
-summary: "Cookbook for adding a new shared capability to OpenClaw"
+summary: "Contributor guide for adding a new shared capability to the OpenClaw plugin system"
read_when:
- Adding a new core capability and plugin registration surface
- Deciding whether code belongs in core, a vendor plugin, or a feature plugin
- Wiring a new runtime helper for channels or tools
-title: "Capability Cookbook"
+title: "Adding Capabilities (Contributor Guide)"
+sidebarTitle: "Adding Capabilities"
---
-# Capability Cookbook
+# Adding Capabilities
+
+
+ This is a **contributor guide** for OpenClaw core developers. If you are
+ building an external plugin, see [Building Plugins](/plugins/building-plugins)
+ instead.
+
Use this when OpenClaw needs a new domain such as image generation, video
generation, or some future vendor-backed feature area.
diff --git a/docs/tools/creating-skills.md b/docs/tools/creating-skills.md
index 964165ad0a2..69024038efc 100644
--- a/docs/tools/creating-skills.md
+++ b/docs/tools/creating-skills.md
@@ -6,53 +6,112 @@ read_when:
- You need a quick starter workflow for SKILL.md-based skills
---
-# Creating Custom Skills π
+# Creating Skills
-OpenClaw is designed to be easily extensible. "Skills" are the primary way to add new capabilities to your assistant.
+Skills teach the agent how and when to use tools. Each skill is a directory
+containing a `SKILL.md` file with YAML frontmatter and markdown instructions.
-## What is a Skill?
+For how skills are loaded and prioritized, see [Skills](/tools/skills).
-A skill is a directory containing a `SKILL.md` file (which provides instructions and tool definitions to the LLM) and optionally some scripts or resources.
+## Create your first skill
-## Step-by-Step: Your First Skill
+
+
+ Skills live in your workspace. Create a new folder:
-### 1. Create the Directory
+ ```bash
+ mkdir -p ~/.openclaw/workspace/skills/hello-world
+ ```
-Skills live in your workspace, usually `~/.openclaw/workspace/skills/`. Create a new folder for your skill:
+
-```bash
-mkdir -p ~/.openclaw/workspace/skills/hello-world
-```
+
+ Create `SKILL.md` inside that directory. The frontmatter defines metadata,
+ and the markdown body contains instructions for the agent.
-### 2. Define the `SKILL.md`
+ ```markdown
+ ---
+ name: hello_world
+ description: A simple skill that says hello.
+ ---
-Create a `SKILL.md` file in that directory. This file uses YAML frontmatter for metadata and Markdown for instructions.
+ # Hello World Skill
-```markdown
----
-name: hello_world
-description: A simple skill that says hello.
----
+ When the user asks for a greeting, use the `echo` tool to say
+ "Hello from your custom skill!".
+ ```
-# Hello World Skill
+
-When the user asks for a greeting, use the `echo` tool to say "Hello from your custom skill!".
-```
+
+ You can define custom tool schemas in the frontmatter or instruct the agent
+ to use existing system tools (like `exec` or `browser`). Skills can also
+ ship inside plugins alongside the tools they document.
-### 3. Add Tools (Optional)
+
-You can define custom tools in the frontmatter or instruct the agent to use existing system tools (like `bash` or `browser`).
+
+ Start a new session so OpenClaw picks up the skill:
-### 4. Refresh OpenClaw
+ ```bash
+ # From chat
+ /new
-Ask your agent to "refresh skills" or restart the gateway. OpenClaw will discover the new directory and index the `SKILL.md`.
+ # Or restart the gateway
+ openclaw gateway restart
+ ```
-## Best Practices
+ Verify the skill loaded:
-- **Be Concise**: Instruct the model on _what_ to do, not how to be an AI.
-- **Safety First**: If your skill uses `bash`, ensure the prompts don't allow arbitrary command injection from untrusted user input.
-- **Test Locally**: Use `openclaw agent --message "use my new skill"` to test.
+ ```bash
+ openclaw skills list
+ ```
-## Shared Skills
+
-You can also browse and contribute skills to [ClawHub](https://clawhub.com).
+
+ Send a message that should trigger the skill:
+
+ ```bash
+ openclaw agent --message "give me a greeting"
+ ```
+
+ Or just chat with the agent and ask for a greeting.
+
+
+
+
+## Skill metadata reference
+
+The YAML frontmatter supports these fields:
+
+| Field | Required | Description |
+| ----------------------------------- | -------- | ------------------------------------------- |
+| `name` | Yes | Unique identifier (snake_case) |
+| `description` | Yes | One-line description shown to the agent |
+| `metadata.openclaw.os` | No | OS filter (`["darwin"]`, `["linux"]`, etc.) |
+| `metadata.openclaw.requires.bins` | No | Required binaries on PATH |
+| `metadata.openclaw.requires.config` | No | Required config keys |
+
+## Best practices
+
+- **Be concise** β instruct the model on _what_ to do, not how to be an AI
+- **Safety first** β if your skill uses `exec`, ensure prompts don't allow arbitrary command injection from untrusted input
+- **Test locally** β use `openclaw agent --message "..."` to test before sharing
+- **Use ClawHub** β browse and contribute skills at [ClawHub](https://clawhub.com)
+
+## Where skills live
+
+| Location | Precedence | Scope |
+| ------------------------------- | ---------- | --------------------- |
+| `\/skills/` | Highest | Per-agent |
+| `~/.openclaw/skills/` | Medium | Shared (all agents) |
+| Bundled (shipped with OpenClaw) | Lowest | Global |
+| `skills.load.extraDirs` | Lowest | Custom shared folders |
+
+## Related
+
+- [Skills reference](/tools/skills) β loading, precedence, and gating rules
+- [Skills config](/tools/skills-config) β `skills.*` config schema
+- [ClawHub](/tools/clawhub) β public skill registry
+- [Building Plugins](/plugins/building-plugins) β plugins can ship skills
diff --git a/docs/tools/elevated.md b/docs/tools/elevated.md
index c10b955ce2d..96a574f6fc9 100644
--- a/docs/tools/elevated.md
+++ b/docs/tools/elevated.md
@@ -1,63 +1,114 @@
---
-summary: "Elevated exec mode and /elevated directives"
+summary: "Elevated exec mode: run commands on the gateway host from a sandboxed agent"
read_when:
- Adjusting elevated mode defaults, allowlists, or slash command behavior
+ - Understanding how sandboxed agents can access the host
title: "Elevated Mode"
---
-# Elevated Mode (/elevated directives)
+# Elevated Mode
-## What it does
+When an agent runs inside a sandbox, its `exec` commands are confined to the
+sandbox environment. **Elevated mode** lets the agent break out and run commands
+on the gateway host instead, with configurable approval gates.
-- `/elevated on` runs on the gateway host and keeps exec approvals (same as `/elevated ask`).
-- `/elevated full` runs on the gateway host **and** auto-approves exec (skips exec approvals).
-- `/elevated ask` runs on the gateway host but keeps exec approvals (same as `/elevated on`).
-- `on`/`ask` do **not** force `exec.security=full`; configured security/ask policy still applies.
-- Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host).
-- Directive forms: `/elevated on|off|ask|full`, `/elev on|off|ask|full`.
-- Only `on|off|ask|full` are accepted; anything else returns a hint and does not change state.
+
+ Elevated mode only changes behavior when the agent is **sandboxed**. For
+ unsandboxed agents, exec already runs on the host.
+
-## What it controls (and what it does not)
+## Directives
-- **Availability gates**: `tools.elevated` is the global baseline. `agents.list[].tools.elevated` can further restrict elevated per agent (both must allow).
-- **Per-session state**: `/elevated on|off|ask|full` sets the elevated level for the current session key.
-- **Inline directive**: `/elevated on|ask|full` inside a message applies to that message only.
-- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
-- **Host execution**: elevated forces `exec` onto the gateway host; `full` also sets `security=full`.
-- **Approvals**: `full` skips exec approvals; `on`/`ask` honor them when allowlist/ask rules require.
-- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status.
-- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
-- **Separate from `/exec`**: `/exec` adjusts per-session defaults for authorized senders and does not require elevated.
+Control elevated mode per-session with slash commands:
+
+| Directive | What it does |
+| ---------------- | --------------------------------------------------- |
+| `/elevated on` | Run on the gateway host, keep exec approvals |
+| `/elevated ask` | Same as `on` (alias) |
+| `/elevated full` | Run on the gateway host **and** skip exec approvals |
+| `/elevated off` | Return to sandbox-confined execution |
+
+Also available as `/elev on|off|ask|full`.
+
+Send `/elevated` with no argument to see the current level.
+
+## How it works
+
+
+
+ Elevated must be enabled in config and the sender must be on the allowlist:
+
+ ```json5
+ {
+ tools: {
+ elevated: {
+ enabled: true,
+ allowFrom: {
+ discord: ["user-id-123"],
+ whatsapp: ["+15555550123"],
+ },
+ },
+ },
+ }
+ ```
+
+
+
+
+ Send a directive-only message to set the session default:
+
+ ```
+ /elevated full
+ ```
+
+ Or use it inline (applies to that message only):
+
+ ```
+ /elevated on run the deployment script
+ ```
+
+
+
+
+ With elevated active, `exec` calls route to the gateway host instead of the
+ sandbox. In `full` mode, exec approvals are skipped. In `on`/`ask` mode,
+ configured approval rules still apply.
+
+
## Resolution order
-1. Inline directive on the message (applies only to that message).
-2. Session override (set by sending a directive-only message).
-3. Global default (`agents.defaults.elevatedDefault` in config).
+1. **Inline directive** on the message (applies only to that message)
+2. **Session override** (set by sending a directive-only message)
+3. **Global default** (`agents.defaults.elevatedDefault` in config)
-## Setting a session default
+## Availability and allowlists
-- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated full`.
-- Confirmation reply is sent (`Elevated mode set to full...` / `Elevated mode disabled.`).
-- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies with an actionable error and does not change session state.
-- Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level.
+- **Global gate**: `tools.elevated.enabled` (must be `true`)
+- **Sender allowlist**: `tools.elevated.allowFrom` with per-channel lists
+- **Per-agent gate**: `agents.list[].tools.elevated.enabled` (can only further restrict)
+- **Per-agent allowlist**: `agents.list[].tools.elevated.allowFrom` (sender must match both global + per-agent)
+- **Discord fallback**: if `tools.elevated.allowFrom.discord` is omitted, `channels.discord.allowFrom` is used as fallback
+- **All gates must pass**; otherwise elevated is treated as unavailable
-## Availability + allowlists
+Allowlist entry formats:
-- Feature gate: `tools.elevated.enabled` (default can be off via config even if the code supports it).
-- Sender allowlist: `tools.elevated.allowFrom` with per-provider allowlists (e.g. `discord`, `whatsapp`).
-- Unprefixed allowlist entries match sender-scoped identity values only (`SenderId`, `SenderE164`, `From`); recipient routing fields are never used for elevated authorization.
-- Mutable sender metadata requires explicit prefixes:
- - `name:` matches `SenderName`
- - `username:` matches `SenderUsername`
- - `tag:` matches `SenderTag`
- - `id:`, `from:`, `e164:` are available for explicit identity targeting
-- Per-agent gate: `agents.list[].tools.elevated.enabled` (optional; can only further restrict).
-- Per-agent allowlist: `agents.list[].tools.elevated.allowFrom` (optional; when set, the sender must match **both** global + per-agent allowlists).
-- Discord fallback: if `tools.elevated.allowFrom.discord` is omitted, the `channels.discord.allowFrom` list is used as a fallback (legacy: `channels.discord.dm.allowFrom`). Set `tools.elevated.allowFrom.discord` (even `[]`) to override. Per-agent allowlists do **not** use the fallback.
-- All gates must pass; otherwise elevated is treated as unavailable.
+| Prefix | Matches |
+| ----------------------- | ------------------------------- |
+| (none) | Sender ID, E.164, or From field |
+| `name:` | Sender display name |
+| `username:` | Sender username |
+| `tag:` | Sender tag |
+| `id:`, `from:`, `e164:` | Explicit identity targeting |
-## Logging + status
+## What elevated does not control
-- Elevated exec calls are logged at info level.
-- Session status includes elevated mode (e.g. `elevated=ask`, `elevated=full`).
+- **Tool policy**: if `exec` is denied by tool policy, elevated cannot override it
+- **Separate from `/exec`**: the `/exec` directive adjusts per-session exec defaults for authorized senders and does not require elevated mode
+
+## Related
+
+- [Exec tool](/tools/exec) β shell command execution
+- [Exec approvals](/tools/exec-approvals) β approval and allowlist system
+- [Sandboxing](/gateway/sandboxing) β sandbox configuration
+- [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated)
diff --git a/docs/tools/index.md b/docs/tools/index.md
index 075971d6877..77f334e826c 100644
--- a/docs/tools/index.md
+++ b/docs/tools/index.md
@@ -1,96 +1,129 @@
---
-summary: "Agent tool surface for OpenClaw (browser, canvas, nodes, message, cron) replacing legacy `openclaw-*` skills"
+summary: "OpenClaw tools and plugins overview: what the agent can do and how to extend it"
read_when:
- - Adding or modifying agent tools
- - Retiring or changing `openclaw-*` skills
-title: "Tools"
+ - You want to understand what tools OpenClaw provides
+ - You need to configure, allow, or deny tools
+ - You are deciding between built-in tools, skills, and plugins
+title: "Tools and Plugins"
---
-# Tools (OpenClaw)
+# Tools and Plugins
-OpenClaw exposes **first-class agent tools** for browser, canvas, nodes, and cron.
-These replace the old `openclaw-*` skills: the tools are typed, no shelling,
-and the agent should rely on them directly.
+Everything the agent does beyond generating text happens through **tools**.
+Tools are how the agent reads files, runs commands, browses the web, sends
+messages, and interacts with devices.
-## Disabling tools
+## Tools, skills, and plugins
-You can globally allow/deny tools via `tools.allow` / `tools.deny` in `openclaw.json`
-(deny wins). This prevents disallowed tools from being sent to model providers.
+OpenClaw has three layers that work together:
+
+
+
+ A tool is a typed function the agent can invoke (e.g. `exec`, `browser`,
+ `web_search`, `message`). OpenClaw ships a set of **built-in tools** and
+ plugins can register additional ones.
+
+ The agent sees tools as structured function definitions sent to the model API.
+
+
+
+
+ A skill is a markdown file (`SKILL.md`) injected into the system prompt.
+ Skills give the agent context, constraints, and step-by-step guidance for
+ using tools effectively. Skills live in your workspace, in shared folders,
+ or ship inside plugins.
+
+ [Skills reference](/tools/skills) | [Creating skills](/tools/creating-skills)
+
+
+
+
+ A plugin is a package that can register any combination of capabilities:
+ channels, model providers, tools, skills, speech, image generation, and more.
+ Some plugins are **core** (shipped with OpenClaw), others are **external**
+ (published on npm by the community).
+
+ [Install and configure plugins](/tools/plugin) | [Build your own](/plugins/building-plugins)
+
+
+
+
+## Built-in tools
+
+These tools ship with OpenClaw and are available without installing any plugins:
+
+| Tool | What it does | Page |
+| ---------------------------- | -------------------------------------------------------- | --------------------------------- |
+| `exec` / `process` | Run shell commands, manage background processes | [Exec](/tools/exec) |
+| `browser` | Control a Chromium browser (navigate, click, screenshot) | [Browser](/tools/browser) |
+| `web_search` / `web_fetch` | Search the web, fetch page content | [Web](/tools/web) |
+| `read` / `write` / `edit` | File I/O in the workspace | |
+| `apply_patch` | Multi-hunk file patches | [Apply Patch](/tools/apply-patch) |
+| `message` | Send messages across all channels | [Agent Send](/tools/agent-send) |
+| `canvas` | Drive node Canvas (present, eval, snapshot) | |
+| `nodes` | Discover and target paired devices | |
+| `cron` / `gateway` | Manage scheduled jobs, restart gateway | |
+| `image` / `image_generate` | Analyze or generate images | |
+| `sessions_*` / `agents_list` | Session management, sub-agents | [Sub-agents](/tools/subagents) |
+
+### Plugin-provided tools
+
+Plugins can register additional tools. Some examples:
+
+- [Lobster](/tools/lobster) β typed workflow runtime with resumable approvals
+- [LLM Task](/tools/llm-task) β JSON-only LLM step for structured output
+- [Diffs](/tools/diffs) β diff viewer and renderer
+- [OpenProse](/prose) β markdown-first workflow orchestration
+
+## Tool configuration
+
+### Allow and deny lists
+
+Control which tools the agent can call via `tools.allow` / `tools.deny` in
+config. Deny always wins over allow.
```json5
{
- tools: { deny: ["browser"] },
+ tools: {
+ allow: ["group:fs", "browser", "web_search"],
+ deny: ["exec"],
+ },
}
```
-Notes:
+### Tool profiles
-- Matching is case-insensitive.
-- `*` wildcards are supported (`"*"` means all tools).
-- If `tools.allow` only references unknown or unloaded plugin tool names, OpenClaw logs a warning and ignores the allowlist so core tools stay available.
-
-## Tool profiles (base allowlist)
-
-`tools.profile` sets a **base tool allowlist** before `tools.allow`/`tools.deny`.
+`tools.profile` sets a base allowlist before `allow`/`deny` is applied.
Per-agent override: `agents.list[].tools.profile`.
-Profiles:
+| Profile | What it includes |
+| ----------- | ------------------------------------------- |
+| `full` | All tools (default) |
+| `coding` | File I/O, runtime, sessions, memory, image |
+| `messaging` | Messaging, session list/history/send/status |
+| `minimal` | `session_status` only |
-- `minimal`: `session_status` only
-- `coding`: `group:fs`, `group:runtime`, `group:sessions`, `group:memory`, `image`
-- `messaging`: `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status`
-- `full`: no restriction (same as unset)
+### Tool groups
-Example (messaging-only by default, allow Slack + Discord tools too):
+Use `group:*` shorthands in allow/deny lists:
-```json5
-{
- tools: {
- profile: "messaging",
- allow: ["slack", "discord"],
- },
-}
-```
+| Group | Tools |
+| ------------------ | ------------------------------------------------------------------------------ |
+| `group:runtime` | exec, bash, process |
+| `group:fs` | read, write, edit, apply_patch |
+| `group:sessions` | sessions_list, sessions_history, sessions_send, sessions_spawn, session_status |
+| `group:memory` | memory_search, memory_get |
+| `group:web` | web_search, web_fetch |
+| `group:ui` | browser, canvas |
+| `group:automation` | cron, gateway |
+| `group:messaging` | message |
+| `group:nodes` | nodes |
+| `group:openclaw` | All built-in OpenClaw tools (excludes plugin tools) |
-Example (coding profile, but deny exec/process everywhere):
+### Provider-specific restrictions
-```json5
-{
- tools: {
- profile: "coding",
- deny: ["group:runtime"],
- },
-}
-```
-
-Example (global coding profile, messaging-only support agent):
-
-```json5
-{
- tools: { profile: "coding" },
- agents: {
- list: [
- {
- id: "support",
- tools: { profile: "messaging", allow: ["slack"] },
- },
- ],
- },
-}
-```
-
-## Provider-specific tool policy
-
-Use `tools.byProvider` to **further restrict** tools for specific providers
-(or a single `provider/model`) without changing your global defaults.
-Per-agent override: `agents.list[].tools.byProvider`.
-
-This is applied **after** the base tool profile and **before** allow/deny lists,
-so it can only narrow the tool set.
-Provider keys accept either `provider` (e.g. `google-antigravity`) or
-`provider/model` (e.g. `openai/gpt-5.2`).
-
-Example (keep global coding profile, but minimal tools for Google Antigravity):
+Use `tools.byProvider` to restrict tools for specific providers without
+changing global defaults:
```json5
{
@@ -102,515 +135,3 @@ Example (keep global coding profile, but minimal tools for Google Antigravity):
},
}
```
-
-Example (provider/model-specific allowlist for a flaky endpoint):
-
-```json5
-{
- tools: {
- allow: ["group:fs", "group:runtime", "sessions_list"],
- byProvider: {
- "openai/gpt-5.2": { allow: ["group:fs", "sessions_list"] },
- },
- },
-}
-```
-
-Example (agent-specific override for a single provider):
-
-```json5
-{
- agents: {
- list: [
- {
- id: "support",
- tools: {
- byProvider: {
- "google-antigravity": { allow: ["message", "sessions_list"] },
- },
- },
- },
- ],
- },
-}
-```
-
-## Tool groups (shorthands)
-
-Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple tools.
-Use these in `tools.allow` / `tools.deny`.
-
-Available groups:
-
-- `group:runtime`: `exec`, `bash`, `process`
-- `group:fs`: `read`, `write`, `edit`, `apply_patch`
-- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
-- `group:memory`: `memory_search`, `memory_get`
-- `group:web`: `web_search`, `web_fetch`
-- `group:ui`: `browser`, `canvas`
-- `group:automation`: `cron`, `gateway`
-- `group:messaging`: `message`
-- `group:nodes`: `nodes`
-- `group:openclaw`: all built-in OpenClaw tools (excludes provider plugins)
-
-Example (allow only file tools + browser):
-
-```json5
-{
- tools: {
- allow: ["group:fs", "browser"],
- },
-}
-```
-
-## Plugins + tools
-
-Plugins can register **additional tools** (and CLI commands) beyond the core set.
-See [Plugins](/tools/plugin) for install + config, and [Skills](/tools/skills) for how
-tool usage guidance is injected into prompts. Some plugins ship their own skills
-alongside tools (for example, the voice-call plugin).
-
-Optional plugin tools:
-
-- [Lobster](/tools/lobster): typed workflow runtime with resumable approvals (requires the Lobster CLI on the gateway host).
-- [LLM Task](/tools/llm-task): JSON-only LLM step for structured workflow output (optional schema validation).
-- [Diffs](/tools/diffs): read-only diff viewer and PNG or PDF file renderer for before/after text or unified patches.
-
-## Tool inventory
-
-### `apply_patch`
-
-Apply structured patches across one or more files. Use for multi-hunk edits.
-Experimental: enable via `tools.exec.applyPatch.enabled` (OpenAI models only).
-`tools.exec.applyPatch.workspaceOnly` defaults to `true` (workspace-contained). Set it to `false` only if you intentionally want `apply_patch` to write/delete outside the workspace directory.
-
-### `exec`
-
-Run shell commands in the workspace.
-
-Core parameters:
-
-- `command` (required)
-- `yieldMs` (auto-background after timeout, default 10000)
-- `background` (immediate background)
-- `timeout` (seconds; kills the process if exceeded, default 1800)
-- `elevated` (bool; run on host if elevated mode is enabled/allowed; only changes behavior when the agent is sandboxed)
-- `host` (`sandbox | gateway | node`)
-- `security` (`deny | allowlist | full`)
-- `ask` (`off | on-miss | always`)
-- `node` (node id/name for `host=node`)
-- Need a real TTY? Set `pty: true`.
-
-Notes:
-
-- Returns `status: "running"` with a `sessionId` when backgrounded.
-- Use `process` to poll/log/write/kill/clear background sessions.
-- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
-- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`.
-- `elevated` only changes behavior when the agent is sandboxed (otherwise itβs a no-op).
-- `host=node` can target a macOS companion app or a headless node host (`openclaw node run`).
-- gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals).
-
-### `process`
-
-Manage background exec sessions.
-
-Core actions:
-
-- `list`, `poll`, `log`, `write`, `kill`, `clear`, `remove`
-
-Notes:
-
-- `poll` returns new output and exit status when complete.
-- `log` supports line-based `offset`/`limit` (omit `offset` to grab the last N lines).
-- `process` is scoped per agent; sessions from other agents are not visible.
-
-### `loop-detection` (tool-call loop guardrails)
-
-OpenClaw tracks recent tool-call history and blocks or warns when it detects repetitive no-progress loops.
-Enable with `tools.loopDetection.enabled: true` (default is `false`).
-
-```json5
-{
- tools: {
- loopDetection: {
- enabled: true,
- warningThreshold: 10,
- criticalThreshold: 20,
- globalCircuitBreakerThreshold: 30,
- historySize: 30,
- detectors: {
- genericRepeat: true,
- knownPollNoProgress: true,
- pingPong: true,
- },
- },
- },
-}
-```
-
-- `genericRepeat`: repeated same tool + same params call pattern.
-- `knownPollNoProgress`: repeating poll-like tools with identical outputs.
-- `pingPong`: alternating `A/B/A/B` no-progress patterns.
-- Per-agent override: `agents.list[].tools.loopDetection`.
-
-### `web_search`
-
-Search the web using Brave, Firecrawl, Gemini, Grok, Kimi, Perplexity, or Tavily.
-
-Core parameters:
-
-- `query` (required)
-- `count` (1β10; default from `tools.web.search.maxResults`)
-
-Notes:
-
-- Requires an API key for the chosen provider (recommended: `openclaw configure --section web`).
-- Enable via `tools.web.search.enabled`.
-- Responses are cached (default 15 min).
-- See [Web tools](/tools/web) for setup.
-
-### `web_fetch`
-
-Fetch and extract readable content from a URL (HTML β markdown/text).
-
-Core parameters:
-
-- `url` (required)
-- `extractMode` (`markdown` | `text`)
-- `maxChars` (truncate long pages)
-
-Notes:
-
-- Enable via `tools.web.fetch.enabled`.
-- `maxChars` is clamped by `tools.web.fetch.maxCharsCap` (default 50000).
-- Responses are cached (default 15 min).
-- For JS-heavy sites, prefer the browser tool.
-- See [Web tools](/tools/web) for setup.
-- See [Firecrawl](/tools/firecrawl) for the optional anti-bot fallback.
-
-### `browser`
-
-Control the dedicated OpenClaw-managed browser.
-
-Core actions:
-
-- `status`, `start`, `stop`, `tabs`, `open`, `focus`, `close`
-- `snapshot` (aria/ai)
-- `screenshot` (returns image block + `MEDIA:`)
-- `act` (UI actions: click/type/press/hover/drag/select/fill/resize/wait/evaluate)
-- `navigate`, `console`, `pdf`, `upload`, `dialog`
-
-Profile management:
-
-- `profiles` β list all browser profiles with status
-- `create-profile` β create new profile with auto-allocated port (or `cdpUrl`)
-- `delete-profile` β stop browser, delete user data, remove from config (local only)
-- `reset-profile` β kill orphan process on profile's port (local only)
-
-Common parameters:
-
-- `profile` (optional; defaults to `browser.defaultProfile`)
-- `target` (`sandbox` | `host` | `node`)
-- `node` (optional; picks a specific node id/name)
- Notes:
-- Requires `browser.enabled=true` (default is `true`; set `false` to disable).
-- All actions accept optional `profile` parameter for multi-instance support.
-- Omit `profile` for the safe default: isolated OpenClaw-managed browser (`openclaw`).
-- Use `profile="user"` for the real local host browser when existing logins/cookies matter and the user is present to click/approve any attach prompt.
-- `profile="user"` is host-only; do not combine it with sandbox/node targets.
-- When `profile` is omitted, uses `browser.defaultProfile` (defaults to `openclaw`).
-- Profile names: lowercase alphanumeric + hyphens only (max 64 chars).
-- Port range: 18800-18899 (~100 profiles max).
-- Remote profiles are attach-only (no start/stop/reset).
-- If a browser-capable node is connected, the tool may auto-route to it (unless you pin `target`).
-- `snapshot` defaults to `ai` when Playwright is installed; use `aria` for the accessibility tree.
-- `snapshot` also supports role-snapshot options (`interactive`, `compact`, `depth`, `selector`) which return refs like `e12`.
-- `act` requires `ref` from `snapshot` (numeric `12` from AI snapshots, or `e12` from role snapshots); use `evaluate` for rare CSS selector needs.
-- Avoid `act` β `wait` by default; use it only in exceptional cases (no reliable UI state to wait on).
-- `upload` can optionally pass a `ref` to auto-click after arming.
-- `upload` also supports `inputRef` (aria ref) or `element` (CSS selector) to set `` directly.
-
-### `canvas`
-
-Drive the node Canvas (present, eval, snapshot, A2UI).
-
-Core actions:
-
-- `present`, `hide`, `navigate`, `eval`
-- `snapshot` (returns image block + `MEDIA:`)
-- `a2ui_push`, `a2ui_reset`
-
-Notes:
-
-- Uses gateway `node.invoke` under the hood.
-- If no `node` is provided, the tool picks a default (single connected node or local mac node).
-- A2UI is v0.8 only (no `createSurface`); the CLI rejects v0.9 JSONL with line errors.
-- Quick smoke: `openclaw nodes canvas a2ui push --node --text "Hello from A2UI"`.
-
-### `nodes`
-
-Discover and target paired nodes; send notifications; capture camera/screen.
-
-Core actions:
-
-- `status`, `describe`
-- `pending`, `approve`, `reject` (pairing)
-- `notify` (macOS `system.notify`)
-- `run` (macOS `system.run`)
-- `camera_list`, `camera_snap`, `camera_clip`, `screen_record`
-- `location_get`, `notifications_list`, `notifications_action`
-- `device_status`, `device_info`, `device_permissions`, `device_health`
-
-Notes:
-
-- Camera/screen commands require the node app to be foregrounded.
-- Images return image blocks + `MEDIA:`.
-- Videos return `FILE:` (mp4).
-- Location returns a JSON payload (lat/lon/accuracy/timestamp).
-- `run` params: `command` argv array; optional `cwd`, `env` (`KEY=VAL`), `commandTimeoutMs`, `invokeTimeoutMs`, `needsScreenRecording`.
-
-Example (`run`):
-
-```json
-{
- "action": "run",
- "node": "office-mac",
- "command": ["echo", "Hello"],
- "env": ["FOO=bar"],
- "commandTimeoutMs": 12000,
- "invokeTimeoutMs": 45000,
- "needsScreenRecording": false
-}
-```
-
-### `image`
-
-Analyze an image with the configured image model.
-
-Core parameters:
-
-- `image` (required path or URL)
-- `prompt` (optional; defaults to "Describe the image.")
-- `model` (optional override)
-- `maxBytesMb` (optional size cap)
-
-Notes:
-
-- Only available when `agents.defaults.imageModel` is configured (primary or fallbacks), or when an implicit image model can be inferred from your default model + configured auth (best-effort pairing).
-- Uses the image model directly (independent of the main chat model).
-
-### `image_generate`
-
-Generate one or more images with the configured or inferred image-generation model.
-
-Core parameters:
-
-- `action` (optional: `generate` or `list`; default `generate`)
-- `prompt` (required)
-- `image` or `images` (optional reference image path/URL for edit mode)
-- `model` (optional provider/model override)
-- `size` (optional size hint)
-- `resolution` (optional `1K|2K|4K` hint)
-- `count` (optional, `1-4`, default `1`)
-
-Notes:
-
-- Available when `agents.defaults.imageGenerationModel` is configured, or when OpenClaw can infer a compatible image-generation default from your enabled providers plus available auth.
-- Explicit `agents.defaults.imageGenerationModel` still wins over any inferred default.
-- Use `action: "list"` to inspect registered providers, default models, supported model ids, sizes, resolutions, and edit support.
-- Returns local `MEDIA:` lines so channels can deliver the generated files directly.
-- Uses the image-generation model directly (independent of the main chat model).
-- Google-backed flows, including `google/gemini-3-pro-image-preview` for the native Nano Banana-style path, support reference-image edits plus explicit `1K|2K|4K` resolution hints.
-- When editing and `resolution` is omitted, OpenClaw infers a draft/final resolution from the input image size.
-- This is the built-in replacement for the old `nano-banana-pro` skill workflow. Use `agents.defaults.imageGenerationModel`, not `skills.entries`, for stock image generation.
-
-Native example:
-
-```json5
-{
- agents: {
- defaults: {
- imageGenerationModel: {
- primary: "google/gemini-3-pro-image-preview", // native Nano Banana path
- fallbacks: ["fal/fal-ai/flux/dev"],
- },
- },
- },
-}
-```
-
-### `pdf`
-
-Analyze one or more PDF documents.
-
-For full behavior, limits, config, and examples, see [PDF tool](/tools/pdf).
-
-### `message`
-
-Send messages and channel actions across Discord/Google Chat/Slack/Telegram/WhatsApp/Signal/iMessage/Microsoft Teams.
-
-Core actions:
-
-- `send` (text + optional media; Microsoft Teams also supports `card` for Adaptive Cards)
-- `poll` (WhatsApp/Discord/Microsoft Teams polls)
-- `react` / `reactions` / `read` / `edit` / `delete`
-- `pin` / `unpin` / `list-pins`
-- `permissions`
-- `thread-create` / `thread-list` / `thread-reply`
-- `search`
-- `sticker`
-- `member-info` / `role-info`
-- `emoji-list` / `emoji-upload` / `sticker-upload`
-- `role-add` / `role-remove`
-- `channel-info` / `channel-list`
-- `voice-status`
-- `event-list` / `event-create`
-- `timeout` / `kick` / `ban`
-
-Notes:
-
-- `send` routes WhatsApp via the Gateway; other channels go direct.
-- `poll` uses the Gateway for WhatsApp and Microsoft Teams; Discord polls go direct.
-- When a message tool call is bound to an active chat session, sends are constrained to that sessionβs target to avoid cross-context leaks.
-
-### `cron`
-
-Manage Gateway cron jobs and wakeups.
-
-Core actions:
-
-- `status`, `list`
-- `add`, `update`, `remove`, `run`, `runs`
-- `wake` (enqueue system event + optional immediate heartbeat)
-
-Notes:
-
-- `add` expects a full cron job object (same schema as `cron.add` RPC).
-- `update` uses `{ jobId, patch }` (`id` accepted for compatibility).
-
-### `gateway`
-
-Restart or apply updates to the running Gateway process (in-place).
-
-Core actions:
-
-- `restart` (authorizes + sends `SIGUSR1` for in-process restart; `openclaw gateway` restart in-place)
-- `config.schema.lookup` (inspect one config path at a time without loading the full schema into prompt context)
-- `config.get`
-- `config.apply` (validate + write config + restart + wake)
-- `config.patch` (merge partial update + restart + wake)
-- `update.run` (run update + restart + wake)
-
-Notes:
-
-- `config.schema.lookup` expects a targeted config path such as `gateway.auth` or `agents.list.*.heartbeat`.
-- Paths may include slash-delimited plugin ids when addressing `plugins.entries.`, for example `plugins.entries.pack/one.config`.
-- Use `delayMs` (defaults to 2000) to avoid interrupting an in-flight reply.
-- `config.schema` remains available to internal Control UI flows and is not exposed through the agent `gateway` tool.
-- `restart` is enabled by default; set `commands.restart: false` to disable it.
-
-### `sessions_list` / `sessions_history` / `sessions_send` / `sessions_spawn` / `session_status`
-
-List sessions, inspect transcript history, or send to another session.
-
-Core parameters:
-
-- `sessions_list`: `kinds?`, `limit?`, `activeMinutes?`, `messageLimit?` (0 = none)
-- `sessions_history`: `sessionKey` (or `sessionId`), `limit?`, `includeTools?`
-- `sessions_send`: `sessionKey` (or `sessionId`), `message`, `timeoutSeconds?` (0 = fire-and-forget)
-- `sessions_spawn`: `task`, `label?`, `runtime?`, `agentId?`, `model?`, `thinking?`, `cwd?`, `runTimeoutSeconds?`, `thread?`, `mode?`, `cleanup?`, `sandbox?`, `streamTo?`, `attachments?`, `attachAs?`
-- `session_status`: `sessionKey?` (default current; accepts `sessionId`), `model?` (`default` clears override)
-
-Notes:
-
-- `main` is the canonical direct-chat key; global/unknown are hidden.
-- `messageLimit > 0` fetches last N messages per session (tool messages filtered).
-- Session targeting is controlled by `tools.sessions.visibility` (default `tree`: current session + spawned subagent sessions). If you run a shared agent for multiple users, consider setting `tools.sessions.visibility: "self"` to prevent cross-session browsing.
-- `sessions_send` waits for final completion when `timeoutSeconds > 0`.
-- Delivery/announce happens after completion and is best-effort; `status: "ok"` confirms the agent run finished, not that the announce was delivered.
-- `sessions_spawn` supports `runtime: "subagent" | "acp"` (`subagent` default). For ACP runtime behavior, see [ACP Agents](/tools/acp-agents).
-- For ACP runtime, `streamTo: "parent"` routes initial-run progress summaries back to the requester session as system events instead of direct child delivery.
-- `sessions_spawn` starts a sub-agent run and posts an announce reply back to the requester chat.
- - Supports one-shot mode (`mode: "run"`) and persistent thread-bound mode (`mode: "session"` with `thread: true`).
- - If `thread: true` and `mode` is omitted, mode defaults to `session`.
- - `mode: "session"` requires `thread: true`.
- - If `runTimeoutSeconds` is omitted, OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise timeout defaults to `0` (no timeout).
- - Discord thread-bound flows depend on `session.threadBindings.*` and `channels.discord.threadBindings.*`.
- - Reply format includes `Status`, `Result`, and compact stats.
- - `Result` is the assistant completion text; if missing, the latest `toolResult` is used as fallback.
-- Manual completion-mode spawns send directly first, with queue fallback and retry on transient failures (`status: "ok"` means run finished, not that announce delivered).
-- `sessions_spawn` supports inline file attachments for subagent runtime only (ACP rejects them). Each attachment has `name`, `content`, and optional `encoding` (`utf8` or `base64`) and `mimeType`. Files are materialized into the child workspace at `.openclaw/attachments//` with a `.manifest.json` metadata file. The tool returns a receipt with `count`, `totalBytes`, per file `sha256`, and `relDir`. Attachment content is automatically redacted from transcript persistence.
- - Configure limits via `tools.sessions_spawn.attachments` (`enabled`, `maxTotalBytes`, `maxFiles`, `maxFileBytes`, `retainOnSessionKeep`).
- - `attachAs.mountPath` is a reserved hint for future mount implementations.
-- `sessions_spawn` is non-blocking and returns `status: "accepted"` immediately.
-- ACP `streamTo: "parent"` responses may include `streamLogPath` (session-scoped `*.acp-stream.jsonl`) for tailing progress history.
-- `sessions_send` runs a replyβback pingβpong (reply `REPLY_SKIP` to stop; max turns via `session.agentToAgent.maxPingPongTurns`, 0β5).
-- After the pingβpong, the target agent runs an **announce step**; reply `ANNOUNCE_SKIP` to suppress the announcement.
-- Sandbox clamp: when the current session is sandboxed and `agents.defaults.sandbox.sessionToolsVisibility: "spawned"`, OpenClaw clamps `tools.sessions.visibility` to `tree`.
-
-### `agents_list`
-
-List agent ids that the current session may target with `sessions_spawn`.
-
-Notes:
-
-- Result is restricted to per-agent allowlists (`agents.list[].subagents.allowAgents`).
-- When `["*"]` is configured, the tool includes all configured agents and marks `allowAny: true`.
-
-## Parameters (common)
-
-Gateway-backed tools (`canvas`, `nodes`, `cron`):
-
-- `gatewayUrl` (default `ws://127.0.0.1:18789`)
-- `gatewayToken` (if auth enabled)
-- `timeoutMs`
-
-Note: when `gatewayUrl` is set, include `gatewayToken` explicitly. Tools do not inherit config
-or environment credentials for overrides, and missing explicit credentials is an error.
-
-Browser tool:
-
-- `profile` (optional; defaults to `browser.defaultProfile`)
-- `target` (`sandbox` | `host` | `node`)
-- `node` (optional; pin a specific node id/name)
-- Troubleshooting guides:
- - Linux startup/CDP issues: [Browser troubleshooting (Linux)](/tools/browser-linux-troubleshooting)
- - WSL2 Gateway + Windows remote Chrome CDP: [WSL2 + Windows + remote Chrome CDP troubleshooting](/tools/browser-wsl2-windows-remote-cdp-troubleshooting)
-
-## Recommended agent flows
-
-Browser automation:
-
-1. `browser` β `status` / `start`
-2. `snapshot` (ai or aria)
-3. `act` (click/type/press)
-4. `screenshot` if you need visual confirmation
-
-Canvas render:
-
-1. `canvas` β `present`
-2. `a2ui_push` (optional)
-3. `snapshot`
-
-Node targeting:
-
-1. `nodes` β `status`
-2. `describe` on the chosen node
-3. `notify` / `run` / `camera_snap` / `screen_record`
-
-## Safety
-
-- Avoid direct `system.run`; use `nodes` β `run` only with explicit user consent.
-- Respect user consent for camera/screen capture.
-- Use `status/describe` to ensure permissions before invoking media commands.
-
-## How tools are presented to the agent
-
-Tools are exposed in two parallel channels:
-
-1. **System prompt text**: a human-readable list + guidance.
-2. **Tool schema**: the structured function definitions sent to the model API.
-
-That means the agent sees both βwhat tools existβ and βhow to call them.β If a tool
-doesnβt appear in the system prompt or the schema, the model cannot call it.
diff --git a/docs/tools/lobster.md b/docs/tools/lobster.md
index 6e502c09c19..fd8e4c5eb92 100644
--- a/docs/tools/lobster.md
+++ b/docs/tools/lobster.md
@@ -330,7 +330,7 @@ OpenProse pairs well with Lobster: use `/prose` to orchestrate multi-agent prep,
## Learn more
- [Plugins](/tools/plugin)
-- [Plugin tool authoring](/plugins/agent-tools)
+- [Plugin tool authoring](/plugins/building-plugins#registering-agent-tools)
## Case study: community workflows
diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md
index 7f1ba0fade4..3ede326f0aa 100644
--- a/docs/tools/plugin.md
+++ b/docs/tools/plugin.md
@@ -1,69 +1,64 @@
---
-summary: "OpenClaw plugins/extensions: discovery, config, and safety"
+summary: "Install, configure, and manage OpenClaw plugins"
read_when:
- - Adding or modifying plugins/extensions
- - Documenting plugin install or load rules
+ - Installing or configuring plugins
+ - Understanding plugin discovery and load rules
- Working with Codex/Claude-compatible plugin bundles
title: "Plugins"
+sidebarTitle: "Install and Configure"
---
-# Plugins (Extensions)
+# Plugins
+
+Plugins extend OpenClaw with new capabilities: channels, model providers, tools,
+skills, speech, image generation, and more. Some plugins are **core** (shipped
+with OpenClaw), others are **external** (published on npm by the community).
## Quick start
-A plugin is either:
+
+
+ ```bash
+ openclaw plugins list
+ ```
+
-- a native **OpenClaw plugin** (`openclaw.plugin.json` + runtime module), or
-- a compatible **bundle** (`.codex-plugin/plugin.json` or `.claude-plugin/plugin.json`)
+
+ ```bash
+ # From npm
+ openclaw plugins install @openclaw/voice-call
-Both show up under `openclaw plugins`, but only native OpenClaw plugins execute
-runtime code in-process.
+ # From a local directory or archive
+ openclaw plugins install ./my-plugin
+ openclaw plugins install ./my-plugin.tgz
+ ```
-1. See what is already loaded:
+
-```bash
-openclaw plugins list
-```
+
+ ```bash
+ openclaw gateway restart
+ ```
-2. Install an official plugin (example: Voice Call):
+ Then configure under `plugins.entries.\.config` in your config file.
-```bash
-openclaw plugins install @openclaw/voice-call
-```
+
+
-Npm specs are registry-only. See [install rules](/cli/plugins#install) for
-details on pinning, prerelease gating, and supported spec formats.
+## Plugin types
-3. Restart the Gateway, then configure under `plugins.entries..config`.
+OpenClaw recognizes two plugin formats:
-See [Voice Call](/plugins/voice-call) for a concrete example plugin.
-Looking for third-party listings? See [Community plugins](/plugins/community).
-Need the bundle compatibility details? See [Plugin bundles](/plugins/bundles).
+| Format | How it works | Examples |
+| ---------- | ------------------------------------------------------------------ | ------------------------------------------------------ |
+| **Native** | `openclaw.plugin.json` + runtime module; executes in-process | Official plugins, community npm packages |
+| **Bundle** | Codex/Claude/Cursor-compatible layout; mapped to OpenClaw features | `.codex-plugin/`, `.claude-plugin/`, `.cursor-plugin/` |
-For compatible bundles, install from a local directory or archive:
+Both show up under `openclaw plugins list`. See [Plugin Bundles](/plugins/bundles) for bundle details.
-```bash
-openclaw plugins install ./my-bundle
-openclaw plugins install ./my-bundle.tgz
-```
+## Official plugins
-For Claude marketplace installs, list the marketplace first, then install by
-marketplace entry name:
-
-```bash
-openclaw plugins marketplace list
-openclaw plugins install @
-```
-
-OpenClaw resolves known Claude marketplace names from
-`~/.claude/plugins/known_marketplaces.json`. You can also pass an explicit
-marketplace source with `--marketplace`.
-
-## Available plugins (official)
-
-### Installable plugins
-
-These are published to npm and installed with `openclaw plugins install`:
+### Installable (npm)
| Plugin | Package | Docs |
| --------------- | ---------------------- | ------------------------------------ |
@@ -74,51 +69,34 @@ These are published to npm and installed with `openclaw plugins install`:
| Zalo | `@openclaw/zalo` | [Zalo](/channels/zalo) |
| Zalo Personal | `@openclaw/zalouser` | [Zalo Personal](/plugins/zalouser) |
-Microsoft Teams is plugin-only as of 2026.1.15.
+### Core (shipped with OpenClaw)
-Packaged installs also ship install-on-demand metadata for heavyweight official
-plugins. Today that includes WhatsApp and `memory-lancedb`: onboarding,
-`openclaw channels add`, `openclaw channels login --channel whatsapp`, and
-other channel setup flows prompt to install them when first used instead of
-shipping their full runtime trees inside the main npm tarball.
+
+
+ `anthropic`, `byteplus`, `cloudflare-ai-gateway`, `github-copilot`, `google`,
+ `huggingface`, `kilocode`, `kimi-coding`, `minimax`, `mistral`, `modelstudio`,
+ `moonshot`, `nvidia`, `openai`, `opencode`, `opencode-go`, `openrouter`,
+ `qianfan`, `qwen-portal-auth`, `synthetic`, `together`, `venice`,
+ `vercel-ai-gateway`, `volcengine`, `xiaomi`, `zai`
+
-### Bundled plugins
+
+ - `memory-core` β bundled memory search (default via `plugins.slots.memory`)
+ - `memory-lancedb` β install-on-demand long-term memory with auto-recall/capture (set `plugins.slots.memory = "memory-lancedb"`)
+
-These ship with OpenClaw and are enabled by default unless noted.
+
+ `elevenlabs`, `microsoft`
+
-**Memory:**
+
+ - `copilot-proxy` β VS Code Copilot Proxy bridge (disabled by default)
+
+
-- `memory-core` -- bundled memory search (default via `plugins.slots.memory`)
-- `memory-lancedb` -- install-on-demand long-term memory with auto-recall/capture (set `plugins.slots.memory = "memory-lancedb"`)
+Looking for third-party plugins? See [Community Plugins](/plugins/community).
-**Model providers** (all enabled by default):
-
-`anthropic`, `byteplus`, `cloudflare-ai-gateway`, `github-copilot`, `google`, `huggingface`, `kilocode`, `kimi-coding`, `minimax`, `mistral`, `modelstudio`, `moonshot`, `nvidia`, `openai`, `opencode`, `opencode-go`, `openrouter`, `qianfan`, `qwen-portal-auth`, `synthetic`, `together`, `venice`, `vercel-ai-gateway`, `volcengine`, `xiaomi`, `zai`
-
-**Speech providers** (enabled by default):
-
-`elevenlabs`, `microsoft`
-
-**Other bundled:**
-
-- `copilot-proxy` -- VS Code Copilot Proxy bridge (disabled by default)
-
-## Compatible bundles
-
-OpenClaw also recognizes compatible external bundle layouts:
-
-- Codex-style bundles: `.codex-plugin/plugin.json`
-- Claude-style bundles: `.claude-plugin/plugin.json` or the default Claude
- component layout without a manifest
-- Cursor-style bundles: `.cursor-plugin/plugin.json`
-
-They are shown in the plugin list as `format=bundle`, with a subtype of
-`codex`, `claude`, or `cursor` in verbose/inspect output.
-
-See [Plugin bundles](/plugins/bundles) for the exact detection rules, mapping
-behavior, and current support matrix.
-
-## Config
+## Configuration
```json5
{
@@ -134,204 +112,140 @@ behavior, and current support matrix.
}
```
-Fields:
+| Field | Description |
+| ---------------- | --------------------------------------------------------- |
+| `enabled` | Master toggle (default: `true`) |
+| `allow` | Plugin allowlist (optional) |
+| `deny` | Plugin denylist (optional; deny wins) |
+| `load.paths` | Extra plugin files/directories |
+| `slots` | Exclusive slot selectors (e.g. `memory`, `contextEngine`) |
+| `entries.\` | Per-plugin toggles + config |
-- `enabled`: master toggle (default: true)
-- `allow`: allowlist (optional)
-- `deny`: denylist (optional; deny wins)
-- `load.paths`: extra plugin files/dirs
-- `slots`: exclusive slot selectors such as `memory` and `contextEngine`
-- `entries.`: per-plugin toggles + config
+Config changes **require a gateway restart**.
-Config changes **require a gateway restart**. See
-[Configuration reference](/configuration) for the full config schema.
-
-Validation rules (strict):
-
-- Unknown plugin ids in `entries`, `allow`, `deny`, or `slots` are **errors**.
-- Unknown `channels.` keys are **errors** unless a plugin manifest declares
- the channel id.
-- Native plugin config is validated using the JSON Schema embedded in
- `openclaw.plugin.json` (`configSchema`).
-- Compatible bundles currently do not expose native OpenClaw config schemas.
-- If a plugin is disabled, its config is preserved and a **warning** is emitted.
-
-### Disabled vs missing vs invalid
-
-These states are intentionally different:
-
-- **disabled**: plugin exists, but enablement rules turned it off
-- **missing**: config references a plugin id that discovery did not find
-- **invalid**: plugin exists, but its config does not match the declared schema
-
-OpenClaw preserves config for disabled plugins so toggling them back on is not
-destructive.
+
+ - **Disabled**: plugin exists but enablement rules turned it off. Config is preserved.
+ - **Missing**: config references a plugin id that discovery did not find.
+ - **Invalid**: plugin exists but its config does not match the declared schema.
+
## Discovery and precedence
-OpenClaw scans, in order:
+OpenClaw scans for plugins in this order (first match wins):
-1. Config paths
+
+
+ `plugins.load.paths` β explicit file or directory paths.
+
-- `plugins.load.paths` (file or directory)
+
+ `\/.openclaw/extensions/*.ts` and `\/.openclaw/extensions/*/index.ts`.
+
-2. Workspace extensions
+
+ `~/.openclaw/extensions/*.ts` and `~/.openclaw/extensions/*/index.ts`.
+
-- `/.openclaw/extensions/*.ts`
-- `/.openclaw/extensions/*/index.ts`
-
-3. Global extensions
-
-- `~/.openclaw/extensions/*.ts`
-- `~/.openclaw/extensions/*/index.ts`
-
-4. Bundled extensions (shipped with OpenClaw; mixed default-on/default-off)
-
-- `/dist/extensions/*` in packaged installs
-- `/dist-runtime/extensions/*` in local built checkouts
-- `/extensions/*` in source/Vitest workflows
-
-Many bundled provider plugins are enabled by default so model catalogs/runtime
-hooks stay available without extra setup. Others still require explicit
-enablement via `plugins.entries..enabled` or
-`openclaw plugins enable `.
-
-Bundled plugin runtime dependencies are owned by each plugin package. Packaged
-builds stage opted-in bundled dependencies under
-`dist/extensions//node_modules` instead of requiring mirrored copies in the
-root package. Very large official plugins can ship as metadata-only bundled
-entries and install their runtime package on demand. npm artifacts ship the
-built `dist/extensions/*` tree; source `extensions/*` directories stay in source
-checkouts only.
-
-Installed plugins are enabled by default, but can be disabled the same way.
-
-Workspace plugins are **disabled by default** unless you explicitly enable them
-or allowlist them. This is intentional: a checked-out repo should not silently
-become production gateway code.
-
-If multiple plugins resolve to the same id, the first match in the order above
-wins and lower-precedence copies are ignored.
+
+ Shipped with OpenClaw. Many are enabled by default (model providers, speech).
+ Others require explicit enablement.
+
+
### Enablement rules
-Enablement is resolved after discovery:
-
- `plugins.enabled: false` disables all plugins
-- `plugins.deny` always wins
-- `plugins.entries..enabled: false` disables that plugin
-- workspace-origin plugins are disabled by default
-- allowlists restrict the active set when `plugins.allow` is non-empty
-- allowlists are **id-based**, not source-based
-- bundled plugins are disabled by default unless:
- - the bundled id is in the built-in default-on set, or
- - you explicitly enable it, or
- - channel config implicitly enables the bundled channel plugin
-- exclusive slots can force-enable the selected plugin for that slot
+- `plugins.deny` always wins over allow
+- `plugins.entries.\.enabled: false` disables that plugin
+- Workspace-origin plugins are **disabled by default** (must be explicitly enabled)
+- Bundled plugins follow the built-in default-on set unless overridden
+- Exclusive slots can force-enable the selected plugin for that slot
## Plugin slots (exclusive categories)
-Some plugin categories are **exclusive** (only one active at a time). Use
-`plugins.slots` to select which plugin owns the slot:
+Some categories are exclusive (only one active at a time):
```json5
{
plugins: {
slots: {
- memory: "memory-core", // or "none" to disable memory plugins
- contextEngine: "legacy", // or a plugin id such as "lossless-claw"
+ memory: "memory-core", // or "none" to disable
+ contextEngine: "legacy", // or a plugin id
},
},
}
```
-Supported exclusive slots:
+| Slot | What it controls | Default |
+| --------------- | --------------------- | ------------------- |
+| `memory` | Active memory plugin | `memory-core` |
+| `contextEngine` | Active context engine | `legacy` (built-in) |
-- `memory`: active memory plugin (`"none"` disables memory plugins)
-- `contextEngine`: active context engine plugin (`"legacy"` is the built-in default)
-
-If multiple plugins declare `kind: "memory"` or `kind: "context-engine"`, only
-the selected plugin loads for that slot. Others are disabled with diagnostics.
-Declare `kind` in your [plugin manifest](/plugins/manifest).
-
-## Plugin IDs
-
-Default plugin ids:
-
-- Package packs: `package.json` `name`
-- Standalone file: file base name (`~/.../voice-call.ts` -> `voice-call`)
-
-If a plugin exports `id`, OpenClaw uses it but warns when it does not match the
-configured id.
-
-## Inspection
+## CLI reference
```bash
-openclaw plugins inspect openai # deep detail on one plugin
-openclaw plugins inspect openai --json # machine-readable
-openclaw plugins list # compact inventory
-openclaw plugins status # operational summary
-openclaw plugins doctor # issue-focused diagnostics
-```
+openclaw plugins list # compact inventory
+openclaw plugins inspect # deep detail
+openclaw plugins inspect --json # machine-readable
+openclaw plugins status # operational summary
+openclaw plugins doctor # diagnostics
-## CLI
+openclaw plugins install # install from npm
+openclaw plugins install # install from local path
+openclaw plugins install -l # link (no copy) for dev
+openclaw plugins update # update one plugin
+openclaw plugins update --all # update all
-```bash
-openclaw plugins list
-openclaw plugins inspect
-openclaw plugins install # copy a local file/dir into ~/.openclaw/extensions/
-openclaw plugins install ./extensions/voice-call # relative path ok
-openclaw plugins install ./plugin.tgz # install from a local tarball
-openclaw plugins install ./plugin.zip # install from a local zip
-openclaw plugins install -l ./extensions/voice-call # link (no copy) for dev
-openclaw plugins install @openclaw/voice-call # install from npm
-openclaw plugins install @openclaw/voice-call --pin # store exact resolved name@version
-openclaw plugins update
-openclaw plugins update --all
openclaw plugins enable
openclaw plugins disable
-openclaw plugins doctor
```
-See [`openclaw plugins` CLI reference](/cli/plugins) for full details on each
-command (install rules, inspect output, marketplace installs, uninstall).
+See [`openclaw plugins` CLI reference](/cli/plugins) for full details.
-Plugins may also register their own top-level commands (example:
-`openclaw voicecall`).
+## Plugin API overview
-## Plugin API (overview)
+Plugins export either a function or an object with `register(api)`:
-Plugins export either:
+```typescript
+export default definePluginEntry({
+ id: "my-plugin",
+ name: "My Plugin",
+ register(api) {
+ api.registerProvider({
+ /* ... */
+ });
+ api.registerTool({
+ /* ... */
+ });
+ api.registerChannel({
+ /* ... */
+ });
+ },
+});
+```
-- A function: `(api) => { ... }`
-- An object: `{ id, name, configSchema, register(api) { ... } }`
+Common registration methods:
-`register(api)` is where plugins attach behavior. Common registrations include:
+| Method | What it registers |
+| ------------------------------------ | -------------------- |
+| `registerProvider` | Model provider (LLM) |
+| `registerChannel` | Chat channel |
+| `registerTool` | Agent tool |
+| `registerHook` / `on(...)` | Lifecycle hooks |
+| `registerSpeechProvider` | Text-to-speech / STT |
+| `registerMediaUnderstandingProvider` | Image/audio analysis |
+| `registerImageGenerationProvider` | Image generation |
+| `registerWebSearchProvider` | Web search |
+| `registerHttpRoute` | HTTP endpoint |
+| `registerCommand` / `registerCli` | CLI commands |
+| `registerContextEngine` | Context engine |
+| `registerService` | Background service |
-- `registerTool`
-- `registerHook`
-- `on(...)` for typed lifecycle hooks
-- `registerChannel`
-- `registerProvider`
-- `registerSpeechProvider`
-- `registerMediaUnderstandingProvider`
-- `registerWebSearchProvider`
-- `registerHttpRoute`
-- `registerCommand`
-- `registerCli`
-- `registerContextEngine`
-- `registerService`
+## Related
-See [Plugin manifest](/plugins/manifest) for the manifest file format.
-
-## Further reading
-
-- [Plugin architecture and internals](/plugins/architecture) -- capability model,
- ownership model, contracts, load pipeline, runtime helpers, and developer API
- reference
-- [Building extensions](/plugins/building-extensions)
-- [Plugin bundles](/plugins/bundles)
-- [Plugin manifest](/plugins/manifest)
-- [Plugin agent tools](/plugins/agent-tools)
-- [Capability Cookbook](/tools/capability-cookbook)
-- [Community plugins](/plugins/community)
+- [Building Plugins](/plugins/building-plugins) β create your own plugin
+- [Plugin Bundles](/plugins/bundles) β Codex/Claude/Cursor bundle compatibility
+- [Plugin Manifest](/plugins/manifest) β manifest schema
+- [Registering Tools](/plugins/building-plugins#registering-agent-tools) β add agent tools in a plugin
+- [Plugin Internals](/plugins/architecture) β capability model and load pipeline
+- [Community Plugins](/plugins/community) β third-party listings
diff --git a/docs/tools/reactions.md b/docs/tools/reactions.md
index 17f9cfbb7f9..56d6b5942e7 100644
--- a/docs/tools/reactions.md
+++ b/docs/tools/reactions.md
@@ -1,23 +1,64 @@
---
-summary: "Reaction semantics shared across channels"
+summary: "Reaction tool semantics across all supported channels"
read_when:
- Working on reactions in any channel
+ - Understanding how emoji reactions differ across platforms
title: "Reactions"
---
-# Reaction tooling
+# Reactions
-Shared reaction semantics across channels:
+The agent can add and remove emoji reactions on messages using the `message`
+tool with the `react` action. Reaction behavior varies by channel.
+
+## How it works
+
+```json
+{
+ "action": "react",
+ "messageId": "msg-123",
+ "emoji": "thumbsup"
+}
+```
- `emoji` is required when adding a reaction.
-- `emoji=""` removes the bot's reaction(s) when supported.
-- `remove: true` removes the specified emoji when supported (requires `emoji`).
+- Set `emoji` to an empty string (`""`) to remove the bot's reaction(s).
+- Set `remove: true` to remove a specific emoji (requires non-empty `emoji`).
-Channel notes:
+## Channel behavior
-- **Discord/Slack**: empty `emoji` removes all of the bot's reactions on the message; `remove: true` removes just that emoji.
-- **Google Chat**: empty `emoji` removes the app's reactions on the message; `remove: true` removes just that emoji.
-- **Telegram**: empty `emoji` removes the bot's reactions; `remove: true` also removes reactions but still requires a non-empty `emoji` for tool validation.
-- **WhatsApp**: empty `emoji` removes the bot reaction; `remove: true` maps to empty emoji (still requires `emoji`).
-- **Zalo Personal (`zalouser`)**: requires non-empty `emoji`; `remove: true` removes that specific emoji reaction.
-- **Signal**: inbound reaction notifications emit system events when `channels.signal.reactionNotifications` is enabled.
+
+
+ - Empty `emoji` removes all of the bot's reactions on the message.
+ - `remove: true` removes just the specified emoji.
+
+
+
+ - Empty `emoji` removes the app's reactions on the message.
+ - `remove: true` removes just the specified emoji.
+
+
+
+ - Empty `emoji` removes the bot's reactions.
+ - `remove: true` also removes reactions but still requires a non-empty `emoji` for tool validation.
+
+
+
+ - Empty `emoji` removes the bot reaction.
+ - `remove: true` maps to empty emoji internally (still requires `emoji` in the tool call).
+
+
+
+ - Requires non-empty `emoji`.
+ - `remove: true` removes that specific emoji reaction.
+
+
+
+ - Inbound reaction notifications emit system events when `channels.signal.reactionNotifications` is enabled.
+
+
+
+## Related
+
+- [Agent Send](/tools/agent-send) β the `message` tool that includes `react`
+- [Channels](/channels) β channel-specific configuration
diff --git a/extensions/anthropic-vertex/provider-catalog.ts b/extensions/anthropic-vertex/provider-catalog.ts
new file mode 100644
index 00000000000..dfad3ade565
--- /dev/null
+++ b/extensions/anthropic-vertex/provider-catalog.ts
@@ -0,0 +1,65 @@
+import type {
+ ModelDefinitionConfig,
+ ModelProviderConfig,
+} from "openclaw/plugin-sdk/provider-models";
+import { resolveAnthropicVertexRegion } from "openclaw/plugin-sdk/provider-models";
+export const ANTHROPIC_VERTEX_DEFAULT_MODEL_ID = "claude-sonnet-4-6";
+const ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW = 1_000_000;
+const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
+
+function buildAnthropicVertexModel(params: {
+ id: string;
+ name: string;
+ reasoning: boolean;
+ input: ModelDefinitionConfig["input"];
+ cost: ModelDefinitionConfig["cost"];
+ maxTokens: number;
+}): ModelDefinitionConfig {
+ return {
+ id: params.id,
+ name: params.name,
+ reasoning: params.reasoning,
+ input: params.input,
+ cost: params.cost,
+ contextWindow: ANTHROPIC_VERTEX_DEFAULT_CONTEXT_WINDOW,
+ maxTokens: params.maxTokens,
+ };
+}
+
+function buildAnthropicVertexCatalog(): ModelDefinitionConfig[] {
+ return [
+ buildAnthropicVertexModel({
+ id: "claude-opus-4-6",
+ name: "Claude Opus 4.6",
+ reasoning: true,
+ input: ["text", "image"],
+ cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
+ maxTokens: 128000,
+ }),
+ buildAnthropicVertexModel({
+ id: ANTHROPIC_VERTEX_DEFAULT_MODEL_ID,
+ name: "Claude Sonnet 4.6",
+ reasoning: true,
+ input: ["text", "image"],
+ cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
+ maxTokens: 128000,
+ }),
+ ];
+}
+
+export function buildAnthropicVertexProvider(params?: {
+ env?: NodeJS.ProcessEnv;
+}): ModelProviderConfig {
+ const region = resolveAnthropicVertexRegion(params?.env);
+ const baseUrl =
+ region.toLowerCase() === "global"
+ ? "https://aiplatform.googleapis.com"
+ : `https://${region}-aiplatform.googleapis.com`;
+
+ return {
+ baseUrl,
+ api: "anthropic-messages",
+ apiKey: GCP_VERTEX_CREDENTIALS_MARKER,
+ models: buildAnthropicVertexCatalog(),
+ };
+}
diff --git a/extensions/bluebubbles/src/actions.test.ts b/extensions/bluebubbles/src/actions.test.ts
index 02cda25b5bc..677e1ae9703 100644
--- a/extensions/bluebubbles/src/actions.test.ts
+++ b/extensions/bluebubbles/src/actions.test.ts
@@ -1,4 +1,3 @@
-import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { bluebubblesMessageActions } from "./actions.js";
import { sendBlueBubblesAttachment } from "./attachments.js";
@@ -6,6 +5,7 @@ import { editBlueBubblesMessage, setGroupIconBlueBubbles } from "./chat.js";
import { resolveBlueBubblesMessageId } from "./monitor.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
import { sendBlueBubblesReaction } from "./reactions.js";
+import type { OpenClawConfig } from "./runtime-api.js";
import { resolveChatGuidForTarget, sendMessageBlueBubbles } from "./send.js";
vi.mock("./accounts.js", async () => {
diff --git a/extensions/bluebubbles/src/attachments.test.ts b/extensions/bluebubbles/src/attachments.test.ts
index cb40ca810e3..0b5ee8bbf02 100644
--- a/extensions/bluebubbles/src/attachments.test.ts
+++ b/extensions/bluebubbles/src/attachments.test.ts
@@ -1,8 +1,8 @@
-import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import "./test-mocks.js";
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
+import "./test-mocks.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
+import type { PluginRuntime } from "./runtime-api.js";
import { setBlueBubblesRuntime } from "./runtime.js";
import {
BLUE_BUBBLES_PRIVATE_API_STATUS,
diff --git a/extensions/bluebubbles/src/channel.ts b/extensions/bluebubbles/src/channel.ts
index 4d4b411a639..5719b12e22b 100644
--- a/extensions/bluebubbles/src/channel.ts
+++ b/extensions/bluebubbles/src/channel.ts
@@ -4,15 +4,15 @@ import {
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
+import {
+ createPairingPrefixStripper,
+ createTextPairingAdapter,
+} from "openclaw/plugin-sdk/channel-pairing";
import {
createOpenGroupPolicyRestrictSendersWarningCollector,
projectWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
-import {
- createAttachedChannelResultAdapter,
- createPairingPrefixStripper,
- createTextPairingAdapter,
-} from "openclaw/plugin-sdk/channel-runtime";
+import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import {
listBlueBubblesAccountIds,
diff --git a/extensions/bluebubbles/src/media-send.test.ts b/extensions/bluebubbles/src/media-send.test.ts
index 59fe82cbeae..ad1523c7863 100644
--- a/extensions/bluebubbles/src/media-send.test.ts
+++ b/extensions/bluebubbles/src/media-send.test.ts
@@ -2,9 +2,9 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
-import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { sendBlueBubblesMedia } from "./media-send.js";
+import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js";
import { setBlueBubblesRuntime } from "./runtime.js";
const sendBlueBubblesAttachmentMock = vi.hoisted(() => vi.fn());
diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts
index 17467465d82..5ff26e2dc96 100644
--- a/extensions/bluebubbles/src/monitor.test.ts
+++ b/extensions/bluebubbles/src/monitor.test.ts
@@ -1,6 +1,5 @@
import { EventEmitter } from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
-import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
@@ -12,6 +11,7 @@ import {
resolveBlueBubblesMessageId,
_resetBlueBubblesShortIdState,
} from "./monitor.js";
+import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js";
import { setBlueBubblesRuntime } from "./runtime.js";
// Mock dependencies
diff --git a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts
index 8d98b0c45eb..aacbb437841 100644
--- a/extensions/bluebubbles/src/monitor.webhook-auth.test.ts
+++ b/extensions/bluebubbles/src/monitor.webhook-auth.test.ts
@@ -1,6 +1,5 @@
import { EventEmitter } from "node:events";
import type { IncomingMessage, ServerResponse } from "node:http";
-import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createPluginRuntimeMock } from "../../../test/helpers/extensions/plugin-runtime-mock.js";
import type { ResolvedBlueBubblesAccount } from "./accounts.js";
@@ -11,6 +10,7 @@ import {
resolveBlueBubblesMessageId,
_resetBlueBubblesShortIdState,
} from "./monitor.js";
+import type { OpenClawConfig, PluginRuntime } from "./runtime-api.js";
import { setBlueBubblesRuntime } from "./runtime.js";
// Mock dependencies
diff --git a/extensions/bluebubbles/src/monitor.webhook-route.test.ts b/extensions/bluebubbles/src/monitor.webhook-route.test.ts
index fc48606b8ed..cb30d9edb01 100644
--- a/extensions/bluebubbles/src/monitor.webhook-route.test.ts
+++ b/extensions/bluebubbles/src/monitor.webhook-route.test.ts
@@ -1,9 +1,9 @@
-import type { OpenClawConfig } from "openclaw/plugin-sdk/bluebubbles";
import { afterEach, describe, expect, it } from "vitest";
import { createEmptyPluginRegistry } from "../../../src/plugins/registry.js";
import { setActivePluginRegistry } from "../../../src/plugins/runtime.js";
import type { WebhookTarget } from "./monitor-shared.js";
import { registerBlueBubblesWebhookTarget } from "./monitor.js";
+import type { OpenClawConfig } from "./runtime-api.js";
function createTarget(): WebhookTarget {
return {
diff --git a/extensions/bluebubbles/src/runtime-api.ts b/extensions/bluebubbles/src/runtime-api.ts
index 23c09660d96..4faebbed877 100644
--- a/extensions/bluebubbles/src/runtime-api.ts
+++ b/extensions/bluebubbles/src/runtime-api.ts
@@ -1 +1 @@
-export * from "openclaw/plugin-sdk/bluebubbles";
+export * from "../../../src/plugin-sdk/bluebubbles.js";
diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts
index ecb8b1f68e0..ff9935c84b3 100644
--- a/extensions/bluebubbles/src/send.test.ts
+++ b/extensions/bluebubbles/src/send.test.ts
@@ -1,7 +1,7 @@
-import type { PluginRuntime } from "openclaw/plugin-sdk/bluebubbles";
import { beforeEach, describe, expect, it, vi } from "vitest";
import "./test-mocks.js";
import { getCachedBlueBubblesPrivateApiStatus } from "./probe.js";
+import type { PluginRuntime } from "./runtime-api.js";
import { clearBlueBubblesRuntime, setBlueBubblesRuntime } from "./runtime.js";
import { sendMessageBlueBubbles, resolveChatGuidForTarget, createChatForHandle } from "./send.js";
import {
diff --git a/extensions/bluebubbles/src/targets.ts b/extensions/bluebubbles/src/targets.ts
index 605c5cecc76..833ac88522e 100644
--- a/extensions/bluebubbles/src/targets.ts
+++ b/extensions/bluebubbles/src/targets.ts
@@ -5,7 +5,7 @@ import {
type ParsedChatTarget,
resolveServicePrefixedAllowTarget,
resolveServicePrefixedTarget,
-} from "openclaw/plugin-sdk/imessage-core";
+} from "../../imessage/api.js";
export type BlueBubblesService = "imessage" | "sms" | "auto";
diff --git a/extensions/bluebubbles/src/test-harness.ts b/extensions/bluebubbles/src/test-harness.ts
index 5f7351b2e9f..9b52971be41 100644
--- a/extensions/bluebubbles/src/test-harness.ts
+++ b/extensions/bluebubbles/src/test-harness.ts
@@ -62,14 +62,16 @@ export function createBlueBubblesProbeMockModule(): BlueBubblesProbeMockModule {
export function installBlueBubblesFetchTestHooks(params: {
mockFetch: ReturnType;
privateApiStatusMock: {
- mockReset: () => unknown;
+ mockReset?: () => unknown;
+ mockClear?: () => unknown;
mockReturnValue: (value: boolean | null) => unknown;
};
}) {
beforeEach(() => {
vi.stubGlobal("fetch", params.mockFetch);
params.mockFetch.mockReset();
- params.privateApiStatusMock.mockReset();
+ params.privateApiStatusMock.mockReset?.();
+ params.privateApiStatusMock.mockClear?.();
params.privateApiStatusMock.mockReturnValue(BLUE_BUBBLES_PRIVATE_API_STATUS.unknown);
});
diff --git a/extensions/chutes/index.ts b/extensions/chutes/index.ts
index 89a2fc4a6fe..de70c603e23 100644
--- a/extensions/chutes/index.ts
+++ b/extensions/chutes/index.ts
@@ -5,8 +5,8 @@ import {
type ProviderAuthContext,
type ProviderAuthResult,
} from "openclaw/plugin-sdk/provider-auth";
+import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth";
import { loginChutes } from "openclaw/plugin-sdk/provider-auth-login";
-import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth";
import {
CHUTES_DEFAULT_MODEL_REF,
applyChutesApiKeyConfig,
diff --git a/extensions/discord/src/account-inspect.ts b/extensions/discord/src/account-inspect.ts
index 7e0a28ec7fd..994245461ed 100644
--- a/extensions/discord/src/account-inspect.ts
+++ b/extensions/discord/src/account-inspect.ts
@@ -2,7 +2,7 @@ import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/acco
import {
hasConfiguredSecretInput,
normalizeSecretInputString,
-} from "openclaw/plugin-sdk/config-runtime";
+} from "openclaw/plugin-sdk/secret-input";
import {
mergeDiscordAccountConfig,
resolveDefaultDiscordAccountId,
diff --git a/extensions/discord/src/actions/handle-action.guild-admin.ts b/extensions/discord/src/actions/handle-action.guild-admin.ts
index e63d00f23ec..fcb3cf530b6 100644
--- a/extensions/discord/src/actions/handle-action.guild-admin.ts
+++ b/extensions/discord/src/actions/handle-action.guild-admin.ts
@@ -5,7 +5,7 @@ import {
readStringArrayParam,
readStringParam,
} from "openclaw/plugin-sdk/agent-runtime";
-import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime";
+import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
import { handleDiscordAction } from "./runtime.js";
import {
isDiscordModerationAction,
diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts
index 9726b07cdda..e0f91daa668 100644
--- a/extensions/discord/src/actions/handle-action.ts
+++ b/extensions/discord/src/actions/handle-action.ts
@@ -5,8 +5,8 @@ import {
readStringParam,
} from "openclaw/plugin-sdk/agent-runtime";
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
-import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime";
-import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime";
+import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions";
+import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
import { normalizeInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
import { buildDiscordInteractiveComponents } from "../shared-interactive.js";
import { resolveDiscordChannelId } from "../targets.js";
diff --git a/extensions/discord/src/channel-actions.ts b/extensions/discord/src/channel-actions.ts
index 1c6b9b5c70f..51fb193b58e 100644
--- a/extensions/discord/src/channel-actions.ts
+++ b/extensions/discord/src/channel-actions.ts
@@ -1,12 +1,12 @@
import {
createUnionActionGate,
listTokenSourcedAccounts,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/channel-actions";
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
ChannelMessageToolDiscovery,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/channel-contract";
import type { DiscordActionConfig } from "openclaw/plugin-sdk/config-runtime";
import { createDiscordActionGate, listEnabledDiscordAccounts } from "./accounts.js";
import { handleDiscordMessageAction } from "./actions/handle-action.js";
diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts
index b5f2224b1dd..152223f12a9 100644
--- a/extensions/discord/src/channel.test.ts
+++ b/extensions/discord/src/channel.test.ts
@@ -1,13 +1,13 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
import type {
ChannelAccountSnapshot,
ChannelGatewayContext,
- OpenClawConfig,
- PluginRuntime,
-} from "openclaw/plugin-sdk/discord";
-import { afterEach, describe, expect, it, vi } from "vitest";
+} from "../../../src/channels/plugins/types.js";
+import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
import type { ResolvedDiscordAccount } from "./accounts.js";
import { discordPlugin } from "./channel.js";
+import type { OpenClawConfig } from "./runtime-api.js";
import { setDiscordRuntime } from "./runtime.js";
const probeDiscordMock = vi.hoisted(() => vi.fn());
diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts
index 0ddb5c9e19f..63f11ede836 100644
--- a/extensions/discord/src/channel.ts
+++ b/extensions/discord/src/channel.ts
@@ -5,20 +5,29 @@ import {
createNestedAllowlistOverrideResolver,
} from "openclaw/plugin-sdk/allowlist-config-edit";
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
-import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import {
- createAttachedChannelResultAdapter,
- createChannelDirectoryAdapter,
createPairingPrefixStripper,
- createTopLevelChannelReplyToModeResolver,
- createRuntimeDirectoryLiveAdapter,
createTextPairingAdapter,
- normalizeMessageChannel,
+} from "openclaw/plugin-sdk/channel-pairing";
+import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy";
+import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
+import { resolveTargetsWithOptionalToken } from "openclaw/plugin-sdk/channel-targets";
+import { createTopLevelChannelReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime";
+import {
+ createChannelDirectoryAdapter,
+ createRuntimeDirectoryLiveAdapter,
+} from "openclaw/plugin-sdk/directory-runtime";
+import {
+ createRuntimeOutboundDelegates,
resolveOutboundSendDep,
- resolveTargetsWithOptionalToken,
-} from "openclaw/plugin-sdk/channel-runtime";
-import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core";
-import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing";
+} from "openclaw/plugin-sdk/infra-runtime";
+import {
+ buildOutboundBaseSessionKey,
+ normalizeMessageChannel,
+ normalizeOutboundThreadId,
+ resolveThreadSessionKeys,
+ type RoutePeer,
+} from "openclaw/plugin-sdk/routing";
import {
listDiscordAccountIds,
resolveDiscordAccount,
diff --git a/extensions/discord/src/config-schema.ts b/extensions/discord/src/config-schema.ts
index a6866fc092d..6498c77a9fb 100644
--- a/extensions/discord/src/config-schema.ts
+++ b/extensions/discord/src/config-schema.ts
@@ -1,3 +1,3 @@
-import { buildChannelConfigSchema, DiscordConfigSchema } from "openclaw/plugin-sdk/discord-core";
+import { buildChannelConfigSchema, DiscordConfigSchema } from "./runtime-api.js";
export const DiscordChannelConfigSchema = buildChannelConfigSchema(DiscordConfigSchema);
diff --git a/extensions/discord/src/directory-live.ts b/extensions/discord/src/directory-live.ts
index 6bd38204a0a..67a8e908f7c 100644
--- a/extensions/discord/src/directory-live.ts
+++ b/extensions/discord/src/directory-live.ts
@@ -1,5 +1,7 @@
-import type { DirectoryConfigParams } from "openclaw/plugin-sdk/channel-runtime";
-import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/channel-runtime";
+import type {
+ ChannelDirectoryEntry,
+ DirectoryConfigParams,
+} from "openclaw/plugin-sdk/directory-runtime";
import { resolveDiscordAccount } from "./accounts.js";
import { fetchDiscord } from "./api.js";
import { rememberDiscordDirectoryUser } from "./directory-cache.js";
diff --git a/extensions/discord/src/draft-stream.ts b/extensions/discord/src/draft-stream.ts
index a12348334bc..ab49b13fbc3 100644
--- a/extensions/discord/src/draft-stream.ts
+++ b/extensions/discord/src/draft-stream.ts
@@ -1,6 +1,6 @@
import type { RequestClient } from "@buape/carbon";
import { Routes } from "discord-api-types/v10";
-import { createFinalizableDraftLifecycle } from "openclaw/plugin-sdk/channel-runtime";
+import { createFinalizableDraftLifecycle } from "openclaw/plugin-sdk/channel-lifecycle";
/** Discord messages cap at 2000 characters. */
const DISCORD_STREAM_MAX_CHARS = 2000;
diff --git a/extensions/discord/src/group-policy.ts b/extensions/discord/src/group-policy.ts
index a5a8ebac5eb..9394e319818 100644
--- a/extensions/discord/src/group-policy.ts
+++ b/extensions/discord/src/group-policy.ts
@@ -1,9 +1,9 @@
+import type { ChannelGroupContext } from "openclaw/plugin-sdk/channel-contract";
import {
resolveToolsBySender,
type GroupToolPolicyBySenderConfig,
type GroupToolPolicyConfig,
} from "openclaw/plugin-sdk/channel-policy";
-import { type ChannelGroupContext } from "openclaw/plugin-sdk/channel-runtime";
import { normalizeAtHashSlug } from "openclaw/plugin-sdk/core";
import type { DiscordConfig } from "./runtime-api.js";
diff --git a/extensions/discord/src/monitor/agent-components-helpers.ts b/extensions/discord/src/monitor/agent-components-helpers.ts
index eecbe73c351..b7c247d1f07 100644
--- a/extensions/discord/src/monitor/agent-components-helpers.ts
+++ b/extensions/discord/src/monitor/agent-components-helpers.ts
@@ -11,7 +11,7 @@ import {
import type { APIStringSelectComponent } from "discord-api-types/v10";
import { ChannelType } from "discord-api-types/v10";
import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
-import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime";
+import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/command-auth";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
diff --git a/extensions/discord/src/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts
index 0fa42d0e23c..429b575b140 100644
--- a/extensions/discord/src/monitor/agent-components.ts
+++ b/extensions/discord/src/monitor/agent-components.ts
@@ -19,8 +19,11 @@ import {
import type { APIStringSelectComponent } from "discord-api-types/v10";
import { ButtonStyle, ChannelType } from "discord-api-types/v10";
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
+import {
+ formatInboundEnvelope,
+ resolveEnvelopeFormatOptions,
+} from "openclaw/plugin-sdk/channel-inbound";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
-import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
@@ -31,6 +34,7 @@ import {
parsePluginBindingApprovalCustomId,
resolvePluginConversationBindingApproval,
} from "openclaw/plugin-sdk/conversation-runtime";
+import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import {
@@ -38,10 +42,6 @@ import {
type PluginInteractiveDiscordHandlerContext,
} from "openclaw/plugin-sdk/plugin-runtime";
import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
-import {
- formatInboundEnvelope,
- resolveEnvelopeFormatOptions,
-} from "openclaw/plugin-sdk/reply-runtime";
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime";
import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime";
import { createReplyReferencePlanner } from "openclaw/plugin-sdk/reply-runtime";
diff --git a/extensions/discord/src/monitor/allow-list.ts b/extensions/discord/src/monitor/allow-list.ts
index 31d95f2f45b..37508b9a092 100644
--- a/extensions/discord/src/monitor/allow-list.ts
+++ b/extensions/discord/src/monitor/allow-list.ts
@@ -1,11 +1,11 @@
import type { Guild, User } from "@buape/carbon";
-import type { AllowlistMatch } from "openclaw/plugin-sdk/channel-runtime";
+import type { AllowlistMatch } from "openclaw/plugin-sdk/allow-from";
import {
buildChannelKeyCandidates,
resolveChannelEntryMatchWithFallback,
resolveChannelMatchConfig,
type ChannelMatchSource,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/channel-targets";
import { evaluateGroupRouteAccessForPolicy } from "openclaw/plugin-sdk/group-access";
import { formatDiscordUserTag } from "./format.js";
diff --git a/extensions/discord/src/monitor/dm-command-auth.ts b/extensions/discord/src/monitor/dm-command-auth.ts
index 1e8f1afbb4b..f668545f733 100644
--- a/extensions/discord/src/monitor/dm-command-auth.ts
+++ b/extensions/discord/src/monitor/dm-command-auth.ts
@@ -1,4 +1,4 @@
-import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime";
+import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/command-auth";
import {
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
diff --git a/extensions/discord/src/monitor/exec-approvals.ts b/extensions/discord/src/monitor/exec-approvals.ts
index 607d5088ad1..c30d0c082e9 100644
--- a/extensions/discord/src/monitor/exec-approvals.ts
+++ b/extensions/discord/src/monitor/exec-approvals.ts
@@ -10,7 +10,6 @@ import {
type TopLevelComponents,
} from "@buape/carbon";
import { ButtonStyle, Routes } from "discord-api-types/v10";
-import { normalizeMessageChannel } from "openclaw/plugin-sdk/channel-runtime";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
import type { DiscordExecApprovalConfig } from "openclaw/plugin-sdk/config-runtime";
@@ -24,7 +23,11 @@ import type {
ExecApprovalRequest,
ExecApprovalResolved,
} from "openclaw/plugin-sdk/infra-runtime";
-import { normalizeAccountId, resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/routing";
+import {
+ normalizeAccountId,
+ normalizeMessageChannel,
+ resolveAgentIdFromSessionKey,
+} from "openclaw/plugin-sdk/routing";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { compileSafeRegex, testRegexWithBoundedInput } from "openclaw/plugin-sdk/security-runtime";
import { logDebug, logError } from "openclaw/plugin-sdk/text-runtime";
diff --git a/extensions/discord/src/monitor/inbound-worker.ts b/extensions/discord/src/monitor/inbound-worker.ts
index 33986e458a3..c00b7dc1c1d 100644
--- a/extensions/discord/src/monitor/inbound-worker.ts
+++ b/extensions/discord/src/monitor/inbound-worker.ts
@@ -1,4 +1,4 @@
-import { createRunStateMachine } from "openclaw/plugin-sdk/channel-runtime";
+import { createRunStateMachine } from "openclaw/plugin-sdk/channel-lifecycle";
import { formatDurationSeconds } from "openclaw/plugin-sdk/infra-runtime";
import { KeyedAsyncQueue } from "openclaw/plugin-sdk/keyed-async-queue";
import { danger } from "openclaw/plugin-sdk/runtime-env";
diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts
index 9094cabb645..55822830cd5 100644
--- a/extensions/discord/src/monitor/message-handler.preflight.ts
+++ b/extensions/discord/src/monitor/message-handler.preflight.ts
@@ -1,9 +1,15 @@
import { ChannelType, MessageType, type Message, type User } from "@buape/carbon";
import { Routes, type APIMessage } from "discord-api-types/v10";
-import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/channel-runtime";
-import { resolveControlCommandGate } from "openclaw/plugin-sdk/channel-runtime";
-import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime";
-import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-runtime";
+import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from";
+import {
+ buildMentionRegexes,
+ logInboundDrop,
+ matchesMentionWithExplicit,
+ resolveMentionGatingWithBypass,
+} from "openclaw/plugin-sdk/channel-inbound";
+import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
+import { hasControlCommand } from "openclaw/plugin-sdk/command-auth";
+import { shouldHandleTextCommands } from "openclaw/plugin-sdk/command-auth";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
import {
@@ -18,13 +24,10 @@ import { buildPairingReply } from "openclaw/plugin-sdk/conversation-runtime";
import { isPluginOwnedSessionBindingRecord } from "openclaw/plugin-sdk/conversation-runtime";
import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
-import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime";
-import { shouldHandleTextCommands } from "openclaw/plugin-sdk/reply-runtime";
import {
recordPendingHistoryEntryIfEnabled,
type HistoryEntry,
-} from "openclaw/plugin-sdk/reply-runtime";
-import { buildMentionRegexes, matchesMentionWithExplicit } from "openclaw/plugin-sdk/reply-runtime";
+} from "openclaw/plugin-sdk/reply-history";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { getChildLogger } from "openclaw/plugin-sdk/runtime-env";
diff --git a/extensions/discord/src/monitor/message-handler.preflight.types.ts b/extensions/discord/src/monitor/message-handler.preflight.types.ts
index 368352e1551..575d8ee165b 100644
--- a/extensions/discord/src/monitor/message-handler.preflight.types.ts
+++ b/extensions/discord/src/monitor/message-handler.preflight.types.ts
@@ -1,7 +1,7 @@
import type { ChannelType, Client, User } from "@buape/carbon";
import type { ReplyToMode } from "openclaw/plugin-sdk/config-runtime";
import type { SessionBindingRecord } from "openclaw/plugin-sdk/conversation-runtime";
-import type { HistoryEntry } from "openclaw/plugin-sdk/reply-runtime";
+import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import type { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import type { DiscordChannelConfigResolved, DiscordGuildEntryResolved } from "./allow-list.js";
import type { DiscordChannelInfo } from "./message-utils.js";
diff --git a/extensions/discord/src/monitor/message-handler.process.ts b/extensions/discord/src/monitor/message-handler.process.ts
index 42f2011d62a..b381013349e 100644
--- a/extensions/discord/src/monitor/message-handler.process.ts
+++ b/extensions/discord/src/monitor/message-handler.process.ts
@@ -1,31 +1,32 @@
import { ChannelType, type RequestClient } from "@buape/carbon";
import { resolveAckReaction, resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import { EmbeddedBlockChunker } from "openclaw/plugin-sdk/agent-runtime";
-import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
-import { shouldAckReaction as shouldAckReactionGate } from "openclaw/plugin-sdk/channel-runtime";
-import { logTypingFailure, logAckFailure } from "openclaw/plugin-sdk/channel-runtime";
-import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime";
import {
createStatusReactionController,
DEFAULT_TIMING,
+ logAckFailure,
+ logTypingFailure,
+ shouldAckReaction as shouldAckReactionGate,
type StatusReactionAdapter,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/channel-feedback";
+import {
+ formatInboundEnvelope,
+ resolveEnvelopeFormatOptions,
+} from "openclaw/plugin-sdk/channel-inbound";
+import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
import { resolveDiscordPreviewStreamMode } from "openclaw/plugin-sdk/config-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
+import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
-import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
-import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime";
-import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
-import {
- formatInboundEnvelope,
- resolveEnvelopeFormatOptions,
-} from "openclaw/plugin-sdk/reply-runtime";
import {
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
-} from "openclaw/plugin-sdk/reply-runtime";
+} from "openclaw/plugin-sdk/reply-history";
+import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
+import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime";
+import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime";
import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
diff --git a/extensions/discord/src/monitor/message-handler.ts b/extensions/discord/src/monitor/message-handler.ts
index 400f35a2529..e17dcc906af 100644
--- a/extensions/discord/src/monitor/message-handler.ts
+++ b/extensions/discord/src/monitor/message-handler.ts
@@ -2,7 +2,7 @@ import type { Client } from "@buape/carbon";
import {
createChannelInboundDebouncer,
shouldDebounceTextInbound,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/channel-inbound";
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import { buildDiscordInboundJob } from "./inbound-job.js";
diff --git a/extensions/discord/src/monitor/message-utils.ts b/extensions/discord/src/monitor/message-utils.ts
index 4e84f4b3827..e0eb58c9266 100644
--- a/extensions/discord/src/monitor/message-utils.ts
+++ b/extensions/discord/src/monitor/message-utils.ts
@@ -1,9 +1,9 @@
import type { ChannelType, Client, Message } from "@buape/carbon";
import { StickerFormatType, type APIAttachment, type APIStickerItem } from "discord-api-types/v10";
-import { buildMediaPayload } from "openclaw/plugin-sdk/channel-runtime";
import type { SsrFPolicy } from "openclaw/plugin-sdk/infra-runtime";
import { fetchRemoteMedia, type FetchLike } from "openclaw/plugin-sdk/media-runtime";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
+import { buildMediaPayload } from "openclaw/plugin-sdk/reply-payload";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
const DISCORD_CDN_HOSTNAMES = [
diff --git a/extensions/discord/src/monitor/model-picker.test-utils.ts b/extensions/discord/src/monitor/model-picker.test-utils.ts
index 56dcd7480c1..60b1c41e8ba 100644
--- a/extensions/discord/src/monitor/model-picker.test-utils.ts
+++ b/extensions/discord/src/monitor/model-picker.test-utils.ts
@@ -1,4 +1,4 @@
-import type { ModelsProviderData } from "openclaw/plugin-sdk/reply-runtime";
+import type { ModelsProviderData } from "openclaw/plugin-sdk/command-auth";
export function createModelsProviderData(
entries: Record,
diff --git a/extensions/discord/src/monitor/model-picker.ts b/extensions/discord/src/monitor/model-picker.ts
index ec067ede2dd..47313af5801 100644
--- a/extensions/discord/src/monitor/model-picker.ts
+++ b/extensions/discord/src/monitor/model-picker.ts
@@ -12,11 +12,8 @@ import {
import type { APISelectMenuOption } from "discord-api-types/v10";
import { ButtonStyle } from "discord-api-types/v10";
import { normalizeProviderId } from "openclaw/plugin-sdk/agent-runtime";
+import { buildModelsProviderData, type ModelsProviderData } from "openclaw/plugin-sdk/command-auth";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
-import {
- buildModelsProviderData,
- type ModelsProviderData,
-} from "openclaw/plugin-sdk/reply-runtime";
export const DISCORD_MODEL_PICKER_CUSTOM_ID_KEY = "mdlpk";
export const DISCORD_CUSTOM_ID_MAX_CHARS = 100;
diff --git a/extensions/discord/src/monitor/monitor.test.ts b/extensions/discord/src/monitor/monitor.test.ts
index 158336d2435..27e129b0bee 100644
--- a/extensions/discord/src/monitor/monitor.test.ts
+++ b/extensions/discord/src/monitor/monitor.test.ts
@@ -117,8 +117,8 @@ vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", async (import
};
});
-vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => {
- const actual = await importOriginal();
+vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
+ const actual = await importOriginal();
return {
...actual,
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
diff --git a/extensions/discord/src/monitor/native-command-context.ts b/extensions/discord/src/monitor/native-command-context.ts
index 07dc0bf0a76..81b97bede15 100644
--- a/extensions/discord/src/monitor/native-command-context.ts
+++ b/extensions/discord/src/monitor/native-command-context.ts
@@ -1,4 +1,4 @@
-import type { CommandArgs } from "openclaw/plugin-sdk/reply-runtime";
+import type { CommandArgs } from "openclaw/plugin-sdk/command-auth";
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime";
import { type DiscordChannelConfigResolved, type DiscordGuildEntryResolved } from "./allow-list.js";
import { buildDiscordInboundAccessContext } from "./inbound-context.js";
diff --git a/extensions/discord/src/monitor/native-command-ui.ts b/extensions/discord/src/monitor/native-command-ui.ts
index 5c31e81ed8f..314c31f11bf 100644
--- a/extensions/discord/src/monitor/native-command-ui.ts
+++ b/extensions/discord/src/monitor/native-command-ui.ts
@@ -11,22 +11,20 @@ import {
type StringSelectMenuInteraction,
} from "@buape/carbon";
import { ButtonStyle } from "discord-api-types/v10";
-import type { OpenClawConfig, loadConfig } from "openclaw/plugin-sdk/config-runtime";
-import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
import {
buildCommandTextFromArgs,
findCommandByNativeName,
listChatCommands,
resolveCommandArgChoices,
+ resolveStoredModelOverride,
serializeCommandArgs,
-} from "openclaw/plugin-sdk/reply-runtime";
-import { resolveStoredModelOverride } from "openclaw/plugin-sdk/reply-runtime";
-import type {
- ChatCommandDefinition,
- CommandArgDefinition,
- CommandArgValues,
- CommandArgs,
-} from "openclaw/plugin-sdk/reply-runtime";
+ type ChatCommandDefinition,
+ type CommandArgDefinition,
+ type CommandArgValues,
+ type CommandArgs,
+} from "openclaw/plugin-sdk/command-auth";
+import type { OpenClawConfig, loadConfig } from "openclaw/plugin-sdk/config-runtime";
+import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { chunkItems, withTimeout } from "openclaw/plugin-sdk/text-runtime";
diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts
index 315e87b7e6f..d00fab280f0 100644
--- a/extensions/discord/src/monitor/native-command.ts
+++ b/extensions/discord/src/monitor/native-command.ts
@@ -13,8 +13,24 @@ import {
import { ApplicationCommandOptionType } from "discord-api-types/v10";
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
-import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime";
-import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/channel-runtime";
+import {
+ resolveCommandAuthorizedFromAuthorizers,
+ resolveNativeCommandSessionTargets,
+} from "openclaw/plugin-sdk/command-auth";
+import {
+ buildCommandTextFromArgs,
+ findCommandByNativeName,
+ listChatCommands,
+ parseCommandArgs,
+ resolveCommandArgChoices,
+ resolveCommandArgMenu,
+ serializeCommandArgs,
+ type ChatCommandDefinition,
+ type CommandArgDefinition,
+ type CommandArgValues,
+ type CommandArgs,
+ type NativeCommandSpec,
+} from "openclaw/plugin-sdk/command-auth";
import type { OpenClawConfig, loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/config-runtime";
@@ -30,22 +46,6 @@ import {
resolveTextChunksWithFallback,
} from "openclaw/plugin-sdk/reply-payload";
import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
-import type {
- ChatCommandDefinition,
- CommandArgDefinition,
- CommandArgValues,
- CommandArgs,
- NativeCommandSpec,
-} from "openclaw/plugin-sdk/reply-runtime";
-import {
- buildCommandTextFromArgs,
- findCommandByNativeName,
- listChatCommands,
- parseCommandArgs,
- resolveCommandArgChoices,
- resolveCommandArgMenu,
- serializeCommandArgs,
-} from "openclaw/plugin-sdk/reply-runtime";
import { dispatchReplyWithDispatcher } from "openclaw/plugin-sdk/reply-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
diff --git a/extensions/discord/src/monitor/provider.allowlist.ts b/extensions/discord/src/monitor/provider.allowlist.ts
index ac6c89dd9f8..8cd945da823 100644
--- a/extensions/discord/src/monitor/provider.allowlist.ts
+++ b/extensions/discord/src/monitor/provider.allowlist.ts
@@ -4,7 +4,7 @@ import {
canonicalizeAllowlistWithResolvedIds,
patchAllowlistUsersInConfigEntries,
summarizeMapping,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/allow-from";
import type { DiscordGuildEntry } from "openclaw/plugin-sdk/config-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
diff --git a/extensions/discord/src/monitor/provider.lifecycle.ts b/extensions/discord/src/monitor/provider.lifecycle.ts
index b2a9e8a6019..884a0bded57 100644
--- a/extensions/discord/src/monitor/provider.lifecycle.ts
+++ b/extensions/discord/src/monitor/provider.lifecycle.ts
@@ -1,6 +1,6 @@
import type { Client } from "@buape/carbon";
import type { GatewayPlugin } from "@buape/carbon/gateway";
-import { createArmableStallWatchdog } from "openclaw/plugin-sdk/channel-runtime";
+import { createArmableStallWatchdog } from "openclaw/plugin-sdk/channel-lifecycle";
import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime";
import { danger } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts
index 8dbb6df29f5..8388438f37d 100644
--- a/extensions/discord/src/monitor/provider.ts
+++ b/extensions/discord/src/monitor/provider.ts
@@ -14,10 +14,10 @@ import { Routes } from "discord-api-types/v10";
import { getAcpSessionManager } from "openclaw/plugin-sdk/acp-runtime";
import { isAcpRuntimeError } from "openclaw/plugin-sdk/acp-runtime";
import {
- resolveThreadBindingIdleTimeoutMs,
- resolveThreadBindingMaxAgeMs,
- resolveThreadBindingsEnabled,
-} from "openclaw/plugin-sdk/channel-runtime";
+ listNativeCommandSpecsForConfig,
+ listSkillCommandsForAgents,
+ type NativeCommandSpec,
+} from "openclaw/plugin-sdk/command-auth";
import {
isNativeCommandsExplicitlyDisabled,
resolveNativeCommandsEnabled,
@@ -32,14 +32,16 @@ import {
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/config-runtime";
+import {
+ resolveThreadBindingIdleTimeoutMs,
+ resolveThreadBindingMaxAgeMs,
+ resolveThreadBindingsEnabled,
+} from "openclaw/plugin-sdk/conversation-runtime";
import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime";
import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime";
import { getPluginCommandSpecs } from "openclaw/plugin-sdk/plugin-runtime";
+import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
-import type { NativeCommandSpec } from "openclaw/plugin-sdk/reply-runtime";
-import { listNativeCommandSpecsForConfig } from "openclaw/plugin-sdk/reply-runtime";
-import type { HistoryEntry } from "openclaw/plugin-sdk/reply-runtime";
-import { listSkillCommandsForAgents } from "openclaw/plugin-sdk/reply-runtime";
import {
danger,
isVerbose,
@@ -90,6 +92,7 @@ import { resolveDiscordPresenceUpdate } from "./presence.js";
import { resolveDiscordAllowlistConfig } from "./provider.allowlist.js";
import { runDiscordGatewayLifecycle } from "./provider.lifecycle.js";
import { resolveDiscordRestFetch } from "./rest-fetch.js";
+import { formatDiscordStartupStatusMessage } from "./startup-status.js";
import type { DiscordMonitorStatusSink } from "./status.js";
import {
createNoopThreadBindingManager,
@@ -970,7 +973,12 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
const botIdentity =
botUserId && botUserName ? `${botUserId} (${botUserName})` : (botUserId ?? botUserName ?? "");
- runtime.log?.(`logged in to discord${botIdentity ? ` as ${botIdentity}` : ""}`);
+ runtime.log?.(
+ formatDiscordStartupStatusMessage({
+ gatewayReady: lifecycleGateway?.isConnected === true,
+ botIdentity: botIdentity || undefined,
+ }),
+ );
if (lifecycleGateway?.isConnected) {
opts.setStatus?.(createConnectedChannelStatusPatch());
}
diff --git a/extensions/discord/src/monitor/startup-status.test.ts b/extensions/discord/src/monitor/startup-status.test.ts
new file mode 100644
index 00000000000..47cc84202d6
--- /dev/null
+++ b/extensions/discord/src/monitor/startup-status.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest";
+import { formatDiscordStartupStatusMessage } from "./startup-status.js";
+
+describe("formatDiscordStartupStatusMessage", () => {
+ it("reports logged-in status only after the gateway is ready", () => {
+ expect(
+ formatDiscordStartupStatusMessage({
+ gatewayReady: true,
+ botIdentity: "bot-1 (Molty)",
+ }),
+ ).toBe("logged in to discord as bot-1 (Molty)");
+ });
+
+ it("reports client initialization while gateway readiness is still pending", () => {
+ expect(
+ formatDiscordStartupStatusMessage({
+ gatewayReady: false,
+ botIdentity: "bot-1 (Molty)",
+ }),
+ ).toBe("discord client initialized as bot-1 (Molty); awaiting gateway readiness");
+ });
+
+ it("handles missing identity without awkward punctuation", () => {
+ expect(
+ formatDiscordStartupStatusMessage({
+ gatewayReady: false,
+ }),
+ ).toBe("discord client initialized; awaiting gateway readiness");
+ });
+});
diff --git a/extensions/discord/src/monitor/startup-status.ts b/extensions/discord/src/monitor/startup-status.ts
new file mode 100644
index 00000000000..94f311912b8
--- /dev/null
+++ b/extensions/discord/src/monitor/startup-status.ts
@@ -0,0 +1,10 @@
+export function formatDiscordStartupStatusMessage(params: {
+ gatewayReady: boolean;
+ botIdentity?: string;
+}): string {
+ const identitySuffix = params.botIdentity ? ` as ${params.botIdentity}` : "";
+ if (params.gatewayReady) {
+ return `logged in to discord${identitySuffix}`;
+ }
+ return `discord client initialized${identitySuffix}; awaiting gateway readiness`;
+}
diff --git a/extensions/discord/src/monitor/thread-bindings.config.ts b/extensions/discord/src/monitor/thread-bindings.config.ts
index 701defcfbe1..a6520c5e868 100644
--- a/extensions/discord/src/monitor/thread-bindings.config.ts
+++ b/extensions/discord/src/monitor/thread-bindings.config.ts
@@ -1,9 +1,9 @@
+import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
resolveThreadBindingIdleTimeoutMs,
resolveThreadBindingMaxAgeMs,
resolveThreadBindingsEnabled,
-} from "openclaw/plugin-sdk/channel-runtime";
-import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
+} from "openclaw/plugin-sdk/conversation-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
export {
diff --git a/extensions/discord/src/monitor/thread-bindings.manager.ts b/extensions/discord/src/monitor/thread-bindings.manager.ts
index 5c37ac4bbf0..0fa8f09aac0 100644
--- a/extensions/discord/src/monitor/thread-bindings.manager.ts
+++ b/extensions/discord/src/monitor/thread-bindings.manager.ts
@@ -1,8 +1,8 @@
import { Routes } from "discord-api-types/v10";
-import { resolveThreadBindingConversationIdFromBindingId } from "openclaw/plugin-sdk/channel-runtime";
import { getRuntimeConfigSnapshot, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
registerSessionBindingAdapter,
+ resolveThreadBindingConversationIdFromBindingId,
unregisterSessionBindingAdapter,
type BindingTargetKind,
type SessionBindingRecord,
diff --git a/extensions/discord/src/monitor/thread-bindings.messages.ts b/extensions/discord/src/monitor/thread-bindings.messages.ts
index 043e888b7fc..1e0a1f3cbb2 100644
--- a/extensions/discord/src/monitor/thread-bindings.messages.ts
+++ b/extensions/discord/src/monitor/thread-bindings.messages.ts
@@ -1,6 +1,6 @@
export {
- formatThreadBindingDurationLabel,
resolveThreadBindingFarewellText,
resolveThreadBindingIntroText,
resolveThreadBindingThreadName,
-} from "openclaw/plugin-sdk/channel-runtime";
+ formatThreadBindingDurationLabel,
+} from "openclaw/plugin-sdk/conversation-runtime";
diff --git a/extensions/discord/src/outbound-adapter.ts b/extensions/discord/src/outbound-adapter.ts
index 8b18fffec90..471cf841aa8 100644
--- a/extensions/discord/src/outbound-adapter.ts
+++ b/extensions/discord/src/outbound-adapter.ts
@@ -1,16 +1,15 @@
+import {
+ attachChannelToResult,
+ type ChannelOutboundAdapter,
+ createAttachedChannelResultAdapter,
+} from "openclaw/plugin-sdk/channel-send-result";
+import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
+import { resolveOutboundSendDep, type OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime";
import {
resolvePayloadMediaUrls,
sendPayloadMediaSequenceOrFallback,
sendTextMediaPayload,
-} from "openclaw/plugin-sdk/channel-runtime";
-import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime";
-import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
-import {
- attachChannelToResult,
- createAttachedChannelResultAdapter,
-} from "openclaw/plugin-sdk/channel-send-result";
-import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
-import type { OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime";
+} from "openclaw/plugin-sdk/reply-payload";
import type { DiscordComponentMessageSpec } from "./components.js";
import { getThreadBindingManager, type ThreadBindingRecord } from "./monitor/thread-bindings.js";
import { normalizeDiscordOutboundTarget } from "./normalize.js";
diff --git a/extensions/discord/src/probe.ts b/extensions/discord/src/probe.ts
index f84b4aad10a..cdd662718eb 100644
--- a/extensions/discord/src/probe.ts
+++ b/extensions/discord/src/probe.ts
@@ -1,4 +1,4 @@
-import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-runtime";
+import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
import { resolveFetch } from "openclaw/plugin-sdk/infra-runtime";
import { fetchWithTimeout } from "openclaw/plugin-sdk/text-runtime";
import { normalizeDiscordToken } from "./token.js";
diff --git a/extensions/discord/src/runtime-api.ts b/extensions/discord/src/runtime-api.ts
index 0d355ab506f..7d9bc355184 100644
--- a/extensions/discord/src/runtime-api.ts
+++ b/extensions/discord/src/runtime-api.ts
@@ -4,7 +4,7 @@ export {
PAIRING_APPROVED_MESSAGE,
projectCredentialSnapshotFields,
resolveConfiguredFromCredentialStatuses,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "../../../src/plugin-sdk/discord.js";
export {
buildChannelConfigSchema,
getChatChannelMeta,
@@ -19,15 +19,15 @@ export {
type DiscordActionConfig,
type DiscordConfig,
type OpenClawConfig,
-} from "openclaw/plugin-sdk/discord-core";
-export { DiscordConfigSchema } from "openclaw/plugin-sdk/discord-core";
+} from "../../../src/plugin-sdk/discord-core.js";
+export { DiscordConfigSchema } from "../../../src/plugin-sdk/discord-core.js";
export { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
export {
assertMediaNotDataUrl,
parseAvailableTags,
readReactionParams,
withNormalizedTimestamp,
-} from "openclaw/plugin-sdk/discord-core";
+} from "../../../src/plugin-sdk/discord-core.js";
export {
createHybridChannelConfigAdapter,
createScopedChannelConfigAdapter,
@@ -44,9 +44,9 @@ export { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
export type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/channel-contract";
export {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
-} from "openclaw/plugin-sdk/config-runtime";
+} from "openclaw/plugin-sdk/secret-input";
diff --git a/extensions/discord/src/session-key-normalization.ts b/extensions/discord/src/session-key-normalization.ts
index 06164d6aba5..f63524428c0 100644
--- a/extensions/discord/src/session-key-normalization.ts
+++ b/extensions/discord/src/session-key-normalization.ts
@@ -1,4 +1,4 @@
-import { normalizeChatType } from "openclaw/plugin-sdk/channel-runtime";
+import { normalizeChatType } from "openclaw/plugin-sdk/account-resolution";
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
export function normalizeExplicitDiscordSessionKey(
diff --git a/extensions/discord/src/setup-account-state.ts b/extensions/discord/src/setup-account-state.ts
index 725e6e4037e..2adbcacb424 100644
--- a/extensions/discord/src/setup-account-state.ts
+++ b/extensions/discord/src/setup-account-state.ts
@@ -1,9 +1,9 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
+import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
hasConfiguredSecretInput,
normalizeSecretInputString,
- type OpenClawConfig,
-} from "openclaw/plugin-sdk/config-runtime";
+} from "openclaw/plugin-sdk/secret-input";
import type { DiscordAccountConfig } from "./runtime-api.js";
import { resolveDiscordToken } from "./token.js";
diff --git a/extensions/discord/src/status-issues.ts b/extensions/discord/src/status-issues.ts
index 4fa26fd011b..f095221483e 100644
--- a/extensions/discord/src/status-issues.ts
+++ b/extensions/discord/src/status-issues.ts
@@ -1,13 +1,13 @@
+import type {
+ ChannelAccountSnapshot,
+ ChannelStatusIssue,
+} from "openclaw/plugin-sdk/channel-contract";
import {
appendMatchMetadata,
asString,
isRecord,
resolveEnabledConfiguredAccountId,
-} from "openclaw/plugin-sdk/channel-runtime";
-import type {
- ChannelAccountSnapshot,
- ChannelStatusIssue,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/status-helpers";
type DiscordIntentSummary = {
messageContent?: "enabled" | "limited" | "disabled";
diff --git a/extensions/discord/src/subagent-hooks.test.ts b/extensions/discord/src/subagent-hooks.test.ts
index a05db63043a..927ae73b0d3 100644
--- a/extensions/discord/src/subagent-hooks.test.ts
+++ b/extensions/discord/src/subagent-hooks.test.ts
@@ -1,4 +1,4 @@
-import type { OpenClawPluginApi } from "openclaw/plugin-sdk/discord";
+import type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
getRequiredHookHandler,
diff --git a/extensions/discord/src/targets.ts b/extensions/discord/src/targets.ts
index 3660f75921e..cb04a96d914 100644
--- a/extensions/discord/src/targets.ts
+++ b/extensions/discord/src/targets.ts
@@ -1,4 +1,3 @@
-import type { DirectoryConfigParams } from "openclaw/plugin-sdk/channel-runtime";
import {
buildMessagingTarget,
parseMentionPrefixOrAtUserTarget,
@@ -6,7 +5,8 @@ import {
type MessagingTarget,
type MessagingTargetKind,
type MessagingTargetParseOptions,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/channel-targets";
+import type { DirectoryConfigParams } from "openclaw/plugin-sdk/directory-runtime";
import { rememberDiscordDirectoryUser } from "./directory-cache.js";
import { listDiscordDirectoryPeersLive } from "./directory-live.js";
diff --git a/extensions/discord/src/token.ts b/extensions/discord/src/token.ts
index 2a979ca4b3b..b9614e59794 100644
--- a/extensions/discord/src/token.ts
+++ b/extensions/discord/src/token.ts
@@ -1,7 +1,7 @@
-import type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-runtime";
+import type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
-import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
+import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
export type DiscordTokenSource = "env" | "config" | "none";
diff --git a/extensions/discord/src/voice/command.ts b/extensions/discord/src/voice/command.ts
index 3ed7aa2ccdb..0d9bf5124d6 100644
--- a/extensions/discord/src/voice/command.ts
+++ b/extensions/discord/src/voice/command.ts
@@ -10,7 +10,7 @@ import {
ChannelType as DiscordChannelType,
type APIApplicationCommandChannelOption,
} from "discord-api-types/v10";
-import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime";
+import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/command-auth";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
import type { DiscordAccountConfig } from "openclaw/plugin-sdk/config-runtime";
diff --git a/extensions/feishu/runtime-api.ts b/extensions/feishu/runtime-api.ts
index ece8df41cca..cde6bbf5569 100644
--- a/extensions/feishu/runtime-api.ts
+++ b/extensions/feishu/runtime-api.ts
@@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Feishu extension.
// Keep this barrel thin and aligned with the local extension surface.
-export * from "openclaw/plugin-sdk/feishu";
+export * from "../../src/plugin-sdk/feishu.js";
diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts
index 97fd5dd068d..4eac10cc0cd 100644
--- a/extensions/feishu/src/channel.ts
+++ b/extensions/feishu/src/channel.ts
@@ -1,21 +1,23 @@
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
+import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-actions";
import { createHybridChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
+import type {
+ ChannelMessageActionAdapter,
+ ChannelMessageToolDiscovery,
+} from "openclaw/plugin-sdk/channel-contract";
+import {
+ createPairingPrefixStripper,
+ createTextPairingAdapter,
+} from "openclaw/plugin-sdk/channel-pairing";
import {
createAllowlistProviderGroupPolicyWarningCollector,
projectWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import {
createChannelDirectoryAdapter,
- createMessageToolCardSchema,
- createPairingPrefixStripper,
createRuntimeDirectoryLiveAdapter,
- createRuntimeOutboundDelegates,
- createTextPairingAdapter,
-} from "openclaw/plugin-sdk/channel-runtime";
-import type {
- ChannelMessageActionAdapter,
- ChannelMessageToolDiscovery,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/directory-runtime";
+import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/infra-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import type { ChannelMeta, ChannelPlugin, ClawdbotConfig } from "../runtime-api.js";
import {
diff --git a/extensions/feishu/src/thread-bindings.ts b/extensions/feishu/src/thread-bindings.ts
index cfae8fb2058..842374155b3 100644
--- a/extensions/feishu/src/thread-bindings.ts
+++ b/extensions/feishu/src/thread-bindings.ts
@@ -1,11 +1,9 @@
-import { resolveThreadBindingConversationIdFromBindingId } from "openclaw/plugin-sdk/channel-runtime";
+import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
resolveThreadBindingIdleTimeoutMsForChannel,
resolveThreadBindingMaxAgeMsForChannel,
-} from "openclaw/plugin-sdk/channel-runtime";
-import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
-import {
registerSessionBindingAdapter,
+ resolveThreadBindingConversationIdFromBindingId,
unregisterSessionBindingAdapter,
type BindingTargetKind,
type SessionBindingRecord,
diff --git a/extensions/firecrawl/src/config.ts b/extensions/firecrawl/src/config.ts
index 3f2d6a82f8a..3c2c2f3c25d 100644
--- a/extensions/firecrawl/src/config.ts
+++ b/extensions/firecrawl/src/config.ts
@@ -1,6 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
-import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime";
import { normalizeSecretInput } from "openclaw/plugin-sdk/provider-auth";
+import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
export const DEFAULT_FIRECRAWL_BASE_URL = "https://api.firecrawl.dev";
export const DEFAULT_FIRECRAWL_SEARCH_TIMEOUT_SECONDS = 30;
diff --git a/extensions/google/gemini-cli-provider.ts b/extensions/google/gemini-cli-provider.ts
index 77fa7077b5d..412d02dd85f 100644
--- a/extensions/google/gemini-cli-provider.ts
+++ b/extensions/google/gemini-cli-provider.ts
@@ -3,7 +3,7 @@ import type {
ProviderAuthContext,
ProviderFetchUsageSnapshotContext,
} from "openclaw/plugin-sdk/plugin-entry";
-import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth";
+import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth";
import { fetchGeminiUsage } from "openclaw/plugin-sdk/provider-usage";
import { isModernGoogleModel, resolveGoogle31ForwardCompatModel } from "./provider-models.js";
diff --git a/extensions/googlechat/runtime-api.ts b/extensions/googlechat/runtime-api.ts
index df946f8ec4a..cd47c0e56c7 100644
--- a/extensions/googlechat/runtime-api.ts
+++ b/extensions/googlechat/runtime-api.ts
@@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Google Chat extension.
// Keep this barrel thin and aligned with the local extension surface.
-export * from "openclaw/plugin-sdk/googlechat";
+export * from "../../src/plugin-sdk/googlechat.js";
diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts
index fc4cf489928..e8917d13c04 100644
--- a/extensions/googlechat/src/channel.ts
+++ b/extensions/googlechat/src/channel.ts
@@ -3,19 +3,17 @@ import {
createScopedChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
+import { createTextPairingAdapter } from "openclaw/plugin-sdk/channel-pairing";
import {
composeWarningCollectors,
createAllowlistProviderGroupPolicyWarningCollector,
createConditionalWarningCollector,
createAllowlistProviderOpenWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
+import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
+import { createTopLevelChannelReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime";
import {
- createAttachedChannelResultAdapter,
createChannelDirectoryAdapter,
- createTopLevelChannelReplyToModeResolver,
- createTextPairingAdapter,
-} from "openclaw/plugin-sdk/channel-runtime";
-import {
listResolvedDirectoryGroupEntriesFromMapKeys,
listResolvedDirectoryUserEntriesFromAllowFrom,
} from "openclaw/plugin-sdk/directory-runtime";
diff --git a/extensions/imessage/runtime-api.ts b/extensions/imessage/runtime-api.ts
index aa6d55c75e5..22b1e4a21ba 100644
--- a/extensions/imessage/runtime-api.ts
+++ b/extensions/imessage/runtime-api.ts
@@ -13,7 +13,7 @@ export {
IMessageConfigSchema,
type ChannelPlugin,
type IMessageAccountConfig,
-} from "openclaw/plugin-sdk/imessage";
+} from "../../src/plugin-sdk/imessage.js";
export {
resolveIMessageGroupRequireMention,
resolveIMessageGroupToolPolicy,
diff --git a/extensions/imessage/src/channel.runtime.ts b/extensions/imessage/src/channel.runtime.ts
index 32cd39a1d64..5ee80d614d6 100644
--- a/extensions/imessage/src/channel.runtime.ts
+++ b/extensions/imessage/src/channel.runtime.ts
@@ -1,4 +1,4 @@
-import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
+import { resolveOutboundSendDep } from "openclaw/plugin-sdk/infra-runtime";
import { PAIRING_APPROVED_MESSAGE, resolveChannelMediaMaxBytes } from "../runtime-api.js";
import type { ResolvedIMessageAccount } from "./accounts.js";
import { monitorIMessageProvider } from "./monitor.js";
diff --git a/extensions/imessage/src/channel.ts b/extensions/imessage/src/channel.ts
index d084ee92a15..5257e32f349 100644
--- a/extensions/imessage/src/channel.ts
+++ b/extensions/imessage/src/channel.ts
@@ -1,12 +1,9 @@
import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit";
-import {
- createAttachedChannelResultAdapter,
- resolveOutboundSendDep,
-} from "openclaw/plugin-sdk/channel-runtime";
-import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core";
+import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
+import { resolveOutboundSendDep } from "openclaw/plugin-sdk/infra-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
-import { type RoutePeer } from "openclaw/plugin-sdk/routing";
+import { buildOutboundBaseSessionKey, type RoutePeer } from "openclaw/plugin-sdk/routing";
import {
collectStatusIssuesFromLastError,
DEFAULT_ACCOUNT_ID,
diff --git a/extensions/imessage/src/config-schema.ts b/extensions/imessage/src/config-schema.ts
index dc960ccdb0e..230c31ce089 100644
--- a/extensions/imessage/src/config-schema.ts
+++ b/extensions/imessage/src/config-schema.ts
@@ -1,3 +1,3 @@
-import { buildChannelConfigSchema, IMessageConfigSchema } from "openclaw/plugin-sdk/imessage-core";
+import { buildChannelConfigSchema, IMessageConfigSchema } from "../runtime-api.js";
export const IMessageChannelConfigSchema = buildChannelConfigSchema(IMessageConfigSchema);
diff --git a/extensions/imessage/src/monitor/inbound-processing.ts b/extensions/imessage/src/monitor/inbound-processing.ts
index 531a8324dfd..358ecf26f17 100644
--- a/extensions/imessage/src/monitor/inbound-processing.ts
+++ b/extensions/imessage/src/monitor/inbound-processing.ts
@@ -1,24 +1,25 @@
-import { resolveDualTextControlCommandGate } from "openclaw/plugin-sdk/channel-runtime";
-import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime";
+import {
+ buildMentionRegexes,
+ type EnvelopeFormatOptions,
+ formatInboundEnvelope,
+ formatInboundFromLabel,
+ logInboundDrop,
+ matchesMentionPatterns,
+ resolveEnvelopeFormatOptions,
+} from "openclaw/plugin-sdk/channel-inbound";
+import { hasControlCommand } from "openclaw/plugin-sdk/command-auth";
+import { resolveDualTextControlCommandGate } from "openclaw/plugin-sdk/command-auth";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
resolveChannelGroupPolicy,
resolveChannelGroupRequireMention,
} from "openclaw/plugin-sdk/config-runtime";
-import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime";
-import {
- formatInboundEnvelope,
- formatInboundFromLabel,
- resolveEnvelopeFormatOptions,
- type EnvelopeFormatOptions,
-} from "openclaw/plugin-sdk/reply-runtime";
import {
buildPendingHistoryContextFromMap,
recordPendingHistoryEntryIfEnabled,
type HistoryEntry,
-} from "openclaw/plugin-sdk/reply-runtime";
+} from "openclaw/plugin-sdk/reply-history";
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime";
-import { buildMentionRegexes, matchesMentionPatterns } from "openclaw/plugin-sdk/reply-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import {
DM_GROUP_ACCESS_REASON,
diff --git a/extensions/imessage/src/monitor/monitor-provider.ts b/extensions/imessage/src/monitor/monitor-provider.ts
index 651926616c6..f5524a12f85 100644
--- a/extensions/imessage/src/monitor/monitor-provider.ts
+++ b/extensions/imessage/src/monitor/monitor-provider.ts
@@ -1,12 +1,11 @@
import fs from "node:fs/promises";
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
-import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
-import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import {
createChannelInboundDebouncer,
shouldDebounceTextInbound,
-} from "openclaw/plugin-sdk/channel-runtime";
-import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/channel-inbound";
+import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
+import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import {
resolveOpenProviderRuntimeGroupPolicy,
@@ -18,6 +17,7 @@ import {
readChannelAllowFromStore,
upsertChannelPairingRequest,
} from "openclaw/plugin-sdk/conversation-runtime";
+import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import { normalizeScpRemoteHost } from "openclaw/plugin-sdk/infra-runtime";
import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime";
import {
@@ -26,13 +26,13 @@ import {
resolveIMessageRemoteAttachmentRoots,
} from "openclaw/plugin-sdk/media-runtime";
import { kindFromMime } from "openclaw/plugin-sdk/media-runtime";
-import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
-import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
import {
clearHistoryEntriesIfEnabled,
DEFAULT_GROUP_HISTORY_LIMIT,
type HistoryEntry,
-} from "openclaw/plugin-sdk/reply-runtime";
+} from "openclaw/plugin-sdk/reply-history";
+import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
+import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
import { createReplyDispatcher } from "openclaw/plugin-sdk/reply-runtime";
import { danger, logVerbose, shouldLogVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
import { resolvePinnedMainDmOwnerFromAllowlist } from "openclaw/plugin-sdk/security-runtime";
diff --git a/extensions/imessage/src/outbound-adapter.ts b/extensions/imessage/src/outbound-adapter.ts
index cd961c30bfa..0b023fa2b02 100644
--- a/extensions/imessage/src/outbound-adapter.ts
+++ b/extensions/imessage/src/outbound-adapter.ts
@@ -1,8 +1,8 @@
+import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/infra-runtime";
import {
- createScopedChannelMediaMaxBytesResolver,
createDirectTextMediaOutbound,
-} from "openclaw/plugin-sdk/channel-runtime";
-import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/channel-runtime";
+ createScopedChannelMediaMaxBytesResolver,
+} from "openclaw/plugin-sdk/media-runtime";
import { sendMessageIMessage } from "./send.js";
function resolveIMessageSender(deps: OutboundSendDeps | undefined) {
diff --git a/extensions/imessage/src/probe.ts b/extensions/imessage/src/probe.ts
index 7ae049f02eb..1609ec2f657 100644
--- a/extensions/imessage/src/probe.ts
+++ b/extensions/imessage/src/probe.ts
@@ -1,4 +1,4 @@
-import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-runtime";
+import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { runCommandWithTimeout } from "openclaw/plugin-sdk/process-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
diff --git a/extensions/irc/src/accounts.ts b/extensions/irc/src/accounts.ts
index 8c68eb5406e..71281cbcf4d 100644
--- a/extensions/irc/src/accounts.ts
+++ b/extensions/irc/src/accounts.ts
@@ -1,8 +1,8 @@
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
-import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime";
import { parseOptionalDelimitedEntries } from "openclaw/plugin-sdk/core";
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime";
+import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js";
const TRUTHY_ENV = new Set(["true", "1", "yes", "on"]);
diff --git a/extensions/irc/src/channel.ts b/extensions/irc/src/channel.ts
index 27571c92d35..69fdc07a79f 100644
--- a/extensions/irc/src/channel.ts
+++ b/extensions/irc/src/channel.ts
@@ -3,17 +3,17 @@ import {
createScopedChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
+import { createTextPairingAdapter } from "openclaw/plugin-sdk/channel-pairing";
import {
composeWarningCollectors,
createAllowlistProviderOpenWarningCollector,
createConditionalWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
+import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
import {
- createAttachedChannelResultAdapter,
createChannelDirectoryAdapter,
- createTextPairingAdapter,
listResolvedDirectoryEntriesFromSources,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/directory-runtime";
import { runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared";
import {
listIrcAccountIds,
diff --git a/extensions/irc/src/runtime-api.ts b/extensions/irc/src/runtime-api.ts
index 40f35e1ad53..96e4bdbbe90 100644
--- a/extensions/irc/src/runtime-api.ts
+++ b/extensions/irc/src/runtime-api.ts
@@ -1,4 +1,4 @@
// Private runtime barrel for the bundled IRC extension.
// Keep this barrel thin and aligned with the local extension surface.
-export * from "openclaw/plugin-sdk/irc";
+export * from "../../../src/plugin-sdk/irc.js";
diff --git a/extensions/irc/src/setup-core.ts b/extensions/irc/src/setup-core.ts
index 8e3a347e35a..f2e83e9838f 100644
--- a/extensions/irc/src/setup-core.ts
+++ b/extensions/irc/src/setup-core.ts
@@ -1,5 +1,4 @@
-import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-runtime";
-import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-runtime";
+import type { ChannelSetupAdapter, ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup";
import type { DmPolicy } from "openclaw/plugin-sdk/config-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import {
diff --git a/extensions/line/api.ts b/extensions/line/api.ts
index 35d637bcc56..3fd34872f05 100644
--- a/extensions/line/api.ts
+++ b/extensions/line/api.ts
@@ -1,2 +1,41 @@
+export type {
+ ChannelPlugin,
+ OpenClawConfig,
+ OpenClawPluginApi,
+ PluginRuntime,
+} from "openclaw/plugin-sdk/core";
+export { buildChannelConfigSchema, clearAccountEntryFields } from "openclaw/plugin-sdk/core";
+export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
+export type { ChannelAccountSnapshot, ChannelGatewayContext } from "openclaw/plugin-sdk/testing";
+export type { ChannelStatusIssue } from "openclaw/plugin-sdk/channel-contract";
+export {
+ buildComputedAccountStatusSnapshot,
+ buildTokenChannelStatusSummary,
+} from "openclaw/plugin-sdk/status-helpers";
+export type {
+ CardAction,
+ LineChannelData,
+ LineConfig,
+ ListItem,
+ ResolvedLineAccount,
+} from "./runtime-api.js";
+export {
+ createActionCard,
+ createImageCard,
+ createInfoCard,
+ createListCard,
+ createReceiptCard,
+ DEFAULT_ACCOUNT_ID,
+ formatDocsLink,
+ LineConfigSchema,
+ listLineAccountIds,
+ normalizeAccountId,
+ processLineMessage,
+ resolveDefaultLineAccountId,
+ resolveExactLineGroupConfigKey,
+ resolveLineAccount,
+ setSetupChannelEnabled,
+ splitSetupEntries,
+} from "./runtime-api.js";
export * from "./runtime-api.js";
export * from "./setup-api.js";
diff --git a/extensions/line/runtime-api.ts b/extensions/line/runtime-api.ts
index b40e5c76e0e..675c11a7467 100644
--- a/extensions/line/runtime-api.ts
+++ b/extensions/line/runtime-api.ts
@@ -1,12 +1,13 @@
// Private runtime barrel for the bundled LINE extension.
// Keep this barrel thin and aligned with the local extension surface.
-export * from "openclaw/plugin-sdk/line";
-export { resolveExactLineGroupConfigKey } from "openclaw/plugin-sdk/line-core";
+export * from "../../src/plugin-sdk/line.js";
export {
+ DEFAULT_ACCOUNT_ID,
formatDocsLink,
+ resolveExactLineGroupConfigKey,
setSetupChannelEnabled,
splitSetupEntries,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
-} from "openclaw/plugin-sdk/line-core";
+} from "../../src/plugin-sdk/line-core.js";
diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts
index 54cd54ff7bf..fd81a4c8f8a 100644
--- a/extensions/line/src/channel.ts
+++ b/extensions/line/src/channel.ts
@@ -1,12 +1,14 @@
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
+import {
+ createPairingPrefixStripper,
+ createTextPairingAdapter,
+} from "openclaw/plugin-sdk/channel-pairing";
import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import {
createAttachedChannelResultAdapter,
- createEmptyChannelDirectoryAdapter,
createEmptyChannelResult,
- createPairingPrefixStripper,
- createTextPairingAdapter,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/channel-send-result";
+import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
import { resolveOutboundMediaUrls } from "openclaw/plugin-sdk/reply-payload";
import {
buildComputedAccountStatusSnapshot,
diff --git a/extensions/line/src/config-adapter.ts b/extensions/line/src/config-adapter.ts
index 3894210f0a6..1b10989b45c 100644
--- a/extensions/line/src/config-adapter.ts
+++ b/extensions/line/src/config-adapter.ts
@@ -5,7 +5,7 @@ import {
resolveLineAccount,
type OpenClawConfig,
type ResolvedLineAccount,
-} from "openclaw/plugin-sdk/line-core";
+} from "../runtime-api.js";
export function normalizeLineAllowFrom(entry: string): string {
return entry.replace(/^line:(?:user:)?/i, "");
diff --git a/extensions/line/src/group-policy.ts b/extensions/line/src/group-policy.ts
index e6b4fa0ba95..eaf30e04cf7 100644
--- a/extensions/line/src/group-policy.ts
+++ b/extensions/line/src/group-policy.ts
@@ -1,5 +1,5 @@
import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy";
-import { resolveExactLineGroupConfigKey, type OpenClawConfig } from "openclaw/plugin-sdk/line-core";
+import { resolveExactLineGroupConfigKey, type OpenClawConfig } from "../runtime-api.js";
type LineGroupContext = {
cfg: OpenClawConfig;
diff --git a/extensions/line/src/setup-core.ts b/extensions/line/src/setup-core.ts
index 363b4dcb2a1..7e894d2b87a 100644
--- a/extensions/line/src/setup-core.ts
+++ b/extensions/line/src/setup-core.ts
@@ -1,11 +1,11 @@
+import type { ChannelSetupAdapter, OpenClawConfig } from "openclaw/plugin-sdk/setup";
import {
DEFAULT_ACCOUNT_ID,
listLineAccountIds,
normalizeAccountId,
resolveLineAccount,
type LineConfig,
-} from "openclaw/plugin-sdk/line-core";
-import type { ChannelSetupAdapter, OpenClawConfig } from "openclaw/plugin-sdk/setup";
+} from "../runtime-api.js";
const channel = "line" as const;
diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts
index 640ad3812b8..6f46cc92217 100644
--- a/extensions/line/src/setup-surface.ts
+++ b/extensions/line/src/setup-surface.ts
@@ -1,3 +1,4 @@
+import { createAllowFromSection, createTopLevelChannelDmPolicy } from "openclaw/plugin-sdk/setup";
import {
DEFAULT_ACCOUNT_ID,
formatDocsLink,
@@ -6,8 +7,7 @@ import {
splitSetupEntries,
type ChannelSetupDmPolicy,
type ChannelSetupWizard,
-} from "openclaw/plugin-sdk/line-core";
-import { createAllowFromSection, createTopLevelChannelDmPolicy } from "openclaw/plugin-sdk/setup";
+} from "../runtime-api.js";
import {
isLineConfigured,
listLineAccountIds,
diff --git a/extensions/matrix/runtime-api.ts b/extensions/matrix/runtime-api.ts
index 865936cb6ff..751ce70e496 100644
--- a/extensions/matrix/runtime-api.ts
+++ b/extensions/matrix/runtime-api.ts
@@ -3,4 +3,27 @@
// matrix-js-sdk during plain runtime-api import.
export * from "./src/auth-precedence.js";
export * from "./helper-api.js";
-export * from "./thread-bindings-runtime.js";
+export {
+ assertHttpUrlTargetsPrivateNetwork,
+ closeDispatcher,
+ createPinnedDispatcher,
+ resolvePinnedHostnameWithPolicy,
+ ssrfPolicyFromAllowPrivateNetwork,
+ type LookupFn,
+ type SsrFPolicy,
+} from "openclaw/plugin-sdk/ssrf-runtime";
+export {
+ setMatrixThreadBindingIdleTimeoutBySessionKey,
+ setMatrixThreadBindingMaxAgeBySessionKey,
+} from "./thread-bindings-runtime.js";
+export { writeJsonFileAtomically } from "../../src/plugin-sdk/json-store.js";
+export type {
+ ChannelDirectoryEntry,
+ ChannelMessageActionContext,
+ OpenClawConfig,
+ PluginRuntime,
+ RuntimeLogger,
+ RuntimeEnv,
+ WizardPrompter,
+} from "../../src/plugin-sdk/matrix.js";
+export { formatZonedTimestamp } from "../../src/plugin-sdk/matrix.js";
diff --git a/extensions/matrix/src/actions.account-propagation.test.ts b/extensions/matrix/src/actions.account-propagation.test.ts
index 12dfea963f3..eaa2be533b0 100644
--- a/extensions/matrix/src/actions.account-propagation.test.ts
+++ b/extensions/matrix/src/actions.account-propagation.test.ts
@@ -1,5 +1,5 @@
-import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/matrix";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { ChannelMessageActionContext } from "../runtime-api.js";
import type { CoreConfig } from "./types.js";
const mocks = vi.hoisted(() => ({
diff --git a/extensions/matrix/src/actions.test.ts b/extensions/matrix/src/actions.test.ts
index 5e657bb4603..6750f7d9fb7 100644
--- a/extensions/matrix/src/actions.test.ts
+++ b/extensions/matrix/src/actions.test.ts
@@ -1,5 +1,5 @@
-import type { PluginRuntime } from "openclaw/plugin-sdk/matrix";
import { beforeEach, describe, expect, it } from "vitest";
+import type { PluginRuntime } from "../runtime-api.js";
import { matrixMessageActions } from "./actions.js";
import { setMatrixRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
diff --git a/extensions/matrix/src/channel.directory.test.ts b/extensions/matrix/src/channel.directory.test.ts
index 8f79f592db8..2c4c8a254bf 100644
--- a/extensions/matrix/src/channel.directory.test.ts
+++ b/extensions/matrix/src/channel.directory.test.ts
@@ -1,5 +1,5 @@
-import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { PluginRuntime, RuntimeEnv } from "../runtime-api.js";
import { matrixPlugin } from "./channel.js";
import { resolveMatrixAccount } from "./matrix/accounts.js";
import { resolveMatrixConfigForAccount } from "./matrix/client/config.js";
diff --git a/extensions/matrix/src/channel.setup.test.ts b/extensions/matrix/src/channel.setup.test.ts
index ecafd4819f6..ba065fba792 100644
--- a/extensions/matrix/src/channel.setup.test.ts
+++ b/extensions/matrix/src/channel.setup.test.ts
@@ -1,5 +1,5 @@
-import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { PluginRuntime, RuntimeEnv } from "../runtime-api.js";
const verificationMocks = vi.hoisted(() => ({
bootstrapMatrixVerification: vi.fn(),
diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts
index ca028d8d99d..bef357c3bdd 100644
--- a/extensions/matrix/src/channel.ts
+++ b/extensions/matrix/src/channel.ts
@@ -2,20 +2,22 @@ import {
createScopedChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
+import {
+ createPairingPrefixStripper,
+ createTextPairingAdapter,
+} from "openclaw/plugin-sdk/channel-pairing";
import {
createAllowlistProviderOpenWarningCollector,
projectWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
+import { createScopedAccountReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime";
import {
createChannelDirectoryAdapter,
- createPairingPrefixStripper,
- createScopedAccountReplyToModeResolver,
createRuntimeDirectoryLiveAdapter,
- createRuntimeOutboundDelegates,
- createTextPairingAdapter,
listResolvedDirectoryEntriesFromSources,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/directory-runtime";
import { buildTrafficStatusSummary } from "openclaw/plugin-sdk/extension-shared";
+import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/infra-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { matrixMessageActions } from "./actions.js";
import { MatrixConfigSchema } from "./config-schema.js";
diff --git a/extensions/matrix/src/cli.test.ts b/extensions/matrix/src/cli.test.ts
index da10215f435..318db978f6b 100644
--- a/extensions/matrix/src/cli.test.ts
+++ b/extensions/matrix/src/cli.test.ts
@@ -1,6 +1,6 @@
import { Command } from "commander";
-import { formatZonedTimestamp } from "openclaw/plugin-sdk/matrix";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { formatZonedTimestamp } from "../runtime-api.js";
const bootstrapMatrixVerificationMock = vi.fn();
const getMatrixRoomKeyBackupStatusMock = vi.fn();
diff --git a/extensions/matrix/src/matrix/client.test.ts b/extensions/matrix/src/matrix/client.test.ts
index e1b8c78c56f..4e6882bc20b 100644
--- a/extensions/matrix/src/matrix/client.test.ts
+++ b/extensions/matrix/src/matrix/client.test.ts
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
-import type { LookupFn } from "../runtime-api.js";
+import type { LookupFn } from "../../runtime-api.js";
import type { CoreConfig } from "../types.js";
import {
getMatrixScopedEnvVarNames,
diff --git a/extensions/matrix/src/matrix/client/storage.test.ts b/extensions/matrix/src/matrix/client/storage.test.ts
index 923f686df67..f0749dd5bef 100644
--- a/extensions/matrix/src/matrix/client/storage.test.ts
+++ b/extensions/matrix/src/matrix/client/storage.test.ts
@@ -1,8 +1,8 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
-import { resolveMatrixAccountStorageRoot } from "openclaw/plugin-sdk/matrix";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import { resolveMatrixAccountStorageRoot } from "../../../runtime-api.js";
import { setMatrixRuntime } from "../../runtime.js";
const createBackupArchiveMock = vi.hoisted(() =>
diff --git a/extensions/matrix/src/matrix/monitor/auto-join.test.ts b/extensions/matrix/src/matrix/monitor/auto-join.test.ts
index 07dc83fe2a6..9aa8914777e 100644
--- a/extensions/matrix/src/matrix/monitor/auto-join.test.ts
+++ b/extensions/matrix/src/matrix/monitor/auto-join.test.ts
@@ -1,5 +1,5 @@
-import type { PluginRuntime } from "openclaw/plugin-sdk/matrix";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { PluginRuntime } from "../../../runtime-api.js";
import { setMatrixRuntime } from "../../runtime.js";
import type { MatrixConfig } from "../../types.js";
import { registerMatrixAutoJoin } from "./auto-join.js";
@@ -48,7 +48,7 @@ describe("registerMatrixAutoJoin", () => {
runtime: {
log: vi.fn(),
error: vi.fn(),
- } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv,
+ } as unknown as import("../../../runtime-api.js").RuntimeEnv,
});
const inviteHandler = getInviteHandler();
@@ -67,7 +67,7 @@ describe("registerMatrixAutoJoin", () => {
runtime: {
log: vi.fn(),
error: vi.fn(),
- } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv,
+ } as unknown as import("../../../runtime-api.js").RuntimeEnv,
});
expect(getInviteHandler()).toBeNull();
@@ -88,7 +88,7 @@ describe("registerMatrixAutoJoin", () => {
runtime: {
log: vi.fn(),
error: vi.fn(),
- } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv,
+ } as unknown as import("../../../runtime-api.js").RuntimeEnv,
});
const inviteHandler = getInviteHandler();
@@ -112,7 +112,7 @@ describe("registerMatrixAutoJoin", () => {
runtime: {
log: vi.fn(),
error: vi.fn(),
- } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv,
+ } as unknown as import("../../../runtime-api.js").RuntimeEnv,
});
const inviteHandler = getInviteHandler();
@@ -135,7 +135,7 @@ describe("registerMatrixAutoJoin", () => {
runtime: {
log: vi.fn(),
error: vi.fn(),
- } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv,
+ } as unknown as import("../../../runtime-api.js").RuntimeEnv,
});
const inviteHandler = getInviteHandler();
@@ -161,7 +161,7 @@ describe("registerMatrixAutoJoin", () => {
runtime: {
log: vi.fn(),
error,
- } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv,
+ } as unknown as import("../../../runtime-api.js").RuntimeEnv,
});
const inviteHandler = getInviteHandler();
@@ -187,7 +187,7 @@ describe("registerMatrixAutoJoin", () => {
runtime: {
log: vi.fn(),
error: vi.fn(),
- } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv,
+ } as unknown as import("../../../runtime-api.js").RuntimeEnv,
});
const inviteHandler = getInviteHandler();
@@ -210,7 +210,7 @@ describe("registerMatrixAutoJoin", () => {
runtime: {
log: vi.fn(),
error: vi.fn(),
- } as unknown as import("openclaw/plugin-sdk/matrix").RuntimeEnv,
+ } as unknown as import("../../../runtime-api.js").RuntimeEnv,
});
const inviteHandler = getInviteHandler();
diff --git a/extensions/matrix/src/matrix/monitor/config.test.ts b/extensions/matrix/src/matrix/monitor/config.test.ts
index f2a146879f7..0b85ef811d5 100644
--- a/extensions/matrix/src/matrix/monitor/config.test.ts
+++ b/extensions/matrix/src/matrix/monitor/config.test.ts
@@ -1,5 +1,5 @@
-import type { RuntimeEnv } from "openclaw/plugin-sdk/matrix";
import { describe, expect, it, vi } from "vitest";
+import type { RuntimeEnv } from "../../../runtime-api.js";
import type { CoreConfig, MatrixRoomConfig } from "../../types.js";
import { resolveMatrixMonitorConfig } from "./config.js";
diff --git a/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts b/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts
index 45c7484d3ca..8623d8541f2 100644
--- a/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts
+++ b/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts
@@ -1,5 +1,5 @@
-import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk/matrix";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "../../../runtime-api.js";
import { setMatrixRuntime } from "../../runtime.js";
import type { MatrixClient } from "../sdk.js";
import type { MatrixRawEvent } from "./types.js";
@@ -53,11 +53,19 @@ function createHandlerHarness() {
dispatcher: {},
replyOptions: {},
markDispatchIdle: vi.fn(),
+ markRunComplete: vi.fn(),
}),
resolveHumanDelayConfig: vi.fn().mockReturnValue(undefined),
dispatchReplyFromConfig: vi
.fn()
.mockResolvedValue({ queuedFinal: false, counts: { final: 0, block: 0, tool: 0 } }),
+ withReplyDispatcher: vi.fn().mockImplementation(async ({ run, onSettled }) => {
+ try {
+ return await run();
+ } finally {
+ await onSettled?.();
+ }
+ }),
},
commands: {
shouldHandleTextCommands: vi.fn().mockReturnValue(true),
diff --git a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts
index 3aa13a735a0..585ce851b0a 100644
--- a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts
+++ b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts
@@ -52,16 +52,28 @@ type MatrixHandlerTestHarnessOptions = {
resolveEnvelopeFormatOptions?: () => Record;
formatAgentEnvelope?: ({ body }: { body: string }) => string;
finalizeInboundContext?: (ctx: unknown) => unknown;
- createReplyDispatcherWithTyping?: () => {
+ createReplyDispatcherWithTyping?: (params?: {
+ onError?: (err: unknown, info: { kind: "tool" | "block" | "final" }) => void;
+ }) => {
dispatcher: Record;
replyOptions: Record;
markDispatchIdle: () => void;
+ markRunComplete: () => void;
};
resolveHumanDelayConfig?: () => undefined;
dispatchReplyFromConfig?: () => Promise<{
queuedFinal: boolean;
counts: { final: number; block: number; tool: number };
}>;
+ withReplyDispatcher?: (params: {
+ dispatcher: {
+ markComplete?: () => void;
+ waitForIdle?: () => Promise;
+ };
+ run: () => Promise;
+ onSettled?: () => void | Promise;
+ }) => Promise;
+ inboundDeduper?: MatrixMonitorHandlerParams["inboundDeduper"];
shouldAckReaction?: () => boolean;
enqueueSystemEvent?: (...args: unknown[]) => void;
getRoomInfo?: MatrixMonitorHandlerParams["getRoomInfo"];
@@ -138,9 +150,32 @@ export function createMatrixHandlerTestHarness(
dispatcher: {},
replyOptions: {},
markDispatchIdle: () => {},
+ markRunComplete: () => {},
})),
resolveHumanDelayConfig: options.resolveHumanDelayConfig ?? (() => undefined),
dispatchReplyFromConfig,
+ withReplyDispatcher:
+ options.withReplyDispatcher ??
+ (async (params: {
+ dispatcher: {
+ markComplete?: () => void;
+ waitForIdle?: () => Promise;
+ };
+ run: () => Promise;
+ onSettled?: () => void | Promise;
+ }) => {
+ const { dispatcher, run, onSettled } = params;
+ try {
+ return await run();
+ } finally {
+ dispatcher.markComplete?.();
+ try {
+ await dispatcher.waitForIdle?.();
+ } finally {
+ await onSettled?.();
+ }
+ }
+ }),
},
reactions: {
shouldAckReaction: options.shouldAckReaction ?? (() => false),
@@ -179,6 +214,7 @@ export function createMatrixHandlerTestHarness(
startupMs: options.startupMs ?? 0,
startupGraceMs: options.startupGraceMs ?? 0,
dropPreStartupMessages: options.dropPreStartupMessages ?? true,
+ inboundDeduper: options.inboundDeduper,
directTracker: {
isDirectMessage: async () => options.isDirectMessage ?? true,
},
diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts
index 289623631fa..8e842e38baa 100644
--- a/extensions/matrix/src/matrix/monitor/handler.test.ts
+++ b/extensions/matrix/src/matrix/monitor/handler.test.ts
@@ -720,12 +720,36 @@ describe("matrix monitor handler pairing account scope", () => {
dispatcher: {},
replyOptions: {},
markDispatchIdle: () => {},
+ markRunComplete: () => {},
}),
resolveHumanDelayConfig: () => undefined,
dispatchReplyFromConfig: async () => ({
queuedFinal: true,
counts: { final: 1, block: 0, tool: 0 },
}),
+ withReplyDispatcher: async ({
+ dispatcher,
+ run,
+ onSettled,
+ }: {
+ dispatcher: {
+ markComplete?: () => void;
+ waitForIdle?: () => Promise;
+ };
+ run: () => Promise;
+ onSettled?: () => void | Promise;
+ }) => {
+ try {
+ return await run();
+ } finally {
+ dispatcher.markComplete?.();
+ try {
+ await dispatcher.waitForIdle?.();
+ } finally {
+ await onSettled?.();
+ }
+ }
+ },
},
reactions: {
shouldAckReaction: () => false,
@@ -989,3 +1013,282 @@ describe("matrix monitor handler pairing account scope", () => {
expect(resolveAgentRoute).toHaveBeenCalledTimes(1);
});
});
+
+describe("matrix monitor handler durable inbound dedupe", () => {
+ it("skips replayed inbound events before session recording", async () => {
+ const inboundDeduper = {
+ claimEvent: vi.fn(() => false),
+ commitEvent: vi.fn(async () => undefined),
+ releaseEvent: vi.fn(),
+ };
+ const { handler, recordInboundSession } = createMatrixHandlerTestHarness({
+ inboundDeduper,
+ dispatchReplyFromConfig: vi.fn(async () => ({
+ queuedFinal: true,
+ counts: { final: 1, block: 0, tool: 0 },
+ })),
+ });
+
+ await handler(
+ "!room:example.org",
+ createMatrixTextMessageEvent({
+ eventId: "$dup",
+ body: "hello",
+ }),
+ );
+
+ expect(inboundDeduper.claimEvent).toHaveBeenCalledWith({
+ roomId: "!room:example.org",
+ eventId: "$dup",
+ });
+ expect(recordInboundSession).not.toHaveBeenCalled();
+ expect(inboundDeduper.commitEvent).not.toHaveBeenCalled();
+ expect(inboundDeduper.releaseEvent).not.toHaveBeenCalled();
+ });
+
+ it("commits inbound events only after queued replies finish delivering", async () => {
+ const callOrder: string[] = [];
+ const inboundDeduper = {
+ claimEvent: vi.fn(() => {
+ callOrder.push("claim");
+ return true;
+ }),
+ commitEvent: vi.fn(async () => {
+ callOrder.push("commit");
+ }),
+ releaseEvent: vi.fn(() => {
+ callOrder.push("release");
+ }),
+ };
+ const recordInboundSession = vi.fn(async () => {
+ callOrder.push("record");
+ });
+ const dispatchReplyFromConfig = vi.fn(async () => {
+ callOrder.push("dispatch");
+ return {
+ queuedFinal: true,
+ counts: { final: 1, block: 0, tool: 0 },
+ };
+ });
+ const { handler } = createMatrixHandlerTestHarness({
+ inboundDeduper,
+ recordInboundSession,
+ dispatchReplyFromConfig,
+ createReplyDispatcherWithTyping: () => ({
+ dispatcher: {
+ markComplete: () => {
+ callOrder.push("mark-complete");
+ },
+ waitForIdle: async () => {
+ callOrder.push("wait-for-idle");
+ },
+ },
+ replyOptions: {},
+ markDispatchIdle: () => {
+ callOrder.push("dispatch-idle");
+ },
+ markRunComplete: () => {
+ callOrder.push("run-complete");
+ },
+ }),
+ });
+
+ await handler(
+ "!room:example.org",
+ createMatrixTextMessageEvent({
+ eventId: "$commit-order",
+ body: "hello",
+ }),
+ );
+
+ expect(callOrder).toEqual([
+ "claim",
+ "record",
+ "dispatch",
+ "run-complete",
+ "mark-complete",
+ "wait-for-idle",
+ "dispatch-idle",
+ "commit",
+ ]);
+ expect(inboundDeduper.releaseEvent).not.toHaveBeenCalled();
+ });
+
+ it("releases a claimed event when reply dispatch fails before completion", async () => {
+ const inboundDeduper = {
+ claimEvent: vi.fn(() => true),
+ commitEvent: vi.fn(async () => undefined),
+ releaseEvent: vi.fn(),
+ };
+ const runtime = {
+ error: vi.fn(),
+ };
+ const { handler } = createMatrixHandlerTestHarness({
+ inboundDeduper,
+ runtime: runtime as never,
+ recordInboundSession: vi.fn(async () => {
+ throw new Error("disk failed");
+ }),
+ dispatchReplyFromConfig: vi.fn(async () => ({
+ queuedFinal: true,
+ counts: { final: 1, block: 0, tool: 0 },
+ })),
+ });
+
+ await handler(
+ "!room:example.org",
+ createMatrixTextMessageEvent({
+ eventId: "$release-on-error",
+ body: "hello",
+ }),
+ );
+
+ expect(inboundDeduper.commitEvent).not.toHaveBeenCalled();
+ expect(inboundDeduper.releaseEvent).toHaveBeenCalledWith({
+ roomId: "!room:example.org",
+ eventId: "$release-on-error",
+ });
+ expect(runtime.error).toHaveBeenCalledWith(expect.stringContaining("matrix handler failed"));
+ });
+
+ it("releases a claimed event when queued final delivery fails", async () => {
+ const inboundDeduper = {
+ claimEvent: vi.fn(() => true),
+ commitEvent: vi.fn(async () => undefined),
+ releaseEvent: vi.fn(),
+ };
+ const runtime = {
+ error: vi.fn(),
+ };
+ const { handler } = createMatrixHandlerTestHarness({
+ inboundDeduper,
+ runtime: runtime as never,
+ dispatchReplyFromConfig: vi.fn(async () => ({
+ queuedFinal: true,
+ counts: { final: 1, block: 0, tool: 0 },
+ })),
+ createReplyDispatcherWithTyping: (params) => ({
+ dispatcher: {
+ markComplete: () => {},
+ waitForIdle: async () => {
+ params?.onError?.(new Error("send failed"), { kind: "final" });
+ },
+ },
+ replyOptions: {},
+ markDispatchIdle: () => {},
+ markRunComplete: () => {},
+ }),
+ });
+
+ await handler(
+ "!room:example.org",
+ createMatrixTextMessageEvent({
+ eventId: "$release-on-final-delivery-error",
+ body: "hello",
+ }),
+ );
+
+ expect(inboundDeduper.commitEvent).not.toHaveBeenCalled();
+ expect(inboundDeduper.releaseEvent).toHaveBeenCalledWith({
+ roomId: "!room:example.org",
+ eventId: "$release-on-final-delivery-error",
+ });
+ expect(runtime.error).toHaveBeenCalledWith(
+ expect.stringContaining("matrix final reply failed"),
+ );
+ });
+
+ it.each(["tool", "block"] as const)(
+ "releases a claimed event when queued %s delivery fails and no final reply exists",
+ async (kind) => {
+ const inboundDeduper = {
+ claimEvent: vi.fn(() => true),
+ commitEvent: vi.fn(async () => undefined),
+ releaseEvent: vi.fn(),
+ };
+ const runtime = {
+ error: vi.fn(),
+ };
+ const { handler } = createMatrixHandlerTestHarness({
+ inboundDeduper,
+ runtime: runtime as never,
+ dispatchReplyFromConfig: vi.fn(async () => ({
+ queuedFinal: false,
+ counts: {
+ final: 0,
+ block: kind === "block" ? 1 : 0,
+ tool: kind === "tool" ? 1 : 0,
+ },
+ })),
+ createReplyDispatcherWithTyping: (params) => ({
+ dispatcher: {
+ markComplete: () => {},
+ waitForIdle: async () => {
+ params?.onError?.(new Error("send failed"), { kind });
+ },
+ },
+ replyOptions: {},
+ markDispatchIdle: () => {},
+ markRunComplete: () => {},
+ }),
+ });
+
+ await handler(
+ "!room:example.org",
+ createMatrixTextMessageEvent({
+ eventId: `$release-on-${kind}-delivery-error`,
+ body: "hello",
+ }),
+ );
+
+ expect(inboundDeduper.commitEvent).not.toHaveBeenCalled();
+ expect(inboundDeduper.releaseEvent).toHaveBeenCalledWith({
+ roomId: "!room:example.org",
+ eventId: `$release-on-${kind}-delivery-error`,
+ });
+ expect(runtime.error).toHaveBeenCalledWith(
+ expect.stringContaining(`matrix ${kind} reply failed`),
+ );
+ },
+ );
+
+ it("commits a claimed event when dispatch completes without a final reply", async () => {
+ const callOrder: string[] = [];
+ const inboundDeduper = {
+ claimEvent: vi.fn(() => {
+ callOrder.push("claim");
+ return true;
+ }),
+ commitEvent: vi.fn(async () => {
+ callOrder.push("commit");
+ }),
+ releaseEvent: vi.fn(() => {
+ callOrder.push("release");
+ }),
+ };
+ const { handler } = createMatrixHandlerTestHarness({
+ inboundDeduper,
+ recordInboundSession: vi.fn(async () => {
+ callOrder.push("record");
+ }),
+ dispatchReplyFromConfig: vi.fn(async () => {
+ callOrder.push("dispatch");
+ return {
+ queuedFinal: false,
+ counts: { final: 0, block: 0, tool: 0 },
+ };
+ }),
+ });
+
+ await handler(
+ "!room:example.org",
+ createMatrixTextMessageEvent({
+ eventId: "$no-final",
+ body: "hello",
+ }),
+ );
+
+ expect(callOrder).toEqual(["claim", "record", "dispatch", "commit"]);
+ expect(inboundDeduper.releaseEvent).not.toHaveBeenCalled();
+ });
+});
diff --git a/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts b/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts
index 51f5a07bdd0..aea230f3afc 100644
--- a/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts
+++ b/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts
@@ -1,5 +1,5 @@
-import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "openclaw/plugin-sdk/matrix";
import { describe, expect, it, vi } from "vitest";
+import type { PluginRuntime, RuntimeEnv, RuntimeLogger } from "../../../runtime-api.js";
import { setMatrixRuntime } from "../../runtime.js";
import type { MatrixClient } from "../sdk.js";
import { createMatrixRoomMessageHandler } from "./handler.js";
diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts
index b7295009bcd..40c386e3820 100644
--- a/extensions/matrix/src/matrix/monitor/handler.ts
+++ b/extensions/matrix/src/matrix/monitor/handler.ts
@@ -30,6 +30,7 @@ import {
} from "../send.js";
import { resolveMatrixMonitorAccessState } from "./access-state.js";
import { resolveMatrixAckReactionConfig } from "./ack-config.js";
+import type { MatrixInboundEventDeduper } from "./inbound-dedupe.js";
import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js";
import { downloadMatrixMedia } from "./media.js";
import { resolveMentions } from "./mentions.js";
@@ -72,6 +73,7 @@ export type MatrixMonitorHandlerParams = {
startupMs: number;
startupGraceMs: number;
dropPreStartupMessages: boolean;
+ inboundDeduper?: Pick;
directTracker: {
isDirectMessage: (params: {
roomId: string;
@@ -163,6 +165,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
startupMs,
startupGraceMs,
dropPreStartupMessages,
+ inboundDeduper,
directTracker,
getRoomInfo,
getMemberDisplayName,
@@ -219,6 +222,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
};
return async (roomId: string, event: MatrixRawEvent) => {
+ const eventId = typeof event.event_id === "string" ? event.event_id.trim() : "";
+ let claimedInboundEvent = false;
try {
const eventType = event.type;
if (eventType === EventType.RoomMessageEncrypted) {
@@ -256,6 +261,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
}
const eventTs = event.origin_server_ts;
const eventAge = event.unsigned?.age;
+ const commitInboundEventIfClaimed = async () => {
+ if (!claimedInboundEvent || !inboundDeduper || !eventId) {
+ return;
+ }
+ await inboundDeduper.commitEvent({ roomId, eventId });
+ claimedInboundEvent = false;
+ };
if (dropPreStartupMessages) {
if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) {
return;
@@ -293,6 +305,13 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
return;
}
}
+ if (eventId && inboundDeduper) {
+ claimedInboundEvent = inboundDeduper.claimEvent({ roomId, eventId });
+ if (!claimedInboundEvent) {
+ logVerboseMessage(`matrix: skip duplicate inbound event room=${roomId} id=${eventId}`);
+ return;
+ }
+ }
const isDirectMessage = await directTracker.isDirectMessage({
roomId,
@@ -302,6 +321,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const isRoom = !isDirectMessage;
if (isRoom && groupPolicy === "disabled") {
+ await commitInboundEventIfClaimed();
return;
}
@@ -332,20 +352,24 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
logVerboseMessage(
`matrix: drop configured bot sender=${senderId} (allowBots=false${isDirectMessage ? "" : `, ${roomMatchMeta}`})`,
);
+ await commitInboundEventIfClaimed();
return;
}
if (isRoom && roomConfig && !roomConfigInfo?.allowed) {
logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
+ await commitInboundEventIfClaimed();
return;
}
if (isRoom && groupPolicy === "allowlist") {
if (!roomConfigInfo?.allowlistConfigured) {
logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
+ await commitInboundEventIfClaimed();
return;
}
if (!roomConfig) {
logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
+ await commitInboundEventIfClaimed();
return;
}
}
@@ -378,6 +402,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
if (isDirectMessage) {
if (!dmEnabled || dmPolicy === "disabled") {
+ await commitInboundEventIfClaimed();
return;
}
if (dmPolicy !== "open") {
@@ -414,19 +439,23 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
accountId,
},
);
+ await commitInboundEventIfClaimed();
} catch (err) {
logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
+ return;
}
} else {
logVerboseMessage(
`matrix pairing reminder suppressed sender=${senderId} (cooldown)`,
);
+ await commitInboundEventIfClaimed();
}
}
if (isReactionEvent || dmPolicy !== "pairing") {
logVerboseMessage(
`matrix: blocked ${isReactionEvent ? "reaction" : "dm"} sender ${senderId} (dmPolicy=${dmPolicy}, ${allowMatchMeta})`,
);
+ await commitInboundEventIfClaimed();
}
return;
}
@@ -439,6 +468,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
roomUserMatch,
)})`,
);
+ await commitInboundEventIfClaimed();
return;
}
if (
@@ -453,6 +483,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
groupAllowMatch,
)})`,
);
+ await commitInboundEventIfClaimed();
return;
}
}
@@ -475,6 +506,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
isDirectMessage,
logVerboseMessage,
});
+ await commitInboundEventIfClaimed();
return;
}
@@ -491,6 +523,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
: undefined;
const mediaUrl = contentUrl ?? contentFile?.url;
if (!mentionPrecheckText && !mediaUrl && !isPollEvent) {
+ await commitInboundEventIfClaimed();
return;
}
@@ -509,6 +542,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
logVerboseMessage(
`matrix: drop configured bot sender=${senderId} (allowBots=mentions, missing mention, ${roomMatchMeta})`,
);
+ await commitInboundEventIfClaimed();
return;
}
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
@@ -534,6 +568,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
reason: "control command (unauthorized)",
target: senderId,
});
+ await commitInboundEventIfClaimed();
return;
}
const shouldRequireMention = isRoom
@@ -556,6 +591,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
const canDetectMention = mentionRegexes.length > 0 || hasExplicitMention;
if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
logger.info("skipping room message", { roomId, reason: "no-mention" });
+ await commitInboundEventIfClaimed();
return;
}
@@ -631,6 +667,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
mediaDownloadFailed,
});
if (!bodyText) {
+ await commitInboundEventIfClaimed();
return;
}
const senderName = await getSenderName();
@@ -799,6 +836,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
accountId: route.accountId,
});
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId);
+ let finalReplyDeliveryFailed = false;
+ let nonFinalReplyDeliveryFailed = false;
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
cfg,
agentId: route.agentId,
@@ -827,7 +866,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
});
},
});
- const { dispatcher, replyOptions, markDispatchIdle } =
+ const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } =
core.channel.reply.createReplyDispatcherWithTyping({
...prefixOptions,
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
@@ -847,32 +886,66 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
});
},
onError: (err: unknown, info: { kind: "tool" | "block" | "final" }) => {
+ if (info.kind === "final") {
+ finalReplyDeliveryFailed = true;
+ } else {
+ nonFinalReplyDeliveryFailed = true;
+ }
runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
},
onReplyStart: typingCallbacks.onReplyStart,
onIdle: typingCallbacks.onIdle,
});
- const { queuedFinal, counts } = await core.channel.reply.dispatchReplyFromConfig({
- ctx: ctxPayload,
- cfg,
+ const { queuedFinal, counts } = await core.channel.reply.withReplyDispatcher({
dispatcher,
- replyOptions: {
- ...replyOptions,
- skillFilter: roomConfig?.skills,
- onModelSelected,
+ onSettled: () => {
+ markDispatchIdle();
+ },
+ run: async () => {
+ try {
+ return await core.channel.reply.dispatchReplyFromConfig({
+ ctx: ctxPayload,
+ cfg,
+ dispatcher,
+ replyOptions: {
+ ...replyOptions,
+ skillFilter: roomConfig?.skills,
+ onModelSelected,
+ },
+ });
+ } finally {
+ markRunComplete();
+ }
},
});
- markDispatchIdle();
+ if (finalReplyDeliveryFailed) {
+ logVerboseMessage(
+ `matrix: final reply delivery failed room=${roomId} id=${messageId}; leaving event uncommitted`,
+ );
+ return;
+ }
+ if (!queuedFinal && nonFinalReplyDeliveryFailed) {
+ logVerboseMessage(
+ `matrix: non-final reply delivery failed room=${roomId} id=${messageId}; leaving event uncommitted`,
+ );
+ return;
+ }
if (!queuedFinal) {
+ await commitInboundEventIfClaimed();
return;
}
const finalCount = counts.final;
logVerboseMessage(
`matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
);
+ await commitInboundEventIfClaimed();
} catch (err) {
runtime.error?.(`matrix handler failed: ${String(err)}`);
+ } finally {
+ if (claimedInboundEvent && inboundDeduper && eventId) {
+ inboundDeduper.releaseEvent({ roomId, eventId });
+ }
}
};
}
diff --git a/extensions/matrix/src/matrix/monitor/inbound-dedupe.test.ts b/extensions/matrix/src/matrix/monitor/inbound-dedupe.test.ts
new file mode 100644
index 00000000000..e0ad423c1f1
--- /dev/null
+++ b/extensions/matrix/src/matrix/monitor/inbound-dedupe.test.ts
@@ -0,0 +1,146 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { createMatrixInboundEventDeduper } from "./inbound-dedupe.js";
+
+describe("Matrix inbound event dedupe", () => {
+ const tempDirs: string[] = [];
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+ for (const dir of tempDirs.splice(0)) {
+ fs.rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ function createStoragePath(): string {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-inbound-dedupe-"));
+ tempDirs.push(dir);
+ return path.join(dir, "inbound-dedupe.json");
+ }
+
+ const auth = {
+ accountId: "ops",
+ homeserver: "https://matrix.example.org",
+ userId: "@bot:example.org",
+ accessToken: "token",
+ deviceId: "DEVICE",
+ } as const;
+
+ it("persists committed events across restarts", async () => {
+ const storagePath = createStoragePath();
+ const first = await createMatrixInboundEventDeduper({
+ auth: auth as never,
+ storagePath,
+ });
+
+ expect(first.claimEvent({ roomId: "!room:example.org", eventId: "$event-1" })).toBe(true);
+ await first.commitEvent({
+ roomId: "!room:example.org",
+ eventId: "$event-1",
+ });
+ await first.stop();
+
+ const second = await createMatrixInboundEventDeduper({
+ auth: auth as never,
+ storagePath,
+ });
+ expect(second.claimEvent({ roomId: "!room:example.org", eventId: "$event-1" })).toBe(false);
+ });
+
+ it("does not persist released pending claims", async () => {
+ const storagePath = createStoragePath();
+ const first = await createMatrixInboundEventDeduper({
+ auth: auth as never,
+ storagePath,
+ });
+
+ expect(first.claimEvent({ roomId: "!room:example.org", eventId: "$event-2" })).toBe(true);
+ first.releaseEvent({ roomId: "!room:example.org", eventId: "$event-2" });
+ await first.stop();
+
+ const second = await createMatrixInboundEventDeduper({
+ auth: auth as never,
+ storagePath,
+ });
+ expect(second.claimEvent({ roomId: "!room:example.org", eventId: "$event-2" })).toBe(true);
+ });
+
+ it("prunes expired and overflowed entries on load", async () => {
+ const storagePath = createStoragePath();
+ fs.writeFileSync(
+ storagePath,
+ JSON.stringify({
+ version: 1,
+ entries: [
+ { key: "!room:example.org|$old", ts: 10 },
+ { key: "!room:example.org|$keep-1", ts: 90 },
+ { key: "!room:example.org|$keep-2", ts: 95 },
+ { key: "!room:example.org|$keep-3", ts: 100 },
+ ],
+ }),
+ "utf8",
+ );
+
+ const deduper = await createMatrixInboundEventDeduper({
+ auth: auth as never,
+ storagePath,
+ ttlMs: 20,
+ maxEntries: 2,
+ nowMs: () => 100,
+ });
+
+ expect(deduper.claimEvent({ roomId: "!room:example.org", eventId: "$old" })).toBe(true);
+ expect(deduper.claimEvent({ roomId: "!room:example.org", eventId: "$keep-1" })).toBe(true);
+ expect(deduper.claimEvent({ roomId: "!room:example.org", eventId: "$keep-2" })).toBe(false);
+ expect(deduper.claimEvent({ roomId: "!room:example.org", eventId: "$keep-3" })).toBe(false);
+ });
+
+ it("retains replayed backlog events based on processing time", async () => {
+ const storagePath = createStoragePath();
+ let now = 100;
+ const first = await createMatrixInboundEventDeduper({
+ auth: auth as never,
+ storagePath,
+ ttlMs: 20,
+ nowMs: () => now,
+ });
+
+ expect(first.claimEvent({ roomId: "!room:example.org", eventId: "$backlog" })).toBe(true);
+ await first.commitEvent({
+ roomId: "!room:example.org",
+ eventId: "$backlog",
+ });
+ await first.stop();
+
+ now = 110;
+ const second = await createMatrixInboundEventDeduper({
+ auth: auth as never,
+ storagePath,
+ ttlMs: 20,
+ nowMs: () => now,
+ });
+ expect(second.claimEvent({ roomId: "!room:example.org", eventId: "$backlog" })).toBe(false);
+ });
+
+ it("treats stop persistence failures as best-effort cleanup", async () => {
+ const blockingPath = createStoragePath();
+ fs.writeFileSync(blockingPath, "blocking file", "utf8");
+ const deduper = await createMatrixInboundEventDeduper({
+ auth: auth as never,
+ storagePath: path.join(blockingPath, "nested", "inbound-dedupe.json"),
+ });
+
+ expect(deduper.claimEvent({ roomId: "!room:example.org", eventId: "$persist-fail" })).toBe(
+ true,
+ );
+ await deduper.commitEvent({
+ roomId: "!room:example.org",
+ eventId: "$persist-fail",
+ });
+
+ await expect(deduper.stop()).resolves.toBeUndefined();
+ });
+});
diff --git a/extensions/matrix/src/matrix/monitor/inbound-dedupe.ts b/extensions/matrix/src/matrix/monitor/inbound-dedupe.ts
new file mode 100644
index 00000000000..2e2b3b8461d
--- /dev/null
+++ b/extensions/matrix/src/matrix/monitor/inbound-dedupe.ts
@@ -0,0 +1,285 @@
+import path from "node:path";
+import { readJsonFileWithFallback, writeJsonFileAtomically } from "../../runtime-api.js";
+import { resolveMatrixStoragePaths } from "../client/storage.js";
+import type { MatrixAuth } from "../client/types.js";
+import { LogService } from "../sdk/logger.js";
+
+const INBOUND_DEDUPE_FILENAME = "inbound-dedupe.json";
+const STORE_VERSION = 1;
+const DEFAULT_MAX_ENTRIES = 20_000;
+const DEFAULT_TTL_MS = 30 * 24 * 60 * 60 * 1000;
+const PERSIST_DEBOUNCE_MS = 250;
+
+type StoredMatrixInboundDedupeEntry = {
+ key: string;
+ ts: number;
+};
+
+type StoredMatrixInboundDedupeState = {
+ version: number;
+ entries: StoredMatrixInboundDedupeEntry[];
+};
+
+export type MatrixInboundEventDeduper = {
+ claimEvent: (params: { roomId: string; eventId: string }) => boolean;
+ commitEvent: (params: { roomId: string; eventId: string }) => Promise;
+ releaseEvent: (params: { roomId: string; eventId: string }) => void;
+ flush: () => Promise;
+ stop: () => Promise;
+};
+
+function createAsyncLock() {
+ let lock: Promise = Promise.resolve();
+ return async function withLock(fn: () => Promise): Promise {
+ const previous = lock;
+ let release: (() => void) | undefined;
+ lock = new Promise((resolve) => {
+ release = resolve;
+ });
+ await previous;
+ try {
+ return await fn();
+ } finally {
+ release?.();
+ }
+ };
+}
+
+function normalizeEventPart(value: string): string {
+ return value.trim();
+}
+
+function buildEventKey(params: { roomId: string; eventId: string }): string {
+ const roomId = normalizeEventPart(params.roomId);
+ const eventId = normalizeEventPart(params.eventId);
+ return roomId && eventId ? `${roomId}|${eventId}` : "";
+}
+
+function resolveInboundDedupeStatePath(params: {
+ auth: MatrixAuth;
+ env?: NodeJS.ProcessEnv;
+ stateDir?: string;
+}): string {
+ const storagePaths = resolveMatrixStoragePaths({
+ homeserver: params.auth.homeserver,
+ userId: params.auth.userId,
+ accessToken: params.auth.accessToken,
+ accountId: params.auth.accountId,
+ deviceId: params.auth.deviceId,
+ env: params.env,
+ stateDir: params.stateDir,
+ });
+ return path.join(storagePaths.rootDir, INBOUND_DEDUPE_FILENAME);
+}
+
+function normalizeTimestamp(raw: unknown): number | null {
+ if (typeof raw !== "number" || !Number.isFinite(raw)) {
+ return null;
+ }
+ return Math.max(0, Math.floor(raw));
+}
+
+function pruneSeenEvents(params: {
+ seen: Map;
+ ttlMs: number;
+ maxEntries: number;
+ nowMs: number;
+}) {
+ const { seen, ttlMs, maxEntries, nowMs } = params;
+ if (ttlMs > 0) {
+ const cutoff = nowMs - ttlMs;
+ for (const [key, ts] of seen) {
+ if (ts < cutoff) {
+ seen.delete(key);
+ }
+ }
+ }
+ const max = Math.max(0, Math.floor(maxEntries));
+ if (max <= 0) {
+ seen.clear();
+ return;
+ }
+ while (seen.size > max) {
+ const oldestKey = seen.keys().next().value;
+ if (typeof oldestKey !== "string") {
+ break;
+ }
+ seen.delete(oldestKey);
+ }
+}
+
+function toStoredState(params: {
+ seen: Map;
+ ttlMs: number;
+ maxEntries: number;
+ nowMs: number;
+}): StoredMatrixInboundDedupeState {
+ pruneSeenEvents(params);
+ return {
+ version: STORE_VERSION,
+ entries: Array.from(params.seen.entries()).map(([key, ts]) => ({ key, ts })),
+ };
+}
+
+async function readStoredState(
+ storagePath: string,
+): Promise {
+ const { value } = await readJsonFileWithFallback(
+ storagePath,
+ null,
+ );
+ if (value?.version !== STORE_VERSION || !Array.isArray(value.entries)) {
+ return null;
+ }
+ return value;
+}
+
+export async function createMatrixInboundEventDeduper(params: {
+ auth: MatrixAuth;
+ env?: NodeJS.ProcessEnv;
+ stateDir?: string;
+ storagePath?: string;
+ ttlMs?: number;
+ maxEntries?: number;
+ nowMs?: () => number;
+}): Promise {
+ const nowMs = params.nowMs ?? (() => Date.now());
+ const ttlMs =
+ typeof params.ttlMs === "number" && Number.isFinite(params.ttlMs)
+ ? Math.max(0, Math.floor(params.ttlMs))
+ : DEFAULT_TTL_MS;
+ const maxEntries =
+ typeof params.maxEntries === "number" && Number.isFinite(params.maxEntries)
+ ? Math.max(0, Math.floor(params.maxEntries))
+ : DEFAULT_MAX_ENTRIES;
+ const storagePath =
+ params.storagePath ??
+ resolveInboundDedupeStatePath({
+ auth: params.auth,
+ env: params.env,
+ stateDir: params.stateDir,
+ });
+
+ const seen = new Map();
+ const pending = new Set();
+ const persistLock = createAsyncLock();
+
+ try {
+ const stored = await readStoredState(storagePath);
+ for (const entry of stored?.entries ?? []) {
+ if (!entry || typeof entry.key !== "string") {
+ continue;
+ }
+ const key = entry.key.trim();
+ const ts = normalizeTimestamp(entry.ts);
+ if (!key || ts === null) {
+ continue;
+ }
+ seen.set(key, ts);
+ }
+ pruneSeenEvents({ seen, ttlMs, maxEntries, nowMs: nowMs() });
+ } catch (err) {
+ LogService.warn("MatrixInboundDedupe", "Failed loading Matrix inbound dedupe store:", err);
+ }
+
+ let dirty = false;
+ let persistTimer: NodeJS.Timeout | null = null;
+ let persistPromise: Promise | null = null;
+
+ const persist = async () => {
+ dirty = false;
+ const payload = toStoredState({
+ seen,
+ ttlMs,
+ maxEntries,
+ nowMs: nowMs(),
+ });
+ try {
+ await persistLock(async () => {
+ await writeJsonFileAtomically(storagePath, payload);
+ });
+ } catch (err) {
+ dirty = true;
+ throw err;
+ }
+ };
+
+ const flush = async (): Promise => {
+ if (persistTimer) {
+ clearTimeout(persistTimer);
+ persistTimer = null;
+ }
+ while (dirty || persistPromise) {
+ if (dirty && !persistPromise) {
+ persistPromise = persist().finally(() => {
+ persistPromise = null;
+ });
+ }
+ await persistPromise;
+ }
+ };
+
+ const schedulePersist = () => {
+ dirty = true;
+ if (persistTimer) {
+ return;
+ }
+ persistTimer = setTimeout(() => {
+ persistTimer = null;
+ void flush().catch((err) => {
+ LogService.warn(
+ "MatrixInboundDedupe",
+ "Failed persisting Matrix inbound dedupe store:",
+ err,
+ );
+ });
+ }, PERSIST_DEBOUNCE_MS);
+ persistTimer.unref?.();
+ };
+
+ return {
+ claimEvent: ({ roomId, eventId }) => {
+ const key = buildEventKey({ roomId, eventId });
+ if (!key) {
+ return true;
+ }
+ pruneSeenEvents({ seen, ttlMs, maxEntries, nowMs: nowMs() });
+ if (seen.has(key) || pending.has(key)) {
+ return false;
+ }
+ pending.add(key);
+ return true;
+ },
+ commitEvent: async ({ roomId, eventId }) => {
+ const key = buildEventKey({ roomId, eventId });
+ if (!key) {
+ return;
+ }
+ pending.delete(key);
+ const ts = nowMs();
+ seen.delete(key);
+ seen.set(key, ts);
+ pruneSeenEvents({ seen, ttlMs, maxEntries, nowMs: nowMs() });
+ schedulePersist();
+ },
+ releaseEvent: ({ roomId, eventId }) => {
+ const key = buildEventKey({ roomId, eventId });
+ if (!key) {
+ return;
+ }
+ pending.delete(key);
+ },
+ flush,
+ stop: async () => {
+ try {
+ await flush();
+ } catch (err) {
+ LogService.warn(
+ "MatrixInboundDedupe",
+ "Failed to flush Matrix inbound dedupe store during stop():",
+ err,
+ );
+ }
+ },
+ };
+}
diff --git a/extensions/matrix/src/matrix/monitor/index.test.ts b/extensions/matrix/src/matrix/monitor/index.test.ts
index b7ddb8f9656..1e7db90d4df 100644
--- a/extensions/matrix/src/matrix/monitor/index.test.ts
+++ b/extensions/matrix/src/matrix/monitor/index.test.ts
@@ -5,9 +5,18 @@ const hoisted = vi.hoisted(() => {
const state = {
startClientError: null as Error | null,
};
+ const inboundDeduper = {
+ claimEvent: vi.fn(() => true),
+ commitEvent: vi.fn(async () => undefined),
+ releaseEvent: vi.fn(),
+ flush: vi.fn(async () => undefined),
+ stop: vi.fn(async () => undefined),
+ };
const client = {
id: "matrix-client",
hasPersistedSyncState: vi.fn(() => false),
+ stopSyncWithoutPersist: vi.fn(),
+ drainPendingDecryptions: vi.fn(async () => undefined),
};
const createMatrixRoomMessageHandler = vi.fn(() => vi.fn());
const resolveTextChunkLimit = vi.fn<
@@ -26,7 +35,9 @@ const hoisted = vi.hoisted(() => {
callOrder,
client,
createMatrixRoomMessageHandler,
+ inboundDeduper,
logger,
+ registeredOnRoomMessage: null as null | ((roomId: string, event: unknown) => Promise),
releaseSharedClientInstance,
resolveTextChunkLimit,
setActiveMatrixClient,
@@ -35,7 +46,7 @@ const hoisted = vi.hoisted(() => {
};
});
-vi.mock("openclaw/plugin-sdk/matrix", () => ({
+vi.mock("../../runtime-api.js", () => ({
GROUP_POLICY_BLOCKED_LABEL: {
room: "room",
},
@@ -181,15 +192,22 @@ vi.mock("./direct.js", () => ({
}));
vi.mock("./events.js", () => ({
- registerMatrixMonitorEvents: vi.fn(() => {
- hoisted.callOrder.push("register-events");
- }),
+ registerMatrixMonitorEvents: vi.fn(
+ (params: { onRoomMessage: (roomId: string, event: unknown) => Promise }) => {
+ hoisted.callOrder.push("register-events");
+ hoisted.registeredOnRoomMessage = params.onRoomMessage;
+ },
+ ),
}));
vi.mock("./handler.js", () => ({
createMatrixRoomMessageHandler: hoisted.createMatrixRoomMessageHandler,
}));
+vi.mock("./inbound-dedupe.js", () => ({
+ createMatrixInboundEventDeduper: vi.fn(async () => hoisted.inboundDeduper),
+}));
+
vi.mock("./legacy-crypto-restore.js", () => ({
maybeRestoreLegacyMatrixBackup: vi.fn(),
}));
@@ -214,9 +232,17 @@ describe("monitorMatrixProvider", () => {
hoisted.state.startClientError = null;
hoisted.resolveTextChunkLimit.mockReset().mockReturnValue(4000);
hoisted.releaseSharedClientInstance.mockReset().mockResolvedValue(true);
+ hoisted.registeredOnRoomMessage = null;
hoisted.setActiveMatrixClient.mockReset();
hoisted.stopThreadBindingManager.mockReset();
hoisted.client.hasPersistedSyncState.mockReset().mockReturnValue(false);
+ hoisted.client.stopSyncWithoutPersist.mockReset();
+ hoisted.client.drainPendingDecryptions.mockReset().mockResolvedValue(undefined);
+ hoisted.inboundDeduper.claimEvent.mockReset().mockReturnValue(true);
+ hoisted.inboundDeduper.commitEvent.mockReset().mockResolvedValue(undefined);
+ hoisted.inboundDeduper.releaseEvent.mockReset();
+ hoisted.inboundDeduper.flush.mockReset().mockResolvedValue(undefined);
+ hoisted.inboundDeduper.stop.mockReset().mockResolvedValue(undefined);
hoisted.createMatrixRoomMessageHandler.mockReset().mockReturnValue(vi.fn());
Object.values(hoisted.logger).forEach((mock) => mock.mockReset());
});
@@ -278,4 +304,77 @@ describe("monitorMatrixProvider", () => {
}),
);
});
+
+ it("stops sync, drains decryptions, then waits for in-flight handlers before persisting", async () => {
+ const { monitorMatrixProvider } = await import("./index.js");
+ const abortController = new AbortController();
+ let resolveHandler: (() => void) | null = null;
+
+ hoisted.createMatrixRoomMessageHandler.mockReturnValue(
+ vi.fn(() => {
+ hoisted.callOrder.push("handler-start");
+ return new Promise((resolve) => {
+ resolveHandler = () => {
+ hoisted.callOrder.push("handler-done");
+ resolve();
+ };
+ });
+ }),
+ );
+ hoisted.client.stopSyncWithoutPersist.mockImplementation(() => {
+ hoisted.callOrder.push("pause-client");
+ });
+ hoisted.client.drainPendingDecryptions.mockImplementation(async () => {
+ hoisted.callOrder.push("drain-decrypts");
+ });
+ hoisted.stopThreadBindingManager.mockImplementation(() => {
+ hoisted.callOrder.push("stop-manager");
+ });
+ hoisted.releaseSharedClientInstance.mockImplementation(async () => {
+ hoisted.callOrder.push("release-client");
+ return true;
+ });
+ hoisted.inboundDeduper.stop.mockImplementation(async () => {
+ hoisted.callOrder.push("stop-deduper");
+ });
+
+ const monitorPromise = monitorMatrixProvider({ abortSignal: abortController.signal });
+ await vi.waitFor(() => {
+ expect(hoisted.callOrder).toContain("start-client");
+ });
+ const onRoomMessage = hoisted.registeredOnRoomMessage;
+ if (!onRoomMessage) {
+ throw new Error("expected room message handler to be registered");
+ }
+
+ const roomMessagePromise = onRoomMessage("!room:example.org", { event_id: "$event" });
+ abortController.abort();
+ await vi.waitFor(() => {
+ expect(hoisted.callOrder).toContain("pause-client");
+ });
+ expect(hoisted.callOrder).not.toContain("stop-deduper");
+
+ if (resolveHandler === null) {
+ throw new Error("expected in-flight handler to be pending");
+ }
+ (resolveHandler as () => void)();
+ await roomMessagePromise;
+ await monitorPromise;
+
+ expect(hoisted.callOrder.indexOf("pause-client")).toBeLessThan(
+ hoisted.callOrder.indexOf("drain-decrypts"),
+ );
+ expect(hoisted.callOrder.indexOf("drain-decrypts")).toBeLessThan(
+ hoisted.callOrder.indexOf("handler-done"),
+ );
+ expect(hoisted.callOrder.indexOf("handler-done")).toBeLessThan(
+ hoisted.callOrder.indexOf("stop-manager"),
+ );
+ expect(hoisted.callOrder.indexOf("stop-manager")).toBeLessThan(
+ hoisted.callOrder.indexOf("stop-deduper"),
+ );
+ expect(hoisted.callOrder.indexOf("stop-deduper")).toBeLessThan(
+ hoisted.callOrder.indexOf("release-client"),
+ );
+ });
});
diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts
index 62ea41b0169..71efc539424 100644
--- a/extensions/matrix/src/matrix/monitor/index.ts
+++ b/extensions/matrix/src/matrix/monitor/index.ts
@@ -25,6 +25,7 @@ import { resolveMatrixMonitorConfig } from "./config.js";
import { createDirectRoomTracker } from "./direct.js";
import { registerMatrixMonitorEvents } from "./events.js";
import { createMatrixRoomMessageHandler } from "./handler.js";
+import { createMatrixInboundEventDeduper } from "./inbound-dedupe.js";
import { createMatrixRoomInfoResolver } from "./room-info.js";
import { runMatrixStartupMaintenance } from "./startup.js";
@@ -136,15 +137,29 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
setActiveMatrixClient(client, auth.accountId);
let cleanedUp = false;
let threadBindingManager: { accountId: string; stop: () => void } | null = null;
+ const inboundDeduper = await createMatrixInboundEventDeduper({
+ auth,
+ env: process.env,
+ });
+ const inFlightRoomMessages = new Set>();
+ const waitForInFlightRoomMessages = async () => {
+ while (inFlightRoomMessages.size > 0) {
+ await Promise.allSettled(Array.from(inFlightRoomMessages));
+ }
+ };
const cleanup = async () => {
if (cleanedUp) {
return;
}
cleanedUp = true;
try {
+ client.stopSyncWithoutPersist();
+ await client.drainPendingDecryptions("matrix monitor shutdown");
+ await waitForInFlightRoomMessages();
threadBindingManager?.stop();
- } finally {
+ await inboundDeduper.stop();
await releaseSharedClientInstance(client, "persist");
+ } finally {
setActiveMatrixClient(null, auth.accountId);
}
};
@@ -219,11 +234,19 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
startupMs,
startupGraceMs,
dropPreStartupMessages,
+ inboundDeduper,
directTracker,
getRoomInfo,
getMemberDisplayName,
needsRoomAliasesForConfig,
});
+ const trackRoomMessage = (roomId: string, event: Parameters[1]) => {
+ const task = Promise.resolve(handleRoomMessage(roomId, event)).finally(() => {
+ inFlightRoomMessages.delete(task);
+ });
+ inFlightRoomMessages.add(task);
+ return task;
+ };
try {
threadBindingManager = await createMatrixThreadBindingManager({
@@ -249,7 +272,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
warnedCryptoMissingRooms,
logger,
formatNativeDependencyHint: core.system.formatNativeDependencyHint,
- onRoomMessage: handleRoomMessage,
+ onRoomMessage: trackRoomMessage,
});
// Register Matrix thread bindings before the client starts syncing so threaded
diff --git a/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.test.ts b/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.test.ts
index 887dd25624a..68e81a48e41 100644
--- a/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.test.ts
+++ b/extensions/matrix/src/matrix/monitor/legacy-crypto-restore.test.ts
@@ -1,8 +1,8 @@
import fs from "node:fs";
import path from "node:path";
-import { resolveMatrixAccountStorageRoot } from "openclaw/plugin-sdk/matrix";
import { describe, expect, it, vi } from "vitest";
import { withTempHome } from "../../../../../test/helpers/temp-home.js";
+import { resolveMatrixAccountStorageRoot } from "../../../runtime-api.js";
import { maybeRestoreLegacyMatrixBackup } from "./legacy-crypto-restore.js";
function createBackupStatus() {
diff --git a/extensions/matrix/src/matrix/monitor/media.test.ts b/extensions/matrix/src/matrix/monitor/media.test.ts
index 19ee48cb57e..73abd2feb80 100644
--- a/extensions/matrix/src/matrix/monitor/media.test.ts
+++ b/extensions/matrix/src/matrix/monitor/media.test.ts
@@ -1,5 +1,5 @@
-import type { PluginRuntime } from "openclaw/plugin-sdk/matrix";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { PluginRuntime } from "../../../runtime-api.js";
import { setMatrixRuntime } from "../../runtime.js";
import { downloadMatrixMedia } from "./media.js";
diff --git a/extensions/matrix/src/matrix/monitor/replies.test.ts b/extensions/matrix/src/matrix/monitor/replies.test.ts
index 33ed0bba226..92146fa4901 100644
--- a/extensions/matrix/src/matrix/monitor/replies.test.ts
+++ b/extensions/matrix/src/matrix/monitor/replies.test.ts
@@ -1,5 +1,5 @@
-import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/matrix";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { PluginRuntime, RuntimeEnv } from "../../../runtime-api.js";
import type { MatrixClient } from "../sdk.js";
const sendMessageMatrixMock = vi.hoisted(() => vi.fn().mockResolvedValue({ messageId: "mx-1" }));
diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts
index 8b7330294e6..dd84a7f6eb2 100644
--- a/extensions/matrix/src/matrix/sdk.test.ts
+++ b/extensions/matrix/src/matrix/sdk.test.ts
@@ -684,6 +684,52 @@ describe("MatrixClient event bridge", () => {
expect(delivered).toEqual(["m.room.message"]);
});
+ it("can drain pending decrypt retries after sync stops", async () => {
+ vi.useFakeTimers();
+ const client = new MatrixClient("https://matrix.example.org", "token");
+ const delivered: string[] = [];
+
+ client.on("room.message", (_roomId, event) => {
+ delivered.push(event.type);
+ });
+
+ const encrypted = new FakeMatrixEvent({
+ roomId: "!room:example.org",
+ eventId: "$event",
+ sender: "@alice:example.org",
+ type: "m.room.encrypted",
+ ts: Date.now(),
+ content: {},
+ decryptionFailure: true,
+ });
+ const decrypted = new FakeMatrixEvent({
+ roomId: "!room:example.org",
+ eventId: "$event",
+ sender: "@alice:example.org",
+ type: "m.room.message",
+ ts: Date.now(),
+ content: {
+ msgtype: "m.text",
+ body: "hello",
+ },
+ });
+
+ matrixJsClient.decryptEventIfNeeded = vi.fn(async () => {
+ encrypted.emit("decrypted", decrypted);
+ });
+
+ await client.start();
+ matrixJsClient.emit("event", encrypted);
+ encrypted.emit("decrypted", encrypted, new Error("missing room key"));
+
+ client.stopSyncWithoutPersist();
+ await client.drainPendingDecryptions("test shutdown");
+
+ expect(matrixJsClient.stopClient).toHaveBeenCalledTimes(1);
+ expect(matrixJsClient.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
+ expect(delivered).toEqual(["m.room.message"]);
+ });
+
it("retries failed decryptions immediately on crypto key update signals", async () => {
vi.useFakeTimers();
const client = new MatrixClient("https://matrix.example.org", "token", undefined, undefined, {
diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts
index f394974106a..4fb0b53389c 100644
--- a/extensions/matrix/src/matrix/sdk.ts
+++ b/extensions/matrix/src/matrix/sdk.ts
@@ -365,11 +365,21 @@ export class MatrixClient {
await this.startSyncSession({ bootstrapCrypto: false });
}
- stop(): void {
+ stopSyncWithoutPersist(): void {
if (this.idbPersistTimer) {
clearInterval(this.idbPersistTimer);
this.idbPersistTimer = null;
}
+ this.client.stopClient();
+ this.started = false;
+ }
+
+ async drainPendingDecryptions(reason = "matrix client shutdown"): Promise {
+ await this.decryptBridge.drainPendingDecryptions(reason);
+ }
+
+ stop(): void {
+ this.stopSyncWithoutPersist();
this.decryptBridge.stop();
// Final persist on shutdown
this.syncStore?.markCleanShutdown();
@@ -380,8 +390,6 @@ export class MatrixClient {
}).catch(noop),
this.syncStore?.flush().catch(noop),
]).then(() => undefined);
- this.client.stopClient();
- this.started = false;
}
async stopAndPersist(): Promise {
diff --git a/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts b/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts
index 1df9e8748bd..1ca35993e91 100644
--- a/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts
+++ b/extensions/matrix/src/matrix/sdk/decrypt-bridge.ts
@@ -51,6 +51,8 @@ export class MatrixDecryptBridge {
private readonly decryptedMessageDedupe = new Map();
private readonly decryptRetries = new Map();
private readonly failedDecryptionsNotified = new Set();
+ private activeRetryRuns = 0;
+ private readonly retryIdleResolvers = new Set<() => void>();
private cryptoRetrySignalsBound = false;
constructor(
@@ -139,6 +141,22 @@ export class MatrixDecryptBridge {
}
}
+ async drainPendingDecryptions(reason: string): Promise {
+ for (let attempts = 0; attempts < MATRIX_DECRYPT_RETRY_MAX_ATTEMPTS; attempts += 1) {
+ if (this.decryptRetries.size === 0) {
+ return;
+ }
+ this.retryPendingNow(reason);
+ await this.waitForActiveRetryRunsToFinish();
+ const hasPendingRetryTimers = Array.from(this.decryptRetries.values()).some(
+ (state) => state.timer || state.inFlight,
+ );
+ if (!hasPendingRetryTimers) {
+ return;
+ }
+ }
+ }
+
private handleEncryptedEventDecrypted(params: {
roomId: string;
encryptedEvent: MatrixEvent;
@@ -246,9 +264,12 @@ export class MatrixDecryptBridge {
state.inFlight = true;
state.timer = null;
+ this.activeRetryRuns += 1;
const canDecrypt = typeof this.deps.client.decryptEventIfNeeded === "function";
if (!canDecrypt) {
this.clearDecryptRetry(retryKey);
+ this.activeRetryRuns = Math.max(0, this.activeRetryRuns - 1);
+ this.resolveRetryIdleIfNeeded();
return;
}
@@ -260,8 +281,13 @@ export class MatrixDecryptBridge {
// Retry with backoff until we hit the configured retry cap.
} finally {
state.inFlight = false;
+ this.activeRetryRuns = Math.max(0, this.activeRetryRuns - 1);
+ this.resolveRetryIdleIfNeeded();
}
+ if (this.decryptRetries.get(retryKey) !== state) {
+ return;
+ }
if (isDecryptionFailure(state.event)) {
this.scheduleDecryptRetry(state);
return;
@@ -304,4 +330,27 @@ export class MatrixDecryptBridge {
this.decryptedMessageDedupe.delete(oldest);
}
}
+
+ private async waitForActiveRetryRunsToFinish(): Promise {
+ if (this.activeRetryRuns === 0) {
+ return;
+ }
+ await new Promise((resolve) => {
+ this.retryIdleResolvers.add(resolve);
+ if (this.activeRetryRuns === 0) {
+ this.retryIdleResolvers.delete(resolve);
+ resolve();
+ }
+ });
+ }
+
+ private resolveRetryIdleIfNeeded(): void {
+ if (this.activeRetryRuns !== 0) {
+ return;
+ }
+ for (const resolve of this.retryIdleResolvers) {
+ resolve();
+ }
+ this.retryIdleResolvers.clear();
+ }
}
diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts
index 5b0f9ff8a07..20e5ba8fd67 100644
--- a/extensions/matrix/src/matrix/send.test.ts
+++ b/extensions/matrix/src/matrix/send.test.ts
@@ -1,5 +1,5 @@
-import type { PluginRuntime } from "openclaw/plugin-sdk/matrix";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import type { PluginRuntime } from "../../runtime-api.js";
import { setMatrixRuntime } from "../runtime.js";
const loadWebMediaMock = vi.fn().mockResolvedValue({
diff --git a/extensions/matrix/src/matrix/thread-bindings-shared.ts b/extensions/matrix/src/matrix/thread-bindings-shared.ts
index 7b5adb5eeda..6c63a731490 100644
--- a/extensions/matrix/src/matrix/thread-bindings-shared.ts
+++ b/extensions/matrix/src/matrix/thread-bindings-shared.ts
@@ -1,8 +1,8 @@
-import { resolveThreadBindingLifecycle } from "openclaw/plugin-sdk/channel-runtime";
import type {
BindingTargetKind,
SessionBindingRecord,
-} from "openclaw/plugin-sdk/conversation-runtime";
+} from "openclaw/plugin-sdk/thread-bindings-runtime";
+import { resolveThreadBindingLifecycle } from "openclaw/plugin-sdk/thread-bindings-runtime";
export type MatrixThreadBindingTargetKind = "subagent" | "acp";
diff --git a/extensions/matrix/src/matrix/thread-bindings.test.ts b/extensions/matrix/src/matrix/thread-bindings.test.ts
index 2b447447c81..be193a920a1 100644
--- a/extensions/matrix/src/matrix/thread-bindings.test.ts
+++ b/extensions/matrix/src/matrix/thread-bindings.test.ts
@@ -1,12 +1,12 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
-import type { PluginRuntime } from "openclaw/plugin-sdk/matrix";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
getSessionBindingService,
__testing,
} from "../../../../src/infra/outbound/session-binding-service.js";
+import type { PluginRuntime } from "../../runtime-api.js";
import { setMatrixRuntime } from "../runtime.js";
import { resolveMatrixStoragePaths } from "./client/storage.js";
import {
@@ -16,31 +16,14 @@ import {
setMatrixThreadBindingMaxAgeBySessionKey,
} from "./thread-bindings.js";
-const pluginSdkActual = vi.hoisted(() => ({
- writeJsonFileAtomically: null as null | ((filePath: string, value: unknown) => Promise),
-}));
-
const sendMessageMatrixMock = vi.hoisted(() =>
vi.fn(async (_to: string, _message: string, opts?: { threadId?: string }) => ({
messageId: opts?.threadId ? "$reply" : "$root",
roomId: "!room:example",
})),
);
-const writeJsonFileAtomicallyMock = vi.hoisted(() =>
- vi.fn<(filePath: string, value: unknown) => Promise>(),
-);
-
-vi.mock("openclaw/plugin-sdk/matrix", async () => {
- const actual = await vi.importActual(
- "openclaw/plugin-sdk/matrix",
- );
- pluginSdkActual.writeJsonFileAtomically = actual.writeJsonFileAtomically;
- return {
- ...actual,
- writeJsonFileAtomically: (filePath: string, value: unknown) =>
- writeJsonFileAtomicallyMock(filePath, value),
- };
-});
+const actualRename = fs.rename.bind(fs);
+const renameMock = vi.spyOn(fs, "rename");
vi.mock("./send.js", async () => {
const actual = await vi.importActual("./send.js");
@@ -83,10 +66,8 @@ describe("matrix thread bindings", () => {
__testing.resetSessionBindingAdaptersForTests();
resetMatrixThreadBindingsForTests();
sendMessageMatrixMock.mockClear();
- writeJsonFileAtomicallyMock.mockReset();
- writeJsonFileAtomicallyMock.mockImplementation(async (filePath: string, value: unknown) => {
- await pluginSdkActual.writeJsonFileAtomically?.(filePath, value);
- });
+ renameMock.mockReset();
+ renameMock.mockImplementation(actualRename);
setMatrixRuntime({
state: {
resolveStateDir: () => stateDir,
@@ -217,7 +198,7 @@ describe("matrix thread bindings", () => {
}
});
- it("persists a batch of expired bindings once per sweep", async () => {
+ it("persists expired bindings after a sweep", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-08T12:00:00.000Z"));
try {
@@ -252,12 +233,8 @@ describe("matrix thread bindings", () => {
placement: "current",
});
- writeJsonFileAtomicallyMock.mockClear();
await vi.advanceTimersByTimeAsync(61_000);
-
- await vi.waitFor(() => {
- expect(writeJsonFileAtomicallyMock).toHaveBeenCalledTimes(1);
- });
+ await Promise.resolve();
await vi.waitFor(async () => {
const persistedRaw = await fs.readFile(resolveBindingsFilePath(), "utf-8");
@@ -297,13 +274,23 @@ describe("matrix thread bindings", () => {
placement: "current",
});
- writeJsonFileAtomicallyMock.mockClear();
- writeJsonFileAtomicallyMock.mockRejectedValueOnce(new Error("disk full"));
+ renameMock.mockRejectedValueOnce(new Error("disk full"));
await vi.advanceTimersByTimeAsync(61_000);
+ await Promise.resolve();
+
+ await vi.waitFor(() => {
+ expect(
+ logVerboseMessage.mock.calls.some(
+ ([message]) =>
+ typeof message === "string" &&
+ message.includes("failed auto-unbinding expired bindings"),
+ ),
+ ).toBe(true);
+ });
await vi.waitFor(() => {
expect(logVerboseMessage).toHaveBeenCalledWith(
- expect.stringContaining("failed auto-unbinding expired bindings"),
+ expect.stringContaining("matrix: auto-unbinding $thread due to idle-expired"),
);
});
diff --git a/extensions/matrix/src/onboarding.resolve.test.ts b/extensions/matrix/src/onboarding.resolve.test.ts
index f1d610aa5d4..270343b7509 100644
--- a/extensions/matrix/src/onboarding.resolve.test.ts
+++ b/extensions/matrix/src/onboarding.resolve.test.ts
@@ -1,5 +1,5 @@
-import type { RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/matrix";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { RuntimeEnv, WizardPrompter } from "../runtime-api.js";
import type { CoreConfig } from "./types.js";
const resolveMatrixTargetsMock = vi.hoisted(() =>
diff --git a/extensions/matrix/src/onboarding.test.ts b/extensions/matrix/src/onboarding.test.ts
index cb5fd1ef445..b27dbf8189f 100644
--- a/extensions/matrix/src/onboarding.test.ts
+++ b/extensions/matrix/src/onboarding.test.ts
@@ -1,5 +1,5 @@
-import type { RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk/matrix";
import { afterEach, describe, expect, it, vi } from "vitest";
+import type { RuntimeEnv, WizardPrompter } from "../runtime-api.js";
import { matrixOnboardingAdapter } from "./onboarding.js";
import { setMatrixRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
diff --git a/extensions/matrix/src/outbound.test.ts b/extensions/matrix/src/outbound.test.ts
index 8f695efec3a..29de2346868 100644
--- a/extensions/matrix/src/outbound.test.ts
+++ b/extensions/matrix/src/outbound.test.ts
@@ -1,5 +1,5 @@
-import type { OpenClawConfig } from "openclaw/plugin-sdk/matrix";
import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { OpenClawConfig } from "../runtime-api.js";
const mocks = vi.hoisted(() => ({
sendMessageMatrix: vi.fn(),
diff --git a/extensions/matrix/src/resolve-targets.test.ts b/extensions/matrix/src/resolve-targets.test.ts
index 801d61f71f5..3f0eb8dfefe 100644
--- a/extensions/matrix/src/resolve-targets.test.ts
+++ b/extensions/matrix/src/resolve-targets.test.ts
@@ -1,5 +1,5 @@
-import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/matrix";
import { describe, expect, it, vi, beforeEach } from "vitest";
+import type { ChannelDirectoryEntry } from "../runtime-api.js";
import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./directory-live.js";
import { resolveMatrixTargets } from "./resolve-targets.js";
diff --git a/extensions/matrix/src/runtime-api.ts b/extensions/matrix/src/runtime-api.ts
index b23758626c0..79a283ac39a 100644
--- a/extensions/matrix/src/runtime-api.ts
+++ b/extensions/matrix/src/runtime-api.ts
@@ -1,4 +1,4 @@
-export * from "openclaw/plugin-sdk/matrix";
+export * from "../../../src/plugin-sdk/matrix.js";
export {
assertHttpUrlTargetsPrivateNetwork,
closeDispatcher,
@@ -8,6 +8,12 @@ export {
type LookupFn,
type SsrFPolicy,
} from "openclaw/plugin-sdk/infra-runtime";
+export {
+ dispatchReplyFromConfigWithSettledDispatcher,
+ ensureConfiguredAcpBindingReady,
+ maybeCreateMatrixMigrationSnapshot,
+ resolveConfiguredAcpBindingRecord,
+} from "openclaw/plugin-sdk/matrix-runtime-heavy";
// Keep auth-precedence available internally without re-exporting helper-api
// twice through both plugin-sdk/matrix and ../runtime-api.js.
export * from "./auth-precedence.js";
diff --git a/extensions/mattermost/runtime-api.ts b/extensions/mattermost/runtime-api.ts
index d4e591c8c1e..2bc65439262 100644
--- a/extensions/mattermost/runtime-api.ts
+++ b/extensions/mattermost/runtime-api.ts
@@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Mattermost extension.
// Keep this barrel thin and aligned with the local extension surface.
-export * from "openclaw/plugin-sdk/mattermost";
+export * from "../../src/plugin-sdk/mattermost.js";
diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts
index 94c5bbff092..476c2c2d19e 100644
--- a/extensions/mattermost/src/channel.ts
+++ b/extensions/mattermost/src/channel.ts
@@ -1,17 +1,19 @@
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
+import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-actions";
import {
createScopedChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
+import type {
+ ChannelMessageActionAdapter,
+ ChannelMessageActionName,
+ ChannelMessageToolDiscovery,
+} from "openclaw/plugin-sdk/channel-contract";
+import { createLoggedPairingApprovalNotifier } from "openclaw/plugin-sdk/channel-pairing";
import { createAllowlistProviderRestrictSendersWarningCollector } from "openclaw/plugin-sdk/channel-policy";
-import {
- createAttachedChannelResultAdapter,
- createChannelDirectoryAdapter,
- createLoggedPairingApprovalNotifier,
- createMessageToolButtonsSchema,
- createScopedAccountReplyToModeResolver,
- type ChannelMessageToolDiscovery,
-} from "openclaw/plugin-sdk/channel-runtime";
+import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
+import { createScopedAccountReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime";
+import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
import { MattermostConfigSchema } from "./config-schema.js";
import { resolveMattermostGroupRequireMention } from "./group-mentions.js";
@@ -39,8 +41,6 @@ import {
DEFAULT_ACCOUNT_ID,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
- type ChannelMessageActionAdapter,
- type ChannelMessageActionName,
type ChannelPlugin,
} from "./runtime-api.js";
import { getMattermostRuntime } from "./runtime.js";
diff --git a/extensions/mattermost/src/session-route.ts b/extensions/mattermost/src/session-route.ts
index 14352708986..39f12e37127 100644
--- a/extensions/mattermost/src/session-route.ts
+++ b/extensions/mattermost/src/session-route.ts
@@ -1,11 +1,11 @@
import {
buildChannelOutboundSessionRoute,
- normalizeOutboundThreadId,
resolveThreadSessionKeys,
stripChannelTargetPrefix,
stripTargetKindPrefix,
type ChannelOutboundSessionRouteParams,
} from "openclaw/plugin-sdk/core";
+import { normalizeOutboundThreadId } from "openclaw/plugin-sdk/routing";
export function resolveMattermostOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
let trimmed = stripChannelTargetPrefix(params.target, "mattermost");
diff --git a/extensions/mattermost/src/setup-core.ts b/extensions/mattermost/src/setup-core.ts
index 36954819fd5..14576f4f5d4 100644
--- a/extensions/mattermost/src/setup-core.ts
+++ b/extensions/mattermost/src/setup-core.ts
@@ -1,4 +1,4 @@
-import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-runtime";
+import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-setup";
import { resolveMattermostAccount, type ResolvedMattermostAccount } from "./mattermost/accounts.js";
import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
import {
diff --git a/extensions/minimax/index.ts b/extensions/minimax/index.ts
index 7dfd9816264..aca00927171 100644
--- a/extensions/minimax/index.ts
+++ b/extensions/minimax/index.ts
@@ -10,7 +10,7 @@ import {
ensureAuthProfileStore,
listProfilesForProvider,
} from "openclaw/plugin-sdk/provider-auth";
-import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth";
+import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth";
import { fetchMinimaxUsage } from "openclaw/plugin-sdk/provider-usage";
import {
minimaxMediaUnderstandingProvider,
diff --git a/extensions/minimax/oauth.ts b/extensions/minimax/oauth.ts
index 20296b2a710..818b29b0372 100644
--- a/extensions/minimax/oauth.ts
+++ b/extensions/minimax/oauth.ts
@@ -1,8 +1,5 @@
import { randomBytes, randomUUID } from "node:crypto";
-import {
- generatePkceVerifierChallenge,
- toFormUrlEncoded,
-} from "openclaw/plugin-sdk/provider-oauth";
+import { generatePkceVerifierChallenge, toFormUrlEncoded } from "openclaw/plugin-sdk/provider-auth";
export type MiniMaxRegion = "cn" | "global";
diff --git a/extensions/msteams/runtime-api.ts b/extensions/msteams/runtime-api.ts
index d32cb7b65d5..e2b75780399 100644
--- a/extensions/msteams/runtime-api.ts
+++ b/extensions/msteams/runtime-api.ts
@@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Microsoft Teams extension.
// Keep this barrel thin and aligned with the local extension surface.
-export * from "openclaw/plugin-sdk/msteams";
+export * from "../../src/plugin-sdk/msteams.js";
diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts
index dc328e46ffc..8a4e66fab9c 100644
--- a/extensions/msteams/src/channel.ts
+++ b/extensions/msteams/src/channel.ts
@@ -1,22 +1,24 @@
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
+import { createMessageToolCardSchema } from "openclaw/plugin-sdk/channel-actions";
import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
+import type {
+ ChannelMessageActionAdapter,
+ ChannelMessageToolDiscovery,
+} from "openclaw/plugin-sdk/channel-contract";
+import {
+ createPairingPrefixStripper,
+ createTextPairingAdapter,
+} from "openclaw/plugin-sdk/channel-pairing";
import {
createAllowlistProviderGroupPolicyWarningCollector,
projectWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import {
createChannelDirectoryAdapter,
- createMessageToolCardSchema,
- createPairingPrefixStripper,
createRuntimeDirectoryLiveAdapter,
- createRuntimeOutboundDelegates,
- createTextPairingAdapter,
-} from "openclaw/plugin-sdk/channel-runtime";
-import type {
- ChannelMessageActionAdapter,
- ChannelMessageToolDiscovery,
-} from "openclaw/plugin-sdk/channel-runtime";
-import { listDirectoryEntriesFromSources } from "openclaw/plugin-sdk/directory-runtime";
+ listDirectoryEntriesFromSources,
+} from "openclaw/plugin-sdk/directory-runtime";
+import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/infra-runtime";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import type { ChannelMessageActionName, ChannelPlugin, OpenClawConfig } from "../runtime-api.js";
import {
diff --git a/extensions/msteams/src/graph-upload.test.ts b/extensions/msteams/src/graph-upload.test.ts
index 43a66e95c3f..45c736e2e1d 100644
--- a/extensions/msteams/src/graph-upload.test.ts
+++ b/extensions/msteams/src/graph-upload.test.ts
@@ -141,7 +141,7 @@ describe("resolveGraphChatId", () => {
}),
);
// Should filter by user AAD object ID
- const callUrl = (fetchFn.mock.calls[0] as unknown as [string, unknown])[0];
+ const callUrl = (fetchFn.mock.calls[0] as unknown[])[0];
expect(callUrl).toContain("user-aad-object-id-123");
expect(result).toBe("19:dm-chat-id@unq.gbl.spaces");
});
diff --git a/extensions/msteams/src/outbound.ts b/extensions/msteams/src/outbound.ts
index cf482825ed2..0e34f637736 100644
--- a/extensions/msteams/src/outbound.ts
+++ b/extensions/msteams/src/outbound.ts
@@ -1,5 +1,5 @@
-import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
+import { resolveOutboundSendDep } from "openclaw/plugin-sdk/infra-runtime";
import type { ChannelOutboundAdapter } from "../runtime-api.js";
import { createMSTeamsPollStoreFs } from "./polls.js";
import { getMSTeamsRuntime } from "./runtime.js";
diff --git a/extensions/msteams/src/resolve-allowlist.ts b/extensions/msteams/src/resolve-allowlist.ts
index 3e28cf8a8cb..a5145bebf0f 100644
--- a/extensions/msteams/src/resolve-allowlist.ts
+++ b/extensions/msteams/src/resolve-allowlist.ts
@@ -1,4 +1,4 @@
-import { mapAllowlistResolutionInputs } from "openclaw/plugin-sdk/allowlist-resolution";
+import { mapAllowlistResolutionInputs } from "openclaw/plugin-sdk/allow-from";
import { searchGraphUsers } from "./graph-users.js";
import {
listChannelsForTeam,
diff --git a/extensions/nextcloud-talk/runtime-api.ts b/extensions/nextcloud-talk/runtime-api.ts
index b2093a7a057..80bc1b1dc7b 100644
--- a/extensions/nextcloud-talk/runtime-api.ts
+++ b/extensions/nextcloud-talk/runtime-api.ts
@@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Nextcloud Talk extension.
// Keep this barrel thin and aligned with the local extension surface.
-export * from "openclaw/plugin-sdk/nextcloud-talk";
+export * from "../../src/plugin-sdk/nextcloud-talk.js";
diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts
index ff316e3a533..880be995ab8 100644
--- a/extensions/nextcloud-talk/src/channel.ts
+++ b/extensions/nextcloud-talk/src/channel.ts
@@ -4,12 +4,12 @@ import {
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
-import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import {
- createAttachedChannelResultAdapter,
createLoggedPairingApprovalNotifier,
createPairingPrefixStripper,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/channel-pairing";
+import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
+import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
import { runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared";
import {
buildBaseChannelStatusSummary,
diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts
index 6aaf7aafbe8..1059cd0a63a 100644
--- a/extensions/nextcloud-talk/src/setup-core.ts
+++ b/extensions/nextcloud-talk/src/setup-core.ts
@@ -1,5 +1,4 @@
-import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-runtime";
-import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-runtime";
+import type { ChannelSetupAdapter, ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
import {
diff --git a/extensions/nextcloud-talk/src/setup-surface.ts b/extensions/nextcloud-talk/src/setup-surface.ts
index 776a9a4fe3e..4aa27c91009 100644
--- a/extensions/nextcloud-talk/src/setup-surface.ts
+++ b/extensions/nextcloud-talk/src/setup-surface.ts
@@ -1,7 +1,7 @@
-import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-runtime";
+import type { ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
-import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/config-runtime";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
+import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
import { setSetupChannelEnabled } from "openclaw/plugin-sdk/setup";
import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup";
import { formatDocsLink } from "openclaw/plugin-sdk/setup";
diff --git a/extensions/nostr/runtime-api.ts b/extensions/nostr/runtime-api.ts
index 29825771891..602b0ac81b7 100644
--- a/extensions/nostr/runtime-api.ts
+++ b/extensions/nostr/runtime-api.ts
@@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Nostr extension.
// Keep this barrel thin and aligned with the local extension surface.
-export * from "openclaw/plugin-sdk/nostr";
+export * from "../../src/plugin-sdk/nostr.js";
diff --git a/extensions/nostr/src/setup-surface.ts b/extensions/nostr/src/setup-surface.ts
index 9c7a1512624..bdcb2ca31bf 100644
--- a/extensions/nostr/src/setup-surface.ts
+++ b/extensions/nostr/src/setup-surface.ts
@@ -1,4 +1,4 @@
-import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-runtime";
+import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-setup";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
import {
diff --git a/extensions/openai/index.test.ts b/extensions/openai/index.test.ts
new file mode 100644
index 00000000000..d1cef565af1
--- /dev/null
+++ b/extensions/openai/index.test.ts
@@ -0,0 +1,397 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import OpenAI from "openai";
+import { describe, expect, it } from "vitest";
+import type { OpenClawConfig } from "../../src/config/config.js";
+import { loadConfig } from "../../src/config/config.js";
+import { encodePngRgba, fillPixel } from "../../src/media/png-encode.js";
+import type { ResolvedTtsConfig } from "../../src/tts/tts.js";
+import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js";
+import plugin from "./index.js";
+
+const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "";
+const LIVE_MODEL_ID = process.env.OPENCLAW_LIVE_OPENAI_PLUGIN_MODEL?.trim() || "gpt-5.4-nano";
+const LIVE_IMAGE_MODEL = process.env.OPENCLAW_LIVE_OPENAI_IMAGE_MODEL?.trim() || "gpt-image-1";
+const LIVE_VISION_MODEL = process.env.OPENCLAW_LIVE_OPENAI_VISION_MODEL?.trim() || "gpt-4.1-mini";
+const liveEnabled = OPENAI_API_KEY.trim().length > 0 && process.env.OPENCLAW_LIVE_TEST === "1";
+const describeLive = liveEnabled ? describe : describe.skip;
+const EMPTY_AUTH_STORE = { version: 1, profiles: {} } as const;
+
+function createTemplateModel(modelId: string) {
+ switch (modelId) {
+ case "gpt-5.4":
+ return {
+ id: "gpt-5.2",
+ name: "GPT-5.2",
+ provider: "openai",
+ api: "openai-completions",
+ baseUrl: "https://api.openai.com/v1",
+ reasoning: true,
+ input: ["text", "image"],
+ cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 400_000,
+ maxTokens: 128_000,
+ };
+ case "gpt-5.4-mini":
+ return {
+ id: "gpt-5-mini",
+ name: "GPT-5 mini",
+ provider: "openai",
+ api: "openai-completions",
+ baseUrl: "https://api.openai.com/v1",
+ reasoning: true,
+ input: ["text", "image"],
+ cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 400_000,
+ maxTokens: 128_000,
+ };
+ case "gpt-5.4-nano":
+ return {
+ id: "gpt-5-nano",
+ name: "GPT-5 nano",
+ provider: "openai",
+ api: "openai-completions",
+ baseUrl: "https://api.openai.com/v1",
+ reasoning: true,
+ input: ["text", "image"],
+ cost: { input: 0.5, output: 1, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 200_000,
+ maxTokens: 64_000,
+ };
+ default:
+ throw new Error(`Unsupported live OpenAI plugin model: ${modelId}`);
+ }
+}
+
+function registerOpenAIPlugin() {
+ const providers: unknown[] = [];
+ const speechProviders: unknown[] = [];
+ const mediaProviders: unknown[] = [];
+ const imageProviders: unknown[] = [];
+
+ plugin.register(
+ createTestPluginApi({
+ id: "openai",
+ name: "OpenAI Provider",
+ source: "test",
+ config: {},
+ runtime: {} as never,
+ registerProvider: (provider) => {
+ providers.push(provider);
+ },
+ registerSpeechProvider: (provider) => {
+ speechProviders.push(provider);
+ },
+ registerMediaUnderstandingProvider: (provider) => {
+ mediaProviders.push(provider);
+ },
+ registerImageGenerationProvider: (provider) => {
+ imageProviders.push(provider);
+ },
+ }),
+ );
+
+ return { providers, speechProviders, mediaProviders, imageProviders };
+}
+
+function createReferencePng(): Buffer {
+ const width = 96;
+ const height = 96;
+ const buf = Buffer.alloc(width * height * 4, 255);
+
+ for (let y = 0; y < height; y += 1) {
+ for (let x = 0; x < width; x += 1) {
+ fillPixel(buf, x, y, width, 225, 242, 255, 255);
+ }
+ }
+
+ for (let y = 24; y < 72; y += 1) {
+ for (let x = 24; x < 72; x += 1) {
+ fillPixel(buf, x, y, width, 255, 153, 51, 255);
+ }
+ }
+
+ return encodePngRgba(buf, width, height);
+}
+
+function createLiveConfig(): OpenClawConfig {
+ const cfg = loadConfig();
+ return {
+ ...cfg,
+ models: {
+ ...cfg.models,
+ providers: {
+ ...cfg.models?.providers,
+ openai: {
+ ...cfg.models?.providers?.openai,
+ apiKey: OPENAI_API_KEY,
+ baseUrl: "https://api.openai.com/v1",
+ },
+ },
+ },
+ } as OpenClawConfig;
+}
+
+function createLiveTtsConfig(): ResolvedTtsConfig {
+ return {
+ auto: "off",
+ mode: "final",
+ provider: "openai",
+ providerSource: "config",
+ modelOverrides: {
+ enabled: true,
+ allowText: true,
+ allowProvider: true,
+ allowVoice: true,
+ allowModelId: true,
+ allowVoiceSettings: true,
+ allowNormalization: true,
+ allowSeed: true,
+ },
+ elevenlabs: {
+ baseUrl: "https://api.elevenlabs.io",
+ voiceId: "",
+ modelId: "eleven_multilingual_v2",
+ voiceSettings: {
+ stability: 0.5,
+ similarityBoost: 0.75,
+ style: 0,
+ useSpeakerBoost: true,
+ speed: 1,
+ },
+ },
+ openai: {
+ apiKey: OPENAI_API_KEY,
+ baseUrl: "https://api.openai.com/v1",
+ model: "gpt-4o-mini-tts",
+ voice: "alloy",
+ },
+ edge: {
+ enabled: false,
+ voice: "en-US-AriaNeural",
+ lang: "en-US",
+ outputFormat: "audio-24khz-48kbitrate-mono-mp3",
+ outputFormatConfigured: false,
+ saveSubtitles: false,
+ },
+ maxTextLength: 4_000,
+ timeoutMs: 30_000,
+ };
+}
+
+async function createTempAgentDir(): Promise {
+ return await fs.mkdtemp(path.join(os.tmpdir(), "openai-plugin-live-"));
+}
+
+describe("openai plugin", () => {
+ it("registers the expected provider surfaces", () => {
+ const { providers, speechProviders, mediaProviders, imageProviders } = registerOpenAIPlugin();
+
+ expect(providers).toHaveLength(2);
+ expect(
+ providers.map(
+ (provider) =>
+ // oxlint-disable-next-line typescript/no-explicit-any
+ (provider as any).id,
+ ),
+ ).toEqual(["openai", "openai-codex"]);
+ expect(speechProviders).toHaveLength(1);
+ expect(mediaProviders).toHaveLength(1);
+ expect(imageProviders).toHaveLength(1);
+ });
+});
+
+describeLive("openai plugin live", () => {
+ it("registers an OpenAI provider that can complete a live request", async () => {
+ const { providers } = registerOpenAIPlugin();
+ const provider =
+ // oxlint-disable-next-line typescript/no-explicit-any
+ providers.find((entry) => (entry as any).id === "openai");
+
+ expect(provider).toBeDefined();
+
+ // oxlint-disable-next-line typescript/no-explicit-any
+ const resolved = (provider as any).resolveDynamicModel?.({
+ provider: "openai",
+ modelId: LIVE_MODEL_ID,
+ modelRegistry: {
+ find(providerId: string, id: string) {
+ if (providerId !== "openai") {
+ return null;
+ }
+ const template = createTemplateModel(LIVE_MODEL_ID);
+ return id === template.id ? template : null;
+ },
+ },
+ });
+
+ expect(resolved).toBeDefined();
+
+ // oxlint-disable-next-line typescript/no-explicit-any
+ const normalized = (provider as any).normalizeResolvedModel?.({
+ provider: "openai",
+ modelId: resolved.id,
+ model: resolved,
+ });
+
+ expect(normalized).toMatchObject({
+ provider: "openai",
+ id: LIVE_MODEL_ID,
+ api: "openai-responses",
+ baseUrl: "https://api.openai.com/v1",
+ });
+
+ const client = new OpenAI({
+ apiKey: OPENAI_API_KEY,
+ baseURL: normalized?.baseUrl,
+ });
+ const response = await client.responses.create({
+ model: normalized?.id ?? LIVE_MODEL_ID,
+ input: "Reply with exactly OK.",
+ max_output_tokens: 16,
+ });
+
+ expect(response.output_text.trim()).toMatch(/^OK[.!]?$/);
+ }, 30_000);
+
+ it("lists voices and synthesizes audio through the registered speech provider", async () => {
+ const { speechProviders } = registerOpenAIPlugin();
+ const speechProvider =
+ // oxlint-disable-next-line typescript/no-explicit-any
+ speechProviders.find((entry) => (entry as any).id === "openai");
+
+ expect(speechProvider).toBeDefined();
+
+ // oxlint-disable-next-line typescript/no-explicit-any
+ const voices = await (speechProvider as any).listVoices?.({});
+ expect(Array.isArray(voices)).toBe(true);
+ expect(voices.map((voice: { id: string }) => voice.id)).toContain("alloy");
+
+ const cfg = createLiveConfig();
+ const ttsConfig = createLiveTtsConfig();
+
+ // oxlint-disable-next-line typescript/no-explicit-any
+ const audioFile = await (speechProvider as any).synthesize({
+ text: "OpenClaw integration test OK.",
+ cfg,
+ config: ttsConfig,
+ target: "audio-file",
+ });
+ expect(audioFile.outputFormat).toBe("mp3");
+ expect(audioFile.fileExtension).toBe(".mp3");
+ expect(audioFile.audioBuffer.byteLength).toBeGreaterThan(512);
+
+ // oxlint-disable-next-line typescript/no-explicit-any
+ const telephony = await (speechProvider as any).synthesizeTelephony?.({
+ text: "Telephony check OK.",
+ cfg,
+ config: ttsConfig,
+ });
+ expect(telephony?.outputFormat).toBe("pcm");
+ expect(telephony?.sampleRate).toBe(24_000);
+ expect(telephony?.audioBuffer.byteLength).toBeGreaterThan(512);
+ }, 45_000);
+
+ it("transcribes synthesized speech through the registered media provider", async () => {
+ const { speechProviders, mediaProviders } = registerOpenAIPlugin();
+ const speechProvider =
+ // oxlint-disable-next-line typescript/no-explicit-any
+ speechProviders.find((entry) => (entry as any).id === "openai");
+ const mediaProvider =
+ // oxlint-disable-next-line typescript/no-explicit-any
+ mediaProviders.find((entry) => (entry as any).id === "openai");
+
+ expect(speechProvider).toBeDefined();
+ expect(mediaProvider).toBeDefined();
+
+ const cfg = createLiveConfig();
+ const ttsConfig = createLiveTtsConfig();
+
+ // oxlint-disable-next-line typescript/no-explicit-any
+ const synthesized = await (speechProvider as any).synthesize({
+ text: "OpenClaw integration test OK.",
+ cfg,
+ config: ttsConfig,
+ target: "audio-file",
+ });
+
+ // oxlint-disable-next-line typescript/no-explicit-any
+ const transcription = await (mediaProvider as any).transcribeAudio?.({
+ buffer: synthesized.audioBuffer,
+ fileName: "openai-plugin-live.mp3",
+ mime: "audio/mpeg",
+ apiKey: OPENAI_API_KEY,
+ timeoutMs: 30_000,
+ });
+
+ const text = String(transcription?.text ?? "").toLowerCase();
+ expect(text.length).toBeGreaterThan(0);
+ expect(text).toContain("openclaw");
+ expect(text).toMatch(/\bok\b/);
+ }, 45_000);
+
+ it("generates an image through the registered image provider", async () => {
+ const { imageProviders } = registerOpenAIPlugin();
+ const imageProvider =
+ // oxlint-disable-next-line typescript/no-explicit-any
+ imageProviders.find((entry) => (entry as any).id === "openai");
+
+ expect(imageProvider).toBeDefined();
+
+ const cfg = createLiveConfig();
+ const agentDir = await createTempAgentDir();
+
+ try {
+ // oxlint-disable-next-line typescript/no-explicit-any
+ const generated = await (imageProvider as any).generateImage({
+ provider: "openai",
+ model: LIVE_IMAGE_MODEL,
+ prompt: "Create a minimal flat orange square centered on a white background.",
+ cfg,
+ agentDir,
+ authStore: EMPTY_AUTH_STORE,
+ timeoutMs: 45_000,
+ size: "1024x1024",
+ });
+
+ expect(generated.model).toBe(LIVE_IMAGE_MODEL);
+ expect(generated.images.length).toBeGreaterThan(0);
+ expect(generated.images[0]?.mimeType).toBe("image/png");
+ expect(generated.images[0]?.buffer.byteLength).toBeGreaterThan(1_000);
+ } finally {
+ await fs.rm(agentDir, { recursive: true, force: true });
+ }
+ }, 60_000);
+
+ it("describes a deterministic image through the registered media provider", async () => {
+ const { mediaProviders } = registerOpenAIPlugin();
+ const mediaProvider =
+ // oxlint-disable-next-line typescript/no-explicit-any
+ mediaProviders.find((entry) => (entry as any).id === "openai");
+
+ expect(mediaProvider).toBeDefined();
+
+ const cfg = createLiveConfig();
+ const agentDir = await createTempAgentDir();
+
+ try {
+ // oxlint-disable-next-line typescript/no-explicit-any
+ const description = await (mediaProvider as any).describeImage?.({
+ buffer: createReferencePng(),
+ fileName: "reference.png",
+ mime: "image/png",
+ prompt: "Reply with one lowercase word for the dominant center color.",
+ timeoutMs: 30_000,
+ agentDir,
+ cfg,
+ model: LIVE_VISION_MODEL,
+ provider: "openai",
+ });
+
+ expect(String(description?.text ?? "").toLowerCase()).toContain("orange");
+ } finally {
+ await fs.rm(agentDir, { recursive: true, force: true });
+ }
+ }, 60_000);
+});
diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts
index 5027f486bb0..36af1146758 100644
--- a/extensions/openai/openai-codex-provider.ts
+++ b/extensions/openai/openai-codex-provider.ts
@@ -9,6 +9,7 @@ import {
listProfilesForProvider,
type OAuthCredential,
} from "openclaw/plugin-sdk/provider-auth";
+import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth";
import { loginOpenAICodexOAuth } from "openclaw/plugin-sdk/provider-auth-login";
import {
DEFAULT_CONTEXT_TOKENS,
@@ -16,7 +17,6 @@ import {
normalizeProviderId,
type ProviderPlugin,
} from "openclaw/plugin-sdk/provider-models";
-import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth";
import { createOpenAIAttributionHeadersWrapper } from "openclaw/plugin-sdk/provider-stream";
import { fetchCodexUsage } from "openclaw/plugin-sdk/provider-usage";
import { buildOpenAICodexProvider } from "./openai-codex-catalog.js";
diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts
index 04ef3700fb3..52182c2b44a 100644
--- a/extensions/openai/openai-provider.test.ts
+++ b/extensions/openai/openai-provider.test.ts
@@ -1,6 +1,73 @@
+import OpenAI from "openai";
import { describe, expect, it } from "vitest";
import { buildOpenAIProvider } from "./openai-provider.js";
+const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "";
+const DEFAULT_LIVE_MODEL_IDS = ["gpt-5.4-mini", "gpt-5.4-nano"] as const;
+const liveEnabled = OPENAI_API_KEY.trim().length > 0 && process.env.OPENCLAW_LIVE_TEST === "1";
+const describeLive = liveEnabled ? describe : describe.skip;
+
+type LiveModelCase = {
+ modelId: string;
+ templateId: string;
+ templateName: string;
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
+ contextWindow: number;
+ maxTokens: number;
+};
+
+function resolveLiveModelCase(modelId: string): LiveModelCase {
+ switch (modelId) {
+ case "gpt-5.4":
+ return {
+ modelId,
+ templateId: "gpt-5.2",
+ templateName: "GPT-5.2",
+ cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 400_000,
+ maxTokens: 128_000,
+ };
+ case "gpt-5.4-pro":
+ return {
+ modelId,
+ templateId: "gpt-5.2-pro",
+ templateName: "GPT-5.2 Pro",
+ cost: { input: 15, output: 60, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 400_000,
+ maxTokens: 128_000,
+ };
+ case "gpt-5.4-mini":
+ return {
+ modelId,
+ templateId: "gpt-5-mini",
+ templateName: "GPT-5 mini",
+ cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 400_000,
+ maxTokens: 128_000,
+ };
+ case "gpt-5.4-nano":
+ return {
+ modelId,
+ templateId: "gpt-5-nano",
+ templateName: "GPT-5 nano",
+ cost: { input: 0.5, output: 1, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 200_000,
+ maxTokens: 64_000,
+ };
+ default:
+ throw new Error(`Unsupported live OpenAI model: ${modelId}`);
+ }
+}
+
+function resolveLiveModelCases(raw?: string): LiveModelCase[] {
+ const requested = raw
+ ?.split(",")
+ .map((value) => value.trim())
+ .filter(Boolean);
+ const modelIds = requested?.length ? requested : [...DEFAULT_LIVE_MODEL_IDS];
+ return [...new Set(modelIds)].map((modelId) => resolveLiveModelCase(modelId));
+}
+
describe("buildOpenAIProvider", () => {
it("resolves gpt-5.4 mini and nano from GPT-5 small-model templates", () => {
const provider = buildOpenAIProvider();
@@ -106,3 +173,69 @@ describe("buildOpenAIProvider", () => {
});
});
});
+
+describeLive("buildOpenAIProvider live", () => {
+ it.each(resolveLiveModelCases(process.env.OPENCLAW_LIVE_OPENAI_MODELS))(
+ "resolves %s and completes through the OpenAI responses API",
+ async (liveCase) => {
+ const provider = buildOpenAIProvider();
+ const registry = {
+ find(providerId: string, id: string) {
+ if (providerId !== "openai") {
+ return null;
+ }
+ if (id === liveCase.templateId) {
+ return {
+ id: liveCase.templateId,
+ name: liveCase.templateName,
+ provider: "openai",
+ api: "openai-completions",
+ baseUrl: "https://api.openai.com/v1",
+ reasoning: true,
+ input: ["text", "image"],
+ cost: liveCase.cost,
+ contextWindow: liveCase.contextWindow,
+ maxTokens: liveCase.maxTokens,
+ };
+ }
+ return null;
+ },
+ };
+
+ const resolved = provider.resolveDynamicModel?.({
+ provider: "openai",
+ modelId: liveCase.modelId,
+ modelRegistry: registry as never,
+ });
+
+ expect(resolved).toBeDefined();
+
+ const normalized = provider.normalizeResolvedModel?.({
+ provider: "openai",
+ modelId: resolved!.id,
+ model: resolved!,
+ });
+
+ expect(normalized).toMatchObject({
+ provider: "openai",
+ id: liveCase.modelId,
+ api: "openai-responses",
+ baseUrl: "https://api.openai.com/v1",
+ });
+
+ const client = new OpenAI({
+ apiKey: OPENAI_API_KEY,
+ baseURL: normalized?.baseUrl,
+ });
+
+ const response = await client.responses.create({
+ model: normalized?.id ?? liveCase.modelId,
+ input: "Reply with exactly OK.",
+ max_output_tokens: 16,
+ });
+
+ expect(response.output_text.trim()).toMatch(/^OK[.!]?$/);
+ },
+ 30_000,
+ );
+});
diff --git a/extensions/openrouter/index.test.ts b/extensions/openrouter/index.test.ts
new file mode 100644
index 00000000000..fa4cbda6cd2
--- /dev/null
+++ b/extensions/openrouter/index.test.ts
@@ -0,0 +1,101 @@
+import OpenAI from "openai";
+import { describe, expect, it } from "vitest";
+import { createTestPluginApi } from "../../test/helpers/extensions/plugin-api.js";
+import plugin from "./index.js";
+
+const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY ?? "";
+const LIVE_MODEL_ID =
+ process.env.OPENCLAW_LIVE_OPENROUTER_PLUGIN_MODEL?.trim() || "openai/gpt-5.4-nano";
+const liveEnabled = OPENROUTER_API_KEY.trim().length > 0 && process.env.OPENCLAW_LIVE_TEST === "1";
+const describeLive = liveEnabled ? describe : describe.skip;
+
+function registerOpenRouterPlugin() {
+ const providers: unknown[] = [];
+ const speechProviders: unknown[] = [];
+ const mediaProviders: unknown[] = [];
+ const imageProviders: unknown[] = [];
+
+ plugin.register(
+ createTestPluginApi({
+ id: "openrouter",
+ name: "OpenRouter Provider",
+ source: "test",
+ config: {},
+ runtime: {} as never,
+ registerProvider: (provider) => {
+ providers.push(provider);
+ },
+ registerSpeechProvider: (provider) => {
+ speechProviders.push(provider);
+ },
+ registerMediaUnderstandingProvider: (provider) => {
+ mediaProviders.push(provider);
+ },
+ registerImageGenerationProvider: (provider) => {
+ imageProviders.push(provider);
+ },
+ }),
+ );
+
+ return { providers, speechProviders, mediaProviders, imageProviders };
+}
+
+describe("openrouter plugin", () => {
+ it("registers the expected provider surfaces", () => {
+ const { providers, speechProviders, mediaProviders, imageProviders } =
+ registerOpenRouterPlugin();
+
+ expect(providers).toHaveLength(1);
+ expect(
+ providers.map(
+ (provider) =>
+ // oxlint-disable-next-line typescript/no-explicit-any
+ (provider as any).id,
+ ),
+ ).toEqual(["openrouter"]);
+ expect(speechProviders).toHaveLength(0);
+ expect(mediaProviders).toHaveLength(0);
+ expect(imageProviders).toHaveLength(0);
+ });
+});
+
+describeLive("openrouter plugin live", () => {
+ it("registers an OpenRouter provider that can complete a live request", async () => {
+ const { providers } = registerOpenRouterPlugin();
+ const provider =
+ // oxlint-disable-next-line typescript/no-explicit-any
+ providers.find((entry) => (entry as any).id === "openrouter");
+
+ expect(provider).toBeDefined();
+
+ // oxlint-disable-next-line typescript/no-explicit-any
+ const resolved = (provider as any).resolveDynamicModel?.({
+ provider: "openrouter",
+ modelId: LIVE_MODEL_ID,
+ modelRegistry: {
+ find() {
+ return null;
+ },
+ },
+ });
+
+ expect(resolved).toMatchObject({
+ provider: "openrouter",
+ id: LIVE_MODEL_ID,
+ api: "openai-completions",
+ baseUrl: "https://openrouter.ai/api/v1",
+ });
+
+ const client = new OpenAI({
+ apiKey: OPENROUTER_API_KEY,
+ baseURL: resolved?.baseUrl,
+ });
+ const response = await client.chat.completions.create({
+ model: resolved?.id ?? LIVE_MODEL_ID,
+ messages: [{ role: "user", content: "Reply with exactly OK." }],
+ max_tokens: 16,
+ });
+
+ expect(response.choices[0]?.message?.content?.trim()).toMatch(/^OK[.!]?$/);
+ }, 30_000);
+});
diff --git a/extensions/qwen-portal-auth/runtime-api.ts b/extensions/qwen-portal-auth/runtime-api.ts
index 52ad77bf6f0..5fbd1e571b4 100644
--- a/extensions/qwen-portal-auth/runtime-api.ts
+++ b/extensions/qwen-portal-auth/runtime-api.ts
@@ -1,10 +1,7 @@
-export { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-oauth";
+export { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth";
export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
export type { ProviderAuthContext, ProviderCatalogContext } from "openclaw/plugin-sdk/plugin-entry";
export { ensureAuthProfileStore, listProfilesForProvider } from "openclaw/plugin-sdk/provider-auth";
export { QWEN_OAUTH_MARKER } from "openclaw/plugin-sdk/agent-runtime";
-export {
- generatePkceVerifierChallenge,
- toFormUrlEncoded,
-} from "openclaw/plugin-sdk/provider-oauth";
+export { generatePkceVerifierChallenge, toFormUrlEncoded } from "openclaw/plugin-sdk/provider-auth";
export { refreshQwenPortalCredentials } from "./refresh.js";
diff --git a/extensions/shared/passive-monitor.ts b/extensions/shared/passive-monitor.ts
index 435f934b123..f9cd2ed58ab 100644
--- a/extensions/shared/passive-monitor.ts
+++ b/extensions/shared/passive-monitor.ts
@@ -1,4 +1,4 @@
-import { runPassiveAccountLifecycle } from "openclaw/plugin-sdk/channel-runtime";
+import { runPassiveAccountLifecycle } from "openclaw/plugin-sdk/channel-lifecycle";
type StoppableMonitor = {
stop: () => void;
diff --git a/extensions/signal/src/channel.ts b/extensions/signal/src/channel.ts
index 6ba7fce6084..9612951c3b4 100644
--- a/extensions/signal/src/channel.ts
+++ b/extensions/signal/src/channel.ts
@@ -1,16 +1,17 @@
import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit";
import {
- attachChannelToResult,
- createAttachedChannelResultAdapter,
createPairingPrefixStripper,
createTextPairingAdapter,
- resolveOutboundSendDep,
-} from "openclaw/plugin-sdk/channel-runtime";
-import { attachChannelToResults } from "openclaw/plugin-sdk/channel-send-result";
+} from "openclaw/plugin-sdk/channel-pairing";
+import {
+ attachChannelToResult,
+ attachChannelToResults,
+ createAttachedChannelResultAdapter,
+} from "openclaw/plugin-sdk/channel-send-result";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
-import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/core";
+import { resolveOutboundSendDep } from "openclaw/plugin-sdk/infra-runtime";
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
-import { type RoutePeer } from "openclaw/plugin-sdk/routing";
+import { buildOutboundBaseSessionKey, type RoutePeer } from "openclaw/plugin-sdk/routing";
import { resolveSignalAccount, type ResolvedSignalAccount } from "./accounts.js";
import { markdownToSignalTextChunks } from "./format.js";
import {
diff --git a/extensions/signal/src/message-actions.ts b/extensions/signal/src/message-actions.ts
index c6082848f02..2645908f3e9 100644
--- a/extensions/signal/src/message-actions.ts
+++ b/extensions/signal/src/message-actions.ts
@@ -1,11 +1,9 @@
-import {
- createActionGate,
- jsonResult,
- readStringParam,
- resolveReactionMessageId,
- type ChannelMessageActionAdapter,
- type ChannelMessageActionName,
-} from "openclaw/plugin-sdk/channel-runtime";
+import { createActionGate, jsonResult, readStringParam } from "openclaw/plugin-sdk/agent-runtime";
+import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions";
+import type {
+ ChannelMessageActionAdapter,
+ ChannelMessageActionName,
+} from "openclaw/plugin-sdk/channel-contract";
import { listEnabledSignalAccounts, resolveSignalAccount } from "./accounts.js";
import { resolveSignalReactionLevel } from "./reaction-level.js";
import { removeReactionSignal, sendReactionSignal } from "./send-reactions.js";
diff --git a/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts
index e8ee7403e38..14fa9bf1f19 100644
--- a/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts
+++ b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts
@@ -1,7 +1,7 @@
+import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
+import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../src/config/config.js";
-import { resolveAgentRoute } from "../../../src/routing/resolve-route.js";
-import { normalizeE164 } from "../../../src/utils.js";
import type { SignalDaemonExitEvent } from "./daemon.js";
import {
createMockSignalDaemonHandle,
@@ -16,16 +16,14 @@ installSignalToolResultTestHooks();
// Import after the harness registers `vi.mock(...)` for Signal internals.
vi.resetModules();
-const [{ peekSystemEvents }, { monitorSignalProvider }] = await Promise.all([
- import("openclaw/plugin-sdk/infra-runtime"),
- import("./monitor.js"),
-]);
+const { monitorSignalProvider } = await import("./monitor.js");
const {
replyMock,
sendMock,
streamMock,
updateLastRouteMock,
+ enqueueSystemEventMock,
upsertPairingRequestMock,
waitForTransportReadyMock,
spawnSignalDaemonMock,
@@ -109,14 +107,23 @@ async function receiveSignalPayloads(params: {
await flush();
}
-function getDirectSignalEventsFor(sender: string) {
+function hasQueuedReactionEventFor(sender: string) {
const route = resolveAgentRoute({
cfg: config as OpenClawConfig,
channel: "signal",
accountId: "default",
peer: { kind: "direct", id: normalizeE164(sender) },
});
- return peekSystemEvents(route.sessionKey);
+ return enqueueSystemEventMock.mock.calls.some(([text, options]) => {
+ return (
+ typeof text === "string" &&
+ text.includes("Signal reaction added") &&
+ typeof options === "object" &&
+ options !== null &&
+ "sessionKey" in options &&
+ (options as { sessionKey?: string }).sessionKey === route.sessionKey
+ );
+ });
}
function makeBaseEnvelope(overrides: Record = {}) {
@@ -383,8 +390,7 @@ describe("monitorSignalProvider tool results", () => {
},
});
- const events = getDirectSignalEventsFor("+15550001111");
- expect(events.some((text) => text.includes("Signal reaction added"))).toBe(true);
+ expect(hasQueuedReactionEventFor("+15550001111")).toBe(true);
});
it.each([
@@ -424,8 +430,7 @@ describe("monitorSignalProvider tool results", () => {
},
});
- const events = getDirectSignalEventsFor("+15550001111");
- expect(events.some((text) => text.includes("Signal reaction added"))).toBe(shouldEnqueue);
+ expect(hasQueuedReactionEventFor("+15550001111")).toBe(shouldEnqueue);
expect(sendMock).not.toHaveBeenCalled();
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
});
@@ -442,8 +447,7 @@ describe("monitorSignalProvider tool results", () => {
},
});
- const events = getDirectSignalEventsFor("+15550001111");
- expect(events.some((text) => text.includes("Signal reaction added"))).toBe(true);
+ expect(hasQueuedReactionEventFor("+15550001111")).toBe(true);
});
it("processes messages when reaction metadata is present", async () => {
diff --git a/extensions/signal/src/monitor.tool-result.test-harness.ts b/extensions/signal/src/monitor.tool-result.test-harness.ts
index 7f1c8b7d7cf..364b86c5bdf 100644
--- a/extensions/signal/src/monitor.tool-result.test-harness.ts
+++ b/extensions/signal/src/monitor.tool-result.test-harness.ts
@@ -4,6 +4,7 @@ import type { SignalDaemonExitEvent, SignalDaemonHandle } from "./daemon.js";
type SignalToolResultTestMocks = {
waitForTransportReadyMock: MockFn;
+ enqueueSystemEventMock: MockFn;
sendMock: MockFn;
replyMock: MockFn;
updateLastRouteMock: MockFn;
@@ -16,6 +17,7 @@ type SignalToolResultTestMocks = {
};
const waitForTransportReadyMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
+const enqueueSystemEventMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
const sendMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
const replyMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
const updateLastRouteMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
@@ -29,6 +31,7 @@ const spawnSignalDaemonMock = vi.hoisted(() => vi.fn()) as unknown as MockFn;
export function getSignalToolResultTestMocks(): SignalToolResultTestMocks {
return {
waitForTransportReadyMock,
+ enqueueSystemEventMock,
sendMock,
replyMock,
updateLastRouteMock,
@@ -162,6 +165,10 @@ vi.mock("openclaw/plugin-sdk/infra-runtime", async () => {
return {
...actual,
waitForTransportReady: (...args: unknown[]) => waitForTransportReadyMock(...args),
+ enqueueSystemEvent: (...args: Parameters) => {
+ enqueueSystemEventMock(...args);
+ return actual.enqueueSystemEvent(...args);
+ },
};
});
@@ -189,6 +196,7 @@ export function installSignalToolResultTestHooks() {
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "PAIRCODE", created: true });
waitForTransportReadyMock.mockReset().mockResolvedValue(undefined);
+ enqueueSystemEventMock.mockReset();
resetSystemEventsForTest();
});
diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts
index b0e601fc01e..9aa32731b1d 100644
--- a/extensions/signal/src/monitor.ts
+++ b/extensions/signal/src/monitor.ts
@@ -9,6 +9,7 @@ import {
import type { BackoffPolicy } from "openclaw/plugin-sdk/infra-runtime";
import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
+import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
@@ -19,7 +20,6 @@ import {
resolveChunkMode,
resolveTextChunkLimit,
} from "openclaw/plugin-sdk/reply-runtime";
-import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-runtime";
import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime";
import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
diff --git a/extensions/signal/src/monitor/event-handler.ts b/extensions/signal/src/monitor/event-handler.ts
index 23eb676ae82..58ff8d4f8d7 100644
--- a/extensions/signal/src/monitor/event-handler.ts
+++ b/extensions/signal/src/monitor/event-handler.ts
@@ -1,32 +1,33 @@
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
-import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
-import { resolveControlCommandGate } from "openclaw/plugin-sdk/channel-runtime";
+import { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
import {
+ buildMentionRegexes,
createChannelInboundDebouncer,
- shouldDebounceTextInbound,
-} from "openclaw/plugin-sdk/channel-runtime";
-import { logInboundDrop, logTypingFailure } from "openclaw/plugin-sdk/channel-runtime";
-import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-runtime";
-import { normalizeSignalMessagingTarget } from "openclaw/plugin-sdk/channel-runtime";
-import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime";
-import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/config-runtime";
-import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
-import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
-import { kindFromMime } from "openclaw/plugin-sdk/media-runtime";
-import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime";
-import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
-import {
formatInboundEnvelope,
formatInboundFromLabel,
+ matchesMentionPatterns,
resolveEnvelopeFormatOptions,
-} from "openclaw/plugin-sdk/reply-runtime";
+ shouldDebounceTextInbound,
+} from "openclaw/plugin-sdk/channel-inbound";
+import {
+ logInboundDrop,
+ resolveMentionGatingWithBypass,
+} from "openclaw/plugin-sdk/channel-inbound";
+import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
+import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
+import { hasControlCommand } from "openclaw/plugin-sdk/command-auth";
+import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/config-runtime";
+import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
+import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
+import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
+import { kindFromMime } from "openclaw/plugin-sdk/media-runtime";
import {
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
recordPendingHistoryEntryIfEnabled,
-} from "openclaw/plugin-sdk/reply-runtime";
+} from "openclaw/plugin-sdk/reply-history";
+import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime";
-import { buildMentionRegexes, matchesMentionPatterns } from "openclaw/plugin-sdk/reply-runtime";
import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
@@ -46,6 +47,7 @@ import {
resolveSignalSender,
type SignalSender,
} from "../identity.js";
+import { normalizeSignalMessagingTarget } from "../runtime-api.js";
import { sendMessageSignal, sendReadReceiptSignal, sendTypingSignal } from "../send.js";
import { handleSignalDirectMessageAccess, resolveSignalAccessState } from "./access-policy.js";
import type {
diff --git a/extensions/signal/src/monitor/event-handler.types.ts b/extensions/signal/src/monitor/event-handler.types.ts
index 82a96af73cc..4ccb85cde5d 100644
--- a/extensions/signal/src/monitor/event-handler.types.ts
+++ b/extensions/signal/src/monitor/event-handler.types.ts
@@ -4,7 +4,7 @@ import type {
GroupPolicy,
SignalReactionNotificationMode,
} from "openclaw/plugin-sdk/config-runtime";
-import type { HistoryEntry } from "openclaw/plugin-sdk/reply-runtime";
+import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import type { SignalSender } from "../identity.js";
diff --git a/extensions/signal/src/outbound-adapter.ts b/extensions/signal/src/outbound-adapter.ts
index 4471871b69b..08d54ddd052 100644
--- a/extensions/signal/src/outbound-adapter.ts
+++ b/extensions/signal/src/outbound-adapter.ts
@@ -1,12 +1,12 @@
-import { createScopedChannelMediaMaxBytesResolver } from "openclaw/plugin-sdk/channel-runtime";
-import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime";
-import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/channel-runtime";
+import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result";
import {
attachChannelToResult,
attachChannelToResults,
createAttachedChannelResultAdapter,
} from "openclaw/plugin-sdk/channel-send-result";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
+import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/infra-runtime";
+import { createScopedChannelMediaMaxBytesResolver } from "openclaw/plugin-sdk/media-runtime";
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
import { markdownToSignalTextChunks } from "./format.js";
import { sendMessageSignal } from "./send.js";
diff --git a/extensions/signal/src/probe.ts b/extensions/signal/src/probe.ts
index ac7dce428e8..4fd26f12355 100644
--- a/extensions/signal/src/probe.ts
+++ b/extensions/signal/src/probe.ts
@@ -1,4 +1,4 @@
-import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-runtime";
+import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
import { signalCheck, signalRpcRequest } from "./client.js";
export type SignalProbe = BaseProbeResult & {
diff --git a/extensions/signal/src/runtime-api.ts b/extensions/signal/src/runtime-api.ts
index 6aeeef0adb1..172943641f8 100644
--- a/extensions/signal/src/runtime-api.ts
+++ b/extensions/signal/src/runtime-api.ts
@@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Signal extension.
// Keep this barrel thin and aligned with the local extension surface.
-export * from "openclaw/plugin-sdk/signal";
+export * from "../../../src/plugin-sdk/signal.js";
diff --git a/extensions/slack/src/account-inspect.ts b/extensions/slack/src/account-inspect.ts
index a1620cfe33b..f465ccf2d79 100644
--- a/extensions/slack/src/account-inspect.ts
+++ b/extensions/slack/src/account-inspect.ts
@@ -6,7 +6,7 @@ import {
import {
hasConfiguredSecretInput,
normalizeSecretInputString,
-} from "openclaw/plugin-sdk/config-runtime";
+} from "openclaw/plugin-sdk/secret-input";
import type { SlackAccountSurfaceFields } from "./account-surface-fields.js";
import {
mergeSlackAccountConfig,
diff --git a/extensions/slack/src/channel-actions.ts b/extensions/slack/src/channel-actions.ts
index 3d9c2417306..4502ddb36a4 100644
--- a/extensions/slack/src/channel-actions.ts
+++ b/extensions/slack/src/channel-actions.ts
@@ -2,7 +2,7 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import {
type ChannelMessageActionAdapter,
type ChannelMessageToolDiscovery,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/channel-contract";
import type { SlackActionContext } from "./action-runtime.js";
import { handleSlackAction } from "./action-runtime.js";
import { handleSlackMessageAction } from "./message-action-dispatch.js";
diff --git a/extensions/slack/src/channel.test.ts b/extensions/slack/src/channel.test.ts
index 691b6126557..e9659c14d7c 100644
--- a/extensions/slack/src/channel.test.ts
+++ b/extensions/slack/src/channel.test.ts
@@ -1,7 +1,7 @@
-import type { OpenClawConfig } from "openclaw/plugin-sdk/slack";
import { describe, expect, it, vi } from "vitest";
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
import { slackOutbound } from "./outbound-adapter.js";
+import type { OpenClawConfig } from "./runtime-api.js";
const handleSlackActionMock = vi.fn();
diff --git a/extensions/slack/src/channel.ts b/extensions/slack/src/channel.ts
index 7a27e73aa8d..3a2646c0152 100644
--- a/extensions/slack/src/channel.ts
+++ b/extensions/slack/src/channel.ts
@@ -4,20 +4,29 @@ import {
createFlatAllowlistOverrideResolver,
} from "openclaw/plugin-sdk/allowlist-config-edit";
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
-import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import {
- createAttachedChannelResultAdapter,
- createChannelDirectoryAdapter,
createPairingPrefixStripper,
- createScopedAccountReplyToModeResolver,
- createRuntimeDirectoryLiveAdapter,
createTextPairingAdapter,
- resolveOutboundSendDep,
- resolveTargetsWithOptionalToken,
-} from "openclaw/plugin-sdk/channel-runtime";
-import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core";
+} from "openclaw/plugin-sdk/channel-pairing";
+import { createOpenProviderConfiguredRouteWarningCollector } from "openclaw/plugin-sdk/channel-policy";
+import { createAttachedChannelResultAdapter } from "openclaw/plugin-sdk/channel-send-result";
+import { resolveTargetsWithOptionalToken } from "openclaw/plugin-sdk/channel-targets";
+import { createScopedAccountReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime";
+import {
+ createChannelDirectoryAdapter,
+ createRuntimeDirectoryLiveAdapter,
+} from "openclaw/plugin-sdk/directory-runtime";
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
-import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing";
+import {
+ createRuntimeOutboundDelegates,
+ resolveOutboundSendDep,
+} from "openclaw/plugin-sdk/infra-runtime";
+import {
+ buildOutboundBaseSessionKey,
+ normalizeOutboundThreadId,
+ resolveThreadSessionKeys,
+ type RoutePeer,
+} from "openclaw/plugin-sdk/routing";
import {
listEnabledSlackAccounts,
resolveSlackAccount,
diff --git a/extensions/slack/src/config-schema.ts b/extensions/slack/src/config-schema.ts
index d5f28cf7905..5b2e38e1665 100644
--- a/extensions/slack/src/config-schema.ts
+++ b/extensions/slack/src/config-schema.ts
@@ -1,3 +1,3 @@
-import { buildChannelConfigSchema, SlackConfigSchema } from "openclaw/plugin-sdk/slack-core";
+import { buildChannelConfigSchema, SlackConfigSchema } from "./runtime-api.js";
export const SlackChannelConfigSchema = buildChannelConfigSchema(SlackConfigSchema);
diff --git a/extensions/slack/src/directory-live.ts b/extensions/slack/src/directory-live.ts
index 0a8bd04af22..93d83978268 100644
--- a/extensions/slack/src/directory-live.ts
+++ b/extensions/slack/src/directory-live.ts
@@ -1,5 +1,7 @@
-import type { DirectoryConfigParams } from "openclaw/plugin-sdk/channel-runtime";
-import type { ChannelDirectoryEntry } from "openclaw/plugin-sdk/channel-runtime";
+import type {
+ ChannelDirectoryEntry,
+ DirectoryConfigParams,
+} from "openclaw/plugin-sdk/directory-runtime";
import { resolveSlackAccount } from "./accounts.js";
import { createSlackWebClient } from "./client.js";
diff --git a/extensions/slack/src/draft-stream.ts b/extensions/slack/src/draft-stream.ts
index f122e2664c5..c4840b938fe 100644
--- a/extensions/slack/src/draft-stream.ts
+++ b/extensions/slack/src/draft-stream.ts
@@ -1,4 +1,4 @@
-import { createDraftStreamLoop } from "openclaw/plugin-sdk/channel-runtime";
+import { createDraftStreamLoop } from "openclaw/plugin-sdk/channel-lifecycle";
import { deleteSlackMessage, editSlackMessage } from "./actions.js";
import { sendMessageSlack } from "./send.js";
diff --git a/extensions/slack/src/group-policy.ts b/extensions/slack/src/group-policy.ts
index d49138fb5f8..b77a63c7a81 100644
--- a/extensions/slack/src/group-policy.ts
+++ b/extensions/slack/src/group-policy.ts
@@ -1,9 +1,9 @@
+import type { ChannelGroupContext } from "openclaw/plugin-sdk/channel-contract";
import {
resolveToolsBySender,
type GroupToolPolicyBySenderConfig,
type GroupToolPolicyConfig,
} from "openclaw/plugin-sdk/channel-policy";
-import { type ChannelGroupContext } from "openclaw/plugin-sdk/channel-runtime";
import { normalizeHyphenSlug } from "openclaw/plugin-sdk/core";
import { inspectSlackAccount } from "./account-inspect.js";
diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts
index 55576d9e822..372ae915700 100644
--- a/extensions/slack/src/message-action-dispatch.ts
+++ b/extensions/slack/src/message-action-dispatch.ts
@@ -1,9 +1,9 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
-import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-runtime";
+import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract";
import { normalizeInteractiveReply } from "openclaw/plugin-sdk/interactive-runtime";
-import { readNumberParam, readStringParam } from "openclaw/plugin-sdk/slack-core";
import { parseSlackBlocksInput } from "./blocks-input.js";
import { buildSlackInteractiveBlocks } from "./blocks-render.js";
+import { readNumberParam, readStringParam } from "./runtime-api.js";
type SlackActionInvoke = (
action: Record,
diff --git a/extensions/slack/src/message-actions.ts b/extensions/slack/src/message-actions.ts
index 938659c9354..5eb3bdb9e76 100644
--- a/extensions/slack/src/message-actions.ts
+++ b/extensions/slack/src/message-actions.ts
@@ -1,9 +1,7 @@
import { createActionGate } from "openclaw/plugin-sdk/agent-runtime";
-import type {
- ChannelMessageActionName,
- ChannelToolSend,
-} from "openclaw/plugin-sdk/channel-runtime";
+import type { ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
+import type { ChannelToolSend } from "openclaw/plugin-sdk/tool-send";
import { listEnabledSlackAccounts } from "./accounts.js";
export function listSlackMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] {
diff --git a/extensions/slack/src/monitor/allow-list.ts b/extensions/slack/src/monitor/allow-list.ts
index 32fb7f40530..0ae6de23ec1 100644
--- a/extensions/slack/src/monitor/allow-list.ts
+++ b/extensions/slack/src/monitor/allow-list.ts
@@ -2,7 +2,7 @@ import {
compileAllowlist,
resolveCompiledAllowlistMatch,
type AllowlistMatch,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/allow-from";
import {
normalizeHyphenSlug,
normalizeStringEntries,
diff --git a/extensions/slack/src/monitor/channel-config.ts b/extensions/slack/src/monitor/channel-config.ts
index 32ad0e6f022..4aca5fc1422 100644
--- a/extensions/slack/src/monitor/channel-config.ts
+++ b/extensions/slack/src/monitor/channel-config.ts
@@ -3,7 +3,7 @@ import {
buildChannelKeyCandidates,
resolveChannelEntryMatchWithFallback,
type ChannelMatchSource,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/channel-targets";
import type { SlackReactionNotificationMode } from "openclaw/plugin-sdk/config-runtime";
import type { SlackMessageEvent } from "../types.js";
import { allowListMatches, normalizeAllowListLower, normalizeSlackSlug } from "./allow-list.js";
diff --git a/extensions/slack/src/monitor/context.ts b/extensions/slack/src/monitor/context.ts
index f39a92ce207..0d3f5706697 100644
--- a/extensions/slack/src/monitor/context.ts
+++ b/extensions/slack/src/monitor/context.ts
@@ -1,5 +1,5 @@
import type { App } from "@slack/bolt";
-import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/channel-runtime";
+import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from";
import type {
OpenClawConfig,
SlackReactionNotificationMode,
@@ -7,7 +7,7 @@ import type {
import { resolveSessionKey, type SessionScope } from "openclaw/plugin-sdk/config-runtime";
import type { DmPolicy, GroupPolicy } from "openclaw/plugin-sdk/config-runtime";
import { createDedupeCache } from "openclaw/plugin-sdk/infra-runtime";
-import type { HistoryEntry } from "openclaw/plugin-sdk/reply-runtime";
+import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { getChildLogger } from "openclaw/plugin-sdk/runtime-env";
diff --git a/extensions/slack/src/monitor/dm-auth.ts b/extensions/slack/src/monitor/dm-auth.ts
index 75a0515bce7..0783fa17acf 100644
--- a/extensions/slack/src/monitor/dm-auth.ts
+++ b/extensions/slack/src/monitor/dm-auth.ts
@@ -1,5 +1,5 @@
+import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/allow-from";
import { createChannelPairingChallengeIssuer } from "openclaw/plugin-sdk/channel-pairing";
-import { formatAllowlistMatchMeta } from "openclaw/plugin-sdk/channel-runtime";
import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime";
import { resolveSlackAllowListMatch } from "./allow-list.js";
import type { SlackMonitorContext } from "./context.js";
diff --git a/extensions/slack/src/monitor/events/channels.ts b/extensions/slack/src/monitor/events/channels.ts
index e4940f80d9f..47fdc2647c4 100644
--- a/extensions/slack/src/monitor/events/channels.ts
+++ b/extensions/slack/src/monitor/events/channels.ts
@@ -1,5 +1,5 @@
import type { SlackEventMiddlewareArgs } from "@slack/bolt";
-import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-runtime";
+import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-helpers";
import { loadConfig, writeConfigFile } from "openclaw/plugin-sdk/config-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
import { danger, warn } from "openclaw/plugin-sdk/runtime-env";
diff --git a/extensions/slack/src/monitor/message-handler.ts b/extensions/slack/src/monitor/message-handler.ts
index feaddff98df..fb700b78350 100644
--- a/extensions/slack/src/monitor/message-handler.ts
+++ b/extensions/slack/src/monitor/message-handler.ts
@@ -1,7 +1,7 @@
import {
createChannelInboundDebouncer,
shouldDebounceTextInbound,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/channel-inbound";
import type { ResolvedSlackAccount } from "../accounts.js";
import type { SlackMessageEvent } from "../types.js";
import { stripSlackMentionsForCommandDetection } from "./commands.js";
diff --git a/extensions/slack/src/monitor/message-handler/dispatch.ts b/extensions/slack/src/monitor/message-handler/dispatch.ts
index 2b31791284e..f3860c2f6bd 100644
--- a/extensions/slack/src/monitor/message-handler/dispatch.ts
+++ b/extensions/slack/src/monitor/message-handler/dispatch.ts
@@ -1,12 +1,15 @@
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
+import {
+ logAckFailure,
+ logTypingFailure,
+ removeAckReactionAfterReply,
+} from "openclaw/plugin-sdk/channel-feedback";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
-import { removeAckReactionAfterReply } from "openclaw/plugin-sdk/channel-runtime";
-import { logAckFailure, logTypingFailure } from "openclaw/plugin-sdk/channel-runtime";
import { resolveStorePath, updateLastRoute } from "openclaw/plugin-sdk/config-runtime";
import { resolveAgentOutboundIdentity } from "openclaw/plugin-sdk/infra-runtime";
+import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { dispatchInboundMessage } from "openclaw/plugin-sdk/reply-runtime";
-import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-runtime";
import { createReplyDispatcherWithTyping } from "openclaw/plugin-sdk/reply-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
diff --git a/extensions/slack/src/monitor/message-handler/prepare-thread-context.ts b/extensions/slack/src/monitor/message-handler/prepare-thread-context.ts
index 5d4020f1b46..e1cfc33088a 100644
--- a/extensions/slack/src/monitor/message-handler/prepare-thread-context.ts
+++ b/extensions/slack/src/monitor/message-handler/prepare-thread-context.ts
@@ -1,5 +1,5 @@
+import { formatInboundEnvelope } from "openclaw/plugin-sdk/channel-inbound";
import { readSessionUpdatedAt } from "openclaw/plugin-sdk/config-runtime";
-import { formatInboundEnvelope } from "openclaw/plugin-sdk/reply-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { ResolvedSlackAccount } from "../../accounts.js";
import type { SlackMessageEvent } from "../../types.js";
@@ -30,7 +30,7 @@ export async function resolveSlackThreadContextData(params: {
storePath: string;
sessionKey: string;
envelopeOptions: ReturnType<
- typeof import("openclaw/plugin-sdk/reply-runtime").resolveEnvelopeFormatOptions
+ typeof import("openclaw/plugin-sdk/channel-inbound").resolveEnvelopeFormatOptions
>;
effectiveDirectMedia: SlackMediaResult[] | null;
}): Promise {
diff --git a/extensions/slack/src/monitor/message-handler/prepare.ts b/extensions/slack/src/monitor/message-handler/prepare.ts
index e6bc3a23446..1f36eef491c 100644
--- a/extensions/slack/src/monitor/message-handler/prepare.ts
+++ b/extensions/slack/src/monitor/message-handler/prepare.ts
@@ -2,26 +2,29 @@ import { resolveAckReaction } from "openclaw/plugin-sdk/agent-runtime";
import {
shouldAckReaction as shouldAckReactionGate,
type AckReactionScope,
-} from "openclaw/plugin-sdk/channel-runtime";
-import { resolveControlCommandGate } from "openclaw/plugin-sdk/channel-runtime";
-import { resolveConversationLabel } from "openclaw/plugin-sdk/channel-runtime";
-import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime";
-import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-runtime";
-import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime";
-import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
-import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
-import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime";
-import { shouldHandleTextCommands } from "openclaw/plugin-sdk/reply-runtime";
+} from "openclaw/plugin-sdk/channel-feedback";
import {
+ buildMentionRegexes,
formatInboundEnvelope,
+ logInboundDrop,
+ matchesMentionWithExplicit,
resolveEnvelopeFormatOptions,
-} from "openclaw/plugin-sdk/reply-runtime";
+ resolveMentionGatingWithBypass,
+} from "openclaw/plugin-sdk/channel-inbound";
+import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
+import { hasControlCommand } from "openclaw/plugin-sdk/command-auth";
+import { shouldHandleTextCommands } from "openclaw/plugin-sdk/command-auth";
+import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
+import {
+ recordInboundSession,
+ resolveConversationLabel,
+} from "openclaw/plugin-sdk/conversation-runtime";
+import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
import {
buildPendingHistoryContextFromMap,
recordPendingHistoryEntryIfEnabled,
-} from "openclaw/plugin-sdk/reply-runtime";
+} from "openclaw/plugin-sdk/reply-history";
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime";
-import { buildMentionRegexes, matchesMentionWithExplicit } from "openclaw/plugin-sdk/reply-runtime";
import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
diff --git a/extensions/slack/src/monitor/provider.ts b/extensions/slack/src/monitor/provider.ts
index 5a382551b47..1af83676e93 100644
--- a/extensions/slack/src/monitor/provider.ts
+++ b/extensions/slack/src/monitor/provider.ts
@@ -6,7 +6,7 @@ import {
mergeAllowlist,
patchAllowlistUsersInConfigEntries,
summarizeMapping,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/allow-from";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime";
import {
@@ -15,15 +15,15 @@ import {
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/config-runtime";
import type { SessionScope } from "openclaw/plugin-sdk/config-runtime";
-import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime";
import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime";
import { computeBackoff, sleepWithAbort } from "openclaw/plugin-sdk/infra-runtime";
import { installRequestBodyLimitGuard } from "openclaw/plugin-sdk/infra-runtime";
+import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history";
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
-import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeMainKey } from "openclaw/plugin-sdk/routing";
import { warn } from "openclaw/plugin-sdk/runtime-env";
import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
+import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
import { normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime";
import { resolveSlackAccount } from "../accounts.js";
import { resolveSlackWebClientOptions } from "../client.js";
diff --git a/extensions/slack/src/monitor/slash-commands.runtime.ts b/extensions/slack/src/monitor/slash-commands.runtime.ts
index aaae82a0602..6659ae61031 100644
--- a/extensions/slack/src/monitor/slash-commands.runtime.ts
+++ b/extensions/slack/src/monitor/slash-commands.runtime.ts
@@ -4,17 +4,17 @@ import {
listNativeCommandSpecsForConfig as listNativeCommandSpecsForConfigImpl,
parseCommandArgs as parseCommandArgsImpl,
resolveCommandArgMenu as resolveCommandArgMenuImpl,
-} from "openclaw/plugin-sdk/reply-runtime";
+} from "openclaw/plugin-sdk/command-auth";
type BuildCommandTextFromArgs =
- typeof import("openclaw/plugin-sdk/reply-runtime").buildCommandTextFromArgs;
+ typeof import("openclaw/plugin-sdk/command-auth").buildCommandTextFromArgs;
type FindCommandByNativeName =
- typeof import("openclaw/plugin-sdk/reply-runtime").findCommandByNativeName;
+ typeof import("openclaw/plugin-sdk/command-auth").findCommandByNativeName;
type ListNativeCommandSpecsForConfig =
- typeof import("openclaw/plugin-sdk/reply-runtime").listNativeCommandSpecsForConfig;
-type ParseCommandArgs = typeof import("openclaw/plugin-sdk/reply-runtime").parseCommandArgs;
+ typeof import("openclaw/plugin-sdk/command-auth").listNativeCommandSpecsForConfig;
+type ParseCommandArgs = typeof import("openclaw/plugin-sdk/command-auth").parseCommandArgs;
type ResolveCommandArgMenu =
- typeof import("openclaw/plugin-sdk/reply-runtime").resolveCommandArgMenu;
+ typeof import("openclaw/plugin-sdk/command-auth").resolveCommandArgMenu;
export function buildCommandTextFromArgs(
...args: Parameters
diff --git a/extensions/slack/src/monitor/slash-dispatch.runtime.ts b/extensions/slack/src/monitor/slash-dispatch.runtime.ts
index affa13c01dd..a9c7eaba1d3 100644
--- a/extensions/slack/src/monitor/slash-dispatch.runtime.ts
+++ b/extensions/slack/src/monitor/slash-dispatch.runtime.ts
@@ -1,8 +1,8 @@
+import { resolveMarkdownTableMode as resolveMarkdownTableModeImpl } from "openclaw/plugin-sdk/config-runtime";
import {
recordInboundSessionMetaSafe as recordInboundSessionMetaSafeImpl,
resolveConversationLabel as resolveConversationLabelImpl,
-} from "openclaw/plugin-sdk/channel-runtime";
-import { resolveMarkdownTableMode as resolveMarkdownTableModeImpl } from "openclaw/plugin-sdk/config-runtime";
+} from "openclaw/plugin-sdk/conversation-runtime";
import {
dispatchReplyWithDispatcher as dispatchReplyWithDispatcherImpl,
finalizeInboundContext as finalizeInboundContextImpl,
@@ -17,9 +17,9 @@ type FinalizeInboundContext =
type DispatchReplyWithDispatcher =
typeof import("openclaw/plugin-sdk/reply-runtime").dispatchReplyWithDispatcher;
type ResolveConversationLabel =
- typeof import("openclaw/plugin-sdk/channel-runtime").resolveConversationLabel;
+ typeof import("openclaw/plugin-sdk/conversation-runtime").resolveConversationLabel;
type RecordInboundSessionMetaSafe =
- typeof import("openclaw/plugin-sdk/channel-runtime").recordInboundSessionMetaSafe;
+ typeof import("openclaw/plugin-sdk/conversation-runtime").recordInboundSessionMetaSafe;
type ResolveMarkdownTableMode =
typeof import("openclaw/plugin-sdk/config-runtime").resolveMarkdownTableMode;
type ResolveAgentRoute = typeof import("openclaw/plugin-sdk/routing").resolveAgentRoute;
diff --git a/extensions/slack/src/monitor/slash-skill-commands.runtime.ts b/extensions/slack/src/monitor/slash-skill-commands.runtime.ts
index ec25e104fec..926eb5a3932 100644
--- a/extensions/slack/src/monitor/slash-skill-commands.runtime.ts
+++ b/extensions/slack/src/monitor/slash-skill-commands.runtime.ts
@@ -1,7 +1,7 @@
-import { listSkillCommandsForAgents as listSkillCommandsForAgentsImpl } from "openclaw/plugin-sdk/reply-runtime";
+import { listSkillCommandsForAgents as listSkillCommandsForAgentsImpl } from "openclaw/plugin-sdk/command-auth";
type ListSkillCommandsForAgents =
- typeof import("openclaw/plugin-sdk/reply-runtime").listSkillCommandsForAgents;
+ typeof import("openclaw/plugin-sdk/command-auth").listSkillCommandsForAgents;
export function listSkillCommandsForAgents(
...args: Parameters
diff --git a/extensions/slack/src/monitor/slash.test-harness.ts b/extensions/slack/src/monitor/slash.test-harness.ts
index 48a11cf3460..c8d4fb811b0 100644
--- a/extensions/slack/src/monitor/slash.test-harness.ts
+++ b/extensions/slack/src/monitor/slash.test-harness.ts
@@ -7,7 +7,6 @@ const mocks = vi.hoisted(() => ({
resolveAgentRouteMock: vi.fn(),
finalizeInboundContextMock: vi.fn(),
resolveConversationLabelMock: vi.fn(),
- createReplyPrefixOptionsMock: vi.fn(),
recordSessionMetaFromInboundMock: vi.fn(),
resolveStorePathMock: vi.fn(),
}));
@@ -21,15 +20,6 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
};
});
-vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- readChannelAllowFromStore: (...args: unknown[]) => mocks.readAllowFromStoreMock(...args),
- upsertChannelPairingRequest: (...args: unknown[]) => mocks.upsertPairingRequestMock(...args),
- };
-});
-
vi.mock("openclaw/plugin-sdk/routing", async (importOriginal) => {
const actual = await importOriginal();
return {
@@ -38,12 +28,11 @@ vi.mock("openclaw/plugin-sdk/routing", async (importOriginal) => {
};
});
-vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => {
- const actual = await importOriginal();
+vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
+ const actual = await importOriginal();
return {
...actual,
resolveConversationLabel: (...args: unknown[]) => mocks.resolveConversationLabelMock(...args),
- createReplyPrefixOptions: (...args: unknown[]) => mocks.createReplyPrefixOptionsMock(...args),
recordInboundSessionMetaSafe: (...args: unknown[]) =>
mocks.recordSessionMetaFromInboundMock(...args),
};
@@ -64,7 +53,6 @@ type SlashHarnessMocks = {
resolveAgentRouteMock: ReturnType;
finalizeInboundContextMock: ReturnType;
resolveConversationLabelMock: ReturnType;
- createReplyPrefixOptionsMock: ReturnType;
recordSessionMetaFromInboundMock: ReturnType;
resolveStorePathMock: ReturnType;
};
@@ -84,7 +72,6 @@ export function resetSlackSlashMocks() {
});
mocks.finalizeInboundContextMock.mockReset().mockImplementation((ctx: unknown) => ctx);
mocks.resolveConversationLabelMock.mockReset().mockReturnValue(undefined);
- mocks.createReplyPrefixOptionsMock.mockReset().mockReturnValue({ onModelSelected: () => {} });
mocks.recordSessionMetaFromInboundMock.mockReset().mockResolvedValue(undefined);
mocks.resolveStorePathMock.mockReset().mockReturnValue("/tmp/openclaw-sessions.json");
}
diff --git a/extensions/slack/src/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts
index e06b22d2e91..6ff790e42b2 100644
--- a/extensions/slack/src/monitor/slash.ts
+++ b/extensions/slack/src/monitor/slash.ts
@@ -1,12 +1,14 @@
import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs } from "@slack/bolt";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
-import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime";
-import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/channel-runtime";
+import {
+ resolveCommandAuthorizedFromAuthorizers,
+ resolveNativeCommandSessionTargets,
+} from "openclaw/plugin-sdk/command-auth";
+import { type ChatCommandDefinition, type CommandArgs } from "openclaw/plugin-sdk/command-auth";
import {
resolveNativeCommandsEnabled,
resolveNativeSkillsEnabled,
} from "openclaw/plugin-sdk/config-runtime";
-import { type ChatCommandDefinition, type CommandArgs } from "openclaw/plugin-sdk/reply-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { chunkItems } from "openclaw/plugin-sdk/text-runtime";
diff --git a/extensions/slack/src/outbound-adapter.ts b/extensions/slack/src/outbound-adapter.ts
index ed107d4c63f..ee3946dde9b 100644
--- a/extensions/slack/src/outbound-adapter.ts
+++ b/extensions/slack/src/outbound-adapter.ts
@@ -1,20 +1,19 @@
-import {
- resolvePayloadMediaUrls,
- sendPayloadMediaSequenceAndFinalize,
- sendTextMediaPayload,
-} from "openclaw/plugin-sdk/channel-runtime";
-import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime";
-import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
import {
attachChannelToResult,
+ type ChannelOutboundAdapter,
createAttachedChannelResultAdapter,
} from "openclaw/plugin-sdk/channel-send-result";
-import type { OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime";
+import { resolveOutboundSendDep, type OutboundIdentity } from "openclaw/plugin-sdk/infra-runtime";
import {
resolveInteractiveTextFallback,
type InteractiveReply,
} from "openclaw/plugin-sdk/interactive-runtime";
import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime";
+import {
+ resolvePayloadMediaUrls,
+ sendPayloadMediaSequenceAndFinalize,
+ sendTextMediaPayload,
+} from "openclaw/plugin-sdk/reply-payload";
import { parseSlackBlocksInput } from "./blocks-input.js";
import { buildSlackInteractiveBlocks, type SlackBlock } from "./blocks-render.js";
import { sendMessageSlack, type SlackSendIdentity } from "./send.js";
diff --git a/extensions/slack/src/probe.ts b/extensions/slack/src/probe.ts
index c370b11be9b..a0d698e54b5 100644
--- a/extensions/slack/src/probe.ts
+++ b/extensions/slack/src/probe.ts
@@ -1,4 +1,4 @@
-import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-runtime";
+import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
import { withTimeout } from "openclaw/plugin-sdk/text-runtime";
import { createSlackWebClient } from "./client.js";
diff --git a/extensions/slack/src/runtime-api.ts b/extensions/slack/src/runtime-api.ts
index 5dac68be756..84f7b9d480b 100644
--- a/extensions/slack/src/runtime-api.ts
+++ b/extensions/slack/src/runtime-api.ts
@@ -9,7 +9,7 @@ export {
type ChannelPlugin,
type OpenClawConfig,
type SlackAccountConfig,
-} from "openclaw/plugin-sdk/slack";
+} from "../../../src/plugin-sdk/slack.js";
export {
listSlackDirectoryGroupsFromConfig,
listSlackDirectoryPeersFromConfig,
@@ -25,5 +25,5 @@ export {
readStringParam,
SlackConfigSchema,
withNormalizedTimestamp,
-} from "openclaw/plugin-sdk/slack-core";
+} from "../../../src/plugin-sdk/slack-core.js";
export { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js";
diff --git a/extensions/slack/src/targets.ts b/extensions/slack/src/targets.ts
index 43162a447d5..356f990d600 100644
--- a/extensions/slack/src/targets.ts
+++ b/extensions/slack/src/targets.ts
@@ -6,7 +6,7 @@ import {
type MessagingTarget,
type MessagingTargetKind,
type MessagingTargetParseOptions,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/channel-targets";
export type SlackTargetKind = MessagingTargetKind;
diff --git a/extensions/slack/src/threading-tool-context.ts b/extensions/slack/src/threading-tool-context.ts
index 30451be5b6b..a6b59189dee 100644
--- a/extensions/slack/src/threading-tool-context.ts
+++ b/extensions/slack/src/threading-tool-context.ts
@@ -1,7 +1,7 @@
import type {
ChannelThreadingContext,
ChannelThreadingToolContext,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveSlackAccount, resolveSlackReplyToMode } from "./accounts.js";
diff --git a/extensions/slack/src/token.ts b/extensions/slack/src/token.ts
index 36f31c89383..03c8c653344 100644
--- a/extensions/slack/src/token.ts
+++ b/extensions/slack/src/token.ts
@@ -1,4 +1,4 @@
-import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime";
+import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
export function normalizeSlackToken(raw?: unknown): string | undefined {
return normalizeResolvedSecretInputString({
diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts
index ef01c240e10..e4ae0bc857d 100644
--- a/extensions/synology-chat/src/channel.ts
+++ b/extensions/synology-chat/src/channel.ts
@@ -9,15 +9,13 @@ import {
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
+import { createTextPairingAdapter } from "openclaw/plugin-sdk/channel-pairing";
import {
createConditionalWarningCollector,
projectWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
-import {
- attachChannelToResult,
- createEmptyChannelDirectoryAdapter,
- createTextPairingAdapter,
-} from "openclaw/plugin-sdk/channel-runtime";
+import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result";
+import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
import { registerPluginHttpRoute } from "openclaw/plugin-sdk/webhook-ingress";
import { z } from "zod";
diff --git a/extensions/tavily/src/config.ts b/extensions/tavily/src/config.ts
index 752a721d17c..7bef2dcdd51 100644
--- a/extensions/tavily/src/config.ts
+++ b/extensions/tavily/src/config.ts
@@ -1,6 +1,6 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
-import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime";
import { normalizeSecretInput } from "openclaw/plugin-sdk/provider-auth";
+import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
export const DEFAULT_TAVILY_BASE_URL = "https://api.tavily.com";
export const DEFAULT_TAVILY_SEARCH_TIMEOUT_SECONDS = 30;
diff --git a/extensions/tavily/src/tavily-extract-tool.ts b/extensions/tavily/src/tavily-extract-tool.ts
index 1a3c381fc64..29a7b04399a 100644
--- a/extensions/tavily/src/tavily-extract-tool.ts
+++ b/extensions/tavily/src/tavily-extract-tool.ts
@@ -1,6 +1,6 @@
import { Type } from "@sinclair/typebox";
-import { optionalStringEnum } from "openclaw/plugin-sdk/agent-runtime";
import { jsonResult, readNumberParam, readStringParam } from "openclaw/plugin-sdk/agent-runtime";
+import { optionalStringEnum } from "openclaw/plugin-sdk/core";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
import { runTavilyExtract } from "./tavily-client.js";
diff --git a/extensions/tavily/src/tavily-search-tool.ts b/extensions/tavily/src/tavily-search-tool.ts
index 1d925973fe0..08cfe3e6606 100644
--- a/extensions/tavily/src/tavily-search-tool.ts
+++ b/extensions/tavily/src/tavily-search-tool.ts
@@ -1,6 +1,6 @@
import { Type } from "@sinclair/typebox";
-import { optionalStringEnum } from "openclaw/plugin-sdk/agent-runtime";
import { jsonResult, readNumberParam, readStringParam } from "openclaw/plugin-sdk/agent-runtime";
+import { optionalStringEnum } from "openclaw/plugin-sdk/core";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-runtime";
import { runTavilySearch } from "./tavily-client.js";
diff --git a/extensions/telegram/runtime-api.ts b/extensions/telegram/runtime-api.ts
index c069a35e40e..28c7788ef9d 100644
--- a/extensions/telegram/runtime-api.ts
+++ b/extensions/telegram/runtime-api.ts
@@ -7,7 +7,7 @@ export type {
TelegramAccountConfig,
TelegramActionConfig,
TelegramNetworkConfig,
-} from "openclaw/plugin-sdk/telegram";
+} from "../../src/plugin-sdk/telegram.js";
export type {
OpenClawPluginService,
OpenClawPluginServiceContext,
@@ -37,7 +37,7 @@ export {
projectCredentialSnapshotFields,
resolveConfiguredFromCredentialStatuses,
resolveTelegramPollVisibility,
-} from "openclaw/plugin-sdk/telegram";
+} from "../../src/plugin-sdk/telegram.js";
export {
buildChannelConfigSchema,
getChatChannelMeta,
@@ -49,7 +49,7 @@ export {
readStringParam,
resolvePollMaxSelections,
TelegramConfigSchema,
-} from "openclaw/plugin-sdk/telegram-core";
+} from "../../src/plugin-sdk/telegram-core.js";
export type { TelegramProbe } from "./src/probe.js";
export { auditTelegramGroupMembership, collectTelegramUnmentionedGroupIds } from "./src/audit.js";
export { telegramMessageActions } from "./src/channel-actions.js";
diff --git a/extensions/telegram/src/account-inspect.ts b/extensions/telegram/src/account-inspect.ts
index 5d131a70586..47c6183fb8b 100644
--- a/extensions/telegram/src/account-inspect.ts
+++ b/extensions/telegram/src/account-inspect.ts
@@ -1,13 +1,13 @@
import { resolveAccountWithDefaultFallback } from "openclaw/plugin-sdk/account-resolution";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
-import {
- coerceSecretRef,
- hasConfiguredSecretInput,
- normalizeSecretInputString,
-} from "openclaw/plugin-sdk/config-runtime";
+import { coerceSecretRef } from "openclaw/plugin-sdk/config-runtime";
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime";
import { resolveDefaultSecretProviderAlias } from "openclaw/plugin-sdk/provider-auth";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
+import {
+ hasConfiguredSecretInput,
+ normalizeSecretInputString,
+} from "openclaw/plugin-sdk/secret-input";
import type { TelegramAccountConfig } from "../runtime-api.js";
import {
mergeTelegramAccountConfig,
diff --git a/extensions/telegram/src/action-runtime.ts b/extensions/telegram/src/action-runtime.ts
index c07dae07681..436f7d84874 100644
--- a/extensions/telegram/src/action-runtime.ts
+++ b/extensions/telegram/src/action-runtime.ts
@@ -1,6 +1,6 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
-import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-runtime";
+import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions";
import { resolveTelegramPollVisibility } from "../runtime-api.js";
import {
jsonResult,
diff --git a/extensions/telegram/src/api-fetch.test.ts b/extensions/telegram/src/api-fetch.test.ts
index e65499ef25c..5de45f6ee75 100644
--- a/extensions/telegram/src/api-fetch.test.ts
+++ b/extensions/telegram/src/api-fetch.test.ts
@@ -54,4 +54,28 @@ describe("fetchTelegramChatId", () => {
undefined,
);
});
+
+ it("uses caller-provided fetch impl when present", async () => {
+ const customFetch = vi.fn(async () => ({
+ ok: true,
+ json: async () => ({ ok: true, result: { id: 12345 } }),
+ }));
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(async () => {
+ throw new Error("global fetch should not be called");
+ }),
+ );
+
+ await fetchTelegramChatId({
+ token: "abc",
+ chatId: "@user",
+ fetchImpl: customFetch as unknown as typeof fetch,
+ });
+
+ expect(customFetch).toHaveBeenCalledWith(
+ "https://api.telegram.org/botabc/getChat?chat_id=%40user",
+ undefined,
+ );
+ });
});
diff --git a/extensions/telegram/src/api-fetch.ts b/extensions/telegram/src/api-fetch.ts
index 8831caa2b8a..21dd8fd64e5 100644
--- a/extensions/telegram/src/api-fetch.ts
+++ b/extensions/telegram/src/api-fetch.ts
@@ -1,11 +1,48 @@
+import type { TelegramNetworkConfig } from "../runtime-api.js";
+import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js";
+import { makeProxyFetch } from "./proxy.js";
+
+export function resolveTelegramChatLookupFetch(params?: {
+ proxyUrl?: string;
+ network?: TelegramNetworkConfig;
+}): typeof fetch {
+ const proxyUrl = params?.proxyUrl?.trim();
+ const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined;
+ return resolveTelegramFetch(proxyFetch, { network: params?.network });
+}
+
+export async function lookupTelegramChatId(params: {
+ token: string;
+ chatId: string;
+ signal?: AbortSignal;
+ apiRoot?: string;
+ proxyUrl?: string;
+ network?: TelegramNetworkConfig;
+}): Promise {
+ return fetchTelegramChatId({
+ token: params.token,
+ chatId: params.chatId,
+ signal: params.signal,
+ apiRoot: params.apiRoot,
+ fetchImpl: resolveTelegramChatLookupFetch({
+ proxyUrl: params.proxyUrl,
+ network: params.network,
+ }),
+ });
+}
+
export async function fetchTelegramChatId(params: {
token: string;
chatId: string;
signal?: AbortSignal;
+ apiRoot?: string;
+ fetchImpl?: typeof fetch;
}): Promise {
- const url = `https://api.telegram.org/bot${params.token}/getChat?chat_id=${encodeURIComponent(params.chatId)}`;
+ const apiBase = resolveTelegramApiBase(params.apiRoot);
+ const url = `${apiBase}/bot${params.token}/getChat?chat_id=${encodeURIComponent(params.chatId)}`;
+ const fetchImpl = params.fetchImpl ?? fetch;
try {
- const res = await fetch(url, params.signal ? { signal: params.signal } : undefined);
+ const res = await fetchImpl(url, params.signal ? { signal: params.signal } : undefined);
if (!res.ok) {
return null;
}
diff --git a/extensions/telegram/src/audit-membership-runtime.ts b/extensions/telegram/src/audit-membership-runtime.ts
index 930d768778e..a8cc98f4701 100644
--- a/extensions/telegram/src/audit-membership-runtime.ts
+++ b/extensions/telegram/src/audit-membership-runtime.ts
@@ -5,11 +5,9 @@ import type {
TelegramGroupMembershipAudit,
TelegramGroupMembershipAuditEntry,
} from "./audit.js";
-import { resolveTelegramFetch } from "./fetch.js";
+import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js";
import { makeProxyFetch } from "./proxy.js";
-const TELEGRAM_API_BASE = "https://api.telegram.org";
-
type TelegramApiOk = { ok: true; result: T };
type TelegramApiErr = { ok: false; description?: string };
type TelegramGroupMembershipAuditData = Omit;
@@ -18,8 +16,11 @@ export async function auditTelegramGroupMembershipImpl(
params: AuditTelegramGroupMembershipParams,
): Promise {
const proxyFetch = params.proxyUrl ? makeProxyFetch(params.proxyUrl) : undefined;
- const fetcher = resolveTelegramFetch(proxyFetch, { network: params.network });
- const base = `${TELEGRAM_API_BASE}/bot${params.token}`;
+ const fetcher = resolveTelegramFetch(proxyFetch, {
+ network: params.network,
+ });
+ const apiBase = resolveTelegramApiBase(params.apiRoot);
+ const base = `${apiBase}/bot${params.token}`;
const groups: TelegramGroupMembershipAuditEntry[] = [];
for (const chatId of params.groupIds) {
diff --git a/extensions/telegram/src/audit.ts b/extensions/telegram/src/audit.ts
index f7fb0969090..f205dc49127 100644
--- a/extensions/telegram/src/audit.ts
+++ b/extensions/telegram/src/audit.ts
@@ -66,6 +66,7 @@ export type AuditTelegramGroupMembershipParams = {
groupIds: string[];
proxyUrl?: string;
network?: TelegramNetworkConfig;
+ apiRoot?: string;
timeoutMs: number;
};
diff --git a/extensions/telegram/src/bot-access.ts b/extensions/telegram/src/bot-access.ts
index c89a8fe6f48..82034aeadb2 100644
--- a/extensions/telegram/src/bot-access.ts
+++ b/extensions/telegram/src/bot-access.ts
@@ -2,8 +2,8 @@ import {
firstDefined,
isSenderIdAllowed,
mergeDmAllowFromSources,
-} from "openclaw/plugin-sdk/channel-runtime";
-import type { AllowlistMatch } from "openclaw/plugin-sdk/channel-runtime";
+ type AllowlistMatch,
+} from "openclaw/plugin-sdk/allow-from";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
export type NormalizedAllowFrom = {
diff --git a/extensions/telegram/src/bot-deps.ts b/extensions/telegram/src/bot-deps.ts
index a21c4f0c586..93aac0c8b8f 100644
--- a/extensions/telegram/src/bot-deps.ts
+++ b/extensions/telegram/src/bot-deps.ts
@@ -1,12 +1,12 @@
+import {
+ buildModelsProviderData,
+ listSkillCommandsForAgents,
+} from "openclaw/plugin-sdk/command-auth";
import { loadConfig, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
import { upsertChannelPairingRequest } from "openclaw/plugin-sdk/conversation-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
-import {
- buildModelsProviderData,
- dispatchReplyWithBufferedBlockDispatcher,
- listSkillCommandsForAgents,
-} from "openclaw/plugin-sdk/reply-runtime";
+import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime";
import { wasSentByBot } from "./sent-message-cache.js";
export type TelegramBotDeps = {
diff --git a/extensions/telegram/src/bot-handlers.buffers.ts b/extensions/telegram/src/bot-handlers.buffers.ts
index 41dcee18aa4..7d301251176 100644
--- a/extensions/telegram/src/bot-handlers.buffers.ts
+++ b/extensions/telegram/src/bot-handlers.buffers.ts
@@ -1,10 +1,10 @@
import type { Message } from "@grammyjs/types";
-import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-runtime";
-import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
-} from "openclaw/plugin-sdk/reply-runtime";
+ shouldDebounceTextInbound,
+} from "openclaw/plugin-sdk/channel-inbound";
+import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
import {
hasInboundMedia,
diff --git a/extensions/telegram/src/bot-handlers.runtime.ts b/extensions/telegram/src/bot-handlers.runtime.ts
index 00dc35041c9..96726785db2 100644
--- a/extensions/telegram/src/bot-handlers.runtime.ts
+++ b/extensions/telegram/src/bot-handlers.runtime.ts
@@ -1,8 +1,18 @@
import type { Message, ReactionTypeEmoji } from "@grammyjs/types";
import { resolveAgentDir, resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime";
-import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-runtime";
-import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-runtime";
+import { resolveChannelConfigWrites } from "openclaw/plugin-sdk/channel-config-helpers";
+import { shouldDebounceTextInbound } from "openclaw/plugin-sdk/channel-inbound";
+import {
+ createInboundDebouncer,
+ resolveInboundDebounceMs,
+} from "openclaw/plugin-sdk/channel-inbound";
+import {
+ buildCommandsMessagePaginated,
+ buildCommandsPaginationKeyboard,
+ formatModelsAvailableHeader,
+ resolveStoredModelOverride,
+} from "openclaw/plugin-sdk/command-auth";
import { writeConfigFile } from "openclaw/plugin-sdk/config-runtime";
import {
loadSessionStore,
@@ -22,14 +32,6 @@ import {
resolvePluginConversationBindingApproval,
} from "openclaw/plugin-sdk/conversation-runtime";
import { dispatchPluginInteractiveHandler } from "openclaw/plugin-sdk/plugin-runtime";
-import {
- createInboundDebouncer,
- resolveInboundDebounceMs,
-} from "openclaw/plugin-sdk/reply-runtime";
-import { buildCommandsPaginationKeyboard } from "openclaw/plugin-sdk/reply-runtime";
-import { formatModelsAvailableHeader } from "openclaw/plugin-sdk/reply-runtime";
-import { resolveStoredModelOverride } from "openclaw/plugin-sdk/reply-runtime";
-import { buildCommandsMessagePaginated } from "openclaw/plugin-sdk/reply-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
import { danger, logVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
@@ -359,7 +361,13 @@ export const registerTelegramHandlers = ({
for (const { ctx } of entry.messages) {
let media;
try {
- media = await resolveMedia(ctx, mediaMaxBytes, opts.token, telegramTransport);
+ media = await resolveMedia(
+ ctx,
+ mediaMaxBytes,
+ opts.token,
+ telegramTransport,
+ telegramCfg.apiRoot,
+ );
} catch (mediaErr) {
if (!isRecoverableMediaGroupError(mediaErr)) {
throw mediaErr;
@@ -464,6 +472,7 @@ export const registerTelegramHandlers = ({
mediaMaxBytes,
opts.token,
telegramTransport,
+ telegramCfg.apiRoot,
);
if (!media) {
return [];
@@ -975,7 +984,13 @@ export const registerTelegramHandlers = ({
let media: Awaited> = null;
try {
- media = await resolveMedia(ctx, mediaMaxBytes, opts.token, telegramTransport);
+ media = await resolveMedia(
+ ctx,
+ mediaMaxBytes,
+ opts.token,
+ telegramTransport,
+ telegramCfg.apiRoot,
+ );
} catch (mediaErr) {
if (isMediaSizeLimitError(mediaErr)) {
if (sendOversizeWarning) {
diff --git a/extensions/telegram/src/bot-message-context.body.ts b/extensions/telegram/src/bot-message-context.body.ts
index 63e6aaa12dd..04e5739d663 100644
--- a/extensions/telegram/src/bot-message-context.body.ts
+++ b/extensions/telegram/src/bot-message-context.body.ts
@@ -4,22 +4,26 @@ import {
modelSupportsVision,
} from "openclaw/plugin-sdk/agent-runtime";
import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime";
-import { resolveControlCommandGate } from "openclaw/plugin-sdk/channel-runtime";
-import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-runtime";
-import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime";
-import { resolveMentionGatingWithBypass } from "openclaw/plugin-sdk/channel-runtime";
+import {
+ buildMentionRegexes,
+ formatLocationText,
+ logInboundDrop,
+ matchesMentionWithExplicit,
+ resolveMentionGatingWithBypass,
+ type NormalizedLocation,
+} from "openclaw/plugin-sdk/channel-inbound";
+import { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
+import { hasControlCommand } from "openclaw/plugin-sdk/command-auth";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type {
TelegramDirectConfig,
TelegramGroupConfig,
TelegramTopicConfig,
} from "openclaw/plugin-sdk/config-runtime";
-import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime";
import {
recordPendingHistoryEntryIfEnabled,
type HistoryEntry,
-} from "openclaw/plugin-sdk/reply-runtime";
-import { buildMentionRegexes, matchesMentionWithExplicit } from "openclaw/plugin-sdk/reply-runtime";
+} from "openclaw/plugin-sdk/reply-history";
import type { MsgContext } from "openclaw/plugin-sdk/reply-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { NormalizedAllowFrom } from "./bot-access.js";
diff --git a/extensions/telegram/src/bot-message-context.named-account-dm.test.ts b/extensions/telegram/src/bot-message-context.named-account-dm.test.ts
index e51c7920ae7..33d1e35e470 100644
--- a/extensions/telegram/src/bot-message-context.named-account-dm.test.ts
+++ b/extensions/telegram/src/bot-message-context.named-account-dm.test.ts
@@ -6,8 +6,8 @@ import {
import { buildTelegramMessageContextForTest } from "./bot-message-context.test-harness.js";
const recordInboundSessionMock = vi.fn().mockResolvedValue(undefined);
-vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => {
- const actual = await importOriginal();
+vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
+ const actual = await importOriginal();
return {
...actual,
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
diff --git a/extensions/telegram/src/bot-message-context.session.ts b/extensions/telegram/src/bot-message-context.session.ts
index 47bcda8592f..2581e1d398b 100644
--- a/extensions/telegram/src/bot-message-context.session.ts
+++ b/extensions/telegram/src/bot-message-context.session.ts
@@ -1,5 +1,10 @@
-import { toLocationContext } from "openclaw/plugin-sdk/channel-runtime";
-import { recordInboundSession } from "openclaw/plugin-sdk/channel-runtime";
+import {
+ formatInboundEnvelope,
+ resolveEnvelopeFormatOptions,
+ toLocationContext,
+ type NormalizedLocation,
+} from "openclaw/plugin-sdk/channel-inbound";
+import { normalizeCommandBody } from "openclaw/plugin-sdk/command-auth";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { readSessionUpdatedAt, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
import type {
@@ -7,15 +12,11 @@ import type {
TelegramGroupConfig,
TelegramTopicConfig,
} from "openclaw/plugin-sdk/config-runtime";
-import { normalizeCommandBody } from "openclaw/plugin-sdk/reply-runtime";
-import {
- formatInboundEnvelope,
- resolveEnvelopeFormatOptions,
-} from "openclaw/plugin-sdk/reply-runtime";
+import { recordInboundSession } from "openclaw/plugin-sdk/conversation-runtime";
import {
buildPendingHistoryContextFromMap,
type HistoryEntry,
-} from "openclaw/plugin-sdk/reply-runtime";
+} from "openclaw/plugin-sdk/reply-history";
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime";
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
import { resolveInboundLastRouteSessionKey } from "openclaw/plugin-sdk/routing";
@@ -63,7 +64,7 @@ export async function buildTelegramInboundContextPayload(params: {
stickerCacheHit: boolean;
effectiveWasMentioned: boolean;
commandAuthorized: boolean;
- locationData?: import("openclaw/plugin-sdk/channel-runtime").NormalizedLocation;
+ locationData?: NormalizedLocation;
options?: TelegramMessageContextOptions;
dmAllowFrom?: Array;
}): Promise<{
diff --git a/extensions/telegram/src/bot-message-context.ts b/extensions/telegram/src/bot-message-context.ts
index 3c90a344708..046717b8175 100644
--- a/extensions/telegram/src/bot-message-context.ts
+++ b/extensions/telegram/src/bot-message-context.ts
@@ -1,10 +1,10 @@
import { resolveAckReaction } from "openclaw/plugin-sdk/agent-runtime";
-import { shouldAckReaction as shouldAckReactionGate } from "openclaw/plugin-sdk/channel-runtime";
-import { logInboundDrop } from "openclaw/plugin-sdk/channel-runtime";
import {
createStatusReactionController,
+ shouldAckReaction as shouldAckReactionGate,
type StatusReactionController,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/channel-feedback";
+import { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import type { TelegramDirectConfig, TelegramGroupConfig } from "openclaw/plugin-sdk/config-runtime";
import { ensureConfiguredBindingRouteReady } from "openclaw/plugin-sdk/conversation-runtime";
diff --git a/extensions/telegram/src/bot-message-context.types.ts b/extensions/telegram/src/bot-message-context.types.ts
index ff782c0a1fa..a7e00397b33 100644
--- a/extensions/telegram/src/bot-message-context.types.ts
+++ b/extensions/telegram/src/bot-message-context.types.ts
@@ -6,7 +6,7 @@ import type {
TelegramGroupConfig,
TelegramTopicConfig,
} from "openclaw/plugin-sdk/config-runtime";
-import type { HistoryEntry } from "openclaw/plugin-sdk/reply-runtime";
+import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import type { StickerMetadata, TelegramContext } from "./bot/types.js";
export type TelegramMediaRef = {
diff --git a/extensions/telegram/src/bot-message-dispatch.ts b/extensions/telegram/src/bot-message-dispatch.ts
index a5f9cb58c89..70e5acf0922 100644
--- a/extensions/telegram/src/bot-message-dispatch.ts
+++ b/extensions/telegram/src/bot-message-dispatch.ts
@@ -6,9 +6,12 @@ import {
modelSupportsVision,
} from "openclaw/plugin-sdk/agent-runtime";
import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime";
+import {
+ logAckFailure,
+ logTypingFailure,
+ removeAckReactionAfterReply,
+} from "openclaw/plugin-sdk/channel-feedback";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
-import { removeAckReactionAfterReply } from "openclaw/plugin-sdk/channel-runtime";
-import { logAckFailure, logTypingFailure } from "openclaw/plugin-sdk/channel-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import {
loadSessionStore,
@@ -21,9 +24,9 @@ import type {
TelegramAccountConfig,
} from "openclaw/plugin-sdk/config-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
+import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-history";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime";
-import { clearHistoryEntriesIfEnabled } from "openclaw/plugin-sdk/reply-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
diff --git a/extensions/telegram/src/bot-native-commands.menu-test-support.ts b/extensions/telegram/src/bot-native-commands.menu-test-support.ts
index e74220b248a..9701802bb2a 100644
--- a/extensions/telegram/src/bot-native-commands.menu-test-support.ts
+++ b/extensions/telegram/src/bot-native-commands.menu-test-support.ts
@@ -34,8 +34,8 @@ const deliveryMocks = vi.hoisted(() => ({
export const listSkillCommandsForAgents = skillCommandMocks.listSkillCommandsForAgents;
export const deliverReplies = deliveryMocks.deliverReplies;
-vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
- const actual = await importOriginal();
+vi.mock("openclaw/plugin-sdk/command-auth", async (importOriginal) => {
+ const actual = await importOriginal();
return {
...actual,
listSkillCommandsForAgents,
diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts
index bfe314d4140..eef2f76abda 100644
--- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts
+++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts
@@ -73,23 +73,6 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
...actual,
resolveConfiguredBindingRoute: persistentBindingMocks.resolveConfiguredBindingRoute,
ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredBindingRouteReady,
- readChannelAllowFromStore: conversationStoreMocks.readChannelAllowFromStore,
- upsertChannelPairingRequest: conversationStoreMocks.upsertChannelPairingRequest,
- getSessionBindingService: () => ({
- bind: vi.fn(),
- getCapabilities: vi.fn(),
- listBySession: vi.fn(),
- resolveByConversation: (ref: unknown) => sessionBindingMocks.resolveByConversation(ref),
- touch: (bindingId: string, at?: number) => sessionBindingMocks.touch(bindingId, at),
- unbind: vi.fn(),
- }),
- };
-});
-vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- createReplyPrefixOptions: vi.fn(() => ({ onModelSelected: () => {} })),
recordInboundSessionMetaSafe: vi.fn(
async (params: {
cfg: OpenClawConfig;
@@ -112,6 +95,23 @@ vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => {
}
},
),
+ readChannelAllowFromStore: conversationStoreMocks.readChannelAllowFromStore,
+ upsertChannelPairingRequest: conversationStoreMocks.upsertChannelPairingRequest,
+ getSessionBindingService: () => ({
+ bind: vi.fn(),
+ getCapabilities: vi.fn(),
+ listBySession: vi.fn(),
+ resolveByConversation: (ref: unknown) => sessionBindingMocks.resolveByConversation(ref),
+ touch: (bindingId: string, at?: number) => sessionBindingMocks.touch(bindingId, at),
+ unbind: vi.fn(),
+ }),
+ };
+});
+vi.mock("openclaw/plugin-sdk/command-auth", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ listSkillCommandsForAgents: vi.fn(() => []),
};
});
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
@@ -120,7 +120,6 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
...actual,
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher,
- listSkillCommandsForAgents: vi.fn(() => []),
};
});
vi.mock("../../../src/config/sessions.js", () => ({
diff --git a/extensions/telegram/src/bot-native-commands.test-helpers.ts b/extensions/telegram/src/bot-native-commands.test-helpers.ts
index 973d62485ab..65e3baf411d 100644
--- a/extensions/telegram/src/bot-native-commands.test-helpers.ts
+++ b/extensions/telegram/src/bot-native-commands.test-helpers.ts
@@ -22,7 +22,7 @@ type DispatchReplyWithBufferedBlockDispatcherResult = Awaited<
ReturnType
>;
type RecordInboundSessionMetaSafeFn =
- typeof import("openclaw/plugin-sdk/channel-runtime").recordInboundSessionMetaSafe;
+ typeof import("openclaw/plugin-sdk/conversation-runtime").recordInboundSessionMetaSafe;
type AnyMock = MockFn<(...args: unknown[]) => unknown>;
type AnyAsyncMock = MockFn<(...args: unknown[]) => Promise>;
type NativeCommandHarness = {
@@ -74,11 +74,12 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
replyPipelineMocks.dispatchReplyWithBufferedBlockDispatcher,
};
});
-vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => {
- const actual = await importOriginal();
+vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
+ const actual = await importOriginal();
return {
...actual,
recordInboundSessionMetaSafe: replyPipelineMocks.recordInboundSessionMetaSafe,
+ readChannelAllowFromStore: vi.fn(async () => []),
};
});
vi.mock("openclaw/plugin-sdk/channel-reply-pipeline", async (importOriginal) => {
@@ -95,13 +96,6 @@ const deliveryMocks = vi.hoisted(() => ({
}));
export const deliverReplies = deliveryMocks.deliverReplies;
vi.mock("./bot/delivery.js", () => ({ deliverReplies: deliveryMocks.deliverReplies }));
-vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- readChannelAllowFromStore: vi.fn(async () => []),
- };
-});
export { createNativeCommandTestParams };
export function createNativeCommandsHarness(params?: {
diff --git a/extensions/telegram/src/bot-native-commands.test.ts b/extensions/telegram/src/bot-native-commands.test.ts
index e85a444369b..2674762b1e0 100644
--- a/extensions/telegram/src/bot-native-commands.test.ts
+++ b/extensions/telegram/src/bot-native-commands.test.ts
@@ -17,8 +17,8 @@ const deliveryMocks = vi.hoisted(() => ({
deliverReplies: vi.fn(async () => ({ delivered: true })),
}));
-vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
- const actual = await importOriginal();
+vi.mock("openclaw/plugin-sdk/command-auth", async (importOriginal) => {
+ const actual = await importOriginal();
return {
...actual,
listSkillCommandsForAgents: skillCommandMocks.listSkillCommandsForAgents,
diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts
index 103cca984e0..e81713956cd 100644
--- a/extensions/telegram/src/bot-native-commands.ts
+++ b/extensions/telegram/src/bot-native-commands.ts
@@ -1,8 +1,19 @@
import type { Bot, Context } from "grammy";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
-import { resolveCommandAuthorizedFromAuthorizers } from "openclaw/plugin-sdk/channel-runtime";
-import { resolveNativeCommandSessionTargets } from "openclaw/plugin-sdk/channel-runtime";
-import { recordInboundSessionMetaSafe } from "openclaw/plugin-sdk/channel-runtime";
+import {
+ resolveCommandAuthorization,
+ resolveCommandAuthorizedFromAuthorizers,
+ resolveNativeCommandSessionTargets,
+} from "openclaw/plugin-sdk/command-auth";
+import {
+ buildCommandTextFromArgs,
+ findCommandByNativeName,
+ listNativeCommandSpecs,
+ listNativeCommandSpecsForConfig,
+ parseCommandArgs,
+ resolveCommandArgMenu,
+ type CommandArgs,
+} from "openclaw/plugin-sdk/command-auth";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ChannelGroupPolicy } from "openclaw/plugin-sdk/config-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
@@ -18,7 +29,10 @@ import type {
TelegramGroupConfig,
TelegramTopicConfig,
} from "openclaw/plugin-sdk/config-runtime";
-import { ensureConfiguredBindingRouteReady } from "openclaw/plugin-sdk/conversation-runtime";
+import {
+ ensureConfiguredBindingRouteReady,
+ recordInboundSessionMetaSafe,
+} from "openclaw/plugin-sdk/conversation-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import {
executePluginCommand,
@@ -26,16 +40,6 @@ import {
matchPluginCommand,
} from "openclaw/plugin-sdk/plugin-runtime";
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-runtime";
-import { resolveCommandAuthorization } from "openclaw/plugin-sdk/reply-runtime";
-import type { CommandArgs } from "openclaw/plugin-sdk/reply-runtime";
-import {
- buildCommandTextFromArgs,
- findCommandByNativeName,
- listNativeCommandSpecs,
- listNativeCommandSpecsForConfig,
- parseCommandArgs,
- resolveCommandArgMenu,
-} from "openclaw/plugin-sdk/reply-runtime";
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
diff --git a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts
index a9793692b21..6009b16947a 100644
--- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts
+++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts
@@ -230,28 +230,40 @@ function createModelsProviderDataFromConfig(cfg: OpenClawConfig): {
return { byProvider, providers, resolvedDefault };
}
+vi.doMock("openclaw/plugin-sdk/command-auth", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ listSkillCommandsForAgents: skillCommandListHoisted.listSkillCommandsForAgents,
+ buildModelsProviderData,
+ };
+});
+vi.doMock("openclaw/plugin-sdk/command-auth.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ listSkillCommandsForAgents: skillCommandListHoisted.listSkillCommandsForAgents,
+ buildModelsProviderData,
+ };
+});
vi.doMock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
- listSkillCommandsForAgents: skillCommandListHoisted.listSkillCommandsForAgents,
getReplyFromConfig: replySpyHoisted.replySpy,
__replySpy: replySpyHoisted.replySpy,
dispatchReplyWithBufferedBlockDispatcher:
dispatchReplyHoisted.dispatchReplyWithBufferedBlockDispatcher,
- buildModelsProviderData,
};
});
vi.doMock("openclaw/plugin-sdk/reply-runtime.js", async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
- listSkillCommandsForAgents: skillCommandListHoisted.listSkillCommandsForAgents,
getReplyFromConfig: replySpyHoisted.replySpy,
__replySpy: replySpyHoisted.replySpy,
dispatchReplyWithBufferedBlockDispatcher:
dispatchReplyHoisted.dispatchReplyWithBufferedBlockDispatcher,
- buildModelsProviderData,
};
});
diff --git a/extensions/telegram/src/bot.ts b/extensions/telegram/src/bot.ts
index 36dcc0f5db2..11c394518c4 100644
--- a/extensions/telegram/src/bot.ts
+++ b/extensions/telegram/src/bot.ts
@@ -1,9 +1,4 @@
import { resolveDefaultAgentId } from "openclaw/plugin-sdk/agent-runtime";
-import {
- resolveThreadBindingIdleTimeoutMsForChannel,
- resolveThreadBindingMaxAgeMsForChannel,
- resolveThreadBindingSpawnPolicy,
-} from "openclaw/plugin-sdk/channel-runtime";
import {
isNativeCommandsExplicitlyDisabled,
resolveNativeCommandsEnabled,
@@ -15,9 +10,14 @@ import {
resolveChannelGroupRequireMention,
} from "openclaw/plugin-sdk/config-runtime";
import { loadSessionStore, resolveStorePath } from "openclaw/plugin-sdk/config-runtime";
+import {
+ resolveThreadBindingIdleTimeoutMsForChannel,
+ resolveThreadBindingMaxAgeMsForChannel,
+ resolveThreadBindingSpawnPolicy,
+} from "openclaw/plugin-sdk/conversation-runtime";
import { formatUncaughtError } from "openclaw/plugin-sdk/infra-runtime";
+import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-history";
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
-import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-runtime";
import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { getChildLogger } from "openclaw/plugin-sdk/runtime-env";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
@@ -230,11 +230,13 @@ export function createTelegramBot(opts: TelegramBotOptions) {
typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds)
? Math.max(1, Math.floor(telegramCfg.timeoutSeconds))
: undefined;
+ const apiRoot = telegramCfg.apiRoot?.trim() || undefined;
const client: ApiClientOptions | undefined =
- finalFetch || timeoutSeconds
+ finalFetch || timeoutSeconds || apiRoot
? {
...(finalFetch ? { fetch: finalFetch } : {}),
...(timeoutSeconds ? { timeoutSeconds } : {}),
+ ...(apiRoot ? { apiRoot } : {}),
}
: undefined;
diff --git a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts
index b1cd7eb4d8a..86d6e608dce 100644
--- a/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts
+++ b/extensions/telegram/src/bot/delivery.resolve-media-retry.test.ts
@@ -360,6 +360,38 @@ describe("resolveMedia getFile retry", () => {
}),
);
});
+
+ it("uses local absolute file paths directly for media downloads", async () => {
+ const getFile = vi.fn().mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/file.pdf" });
+
+ const result = await resolveMedia(makeCtx("document", getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
+
+ expect(fetchRemoteMedia).not.toHaveBeenCalled();
+ expect(saveMediaBuffer).not.toHaveBeenCalled();
+ expect(result).toEqual(
+ expect.objectContaining({
+ path: "/var/lib/telegram-bot-api/file.pdf",
+ placeholder: "",
+ }),
+ );
+ });
+
+ it("uses local absolute file paths directly for sticker downloads", async () => {
+ const getFile = vi
+ .fn()
+ .mockResolvedValue({ file_path: "/var/lib/telegram-bot-api/sticker.webp" });
+
+ const result = await resolveMedia(makeCtx("sticker", getFile), MAX_MEDIA_BYTES, BOT_TOKEN);
+
+ expect(fetchRemoteMedia).not.toHaveBeenCalled();
+ expect(saveMediaBuffer).not.toHaveBeenCalled();
+ expect(result).toEqual(
+ expect.objectContaining({
+ path: "/var/lib/telegram-bot-api/sticker.webp",
+ placeholder: "",
+ }),
+ );
+ });
});
describe("resolveMedia original filename preservation", () => {
diff --git a/extensions/telegram/src/bot/delivery.resolve-media.ts b/extensions/telegram/src/bot/delivery.resolve-media.ts
index 52f6eef966c..2e552529dec 100644
--- a/extensions/telegram/src/bot/delivery.resolve-media.ts
+++ b/extensions/telegram/src/bot/delivery.resolve-media.ts
@@ -1,21 +1,39 @@
+import path from "node:path";
import { GrammyError } from "grammy";
import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime";
import { retryAsync } from "openclaw/plugin-sdk/infra-runtime";
import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
import { logVerbose, warn } from "openclaw/plugin-sdk/runtime-env";
-import { shouldRetryTelegramTransportFallback, type TelegramTransport } from "../fetch.js";
+import {
+ resolveTelegramApiBase,
+ shouldRetryTelegramTransportFallback,
+ type TelegramTransport,
+} from "../fetch.js";
import { cacheSticker, getCachedSticker } from "../sticker-cache.js";
import { resolveTelegramMediaPlaceholder } from "./helpers.js";
import type { StickerMetadata, TelegramContext } from "./types.js";
const FILE_TOO_BIG_RE = /file is too big/i;
-const TELEGRAM_MEDIA_SSRF_POLICY = {
- // Telegram file downloads should trust api.telegram.org even when DNS/proxy
- // resolution maps to private/internal ranges in restricted networks.
- allowedHostnames: ["api.telegram.org"],
- allowRfc2544BenchmarkRange: true,
-};
+function buildTelegramMediaSsrfPolicy(apiRoot?: string) {
+ const hostnames = ["api.telegram.org"];
+ if (apiRoot) {
+ try {
+ const customHost = new URL(apiRoot).hostname;
+ if (customHost && !hostnames.includes(customHost)) {
+ hostnames.push(customHost);
+ }
+ } catch {
+ // invalid URL; fall through to default
+ }
+ }
+ return {
+ // Telegram file downloads should trust the API hostname even when DNS/proxy
+ // resolution maps to private/internal ranges in restricted networks.
+ allowedHostnames: hostnames,
+ allowRfc2544BenchmarkRange: true,
+ };
+}
/**
* Returns true if the error is Telegram's "file is too big" error.
@@ -124,8 +142,13 @@ async function downloadAndSaveTelegramFile(params: {
transport: TelegramTransport;
maxBytes: number;
telegramFileName?: string;
+ apiRoot?: string;
}) {
- const url = `https://api.telegram.org/file/bot${params.token}/${params.filePath}`;
+ if (path.isAbsolute(params.filePath)) {
+ return { path: params.filePath, contentType: undefined };
+ }
+ const apiBase = resolveTelegramApiBase(params.apiRoot);
+ const url = `${apiBase}/file/bot${params.token}/${params.filePath}`;
const fetched = await fetchRemoteMedia({
url,
fetchImpl: params.transport.sourceFetch,
@@ -134,7 +157,7 @@ async function downloadAndSaveTelegramFile(params: {
filePathHint: params.filePath,
maxBytes: params.maxBytes,
readIdleTimeoutMs: TELEGRAM_DOWNLOAD_IDLE_TIMEOUT_MS,
- ssrfPolicy: TELEGRAM_MEDIA_SSRF_POLICY,
+ ssrfPolicy: buildTelegramMediaSsrfPolicy(params.apiRoot),
});
const originalName = params.telegramFileName ?? fetched.fileName ?? params.filePath;
return saveMediaBuffer(
@@ -152,6 +175,7 @@ async function resolveStickerMedia(params: {
maxBytes: number;
token: string;
transport?: TelegramTransport;
+ apiRoot?: string;
}): Promise<
| {
path: string;
@@ -192,6 +216,7 @@ async function resolveStickerMedia(params: {
token,
transport: resolvedTransport,
maxBytes,
+ apiRoot: params.apiRoot,
});
// Check sticker cache for existing description
@@ -247,6 +272,7 @@ export async function resolveMedia(
maxBytes: number,
token: string,
transport?: TelegramTransport,
+ apiRoot?: string,
): Promise<{
path: string;
contentType?: string;
@@ -260,6 +286,7 @@ export async function resolveMedia(
maxBytes,
token,
transport,
+ apiRoot,
});
if (stickerResolved !== undefined) {
return stickerResolved;
@@ -283,6 +310,7 @@ export async function resolveMedia(
transport: resolveRequiredTelegramTransport(transport),
maxBytes,
telegramFileName: resolveTelegramFileName(msg),
+ apiRoot,
});
const placeholder = resolveTelegramMediaPlaceholder(msg) ?? "";
return { path: saved.path, contentType: saved.contentType, placeholder };
diff --git a/extensions/telegram/src/bot/helpers.ts b/extensions/telegram/src/bot/helpers.ts
index 98ec1f1aaf6..29561953466 100644
--- a/extensions/telegram/src/bot/helpers.ts
+++ b/extensions/telegram/src/bot/helpers.ts
@@ -1,5 +1,5 @@
import type { Chat, Message, MessageOrigin, User } from "@grammyjs/types";
-import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-runtime";
+import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound";
import { resolveTelegramPreviewStreamMode } from "openclaw/plugin-sdk/config-runtime";
import type {
TelegramDirectConfig,
diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts
index d01c5f91839..5cb17a2ee12 100644
--- a/extensions/telegram/src/channel-actions.ts
+++ b/extensions/telegram/src/channel-actions.ts
@@ -1,13 +1,15 @@
import {
- createMessageToolButtonsSchema,
createUnionActionGate,
listTokenSourcedAccounts,
resolveReactionMessageId,
- type ChannelMessageActionAdapter,
- type ChannelMessageActionName,
- type ChannelMessageToolDiscovery,
- type ChannelMessageToolSchemaContribution,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/channel-actions";
+import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-actions";
+import type {
+ ChannelMessageActionAdapter,
+ ChannelMessageActionName,
+ ChannelMessageToolDiscovery,
+ ChannelMessageToolSchemaContribution,
+} from "openclaw/plugin-sdk/channel-contract";
import type { TelegramActionConfig } from "openclaw/plugin-sdk/config-runtime";
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
import {
diff --git a/extensions/telegram/src/channel.test.ts b/extensions/telegram/src/channel.test.ts
index c9e8df40be0..1a174f7200f 100644
--- a/extensions/telegram/src/channel.test.ts
+++ b/extensions/telegram/src/channel.test.ts
@@ -1,10 +1,10 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
import type {
ChannelAccountSnapshot,
ChannelGatewayContext,
- OpenClawConfig,
- PluginRuntime,
-} from "openclaw/plugin-sdk/telegram";
-import { afterEach, describe, expect, it, vi } from "vitest";
+} from "../../../src/channels/plugins/types.js";
+import type { OpenClawConfig } from "../../../src/config/config.js";
+import type { PluginRuntime } from "../../../src/plugins/runtime/types.js";
import { createRuntimeEnv } from "../../../test/helpers/extensions/runtime-env.js";
import type { ResolvedTelegramAccount } from "./accounts.js";
import * as auditModule from "./audit.js";
diff --git a/extensions/telegram/src/channel.ts b/extensions/telegram/src/channel.ts
index 25c81509820..5a481ba8ac3 100644
--- a/extensions/telegram/src/channel.ts
+++ b/extensions/telegram/src/channel.ts
@@ -3,22 +3,27 @@ import {
createNestedAllowlistOverrideResolver,
} from "openclaw/plugin-sdk/allowlist-config-edit";
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
+import {
+ createPairingPrefixStripper,
+ createTextPairingAdapter,
+} from "openclaw/plugin-sdk/channel-pairing";
import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import {
attachChannelToResult,
createAttachedChannelResultAdapter,
- createChannelDirectoryAdapter,
- createPairingPrefixStripper,
- createTopLevelChannelReplyToModeResolver,
- createTextPairingAdapter,
- normalizeMessageChannel,
- type OutboundSendDeps,
- resolveOutboundSendDep,
-} from "openclaw/plugin-sdk/channel-runtime";
-import { buildOutboundBaseSessionKey, normalizeOutboundThreadId } from "openclaw/plugin-sdk/core";
+} from "openclaw/plugin-sdk/channel-send-result";
+import { createTopLevelChannelReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime";
+import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
import { resolveExecApprovalCommandDisplay } from "openclaw/plugin-sdk/infra-runtime";
import { buildExecApprovalPendingReplyPayload } from "openclaw/plugin-sdk/infra-runtime";
-import { resolveThreadSessionKeys, type RoutePeer } from "openclaw/plugin-sdk/routing";
+import { resolveOutboundSendDep, type OutboundSendDeps } from "openclaw/plugin-sdk/infra-runtime";
+import {
+ buildOutboundBaseSessionKey,
+ normalizeMessageChannel,
+ normalizeOutboundThreadId,
+ resolveThreadSessionKeys,
+ type RoutePeer,
+} from "openclaw/plugin-sdk/routing";
import { parseTelegramTopicConversation } from "../runtime-api.js";
import {
buildTokenChannelStatusSummary,
@@ -581,6 +586,7 @@ export const telegramPlugin: ChannelPlugin {
const lines = [];
@@ -632,6 +638,7 @@ export const telegramPlugin: ChannelPlugin vi.fn());
vi.mock("./fetch.js", () => ({
resolveTelegramFetch,
+ resolveTelegramApiBase: (apiRoot?: string) =>
+ apiRoot?.trim()?.replace(/\/+$/, "") || "https://api.telegram.org",
}));
vi.mock("./proxy.js", () => ({
@@ -190,6 +192,7 @@ describe("probeTelegram retry logic", () => {
autoSelectFamily: false,
dnsResultOrder: "ipv4first",
},
+ apiRoot: undefined,
});
});
diff --git a/extensions/telegram/src/probe.ts b/extensions/telegram/src/probe.ts
index 60d9b3a3a40..bec56269927 100644
--- a/extensions/telegram/src/probe.ts
+++ b/extensions/telegram/src/probe.ts
@@ -1,11 +1,9 @@
-import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-runtime";
+import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
import { fetchWithTimeout } from "openclaw/plugin-sdk/text-runtime";
import type { TelegramNetworkConfig } from "../runtime-api.js";
-import { resolveTelegramFetch } from "./fetch.js";
+import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js";
import { makeProxyFetch } from "./proxy.js";
-const TELEGRAM_API_BASE = "https://api.telegram.org";
-
export type TelegramProbe = BaseProbeResult & {
status?: number | null;
elapsedMs: number;
@@ -23,6 +21,7 @@ export type TelegramProbeOptions = {
proxyUrl?: string;
network?: TelegramNetworkConfig;
accountId?: string;
+ apiRoot?: string;
};
const probeFetcherCache = new Map();
@@ -56,7 +55,8 @@ function buildProbeFetcherCacheKey(token: string, options?: TelegramProbeOptions
const autoSelectFamilyKey =
typeof autoSelectFamily === "boolean" ? String(autoSelectFamily) : "default";
const dnsResultOrderKey = options?.network?.dnsResultOrder ?? "default";
- return `${cacheIdentityKind}:${cacheIdentity}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}`;
+ const apiRootKey = options?.apiRoot?.trim() ?? "";
+ return `${cacheIdentityKind}:${cacheIdentity}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}::${apiRootKey}`;
}
function setCachedProbeFetcher(cacheKey: string, fetcher: typeof fetch): typeof fetch {
@@ -82,7 +82,9 @@ function resolveProbeFetcher(token: string, options?: TelegramProbeOptions): typ
const proxyUrl = options?.proxyUrl?.trim();
const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined;
- const resolved = resolveTelegramFetch(proxyFetch, { network: options?.network });
+ const resolved = resolveTelegramFetch(proxyFetch, {
+ network: options?.network,
+ });
if (cacheKey) {
return setCachedProbeFetcher(cacheKey, resolved);
@@ -100,7 +102,8 @@ export async function probeTelegram(
const deadlineMs = started + timeoutBudgetMs;
const options = resolveProbeOptions(proxyOrOptions);
const fetcher = resolveProbeFetcher(token, options);
- const base = `${TELEGRAM_API_BASE}/bot${token}`;
+ const apiBase = resolveTelegramApiBase(options?.apiRoot);
+ const base = `${apiBase}/bot${token}`;
const retryDelayMs = Math.max(50, Math.min(1000, Math.floor(timeoutBudgetMs / 5)));
const resolveRemainingBudgetMs = () => Math.max(0, deadlineMs - Date.now());
diff --git a/extensions/telegram/src/send.proxy.test.ts b/extensions/telegram/src/send.proxy.test.ts
index 6c17b33fe38..4f5709e581e 100644
--- a/extensions/telegram/src/send.proxy.test.ts
+++ b/extensions/telegram/src/send.proxy.test.ts
@@ -21,8 +21,10 @@ const { resolveTelegramFetch } = vi.hoisted(() => ({
resolveTelegramFetch: vi.fn(),
}));
-vi.mock("../../../src/config/config.js", async (importOriginal) => {
- const actual = await importOriginal();
+vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
+ const actual = await vi.importActual(
+ "openclaw/plugin-sdk/config-runtime",
+ );
return {
...actual,
loadConfig,
@@ -35,6 +37,8 @@ vi.mock("./proxy.js", () => ({
vi.mock("./fetch.js", () => ({
resolveTelegramFetch,
+ resolveTelegramApiBase: (apiRoot?: string) =>
+ apiRoot?.trim()?.replace(/\/+$/, "") || "https://api.telegram.org",
}));
vi.mock("grammy", () => ({
diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts
index ec824d88ec7..55f1d689359 100644
--- a/extensions/telegram/src/send.ts
+++ b/extensions/telegram/src/send.ts
@@ -25,7 +25,7 @@ import { withTelegramApiErrorLogging } from "./api-logging.js";
import { buildTelegramThreadParams, buildTypingThreadParams } from "./bot/helpers.js";
import type { TelegramInlineButtons } from "./button-types.js";
import { splitTelegramCaption } from "./caption.js";
-import { resolveTelegramFetch } from "./fetch.js";
+import { resolveTelegramApiBase, resolveTelegramFetch } from "./fetch.js";
import { renderTelegramHtmlText, splitTelegramHtmlChunks } from "./format.js";
import {
isRecoverableTelegramNetworkError,
@@ -192,9 +192,10 @@ function buildTelegramClientOptionsCacheKey(params: {
const autoSelectFamilyKey =
typeof autoSelectFamily === "boolean" ? String(autoSelectFamily) : "default";
const dnsResultOrderKey = params.account.config.network?.dnsResultOrder ?? "default";
+ const apiRootKey = params.account.config.apiRoot?.trim() ?? "";
const timeoutSecondsKey =
typeof params.timeoutSeconds === "number" ? String(params.timeoutSeconds) : "default";
- return `${params.account.accountId}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}::${timeoutSecondsKey}`;
+ return `${params.account.accountId}::${proxyKey}::${autoSelectFamilyKey}::${dnsResultOrderKey}::${apiRootKey}::${timeoutSecondsKey}`;
}
function setCachedTelegramClientOptions(
@@ -233,14 +234,16 @@ function resolveTelegramClientOptions(
const proxyUrl = account.config.proxy?.trim();
const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined;
+ const apiRoot = account.config.apiRoot?.trim() || undefined;
const fetchImpl = resolveTelegramFetch(proxyFetch, {
network: account.config.network,
});
const clientOptions =
- fetchImpl || timeoutSeconds
+ fetchImpl || timeoutSeconds || apiRoot
? {
...(fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : {}),
...(timeoutSeconds ? { timeoutSeconds } : {}),
+ ...(apiRoot ? { apiRoot } : {}),
}
: undefined;
if (cacheKey) {
diff --git a/extensions/telegram/src/setup-core.test.ts b/extensions/telegram/src/setup-core.test.ts
new file mode 100644
index 00000000000..5cf316c54d6
--- /dev/null
+++ b/extensions/telegram/src/setup-core.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it, vi } from "vitest";
+import { resolveTelegramAllowFromEntries } from "./setup-core.js";
+
+describe("resolveTelegramAllowFromEntries", () => {
+ it("passes apiRoot through username lookups", async () => {
+ const globalFetch = vi.fn(async () => {
+ throw new Error("global fetch should not be called");
+ });
+ const fetchMock = vi.fn(async () => ({
+ ok: true,
+ json: async () => ({ ok: true, result: { id: 12345 } }),
+ }));
+ vi.stubGlobal("fetch", globalFetch);
+ const proxyFetch = vi.fn();
+ const fetchModule = await import("./fetch.js");
+ const proxyModule = await import("./proxy.js");
+ const resolveTelegramFetch = vi.spyOn(fetchModule, "resolveTelegramFetch");
+ const makeProxyFetch = vi.spyOn(proxyModule, "makeProxyFetch");
+ makeProxyFetch.mockReturnValue(proxyFetch as unknown as typeof fetch);
+ resolveTelegramFetch.mockReturnValue(fetchMock as unknown as typeof fetch);
+
+ try {
+ const resolved = await resolveTelegramAllowFromEntries({
+ entries: ["@user"],
+ credentialValue: "tok",
+ apiRoot: "https://custom.telegram.test/root/",
+ proxyUrl: "http://127.0.0.1:8080",
+ network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" },
+ });
+
+ expect(resolved).toEqual([{ input: "@user", resolved: true, id: "12345" }]);
+ expect(makeProxyFetch).toHaveBeenCalledWith("http://127.0.0.1:8080");
+ expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, {
+ network: { autoSelectFamily: false, dnsResultOrder: "ipv4first" },
+ });
+ expect(fetchMock).toHaveBeenCalledWith(
+ "https://custom.telegram.test/root/bottok/getChat?chat_id=%40user",
+ undefined,
+ );
+ } finally {
+ makeProxyFetch.mockRestore();
+ resolveTelegramFetch.mockRestore();
+ vi.unstubAllGlobals();
+ }
+ });
+});
diff --git a/extensions/telegram/src/setup-core.ts b/extensions/telegram/src/setup-core.ts
index afc302500bf..6e24563a9c9 100644
--- a/extensions/telegram/src/setup-core.ts
+++ b/extensions/telegram/src/setup-core.ts
@@ -9,8 +9,9 @@ import {
} from "openclaw/plugin-sdk/setup";
import type { ChannelSetupAdapter, ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup";
import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
+import type { TelegramNetworkConfig } from "../runtime-api.js";
import { resolveDefaultTelegramAccountId, resolveTelegramAccount } from "./accounts.js";
-import { fetchTelegramChatId } from "./api-fetch.js";
+import { lookupTelegramChatId } from "./api-fetch.js";
const channel = "telegram" as const;
@@ -46,6 +47,9 @@ export function parseTelegramAllowFromId(raw: string): string | null {
export async function resolveTelegramAllowFromEntries(params: {
entries: string[];
credentialValue?: string;
+ apiRoot?: string;
+ proxyUrl?: string;
+ network?: TelegramNetworkConfig;
}) {
return await Promise.all(
params.entries.map(async (entry) => {
@@ -58,9 +62,12 @@ export async function resolveTelegramAllowFromEntries(params: {
return { input: entry, resolved: false, id: null };
}
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
- const id = await fetchTelegramChatId({
+ const id = await lookupTelegramChatId({
token: params.credentialValue,
chatId: username,
+ apiRoot: params.apiRoot,
+ proxyUrl: params.proxyUrl,
+ network: params.network,
});
return { input: entry, resolved: Boolean(id), id };
}),
@@ -96,6 +103,9 @@ export async function promptTelegramAllowFromForAccount(params: {
resolveTelegramAllowFromEntries({
credentialValue: token,
entries,
+ apiRoot: resolved.config.apiRoot,
+ proxyUrl: resolved.config.proxy,
+ network: resolved.config.network,
}),
});
return patchChannelConfigForAccount({
diff --git a/extensions/telegram/src/setup-surface.ts b/extensions/telegram/src/setup-surface.ts
index 75ebee401a2..f7b0c3e5ebb 100644
--- a/extensions/telegram/src/setup-surface.ts
+++ b/extensions/telegram/src/setup-surface.ts
@@ -119,10 +119,11 @@ export const telegramSetupWizard: ChannelSetupWizard = {
"Telegram token missing; use numeric sender ids (usernames require a bot token).",
parseInputs: splitSetupEntries,
parseId: parseTelegramAllowFromId,
- resolveEntries: async ({ credentialValues, entries }) =>
+ resolveEntries: async ({ cfg, accountId, credentialValues, entries }) =>
resolveTelegramAllowFromEntries({
credentialValue: credentialValues.token,
entries,
+ apiRoot: resolveTelegramAccount({ cfg, accountId }).config.apiRoot,
}),
apply: async ({ cfg, accountId, allowFrom }) =>
patchChannelConfigForAccount({
diff --git a/extensions/telegram/src/status-issues.ts b/extensions/telegram/src/status-issues.ts
index 0178c0c7346..b819308503a 100644
--- a/extensions/telegram/src/status-issues.ts
+++ b/extensions/telegram/src/status-issues.ts
@@ -1,13 +1,13 @@
+import type {
+ ChannelAccountSnapshot,
+ ChannelStatusIssue,
+} from "openclaw/plugin-sdk/channel-contract";
import {
appendMatchMetadata,
asString,
isRecord,
resolveEnabledConfiguredAccountId,
-} from "openclaw/plugin-sdk/channel-runtime";
-import type {
- ChannelAccountSnapshot,
- ChannelStatusIssue,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/status-helpers";
type TelegramAccountStatus = {
accountId?: unknown;
diff --git a/extensions/telegram/src/status-reaction-variants.ts b/extensions/telegram/src/status-reaction-variants.ts
index 8c04a87554e..7d995a23168 100644
--- a/extensions/telegram/src/status-reaction-variants.ts
+++ b/extensions/telegram/src/status-reaction-variants.ts
@@ -1,4 +1,4 @@
-import { DEFAULT_EMOJIS, type StatusReactionEmojis } from "openclaw/plugin-sdk/channel-runtime";
+import { DEFAULT_EMOJIS, type StatusReactionEmojis } from "openclaw/plugin-sdk/channel-feedback";
type StatusReactionEmojiKey = keyof Required;
diff --git a/extensions/telegram/src/thread-bindings.ts b/extensions/telegram/src/thread-bindings.ts
index 0078c3362e6..be734804efb 100644
--- a/extensions/telegram/src/thread-bindings.ts
+++ b/extensions/telegram/src/thread-bindings.ts
@@ -1,11 +1,11 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
-import { resolveThreadBindingConversationIdFromBindingId } from "openclaw/plugin-sdk/channel-runtime";
-import { resolveThreadBindingEffectiveExpiresAt } from "openclaw/plugin-sdk/channel-runtime";
-import { formatThreadBindingDurationLabel } from "openclaw/plugin-sdk/channel-runtime";
import {
+ formatThreadBindingDurationLabel,
registerSessionBindingAdapter,
+ resolveThreadBindingConversationIdFromBindingId,
+ resolveThreadBindingEffectiveExpiresAt,
unregisterSessionBindingAdapter,
type BindingTargetKind,
type SessionBindingRecord,
diff --git a/extensions/telegram/src/token.test.ts b/extensions/telegram/src/token.test.ts
index c81e5d57b2c..74218f83ddd 100644
--- a/extensions/telegram/src/token.test.ts
+++ b/extensions/telegram/src/token.test.ts
@@ -188,6 +188,24 @@ describe("resolveTelegramToken", () => {
expect(res.source).toBe("none");
});
+ it("does not fall through to channel-level token when non-default accountId is not in config", () => {
+ vi.stubEnv("TELEGRAM_BOT_TOKEN", "");
+ const cfg = {
+ channels: {
+ telegram: {
+ botToken: "wrong-bot-token",
+ accounts: {
+ knownBot: { botToken: "known-bot-token" },
+ },
+ },
+ },
+ } as OpenClawConfig;
+
+ const res = resolveTelegramToken(cfg, { accountId: "unknownBot" });
+ expect(res.token).toBe("");
+ expect(res.source).toBe("none");
+ });
+
it("throws when botToken is an unresolved SecretRef object", () => {
const cfg = {
channels: {
diff --git a/extensions/telegram/src/token.ts b/extensions/telegram/src/token.ts
index 6727e9a7ee4..c2482772c61 100644
--- a/extensions/telegram/src/token.ts
+++ b/extensions/telegram/src/token.ts
@@ -1,8 +1,8 @@
-import type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-runtime";
+import type { BaseTokenResolution } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
-import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/config-runtime";
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/infra-runtime";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
+import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
import type { TelegramAccountConfig } from "../runtime-api.js";
export type TelegramTokenSource = "env" | "tokenFile" | "config" | "none";
@@ -44,6 +44,17 @@ export function resolveTelegramToken(
const accountCfg = resolveAccountCfg(
accountId !== DEFAULT_ACCOUNT_ID ? accountId : DEFAULT_ACCOUNT_ID,
);
+
+ // When a non-default accountId is explicitly specified but not found in config,
+ // return empty immediately β do NOT fall through to channel-level defaults,
+ // which would silently route the message via the wrong bot's token.
+ if (accountId !== DEFAULT_ACCOUNT_ID && !accountCfg) {
+ opts.logMissingFile?.(
+ `channels.telegram.accounts: unknown accountId "${accountId}" β not found in config, refusing channel-level fallback`,
+ );
+ return { token: "", source: "none" };
+ }
+
const accountTokenFile = accountCfg?.tokenFile?.trim();
if (accountTokenFile) {
const token = tryReadSecretFileSync(
diff --git a/extensions/tlon/runtime-api.ts b/extensions/tlon/runtime-api.ts
index 3c2c83655c5..3ba9718868f 100644
--- a/extensions/tlon/runtime-api.ts
+++ b/extensions/tlon/runtime-api.ts
@@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Tlon extension.
// Keep this barrel thin and aligned with the local extension surface.
-export * from "openclaw/plugin-sdk/tlon";
+export * from "../../src/plugin-sdk/tlon.js";
diff --git a/extensions/tlon/src/channel.runtime.ts b/extensions/tlon/src/channel.runtime.ts
index 56d59d6003b..c00199eeb9b 100644
--- a/extensions/tlon/src/channel.runtime.ts
+++ b/extensions/tlon/src/channel.runtime.ts
@@ -1,8 +1,6 @@
import crypto from "node:crypto";
-import type {
- ChannelAccountSnapshot,
- ChannelOutboundAdapter,
-} from "openclaw/plugin-sdk/channel-runtime";
+import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract";
+import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-send-result";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
import { createLoggerBackedRuntime } from "openclaw/plugin-sdk/runtime";
diff --git a/extensions/tlon/src/channel.ts b/extensions/tlon/src/channel.ts
index 89e4a235b60..71752c4d1a3 100644
--- a/extensions/tlon/src/channel.ts
+++ b/extensions/tlon/src/channel.ts
@@ -1,10 +1,8 @@
import { createHybridChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers";
-import {
- createRuntimeOutboundDelegates,
- type ChannelAccountSnapshot,
- type ChannelPlugin,
-} from "openclaw/plugin-sdk/channel-runtime";
+import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/channel-contract";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
+import type { ChannelPlugin } from "openclaw/plugin-sdk/core";
+import { createRuntimeOutboundDelegates } from "openclaw/plugin-sdk/infra-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { tlonChannelConfigSchema } from "./config-schema.js";
import { resolveTlonOutboundSessionRoute } from "./session-route.js";
diff --git a/extensions/twitch/runtime-api.ts b/extensions/twitch/runtime-api.ts
index 87433b1997f..9d055202a39 100644
--- a/extensions/twitch/runtime-api.ts
+++ b/extensions/twitch/runtime-api.ts
@@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Twitch extension.
// Keep this barrel thin and aligned with the local extension surface.
-export * from "openclaw/plugin-sdk/twitch";
+export * from "../../src/plugin-sdk/twitch.js";
diff --git a/extensions/voice-call/runtime-api.ts b/extensions/voice-call/runtime-api.ts
index 9dd4fb0f3bc..f0b32548645 100644
--- a/extensions/voice-call/runtime-api.ts
+++ b/extensions/voice-call/runtime-api.ts
@@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Voice Call extension.
// Keep this barrel thin and aligned with the local extension surface.
-export * from "openclaw/plugin-sdk/voice-call";
+export * from "../../src/plugin-sdk/voice-call.js";
diff --git a/extensions/whatsapp/api.ts b/extensions/whatsapp/api.ts
index 8bf50cefccd..c9d2ae0bcee 100644
--- a/extensions/whatsapp/api.ts
+++ b/extensions/whatsapp/api.ts
@@ -7,4 +7,4 @@ export {
listWhatsAppDirectoryGroupsFromConfig,
listWhatsAppDirectoryPeersFromConfig,
} from "./src/directory-config.js";
-export { resolveWhatsAppGroupIntroHint } from "openclaw/plugin-sdk/whatsapp-core";
+export { resolveWhatsAppGroupIntroHint } from "./src/runtime-api.js";
diff --git a/extensions/whatsapp/src/agent-tools-login.ts b/extensions/whatsapp/src/agent-tools-login.ts
index d53f5105ca2..653f4c5ef6b 100644
--- a/extensions/whatsapp/src/agent-tools-login.ts
+++ b/extensions/whatsapp/src/agent-tools-login.ts
@@ -1,6 +1,6 @@
import { Type } from "@sinclair/typebox";
-import type { ChannelAgentTool } from "openclaw/plugin-sdk/channel-runtime";
-import { startWebLoginWithQr, waitForWebLogin } from "openclaw/plugin-sdk/whatsapp-login-qr";
+import type { ChannelAgentTool } from "openclaw/plugin-sdk/channel-contract";
+import { startWebLoginWithQr, waitForWebLogin } from "../login-qr-api.js";
export function createWhatsAppLoginTool(): ChannelAgentTool {
return {
diff --git a/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts b/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts
index 8fb27a39fe4..8c8c8639734 100644
--- a/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts
+++ b/extensions/whatsapp/src/auto-reply/heartbeat-runner.ts
@@ -1,5 +1,4 @@
import { appendCronStyleCurrentTimeLine } from "openclaw/plugin-sdk/agent-runtime";
-import { resolveWhatsAppHeartbeatRecipients } from "openclaw/plugin-sdk/channel-runtime";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import {
loadSessionStore,
@@ -25,6 +24,7 @@ import { normalizeMainKey } from "openclaw/plugin-sdk/routing";
import { getChildLogger } from "openclaw/plugin-sdk/runtime-env";
import { redactIdentifier } from "openclaw/plugin-sdk/text-runtime";
import { newConnectionId } from "../reconnect.js";
+import { resolveWhatsAppHeartbeatRecipients } from "../runtime-api.js";
import { sendMessageWhatsApp } from "../send.js";
import { formatError } from "../session.js";
import { whatsappHeartbeatLog } from "./loggers.js";
diff --git a/extensions/whatsapp/src/auto-reply/mentions.ts b/extensions/whatsapp/src/auto-reply/mentions.ts
index ad42c814c26..967b4c1c61b 100644
--- a/extensions/whatsapp/src/auto-reply/mentions.ts
+++ b/extensions/whatsapp/src/auto-reply/mentions.ts
@@ -1,5 +1,5 @@
+import { buildMentionRegexes, normalizeMentionText } from "openclaw/plugin-sdk/channel-inbound";
import type { loadConfig } from "openclaw/plugin-sdk/config-runtime";
-import { buildMentionRegexes, normalizeMentionText } from "openclaw/plugin-sdk/reply-runtime";
import { isSelfChatMode, jidToE164, normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
import type { WebInboundMsg } from "./types.js";
diff --git a/extensions/whatsapp/src/auto-reply/monitor.ts b/extensions/whatsapp/src/auto-reply/monitor.ts
index 2f83e65079a..1997ddc38a1 100644
--- a/extensions/whatsapp/src/auto-reply/monitor.ts
+++ b/extensions/whatsapp/src/auto-reply/monitor.ts
@@ -1,13 +1,13 @@
+import { resolveInboundDebounceMs } from "openclaw/plugin-sdk/channel-inbound";
import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime";
import { waitForever } from "openclaw/plugin-sdk/cli-runtime";
+import { hasControlCommand } from "openclaw/plugin-sdk/command-auth";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { createConnectedChannelStatusPatch } from "openclaw/plugin-sdk/gateway-runtime";
import { formatDurationPrecise } from "openclaw/plugin-sdk/infra-runtime";
import { enqueueSystemEvent } from "openclaw/plugin-sdk/infra-runtime";
-import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime";
-import { resolveInboundDebounceMs } from "openclaw/plugin-sdk/reply-runtime";
+import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-history";
import { getReplyFromConfig } from "openclaw/plugin-sdk/reply-runtime";
-import { DEFAULT_GROUP_HISTORY_LIMIT } from "openclaw/plugin-sdk/reply-runtime";
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { registerUnhandledRejectionHandler } from "openclaw/plugin-sdk/runtime-env";
diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts
index 126c485ec6f..bb6e1a181ab 100644
--- a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts
+++ b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts
@@ -1,4 +1,4 @@
-import { shouldAckReactionForWhatsApp } from "openclaw/plugin-sdk/channel-runtime";
+import { shouldAckReactionForWhatsApp } from "openclaw/plugin-sdk/channel-feedback";
import type { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { sendReactionWhatsApp } from "../../send.js";
diff --git a/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts b/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts
index 847e5e3182f..d639e9e182a 100644
--- a/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts
+++ b/extensions/whatsapp/src/auto-reply/monitor/group-gating.ts
@@ -1,8 +1,8 @@
-import { resolveMentionGating } from "openclaw/plugin-sdk/channel-runtime";
+import { resolveMentionGating } from "openclaw/plugin-sdk/channel-inbound";
+import { hasControlCommand } from "openclaw/plugin-sdk/command-auth";
import type { loadConfig } from "openclaw/plugin-sdk/config-runtime";
-import { hasControlCommand } from "openclaw/plugin-sdk/reply-runtime";
+import { recordPendingHistoryEntryIfEnabled } from "openclaw/plugin-sdk/reply-history";
import { parseActivationCommand } from "openclaw/plugin-sdk/reply-runtime";
-import { recordPendingHistoryEntryIfEnabled } from "openclaw/plugin-sdk/reply-runtime";
import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
import type { MentionConfig } from "../mentions.js";
import { buildMentionConfig, debugMention, resolveOwnerList } from "../mentions.js";
diff --git a/extensions/whatsapp/src/auto-reply/monitor/message-line.ts b/extensions/whatsapp/src/auto-reply/monitor/message-line.ts
index b9494f0325c..4b33649da43 100644
--- a/extensions/whatsapp/src/auto-reply/monitor/message-line.ts
+++ b/extensions/whatsapp/src/auto-reply/monitor/message-line.ts
@@ -1,9 +1,9 @@
import { resolveMessagePrefix } from "openclaw/plugin-sdk/agent-runtime";
-import type { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import {
formatInboundEnvelope,
type EnvelopeFormatOptions,
-} from "openclaw/plugin-sdk/reply-runtime";
+} from "openclaw/plugin-sdk/channel-inbound";
+import type { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import type { WebInboundMsg } from "../types.js";
export function formatReplyContext(msg: WebInboundMsg) {
diff --git a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts
index 067087f87d3..255c211f0ee 100644
--- a/extensions/whatsapp/src/auto-reply/monitor/process-message.ts
+++ b/extensions/whatsapp/src/auto-reply/monitor/process-message.ts
@@ -1,20 +1,22 @@
import { resolveIdentityNamePrefix } from "openclaw/plugin-sdk/agent-runtime";
+import {
+ resolveInboundSessionEnvelopeContext,
+ toLocationContext,
+} from "openclaw/plugin-sdk/channel-inbound";
+import { formatInboundEnvelope } from "openclaw/plugin-sdk/channel-inbound";
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
-import { toLocationContext } from "openclaw/plugin-sdk/channel-runtime";
-import { resolveInboundSessionEnvelopeContext } from "openclaw/plugin-sdk/channel-runtime";
+import { shouldComputeCommandAuthorized } from "openclaw/plugin-sdk/command-auth";
import type { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { recordSessionMetaFromInbound } from "openclaw/plugin-sdk/config-runtime";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
-import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
-import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
-import { shouldComputeCommandAuthorized } from "openclaw/plugin-sdk/reply-runtime";
-import { formatInboundEnvelope } from "openclaw/plugin-sdk/reply-runtime";
-import type { getReplyFromConfig } from "openclaw/plugin-sdk/reply-runtime";
import {
buildHistoryContextFromEntries,
type HistoryEntry,
-} from "openclaw/plugin-sdk/reply-runtime";
+} from "openclaw/plugin-sdk/reply-history";
+import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
+import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-runtime";
+import type { getReplyFromConfig } from "openclaw/plugin-sdk/reply-runtime";
import { finalizeInboundContext } from "openclaw/plugin-sdk/reply-runtime";
import { dispatchReplyWithBufferedBlockDispatcher } from "openclaw/plugin-sdk/reply-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
diff --git a/extensions/whatsapp/src/channel.directory.test.ts b/extensions/whatsapp/src/channel.directory.test.ts
index 3fd58b31d4d..d9a072c86f1 100644
--- a/extensions/whatsapp/src/channel.directory.test.ts
+++ b/extensions/whatsapp/src/channel.directory.test.ts
@@ -1,10 +1,10 @@
-import type { OpenClawConfig } from "openclaw/plugin-sdk/whatsapp";
import { describe, expect, it } from "vitest";
import {
createDirectoryTestRuntime,
expectDirectorySurface,
} from "../../../test/helpers/extensions/directory.ts";
import { whatsappPlugin } from "./channel.js";
+import type { OpenClawConfig } from "./runtime-api.js";
describe("whatsapp directory", () => {
const runtimeEnv = createDirectoryTestRuntime() as never;
diff --git a/extensions/whatsapp/src/config-schema.ts b/extensions/whatsapp/src/config-schema.ts
index 23f7de4058f..89681ce2d54 100644
--- a/extensions/whatsapp/src/config-schema.ts
+++ b/extensions/whatsapp/src/config-schema.ts
@@ -1,3 +1,3 @@
-import { buildChannelConfigSchema, WhatsAppConfigSchema } from "openclaw/plugin-sdk/whatsapp-core";
+import { buildChannelConfigSchema, WhatsAppConfigSchema } from "./runtime-api.js";
export const WhatsAppChannelConfigSchema = buildChannelConfigSchema(WhatsAppConfigSchema);
diff --git a/extensions/whatsapp/src/inbound.media.test.ts b/extensions/whatsapp/src/inbound.media.test.ts
index 7ed52cace45..d83ef1dfea5 100644
--- a/extensions/whatsapp/src/inbound.media.test.ts
+++ b/extensions/whatsapp/src/inbound.media.test.ts
@@ -8,8 +8,10 @@ const readAllowFromStoreMock = vi.fn().mockResolvedValue([]);
const upsertPairingRequestMock = vi.fn().mockResolvedValue({ code: "PAIRCODE", created: true });
const saveMediaBufferSpy = vi.fn();
-vi.mock("../../../src/config/config.js", async (importOriginal) => {
- const actual = await importOriginal();
+vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
+ const actual = await vi.importActual(
+ "openclaw/plugin-sdk/config-runtime",
+ );
return {
...actual,
loadConfig: vi.fn().mockReturnValue({
@@ -37,8 +39,10 @@ vi.mock("../../../src/pairing/pairing-store.js", () => {
};
});
-vi.mock("../../../src/media/store.js", async (importOriginal) => {
- const actual = await importOriginal();
+vi.mock("openclaw/plugin-sdk/media-runtime", async () => {
+ const actual = await vi.importActual(
+ "openclaw/plugin-sdk/media-runtime",
+ );
return {
...actual,
saveMediaBuffer: vi.fn(async (...args: Parameters) => {
diff --git a/extensions/whatsapp/src/inbound/extract.ts b/extensions/whatsapp/src/inbound/extract.ts
index 9fa663847a6..b1b64e4fe91 100644
--- a/extensions/whatsapp/src/inbound/extract.ts
+++ b/extensions/whatsapp/src/inbound/extract.ts
@@ -4,7 +4,7 @@ import {
getContentType,
normalizeMessageContent,
} from "@whiskeysockets/baileys";
-import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-runtime";
+import { formatLocationText, type NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { jidToE164 } from "openclaw/plugin-sdk/text-runtime";
import { parseVcard } from "../vcard.js";
diff --git a/extensions/whatsapp/src/inbound/monitor.ts b/extensions/whatsapp/src/inbound/monitor.ts
index 35669bc1b49..b19e37feb69 100644
--- a/extensions/whatsapp/src/inbound/monitor.ts
+++ b/extensions/whatsapp/src/inbound/monitor.ts
@@ -1,9 +1,8 @@
import type { AnyMessageContent, proto, WAMessage } from "@whiskeysockets/baileys";
import { DisconnectReason, isJidGroup } from "@whiskeysockets/baileys";
-import { formatLocationText } from "openclaw/plugin-sdk/channel-runtime";
+import { createInboundDebouncer, formatLocationText } from "openclaw/plugin-sdk/channel-inbound";
import { recordChannelActivity } from "openclaw/plugin-sdk/infra-runtime";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
-import { createInboundDebouncer } from "openclaw/plugin-sdk/reply-runtime";
import { logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { getChildLogger } from "openclaw/plugin-sdk/text-runtime";
diff --git a/extensions/whatsapp/src/inbound/types.ts b/extensions/whatsapp/src/inbound/types.ts
index 42e4b5121d1..731dcd2c8cc 100644
--- a/extensions/whatsapp/src/inbound/types.ts
+++ b/extensions/whatsapp/src/inbound/types.ts
@@ -1,5 +1,5 @@
import type { AnyMessageContent } from "@whiskeysockets/baileys";
-import type { NormalizedLocation } from "openclaw/plugin-sdk/channel-runtime";
+import type { NormalizedLocation } from "openclaw/plugin-sdk/channel-inbound";
export type WebListenerCloseReason = {
status?: number;
diff --git a/extensions/whatsapp/src/login.coverage.test.ts b/extensions/whatsapp/src/login.coverage.test.ts
index dda665ccdce..7215d3ac862 100644
--- a/extensions/whatsapp/src/login.coverage.test.ts
+++ b/extensions/whatsapp/src/login.coverage.test.ts
@@ -19,25 +19,30 @@ function resolveTestAuthDir() {
const authDir = resolveTestAuthDir();
-vi.mock("../../../src/config/config.js", () => ({
- loadConfig: () =>
- ({
- channels: {
- whatsapp: {
- accounts: {
- default: { enabled: true, authDir: resolveTestAuthDir() },
+vi.mock("openclaw/plugin-sdk/config-runtime", async () => {
+ const actual = await vi.importActual(
+ "openclaw/plugin-sdk/config-runtime",
+ );
+ return {
+ ...actual,
+ loadConfig: () =>
+ ({
+ channels: {
+ whatsapp: {
+ accounts: {
+ default: { enabled: true, authDir: resolveTestAuthDir() },
+ },
},
},
- },
- }) as never,
-}));
+ }) as never,
+ };
+});
vi.mock("./session.js", () => {
const authDir = resolveTestAuthDir();
const sockA = { ws: { close: vi.fn() } };
const sockB = { ws: { close: vi.fn() } };
- let call = 0;
- const createWaSocket = vi.fn(async () => (call++ === 0 ? sockA : sockB));
+ const createWaSocket = vi.fn(async () => (createWaSocket.mock.calls.length <= 1 ? sockA : sockB));
const waitForWaConnection = vi.fn();
const formatError = vi.fn((err: unknown) => `formatted:${String(err)}`);
const getStatusCode = vi.fn(
@@ -78,6 +83,10 @@ describe("loginWeb coverage", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
+ createWaSocketMock.mockClear();
+ waitForWaConnectionMock.mockReset().mockResolvedValue(undefined);
+ waitForCredsSaveQueueWithTimeoutMock.mockReset().mockResolvedValue(undefined);
+ formatErrorMock.mockReset().mockImplementation((err: unknown) => `formatted:${String(err)}`);
rmMock.mockClear();
});
afterEach(() => {
diff --git a/extensions/whatsapp/src/normalize.ts b/extensions/whatsapp/src/normalize.ts
index d0506cd5883..63a1c8279bb 100644
--- a/extensions/whatsapp/src/normalize.ts
+++ b/extensions/whatsapp/src/normalize.ts
@@ -4,4 +4,4 @@ export {
normalizeWhatsAppAllowFromEntries,
normalizeWhatsAppMessagingTarget,
normalizeWhatsAppTarget,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "./runtime-api.js";
diff --git a/extensions/whatsapp/src/outbound-adapter.ts b/extensions/whatsapp/src/outbound-adapter.ts
index 4800e2ded43..45fa8d046e7 100644
--- a/extensions/whatsapp/src/outbound-adapter.ts
+++ b/extensions/whatsapp/src/outbound-adapter.ts
@@ -1,11 +1,13 @@
-import { sendTextMediaPayload } from "openclaw/plugin-sdk/channel-runtime";
-import type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-runtime";
-import { resolveOutboundSendDep } from "openclaw/plugin-sdk/channel-runtime";
import {
+ type ChannelOutboundAdapter,
createAttachedChannelResultAdapter,
createEmptyChannelResult,
} from "openclaw/plugin-sdk/channel-send-result";
-import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
+import { resolveOutboundSendDep } from "openclaw/plugin-sdk/infra-runtime";
+import {
+ resolveSendableOutboundReplyParts,
+ sendTextMediaPayload,
+} from "openclaw/plugin-sdk/reply-payload";
import { chunkText } from "openclaw/plugin-sdk/reply-runtime";
import { shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveWhatsAppOutboundTarget } from "./runtime-api.js";
diff --git a/extensions/whatsapp/src/resolve-target.test.ts b/extensions/whatsapp/src/resolve-target.test.ts
index c24b6812cae..ca5cef77b9b 100644
--- a/extensions/whatsapp/src/resolve-target.test.ts
+++ b/extensions/whatsapp/src/resolve-target.test.ts
@@ -1,10 +1,8 @@
import { installCommonResolveTargetErrorCases } from "openclaw/plugin-sdk/testing";
import { describe, expect, it, vi } from "vitest";
-vi.mock("openclaw/plugin-sdk/whatsapp", async () => {
- const actual = await vi.importActual(
- "openclaw/plugin-sdk/whatsapp",
- );
+vi.mock("./runtime-api.js", async () => {
+ const actual = await vi.importActual("./runtime-api.js");
const normalizeWhatsAppTarget = (value: string) => {
if (value === "invalid-target") return null;
// Simulate E.164 normalization: strip leading + and whatsapp: prefix.
@@ -84,7 +82,7 @@ describe("whatsapp resolveTarget", () => {
if (!result.ok) {
throw result.error;
}
- expect(result.to).toBe("+5511999999999");
+ expect(result.to).toBe("5511999999999@s.whatsapp.net");
});
it("should resolve target in implicit mode with wildcard", () => {
@@ -98,7 +96,7 @@ describe("whatsapp resolveTarget", () => {
if (!result.ok) {
throw result.error;
}
- expect(result.to).toBe("+5511999999999");
+ expect(result.to).toBe("5511999999999@s.whatsapp.net");
});
it("should resolve target in implicit mode when in allowlist", () => {
@@ -112,7 +110,7 @@ describe("whatsapp resolveTarget", () => {
if (!result.ok) {
throw result.error;
}
- expect(result.to).toBe("+5511999999999");
+ expect(result.to).toBe("5511999999999@s.whatsapp.net");
});
it("should allow group JID regardless of allowlist", () => {
diff --git a/extensions/whatsapp/src/runtime-api.ts b/extensions/whatsapp/src/runtime-api.ts
index 515040ffb42..a98c264b2b2 100644
--- a/extensions/whatsapp/src/runtime-api.ts
+++ b/extensions/whatsapp/src/runtime-api.ts
@@ -9,16 +9,21 @@ export {
readReactionParams,
readStringParam,
resolveWhatsAppGroupIntroHint,
+ resolveWhatsAppGroupRequireMention,
+ resolveWhatsAppGroupToolPolicy,
resolveWhatsAppOutboundTarget,
ToolAuthorizationError,
WhatsAppConfigSchema,
type ChannelPlugin,
type OpenClawConfig,
-} from "openclaw/plugin-sdk/whatsapp-core";
+} from "../../../src/plugin-sdk/whatsapp-core.js";
export {
createWhatsAppOutboundBase,
isWhatsAppGroupJid,
+ looksLikeWhatsAppTargetId,
+ normalizeWhatsAppAllowFromEntries,
+ normalizeWhatsAppMessagingTarget,
normalizeWhatsAppTarget,
resolveWhatsAppHeartbeatRecipients,
resolveWhatsAppMentionStripRegexes,
@@ -26,6 +31,6 @@ export {
type DmPolicy,
type GroupPolicy,
type WhatsAppAccountConfig,
-} from "openclaw/plugin-sdk/whatsapp-shared";
+} from "../../../src/plugin-sdk/whatsapp-shared.js";
export { monitorWebChannel } from "./channel.runtime.js";
diff --git a/extensions/whatsapp/src/shared.ts b/extensions/whatsapp/src/shared.ts
index 3e241c9f94c..fcc5bb92421 100644
--- a/extensions/whatsapp/src/shared.ts
+++ b/extensions/whatsapp/src/shared.ts
@@ -5,6 +5,12 @@ import {
import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import { createChannelPluginBase } from "openclaw/plugin-sdk/core";
import { createDelegatedSetupWizardProxy } from "openclaw/plugin-sdk/setup";
+import {
+ listWhatsAppAccountIds,
+ resolveDefaultWhatsAppAccountId,
+ resolveWhatsAppAccount,
+ type ResolvedWhatsAppAccount,
+} from "./accounts.js";
import {
buildChannelConfigSchema,
formatWhatsAppConfigAllowFromEntries,
@@ -15,13 +21,7 @@ import {
resolveWhatsAppGroupToolPolicy,
WhatsAppConfigSchema,
type ChannelPlugin,
-} from "openclaw/plugin-sdk/whatsapp-core";
-import {
- listWhatsAppAccountIds,
- resolveDefaultWhatsAppAccountId,
- resolveWhatsAppAccount,
- type ResolvedWhatsAppAccount,
-} from "./accounts.js";
+} from "./runtime-api.js";
export const WHATSAPP_CHANNEL = "whatsapp" as const;
diff --git a/extensions/whatsapp/src/status-issues.ts b/extensions/whatsapp/src/status-issues.ts
index f369ba29cda..15e6e6b216f 100644
--- a/extensions/whatsapp/src/status-issues.ts
+++ b/extensions/whatsapp/src/status-issues.ts
@@ -1,13 +1,13 @@
+import type {
+ ChannelAccountSnapshot,
+ ChannelStatusIssue,
+} from "openclaw/plugin-sdk/channel-contract";
+import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime";
import {
asString,
collectIssuesForEnabledAccounts,
isRecord,
-} from "openclaw/plugin-sdk/channel-runtime";
-import type {
- ChannelAccountSnapshot,
- ChannelStatusIssue,
-} from "openclaw/plugin-sdk/channel-runtime";
-import { formatCliCommand } from "openclaw/plugin-sdk/cli-runtime";
+} from "openclaw/plugin-sdk/status-helpers";
type WhatsAppAccountStatus = {
accountId?: unknown;
diff --git a/extensions/xai/model-definitions.ts b/extensions/xai/model-definitions.ts
index 87d18484264..a925f7848ca 100644
--- a/extensions/xai/model-definitions.ts
+++ b/extensions/xai/model-definitions.ts
@@ -59,14 +59,14 @@ const XAI_MODEL_CATALOG = [
contextWindow: XAI_LARGE_CONTEXT_WINDOW,
},
{
- id: "grok-4.20-experimental-beta-0304-reasoning",
- name: "Grok 4.20 Experimental Beta 0304 (Reasoning)",
+ id: "grok-4.20-reasoning",
+ name: "Grok 4.20 (Reasoning)",
reasoning: true,
contextWindow: XAI_LARGE_CONTEXT_WINDOW,
},
{
- id: "grok-4.20-experimental-beta-0304-non-reasoning",
- name: "Grok 4.20 Experimental Beta 0304 (Non-Reasoning)",
+ id: "grok-4.20-non-reasoning",
+ name: "Grok 4.20 (Non-Reasoning)",
reasoning: false,
contextWindow: XAI_LARGE_CONTEXT_WINDOW,
},
diff --git a/extensions/xai/provider-models.test.ts b/extensions/xai/provider-models.test.ts
index 175209f4975..d0d025a852a 100644
--- a/extensions/xai/provider-models.test.ts
+++ b/extensions/xai/provider-models.test.ts
@@ -16,8 +16,21 @@ describe("xai provider models", () => {
});
});
+ it("publishes Grok 4.20 reasoning and non-reasoning models", () => {
+ expect(resolveXaiCatalogEntry("grok-4.20-reasoning")).toMatchObject({
+ id: "grok-4.20-reasoning",
+ reasoning: true,
+ contextWindow: 2_000_000,
+ });
+ expect(resolveXaiCatalogEntry("grok-4.20-non-reasoning")).toMatchObject({
+ id: "grok-4.20-non-reasoning",
+ reasoning: false,
+ contextWindow: 2_000_000,
+ });
+ });
+
it("marks current Grok families as modern while excluding multi-agent ids", () => {
- expect(isModernXaiModel("grok-4.20-experimental-beta-0304-reasoning")).toBe(true);
+ expect(isModernXaiModel("grok-4.20-reasoning")).toBe(true);
expect(isModernXaiModel("grok-code-fast-1")).toBe(true);
expect(isModernXaiModel("grok-3-mini-fast")).toBe(false);
expect(isModernXaiModel("grok-4.20-multi-agent-experimental-beta-0304")).toBe(false);
@@ -40,7 +53,7 @@ describe("xai provider models", () => {
providerId: "xai",
ctx: {
provider: "xai",
- modelId: "grok-4.20-experimental-beta-0304-reasoning",
+ modelId: "grok-4.20-reasoning",
modelRegistry: { find: () => null } as never,
providerConfig: {
api: "openai-completions",
@@ -59,7 +72,7 @@ describe("xai provider models", () => {
});
expect(grok420).toMatchObject({
provider: "xai",
- id: "grok-4.20-experimental-beta-0304-reasoning",
+ id: "grok-4.20-reasoning",
api: "openai-completions",
baseUrl: "https://api.x.ai/v1",
reasoning: true,
diff --git a/extensions/xai/src/web-search-shared.ts b/extensions/xai/src/web-search-shared.ts
index 47616bcf13c..85ea11aa49d 100644
--- a/extensions/xai/src/web-search-shared.ts
+++ b/extensions/xai/src/web-search-shared.ts
@@ -1,3 +1,4 @@
+import { normalizeXaiModelId } from "openclaw/plugin-sdk/provider-models";
import { postTrustedWebToolsJson, wrapWebContent } from "openclaw/plugin-sdk/provider-web-search";
export const XAI_WEB_SEARCH_ENDPOINT = "https://api.x.ai/v1/responses";
@@ -79,7 +80,7 @@ export function resolveXaiSearchConfig(searchConfig?: Record):
export function resolveXaiWebSearchModel(searchConfig?: Record): string {
const config = resolveXaiSearchConfig(searchConfig);
return typeof config.model === "string" && config.model.trim()
- ? config.model.trim()
+ ? normalizeXaiModelId(config.model.trim())
: XAI_DEFAULT_WEB_SEARCH_MODEL;
}
diff --git a/extensions/xai/web-search.test.ts b/extensions/xai/web-search.test.ts
index 29433ec7efa..a6dfff40633 100644
--- a/extensions/xai/web-search.test.ts
+++ b/extensions/xai/web-search.test.ts
@@ -44,6 +44,19 @@ describe("xai web search config resolution", () => {
);
});
+ it("normalizes deprecated grok 4.20 beta model ids to GA ids", () => {
+ expect(
+ resolveXaiWebSearchModel({
+ grok: { model: "grok-4.20-experimental-beta-0304-reasoning" },
+ }),
+ ).toBe("grok-4.20-reasoning");
+ expect(
+ resolveXaiWebSearchModel({
+ grok: { model: "grok-4.20-experimental-beta-0304-non-reasoning" },
+ }),
+ ).toBe("grok-4.20-non-reasoning");
+ });
+
it("defaults inlineCitations to false", () => {
expect(resolveXaiInlineCitations({})).toBe(false);
expect(resolveXaiInlineCitations(undefined)).toBe(false);
diff --git a/extensions/zalo/runtime-api.ts b/extensions/zalo/runtime-api.ts
index 90ced0da803..082f65d43b8 100644
--- a/extensions/zalo/runtime-api.ts
+++ b/extensions/zalo/runtime-api.ts
@@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Zalo extension.
// Keep this barrel thin and aligned with the local extension surface.
-export * from "openclaw/plugin-sdk/zalo";
+export * from "../../src/plugin-sdk/zalo.js";
diff --git a/extensions/zalo/src/channel.ts b/extensions/zalo/src/channel.ts
index b8d11b50937..165fe5bac52 100644
--- a/extensions/zalo/src/channel.ts
+++ b/extensions/zalo/src/channel.ts
@@ -9,11 +9,11 @@ import {
createOpenProviderGroupPolicyWarningCollector,
} from "openclaw/plugin-sdk/channel-policy";
import {
- createChannelDirectoryAdapter,
createEmptyChannelResult,
createRawChannelSendResultAdapter,
- createStaticReplyToModeResolver,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/channel-send-result";
+import { createStaticReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime";
+import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
import { listResolvedDirectoryUserEntriesFromAllowFrom } from "openclaw/plugin-sdk/directory-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import {
diff --git a/extensions/zalouser/runtime-api.ts b/extensions/zalouser/runtime-api.ts
index 7d931f2d118..1b63edaea42 100644
--- a/extensions/zalouser/runtime-api.ts
+++ b/extensions/zalouser/runtime-api.ts
@@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Zalo Personal extension.
// Keep this barrel thin and aligned with the local extension surface.
-export * from "openclaw/plugin-sdk/zalouser";
+export * from "../../src/plugin-sdk/zalouser.js";
diff --git a/extensions/zalouser/src/channel.ts b/extensions/zalouser/src/channel.ts
index 571ad31c164..c9b6fc17a67 100644
--- a/extensions/zalouser/src/channel.ts
+++ b/extensions/zalouser/src/channel.ts
@@ -1,12 +1,14 @@
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
import {
- createEmptyChannelResult,
createPairingPrefixStripper,
- createRawChannelSendResultAdapter,
- createStaticReplyToModeResolver,
createTextPairingAdapter,
-} from "openclaw/plugin-sdk/channel-runtime";
+} from "openclaw/plugin-sdk/channel-pairing";
+import {
+ createEmptyChannelResult,
+ createRawChannelSendResultAdapter,
+} from "openclaw/plugin-sdk/channel-send-result";
+import { createStaticReplyToModeResolver } from "openclaw/plugin-sdk/conversation-runtime";
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
import type {
ChannelAccountSnapshot,
diff --git a/package.json b/package.json
index 91abc6172a7..99529029aed 100644
--- a/package.json
+++ b/package.json
@@ -121,6 +121,10 @@
"types": "./dist/plugin-sdk/infra-runtime.d.ts",
"default": "./dist/plugin-sdk/infra-runtime.js"
},
+ "./plugin-sdk/ssrf-runtime": {
+ "types": "./dist/plugin-sdk/ssrf-runtime.d.ts",
+ "default": "./dist/plugin-sdk/ssrf-runtime.js"
+ },
"./plugin-sdk/media-runtime": {
"types": "./dist/plugin-sdk/media-runtime.d.ts",
"default": "./dist/plugin-sdk/media-runtime.js"
@@ -133,6 +137,18 @@
"types": "./dist/plugin-sdk/conversation-runtime.d.ts",
"default": "./dist/plugin-sdk/conversation-runtime.js"
},
+ "./plugin-sdk/matrix-runtime-heavy": {
+ "types": "./dist/plugin-sdk/matrix-runtime-heavy.d.ts",
+ "default": "./dist/plugin-sdk/matrix-runtime-heavy.js"
+ },
+ "./plugin-sdk/matrix-runtime-shared": {
+ "types": "./dist/plugin-sdk/matrix-runtime-shared.d.ts",
+ "default": "./dist/plugin-sdk/matrix-runtime-shared.js"
+ },
+ "./plugin-sdk/thread-bindings-runtime": {
+ "types": "./dist/plugin-sdk/thread-bindings-runtime.d.ts",
+ "default": "./dist/plugin-sdk/thread-bindings-runtime.js"
+ },
"./plugin-sdk/text-runtime": {
"types": "./dist/plugin-sdk/text-runtime.d.ts",
"default": "./dist/plugin-sdk/text-runtime.js"
@@ -177,126 +193,6 @@
"types": "./dist/plugin-sdk/acp-runtime.d.ts",
"default": "./dist/plugin-sdk/acp-runtime.js"
},
- "./plugin-sdk/telegram": {
- "types": "./dist/plugin-sdk/telegram.d.ts",
- "default": "./dist/plugin-sdk/telegram.js"
- },
- "./plugin-sdk/telegram-core": {
- "types": "./dist/plugin-sdk/telegram-core.d.ts",
- "default": "./dist/plugin-sdk/telegram-core.js"
- },
- "./plugin-sdk/discord": {
- "types": "./dist/plugin-sdk/discord.d.ts",
- "default": "./dist/plugin-sdk/discord.js"
- },
- "./plugin-sdk/discord-core": {
- "types": "./dist/plugin-sdk/discord-core.d.ts",
- "default": "./dist/plugin-sdk/discord-core.js"
- },
- "./plugin-sdk/feishu": {
- "types": "./dist/plugin-sdk/feishu.d.ts",
- "default": "./dist/plugin-sdk/feishu.js"
- },
- "./plugin-sdk/googlechat": {
- "types": "./dist/plugin-sdk/googlechat.d.ts",
- "default": "./dist/plugin-sdk/googlechat.js"
- },
- "./plugin-sdk/irc": {
- "types": "./dist/plugin-sdk/irc.d.ts",
- "default": "./dist/plugin-sdk/irc.js"
- },
- "./plugin-sdk/line": {
- "types": "./dist/plugin-sdk/line.d.ts",
- "default": "./dist/plugin-sdk/line.js"
- },
- "./plugin-sdk/line-core": {
- "types": "./dist/plugin-sdk/line-core.d.ts",
- "default": "./dist/plugin-sdk/line-core.js"
- },
- "./plugin-sdk/matrix": {
- "types": "./dist/plugin-sdk/matrix.d.ts",
- "default": "./dist/plugin-sdk/matrix.js"
- },
- "./plugin-sdk/mattermost": {
- "types": "./dist/plugin-sdk/mattermost.d.ts",
- "default": "./dist/plugin-sdk/mattermost.js"
- },
- "./plugin-sdk/msteams": {
- "types": "./dist/plugin-sdk/msteams.d.ts",
- "default": "./dist/plugin-sdk/msteams.js"
- },
- "./plugin-sdk/nextcloud-talk": {
- "types": "./dist/plugin-sdk/nextcloud-talk.d.ts",
- "default": "./dist/plugin-sdk/nextcloud-talk.js"
- },
- "./plugin-sdk/nostr": {
- "types": "./dist/plugin-sdk/nostr.d.ts",
- "default": "./dist/plugin-sdk/nostr.js"
- },
- "./plugin-sdk/signal": {
- "types": "./dist/plugin-sdk/signal.d.ts",
- "default": "./dist/plugin-sdk/signal.js"
- },
- "./plugin-sdk/slack": {
- "types": "./dist/plugin-sdk/slack.d.ts",
- "default": "./dist/plugin-sdk/slack.js"
- },
- "./plugin-sdk/slack-core": {
- "types": "./dist/plugin-sdk/slack-core.d.ts",
- "default": "./dist/plugin-sdk/slack-core.js"
- },
- "./plugin-sdk/tlon": {
- "types": "./dist/plugin-sdk/tlon.d.ts",
- "default": "./dist/plugin-sdk/tlon.js"
- },
- "./plugin-sdk/twitch": {
- "types": "./dist/plugin-sdk/twitch.d.ts",
- "default": "./dist/plugin-sdk/twitch.js"
- },
- "./plugin-sdk/voice-call": {
- "types": "./dist/plugin-sdk/voice-call.d.ts",
- "default": "./dist/plugin-sdk/voice-call.js"
- },
- "./plugin-sdk/zalo": {
- "types": "./dist/plugin-sdk/zalo.d.ts",
- "default": "./dist/plugin-sdk/zalo.js"
- },
- "./plugin-sdk/zalouser": {
- "types": "./dist/plugin-sdk/zalouser.d.ts",
- "default": "./dist/plugin-sdk/zalouser.js"
- },
- "./plugin-sdk/imessage": {
- "types": "./dist/plugin-sdk/imessage.d.ts",
- "default": "./dist/plugin-sdk/imessage.js"
- },
- "./plugin-sdk/imessage-core": {
- "types": "./dist/plugin-sdk/imessage-core.d.ts",
- "default": "./dist/plugin-sdk/imessage-core.js"
- },
- "./plugin-sdk/whatsapp": {
- "types": "./dist/plugin-sdk/whatsapp.d.ts",
- "default": "./dist/plugin-sdk/whatsapp.js"
- },
- "./plugin-sdk/whatsapp-shared": {
- "types": "./dist/plugin-sdk/whatsapp-shared.d.ts",
- "default": "./dist/plugin-sdk/whatsapp-shared.js"
- },
- "./plugin-sdk/whatsapp-action-runtime": {
- "types": "./dist/plugin-sdk/whatsapp-action-runtime.d.ts",
- "default": "./dist/plugin-sdk/whatsapp-action-runtime.js"
- },
- "./plugin-sdk/whatsapp-login-qr": {
- "types": "./dist/plugin-sdk/whatsapp-login-qr.d.ts",
- "default": "./dist/plugin-sdk/whatsapp-login-qr.js"
- },
- "./plugin-sdk/whatsapp-core": {
- "types": "./dist/plugin-sdk/whatsapp-core.d.ts",
- "default": "./dist/plugin-sdk/whatsapp-core.js"
- },
- "./plugin-sdk/bluebubbles": {
- "types": "./dist/plugin-sdk/bluebubbles.d.ts",
- "default": "./dist/plugin-sdk/bluebubbles.js"
- },
"./plugin-sdk/lazy-runtime": {
"types": "./dist/plugin-sdk/lazy-runtime.d.ts",
"default": "./dist/plugin-sdk/lazy-runtime.js"
@@ -321,10 +217,6 @@
"types": "./dist/plugin-sdk/allow-from.d.ts",
"default": "./dist/plugin-sdk/allow-from.js"
},
- "./plugin-sdk/allowlist-resolution": {
- "types": "./dist/plugin-sdk/allowlist-resolution.d.ts",
- "default": "./dist/plugin-sdk/allowlist-resolution.js"
- },
"./plugin-sdk/allowlist-config-edit": {
"types": "./dist/plugin-sdk/allowlist-config-edit.d.ts",
"default": "./dist/plugin-sdk/allowlist-config-edit.js"
@@ -333,6 +225,10 @@
"types": "./dist/plugin-sdk/boolean-param.d.ts",
"default": "./dist/plugin-sdk/boolean-param.js"
},
+ "./plugin-sdk/command-auth": {
+ "types": "./dist/plugin-sdk/command-auth.d.ts",
+ "default": "./dist/plugin-sdk/command-auth.js"
+ },
"./plugin-sdk/device-bootstrap": {
"types": "./dist/plugin-sdk/device-bootstrap.d.ts",
"default": "./dist/plugin-sdk/device-bootstrap.js"
@@ -357,6 +253,22 @@
"types": "./dist/plugin-sdk/channel-config-schema.d.ts",
"default": "./dist/plugin-sdk/channel-config-schema.js"
},
+ "./plugin-sdk/channel-actions": {
+ "types": "./dist/plugin-sdk/channel-actions.d.ts",
+ "default": "./dist/plugin-sdk/channel-actions.js"
+ },
+ "./plugin-sdk/channel-contract": {
+ "types": "./dist/plugin-sdk/channel-contract.d.ts",
+ "default": "./dist/plugin-sdk/channel-contract.js"
+ },
+ "./plugin-sdk/channel-feedback": {
+ "types": "./dist/plugin-sdk/channel-feedback.d.ts",
+ "default": "./dist/plugin-sdk/channel-feedback.js"
+ },
+ "./plugin-sdk/channel-inbound": {
+ "types": "./dist/plugin-sdk/channel-inbound.d.ts",
+ "default": "./dist/plugin-sdk/channel-inbound.js"
+ },
"./plugin-sdk/channel-lifecycle": {
"types": "./dist/plugin-sdk/channel-lifecycle.d.ts",
"default": "./dist/plugin-sdk/channel-lifecycle.js"
@@ -373,6 +285,10 @@
"types": "./dist/plugin-sdk/channel-send-result.d.ts",
"default": "./dist/plugin-sdk/channel-send-result.js"
},
+ "./plugin-sdk/channel-targets": {
+ "types": "./dist/plugin-sdk/channel-targets.d.ts",
+ "default": "./dist/plugin-sdk/channel-targets.js"
+ },
"./plugin-sdk/group-access": {
"types": "./dist/plugin-sdk/group-access.d.ts",
"default": "./dist/plugin-sdk/group-access.js"
@@ -401,10 +317,6 @@
"types": "./dist/plugin-sdk/provider-auth.d.ts",
"default": "./dist/plugin-sdk/provider-auth.js"
},
- "./plugin-sdk/provider-oauth": {
- "types": "./dist/plugin-sdk/provider-oauth.d.ts",
- "default": "./dist/plugin-sdk/provider-oauth.js"
- },
"./plugin-sdk/provider-auth-api-key": {
"types": "./dist/plugin-sdk/provider-auth-api-key.d.ts",
"default": "./dist/plugin-sdk/provider-auth-api-key.js"
@@ -465,14 +377,6 @@
"types": "./dist/plugin-sdk/media-understanding.d.ts",
"default": "./dist/plugin-sdk/media-understanding.js"
},
- "./plugin-sdk/secret-input-runtime": {
- "types": "./dist/plugin-sdk/secret-input-runtime.d.ts",
- "default": "./dist/plugin-sdk/secret-input-runtime.js"
- },
- "./plugin-sdk/secret-input-schema": {
- "types": "./dist/plugin-sdk/secret-input-schema.d.ts",
- "default": "./dist/plugin-sdk/secret-input-schema.js"
- },
"./plugin-sdk/request-url": {
"types": "./dist/plugin-sdk/request-url.d.ts",
"default": "./dist/plugin-sdk/request-url.js"
@@ -489,6 +393,10 @@
"types": "./dist/plugin-sdk/runtime-store.d.ts",
"default": "./dist/plugin-sdk/runtime-store.js"
},
+ "./plugin-sdk/status-helpers": {
+ "types": "./dist/plugin-sdk/status-helpers.d.ts",
+ "default": "./dist/plugin-sdk/status-helpers.js"
+ },
"./plugin-sdk/secret-input": {
"types": "./dist/plugin-sdk/secret-input.d.ts",
"default": "./dist/plugin-sdk/secret-input.js"
@@ -501,6 +409,14 @@
"types": "./dist/plugin-sdk/web-media.d.ts",
"default": "./dist/plugin-sdk/web-media.js"
},
+ "./plugin-sdk/zalo": {
+ "types": "./dist/plugin-sdk/zalo.d.ts",
+ "default": "./dist/plugin-sdk/zalo.js"
+ },
+ "./plugin-sdk/zalouser": {
+ "types": "./dist/plugin-sdk/zalouser.d.ts",
+ "default": "./dist/plugin-sdk/zalouser.js"
+ },
"./plugin-sdk/speech": {
"types": "./dist/plugin-sdk/speech.d.ts",
"default": "./dist/plugin-sdk/speech.js"
@@ -513,6 +429,7 @@
"types": "./dist/plugin-sdk/tool-send.d.ts",
"default": "./dist/plugin-sdk/tool-send.js"
},
+ "./extension-api": "./dist/extensionAPI.js",
"./cli-entry": "./openclaw.mjs"
},
"scripts": {
@@ -676,6 +593,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "0.16.1",
+ "@anthropic-ai/vertex-sdk": "^0.14.4",
"@aws-sdk/client-bedrock": "^3.1011.0",
"@clack/prompts": "^1.1.0",
"@homebridge/ciao": "^1.3.5",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f821a4aa3c4..7f438d0a2e3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -29,6 +29,9 @@ importers:
'@agentclientprotocol/sdk':
specifier: 0.16.1
version: 0.16.1(zod@4.3.6)
+ '@anthropic-ai/vertex-sdk':
+ specifier: ^0.14.4
+ version: 0.14.4(zod@4.3.6)
'@aws-sdk/client-bedrock':
specifier: ^3.1011.0
version: 3.1011.0
@@ -688,6 +691,9 @@ packages:
zod:
optional: true
+ '@anthropic-ai/vertex-sdk@0.14.4':
+ resolution: {integrity: sha512-BZUPRWghZxfSFtAxU563wH+jfWBPoedAwsVxG35FhmNsjeV8tyfN+lFriWhCpcZApxA4NdT6Soov+PzfnxxD5g==}
+
'@asamuzakjp/css-color@5.0.1':
resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
@@ -1480,10 +1486,6 @@ packages:
cpu: [x64]
os: [win32]
- '@isaacs/cliui@8.0.2':
- resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
- engines: {node: '>=12'}
-
'@isaacs/fs-minipass@4.0.1':
resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==}
engines: {node: '>=18.0.0'}
@@ -2619,10 +2621,6 @@ packages:
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
- '@pkgjs/parseargs@0.11.0':
- resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
- engines: {node: '>=14'}
-
'@polka/url@1.0.0-next.29':
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
@@ -4125,9 +4123,6 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
- eastasianwidth@0.2.0:
- resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
-
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
@@ -4140,9 +4135,6 @@ packages:
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
- emoji-regex@9.2.2:
- resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
-
empathic@2.0.0:
resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==}
engines: {node: '>=14'}
@@ -4359,10 +4351,6 @@ packages:
debug:
optional: true
- foreground-child@3.3.1:
- resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
- engines: {node: '>=14'}
-
form-data@2.5.4:
resolution: {integrity: sha512-Y/3MmRiR8Nd+0CUtrbvcKtKzLWiUfpQ7DFVggH8PwmGt/0r7RSy32GuP4hpCJlQNEBusisSx1DLtD8uD386HJQ==}
engines: {node: '>= 0.12'}
@@ -4409,14 +4397,18 @@ packages:
engines: {node: '>=10'}
deprecated: This package is no longer supported.
- gaxios@7.1.3:
- resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==}
- engines: {node: '>=18'}
+ gaxios@6.7.1:
+ resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==}
+ engines: {node: '>=14'}
gaxios@7.1.4:
resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==}
engines: {node: '>=18'}
+ gcp-metadata@6.1.1:
+ resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==}
+ engines: {node: '>=14'}
+
gcp-metadata@8.1.2:
resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==}
engines: {node: '>=18'}
@@ -4459,11 +4451,6 @@ packages:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
- glob@10.5.0:
- resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
- deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
- hasBin: true
-
glob@13.0.6:
resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==}
engines: {node: 18 || 20 || >=22}
@@ -4472,14 +4459,18 @@ packages:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
- google-auth-library@10.6.1:
- resolution: {integrity: sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA==}
- engines: {node: '>=18'}
-
google-auth-library@10.6.2:
resolution: {integrity: sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==}
engines: {node: '>=18'}
+ google-auth-library@9.15.1:
+ resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==}
+ engines: {node: '>=14'}
+
+ google-logging-utils@0.0.2:
+ resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==}
+ engines: {node: '>=14'}
+
google-logging-utils@1.1.3:
resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==}
engines: {node: '>=14'}
@@ -4495,6 +4486,10 @@ packages:
resolution: {integrity: sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ==}
engines: {node: ^12.20.0 || >=14.13.1}
+ gtoken@7.1.0:
+ resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==}
+ engines: {node: '>=14.0.0'}
+
has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'}
@@ -4721,9 +4716,6 @@ packages:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
- jackspeak@3.4.3:
- resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==}
-
jimp@1.6.0:
resolution: {integrity: sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg==}
engines: {node: '>=18'}
@@ -4993,9 +4985,6 @@ packages:
resolution: {integrity: sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==}
engines: {node: '>=18'}
- lru-cache@10.4.3:
- resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
-
lru-cache@11.2.7:
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
engines: {node: 20 || >=22}
@@ -5423,9 +5412,6 @@ packages:
resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==}
engines: {node: '>= 14'}
- package-json-from-dist@1.0.1:
- resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
-
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
@@ -5483,10 +5469,6 @@ packages:
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
- path-scurry@1.11.1:
- resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==}
- engines: {node: '>=16 || 14 >=14.18'}
-
path-scurry@2.0.2:
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
engines: {node: 18 || 20 || >=22}
@@ -5794,10 +5776,6 @@ packages:
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
- rimraf@5.0.10:
- resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==}
- hasBin: true
-
rolldown-plugin-dts@0.22.5:
resolution: {integrity: sha512-M/HXfM4cboo+jONx9Z0X+CUf3B5tCi7ni+kR5fUW50Fp9AlZk0oVLesibGWgCXDKFp5lpgQ9yhKoImUFjl3VZw==}
engines: {node: '>=20.19.0'}
@@ -6089,10 +6067,6 @@ packages:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
- string-width@5.1.2:
- resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
- engines: {node: '>=12'}
-
string-width@7.2.0:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'}
@@ -6402,6 +6376,10 @@ packages:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
+ uuid@9.0.1:
+ resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
+ hasBin: true
+
validate-npm-package-name@7.0.2:
resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==}
engines: {node: ^20.17.0 || >=22.9.0}
@@ -6557,10 +6535,6 @@ packages:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
- wrap-ansi@8.1.0:
- resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
- engines: {node: '>=12'}
-
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
@@ -6668,6 +6642,15 @@ snapshots:
optionalDependencies:
zod: 4.3.6
+ '@anthropic-ai/vertex-sdk@0.14.4(zod@4.3.6)':
+ dependencies:
+ '@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
+ google-auth-library: 9.15.1
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+ - zod
+
'@asamuzakjp/css-color@5.0.1':
dependencies:
'@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
@@ -7804,7 +7787,7 @@ snapshots:
'@google/genai@1.44.0(@modelcontextprotocol/sdk@1.27.1(zod@4.3.6))':
dependencies:
- google-auth-library: 10.6.1
+ google-auth-library: 10.6.2
p-retry: 4.6.2
protobufjs: 7.5.4
ws: 8.19.0
@@ -7969,15 +7952,6 @@ snapshots:
'@img/sharp-win32-x64@0.34.5':
optional: true
- '@isaacs/cliui@8.0.2':
- dependencies:
- string-width: 5.1.2
- string-width-cjs: string-width@4.2.3
- strip-ansi: 7.2.0
- strip-ansi-cjs: strip-ansi@6.0.1
- wrap-ansi: 8.1.0
- wrap-ansi-cjs: wrap-ansi@7.0.0
-
'@isaacs/fs-minipass@4.0.1':
dependencies:
minipass: 7.1.3
@@ -9320,9 +9294,6 @@ snapshots:
'@pinojs/redact@0.4.0': {}
- '@pkgjs/parseargs@0.11.0':
- optional: true
-
'@polka/url@1.0.0-next.29': {}
'@protobufjs/aspromise@1.1.2': {}
@@ -11012,8 +10983,6 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
- eastasianwidth@0.2.0: {}
-
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
@@ -11024,8 +10993,6 @@ snapshots:
emoji-regex@8.0.0: {}
- emoji-regex@9.2.2: {}
-
empathic@2.0.0: {}
encodeurl@2.0.0: {}
@@ -11278,11 +11245,6 @@ snapshots:
follow-redirects@1.15.11: {}
- foreground-child@3.3.1:
- dependencies:
- cross-spawn: 7.0.6
- signal-exit: 4.1.0
-
form-data@2.5.4:
dependencies:
asynckit: 0.4.0
@@ -11336,13 +11298,15 @@ snapshots:
wide-align: 1.1.5
optional: true
- gaxios@7.1.3:
+ gaxios@6.7.1:
dependencies:
extend: 3.0.2
https-proxy-agent: 7.0.6
- node-fetch: 3.3.2
- rimraf: 5.0.10
+ is-stream: 2.0.1
+ node-fetch: 2.7.0
+ uuid: 9.0.1
transitivePeerDependencies:
+ - encoding
- supports-color
gaxios@7.1.4:
@@ -11353,6 +11317,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ gcp-metadata@6.1.1:
+ dependencies:
+ gaxios: 6.7.1
+ google-logging-utils: 0.0.2
+ json-bigint: 1.0.0
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+
gcp-metadata@8.1.2:
dependencies:
gaxios: 7.1.4
@@ -11411,15 +11384,6 @@ snapshots:
dependencies:
is-glob: 4.0.3
- glob@10.5.0:
- dependencies:
- foreground-child: 3.3.1
- jackspeak: 3.4.3
- minimatch: 10.2.4
- minipass: 7.1.3
- package-json-from-dist: 1.0.1
- path-scurry: 1.11.1
-
glob@13.0.6:
dependencies:
minimatch: 10.2.4
@@ -11436,17 +11400,6 @@ snapshots:
path-is-absolute: 1.0.1
optional: true
- google-auth-library@10.6.1:
- dependencies:
- base64-js: 1.5.1
- ecdsa-sig-formatter: 1.0.11
- gaxios: 7.1.3
- gcp-metadata: 8.1.2
- google-logging-utils: 1.1.3
- jws: 4.0.1
- transitivePeerDependencies:
- - supports-color
-
google-auth-library@10.6.2:
dependencies:
base64-js: 1.5.1
@@ -11458,6 +11411,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ google-auth-library@9.15.1:
+ dependencies:
+ base64-js: 1.5.1
+ ecdsa-sig-formatter: 1.0.11
+ gaxios: 6.7.1
+ gcp-metadata: 6.1.1
+ gtoken: 7.1.0
+ jws: 4.0.1
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+
+ google-logging-utils@0.0.2: {}
+
google-logging-utils@1.1.3: {}
gopd@1.2.0: {}
@@ -11474,6 +11441,14 @@ snapshots:
- encoding
- supports-color
+ gtoken@7.1.0:
+ dependencies:
+ gaxios: 6.7.1
+ jws: 4.0.1
+ transitivePeerDependencies:
+ - encoding
+ - supports-color
+
has-flag@4.0.0: {}
has-own@1.0.1: {}
@@ -11725,12 +11700,6 @@ snapshots:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
- jackspeak@3.4.3:
- dependencies:
- '@isaacs/cliui': 8.0.2
- optionalDependencies:
- '@pkgjs/parseargs': 0.11.0
-
jimp@1.6.0:
dependencies:
'@jimp/core': 1.6.0
@@ -12037,8 +12006,6 @@ snapshots:
dependencies:
steno: 4.0.2
- lru-cache@10.4.3: {}
-
lru-cache@11.2.7: {}
lru-cache@6.0.0:
@@ -12634,8 +12601,6 @@ snapshots:
degenerator: 5.0.1
netmask: 2.0.2
- package-json-from-dist@1.0.1: {}
-
pako@1.0.11: {}
pako@2.1.0: {}
@@ -12681,11 +12646,6 @@ snapshots:
path-parse@1.0.7: {}
- path-scurry@1.11.1:
- dependencies:
- lru-cache: 10.4.3
- minipass: 7.1.3
-
path-scurry@2.0.2:
dependencies:
lru-cache: 11.2.7
@@ -13036,10 +12996,6 @@ snapshots:
glob: 7.2.3
optional: true
- rimraf@5.0.10:
- dependencies:
- glob: 10.5.0
-
rolldown-plugin-dts@0.22.5(@typescript/native-preview@7.0.0-dev.20260317.1)(rolldown@1.0.0-rc.9)(typescript@5.9.3):
dependencies:
'@babel/generator': 8.0.0-rc.2
@@ -13394,12 +13350,6 @@ snapshots:
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
- string-width@5.1.2:
- dependencies:
- eastasianwidth: 0.2.0
- emoji-regex: 9.2.2
- strip-ansi: 7.2.0
-
string-width@7.2.0:
dependencies:
emoji-regex: 10.6.0
@@ -13687,6 +13637,8 @@ snapshots:
uuid@8.3.2: {}
+ uuid@9.0.1: {}
+
validate-npm-package-name@7.0.2: {}
vary@1.1.2: {}
@@ -13809,12 +13761,6 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
- wrap-ansi@8.1.0:
- dependencies:
- ansi-styles: 6.2.3
- string-width: 5.1.2
- strip-ansi: 7.2.0
-
wrappy@1.0.2: {}
ws@8.19.0: {}
diff --git a/scripts/check-plugin-extension-import-boundary.mjs b/scripts/check-plugin-extension-import-boundary.mjs
index bbe9f9702f5..ac9c5e178a4 100644
--- a/scripts/check-plugin-extension-import-boundary.mjs
+++ b/scripts/check-plugin-extension-import-boundary.mjs
@@ -195,6 +195,7 @@ function scanWebSearchRegistrySmells(sourceFile, filePath) {
function shouldSkipFile(filePath) {
const relativeFile = normalizePath(filePath);
return (
+ relativeFile === "src/plugins/bundled-web-search-registry.ts" ||
relativeFile.startsWith("src/plugins/contracts/") ||
/^src\/plugins\/runtime\/runtime-[^/]+-contract\.[cm]?[jt]s$/u.test(relativeFile)
);
diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json
index 1dc306bd9b7..656dd6a72bb 100644
--- a/scripts/lib/plugin-sdk-entrypoints.json
+++ b/scripts/lib/plugin-sdk-entrypoints.json
@@ -20,9 +20,13 @@
"channel-runtime",
"interactive-runtime",
"infra-runtime",
+ "ssrf-runtime",
"media-runtime",
"media-understanding-runtime",
"conversation-runtime",
+ "matrix-runtime-heavy",
+ "matrix-runtime-shared",
+ "thread-bindings-runtime",
"text-runtime",
"agent-runtime",
"speech-runtime",
@@ -34,55 +38,30 @@
"process-runtime",
"windows-spawn",
"acp-runtime",
- "telegram",
- "telegram-core",
- "discord",
- "discord-core",
- "feishu",
- "googlechat",
- "irc",
- "line",
- "line-core",
- "matrix",
- "mattermost",
- "msteams",
- "nextcloud-talk",
- "nostr",
- "signal",
- "slack",
- "slack-core",
- "tlon",
- "twitch",
- "voice-call",
- "zalo",
- "zalouser",
- "imessage",
- "imessage-core",
- "whatsapp",
- "whatsapp-shared",
- "whatsapp-action-runtime",
- "whatsapp-login-qr",
- "whatsapp-core",
- "bluebubbles",
"lazy-runtime",
"testing",
"account-helpers",
"account-id",
"account-resolution",
"allow-from",
- "allowlist-resolution",
"allowlist-config-edit",
"boolean-param",
+ "command-auth",
"device-bootstrap",
"diagnostics-otel",
"diffs",
"extension-shared",
"channel-config-helpers",
"channel-config-schema",
+ "channel-actions",
+ "channel-contract",
+ "channel-feedback",
+ "channel-inbound",
"channel-lifecycle",
"channel-pairing",
"channel-policy",
"channel-send-result",
+ "channel-targets",
"group-access",
"directory-runtime",
"json-store",
@@ -90,7 +69,6 @@
"llm-task",
"memory-lancedb",
"provider-auth",
- "provider-oauth",
"provider-auth-api-key",
"provider-auth-login",
"plugin-entry",
@@ -106,15 +84,16 @@
"image-generation",
"reply-history",
"media-understanding",
- "secret-input-runtime",
- "secret-input-schema",
"request-url",
"webhook-ingress",
"webhook-path",
"runtime-store",
+ "status-helpers",
"secret-input",
"thread-ownership",
"web-media",
+ "zalo",
+ "zalouser",
"speech",
"state-paths",
"tool-send"
diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs
index 5bbd4c94ac6..41a4d285d05 100644
--- a/scripts/test-parallel.mjs
+++ b/scripts/test-parallel.mjs
@@ -297,7 +297,7 @@ const defaultHeavyUnitFileLimit =
: isMacMiniProfile
? 90
: testProfile === "low"
- ? 20
+ ? 36
: highMemLocalHost
? 80
: 60;
@@ -307,7 +307,7 @@ const defaultHeavyUnitLaneCount =
: isMacMiniProfile
? 6
: testProfile === "low"
- ? 2
+ ? 4
: highMemLocalHost
? 5
: 4;
@@ -365,11 +365,13 @@ const defaultSingletonBatchLaneCount =
? 0
: isCI
? Math.ceil(unitSingletonBatchFiles.length / 6)
- : highMemLocalHost
- ? Math.ceil(unitSingletonBatchFiles.length / 8)
- : lowMemLocalHost
- ? Math.ceil(unitSingletonBatchFiles.length / 12)
- : Math.ceil(unitSingletonBatchFiles.length / 10);
+ : testProfile === "low" && highMemLocalHost
+ ? Math.ceil(unitSingletonBatchFiles.length / 8) + 1
+ : highMemLocalHost
+ ? Math.ceil(unitSingletonBatchFiles.length / 8)
+ : lowMemLocalHost
+ ? Math.ceil(unitSingletonBatchFiles.length / 12)
+ : Math.ceil(unitSingletonBatchFiles.length / 10);
const singletonBatchLaneCount =
unitSingletonBatchFiles.length === 0
? 0
@@ -437,6 +439,22 @@ const unitSingletonEntries = unitSingletonBuckets.map((files, index) => ({
unitSingletonBuckets.length === 1 ? "unit-singleton" : `unit-singleton-${String(index + 1)}`,
args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=forks", ...files],
}));
+const unitThreadEntries =
+ unitThreadSingletonFiles.length > 0
+ ? [
+ {
+ name: "unit-threads",
+ args: [
+ "vitest",
+ "run",
+ "--config",
+ "vitest.unit.config.ts",
+ "--pool=threads",
+ ...unitThreadSingletonFiles,
+ ],
+ },
+ ]
+ : [];
const baseRuns = [
...(shouldSplitUnitRuns
? [
@@ -469,10 +487,7 @@ const baseRuns = [
file,
],
})),
- ...unitThreadSingletonFiles.map((file) => ({
- name: `${path.basename(file, ".test.ts")}-threads`,
- args: ["vitest", "run", "--config", "vitest.unit.config.ts", "--pool=threads", file],
- })),
+ ...unitThreadEntries,
...unitVmForkSingletonFiles.map((file) => ({
name: `${path.basename(file, ".test.ts")}-vmforks`,
args: [
@@ -695,7 +710,9 @@ const defaultTopLevelParallelLimit =
testProfile === "serial"
? 1
: testProfile === "low"
- ? 2
+ ? lowMemLocalHost
+ ? 2
+ : 3
: testProfile === "max"
? 5
: highMemLocalHost
@@ -1287,9 +1304,16 @@ if (serialPrefixRuns.length > 0) {
if (failedSerialPrefix !== undefined) {
process.exit(failedSerialPrefix);
}
+ const deferredRunConcurrency = isMacMiniProfile ? 3 : testProfile === "low" ? 2 : undefined;
const failedDeferredParallel = isMacMiniProfile
- ? await runEntriesWithLimit(deferredParallelRuns, passthroughOptionArgs, 3)
- : await runEntries(deferredParallelRuns, passthroughOptionArgs);
+ ? await runEntriesWithLimit(deferredParallelRuns, passthroughOptionArgs, deferredRunConcurrency)
+ : deferredRunConcurrency
+ ? await runEntriesWithLimit(
+ deferredParallelRuns,
+ passthroughOptionArgs,
+ deferredRunConcurrency,
+ )
+ : await runEntries(deferredParallelRuns, passthroughOptionArgs);
if (failedDeferredParallel !== undefined) {
process.exit(failedDeferredParallel);
}
diff --git a/scripts/test-runner-manifest.mjs b/scripts/test-runner-manifest.mjs
index ee5644f3328..ce34d28c59b 100644
--- a/scripts/test-runner-manifest.mjs
+++ b/scripts/test-runner-manifest.mjs
@@ -25,14 +25,25 @@ const readJson = (filePath, fallback) => {
};
const normalizeRepoPath = (value) => value.split(path.sep).join("/");
+const repoRoot = path.resolve(process.cwd());
+const normalizeTrackedRepoPath = (value) => {
+ const normalizedValue = typeof value === "string" ? value : String(value ?? "");
+ const repoRelative = path.isAbsolute(normalizedValue)
+ ? path.relative(repoRoot, path.resolve(normalizedValue))
+ : normalizedValue;
+ if (path.isAbsolute(repoRelative) || repoRelative.startsWith("..") || repoRelative === "") {
+ return normalizeRepoPath(normalizedValue);
+ }
+ return normalizeRepoPath(repoRelative);
+};
const normalizeManifestEntries = (entries) =>
entries
.map((entry) =>
typeof entry === "string"
- ? { file: normalizeRepoPath(entry), reason: "" }
+ ? { file: normalizeTrackedRepoPath(entry), reason: "" }
: {
- file: normalizeRepoPath(String(entry?.file ?? "")),
+ file: normalizeTrackedRepoPath(String(entry?.file ?? "")),
reason: typeof entry?.reason === "string" ? entry.reason : "",
},
)
@@ -60,7 +71,7 @@ export function loadUnitTimingManifest() {
const files = Object.fromEntries(
Object.entries(raw.files ?? {})
.map(([file, value]) => {
- const normalizedFile = normalizeRepoPath(file);
+ const normalizedFile = normalizeTrackedRepoPath(file);
const durationMs =
Number.isFinite(value?.durationMs) && value.durationMs >= 0 ? value.durationMs : null;
const testCount =
@@ -97,7 +108,7 @@ export function loadUnitMemoryHotspotManifest() {
const files = Object.fromEntries(
Object.entries(raw.files ?? {})
.map(([file, value]) => {
- const normalizedFile = normalizeRepoPath(file);
+ const normalizedFile = normalizeTrackedRepoPath(file);
const deltaKb =
Number.isFinite(value?.deltaKb) && value.deltaKb > 0 ? Math.round(value.deltaKb) : null;
const sources = Array.isArray(value?.sources)
diff --git a/scripts/test-update-memory-hotspots.mjs b/scripts/test-update-memory-hotspots.mjs
index 2abbf2b2d02..af4cb7c624c 100644
--- a/scripts/test-update-memory-hotspots.mjs
+++ b/scripts/test-update-memory-hotspots.mjs
@@ -57,10 +57,24 @@ function parseArgs(argv) {
return args;
}
+const normalizeRepoPath = (value) => value.split(path.sep).join("/");
+const repoRoot = path.resolve(process.cwd());
+const normalizeTrackedRepoPath = (value) => {
+ const normalizedValue = typeof value === "string" ? value : String(value ?? "");
+ const repoRelative = path.isAbsolute(normalizedValue)
+ ? path.relative(repoRoot, path.resolve(normalizedValue))
+ : normalizedValue;
+ if (path.isAbsolute(repoRelative) || repoRelative.startsWith("..") || repoRelative === "") {
+ return normalizeRepoPath(normalizedValue);
+ }
+ return normalizeRepoPath(repoRelative);
+};
+
function mergeHotspotEntry(aggregated, file, value) {
if (!(Number.isFinite(value?.deltaKb) && value.deltaKb > 0)) {
return;
}
+ const normalizedFile = normalizeTrackedRepoPath(file);
const normalizeSourceLabel = (source) => {
const separator = source.lastIndexOf(":");
if (separator === -1) {
@@ -75,9 +89,9 @@ function mergeHotspotEntry(aggregated, file, value) {
.filter((source) => typeof source === "string" && source.length > 0)
.map(normalizeSourceLabel)
: [];
- const previous = aggregated.get(file);
+ const previous = aggregated.get(normalizedFile);
if (!previous) {
- aggregated.set(file, {
+ aggregated.set(normalizedFile, {
deltaKb: Math.round(value.deltaKb),
sources: [...new Set(nextSources)],
});
diff --git a/scripts/test-update-timings.mjs b/scripts/test-update-timings.mjs
index 722d3539f7a..e450ff9cd31 100644
--- a/scripts/test-update-timings.mjs
+++ b/scripts/test-update-timings.mjs
@@ -9,7 +9,7 @@ function parseArgs(argv) {
config: "vitest.unit.config.ts",
out: unitTimingManifestPath,
reportPath: "",
- limit: 128,
+ limit: 256,
defaultDurationMs: 250,
};
for (let i = 0; i < argv.length; i += 1) {
@@ -50,6 +50,17 @@ function parseArgs(argv) {
}
const normalizeRepoPath = (value) => value.split(path.sep).join("/");
+const repoRoot = path.resolve(process.cwd());
+const normalizeTrackedRepoPath = (value) => {
+ const normalizedValue = typeof value === "string" ? value : String(value ?? "");
+ const repoRelative = path.isAbsolute(normalizedValue)
+ ? path.relative(repoRoot, path.resolve(normalizedValue))
+ : normalizedValue;
+ if (path.isAbsolute(repoRelative) || repoRelative.startsWith("..") || repoRelative === "") {
+ return normalizeRepoPath(normalizedValue);
+ }
+ return normalizeRepoPath(repoRelative);
+};
const opts = parseArgs(process.argv.slice(2));
const reportPath =
@@ -74,7 +85,7 @@ const report = JSON.parse(fs.readFileSync(reportPath, "utf8"));
const files = Object.fromEntries(
(report.testResults ?? [])
.map((result) => {
- const file = typeof result.name === "string" ? normalizeRepoPath(result.name) : "";
+ const file = typeof result.name === "string" ? normalizeTrackedRepoPath(result.name) : "";
const start = typeof result.startTime === "number" ? result.startTime : 0;
const end = typeof result.endTime === "number" ? result.endTime : 0;
const testCount = Array.isArray(result.assertionResults) ? result.assertionResults.length : 0;
diff --git a/src/acp/persistent-bindings.test.ts b/src/acp/persistent-bindings.test.ts
index b9fc0c9e9b3..2be5eabe372 100644
--- a/src/acp/persistent-bindings.test.ts
+++ b/src/acp/persistent-bindings.test.ts
@@ -1,11 +1,11 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
-import { discordPlugin } from "../../extensions/discord/src/channel.js";
-import { feishuPlugin } from "../../extensions/feishu/src/channel.js";
-import { telegramPlugin } from "../../extensions/telegram/src/channel.js";
+import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import { parseFeishuConversationId } from "../../extensions/feishu/src/conversation-id.js";
+import { parseTelegramTopicConversation } from "../../extensions/telegram/runtime-api.js";
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
+import type { ChannelConfiguredBindingProvider, ChannelPlugin } from "../channels/plugins/types.js";
import type { OpenClawConfig } from "../config/config.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
-import { createTestRegistry } from "../test-utils/channel-plugins.js";
+import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
import * as persistentBindingsResolveModule from "./persistent-bindings.resolve.js";
import { buildConfiguredAcpSessionKey } from "./persistent-bindings.types.js";
const managerMocks = vi.hoisted(() => ({
@@ -39,6 +39,10 @@ type PersistentBindingsModule = Pick<
"ensureConfiguredAcpBindingSession" | "resetAcpSessionInPlace"
>;
let persistentBindings: PersistentBindingsModule;
+let lifecycleBindingsModule: Pick<
+ typeof import("./persistent-bindings.lifecycle.js"),
+ "ensureConfiguredAcpBindingSession" | "resetAcpSessionInPlace"
+>;
type ConfiguredBinding = NonNullable[number];
type BindingRecordInput = Parameters<
@@ -58,6 +62,131 @@ const baseCfg = {
const defaultDiscordConversationId = "1478836151241412759";
const defaultDiscordAccountId = "default";
+const discordBindings: ChannelConfiguredBindingProvider = {
+ compileConfiguredBinding: ({ conversationId }) => {
+ const normalized = conversationId.trim();
+ return normalized ? { conversationId: normalized } : null;
+ },
+ matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => {
+ if (compiledBinding.conversationId === conversationId) {
+ return { conversationId, matchPriority: 2 };
+ }
+ if (
+ parentConversationId &&
+ parentConversationId !== conversationId &&
+ compiledBinding.conversationId === parentConversationId
+ ) {
+ return { conversationId: parentConversationId, matchPriority: 1 };
+ }
+ return null;
+ },
+};
+
+const telegramBindings: ChannelConfiguredBindingProvider = {
+ compileConfiguredBinding: ({ conversationId }) => {
+ const parsed = parseTelegramTopicConversation({ conversationId });
+ if (!parsed || !parsed.chatId.startsWith("-")) {
+ return null;
+ }
+ return {
+ conversationId: parsed.canonicalConversationId,
+ parentConversationId: parsed.chatId,
+ };
+ },
+ matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => {
+ const incoming = parseTelegramTopicConversation({
+ conversationId,
+ parentConversationId,
+ });
+ if (!incoming || !incoming.chatId.startsWith("-")) {
+ return null;
+ }
+ if (compiledBinding.conversationId !== incoming.canonicalConversationId) {
+ return null;
+ }
+ return {
+ conversationId: incoming.canonicalConversationId,
+ parentConversationId: incoming.chatId,
+ matchPriority: 2,
+ };
+ },
+};
+
+function isSupportedFeishuDirectConversationId(conversationId: string): boolean {
+ const trimmed = conversationId.trim();
+ if (!trimmed || trimmed.includes(":")) {
+ return false;
+ }
+ if (trimmed.startsWith("oc_") || trimmed.startsWith("on_")) {
+ return false;
+ }
+ return true;
+}
+
+const feishuBindings: ChannelConfiguredBindingProvider = {
+ compileConfiguredBinding: ({ conversationId }) => {
+ const parsed = parseFeishuConversationId({ conversationId });
+ if (
+ !parsed ||
+ (parsed.scope !== "group_topic" &&
+ parsed.scope !== "group_topic_sender" &&
+ !isSupportedFeishuDirectConversationId(parsed.canonicalConversationId))
+ ) {
+ return null;
+ }
+ return {
+ conversationId: parsed.canonicalConversationId,
+ parentConversationId:
+ parsed.scope === "group_topic" || parsed.scope === "group_topic_sender"
+ ? parsed.chatId
+ : undefined,
+ };
+ },
+ matchInboundConversation: ({ compiledBinding, conversationId, parentConversationId }) => {
+ const incoming = parseFeishuConversationId({
+ conversationId,
+ parentConversationId,
+ });
+ if (
+ !incoming ||
+ (incoming.scope !== "group_topic" &&
+ incoming.scope !== "group_topic_sender" &&
+ !isSupportedFeishuDirectConversationId(incoming.canonicalConversationId))
+ ) {
+ return null;
+ }
+ const matchesCanonicalConversation =
+ compiledBinding.conversationId === incoming.canonicalConversationId;
+ const matchesParentTopicForSenderScopedConversation =
+ incoming.scope === "group_topic_sender" &&
+ compiledBinding.parentConversationId === incoming.chatId &&
+ compiledBinding.conversationId === `${incoming.chatId}:topic:${incoming.topicId}`;
+ if (!matchesCanonicalConversation && !matchesParentTopicForSenderScopedConversation) {
+ return null;
+ }
+ return {
+ conversationId: matchesParentTopicForSenderScopedConversation
+ ? compiledBinding.conversationId
+ : incoming.canonicalConversationId,
+ parentConversationId:
+ incoming.scope === "group_topic" || incoming.scope === "group_topic_sender"
+ ? incoming.chatId
+ : undefined,
+ matchPriority: matchesCanonicalConversation ? 2 : 1,
+ };
+ },
+};
+
+function createConfiguredBindingTestPlugin(
+ id: ChannelPlugin["id"],
+ bindings: ChannelConfiguredBindingProvider,
+): Pick {
+ return {
+ ...createChannelTestPluginBase({ id }),
+ bindings,
+ };
+}
+
function createCfgWithBindings(
bindings: ConfiguredBinding[],
overrides?: Partial,
@@ -185,20 +314,26 @@ beforeEach(() => {
persistentBindingsResolveModule.resolveConfiguredAcpBindingRecord,
resolveConfiguredAcpBindingSpecBySessionKey:
persistentBindingsResolveModule.resolveConfiguredAcpBindingSpecBySessionKey,
- ensureConfiguredAcpBindingSession: async (...args) => {
- const lifecycleModule = await import("./persistent-bindings.lifecycle.js");
- return await lifecycleModule.ensureConfiguredAcpBindingSession(...args);
- },
- resetAcpSessionInPlace: async (...args) => {
- const lifecycleModule = await import("./persistent-bindings.lifecycle.js");
- return await lifecycleModule.resetAcpSessionInPlace(...args);
- },
+ ensureConfiguredAcpBindingSession: lifecycleBindingsModule.ensureConfiguredAcpBindingSession,
+ resetAcpSessionInPlace: lifecycleBindingsModule.resetAcpSessionInPlace,
};
setActivePluginRegistry(
createTestRegistry([
- { pluginId: "discord", plugin: discordPlugin, source: "test" },
- { pluginId: "telegram", plugin: telegramPlugin, source: "test" },
- { pluginId: "feishu", plugin: feishuPlugin, source: "test" },
+ {
+ pluginId: "discord",
+ plugin: createConfiguredBindingTestPlugin("discord", discordBindings),
+ source: "test",
+ },
+ {
+ pluginId: "telegram",
+ plugin: createConfiguredBindingTestPlugin("telegram", telegramBindings),
+ source: "test",
+ },
+ {
+ pluginId: "feishu",
+ plugin: createConfiguredBindingTestPlugin("feishu", feishuBindings),
+ source: "test",
+ },
]),
);
managerMocks.resolveSession.mockReset();
@@ -211,6 +346,10 @@ beforeEach(() => {
sessionMetaMocks.readAcpSessionEntry.mockReset().mockReturnValue(undefined);
});
+beforeAll(async () => {
+ lifecycleBindingsModule = await import("./persistent-bindings.lifecycle.js");
+});
+
describe("resolveConfiguredAcpBindingRecord", () => {
it("resolves discord channel ACP binding from top-level typed bindings", () => {
const cfg = createCfgWithBindings([
diff --git a/src/agents/anthropic-vertex-provider.ts b/src/agents/anthropic-vertex-provider.ts
new file mode 100644
index 00000000000..17df481f1e5
--- /dev/null
+++ b/src/agents/anthropic-vertex-provider.ts
@@ -0,0 +1,124 @@
+import { existsSync, readFileSync } from "node:fs";
+import { homedir, platform } from "node:os";
+import { join } from "node:path";
+import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
+
+const ANTHROPIC_VERTEX_DEFAULT_REGION = "global";
+const ANTHROPIC_VERTEX_REGION_RE = /^[a-z0-9-]+$/;
+const GCLOUD_DEFAULT_ADC_PATH = join(
+ homedir(),
+ ".config",
+ "gcloud",
+ "application_default_credentials.json",
+);
+
+type AdcProjectFile = {
+ project_id?: unknown;
+ quota_project_id?: unknown;
+};
+
+export function resolveAnthropicVertexProjectId(
+ env: NodeJS.ProcessEnv = process.env,
+): string | undefined {
+ return (
+ normalizeOptionalSecretInput(env.ANTHROPIC_VERTEX_PROJECT_ID) ||
+ normalizeOptionalSecretInput(env.GOOGLE_CLOUD_PROJECT) ||
+ normalizeOptionalSecretInput(env.GOOGLE_CLOUD_PROJECT_ID) ||
+ resolveAnthropicVertexProjectIdFromAdc(env)
+ );
+}
+
+export function resolveAnthropicVertexRegion(env: NodeJS.ProcessEnv = process.env): string {
+ const region =
+ normalizeOptionalSecretInput(env.GOOGLE_CLOUD_LOCATION) ||
+ normalizeOptionalSecretInput(env.CLOUD_ML_REGION);
+
+ return region && ANTHROPIC_VERTEX_REGION_RE.test(region)
+ ? region
+ : ANTHROPIC_VERTEX_DEFAULT_REGION;
+}
+
+export function resolveAnthropicVertexRegionFromBaseUrl(baseUrl?: string): string | undefined {
+ const trimmed = baseUrl?.trim();
+ if (!trimmed) {
+ return undefined;
+ }
+
+ try {
+ const host = new URL(trimmed).hostname.toLowerCase();
+ if (host === "aiplatform.googleapis.com") {
+ return "global";
+ }
+ const match = /^([a-z0-9-]+)-aiplatform\.googleapis\.com$/.exec(host);
+ return match?.[1];
+ } catch {
+ return undefined;
+ }
+}
+
+export function resolveAnthropicVertexClientRegion(params?: {
+ baseUrl?: string;
+ env?: NodeJS.ProcessEnv;
+}): string {
+ return (
+ resolveAnthropicVertexRegionFromBaseUrl(params?.baseUrl) ||
+ resolveAnthropicVertexRegion(params?.env)
+ );
+}
+
+function hasAnthropicVertexMetadataServerAdc(env: NodeJS.ProcessEnv = process.env): boolean {
+ const explicitMetadataOptIn = normalizeOptionalSecretInput(env.ANTHROPIC_VERTEX_USE_GCP_METADATA);
+ return explicitMetadataOptIn === "1" || explicitMetadataOptIn?.toLowerCase() === "true";
+}
+
+function resolveAnthropicVertexDefaultAdcPath(env: NodeJS.ProcessEnv = process.env): string {
+ return platform() === "win32"
+ ? join(
+ env.APPDATA ?? join(homedir(), "AppData", "Roaming"),
+ "gcloud",
+ "application_default_credentials.json",
+ )
+ : GCLOUD_DEFAULT_ADC_PATH;
+}
+
+function resolveAnthropicVertexAdcCredentialsPath(
+ env: NodeJS.ProcessEnv = process.env,
+): string | undefined {
+ const explicitCredentialsPath = normalizeOptionalSecretInput(env.GOOGLE_APPLICATION_CREDENTIALS);
+ if (explicitCredentialsPath) {
+ return existsSync(explicitCredentialsPath) ? explicitCredentialsPath : undefined;
+ }
+
+ const defaultAdcPath = resolveAnthropicVertexDefaultAdcPath(env);
+ return existsSync(defaultAdcPath) ? defaultAdcPath : undefined;
+}
+
+function resolveAnthropicVertexProjectIdFromAdc(
+ env: NodeJS.ProcessEnv = process.env,
+): string | undefined {
+ const credentialsPath = resolveAnthropicVertexAdcCredentialsPath(env);
+ if (!credentialsPath) {
+ return undefined;
+ }
+
+ try {
+ const parsed = JSON.parse(readFileSync(credentialsPath, "utf8")) as AdcProjectFile;
+ return (
+ normalizeOptionalSecretInput(parsed.project_id) ||
+ normalizeOptionalSecretInput(parsed.quota_project_id)
+ );
+ } catch {
+ return undefined;
+ }
+}
+
+export function hasAnthropicVertexCredentials(env: NodeJS.ProcessEnv = process.env): boolean {
+ return (
+ hasAnthropicVertexMetadataServerAdc(env) ||
+ resolveAnthropicVertexAdcCredentialsPath(env) !== undefined
+ );
+}
+
+export function hasAnthropicVertexAvailableAuth(env: NodeJS.ProcessEnv = process.env): boolean {
+ return hasAnthropicVertexCredentials(env);
+}
diff --git a/src/agents/anthropic-vertex-stream.test.ts b/src/agents/anthropic-vertex-stream.test.ts
new file mode 100644
index 00000000000..3209bc0fb02
--- /dev/null
+++ b/src/agents/anthropic-vertex-stream.test.ts
@@ -0,0 +1,221 @@
+import type { Model } from "@mariozechner/pi-ai";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const hoisted = vi.hoisted(() => {
+ const streamAnthropicMock = vi.fn<(model: unknown, context: unknown, options: unknown) => symbol>(
+ () => Symbol("anthropic-vertex-stream"),
+ );
+ const anthropicVertexCtorMock = vi.fn();
+
+ return {
+ streamAnthropicMock,
+ anthropicVertexCtorMock,
+ };
+});
+
+vi.mock("@mariozechner/pi-ai", () => {
+ return {
+ streamAnthropic: (model: unknown, context: unknown, options: unknown) =>
+ hoisted.streamAnthropicMock(model, context, options),
+ };
+});
+
+vi.mock("@anthropic-ai/vertex-sdk", () => ({
+ AnthropicVertex: vi.fn(function MockAnthropicVertex(options: unknown) {
+ hoisted.anthropicVertexCtorMock(options);
+ return { options };
+ }),
+}));
+
+import {
+ resolveAnthropicVertexRegion,
+ resolveAnthropicVertexRegionFromBaseUrl,
+} from "./anthropic-vertex-provider.js";
+import {
+ createAnthropicVertexStreamFn,
+ createAnthropicVertexStreamFnForModel,
+} from "./anthropic-vertex-stream.js";
+
+function makeModel(params: { id: string; maxTokens?: number }): Model<"anthropic-messages"> {
+ return {
+ id: params.id,
+ api: "anthropic-messages",
+ provider: "anthropic-vertex",
+ ...(params.maxTokens !== undefined ? { maxTokens: params.maxTokens } : {}),
+ } as Model<"anthropic-messages">;
+}
+
+describe("createAnthropicVertexStreamFn", () => {
+ beforeEach(() => {
+ hoisted.streamAnthropicMock.mockClear();
+ hoisted.anthropicVertexCtorMock.mockClear();
+ });
+
+ it("omits projectId when ADC credentials are used without an explicit project", () => {
+ const streamFn = createAnthropicVertexStreamFn(undefined, "global");
+
+ void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 }), { messages: [] }, {});
+
+ expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({
+ region: "global",
+ });
+ });
+
+ it("passes an explicit baseURL through to the Vertex client", () => {
+ const streamFn = createAnthropicVertexStreamFn(
+ "vertex-project",
+ "us-east5",
+ "https://proxy.example.test/vertex/v1",
+ );
+
+ void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 }), { messages: [] }, {});
+
+ expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({
+ projectId: "vertex-project",
+ region: "us-east5",
+ baseURL: "https://proxy.example.test/vertex/v1",
+ });
+ });
+
+ it("defaults maxTokens to the model limit instead of the old 32000 cap", () => {
+ const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5");
+ const model = makeModel({ id: "claude-opus-4-6", maxTokens: 128000 });
+
+ void streamFn(model, { messages: [] }, {});
+
+ expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith(
+ model,
+ { messages: [] },
+ expect.objectContaining({
+ maxTokens: 128000,
+ }),
+ );
+ });
+
+ it("clamps explicit maxTokens to the selected model limit", () => {
+ const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5");
+ const model = makeModel({ id: "claude-sonnet-4-6", maxTokens: 128000 });
+
+ void streamFn(model, { messages: [] }, { maxTokens: 999999 });
+
+ expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith(
+ model,
+ { messages: [] },
+ expect.objectContaining({
+ maxTokens: 128000,
+ }),
+ );
+ });
+
+ it("maps xhigh reasoning to max effort for adaptive Opus models", () => {
+ const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5");
+ const model = makeModel({ id: "claude-opus-4-6", maxTokens: 64000 });
+
+ void streamFn(model, { messages: [] }, { reasoning: "xhigh" });
+
+ expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith(
+ model,
+ { messages: [] },
+ expect.objectContaining({
+ thinkingEnabled: true,
+ effort: "max",
+ }),
+ );
+ });
+
+ it("omits maxTokens when neither the model nor request provide a finite limit", () => {
+ const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5");
+ const model = makeModel({ id: "claude-sonnet-4-6" });
+
+ void streamFn(model, { messages: [] }, { maxTokens: Number.NaN });
+
+ expect(hoisted.streamAnthropicMock).toHaveBeenCalledWith(
+ model,
+ { messages: [] },
+ expect.not.objectContaining({
+ maxTokens: expect.anything(),
+ }),
+ );
+ });
+});
+
+describe("resolveAnthropicVertexRegionFromBaseUrl", () => {
+ it("accepts well-formed regional env values", () => {
+ expect(
+ resolveAnthropicVertexRegion({
+ GOOGLE_CLOUD_LOCATION: "us-east1",
+ } as NodeJS.ProcessEnv),
+ ).toBe("us-east1");
+ });
+
+ it("falls back to the default region for malformed env values", () => {
+ expect(
+ resolveAnthropicVertexRegion({
+ GOOGLE_CLOUD_LOCATION: "us-central1.attacker.example",
+ } as NodeJS.ProcessEnv),
+ ).toBe("global");
+ });
+
+ it("parses regional Vertex endpoints", () => {
+ expect(
+ resolveAnthropicVertexRegionFromBaseUrl("https://europe-west4-aiplatform.googleapis.com"),
+ ).toBe("europe-west4");
+ });
+
+ it("treats the global Vertex endpoint as global", () => {
+ expect(resolveAnthropicVertexRegionFromBaseUrl("https://aiplatform.googleapis.com")).toBe(
+ "global",
+ );
+ });
+});
+
+describe("createAnthropicVertexStreamFnForModel", () => {
+ beforeEach(() => {
+ hoisted.anthropicVertexCtorMock.mockClear();
+ });
+
+ it("derives project and region from the model and env", () => {
+ const streamFn = createAnthropicVertexStreamFnForModel(
+ { baseUrl: "https://europe-west4-aiplatform.googleapis.com" },
+ { GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv,
+ );
+
+ void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {});
+
+ expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({
+ projectId: "vertex-project",
+ region: "europe-west4",
+ baseURL: "https://europe-west4-aiplatform.googleapis.com/v1",
+ });
+ });
+
+ it("preserves explicit custom provider base URLs", () => {
+ const streamFn = createAnthropicVertexStreamFnForModel(
+ { baseUrl: "https://proxy.example.test/custom-root/v1" },
+ { GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv,
+ );
+
+ void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {});
+
+ expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({
+ projectId: "vertex-project",
+ region: "global",
+ baseURL: "https://proxy.example.test/custom-root/v1",
+ });
+ });
+
+ it("adds /v1 for path-prefixed custom provider base URLs", () => {
+ const streamFn = createAnthropicVertexStreamFnForModel(
+ { baseUrl: "https://proxy.example.test/custom-root" },
+ { GOOGLE_CLOUD_PROJECT_ID: "vertex-project" } as NodeJS.ProcessEnv,
+ );
+
+ void streamFn(makeModel({ id: "claude-sonnet-4-6", maxTokens: 64000 }), { messages: [] }, {});
+
+ expect(hoisted.anthropicVertexCtorMock).toHaveBeenCalledWith({
+ projectId: "vertex-project",
+ region: "global",
+ baseURL: "https://proxy.example.test/custom-root/v1",
+ });
+ });
+});
diff --git a/src/agents/anthropic-vertex-stream.ts b/src/agents/anthropic-vertex-stream.ts
new file mode 100644
index 00000000000..de808f5cdd6
--- /dev/null
+++ b/src/agents/anthropic-vertex-stream.ts
@@ -0,0 +1,137 @@
+import { AnthropicVertex } from "@anthropic-ai/vertex-sdk";
+import type { StreamFn } from "@mariozechner/pi-agent-core";
+import { streamAnthropic, type AnthropicOptions, type Model } from "@mariozechner/pi-ai";
+import {
+ resolveAnthropicVertexClientRegion,
+ resolveAnthropicVertexProjectId,
+} from "./anthropic-vertex-provider.js";
+
+type AnthropicVertexEffort = NonNullable;
+
+function resolveAnthropicVertexMaxTokens(params: {
+ modelMaxTokens: number | undefined;
+ requestedMaxTokens: number | undefined;
+}): number | undefined {
+ const modelMax =
+ typeof params.modelMaxTokens === "number" &&
+ Number.isFinite(params.modelMaxTokens) &&
+ params.modelMaxTokens > 0
+ ? Math.floor(params.modelMaxTokens)
+ : undefined;
+ const requested =
+ typeof params.requestedMaxTokens === "number" &&
+ Number.isFinite(params.requestedMaxTokens) &&
+ params.requestedMaxTokens > 0
+ ? Math.floor(params.requestedMaxTokens)
+ : undefined;
+
+ if (modelMax !== undefined && requested !== undefined) {
+ return Math.min(requested, modelMax);
+ }
+ return requested ?? modelMax;
+}
+
+/**
+ * Create a StreamFn that routes through pi-ai's `streamAnthropic` with an
+ * injected `AnthropicVertex` client. All streaming, message conversion, and
+ * event handling is handled by pi-ai β we only supply the GCP-authenticated
+ * client and map SimpleStreamOptions β AnthropicOptions.
+ */
+export function createAnthropicVertexStreamFn(
+ projectId: string | undefined,
+ region: string,
+ baseURL?: string,
+): StreamFn {
+ const client = new AnthropicVertex({
+ region,
+ ...(baseURL ? { baseURL } : {}),
+ ...(projectId ? { projectId } : {}),
+ });
+
+ return (model, context, options) => {
+ const maxTokens = resolveAnthropicVertexMaxTokens({
+ modelMaxTokens: model.maxTokens,
+ requestedMaxTokens: options?.maxTokens,
+ });
+ const opts: AnthropicOptions = {
+ client: client as unknown as AnthropicOptions["client"],
+ temperature: options?.temperature,
+ ...(maxTokens !== undefined ? { maxTokens } : {}),
+ signal: options?.signal,
+ cacheRetention: options?.cacheRetention,
+ sessionId: options?.sessionId,
+ headers: options?.headers,
+ onPayload: options?.onPayload,
+ maxRetryDelayMs: options?.maxRetryDelayMs,
+ metadata: options?.metadata,
+ };
+
+ if (options?.reasoning) {
+ const isAdaptive =
+ model.id.includes("opus-4-6") ||
+ model.id.includes("opus-4.6") ||
+ model.id.includes("sonnet-4-6") ||
+ model.id.includes("sonnet-4.6");
+
+ if (isAdaptive) {
+ opts.thinkingEnabled = true;
+ const effortMap: Record = {
+ minimal: "low",
+ low: "low",
+ medium: "medium",
+ high: "high",
+ xhigh: model.id.includes("opus-4-6") || model.id.includes("opus-4.6") ? "max" : "high",
+ };
+ opts.effort = effortMap[options.reasoning] ?? "high";
+ } else {
+ opts.thinkingEnabled = true;
+ const budgets = options.thinkingBudgets;
+ opts.thinkingBudgetTokens =
+ (budgets && options.reasoning in budgets
+ ? budgets[options.reasoning as keyof typeof budgets]
+ : undefined) ?? 10000;
+ }
+ } else {
+ opts.thinkingEnabled = false;
+ }
+
+ return streamAnthropic(model as Model<"anthropic-messages">, context, opts);
+ };
+}
+
+function resolveAnthropicVertexSdkBaseUrl(baseUrl?: string): string | undefined {
+ const trimmed = baseUrl?.trim();
+ if (!trimmed) {
+ return undefined;
+ }
+
+ try {
+ const url = new URL(trimmed);
+ const normalizedPath = url.pathname.replace(/\/+$/, "");
+ if (!normalizedPath || normalizedPath === "") {
+ url.pathname = "/v1";
+ return url.toString().replace(/\/$/, "");
+ }
+ if (!normalizedPath.endsWith("/v1")) {
+ url.pathname = `${normalizedPath}/v1`;
+ return url.toString().replace(/\/$/, "");
+ }
+ return trimmed;
+ } catch {
+ return trimmed;
+ }
+}
+
+export function createAnthropicVertexStreamFnForModel(
+ model: { baseUrl?: string },
+ env: NodeJS.ProcessEnv = process.env,
+): StreamFn {
+ return createAnthropicVertexStreamFn(
+ resolveAnthropicVertexProjectId(env),
+ resolveAnthropicVertexClientRegion({
+ baseUrl: model.baseUrl,
+ env,
+ }),
+ resolveAnthropicVertexSdkBaseUrl(model.baseUrl),
+ );
+}
diff --git a/src/agents/bash-tools.exec.path.test.ts b/src/agents/bash-tools.exec.path.test.ts
index 766bfe22107..247c21aede9 100644
--- a/src/agents/bash-tools.exec.path.test.ts
+++ b/src/agents/bash-tools.exec.path.test.ts
@@ -130,6 +130,22 @@ describe("exec PATH login shell merge", () => {
expect(shellPathMock).not.toHaveBeenCalled();
});
+ it("fails closed when a blocked runtime override key is requested", async () => {
+ if (isWin) {
+ return;
+ }
+ const tool = createExecTool({ host: "gateway", security: "full", ask: "off" });
+
+ await expect(
+ tool.execute("call-blocked-runtime-env", {
+ command: "echo ok",
+ env: { CLASSPATH: "/tmp/evil-classpath" },
+ }),
+ ).rejects.toThrow(
+ /Security Violation: Environment variable 'CLASSPATH' is forbidden during host execution\./,
+ );
+ });
+
it("does not apply login-shell PATH when probe rejects unregistered absolute SHELL", async () => {
if (isWin) {
return;
diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts
index 5fe0f7deac4..dcb50c0344c 100644
--- a/src/agents/bash-tools.exec.ts
+++ b/src/agents/bash-tools.exec.ts
@@ -3,6 +3,7 @@ import path from "node:path";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { type ExecHost, loadExecApprovals, maxAsk, minSecurity } from "../infra/exec-approvals.js";
import { resolveExecSafeBinRuntimePolicy } from "../infra/exec-safe-bin-runtime-policy.js";
+import { sanitizeHostExecEnvWithDiagnostics } from "../infra/host-env-security.js";
import {
getShellPathFromLoginShell,
resolveShellEnvFallbackTimeoutMs,
@@ -25,9 +26,7 @@ import {
renderExecHostLabel,
resolveApprovalRunningNoticeMs,
runExecProcess,
- sanitizeHostBaseEnv,
execSchema,
- validateHostEnv,
} from "./bash-tools.exec-runtime.js";
import type {
ExecElevatedDefaults,
@@ -362,24 +361,58 @@ export function createExecTool(
}
const inheritedBaseEnv = coerceEnv(process.env);
- const baseEnv = host === "sandbox" ? inheritedBaseEnv : sanitizeHostBaseEnv(inheritedBaseEnv);
-
- // Logic: Sandbox gets raw env. Host (gateway/node) must pass validation.
- // We validate BEFORE merging to prevent any dangerous vars from entering the stream.
- if (host !== "sandbox" && params.env) {
- validateHostEnv(params.env);
+ const hostEnvResult =
+ host === "sandbox"
+ ? null
+ : sanitizeHostExecEnvWithDiagnostics({
+ baseEnv: inheritedBaseEnv,
+ overrides: params.env,
+ blockPathOverrides: true,
+ });
+ if (
+ hostEnvResult &&
+ params.env &&
+ (hostEnvResult.rejectedOverrideBlockedKeys.length > 0 ||
+ hostEnvResult.rejectedOverrideInvalidKeys.length > 0)
+ ) {
+ const blockedKeys = hostEnvResult.rejectedOverrideBlockedKeys;
+ const invalidKeys = hostEnvResult.rejectedOverrideInvalidKeys;
+ const pathBlocked = blockedKeys.includes("PATH");
+ if (pathBlocked && blockedKeys.length === 1 && invalidKeys.length === 0) {
+ throw new Error(
+ "Security Violation: Custom 'PATH' variable is forbidden during host execution.",
+ );
+ }
+ if (blockedKeys.length === 1 && invalidKeys.length === 0) {
+ throw new Error(
+ `Security Violation: Environment variable '${blockedKeys[0]}' is forbidden during host execution.`,
+ );
+ }
+ const details: string[] = [];
+ if (blockedKeys.length > 0) {
+ details.push(`blocked override keys: ${blockedKeys.join(", ")}`);
+ }
+ if (invalidKeys.length > 0) {
+ details.push(`invalid non-portable override keys: ${invalidKeys.join(", ")}`);
+ }
+ const suffix = details.join("; ");
+ if (pathBlocked) {
+ throw new Error(
+ `Security Violation: Custom 'PATH' variable is forbidden during host execution (${suffix}).`,
+ );
+ }
+ throw new Error(`Security Violation: ${suffix}.`);
}
- const mergedEnv = params.env ? { ...baseEnv, ...params.env } : baseEnv;
-
- const env = sandbox
- ? buildSandboxEnv({
- defaultPath: DEFAULT_PATH,
- paramsEnv: params.env,
- sandboxEnv: sandbox.env,
- containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
- })
- : mergedEnv;
+ const env =
+ sandbox && host === "sandbox"
+ ? buildSandboxEnv({
+ defaultPath: DEFAULT_PATH,
+ paramsEnv: params.env,
+ sandboxEnv: sandbox.env,
+ containerWorkdir: containerWorkdir ?? sandbox.containerWorkdir,
+ })
+ : (hostEnvResult?.env ?? inheritedBaseEnv);
if (!sandbox && host === "gateway" && !params.env?.PATH) {
const shellPath = getShellPathFromLoginShell({
diff --git a/src/agents/model-auth-markers.test.ts b/src/agents/model-auth-markers.test.ts
index 960a648675b..96b7aa96317 100644
--- a/src/agents/model-auth-markers.test.ts
+++ b/src/agents/model-auth-markers.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { listKnownProviderEnvApiKeyNames } from "./model-auth-env-vars.js";
import {
+ GCP_VERTEX_CREDENTIALS_MARKER,
isKnownEnvApiKeyMarker,
isNonSecretApiKeyMarker,
NON_ENV_SECRETREF_MARKER,
@@ -13,6 +14,7 @@ describe("model auth markers", () => {
expect(isNonSecretApiKeyMarker("qwen-oauth")).toBe(true);
expect(isNonSecretApiKeyMarker(resolveOAuthApiKeyMarker("chutes"))).toBe(true);
expect(isNonSecretApiKeyMarker("ollama-local")).toBe(true);
+ expect(isNonSecretApiKeyMarker(GCP_VERTEX_CREDENTIALS_MARKER)).toBe(true);
});
it("recognizes known env marker names but not arbitrary all-caps keys", () => {
diff --git a/src/agents/model-auth-markers.ts b/src/agents/model-auth-markers.ts
index 37ec67ba2c0..4009630afc8 100644
--- a/src/agents/model-auth-markers.ts
+++ b/src/agents/model-auth-markers.ts
@@ -6,6 +6,7 @@ export const OAUTH_API_KEY_MARKER_PREFIX = "oauth:";
export const QWEN_OAUTH_MARKER = "qwen-oauth";
export const OLLAMA_LOCAL_AUTH_MARKER = "ollama-local";
export const CUSTOM_LOCAL_AUTH_MARKER = "custom-local";
+export const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials";
export const NON_ENV_SECRETREF_MARKER = "secretref-managed"; // pragma: allowlist secret
export const SECRETREF_ENV_HEADER_MARKER_PREFIX = "secretref-env:"; // pragma: allowlist secret
@@ -83,6 +84,7 @@ export function isNonSecretApiKeyMarker(
isOAuthApiKeyMarker(trimmed) ||
trimmed === OLLAMA_LOCAL_AUTH_MARKER ||
trimmed === CUSTOM_LOCAL_AUTH_MARKER ||
+ trimmed === GCP_VERTEX_CREDENTIALS_MARKER ||
trimmed === NON_ENV_SECRETREF_MARKER ||
isAwsSdkAuthMarker(trimmed);
if (isKnownMarker) {
diff --git a/src/agents/model-auth.profiles.test.ts b/src/agents/model-auth.profiles.test.ts
index f9395373024..3213ef7be32 100644
--- a/src/agents/model-auth.profiles.test.ts
+++ b/src/agents/model-auth.profiles.test.ts
@@ -506,4 +506,55 @@ describe("getApiKeyForModel", () => {
},
);
});
+
+ it("resolveEnvApiKey('anthropic-vertex') uses the provided env snapshot", async () => {
+ const resolved = resolveEnvApiKey("anthropic-vertex", {
+ GOOGLE_CLOUD_PROJECT_ID: "vertex-project",
+ } as NodeJS.ProcessEnv);
+
+ expect(resolved).toBeNull();
+ });
+
+ it("resolveEnvApiKey('anthropic-vertex') accepts GOOGLE_APPLICATION_CREDENTIALS with project_id", async () => {
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-adc-"));
+ const credentialsPath = path.join(tempDir, "adc.json");
+ await fs.writeFile(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8");
+
+ try {
+ const resolved = resolveEnvApiKey("anthropic-vertex", {
+ GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
+ } as NodeJS.ProcessEnv);
+
+ expect(resolved?.apiKey).toBe("gcp-vertex-credentials");
+ expect(resolved?.source).toBe("gcloud adc");
+ } finally {
+ await fs.rm(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it("resolveEnvApiKey('anthropic-vertex') accepts GOOGLE_APPLICATION_CREDENTIALS without a local project field", async () => {
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-adc-"));
+ const credentialsPath = path.join(tempDir, "adc.json");
+ await fs.writeFile(credentialsPath, "{}", "utf8");
+
+ try {
+ const resolved = resolveEnvApiKey("anthropic-vertex", {
+ GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
+ } as NodeJS.ProcessEnv);
+
+ expect(resolved?.apiKey).toBe("gcp-vertex-credentials");
+ expect(resolved?.source).toBe("gcloud adc");
+ } finally {
+ await fs.rm(tempDir, { recursive: true, force: true });
+ }
+ });
+
+ it("resolveEnvApiKey('anthropic-vertex') accepts explicit metadata auth opt-in", async () => {
+ const resolved = resolveEnvApiKey("anthropic-vertex", {
+ ANTHROPIC_VERTEX_USE_GCP_METADATA: "true",
+ } as NodeJS.ProcessEnv);
+
+ expect(resolved?.apiKey).toBe("gcp-vertex-credentials");
+ expect(resolved?.source).toBe("gcloud adc");
+ });
});
diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts
index 31fdee5496c..3949a4655a5 100644
--- a/src/agents/model-auth.test.ts
+++ b/src/agents/model-auth.test.ts
@@ -2,7 +2,11 @@ import { streamSimpleOpenAICompletions, type Model } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import type { AuthProfileStore } from "./auth-profiles.js";
-import { CUSTOM_LOCAL_AUTH_MARKER, NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
+import {
+ CUSTOM_LOCAL_AUTH_MARKER,
+ GCP_VERTEX_CREDENTIALS_MARKER,
+ NON_ENV_SECRETREF_MARKER,
+} from "./model-auth-markers.js";
import {
applyLocalNoAuthHeaderOverride,
hasUsableCustomProviderApiKey,
@@ -169,6 +173,24 @@ describe("resolveUsableCustomProviderApiKey", () => {
expect(resolved).toBeNull();
});
+ it("does not treat the Vertex ADC marker as a usable models.json credential", () => {
+ const resolved = resolveUsableCustomProviderApiKey({
+ cfg: {
+ models: {
+ providers: {
+ "anthropic-vertex": {
+ baseUrl: "https://us-central1-aiplatform.googleapis.com",
+ apiKey: GCP_VERTEX_CREDENTIALS_MARKER,
+ models: [],
+ },
+ },
+ },
+ },
+ provider: "anthropic-vertex",
+ });
+ expect(resolved).toBeNull();
+ });
+
it("resolves known env marker names from process env for custom providers", () => {
const previous = process.env.OPENAI_API_KEY;
process.env.OPENAI_API_KEY = "sk-from-env"; // pragma: allowlist secret
diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts
index e494cc71b8c..42665cc4713 100644
--- a/src/agents/model-auth.ts
+++ b/src/agents/model-auth.ts
@@ -10,6 +10,7 @@ import {
normalizeOptionalSecretInput,
normalizeSecretInput,
} from "../utils/normalize-secret-input.js";
+import { hasAnthropicVertexAvailableAuth } from "./anthropic-vertex-provider.js";
import {
type AuthProfileStore,
ensureAuthProfileStore,
@@ -21,6 +22,7 @@ import {
import { PROVIDER_ENV_API_KEY_CANDIDATES } from "./model-auth-env-vars.js";
import {
CUSTOM_LOCAL_AUTH_MARKER,
+ GCP_VERTEX_CREDENTIALS_MARKER,
isKnownEnvApiKeyMarker,
isNonSecretApiKeyMarker,
OLLAMA_LOCAL_AUTH_MARKER,
@@ -428,6 +430,16 @@ export function resolveEnvApiKey(
}
return { apiKey: envKey, source: "gcloud adc" };
}
+
+ if (normalized === "anthropic-vertex") {
+ // Vertex AI uses GCP credentials (SA JSON or ADC), not API keys.
+ // Return a sentinel so the model resolver considers this provider available.
+ if (hasAnthropicVertexAvailableAuth(env)) {
+ return { apiKey: GCP_VERTEX_CREDENTIALS_MARKER, source: "gcloud adc" };
+ }
+ return null;
+ }
+
return null;
}
diff --git a/src/agents/model-id-normalization.test.ts b/src/agents/model-id-normalization.test.ts
new file mode 100644
index 00000000000..7ae0d1b736b
--- /dev/null
+++ b/src/agents/model-id-normalization.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vitest";
+import { normalizeXaiModelId } from "./model-id-normalization.js";
+
+describe("normalizeXaiModelId", () => {
+ it("maps deprecated grok 4.20 beta ids to GA ids", () => {
+ expect(normalizeXaiModelId("grok-4.20-experimental-beta-0304-reasoning")).toBe(
+ "grok-4.20-reasoning",
+ );
+ expect(normalizeXaiModelId("grok-4.20-experimental-beta-0304-non-reasoning")).toBe(
+ "grok-4.20-non-reasoning",
+ );
+ });
+
+ it("leaves current xai model ids unchanged", () => {
+ expect(normalizeXaiModelId("grok-4.20-reasoning")).toBe("grok-4.20-reasoning");
+ expect(normalizeXaiModelId("grok-4")).toBe("grok-4");
+ });
+});
diff --git a/src/agents/model-id-normalization.ts b/src/agents/model-id-normalization.ts
index 9b0b27a7f01..8131c5a1d29 100644
--- a/src/agents/model-id-normalization.ts
+++ b/src/agents/model-id-normalization.ts
@@ -21,3 +21,13 @@ export function normalizeGoogleModelId(id: string): string {
}
return id;
}
+
+export function normalizeXaiModelId(id: string): string {
+ if (id === "grok-4.20-experimental-beta-0304-reasoning") {
+ return "grok-4.20-reasoning";
+ }
+ if (id === "grok-4.20-experimental-beta-0304-non-reasoning") {
+ return "grok-4.20-non-reasoning";
+ }
+ return id;
+}
diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts
index e7d583d106f..5d81afc4970 100644
--- a/src/agents/model-selection.test.ts
+++ b/src/agents/model-selection.test.ts
@@ -194,6 +194,15 @@ describe("model-selection", () => {
defaultProvider: "google",
expected: { provider: "google", model: "gemini-3.1-flash-lite-preview" },
},
+ {
+ name: "normalizes deprecated xai grok 4.20 beta ids",
+ variants: [
+ "xai/grok-4.20-experimental-beta-0304-reasoning",
+ "grok-4.20-experimental-beta-0304-reasoning",
+ ],
+ defaultProvider: "xai",
+ expected: { provider: "xai", model: "grok-4.20-reasoning" },
+ },
{
name: "keeps OpenAI codex refs on the openai provider",
variants: ["openai/gpt-5.3-codex", "gpt-5.3-codex"],
diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts
index acc29a32bf9..7e654dd24f3 100644
--- a/src/agents/model-selection.ts
+++ b/src/agents/model-selection.ts
@@ -14,7 +14,7 @@ import {
} from "./agent-scope.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import type { ModelCatalogEntry } from "./model-catalog.js";
-import { normalizeGoogleModelId } from "./model-id-normalization.js";
+import { normalizeGoogleModelId, normalizeXaiModelId } from "./model-id-normalization.js";
import { splitTrailingAuthProfile } from "./model-ref-profile.js";
import {
findNormalizedProviderKey,
@@ -121,6 +121,9 @@ function normalizeProviderModelId(provider: string, model: string): string {
if (provider === "google" || provider === "google-vertex") {
return normalizeGoogleModelId(model);
}
+ if (provider === "xai") {
+ return normalizeXaiModelId(model);
+ }
// OpenRouter-native models (e.g. "openrouter/aurora-alpha") need the full
// "openrouter/" as the model ID sent to the API. Models from external
// providers already contain a slash (e.g. "anthropic/claude-sonnet-4-5") and
diff --git a/src/agents/models-config.e2e-harness.ts b/src/agents/models-config.e2e-harness.ts
index 81518ec9aee..bd01edc86be 100644
--- a/src/agents/models-config.e2e-harness.ts
+++ b/src/agents/models-config.e2e-harness.ts
@@ -112,9 +112,15 @@ export const MODELS_CONFIG_IMPLICIT_ENV_VARS = [
"KIMI_API_KEY",
"KIMICODE_API_KEY",
"GEMINI_API_KEY",
+ "GOOGLE_APPLICATION_CREDENTIALS",
+ "GOOGLE_CLOUD_LOCATION",
+ "GOOGLE_CLOUD_PROJECT",
+ "GOOGLE_CLOUD_PROJECT_ID",
"VENICE_API_KEY",
"VLLM_API_KEY",
"XIAOMI_API_KEY",
+ "ANTHROPIC_VERTEX_PROJECT_ID",
+ "CLOUD_ML_REGION",
// Avoid ambient AWS creds unintentionally enabling Bedrock discovery.
"AWS_ACCESS_KEY_ID",
"AWS_CONFIG_FILE",
diff --git a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts
index 5e0f870e476..8906800aa8e 100644
--- a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts
+++ b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts
@@ -1,4 +1,5 @@
import fs from "node:fs/promises";
+import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
@@ -333,6 +334,53 @@ describe("models-config", () => {
});
});
});
+
+ it("fills anthropic-vertex apiKey with the ADC sentinel when models exist", async () => {
+ await withTempHome(async () => {
+ const adcDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-adc-"));
+ const credentialsPath = path.join(adcDir, "application_default_credentials.json");
+ await fs.writeFile(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8");
+ const previousCredentials = process.env.GOOGLE_APPLICATION_CREDENTIALS;
+
+ try {
+ process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
+
+ await ensureOpenClawModelsJson({
+ models: {
+ providers: {
+ "anthropic-vertex": {
+ baseUrl: "https://us-central1-aiplatform.googleapis.com",
+ api: "anthropic-messages",
+ models: [
+ {
+ id: "claude-sonnet-4-6",
+ name: "Claude Sonnet 4.6",
+ reasoning: true,
+ input: ["text", "image"],
+ cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
+ contextWindow: 200000,
+ maxTokens: 64000,
+ },
+ ],
+ },
+ },
+ },
+ });
+
+ const parsed = await readGeneratedModelsJson<{
+ providers: Record;
+ }>();
+ expect(parsed.providers["anthropic-vertex"]?.apiKey).toBe("gcp-vertex-credentials");
+ } finally {
+ if (previousCredentials === undefined) {
+ delete process.env.GOOGLE_APPLICATION_CREDENTIALS;
+ } else {
+ process.env.GOOGLE_APPLICATION_CREDENTIALS = previousCredentials;
+ }
+ await fs.rm(adcDir, { recursive: true, force: true });
+ }
+ });
+ });
it("merges providers by default", async () => {
await withTempHome(async () => {
await writeAgentModelsJson({
diff --git a/src/agents/models-config.providers.anthropic-vertex.test.ts b/src/agents/models-config.providers.anthropic-vertex.test.ts
new file mode 100644
index 00000000000..207abe0c5b1
--- /dev/null
+++ b/src/agents/models-config.providers.anthropic-vertex.test.ts
@@ -0,0 +1,190 @@
+import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import type { OpenClawConfig } from "../config/config.js";
+import { captureEnv } from "../test-utils/env.js";
+import { resolveImplicitProvidersForTest } from "./models-config.e2e-harness.js";
+
+describe("anthropic-vertex implicit provider", () => {
+ it("offers Claude models when GOOGLE_CLOUD_PROJECT_ID is set", async () => {
+ const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
+ const envSnapshot = captureEnv(["GOOGLE_CLOUD_PROJECT_ID"]);
+ process.env.GOOGLE_CLOUD_PROJECT_ID = "vertex-project";
+
+ try {
+ const providers = await resolveImplicitProvidersForTest({ agentDir });
+ expect(providers?.["anthropic-vertex"]).toBeUndefined();
+ } finally {
+ envSnapshot.restore();
+ }
+ });
+
+ it("accepts ADC credentials when the file includes a project_id", async () => {
+ const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
+ const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]);
+ const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-"));
+ const credentialsPath = join(adcDir, "application_default_credentials.json");
+ writeFileSync(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8");
+ process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
+ process.env.GOOGLE_CLOUD_LOCATION = "us-east1";
+
+ try {
+ const providers = await resolveImplicitProvidersForTest({ agentDir });
+ expect(providers?.["anthropic-vertex"]?.baseUrl).toBe(
+ "https://us-east1-aiplatform.googleapis.com",
+ );
+ expect(providers?.["anthropic-vertex"]?.models).toMatchObject([
+ { id: "claude-opus-4-6", maxTokens: 128000, contextWindow: 1_000_000 },
+ { id: "claude-sonnet-4-6", maxTokens: 128000, contextWindow: 1_000_000 },
+ ]);
+ } finally {
+ rmSync(adcDir, { recursive: true, force: true });
+ envSnapshot.restore();
+ }
+ });
+
+ it("accepts ADC credentials when the file only includes a quota_project_id", async () => {
+ const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
+ const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]);
+ const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-"));
+ const credentialsPath = join(adcDir, "application_default_credentials.json");
+ writeFileSync(credentialsPath, JSON.stringify({ quota_project_id: "vertex-quota" }), "utf8");
+ process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
+ process.env.GOOGLE_CLOUD_LOCATION = "us-east5";
+
+ try {
+ const providers = await resolveImplicitProvidersForTest({ agentDir });
+ expect(providers?.["anthropic-vertex"]?.baseUrl).toBe(
+ "https://us-east5-aiplatform.googleapis.com",
+ );
+ } finally {
+ rmSync(adcDir, { recursive: true, force: true });
+ envSnapshot.restore();
+ }
+ });
+
+ it("accepts ADC credentials when project_id is resolved at runtime", async () => {
+ const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
+ const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]);
+ const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-"));
+ const credentialsPath = join(adcDir, "application_default_credentials.json");
+ writeFileSync(credentialsPath, "{}", "utf8");
+ process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
+ process.env.GOOGLE_CLOUD_LOCATION = "europe-west4";
+
+ try {
+ const providers = await resolveImplicitProvidersForTest({ agentDir });
+ expect(providers?.["anthropic-vertex"]?.baseUrl).toBe(
+ "https://europe-west4-aiplatform.googleapis.com",
+ );
+ } finally {
+ rmSync(adcDir, { recursive: true, force: true });
+ envSnapshot.restore();
+ }
+ });
+
+ it("falls back to the default region when GOOGLE_CLOUD_LOCATION is invalid", async () => {
+ const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
+ const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]);
+ const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-"));
+ const credentialsPath = join(adcDir, "application_default_credentials.json");
+ writeFileSync(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8");
+ process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
+ process.env.GOOGLE_CLOUD_LOCATION = "us-central1.attacker.example";
+
+ try {
+ const providers = await resolveImplicitProvidersForTest({ agentDir });
+ expect(providers?.["anthropic-vertex"]?.baseUrl).toBe("https://aiplatform.googleapis.com");
+ } finally {
+ rmSync(adcDir, { recursive: true, force: true });
+ envSnapshot.restore();
+ }
+ });
+
+ it("uses the Vertex global endpoint when GOOGLE_CLOUD_LOCATION=global", async () => {
+ const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
+ const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]);
+ const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-"));
+ const credentialsPath = join(adcDir, "application_default_credentials.json");
+ writeFileSync(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8");
+ process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
+ process.env.GOOGLE_CLOUD_LOCATION = "global";
+
+ try {
+ const providers = await resolveImplicitProvidersForTest({ agentDir });
+ expect(providers?.["anthropic-vertex"]?.baseUrl).toBe("https://aiplatform.googleapis.com");
+ } finally {
+ rmSync(adcDir, { recursive: true, force: true });
+ envSnapshot.restore();
+ }
+ });
+
+ it("accepts explicit metadata auth opt-in without local credential files", async () => {
+ const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
+ const envSnapshot = captureEnv(["ANTHROPIC_VERTEX_USE_GCP_METADATA", "GOOGLE_CLOUD_LOCATION"]);
+ process.env.ANTHROPIC_VERTEX_USE_GCP_METADATA = "true";
+ process.env.GOOGLE_CLOUD_LOCATION = "us-east5";
+
+ try {
+ const providers = await resolveImplicitProvidersForTest({ agentDir });
+ expect(providers?.["anthropic-vertex"]?.baseUrl).toBe(
+ "https://us-east5-aiplatform.googleapis.com",
+ );
+ } finally {
+ envSnapshot.restore();
+ }
+ });
+
+ it("merges the bundled catalog into explicit anthropic-vertex provider overrides", async () => {
+ const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
+ const envSnapshot = captureEnv(["GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_CLOUD_LOCATION"]);
+ const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-"));
+ const credentialsPath = join(adcDir, "application_default_credentials.json");
+ writeFileSync(credentialsPath, JSON.stringify({ project_id: "vertex-project" }), "utf8");
+ process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
+ process.env.GOOGLE_CLOUD_LOCATION = "us-east5";
+
+ try {
+ const providers = await resolveImplicitProvidersForTest({
+ agentDir,
+ config: {
+ models: {
+ providers: {
+ "anthropic-vertex": {
+ baseUrl: "https://europe-west4-aiplatform.googleapis.com",
+ headers: { "x-test-header": "1" },
+ },
+ },
+ },
+ } as unknown as OpenClawConfig,
+ });
+
+ expect(providers?.["anthropic-vertex"]?.baseUrl).toBe(
+ "https://europe-west4-aiplatform.googleapis.com",
+ );
+ expect(providers?.["anthropic-vertex"]?.headers).toEqual({ "x-test-header": "1" });
+ expect(providers?.["anthropic-vertex"]?.models?.map((model) => model.id)).toEqual([
+ "claude-opus-4-6",
+ "claude-sonnet-4-6",
+ ]);
+ } finally {
+ rmSync(adcDir, { recursive: true, force: true });
+ envSnapshot.restore();
+ }
+ });
+
+ it("does not accept generic Kubernetes env without a GCP ADC signal", async () => {
+ const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
+ const envSnapshot = captureEnv(["KUBERNETES_SERVICE_HOST", "GOOGLE_CLOUD_LOCATION"]);
+ process.env.KUBERNETES_SERVICE_HOST = "10.0.0.1";
+ process.env.GOOGLE_CLOUD_LOCATION = "us-east5";
+
+ try {
+ const providers = await resolveImplicitProvidersForTest({ agentDir });
+ expect(providers?.["anthropic-vertex"]).toBeUndefined();
+ } finally {
+ envSnapshot.restore();
+ }
+ });
+});
diff --git a/src/agents/models-config.providers.static.ts b/src/agents/models-config.providers.static.ts
index 71184e12286..dea2c4e6f2f 100644
--- a/src/agents/models-config.providers.static.ts
+++ b/src/agents/models-config.providers.static.ts
@@ -1,3 +1,7 @@
+export {
+ ANTHROPIC_VERTEX_DEFAULT_MODEL_ID,
+ buildAnthropicVertexProvider,
+} from "../../extensions/anthropic-vertex/provider-catalog.js";
export {
buildBytePlusCodingProvider,
buildBytePlusProvider,
diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts
index af9c3d6e34a..f4f6172dc09 100644
--- a/src/agents/models-config.providers.ts
+++ b/src/agents/models-config.providers.ts
@@ -1,3 +1,4 @@
+import { buildAnthropicVertexProvider } from "../../extensions/anthropic-vertex/provider-catalog.js";
import {
QIANFAN_BASE_URL,
QIANFAN_DEFAULT_MODEL_ID,
@@ -7,9 +8,10 @@ import type { OpenClawConfig } from "../config/config.js";
import { coerceSecretRef, resolveSecretInputRef } from "../config/types.secrets.js";
import { isRecord } from "../utils.js";
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
+import { hasAnthropicVertexAvailableAuth } from "./anthropic-vertex-provider.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
import { discoverBedrockModels } from "./bedrock-discovery.js";
-import { normalizeGoogleModelId } from "./model-id-normalization.js";
+import { normalizeGoogleModelId, normalizeXaiModelId } from "./model-id-normalization.js";
import { resolveOllamaApiBase } from "./models-config.providers.discovery.js";
export { buildKimiCodingProvider } from "../../extensions/kimi-coding/provider-catalog.js";
export { buildKilocodeProvider } from "../../extensions/kilocode/provider-catalog.js";
@@ -42,7 +44,7 @@ import {
} from "./model-auth-markers.js";
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
export { resolveOllamaApiBase } from "./models-config.providers.discovery.js";
-export { normalizeGoogleModelId };
+export { normalizeGoogleModelId, normalizeXaiModelId };
type ModelsConfig = NonNullable;
export type ProviderConfig = NonNullable[string];
@@ -552,7 +554,10 @@ export function normalizeProviders(params: {
mutated = true;
normalizedProvider = { ...normalizedProvider, apiKey };
} else {
- const fromEnv = resolveEnvApiKeyVarName(normalizedKey, env);
+ const fromEnv =
+ normalizedKey === "anthropic-vertex"
+ ? resolveEnvApiKey(normalizedKey, env)?.apiKey
+ : resolveEnvApiKeyVarName(normalizedKey, env);
const apiKey = fromEnv ?? profileApiKey?.apiKey;
if (apiKey?.trim()) {
if (profileApiKey && profileApiKey.source !== "plaintext") {
@@ -812,9 +817,34 @@ export async function resolveImplicitProviders(
: implicitBedrock;
}
+ const implicitAnthropicVertex = resolveImplicitAnthropicVertexProvider({ env });
+ if (implicitAnthropicVertex) {
+ const existing = providers["anthropic-vertex"];
+ providers["anthropic-vertex"] = existing
+ ? {
+ ...implicitAnthropicVertex,
+ ...existing,
+ models:
+ Array.isArray(existing.models) && existing.models.length > 0
+ ? existing.models
+ : implicitAnthropicVertex.models,
+ }
+ : implicitAnthropicVertex;
+ }
+
return providers;
}
+export function resolveImplicitAnthropicVertexProvider(params: {
+ env?: NodeJS.ProcessEnv;
+}): ProviderConfig | null {
+ const env = params.env ?? process.env;
+ if (!hasAnthropicVertexAvailableAuth(env)) {
+ return null;
+ }
+
+ return buildAnthropicVertexProvider({ env });
+}
export async function resolveImplicitBedrockProvider(params: {
agentDir: string;
config?: OpenClawConfig;
diff --git a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts
index 35fc741db58..47460c5efa7 100644
--- a/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts
+++ b/src/agents/pi-embedded-helpers.formatassistanterrortext.test.ts
@@ -125,6 +125,27 @@ describe("formatAssistantErrorText", () => {
const msg = makeAssistantError("request ended without sending any chunks");
expect(formatAssistantErrorText(msg)).toBe("LLM request timed out.");
});
+
+ it("returns a connection-refused message for ECONNREFUSED failures", () => {
+ const msg = makeAssistantError("connect ECONNREFUSED 127.0.0.1:443 during upstream call");
+ expect(formatAssistantErrorText(msg)).toBe(
+ "LLM request failed: connection refused by the provider endpoint.",
+ );
+ });
+
+ it("returns a DNS-specific message for provider lookup failures", () => {
+ const msg = makeAssistantError("dial tcp: lookup api.example.com: no such host (ENOTFOUND)");
+ expect(formatAssistantErrorText(msg)).toBe(
+ "LLM request failed: DNS lookup for the provider endpoint failed.",
+ );
+ });
+
+ it("returns an interrupted-connection message for socket hang ups", () => {
+ const msg = makeAssistantError("socket hang up");
+ expect(formatAssistantErrorText(msg)).toBe(
+ "LLM request failed: network connection was interrupted.",
+ );
+ });
});
describe("formatRawAssistantErrorForUi", () => {
diff --git a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts
index 2808d320cc5..82fe67c47f4 100644
--- a/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts
+++ b/src/agents/pi-embedded-helpers.sanitizeuserfacingtext.test.ts
@@ -88,6 +88,14 @@ describe("sanitizeUserFacingText", () => {
);
});
+ it("returns a transport-specific message for prefixed ECONNREFUSED errors", () => {
+ expect(
+ sanitizeUserFacingText("Error: connect ECONNREFUSED 127.0.0.1:443", {
+ errorContext: true,
+ }),
+ ).toBe("LLM request failed: connection refused by the provider endpoint.");
+ });
+
it.each([
{
input: "Hello there!\n\nHello there!",
diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts
index 7719ecb41a0..bb3d6b78206 100644
--- a/src/agents/pi-embedded-helpers/errors.ts
+++ b/src/agents/pi-embedded-helpers/errors.ts
@@ -65,6 +65,57 @@ function formatRateLimitOrOverloadedErrorCopy(raw: string): string | undefined {
return undefined;
}
+function formatTransportErrorCopy(raw: string): string | undefined {
+ if (!raw) {
+ return undefined;
+ }
+ const lower = raw.toLowerCase();
+
+ if (
+ /\beconnrefused\b/i.test(raw) ||
+ lower.includes("connection refused") ||
+ lower.includes("actively refused")
+ ) {
+ return "LLM request failed: connection refused by the provider endpoint.";
+ }
+
+ if (
+ /\beconnreset\b|\beconnaborted\b|\benetreset\b|\bepipe\b/i.test(raw) ||
+ lower.includes("socket hang up") ||
+ lower.includes("connection reset") ||
+ lower.includes("connection aborted")
+ ) {
+ return "LLM request failed: network connection was interrupted.";
+ }
+
+ if (
+ /\benotfound\b|\beai_again\b/i.test(raw) ||
+ lower.includes("getaddrinfo") ||
+ lower.includes("no such host") ||
+ lower.includes("dns")
+ ) {
+ return "LLM request failed: DNS lookup for the provider endpoint failed.";
+ }
+
+ if (
+ /\benetunreach\b|\behostunreach\b|\behostdown\b/i.test(raw) ||
+ lower.includes("network is unreachable") ||
+ lower.includes("host is unreachable")
+ ) {
+ return "LLM request failed: the provider endpoint is unreachable from this host.";
+ }
+
+ if (
+ lower.includes("fetch failed") ||
+ lower.includes("connection error") ||
+ lower.includes("network request failed")
+ ) {
+ return "LLM request failed: network connection error.";
+ }
+
+ return undefined;
+}
+
function isReasoningConstraintErrorMessage(raw: string): boolean {
if (!raw) {
return false;
@@ -566,6 +617,11 @@ export function formatAssistantErrorText(
return transientCopy;
}
+ const transportCopy = formatTransportErrorCopy(raw);
+ if (transportCopy) {
+ return transportCopy;
+ }
+
if (isTimeoutErrorMessage(raw)) {
return "LLM request timed out.";
}
@@ -626,6 +682,10 @@ export function sanitizeUserFacingText(text: string, opts?: { errorContext?: boo
if (prefixedCopy) {
return prefixedCopy;
}
+ const transportCopy = formatTransportErrorCopy(trimmed);
+ if (transportCopy) {
+ return transportCopy;
+ }
if (isTimeoutErrorMessage(trimmed)) {
return "LLM request timed out.";
}
diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts
index 1a97501959e..f8f486f230f 100644
--- a/src/agents/pi-embedded-runner/compact.hooks.test.ts
+++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts
@@ -623,6 +623,36 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => {
}
});
+ it("runs maintain after successful compaction with a transcript rewrite helper", async () => {
+ const maintain = vi.fn(async (_params?: unknown) => ({
+ changed: false,
+ bytesFreed: 0,
+ rewrittenEntries: 0,
+ }));
+ resolveContextEngineMock.mockResolvedValue({
+ info: { ownsCompaction: true },
+ compact: contextEngineCompactMock,
+ maintain,
+ } as never);
+
+ const result = await compactEmbeddedPiSession(wrappedCompactionArgs());
+
+ expect(result.ok).toBe(true);
+ expect(maintain).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sessionKey: TEST_SESSION_KEY,
+ sessionFile: TEST_SESSION_FILE,
+ runtimeContext: expect.objectContaining({
+ workspaceDir: TEST_WORKSPACE_DIR,
+ }),
+ }),
+ );
+ const runtimeContext = (
+ maintain.mock.calls[0]?.[0] as { runtimeContext?: Record } | undefined
+ )?.runtimeContext;
+ expect(typeof runtimeContext?.rewriteTranscriptEntries).toBe("function");
+ });
+
it("does not fire after_compaction when compaction fails", async () => {
hookRunner.hasHooks.mockReturnValue(true);
const sync = vi.fn(async () => {});
diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts
index 6c753e9d723..dd5806421a0 100644
--- a/src/agents/pi-embedded-runner/compact.ts
+++ b/src/agents/pi-embedded-runner/compact.ts
@@ -10,7 +10,7 @@ import {
import {
resolveTelegramInlineButtonsScope,
resolveTelegramReactionLevel,
-} from "openclaw/plugin-sdk/telegram";
+} from "../../../extensions/telegram/api.js";
import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js";
import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
import { resolveChannelCapabilities } from "../../config/channel-capabilities.js";
@@ -83,6 +83,7 @@ import {
compactWithSafetyTimeout,
resolveCompactionTimeoutMs,
} from "./compaction-safety-timeout.js";
+import { runContextEngineMaintenance } from "./context-engine-maintenance.js";
import { buildEmbeddedExtensionFactories } from "./extensions.js";
import {
logToolSchemasForGoogle,
@@ -1226,6 +1227,16 @@ export async function compactEmbeddedPiSession(
force: params.trigger === "manual",
runtimeContext: params as Record,
});
+ if (result.ok && result.compacted) {
+ await runContextEngineMaintenance({
+ contextEngine,
+ sessionId: params.sessionId,
+ sessionKey: params.sessionKey,
+ sessionFile: params.sessionFile,
+ reason: "compaction",
+ runtimeContext: params as Record,
+ });
+ }
if (engineOwnsCompaction && result.ok && result.compacted) {
await runPostCompactionSideEffects({
config: params.config,
diff --git a/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts b/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts
new file mode 100644
index 00000000000..3c62e463620
--- /dev/null
+++ b/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts
@@ -0,0 +1,150 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const rewriteTranscriptEntriesInSessionManagerMock = vi.fn((_params?: unknown) => ({
+ changed: true,
+ bytesFreed: 77,
+ rewrittenEntries: 1,
+}));
+const rewriteTranscriptEntriesInSessionFileMock = vi.fn(async (_params?: unknown) => ({
+ changed: true,
+ bytesFreed: 123,
+ rewrittenEntries: 2,
+}));
+
+vi.mock("./transcript-rewrite.js", () => ({
+ rewriteTranscriptEntriesInSessionManager: (params: unknown) =>
+ rewriteTranscriptEntriesInSessionManagerMock(params),
+ rewriteTranscriptEntriesInSessionFile: (params: unknown) =>
+ rewriteTranscriptEntriesInSessionFileMock(params),
+}));
+
+import {
+ buildContextEngineMaintenanceRuntimeContext,
+ runContextEngineMaintenance,
+} from "./context-engine-maintenance.js";
+
+describe("buildContextEngineMaintenanceRuntimeContext", () => {
+ beforeEach(() => {
+ rewriteTranscriptEntriesInSessionManagerMock.mockClear();
+ rewriteTranscriptEntriesInSessionFileMock.mockClear();
+ });
+
+ it("adds a transcript rewrite helper that targets the current session file", async () => {
+ const runtimeContext = buildContextEngineMaintenanceRuntimeContext({
+ sessionId: "session-1",
+ sessionKey: "agent:main:session-1",
+ sessionFile: "/tmp/session.jsonl",
+ runtimeContext: { workspaceDir: "/tmp/workspace" },
+ });
+
+ expect(runtimeContext.workspaceDir).toBe("/tmp/workspace");
+ expect(typeof runtimeContext.rewriteTranscriptEntries).toBe("function");
+
+ const result = await runtimeContext.rewriteTranscriptEntries?.({
+ replacements: [
+ { entryId: "entry-1", message: { role: "user", content: "hi", timestamp: 1 } },
+ ],
+ });
+
+ expect(result).toEqual({
+ changed: true,
+ bytesFreed: 123,
+ rewrittenEntries: 2,
+ });
+ expect(rewriteTranscriptEntriesInSessionFileMock).toHaveBeenCalledWith({
+ sessionFile: "/tmp/session.jsonl",
+ sessionId: "session-1",
+ sessionKey: "agent:main:session-1",
+ request: {
+ replacements: [
+ { entryId: "entry-1", message: { role: "user", content: "hi", timestamp: 1 } },
+ ],
+ },
+ });
+ });
+
+ it("reuses the active session manager when one is provided", async () => {
+ const sessionManager = { appendMessage: vi.fn() } as unknown as Parameters<
+ typeof buildContextEngineMaintenanceRuntimeContext
+ >[0]["sessionManager"];
+ const runtimeContext = buildContextEngineMaintenanceRuntimeContext({
+ sessionId: "session-1",
+ sessionKey: "agent:main:session-1",
+ sessionFile: "/tmp/session.jsonl",
+ sessionManager,
+ });
+
+ const result = await runtimeContext.rewriteTranscriptEntries?.({
+ replacements: [
+ { entryId: "entry-1", message: { role: "user", content: "hi", timestamp: 1 } },
+ ],
+ });
+
+ expect(result).toEqual({
+ changed: true,
+ bytesFreed: 77,
+ rewrittenEntries: 1,
+ });
+ expect(rewriteTranscriptEntriesInSessionManagerMock).toHaveBeenCalledWith({
+ sessionManager,
+ replacements: [
+ { entryId: "entry-1", message: { role: "user", content: "hi", timestamp: 1 } },
+ ],
+ });
+ expect(rewriteTranscriptEntriesInSessionFileMock).not.toHaveBeenCalled();
+ });
+});
+
+describe("runContextEngineMaintenance", () => {
+ beforeEach(() => {
+ rewriteTranscriptEntriesInSessionManagerMock.mockClear();
+ rewriteTranscriptEntriesInSessionFileMock.mockClear();
+ });
+
+ it("passes a rewrite-capable runtime context into maintain()", async () => {
+ const maintain = vi.fn(async (_params?: unknown) => ({
+ changed: false,
+ bytesFreed: 0,
+ rewrittenEntries: 0,
+ }));
+
+ const result = await runContextEngineMaintenance({
+ contextEngine: {
+ info: { id: "test", name: "Test Engine" },
+ ingest: async () => ({ ingested: true }),
+ assemble: async ({ messages }) => ({ messages, estimatedTokens: 0 }),
+ compact: async () => ({ ok: true, compacted: false }),
+ maintain,
+ },
+ sessionId: "session-1",
+ sessionKey: "agent:main:session-1",
+ sessionFile: "/tmp/session.jsonl",
+ reason: "turn",
+ runtimeContext: { workspaceDir: "/tmp/workspace" },
+ });
+
+ expect(result).toEqual({
+ changed: false,
+ bytesFreed: 0,
+ rewrittenEntries: 0,
+ });
+ expect(maintain).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sessionId: "session-1",
+ sessionKey: "agent:main:session-1",
+ sessionFile: "/tmp/session.jsonl",
+ runtimeContext: expect.objectContaining({
+ workspaceDir: "/tmp/workspace",
+ }),
+ }),
+ );
+ const runtimeContext = (
+ maintain.mock.calls[0]?.[0] as
+ | { runtimeContext?: { rewriteTranscriptEntries?: (request: unknown) => Promise } }
+ | undefined
+ )?.runtimeContext as
+ | { rewriteTranscriptEntries?: (request: unknown) => Promise }
+ | undefined;
+ expect(typeof runtimeContext?.rewriteTranscriptEntries).toBe("function");
+ });
+});
diff --git a/src/agents/pi-embedded-runner/context-engine-maintenance.ts b/src/agents/pi-embedded-runner/context-engine-maintenance.ts
new file mode 100644
index 00000000000..88e417f5757
--- /dev/null
+++ b/src/agents/pi-embedded-runner/context-engine-maintenance.ts
@@ -0,0 +1,83 @@
+import type {
+ ContextEngine,
+ ContextEngineMaintenanceResult,
+ ContextEngineRuntimeContext,
+} from "../../context-engine/types.js";
+import { log } from "./logger.js";
+import {
+ rewriteTranscriptEntriesInSessionFile,
+ rewriteTranscriptEntriesInSessionManager,
+} from "./transcript-rewrite.js";
+
+/**
+ * Attach runtime-owned transcript rewrite helpers to an existing
+ * context-engine runtime context payload.
+ */
+export function buildContextEngineMaintenanceRuntimeContext(params: {
+ sessionId: string;
+ sessionKey?: string;
+ sessionFile: string;
+ sessionManager?: Parameters[0]["sessionManager"];
+ runtimeContext?: ContextEngineRuntimeContext;
+}): ContextEngineRuntimeContext {
+ return {
+ ...params.runtimeContext,
+ rewriteTranscriptEntries: async (request) => {
+ if (params.sessionManager) {
+ return rewriteTranscriptEntriesInSessionManager({
+ sessionManager: params.sessionManager,
+ replacements: request.replacements,
+ });
+ }
+ return await rewriteTranscriptEntriesInSessionFile({
+ sessionFile: params.sessionFile,
+ sessionId: params.sessionId,
+ sessionKey: params.sessionKey,
+ request,
+ });
+ },
+ };
+}
+
+/**
+ * Run optional context-engine transcript maintenance and normalize the result.
+ */
+export async function runContextEngineMaintenance(params: {
+ contextEngine?: ContextEngine;
+ sessionId: string;
+ sessionKey?: string;
+ sessionFile: string;
+ reason: "bootstrap" | "compaction" | "turn";
+ sessionManager?: Parameters[0]["sessionManager"];
+ runtimeContext?: ContextEngineRuntimeContext;
+}): Promise {
+ if (typeof params.contextEngine?.maintain !== "function") {
+ return undefined;
+ }
+
+ try {
+ const result = await params.contextEngine.maintain({
+ sessionId: params.sessionId,
+ sessionKey: params.sessionKey,
+ sessionFile: params.sessionFile,
+ runtimeContext: buildContextEngineMaintenanceRuntimeContext({
+ sessionId: params.sessionId,
+ sessionKey: params.sessionKey,
+ sessionFile: params.sessionFile,
+ sessionManager: params.sessionManager,
+ runtimeContext: params.runtimeContext,
+ }),
+ });
+ if (result.changed) {
+ log.info(
+ `[context-engine] maintenance(${params.reason}) changed transcript ` +
+ `rewrittenEntries=${result.rewrittenEntries} bytesFreed=${result.bytesFreed} ` +
+ `sessionKey=${params.sessionKey ?? params.sessionId ?? "unknown"}`,
+ );
+ }
+ return result;
+ } catch (err) {
+ log.warn(`context engine maintain failed (${params.reason}): ${String(err)}`);
+ return undefined;
+ }
+}
diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts
index 9e7853ef7d5..10c13dfe6fc 100644
--- a/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts
+++ b/src/agents/pi-embedded-runner/run.overflow-compaction.harness.ts
@@ -66,6 +66,7 @@ export const mockedEnsureRuntimePluginsLoaded = vi.fn<(params?: unknown) => void
export const mockedPrepareProviderRuntimeAuth = vi.fn(async () => undefined);
export const mockedRunEmbeddedAttempt =
vi.fn<(params: unknown) => Promise>();
+export const mockedRunContextEngineMaintenance = vi.fn(async () => undefined);
export const mockedSessionLikelyHasOversizedToolResults = vi.fn(() => false);
export const mockedTruncateOversizedToolResultsInSession = vi.fn<
() => Promise
@@ -173,6 +174,8 @@ export function resetRunOverflowCompactionHarnessMocks(): void {
mockedPrepareProviderRuntimeAuth.mockReset();
mockedPrepareProviderRuntimeAuth.mockResolvedValue(undefined);
mockedRunEmbeddedAttempt.mockReset();
+ mockedRunContextEngineMaintenance.mockReset();
+ mockedRunContextEngineMaintenance.mockResolvedValue(undefined);
mockedSessionLikelyHasOversizedToolResults.mockReset();
mockedSessionLikelyHasOversizedToolResults.mockReturnValue(false);
mockedTruncateOversizedToolResultsInSession.mockReset();
@@ -303,6 +306,10 @@ export async function loadRunOverflowCompactionHarness(): Promise<{
runEmbeddedAttempt: mockedRunEmbeddedAttempt,
}));
+ vi.doMock("./context-engine-maintenance.js", () => ({
+ runContextEngineMaintenance: mockedRunContextEngineMaintenance,
+ }));
+
vi.doMock("./model.js", () => ({
resolveModelAsync: vi.fn(async () => ({
model: {
diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts
index 1f5f0b6de35..56b4fbf0186 100644
--- a/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts
+++ b/src/agents/pi-embedded-runner/run.overflow-compaction.test.ts
@@ -16,6 +16,7 @@ import {
mockedContextEngine,
mockedCompactDirect,
mockedRunEmbeddedAttempt,
+ mockedRunContextEngineMaintenance,
resetRunOverflowCompactionHarnessMocks,
mockedSessionLikelyHasOversizedToolResults,
mockedTruncateOversizedToolResultsInSession,
@@ -35,6 +36,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
beforeEach(() => {
mockedRunEmbeddedAttempt.mockReset();
+ mockedRunContextEngineMaintenance.mockReset();
mockedCompactDirect.mockReset();
mockedCoerceToFailoverError.mockReset();
mockedDescribeFailoverError.mockReset();
@@ -50,6 +52,7 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
compacted: false,
reason: "nothing to compact",
});
+ mockedRunContextEngineMaintenance.mockResolvedValue(undefined);
mockedCoerceToFailoverError.mockReturnValue(null);
mockedDescribeFailoverError.mockImplementation((err: unknown) => ({
message: err instanceof Error ? err.message : String(err),
@@ -241,6 +244,37 @@ describe("runEmbeddedPiAgent overflow compaction trigger routing", () => {
);
});
+ it("runs maintenance after successful overflow-recovery compaction", async () => {
+ mockedContextEngine.info.ownsCompaction = true;
+ mockedRunEmbeddedAttempt
+ .mockResolvedValueOnce(makeAttemptResult({ promptError: makeOverflowError() }))
+ .mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
+ mockedCompactDirect.mockResolvedValueOnce({
+ ok: true,
+ compacted: true,
+ result: {
+ summary: "engine-owned compaction",
+ tokensAfter: 50,
+ },
+ });
+
+ await runEmbeddedPiAgent(overflowBaseRunParams);
+
+ expect(mockedRunContextEngineMaintenance).toHaveBeenCalledWith(
+ expect.objectContaining({
+ contextEngine: mockedContextEngine,
+ sessionId: "test-session",
+ sessionKey: "test-key",
+ sessionFile: "/tmp/session.json",
+ reason: "compaction",
+ runtimeContext: expect.objectContaining({
+ trigger: "overflow",
+ authProfileId: "test-profile",
+ }),
+ }),
+ );
+ });
+
it("guards thrown engine-owned overflow compaction attempts", async () => {
mockedContextEngine.info.ownsCompaction = true;
mockedGlobalHookRunner.hasHooks.mockImplementation(
diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts
index a35c03d98ca..0c66203992f 100644
--- a/src/agents/pi-embedded-runner/run.ts
+++ b/src/agents/pi-embedded-runner/run.ts
@@ -66,6 +66,7 @@ import { ensureRuntimePluginsLoaded } from "../runtime-plugins.js";
import { derivePromptTokens, normalizeUsage, type UsageLike } from "../usage.js";
import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js";
import { buildEmbeddedCompactionRuntimeContext } from "./compaction-runtime-context.js";
+import { runContextEngineMaintenance } from "./context-engine-maintenance.js";
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
import { log } from "./logger.js";
import { resolveModelAsync } from "./model.js";
@@ -1131,6 +1132,39 @@ export async function runEmbeddedPiAgent(
}
}
try {
+ const overflowCompactionRuntimeContext = {
+ ...buildEmbeddedCompactionRuntimeContext({
+ sessionKey: params.sessionKey,
+ messageChannel: params.messageChannel,
+ messageProvider: params.messageProvider,
+ agentAccountId: params.agentAccountId,
+ currentChannelId: params.currentChannelId,
+ currentThreadTs: params.currentThreadTs,
+ currentMessageId: params.currentMessageId,
+ authProfileId: lastProfileId,
+ workspaceDir: resolvedWorkspace,
+ agentDir,
+ config: params.config,
+ skillsSnapshot: params.skillsSnapshot,
+ senderIsOwner: params.senderIsOwner,
+ senderId: params.senderId,
+ provider,
+ modelId,
+ thinkLevel,
+ reasoningLevel: params.reasoningLevel,
+ bashElevated: params.bashElevated,
+ extraSystemPrompt: params.extraSystemPrompt,
+ ownerNumbers: params.ownerNumbers,
+ }),
+ runId: params.runId,
+ trigger: "overflow",
+ ...(observedOverflowTokens !== undefined
+ ? { currentTokenCount: observedOverflowTokens }
+ : {}),
+ diagId: overflowDiagId,
+ attempt: overflowCompactionAttempts,
+ maxAttempts: MAX_OVERFLOW_COMPACTION_ATTEMPTS,
+ };
compactResult = await contextEngine.compact({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
@@ -1141,40 +1175,18 @@ export async function runEmbeddedPiAgent(
: {}),
force: true,
compactionTarget: "budget",
- runtimeContext: {
- ...buildEmbeddedCompactionRuntimeContext({
- sessionKey: params.sessionKey,
- messageChannel: params.messageChannel,
- messageProvider: params.messageProvider,
- agentAccountId: params.agentAccountId,
- currentChannelId: params.currentChannelId,
- currentThreadTs: params.currentThreadTs,
- currentMessageId: params.currentMessageId,
- authProfileId: lastProfileId,
- workspaceDir: resolvedWorkspace,
- agentDir,
- config: params.config,
- skillsSnapshot: params.skillsSnapshot,
- senderIsOwner: params.senderIsOwner,
- senderId: params.senderId,
- provider,
- modelId,
- thinkLevel,
- reasoningLevel: params.reasoningLevel,
- bashElevated: params.bashElevated,
- extraSystemPrompt: params.extraSystemPrompt,
- ownerNumbers: params.ownerNumbers,
- }),
- runId: params.runId,
- trigger: "overflow",
- ...(observedOverflowTokens !== undefined
- ? { currentTokenCount: observedOverflowTokens }
- : {}),
- diagId: overflowDiagId,
- attempt: overflowCompactionAttempts,
- maxAttempts: MAX_OVERFLOW_COMPACTION_ATTEMPTS,
- },
+ runtimeContext: overflowCompactionRuntimeContext,
});
+ if (compactResult.ok && compactResult.compacted) {
+ await runContextEngineMaintenance({
+ contextEngine,
+ sessionId: params.sessionId,
+ sessionKey: params.sessionKey,
+ sessionFile: params.sessionFile,
+ reason: "compaction",
+ runtimeContext: overflowCompactionRuntimeContext,
+ });
+ }
} catch (compactErr) {
log.warn(
`contextEngine.compact() threw during overflow recovery for ${provider}/${modelId}: ${String(compactErr)}`,
diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts
index 082442045d3..20617816e6e 100644
--- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts
+++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test.ts
@@ -40,6 +40,7 @@ const hoisted = vi.hoisted(() => {
}));
const getGlobalHookRunnerMock = vi.fn<() => unknown>(() => undefined);
const initializeGlobalHookRunnerMock = vi.fn();
+ const runContextEngineMaintenanceMock = vi.fn(async (_params?: unknown) => undefined);
const sessionManager = {
getLeafEntry: vi.fn(() => null),
branch: vi.fn(),
@@ -57,6 +58,7 @@ const hoisted = vi.hoisted(() => {
resolveBootstrapContextForRunMock,
getGlobalHookRunnerMock,
initializeGlobalHookRunnerMock,
+ runContextEngineMaintenanceMock,
sessionManager,
};
});
@@ -126,6 +128,10 @@ vi.mock("../skills-runtime.js", () => ({
}),
}));
+vi.mock("../context-engine-maintenance.js", () => ({
+ runContextEngineMaintenance: (params: unknown) => hoisted.runContextEngineMaintenanceMock(params),
+}));
+
vi.mock("../../docs-path.js", () => ({
resolveOpenClawDocsPath: async () => undefined,
}));
@@ -300,6 +306,7 @@ function resetEmbeddedAttemptHarness(
contextFiles: [],
});
hoisted.getGlobalHookRunnerMock.mockReset().mockReturnValue(undefined);
+ hoisted.runContextEngineMaintenanceMock.mockReset().mockResolvedValue(undefined);
hoisted.sessionManager.getLeafEntry.mockReset().mockReturnValue(null);
hoisted.sessionManager.branch.mockReset();
hoisted.sessionManager.resetLeaf.mockReset();
@@ -852,4 +859,55 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
}),
).toBe(true);
});
+
+ it("skips maintenance when afterTurn fails", async () => {
+ const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble();
+ const afterTurn = vi.fn(async () => {
+ throw new Error("afterTurn failed");
+ });
+
+ const result = await runAttemptWithContextEngine({
+ bootstrap,
+ assemble,
+ afterTurn,
+ });
+
+ expect(result.promptError).toBeNull();
+ expect(afterTurn).toHaveBeenCalled();
+ expect(hoisted.runContextEngineMaintenanceMock).not.toHaveBeenCalledWith(
+ expect.objectContaining({ reason: "turn" }),
+ );
+ });
+
+ it("runs startup maintenance for existing sessions even without bootstrap()", async () => {
+ const { assemble } = createContextEngineBootstrapAndAssemble();
+
+ const result = await runAttemptWithContextEngine({
+ assemble,
+ });
+
+ expect(result.promptError).toBeNull();
+ expect(hoisted.runContextEngineMaintenanceMock).toHaveBeenCalledWith(
+ expect.objectContaining({ reason: "bootstrap" }),
+ );
+ });
+
+ it("skips maintenance when ingestBatch fails", async () => {
+ const { bootstrap, assemble } = createContextEngineBootstrapAndAssemble();
+ const ingestBatch = vi.fn(async () => {
+ throw new Error("ingestBatch failed");
+ });
+
+ const result = await runAttemptWithContextEngine({
+ bootstrap,
+ assemble,
+ ingestBatch,
+ });
+
+ expect(result.promptError).toBeNull();
+ expect(ingestBatch).toHaveBeenCalled();
+ expect(hoisted.runContextEngineMaintenanceMock).not.toHaveBeenCalledWith(
+ expect.objectContaining({ reason: "turn" }),
+ );
+ });
});
diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts
index 20bf752587b..39b2abe4da7 100644
--- a/src/agents/pi-embedded-runner/run/attempt.test.ts
+++ b/src/agents/pi-embedded-runner/run/attempt.test.ts
@@ -16,6 +16,7 @@ import {
decodeHtmlEntitiesInObject,
wrapOllamaCompatNumCtx,
wrapStreamFnRepairMalformedToolCallArguments,
+ wrapStreamFnSanitizeMalformedToolCalls,
wrapStreamFnTrimToolCallNames,
} from "./attempt.js";
@@ -779,6 +780,552 @@ describe("wrapStreamFnTrimToolCallNames", () => {
});
});
+describe("wrapStreamFnSanitizeMalformedToolCalls", () => {
+ it("drops malformed assistant tool calls from outbound context before provider replay", async () => {
+ const messages = [
+ {
+ role: "assistant",
+ stopReason: "error",
+ content: [{ type: "toolCall", name: "read", arguments: {} }],
+ },
+ {
+ role: "user",
+ content: [{ type: "text", text: "retry" }],
+ },
+ ];
+ const baseFn = vi.fn((_model, _context) =>
+ createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
+ );
+
+ const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
+ const stream = wrapped({} as never, { messages } as never, {} as never) as
+ | FakeWrappedStream
+ | Promise;
+ await Promise.resolve(stream);
+
+ expect(baseFn).toHaveBeenCalledTimes(1);
+ const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] };
+ expect(seenContext.messages).toEqual([
+ {
+ role: "user",
+ content: [{ type: "text", text: "retry" }],
+ },
+ ]);
+ expect(seenContext.messages).not.toBe(messages);
+ });
+
+ it("preserves outbound context when all assistant tool calls are valid", async () => {
+ const messages = [
+ {
+ role: "assistant",
+ content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
+ },
+ ];
+ const baseFn = vi.fn((_model, _context) =>
+ createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
+ );
+
+ const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
+ const stream = wrapped({} as never, { messages } as never, {} as never) as
+ | FakeWrappedStream
+ | Promise;
+ await Promise.resolve(stream);
+
+ expect(baseFn).toHaveBeenCalledTimes(1);
+ const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] };
+ expect(seenContext.messages).toBe(messages);
+ });
+
+ it("preserves sessions_spawn attachment payloads on replay", async () => {
+ const attachmentContent = "INLINE_ATTACHMENT_PAYLOAD";
+ const messages = [
+ {
+ role: "assistant",
+ content: [
+ {
+ type: "toolUse",
+ id: "call_1",
+ name: " SESSIONS_SPAWN ",
+ input: {
+ task: "inspect attachment",
+ attachments: [{ name: "snapshot.txt", content: attachmentContent }],
+ },
+ },
+ ],
+ },
+ ];
+ const baseFn = vi.fn((_model, _context) =>
+ createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
+ );
+
+ const wrapped = wrapStreamFnSanitizeMalformedToolCalls(
+ baseFn as never,
+ new Set(["sessions_spawn"]),
+ );
+ const stream = wrapped({} as never, { messages } as never, {} as never) as
+ | FakeWrappedStream
+ | Promise;
+ await Promise.resolve(stream);
+
+ expect(baseFn).toHaveBeenCalledTimes(1);
+ const seenContext = baseFn.mock.calls[0]?.[1] as {
+ messages: Array<{ content?: Array> }>;
+ };
+ const toolCall = seenContext.messages[0]?.content?.[0] as {
+ name?: string;
+ input?: { attachments?: Array<{ content?: string }> };
+ };
+ expect(toolCall.name).toBe("sessions_spawn");
+ expect(toolCall.input?.attachments?.[0]?.content).toBe(attachmentContent);
+ });
+
+ it("preserves allowlisted tool names that contain punctuation", async () => {
+ const messages = [
+ {
+ role: "assistant",
+ content: [{ type: "toolUse", id: "call_1", name: "admin.export", input: { scope: "all" } }],
+ },
+ ];
+ const baseFn = vi.fn((_model, _context) =>
+ createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
+ );
+
+ const wrapped = wrapStreamFnSanitizeMalformedToolCalls(
+ baseFn as never,
+ new Set(["admin.export"]),
+ );
+ const stream = wrapped({} as never, { messages } as never, {} as never) as
+ | FakeWrappedStream
+ | Promise;
+ await Promise.resolve(stream);
+
+ expect(baseFn).toHaveBeenCalledTimes(1);
+ const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] };
+ expect(seenContext.messages).toBe(messages);
+ });
+
+ it("normalizes provider-prefixed replayed tool names before provider replay", async () => {
+ const messages = [
+ {
+ role: "assistant",
+ content: [{ type: "toolUse", id: "call_1", name: "functions.read", input: { path: "." } }],
+ },
+ ];
+ const baseFn = vi.fn((_model, _context) =>
+ createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
+ );
+
+ const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
+ const stream = wrapped({} as never, { messages } as never, {} as never) as
+ | FakeWrappedStream
+ | Promise;
+ await Promise.resolve(stream);
+
+ expect(baseFn).toHaveBeenCalledTimes(1);
+ const seenContext = baseFn.mock.calls[0]?.[1] as {
+ messages: Array<{ content?: Array<{ name?: string }> }>;
+ };
+ expect(seenContext.messages[0]?.content?.[0]?.name).toBe("read");
+ });
+
+ it("canonicalizes mixed-case allowlisted tool names on replay", async () => {
+ const messages = [
+ {
+ role: "assistant",
+ content: [{ type: "toolCall", id: "call_1", name: "readfile", arguments: {} }],
+ },
+ ];
+ const baseFn = vi.fn((_model, _context) =>
+ createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
+ );
+
+ const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["ReadFile"]));
+ const stream = wrapped({} as never, { messages } as never, {} as never) as
+ | FakeWrappedStream
+ | Promise;
+ await Promise.resolve(stream);
+
+ expect(baseFn).toHaveBeenCalledTimes(1);
+ const seenContext = baseFn.mock.calls[0]?.[1] as {
+ messages: Array<{ content?: Array<{ name?: string }> }>;
+ };
+ expect(seenContext.messages[0]?.content?.[0]?.name).toBe("ReadFile");
+ });
+
+ it("recovers blank replayed tool names from their ids", async () => {
+ const messages = [
+ {
+ role: "assistant",
+ content: [{ type: "toolCall", id: "functionswrite4", name: " ", arguments: {} }],
+ },
+ ];
+ const baseFn = vi.fn((_model, _context) =>
+ createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
+ );
+
+ const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["write"]));
+ const stream = wrapped({} as never, { messages } as never, {} as never) as
+ | FakeWrappedStream
+ | Promise;
+ await Promise.resolve(stream);
+
+ expect(baseFn).toHaveBeenCalledTimes(1);
+ const seenContext = baseFn.mock.calls[0]?.[1] as {
+ messages: Array<{ content?: Array<{ name?: string }> }>;
+ };
+ expect(seenContext.messages[0]?.content?.[0]?.name).toBe("write");
+ });
+
+ it("recovers mangled replayed tool names before dropping the call", async () => {
+ const messages = [
+ {
+ role: "assistant",
+ content: [{ type: "toolCall", id: "call_1", name: "functionsread3", arguments: {} }],
+ },
+ ];
+ const baseFn = vi.fn((_model, _context) =>
+ createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
+ );
+
+ const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
+ const stream = wrapped({} as never, { messages } as never, {} as never) as
+ | FakeWrappedStream
+ | Promise;
+ await Promise.resolve(stream);
+
+ expect(baseFn).toHaveBeenCalledTimes(1);
+ const seenContext = baseFn.mock.calls[0]?.[1] as {
+ messages: Array<{ content?: Array<{ name?: string }> }>;
+ };
+ expect(seenContext.messages[0]?.content?.[0]?.name).toBe("read");
+ });
+
+ it("drops orphaned tool results after replay sanitization removes a tool-call turn", async () => {
+ const messages = [
+ {
+ role: "assistant",
+ content: [{ type: "toolCall", name: "read", arguments: {} }],
+ stopReason: "error",
+ },
+ {
+ role: "toolResult",
+ toolCallId: "call_missing",
+ toolName: "read",
+ content: [{ type: "text", text: "stale result" }],
+ isError: false,
+ },
+ {
+ role: "user",
+ content: [{ type: "text", text: "retry" }],
+ },
+ ];
+ const baseFn = vi.fn((_model, _context) =>
+ createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
+ );
+
+ const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
+ const stream = wrapped({} as never, { messages } as never, {} as never) as
+ | FakeWrappedStream
+ | Promise;
+ await Promise.resolve(stream);
+
+ expect(baseFn).toHaveBeenCalledTimes(1);
+ const seenContext = baseFn.mock.calls[0]?.[1] as {
+ messages: Array<{ role?: string }>;
+ };
+ expect(seenContext.messages).toEqual([
+ {
+ role: "user",
+ content: [{ type: "text", text: "retry" }],
+ },
+ ]);
+ });
+
+ it("drops replayed tool calls that are no longer allowlisted", async () => {
+ const messages = [
+ {
+ role: "assistant",
+ content: [{ type: "toolCall", id: "call_1", name: "write", arguments: {} }],
+ },
+ {
+ role: "toolResult",
+ toolCallId: "call_1",
+ toolName: "write",
+ content: [{ type: "text", text: "stale result" }],
+ isError: false,
+ },
+ {
+ role: "user",
+ content: [{ type: "text", text: "retry" }],
+ },
+ ];
+ const baseFn = vi.fn((_model, _context) =>
+ createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
+ );
+
+ const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
+ const stream = wrapped({} as never, { messages } as never, {} as never) as
+ | FakeWrappedStream
+ | Promise;
+ await Promise.resolve(stream);
+
+ expect(baseFn).toHaveBeenCalledTimes(1);
+ const seenContext = baseFn.mock.calls[0]?.[1] as {
+ messages: Array<{ role?: string }>;
+ };
+ expect(seenContext.messages).toEqual([
+ {
+ role: "user",
+ content: [{ type: "text", text: "retry" }],
+ },
+ ]);
+ });
+ it("drops replayed tool names that are no longer allowlisted", async () => {
+ const messages = [
+ {
+ role: "assistant",
+ content: [{ type: "toolUse", id: "call_1", name: "unknown_tool", input: { path: "." } }],
+ },
+ {
+ role: "toolResult",
+ toolCallId: "call_1",
+ toolName: "unknown_tool",
+ content: [{ type: "text", text: "stale result" }],
+ isError: false,
+ },
+ ];
+ const baseFn = vi.fn((_model, _context) =>
+ createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
+ );
+
+ const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
+ const stream = wrapped({} as never, { messages } as never, {} as never) as
+ | FakeWrappedStream
+ | Promise;
+ await Promise.resolve(stream);
+
+ expect(baseFn).toHaveBeenCalledTimes(1);
+ const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] };
+ expect(seenContext.messages).toEqual([]);
+ });
+
+ it("drops ambiguous mangled replay names instead of guessing a tool", async () => {
+ const messages = [
+ {
+ role: "assistant",
+ content: [{ type: "toolCall", id: "call_1", name: "functions.exec2", arguments: {} }],
+ },
+ ];
+ const baseFn = vi.fn((_model, _context) =>
+ createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
+ );
+
+ const wrapped = wrapStreamFnSanitizeMalformedToolCalls(
+ baseFn as never,
+ new Set(["exec", "exec2"]),
+ );
+ const stream = wrapped({} as never, { messages } as never, {} as never) as
+ | FakeWrappedStream
+ | Promise;
+ await Promise.resolve(stream);
+
+ expect(baseFn).toHaveBeenCalledTimes(1);
+ const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] };
+ expect(seenContext.messages).toEqual([]);
+ });
+
+ it("preserves matching tool results for retained errored assistant turns", async () => {
+ const messages = [
+ {
+ role: "assistant",
+ stopReason: "error",
+ content: [
+ { type: "toolCall", id: "call_1", name: "read", arguments: {} },
+ { type: "toolCall", name: "read", arguments: {} },
+ ],
+ },
+ {
+ role: "toolResult",
+ toolCallId: "call_1",
+ toolName: "read",
+ content: [{ type: "text", text: "kept result" }],
+ isError: false,
+ },
+ {
+ role: "user",
+ content: [{ type: "text", text: "retry" }],
+ },
+ ];
+ const baseFn = vi.fn((_model, _context) =>
+ createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
+ );
+
+ const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]));
+ const stream = wrapped({} as never, { messages } as never, {} as never) as
+ | FakeWrappedStream
+ | Promise;
+ await Promise.resolve(stream);
+
+ expect(baseFn).toHaveBeenCalledTimes(1);
+ const seenContext = baseFn.mock.calls[0]?.[1] as { messages: unknown[] };
+ expect(seenContext.messages).toEqual([
+ {
+ role: "assistant",
+ stopReason: "error",
+ content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
+ },
+ {
+ role: "toolResult",
+ toolCallId: "call_1",
+ toolName: "read",
+ content: [{ type: "text", text: "kept result" }],
+ isError: false,
+ },
+ {
+ role: "user",
+ content: [{ type: "text", text: "retry" }],
+ },
+ ]);
+ });
+
+ it("revalidates turn ordering after dropping an assistant replay turn", async () => {
+ const messages = [
+ {
+ role: "user",
+ content: [{ type: "text", text: "first" }],
+ },
+ {
+ role: "assistant",
+ stopReason: "error",
+ content: [{ type: "toolCall", name: "read", arguments: {} }],
+ },
+ {
+ role: "user",
+ content: [{ type: "text", text: "second" }],
+ },
+ ];
+ const baseFn = vi.fn((_model, _context) =>
+ createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
+ );
+
+ const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]), {
+ validateGeminiTurns: false,
+ validateAnthropicTurns: true,
+ });
+ const stream = wrapped({} as never, { messages } as never, {} as never) as
+ | FakeWrappedStream
+ | Promise;
+ await Promise.resolve(stream);
+
+ expect(baseFn).toHaveBeenCalledTimes(1);
+ const seenContext = baseFn.mock.calls[0]?.[1] as {
+ messages: Array<{ role?: string; content?: unknown[] }>;
+ };
+ expect(seenContext.messages).toEqual([
+ {
+ role: "user",
+ content: [
+ { type: "text", text: "first" },
+ { type: "text", text: "second" },
+ ],
+ },
+ ]);
+ });
+
+ it("drops orphaned Anthropic user tool_result blocks after replay sanitization", async () => {
+ const messages = [
+ {
+ role: "assistant",
+ content: [
+ { type: "text", text: "partial response" },
+ { type: "toolUse", name: "read", input: { path: "." } },
+ ],
+ },
+ {
+ role: "user",
+ content: [
+ { type: "toolResult", toolUseId: "call_1", content: [{ type: "text", text: "stale" }] },
+ { type: "text", text: "retry" },
+ ],
+ },
+ ];
+ const baseFn = vi.fn((_model, _context) =>
+ createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
+ );
+
+ const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]), {
+ validateGeminiTurns: false,
+ validateAnthropicTurns: true,
+ });
+ const stream = wrapped({} as never, { messages } as never, {} as never) as
+ | FakeWrappedStream
+ | Promise;
+ await Promise.resolve(stream);
+
+ expect(baseFn).toHaveBeenCalledTimes(1);
+ const seenContext = baseFn.mock.calls[0]?.[1] as {
+ messages: Array<{ role?: string; content?: unknown[] }>;
+ };
+ expect(seenContext.messages).toEqual([
+ {
+ role: "assistant",
+ content: [{ type: "text", text: "partial response" }],
+ },
+ {
+ role: "user",
+ content: [{ type: "text", text: "retry" }],
+ },
+ ]);
+ });
+
+ it("drops orphaned Anthropic user tool_result blocks after dropping an assistant replay turn", async () => {
+ const messages = [
+ {
+ role: "user",
+ content: [{ type: "text", text: "first" }],
+ },
+ {
+ role: "assistant",
+ stopReason: "error",
+ content: [{ type: "toolUse", name: "read", input: { path: "." } }],
+ },
+ {
+ role: "user",
+ content: [
+ { type: "toolResult", toolUseId: "call_1", content: [{ type: "text", text: "stale" }] },
+ { type: "text", text: "second" },
+ ],
+ },
+ ];
+ const baseFn = vi.fn((_model, _context) =>
+ createFakeStream({ events: [], resultMessage: { role: "assistant", content: [] } }),
+ );
+
+ const wrapped = wrapStreamFnSanitizeMalformedToolCalls(baseFn as never, new Set(["read"]), {
+ validateGeminiTurns: false,
+ validateAnthropicTurns: true,
+ });
+ const stream = wrapped({} as never, { messages } as never, {} as never) as
+ | FakeWrappedStream
+ | Promise;
+ await Promise.resolve(stream);
+
+ expect(baseFn).toHaveBeenCalledTimes(1);
+ const seenContext = baseFn.mock.calls[0]?.[1] as {
+ messages: Array<{ role?: string; content?: unknown[] }>;
+ };
+ expect(seenContext.messages).toEqual([
+ {
+ role: "user",
+ content: [
+ { type: "text", text: "first" },
+ { type: "text", text: "second" },
+ ],
+ },
+ ]);
+ });
+});
+
describe("wrapStreamFnRepairMalformedToolCallArguments", () => {
async function invokeWrappedStream(baseFn: (...args: never[]) => unknown) {
return await invokeWrappedTestStream(
diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts
index 71db23d0f5b..d785218f819 100644
--- a/src/agents/pi-embedded-runner/run/attempt.ts
+++ b/src/agents/pi-embedded-runner/run/attempt.ts
@@ -10,7 +10,7 @@ import {
import {
resolveTelegramInlineButtonsScope,
resolveTelegramReactionLevel,
-} from "openclaw/plugin-sdk/telegram";
+} from "../../../../extensions/telegram/api.js";
import { resolveHeartbeatPrompt } from "../../../auto-reply/heartbeat.js";
import { resolveChannelCapabilities } from "../../../config/channel-capabilities.js";
import type { OpenClawConfig } from "../../../config/config.js";
@@ -36,6 +36,7 @@ import { isReasoningTagProvider } from "../../../utils/provider-utils.js";
import { resolveOpenClawAgentDir } from "../../agent-paths.js";
import { resolveSessionAgentIds } from "../../agent-scope.js";
import { createAnthropicPayloadLogger } from "../../anthropic-payload-log.js";
+import { createAnthropicVertexStreamFnForModel } from "../../anthropic-vertex-stream.js";
import {
analyzeBootstrapBudget,
buildBootstrapPromptWarning,
@@ -97,6 +98,7 @@ import { buildSystemPromptReport } from "../../system-prompt-report.js";
import { sanitizeToolCallIdsForCloudCodeAssist } from "../../tool-call-id.js";
import { resolveEffectiveToolFsWorkspaceOnly } from "../../tool-fs-policy.js";
import { normalizeToolName } from "../../tool-policy.js";
+import type { TranscriptPolicy } from "../../transcript-policy.js";
import { resolveTranscriptPolicy } from "../../transcript-policy.js";
import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js";
import { isRunnerAbortError } from "../abort.js";
@@ -104,6 +106,7 @@ import { appendCacheTtlTimestamp, isCacheTtlEligibleProvider } from "../cache-tt
import type { CompactEmbeddedPiSessionParams } from "../compact.js";
import { buildEmbeddedCompactionRuntimeContext } from "../compaction-runtime-context.js";
import { resolveCompactionTimeoutMs } from "../compaction-safety-timeout.js";
+import { runContextEngineMaintenance } from "../context-engine-maintenance.js";
import { buildEmbeddedExtensionFactories } from "../extensions.js";
import { applyExtraParamsToAgent } from "../extra-params.js";
import {
@@ -648,6 +651,200 @@ function isToolCallBlockType(type: unknown): boolean {
return type === "toolCall" || type === "toolUse" || type === "functionCall";
}
+const REPLAY_TOOL_CALL_NAME_MAX_CHARS = 64;
+
+type ReplayToolCallBlock = {
+ type?: unknown;
+ id?: unknown;
+ name?: unknown;
+ input?: unknown;
+ arguments?: unknown;
+};
+
+type ReplayToolCallSanitizeReport = {
+ messages: AgentMessage[];
+ droppedAssistantMessages: number;
+};
+
+type AnthropicToolResultContentBlock = {
+ type?: unknown;
+ toolUseId?: unknown;
+};
+
+function isReplayToolCallBlock(block: unknown): block is ReplayToolCallBlock {
+ if (!block || typeof block !== "object") {
+ return false;
+ }
+ return isToolCallBlockType((block as { type?: unknown }).type);
+}
+
+function replayToolCallHasInput(block: ReplayToolCallBlock): boolean {
+ const hasInput = "input" in block ? block.input !== undefined && block.input !== null : false;
+ const hasArguments =
+ "arguments" in block ? block.arguments !== undefined && block.arguments !== null : false;
+ return hasInput || hasArguments;
+}
+
+function replayToolCallNonEmptyString(value: unknown): value is string {
+ return typeof value === "string" && value.trim().length > 0;
+}
+
+function resolveReplayToolCallName(
+ rawName: string,
+ rawId: string,
+ allowedToolNames?: Set,
+): string | null {
+ if (rawName.length > REPLAY_TOOL_CALL_NAME_MAX_CHARS * 2) {
+ return null;
+ }
+ const normalized = normalizeToolCallNameForDispatch(rawName, allowedToolNames, rawId);
+ const trimmed = normalized.trim();
+ if (!trimmed || trimmed.length > REPLAY_TOOL_CALL_NAME_MAX_CHARS || /\s/.test(trimmed)) {
+ return null;
+ }
+ if (!allowedToolNames || allowedToolNames.size === 0) {
+ return trimmed;
+ }
+ return resolveExactAllowedToolName(trimmed, allowedToolNames);
+}
+
+function sanitizeReplayToolCallInputs(
+ messages: AgentMessage[],
+ allowedToolNames?: Set,
+): ReplayToolCallSanitizeReport {
+ let changed = false;
+ let droppedAssistantMessages = 0;
+ const out: AgentMessage[] = [];
+
+ for (const message of messages) {
+ if (!message || typeof message !== "object" || message.role !== "assistant") {
+ out.push(message);
+ continue;
+ }
+ if (!Array.isArray(message.content)) {
+ out.push(message);
+ continue;
+ }
+
+ const nextContent: typeof message.content = [];
+ let messageChanged = false;
+
+ for (const block of message.content) {
+ if (!isReplayToolCallBlock(block)) {
+ nextContent.push(block);
+ continue;
+ }
+ const replayBlock = block as ReplayToolCallBlock;
+
+ if (!replayToolCallHasInput(replayBlock) || !replayToolCallNonEmptyString(replayBlock.id)) {
+ changed = true;
+ messageChanged = true;
+ continue;
+ }
+
+ const rawName = typeof replayBlock.name === "string" ? replayBlock.name : "";
+ const resolvedName = resolveReplayToolCallName(rawName, replayBlock.id, allowedToolNames);
+ if (!resolvedName) {
+ changed = true;
+ messageChanged = true;
+ continue;
+ }
+
+ if (replayBlock.name !== resolvedName) {
+ nextContent.push({ ...(block as object), name: resolvedName } as typeof block);
+ changed = true;
+ messageChanged = true;
+ continue;
+ }
+ nextContent.push(block);
+ }
+
+ if (messageChanged) {
+ changed = true;
+ if (nextContent.length > 0) {
+ out.push({ ...message, content: nextContent });
+ } else {
+ droppedAssistantMessages += 1;
+ }
+ continue;
+ }
+
+ out.push(message);
+ }
+
+ return {
+ messages: changed ? out : messages,
+ droppedAssistantMessages,
+ };
+}
+
+function sanitizeAnthropicReplayToolResults(messages: AgentMessage[]): AgentMessage[] {
+ let changed = false;
+ const out: AgentMessage[] = [];
+
+ for (let index = 0; index < messages.length; index += 1) {
+ const message = messages[index];
+ if (!message || typeof message !== "object" || message.role !== "user") {
+ out.push(message);
+ continue;
+ }
+ if (!Array.isArray(message.content)) {
+ out.push(message);
+ continue;
+ }
+
+ const previous = messages[index - 1];
+ const validToolUseIds = new Set();
+ if (previous && typeof previous === "object" && previous.role === "assistant") {
+ const previousContent = (previous as { content?: unknown }).content;
+ if (Array.isArray(previousContent)) {
+ for (const block of previousContent) {
+ if (!block || typeof block !== "object") {
+ continue;
+ }
+ const typedBlock = block as { type?: unknown; id?: unknown };
+ if (typedBlock.type !== "toolUse" || typeof typedBlock.id !== "string") {
+ continue;
+ }
+ const trimmedId = typedBlock.id.trim();
+ if (trimmedId) {
+ validToolUseIds.add(trimmedId);
+ }
+ }
+ }
+ }
+
+ const nextContent = message.content.filter((block) => {
+ if (!block || typeof block !== "object") {
+ return true;
+ }
+ const typedBlock = block as AnthropicToolResultContentBlock;
+ if (typedBlock.type !== "toolResult" || typeof typedBlock.toolUseId !== "string") {
+ return true;
+ }
+ return validToolUseIds.size > 0 && validToolUseIds.has(typedBlock.toolUseId);
+ });
+
+ if (nextContent.length === message.content.length) {
+ out.push(message);
+ continue;
+ }
+
+ changed = true;
+ if (nextContent.length > 0) {
+ out.push({ ...message, content: nextContent });
+ continue;
+ }
+
+ out.push({
+ ...message,
+ content: [{ type: "text", text: "[tool results omitted]" }],
+ } as AgentMessage);
+ }
+
+ return changed ? out : messages;
+}
+
function normalizeToolCallIdsInMessage(message: unknown): void {
if (!message || typeof message !== "object") {
return;
@@ -796,6 +993,43 @@ export function wrapStreamFnTrimToolCallNames(
};
}
+export function wrapStreamFnSanitizeMalformedToolCalls(
+ baseFn: StreamFn,
+ allowedToolNames?: Set,
+ transcriptPolicy?: Pick,
+): StreamFn {
+ return (model, context, options) => {
+ const ctx = context as unknown as { messages?: unknown };
+ const messages = ctx?.messages;
+ if (!Array.isArray(messages)) {
+ return baseFn(model, context, options);
+ }
+ const sanitized = sanitizeReplayToolCallInputs(messages as AgentMessage[], allowedToolNames);
+ if (sanitized.messages === messages) {
+ return baseFn(model, context, options);
+ }
+ let nextMessages = sanitizeToolUseResultPairing(sanitized.messages, {
+ preserveErroredAssistantResults: true,
+ });
+ if (transcriptPolicy?.validateAnthropicTurns) {
+ nextMessages = sanitizeAnthropicReplayToolResults(nextMessages);
+ }
+ if (sanitized.droppedAssistantMessages > 0 || transcriptPolicy?.validateAnthropicTurns) {
+ if (transcriptPolicy?.validateGeminiTurns) {
+ nextMessages = validateGeminiTurns(nextMessages);
+ }
+ if (transcriptPolicy?.validateAnthropicTurns) {
+ nextMessages = validateAnthropicTurns(nextMessages);
+ }
+ }
+ const nextContext = {
+ ...(context as unknown as Record),
+ messages: nextMessages,
+ } as unknown;
+ return baseFn(model, nextContext as typeof context, options);
+ };
+}
+
function extractBalancedJsonPrefix(raw: string): string | null {
let start = 0;
while (start < raw.length && /\s/.test(raw[start] ?? "")) {
@@ -1802,12 +2036,27 @@ export async function runEmbeddedAttempt(
});
trackSessionManagerAccess(params.sessionFile);
- if (hadSessionFile && params.contextEngine?.bootstrap) {
+ if (hadSessionFile && (params.contextEngine?.bootstrap || params.contextEngine?.maintain)) {
try {
- await params.contextEngine.bootstrap({
+ if (typeof params.contextEngine?.bootstrap === "function") {
+ await params.contextEngine.bootstrap({
+ sessionId: params.sessionId,
+ sessionKey: params.sessionKey,
+ sessionFile: params.sessionFile,
+ });
+ }
+ await runContextEngineMaintenance({
+ contextEngine: params.contextEngine,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionFile: params.sessionFile,
+ reason: "bootstrap",
+ sessionManager,
+ runtimeContext: buildAfterTurnRuntimeContext({
+ attempt: params,
+ workspaceDir: effectiveWorkspace,
+ agentDir,
+ }),
});
} catch (bootstrapErr) {
log.warn(`context engine bootstrap failed: ${String(bootstrapErr)}`);
@@ -1964,6 +2213,10 @@ export async function runEmbeddedAttempt(
log.warn(`[ws-stream] no API key for provider=${params.provider}; using HTTP transport`);
activeSession.agent.streamFn = streamSimple;
}
+ } else if (params.model.provider === "anthropic-vertex") {
+ // Anthropic Vertex AI: inject AnthropicVertex client into pi-ai's
+ // streamAnthropic for GCP IAM auth instead of Anthropic API keys.
+ activeSession.agent.streamFn = createAnthropicVertexStreamFnForModel(params.model);
} else {
// Force a stable streamFn reference so vitest can reliably mock @mariozechner/pi-ai.
activeSession.agent.streamFn = streamSimple;
@@ -2100,6 +2353,11 @@ export async function runEmbeddedAttempt(
// Some models emit tool names with surrounding whitespace (e.g. " read ").
// pi-agent-core dispatches tool calls with exact string matching, so normalize
// names on the live response stream before tool execution.
+ activeSession.agent.streamFn = wrapStreamFnSanitizeMalformedToolCalls(
+ activeSession.agent.streamFn,
+ allowedToolNames,
+ transcriptPolicy,
+ );
activeSession.agent.streamFn = wrapStreamFnTrimToolCallNames(
activeSession.agent.streamFn,
allowedToolNames,
@@ -2168,6 +2426,7 @@ export async function runEmbeddedAttempt(
messages: activeSession.messages,
tokenBudget: params.contextTokenBudget,
model: params.modelId,
+ ...(params.prompt !== undefined ? { prompt: params.prompt } : {}),
});
if (assembled.messages !== activeSession.messages) {
activeSession.agent.replaceMessages(assembled.messages);
@@ -2736,6 +2995,7 @@ export async function runEmbeddedAttempt(
workspaceDir: effectiveWorkspace,
agentDir,
});
+ let postTurnFinalizationSucceeded = true;
if (typeof params.contextEngine.afterTurn === "function") {
try {
@@ -2749,6 +3009,7 @@ export async function runEmbeddedAttempt(
runtimeContext: afterTurnRuntimeContext,
});
} catch (afterTurnErr) {
+ postTurnFinalizationSucceeded = false;
log.warn(`context engine afterTurn failed: ${String(afterTurnErr)}`);
}
} else {
@@ -2763,6 +3024,7 @@ export async function runEmbeddedAttempt(
messages: newMessages,
});
} catch (ingestErr) {
+ postTurnFinalizationSucceeded = false;
log.warn(`context engine ingest failed: ${String(ingestErr)}`);
}
} else {
@@ -2774,12 +3036,25 @@ export async function runEmbeddedAttempt(
message: msg,
});
} catch (ingestErr) {
+ postTurnFinalizationSucceeded = false;
log.warn(`context engine ingest failed: ${String(ingestErr)}`);
}
}
}
}
}
+
+ if (!promptError && !aborted && !yieldAborted && postTurnFinalizationSucceeded) {
+ await runContextEngineMaintenance({
+ contextEngine: params.contextEngine,
+ sessionId: sessionIdUsed,
+ sessionKey: params.sessionKey,
+ sessionFile: params.sessionFile,
+ reason: "turn",
+ sessionManager,
+ runtimeContext: afterTurnRuntimeContext,
+ });
+ }
}
cacheTrace?.recordStage("session:after", {
diff --git a/src/agents/pi-embedded-runner/tool-result-truncation.test.ts b/src/agents/pi-embedded-runner/tool-result-truncation.test.ts
index b65ed0a65e8..016130ff23d 100644
--- a/src/agents/pi-embedded-runner/tool-result-truncation.test.ts
+++ b/src/agents/pi-embedded-runner/tool-result-truncation.test.ts
@@ -1,13 +1,26 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
-import { describe, expect, it } from "vitest";
+import { SessionManager } from "@mariozechner/pi-coding-agent";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js";
+
+const acquireSessionWriteLockReleaseMock = vi.hoisted(() => vi.fn(async () => {}));
+const acquireSessionWriteLockMock = vi.hoisted(() =>
+ vi.fn(async (_params?: unknown) => ({ release: acquireSessionWriteLockReleaseMock })),
+);
+
+vi.mock("../session-write-lock.js", () => ({
+ acquireSessionWriteLock: (params: unknown) => acquireSessionWriteLockMock(params),
+}));
+
import {
truncateToolResultText,
truncateToolResultMessage,
calculateMaxToolResultChars,
getToolResultTextLength,
truncateOversizedToolResultsInMessages,
+ truncateOversizedToolResultsInSession,
isOversizedToolResult,
sessionLikelyHasOversizedToolResults,
HARD_MAX_TOOL_RESULT_CHARS,
@@ -16,6 +29,12 @@ import {
let testTimestamp = 1;
const nextTimestamp = () => testTimestamp++;
+beforeEach(() => {
+ testTimestamp = 1;
+ acquireSessionWriteLockMock.mockClear();
+ acquireSessionWriteLockReleaseMock.mockClear();
+});
+
function makeToolResult(text: string, toolCallId = "call_1"): ToolResultMessage {
return {
role: "toolResult",
@@ -248,6 +267,54 @@ describe("truncateOversizedToolResultsInMessages", () => {
});
});
+describe("truncateOversizedToolResultsInSession", () => {
+ it("acquires the session write lock before rewriting oversized tool results", async () => {
+ const sessionFile = "/tmp/tool-result-truncation-session.jsonl";
+ const sessionManager = SessionManager.inMemory();
+ sessionManager.appendMessage(makeUserMessage("hello"));
+ sessionManager.appendMessage(makeAssistantMessage("reading file"));
+ sessionManager.appendMessage(makeToolResult("x".repeat(500_000)));
+
+ const openSpy = vi
+ .spyOn(SessionManager, "open")
+ .mockReturnValue(sessionManager as unknown as ReturnType);
+ const listener = vi.fn();
+ const cleanup = onSessionTranscriptUpdate(listener);
+
+ try {
+ const result = await truncateOversizedToolResultsInSession({
+ sessionFile,
+ contextWindowTokens: 128_000,
+ sessionKey: "agent:main:test",
+ });
+
+ expect(result.truncated).toBe(true);
+ expect(result.truncatedCount).toBe(1);
+ expect(acquireSessionWriteLockMock).toHaveBeenCalledWith({ sessionFile });
+ expect(acquireSessionWriteLockReleaseMock).toHaveBeenCalledTimes(1);
+ expect(listener).toHaveBeenCalledWith({ sessionFile });
+
+ const branch = sessionManager.getBranch();
+ const rewrittenToolResult = branch.find(
+ (entry) => entry.type === "message" && entry.message.role === "toolResult",
+ );
+ expect(rewrittenToolResult?.type).toBe("message");
+ if (
+ rewrittenToolResult?.type !== "message" ||
+ rewrittenToolResult.message.role !== "toolResult"
+ ) {
+ throw new Error("expected rewritten tool result");
+ }
+ const rewrittenText = getFirstToolResultText(rewrittenToolResult.message);
+ expect(rewrittenText.length).toBeLessThan(500_000);
+ expect(rewrittenText).toContain("truncated");
+ } finally {
+ cleanup();
+ openSpy.mockRestore();
+ }
+ });
+});
+
describe("sessionLikelyHasOversizedToolResults", () => {
it("returns false when no tool results are oversized", () => {
const messages = [makeUserMessage("hello"), makeToolResult("small result")];
diff --git a/src/agents/pi-embedded-runner/tool-result-truncation.ts b/src/agents/pi-embedded-runner/tool-result-truncation.ts
index c8cbd1124bb..675c70228a3 100644
--- a/src/agents/pi-embedded-runner/tool-result-truncation.ts
+++ b/src/agents/pi-embedded-runner/tool-result-truncation.ts
@@ -1,7 +1,10 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import type { TextContent } from "@mariozechner/pi-ai";
import { SessionManager } from "@mariozechner/pi-coding-agent";
+import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
+import { acquireSessionWriteLock } from "../session-write-lock.js";
import { log } from "./logger.js";
+import { rewriteTranscriptEntriesInSessionManager } from "./transcript-rewrite.js";
/**
* Maximum share of the context window a single tool result should occupy.
@@ -211,8 +214,10 @@ export async function truncateOversizedToolResultsInSession(params: {
}): Promise<{ truncated: boolean; truncatedCount: number; reason?: string }> {
const { sessionFile, contextWindowTokens } = params;
const maxChars = calculateMaxToolResultChars(contextWindowTokens);
+ let sessionLock: Awaited> | undefined;
try {
+ sessionLock = await acquireSessionWriteLock({ sessionFile });
const sessionManager = SessionManager.open(sessionFile);
const branch = sessionManager.getBranch();
@@ -246,87 +251,46 @@ export async function truncateOversizedToolResultsInSession(params: {
return { truncated: false, truncatedCount: 0, reason: "no oversized tool results" };
}
- // Branch from the parent of the first oversized entry
- const firstOversizedIdx = oversizedIndices[0];
- const firstOversizedEntry = branch[firstOversizedIdx];
- const branchFromId = firstOversizedEntry.parentId;
-
- if (!branchFromId) {
- // The oversized entry is the root - very unusual but handle it
- sessionManager.resetLeaf();
- } else {
- sessionManager.branch(branchFromId);
- }
-
- // Re-append all entries from the first oversized one onwards,
- // with truncated tool results
- const oversizedSet = new Set(oversizedIndices);
- let truncatedCount = 0;
-
- for (let i = firstOversizedIdx; i < branch.length; i++) {
- const entry = branch[i];
-
- if (entry.type === "message") {
- let message = entry.message;
-
- if (oversizedSet.has(i)) {
- message = truncateToolResultMessage(message, maxChars);
- truncatedCount++;
- const newLength = getToolResultTextLength(message);
- log.info(
- `[tool-result-truncation] Truncated tool result: ` +
- `originalEntry=${entry.id} newChars=${newLength} ` +
- `sessionKey=${params.sessionKey ?? params.sessionId ?? "unknown"}`,
- );
- }
-
- // appendMessage expects Message | CustomMessage | BashExecutionMessage
- sessionManager.appendMessage(message as Parameters[0]);
- } else if (entry.type === "compaction") {
- sessionManager.appendCompaction(
- entry.summary,
- entry.firstKeptEntryId,
- entry.tokensBefore,
- entry.details,
- entry.fromHook,
- );
- } else if (entry.type === "thinking_level_change") {
- sessionManager.appendThinkingLevelChange(entry.thinkingLevel);
- } else if (entry.type === "model_change") {
- sessionManager.appendModelChange(entry.provider, entry.modelId);
- } else if (entry.type === "custom") {
- sessionManager.appendCustomEntry(entry.customType, entry.data);
- } else if (entry.type === "custom_message") {
- sessionManager.appendCustomMessageEntry(
- entry.customType,
- entry.content,
- entry.display,
- entry.details,
- );
- } else if (entry.type === "branch_summary") {
- // Branch summaries reference specific entry IDs - skip to avoid inconsistency
- continue;
- } else if (entry.type === "label") {
- // Labels reference specific entry IDs - skip to avoid inconsistency
- continue;
- } else if (entry.type === "session_info") {
- if (entry.name) {
- sessionManager.appendSessionInfo(entry.name);
- }
+ const replacements = oversizedIndices.flatMap((index) => {
+ const entry = branch[index];
+ if (!entry || entry.type !== "message") {
+ return [];
}
+ const message = truncateToolResultMessage(entry.message, maxChars);
+ const newLength = getToolResultTextLength(message);
+ log.info(
+ `[tool-result-truncation] Truncated tool result: ` +
+ `originalEntry=${entry.id} newChars=${newLength} ` +
+ `sessionKey=${params.sessionKey ?? params.sessionId ?? "unknown"}`,
+ );
+ return [{ entryId: entry.id, message }];
+ });
+
+ const rewriteResult = rewriteTranscriptEntriesInSessionManager({
+ sessionManager,
+ replacements,
+ });
+ if (rewriteResult.changed) {
+ emitSessionTranscriptUpdate(sessionFile);
}
log.info(
- `[tool-result-truncation] Truncated ${truncatedCount} tool result(s) in session ` +
+ `[tool-result-truncation] Truncated ${rewriteResult.rewrittenEntries} tool result(s) in session ` +
`(contextWindow=${contextWindowTokens} maxChars=${maxChars}) ` +
`sessionKey=${params.sessionKey ?? params.sessionId ?? "unknown"}`,
);
- return { truncated: true, truncatedCount };
+ return {
+ truncated: rewriteResult.changed,
+ truncatedCount: rewriteResult.rewrittenEntries,
+ reason: rewriteResult.reason,
+ };
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
log.warn(`[tool-result-truncation] Failed to truncate: ${errMsg}`);
return { truncated: false, truncatedCount: 0, reason: errMsg };
+ } finally {
+ await sessionLock?.release();
}
}
diff --git a/src/agents/pi-embedded-runner/transcript-rewrite.test.ts b/src/agents/pi-embedded-runner/transcript-rewrite.test.ts
new file mode 100644
index 00000000000..0e698244962
--- /dev/null
+++ b/src/agents/pi-embedded-runner/transcript-rewrite.test.ts
@@ -0,0 +1,402 @@
+import type { AgentMessage } from "@mariozechner/pi-agent-core";
+import { SessionManager } from "@mariozechner/pi-coding-agent";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
+import { installSessionToolResultGuard } from "../session-tool-result-guard.js";
+
+const acquireSessionWriteLockReleaseMock = vi.hoisted(() => vi.fn(async () => {}));
+const acquireSessionWriteLockMock = vi.hoisted(() =>
+ vi.fn(async (_params?: unknown) => ({ release: acquireSessionWriteLockReleaseMock })),
+);
+
+vi.mock("../session-write-lock.js", () => ({
+ acquireSessionWriteLock: (params: unknown) => acquireSessionWriteLockMock(params),
+}));
+
+import {
+ rewriteTranscriptEntriesInSessionFile,
+ rewriteTranscriptEntriesInSessionManager,
+} from "./transcript-rewrite.js";
+
+type AppendMessage = Parameters[0];
+
+function asAppendMessage(message: unknown): AppendMessage {
+ return message as AppendMessage;
+}
+
+function getBranchMessages(sessionManager: SessionManager): AgentMessage[] {
+ return sessionManager
+ .getBranch()
+ .filter((entry) => entry.type === "message")
+ .map((entry) => entry.message);
+}
+
+beforeEach(() => {
+ acquireSessionWriteLockMock.mockClear();
+ acquireSessionWriteLockReleaseMock.mockClear();
+});
+
+describe("rewriteTranscriptEntriesInSessionManager", () => {
+ it("branches from the first replaced message and re-appends the remaining suffix", () => {
+ const sessionManager = SessionManager.inMemory();
+ sessionManager.appendMessage(
+ asAppendMessage({
+ role: "user",
+ content: "read file",
+ timestamp: 1,
+ }),
+ );
+ sessionManager.appendMessage(
+ asAppendMessage({
+ role: "assistant",
+ content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
+ timestamp: 2,
+ }),
+ );
+ sessionManager.appendMessage(
+ asAppendMessage({
+ role: "toolResult",
+ toolCallId: "call_1",
+ toolName: "read",
+ content: [{ type: "text", text: "x".repeat(8_000) }],
+ isError: false,
+ timestamp: 3,
+ }),
+ );
+ sessionManager.appendMessage(
+ asAppendMessage({
+ role: "assistant",
+ content: [{ type: "text", text: "summarized" }],
+ timestamp: 4,
+ }),
+ );
+
+ const toolResultEntry = sessionManager
+ .getBranch()
+ .find((entry) => entry.type === "message" && entry.message.role === "toolResult");
+ expect(toolResultEntry).toBeDefined();
+
+ const result = rewriteTranscriptEntriesInSessionManager({
+ sessionManager,
+ replacements: [
+ {
+ entryId: toolResultEntry!.id,
+ message: {
+ role: "toolResult",
+ toolCallId: "call_1",
+ toolName: "read",
+ content: [{ type: "text", text: "[externalized file_123]" }],
+ isError: false,
+ timestamp: 3,
+ },
+ },
+ ],
+ });
+
+ expect(result).toMatchObject({
+ changed: true,
+ rewrittenEntries: 1,
+ });
+ expect(result.bytesFreed).toBeGreaterThan(0);
+
+ const branchMessages = getBranchMessages(sessionManager);
+ expect(branchMessages.map((message) => message.role)).toEqual([
+ "user",
+ "assistant",
+ "toolResult",
+ "assistant",
+ ]);
+ const rewrittenToolResult = branchMessages[2] as Extract;
+ expect(rewrittenToolResult.content).toEqual([
+ { type: "text", text: "[externalized file_123]" },
+ ]);
+ });
+
+ it("preserves active-branch labels after rewritten entries are re-appended", () => {
+ const sessionManager = SessionManager.inMemory();
+ sessionManager.appendMessage(
+ asAppendMessage({
+ role: "user",
+ content: "read file",
+ timestamp: 1,
+ }),
+ );
+ sessionManager.appendMessage(
+ asAppendMessage({
+ role: "assistant",
+ content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
+ timestamp: 2,
+ }),
+ );
+ const toolResultEntryId = sessionManager.appendMessage(
+ asAppendMessage({
+ role: "toolResult",
+ toolCallId: "call_1",
+ toolName: "read",
+ content: [{ type: "text", text: "x".repeat(8_000) }],
+ isError: false,
+ timestamp: 3,
+ }),
+ );
+ sessionManager.appendMessage(
+ asAppendMessage({
+ role: "assistant",
+ content: [{ type: "text", text: "summarized" }],
+ timestamp: 4,
+ }),
+ );
+
+ const summaryEntry = sessionManager
+ .getBranch()
+ .find(
+ (entry) =>
+ entry.type === "message" &&
+ entry.message.role === "assistant" &&
+ Array.isArray(entry.message.content) &&
+ entry.message.content.some((part) => part.type === "text" && part.text === "summarized"),
+ );
+ expect(summaryEntry).toBeDefined();
+ sessionManager.appendLabelChange(summaryEntry!.id, "bookmark");
+
+ const result = rewriteTranscriptEntriesInSessionManager({
+ sessionManager,
+ replacements: [
+ {
+ entryId: toolResultEntryId,
+ message: {
+ role: "toolResult",
+ toolCallId: "call_1",
+ toolName: "read",
+ content: [{ type: "text", text: "[externalized file_123]" }],
+ isError: false,
+ timestamp: 3,
+ },
+ },
+ ],
+ });
+
+ expect(result.changed).toBe(true);
+ const rewrittenSummaryEntry = sessionManager
+ .getBranch()
+ .find(
+ (entry) =>
+ entry.type === "message" &&
+ entry.message.role === "assistant" &&
+ Array.isArray(entry.message.content) &&
+ entry.message.content.some((part) => part.type === "text" && part.text === "summarized"),
+ );
+ expect(rewrittenSummaryEntry).toBeDefined();
+ expect(sessionManager.getLabel(rewrittenSummaryEntry!.id)).toBe("bookmark");
+ expect(sessionManager.getBranch().some((entry) => entry.type === "label")).toBe(true);
+ });
+
+ it("remaps compaction keep markers when rewritten entries change ids", () => {
+ const sessionManager = SessionManager.inMemory();
+ sessionManager.appendMessage(
+ asAppendMessage({
+ role: "user",
+ content: "read file",
+ timestamp: 1,
+ }),
+ );
+ sessionManager.appendMessage(
+ asAppendMessage({
+ role: "assistant",
+ content: [{ type: "toolCall", id: "call_1", name: "read", arguments: {} }],
+ timestamp: 2,
+ }),
+ );
+ const toolResultEntryId = sessionManager.appendMessage(
+ asAppendMessage({
+ role: "toolResult",
+ toolCallId: "call_1",
+ toolName: "read",
+ content: [{ type: "text", text: "x".repeat(8_000) }],
+ isError: false,
+ timestamp: 3,
+ }),
+ );
+ const keptAssistantEntryId = sessionManager.appendMessage(
+ asAppendMessage({
+ role: "assistant",
+ content: [{ type: "text", text: "keep me" }],
+ timestamp: 4,
+ }),
+ );
+ sessionManager.appendCompaction("summary", keptAssistantEntryId, 123);
+
+ const result = rewriteTranscriptEntriesInSessionManager({
+ sessionManager,
+ replacements: [
+ {
+ entryId: toolResultEntryId,
+ message: {
+ role: "toolResult",
+ toolCallId: "call_1",
+ toolName: "read",
+ content: [{ type: "text", text: "[externalized file_123]" }],
+ isError: false,
+ timestamp: 3,
+ },
+ },
+ ],
+ });
+
+ expect(result.changed).toBe(true);
+ const branch = sessionManager.getBranch();
+ const keptAssistantEntry = branch.find(
+ (entry) =>
+ entry.type === "message" &&
+ entry.message.role === "assistant" &&
+ Array.isArray(entry.message.content) &&
+ entry.message.content.some((part) => part.type === "text" && part.text === "keep me"),
+ );
+ const compactionEntry = branch.find((entry) => entry.type === "compaction");
+
+ expect(keptAssistantEntry).toBeDefined();
+ expect(compactionEntry).toBeDefined();
+ expect(compactionEntry?.firstKeptEntryId).toBe(keptAssistantEntry?.id);
+ expect(compactionEntry?.firstKeptEntryId).not.toBe(keptAssistantEntryId);
+ });
+
+ it("bypasses persistence hooks when replaying rewritten messages", () => {
+ const sessionManager = SessionManager.inMemory();
+ sessionManager.appendMessage(
+ asAppendMessage({
+ role: "user",
+ content: "run tool",
+ timestamp: 1,
+ }),
+ );
+ const toolResultEntryId = sessionManager.appendMessage(
+ asAppendMessage({
+ role: "toolResult",
+ toolCallId: "call_1",
+ toolName: "exec",
+ content: [{ type: "text", text: "before rewrite" }],
+ isError: false,
+ timestamp: 2,
+ }),
+ );
+ sessionManager.appendMessage(
+ asAppendMessage({
+ role: "assistant",
+ content: [{ type: "text", text: "summarized" }],
+ timestamp: 3,
+ }),
+ );
+ installSessionToolResultGuard(sessionManager, {
+ transformToolResultForPersistence: (message) => ({
+ ...(message as Extract),
+ content: [{ type: "text", text: "[hook transformed]" }],
+ }),
+ beforeMessageWriteHook: ({ message }) =>
+ message.role === "assistant" ? { block: true } : undefined,
+ });
+
+ const result = rewriteTranscriptEntriesInSessionManager({
+ sessionManager,
+ replacements: [
+ {
+ entryId: toolResultEntryId,
+ message: {
+ role: "toolResult",
+ toolCallId: "call_1",
+ toolName: "exec",
+ content: [{ type: "text", text: "[exact replacement]" }],
+ isError: false,
+ timestamp: 2,
+ },
+ },
+ ],
+ });
+
+ expect(result.changed).toBe(true);
+ const branchMessages = getBranchMessages(sessionManager);
+ expect(branchMessages.map((message) => message.role)).toEqual([
+ "user",
+ "toolResult",
+ "assistant",
+ ]);
+ expect((branchMessages[1] as Extract).content).toEqual([
+ { type: "text", text: "[exact replacement]" },
+ ]);
+ expect(branchMessages[2]).toMatchObject({
+ role: "assistant",
+ content: [{ type: "text", text: "summarized" }],
+ });
+ });
+});
+
+describe("rewriteTranscriptEntriesInSessionFile", () => {
+ it("emits transcript updates when the active branch changes", async () => {
+ const sessionFile = "/tmp/session.jsonl";
+ const sessionManager = SessionManager.inMemory();
+ sessionManager.appendMessage(
+ asAppendMessage({
+ role: "user",
+ content: "run tool",
+ timestamp: 1,
+ }),
+ );
+ sessionManager.appendMessage(
+ asAppendMessage({
+ role: "toolResult",
+ toolCallId: "call_1",
+ toolName: "exec",
+ content: [{ type: "text", text: "y".repeat(6_000) }],
+ isError: false,
+ timestamp: 2,
+ }),
+ );
+
+ const toolResultEntry = sessionManager
+ .getBranch()
+ .find((entry) => entry.type === "message" && entry.message.role === "toolResult");
+ expect(toolResultEntry).toBeDefined();
+
+ const openSpy = vi
+ .spyOn(SessionManager, "open")
+ .mockReturnValue(sessionManager as unknown as ReturnType);
+ const listener = vi.fn();
+ const cleanup = onSessionTranscriptUpdate(listener);
+
+ try {
+ const result = await rewriteTranscriptEntriesInSessionFile({
+ sessionFile,
+ sessionKey: "agent:main:test",
+ request: {
+ replacements: [
+ {
+ entryId: toolResultEntry!.id,
+ message: {
+ role: "toolResult",
+ toolCallId: "call_1",
+ toolName: "exec",
+ content: [{ type: "text", text: "[file_ref:file_abc]" }],
+ isError: false,
+ timestamp: 2,
+ },
+ },
+ ],
+ },
+ });
+
+ expect(result.changed).toBe(true);
+ expect(acquireSessionWriteLockMock).toHaveBeenCalledWith({
+ sessionFile,
+ });
+ expect(acquireSessionWriteLockReleaseMock).toHaveBeenCalledTimes(1);
+ expect(listener).toHaveBeenCalledWith({ sessionFile });
+
+ const rewrittenToolResult = getBranchMessages(sessionManager)[1] as Extract<
+ AgentMessage,
+ { role: "toolResult" }
+ >;
+ expect(rewrittenToolResult.content).toEqual([{ type: "text", text: "[file_ref:file_abc]" }]);
+ } finally {
+ cleanup();
+ openSpy.mockRestore();
+ }
+ });
+});
diff --git a/src/agents/pi-embedded-runner/transcript-rewrite.ts b/src/agents/pi-embedded-runner/transcript-rewrite.ts
new file mode 100644
index 00000000000..48d93d445b6
--- /dev/null
+++ b/src/agents/pi-embedded-runner/transcript-rewrite.ts
@@ -0,0 +1,232 @@
+import type { AgentMessage } from "@mariozechner/pi-agent-core";
+import { SessionManager } from "@mariozechner/pi-coding-agent";
+import type {
+ TranscriptRewriteReplacement,
+ TranscriptRewriteRequest,
+ TranscriptRewriteResult,
+} from "../../context-engine/types.js";
+import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
+import { getRawSessionAppendMessage } from "../session-tool-result-guard.js";
+import { acquireSessionWriteLock } from "../session-write-lock.js";
+import { log } from "./logger.js";
+
+type SessionManagerLike = ReturnType;
+type SessionBranchEntry = ReturnType[number];
+
+function estimateMessageBytes(message: AgentMessage): number {
+ return Buffer.byteLength(JSON.stringify(message), "utf8");
+}
+
+function remapEntryId(
+ entryId: string | null | undefined,
+ rewrittenEntryIds: ReadonlyMap,
+): string | null {
+ if (!entryId) {
+ return null;
+ }
+ return rewrittenEntryIds.get(entryId) ?? entryId;
+}
+
+function appendBranchEntry(params: {
+ sessionManager: SessionManagerLike;
+ entry: SessionBranchEntry;
+ rewrittenEntryIds: ReadonlyMap;
+ appendMessage: SessionManagerLike["appendMessage"];
+}): string {
+ const { sessionManager, entry, rewrittenEntryIds, appendMessage } = params;
+ if (entry.type === "message") {
+ return appendMessage(entry.message as Parameters[0]);
+ }
+ if (entry.type === "compaction") {
+ return sessionManager.appendCompaction(
+ entry.summary,
+ remapEntryId(entry.firstKeptEntryId, rewrittenEntryIds) ?? entry.firstKeptEntryId,
+ entry.tokensBefore,
+ entry.details,
+ entry.fromHook,
+ );
+ }
+ if (entry.type === "thinking_level_change") {
+ return sessionManager.appendThinkingLevelChange(entry.thinkingLevel);
+ }
+ if (entry.type === "model_change") {
+ return sessionManager.appendModelChange(entry.provider, entry.modelId);
+ }
+ if (entry.type === "custom") {
+ return sessionManager.appendCustomEntry(entry.customType, entry.data);
+ }
+ if (entry.type === "custom_message") {
+ return sessionManager.appendCustomMessageEntry(
+ entry.customType,
+ entry.content,
+ entry.display,
+ entry.details,
+ );
+ }
+ if (entry.type === "session_info") {
+ if (entry.name) {
+ return sessionManager.appendSessionInfo(entry.name);
+ }
+ return sessionManager.appendSessionInfo("");
+ }
+ if (entry.type === "branch_summary") {
+ return sessionManager.branchWithSummary(
+ remapEntryId(entry.parentId, rewrittenEntryIds),
+ entry.summary,
+ entry.details,
+ entry.fromHook,
+ );
+ }
+ return sessionManager.appendLabelChange(
+ remapEntryId(entry.targetId, rewrittenEntryIds) ?? entry.targetId,
+ entry.label,
+ );
+}
+
+/**
+ * Safely rewrites transcript message entries on the active branch by branching
+ * from the first rewritten message's parent and re-appending the suffix.
+ */
+export function rewriteTranscriptEntriesInSessionManager(params: {
+ sessionManager: SessionManagerLike;
+ replacements: TranscriptRewriteReplacement[];
+}): TranscriptRewriteResult {
+ const replacementsById = new Map(
+ params.replacements
+ .filter((replacement) => replacement.entryId.trim().length > 0)
+ .map((replacement) => [replacement.entryId, replacement.message]),
+ );
+ if (replacementsById.size === 0) {
+ return {
+ changed: false,
+ bytesFreed: 0,
+ rewrittenEntries: 0,
+ reason: "no replacements requested",
+ };
+ }
+
+ const branch = params.sessionManager.getBranch();
+ if (branch.length === 0) {
+ return {
+ changed: false,
+ bytesFreed: 0,
+ rewrittenEntries: 0,
+ reason: "empty session",
+ };
+ }
+
+ const matchedIndices: number[] = [];
+ let bytesFreed = 0;
+
+ for (let index = 0; index < branch.length; index++) {
+ const entry = branch[index];
+ if (entry.type !== "message") {
+ continue;
+ }
+ const replacement = replacementsById.get(entry.id);
+ if (!replacement) {
+ continue;
+ }
+ const originalBytes = estimateMessageBytes(entry.message);
+ const replacementBytes = estimateMessageBytes(replacement);
+ matchedIndices.push(index);
+ bytesFreed += Math.max(0, originalBytes - replacementBytes);
+ }
+
+ if (matchedIndices.length === 0) {
+ return {
+ changed: false,
+ bytesFreed: 0,
+ rewrittenEntries: 0,
+ reason: "no matching message entries",
+ };
+ }
+
+ const firstMatchedEntry = branch[matchedIndices[0]] as
+ | Extract
+ | undefined;
+ // matchedIndices only contains indices of branch "message" entries.
+ if (!firstMatchedEntry) {
+ return {
+ changed: false,
+ bytesFreed: 0,
+ rewrittenEntries: 0,
+ reason: "invalid first rewrite target",
+ };
+ }
+
+ if (!firstMatchedEntry.parentId) {
+ params.sessionManager.resetLeaf();
+ } else {
+ params.sessionManager.branch(firstMatchedEntry.parentId);
+ }
+
+ // Maintenance rewrites should preserve the exact requested history without
+ // re-running persistence hooks or size truncation on replayed messages.
+ const appendMessage = getRawSessionAppendMessage(params.sessionManager);
+ const rewrittenEntryIds = new Map();
+ for (let index = matchedIndices[0]; index < branch.length; index++) {
+ const entry = branch[index];
+ const replacement = entry.type === "message" ? replacementsById.get(entry.id) : undefined;
+ const newEntryId =
+ replacement === undefined
+ ? appendBranchEntry({
+ sessionManager: params.sessionManager,
+ entry,
+ rewrittenEntryIds,
+ appendMessage,
+ })
+ : appendMessage(replacement as Parameters