Compare commits

..
549 changed files with 5440 additions and 20481 deletions

No files matched your search

+1 -1
View File
@@ -1 +1 @@
allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty,@lydell/node-pty allow-build-scripts=@whiskeysockets/baileys,sharp,esbuild,protobufjs,fs-ext,node-pty
-2
View File
@@ -73,7 +73,6 @@
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable. - Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
- Environment variables: see `~/.profile`. - Environment variables: see `~/.profile`.
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples. - Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them.
## Troubleshooting ## Troubleshooting
- Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`). - Rebrand/migration issues or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).
@@ -118,7 +117,6 @@
- Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes. - Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
- launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`. - launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
- For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping. - For manual `clawdbot message send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.
- Release guardrails: do not change version numbers without operator’s explicit consent; always ask permission before running any npm publish/release step.
## NPM + 1Password (publish/verify) ## NPM + 1Password (publish/verify)
- Use the 1password skill; all `op` commands must run inside a fresh tmux session. - Use the 1password skill; all `op` commands must run inside a fresh tmux session.
+9 -72
View File
@@ -1,118 +1,55 @@
# Changelog # Changelog
Docs: https://docs.clawd.bot ## 2026.1.16 (unreleased)
## 2026.1.17 (Unreleased)
### Changes
- macOS: strip prerelease/build suffixes when parsing gateway semver patches. (#1110) — thanks @zerone0x.
- macOS: keep CLI install pinned to the full build suffix. (#1111) — thanks @artuskg.
## 2026.1.16-2
### Changes
- CLI: stamp build commit into dist metadata so banners show the commit in npm installs.
## 2026.1.16-1
### Highlights ### Highlights
- Hooks: add hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake. https://docs.clawd.bot/hooks - Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos.
- Media: add inbound media understanding (image/audio/video) with provider + CLI fallbacks. https://docs.clawd.bot/nodes/media-understanding - Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh.
- Plugins: add Zalo Personal plugin (`@clawdbot/zalouser`) and unify channel directory for plugins. (#1032) — thanks @suminhthanh. https://docs.clawd.bot/plugins/zalouser - Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins.
- Models: add Vercel AI Gateway auth choice + onboarding updates. (#1016) — thanks @timolins. https://docs.clawd.bot/providers/vercel-ai-gateway - Sessions: add `session.identityLinks` for cross-platform DM session linking. (#1033) — thanks @thewilloftheshadow.
- Sessions: add `session.identityLinks` for cross-platform DM session li nking. (#1033) — thanks @thewilloftheshadow. https://docs.clawd.bot/concepts/session - Hooks: add internal hooks system with bundled hooks, CLI tooling, and docs. (#1028) — thanks @ThomsenDrake.
- Web search: add `country`/`language` parameters (schema + Brave API) and docs. (#1046) — thanks @YuriNachos. https://docs.clawd.bot/tools/web
### Breaking ### Breaking
- **BREAKING:** `clawdbot message` and message tool now require `target` (dropping `to`/`channelId` for destinations). (#1034) — thanks @tobalsan.
- **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow. - **BREAKING:** Channel auth now prefers config over env for Discord/Telegram/Matrix (env is fallback only). (#1040) — thanks @thewilloftheshadow.
- **BREAKING:** Drop legacy `chatType: "room"` support; use `chatType: "channel"`.
- **BREAKING:** remove legacy provider-specific target resolution fallbacks; target resolution is centralized with plugin hints + directory lookups.
- **BREAKING:** `clawdbot hooks` is now `clawdbot webhooks`; hooks live under `clawdbot hooks`. https://docs.clawd.bot/cli/webhooks
- **BREAKING:** `clawdbot plugins install <path>` now copies into `~/.clawdbot/extensions` (use `--link` to keep path-based loading).
### Changes ### Changes
- Plugins: ship bundled plugins disabled by default and allow overrides by installed versions. (#1066) — thanks @ItzR3NO.
- Plugins: add bundled Antigravity + Gemini CLI OAuth + Copilot Proxy provider plugins. (#1066) — thanks @ItzR3NO.
- Tools: improve `web_fetch` extraction using Readability (with fallback). - Tools: improve `web_fetch` extraction using Readability (with fallback).
- Tools: add Firecrawl fallback for `web_fetch` when configured. - Tools: add Firecrawl fallback for `web_fetch` when configured.
- Tools: send Chrome-like headers by default for `web_fetch` to improve extraction on bot-sensitive sites. - Tools: send Chrome-like headers by default for `web_fetch` to improve extraction on bot-sensitive sites.
- Tools: Firecrawl fallback now uses bot-circumvention + cache by default; remove basic HTML fallback when extraction fails. - Tools: Firecrawl fallback now uses bot-circumvention + cache by default; remove basic HTML fallback when extraction fails.
- Tools: default `exec` exit notifications and auto-migrate legacy `tools.bash` to `tools.exec`.
- Tools: add `exec` PTY support for interactive sessions. https://docs.clawd.bot/tools/exec
- Tools: add tmux-style `process send-keys` and bracketed paste helpers for PTY sessions.
- Tools: add `process submit` helper to send CR for PTY sessions.
- Tools: respond to PTY cursor position queries to unblock interactive TUIs.
- Tools: include tool outputs in verbose mode and expand verbose tool feedback.
- Skills: update coding-agent guidance to prefer PTY-enabled exec runs and simplify tmux usage.
- TUI: refresh session token counts after runs complete or fail. (#1079) — thanks @d-ploutarchos.
- Status: trim `/status` to current-provider usage only and drop the OAuth/token block.
- Directory: unify `clawdbot directory` across channels and plugin channels. - Directory: unify `clawdbot directory` across channels and plugin channels.
- UI: allow deleting sessions from the Control UI. - UI: allow deleting sessions from the Control UI.
- Skills: add user-invocable skill commands and expanded skill command registration. - Skills: add user-invocable skill commands and expanded skill command registration.
- Telegram: default reaction level to minimal and enable reaction notifications by default. - Telegram: default reaction level to minimal and enable reaction notifications by default.
- Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2. - Telegram: allow reply-chain messages to bypass mention gating in groups. (#1038) — thanks @adityashaw2.
- iMessage: add remote attachment support for VM/SSH deployments.
- Messages: refresh live directory cache results when resolving targets.
- Messages: mirror delivered outbound text/media into session transcripts. (#1031) — thanks @TSavo. - Messages: mirror delivered outbound text/media into session transcripts. (#1031) — thanks @TSavo.
- Messages: avoid redundant sender envelopes for iMessage + Signal group chats. (#1080) — thanks @tyler6204.
- Media: normalize Deepgram audio upload bytes for fetch compatibility.
- Cron: isolated cron jobs now start a fresh session id on every run to prevent context buildup. - Cron: isolated cron jobs now start a fresh session id on every run to prevent context buildup.
- Docs: add `/help` hub, Node/npm PATH guide, and expand directory CLI docs. - Docs: add `/help` hub, Node/npm PATH guide, and expand directory CLI docs.
- Config: support env var substitution in config values. (#1044) — thanks @sebslight. - Config: support env var substitution in config values. (#1044) — thanks @sebslight.
- Health: add per-agent session summaries and account-level health details, and allow selective probes. (#1047) — thanks @gumadeiras. - Health: add per-agent session summaries and account-level health details, and allow selective probes. (#1047) — thanks @gumadeiras.
- Hooks: add hook pack installs (npm/path/zip/tar) with `clawdbot.hooks` manifests and `clawdbot hooks install/update`.
- Plugins: add zip installs and `--link` to avoid copying local paths.
### Fixes ### Fixes
- macOS: drain subprocess pipes before waiting to avoid deadlocks. (#1081) — thanks @thesash. - Sub-agents: route announce delivery through the correct channel account IDs. (#1061, #1058) — thanks @adam91holt.
- Verbose: wrap tool summaries/output in markdown only for markdown-capable channels. - Telegram: split long media captions into follow-up text messages in bot delivery. (#1063) — thanks @mukhtharcm.
- Telegram: accept tg/group/telegram prefixes + topic targets for inline button validation. (#1072) — thanks @danielz1z.
- Telegram: split long captions into follow-up messages.
- Config: block startup on invalid config, preserve best-effort doctor config, and keep rolling config backups. (#1083) — thanks @mukhtharcm.
- Sub-agents: normalize announce delivery origin + queue bucketing by accountId to keep multi-account routing stable. (#1061, #1058) — thanks @adam91holt.
- Sessions: include deliveryContext in sessions.list and reuse normalized delivery routing for announce/restart fallbacks. (#1058)
- Sessions: propagate deliveryContext into last-route updates to keep account/channel routing stable. (#1058)
- Sessions: preserve overrides on `/new` reset.
- Memory: prevent unhandled rejections when watch/interval sync fails. (#1076) — thanks @roshanasingh4.
- Memory: avoid gateway crash when embeddings return 429/insufficient_quota (disable tool + surface error). (#1004)
- Gateway: honor explicit delivery targets without implicit accountId fallback; preserve lastAccountId for implicit routing.
- Gateway: avoid reusing last-to/accountId when the requested channel differs; sync deliveryContext with last route fields.
- Build: allow `@lydell/node-pty` builds on supported platforms.
- Repo: fix oxlint config filename and move ignore pattern into config. (#1064) — thanks @connorshea. - Repo: fix oxlint config filename and move ignore pattern into config. (#1064) — thanks @connorshea.
- Messages: `/stop` now hard-aborts queued followups and sub-agent runs; suppress zero-count stop notes. - Messages: `/stop` now hard-aborts queued followups and sub-agent runs; suppress zero-count stop notes.
- Messages: honor message tool channel when deduping sends.
- Messages: include sender labels for live group messages across channels, matching queued/history formatting. (#1059)
- Sessions: reset `compactionCount` on `/new` and `/reset`, and preserve `sessions.json` file mode (0600). - Sessions: reset `compactionCount` on `/new` and `/reset`, and preserve `sessions.json` file mode (0600).
- Sessions: repair orphaned user turns before embedded prompts. - Sessions: repair orphaned user turns before embedded prompts.
- Sessions: hard-stop `sessions.delete` cleanup.
- Channels: treat replies to the bot as implicit mentions across supported channels. - Channels: treat replies to the bot as implicit mentions across supported channels.
- Channels: normalize object-format capabilities in channel capability parsing.
- Security: default-deny slash/control commands unless a channel computed `CommandAuthorized` (fixes accidental “open” behavior), and ensure WhatsApp + Zalo plugin channels gate inline `/…` tokens correctly. https://docs.clawd.bot/gateway/security
- Security: redact sensitive text in gateway WS logs.
- Tools: cap pending `exec` process output to avoid unbounded buffers.
- CLI: speed up `clawdbot sandbox-explain` by avoiding heavy plugin imports when normalizing channel ids.
- Browser: remote profile tab operations prefer persistent Playwright and avoid silent HTTP fallbacks. (#1057) — thanks @mukhtharcm. - Browser: remote profile tab operations prefer persistent Playwright and avoid silent HTTP fallbacks. (#1057) — thanks @mukhtharcm.
- Browser: remote profile tab ops follow-up: shared Playwright loader, Playwright-based focus, and more coverage (incl. opt-in live Browserless test). (follow-up to #1057) — thanks @mukhtharcm. - Browser: remote profile tab ops follow-up: shared Playwright loader, Playwright-based focus, and more coverage (incl. opt-in live Browserless test). (follow-up to #1057) — thanks @mukhtharcm.
- Browser: refresh extension relay tab metadata after navigation so `/json/list` stays current. (#1073) — thanks @roshanasingh4.
- WhatsApp: scope self-chat response prefix; inject pending-only group history and clear after any processed message. - WhatsApp: scope self-chat response prefix; inject pending-only group history and clear after any processed message.
- WhatsApp: include `linked` field in `describeAccount`.
- Agents: drop unsigned Gemini tool calls and avoid JSON Schema `format` keyword collisions. - Agents: drop unsigned Gemini tool calls and avoid JSON Schema `format` keyword collisions.
- Agents: hide the image tool when the primary model already supports images.
- Agents: avoid duplicate sends by replying with `NO_REPLY` after `message` tool sends. - Agents: avoid duplicate sends by replying with `NO_REPLY` after `message` tool sends.
- Auth: inherit/merge sub-agent auth profiles from the main agent. - Auth: inherit/merge sub-agent auth profiles from the main agent.
- Gateway: resolve local auth for security probe and validate gateway token/password file modes. (#1011, #1022) — thanks @ivanrvpereira, @kkarimi. - Gateway: resolve local auth for security probe and validate gateway token/password file modes. (#1011, #1022) — thanks @ivanrvpereira, @kkarimi.
- Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel. - Signal/iMessage: bound transport readiness waits to 30s with periodic logging. (#1014) — thanks @Szpadel.
- iMessage: avoid RPC restart loops. - OpenAI image-gen: remove deprecated `response_format` and use URL downloads.
- OpenAI image-gen: handle URL + `b64_json` responses and remove deprecated `response_format` (use URL downloads).
- CLI: auto-update global installs when installed via a package manager. - CLI: auto-update global installs when installed via a package manager.
- Routing: migrate legacy `accountID` bindings to `accountId` and remove legacy fallback lookups. (#1047) — thanks @gumadeiras. - Routing: migrate legacy `accountID` bindings to `accountId` and remove legacy fallback lookups. (#1047) — thanks @gumadeiras.
- Discord: truncate skill command descriptions to 100 chars for slash command limits. (#1018) — thanks @evalexpr. - Discord: truncate skill command descriptions to 100 chars for slash command limits. (#1018) — thanks @evalexpr.
- Security: bump `tar` to 7.5.3. - Security: bump `tar` to 7.5.3.
- Models: align ZAI thinking toggles. - Models: align ZAI thinking toggles.
- iMessage/Signal: include sender metadata for non-queued group messages. (#1059)
- Discord: preserve whitespace when chunking long lines so message splits keep spacing intact.
- Skills: fix skills watcher ignored list typing (tsc).
## 2026.1.15 ## 2026.1.15
+16 -16
View File
@@ -2,22 +2,6 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0"> <rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel> <channel>
<title>Clawdbot</title> <title>Clawdbot</title>
<item>
<title>2026.1.16-2</title>
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>6273</sparkle:version>
<sparkle:shortVersionString>2026.1.16-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.16-2</h2>
<h3>Changes</h3>
<ul>
<li>CLI: stamp build commit into dist metadata so banners show the commit in npm installs.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
</item>
<item> <item>
<title>2026.1.15</title> <title>2026.1.15</title>
<pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate> <pubDate>Fri, 16 Jan 2026 10:31:53 +0000</pubDate>
@@ -271,5 +255,21 @@
]]></description> ]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/> <enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.14-1/Clawdbot-2026.1.14-1.zip" length="19887144" type="application/octet-stream" sparkle:edSignature="1irKxBLt2eRtns34m/8JsjL/ZzhZQNjahwrxtArTvzaCnidS/MEnpD4nV2SHnhuo8g+fJZQpV9NoCAoEOAinCw=="/>
</item> </item>
<item>
<title>2026.1.12-2</title>
<pubDate>Tue, 13 Jan 2026 10:05:25 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>5534</sparkle:version>
<sparkle:shortVersionString>2026.1.12-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.12-2</h2>
<h3>Fixes</h3>
<ul>
<li>Packaging: include <code>dist/memory/**</code> in the npm tarball (fixes <code>ERR_MODULE_NOT_FOUND</code> for <code>dist/memory/index.js</code>).</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.12-2/Clawdbot-2026.1.12-2.zip" length="19854203" type="application/octet-stream" sparkle:edSignature="CVpUofNS+pl6Smk/K0Q8q35saRuuFx90s4sePABORFvGcAF1biajC8zpiImKuXpqD0ENb+VTwDJ1ul1Oxh3wDA=="/>
</item>
</channel> </channel>
</rss> </rss>
@@ -35,7 +35,7 @@ enum CLIInstaller {
} }
static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async { static func install(statusHandler: @escaping @MainActor @Sendable (String) async -> Void) async {
let expected = GatewayEnvironment.expectedGatewayVersionString() ?? "latest" let expected = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
let prefix = Self.installPrefix() let prefix = Self.installPrefix()
await statusHandler("Installing clawdbot CLI…") await statusHandler("Installing clawdbot CLI…")
let cmd = self.installScriptCommand(version: expected, prefix: prefix) let cmd = self.installScriptCommand(version: expected, prefix: prefix)
@@ -6,11 +6,11 @@ struct ConfigSchemaForm: View {
let path: ConfigPath let path: ConfigPath
var body: some View { var body: some View {
self.renderNode(self.schema, path: self.path) self.renderNode(schema, path: path)
} }
private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView { private func renderNode(_ schema: ConfigSchemaNode, path: ConfigPath) -> AnyView {
let storedValue = self.store.configValue(at: path) let storedValue = store.configValue(at: path)
let value = storedValue ?? schema.explicitDefault let value = storedValue ?? schema.explicitDefault
let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title let label = hintForPath(path, hints: store.configUiHints)?.label ?? schema.title
let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description let help = hintForPath(path, hints: store.configUiHints)?.help ?? schema.description
@@ -21,7 +21,7 @@ struct ConfigSchemaForm: View {
if nonNull.count == 1, let only = nonNull.first { if nonNull.count == 1, let only = nonNull.first {
return self.renderNode(only, path: path) return self.renderNode(only, path: path)
} }
let literals = nonNull.compactMap(\.literalValue) let literals = nonNull.compactMap { $0.literalValue }
if !literals.isEmpty, literals.count == nonNull.count { if !literals.isEmpty, literals.count == nonNull.count {
return AnyView( return AnyView(
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
@@ -31,20 +31,15 @@ struct ConfigSchemaForm: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Picker( Picker("", selection: self.enumBinding(path, options: literals, defaultValue: schema.explicitDefault)) {
"",
selection: self.enumBinding(
path,
options: literals,
defaultValue: schema.explicitDefault))
{
Text("Select…").tag(-1) Text("Select…").tag(-1)
ForEach(literals.indices, id: \ .self) { index in ForEach(literals.indices, id: \ .self) { index in
Text(String(describing: literals[index])).tag(index) Text(String(describing: literals[index])).tag(index)
} }
} }
.pickerStyle(.menu) .pickerStyle(.menu)
}) }
)
} }
} }
@@ -76,7 +71,8 @@ struct ConfigSchemaForm: View {
if schema.allowsAdditionalProperties { if schema.allowsAdditionalProperties {
self.renderAdditionalProperties(schema, path: path, value: value) self.renderAdditionalProperties(schema, path: path, value: value)
} }
}) }
)
case "array": case "array":
return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help)) return AnyView(self.renderArray(schema, path: path, value: value, label: label, help: help))
case "boolean": case "boolean":
@@ -84,7 +80,8 @@ struct ConfigSchemaForm: View {
Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) { Toggle(isOn: self.boolBinding(path, defaultValue: schema.explicitDefault as? Bool)) {
if let label { Text(label) } else { Text("Enabled") } if let label { Text(label) } else { Text("Enabled") }
} }
.help(help ?? "")) .help(help ?? "")
)
case "number", "integer": case "number", "integer":
return AnyView(self.renderNumberField(schema, path: path, label: label, help: help)) return AnyView(self.renderNumberField(schema, path: path, label: label, help: help))
case "string": case "string":
@@ -96,7 +93,8 @@ struct ConfigSchemaForm: View {
Text("Unsupported field type.") Text("Unsupported field type.")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
}) }
)
} }
} }
@@ -157,7 +155,9 @@ struct ConfigSchemaForm: View {
text: self.numberBinding( text: self.numberBinding(
path, path,
isInteger: schema.schemaType == "integer", isInteger: schema.schemaType == "integer",
defaultValue: defaultValue)) defaultValue: defaultValue
)
)
.textFieldStyle(.roundedBorder) .textFieldStyle(.roundedBorder)
} }
} }
@@ -189,7 +189,7 @@ struct ConfigSchemaForm: View {
Button("Remove") { Button("Remove") {
var next = items var next = items
next.remove(at: index) next.remove(at: index)
self.store.updateConfigValue(path: path, value: next) store.updateConfigValue(path: path, value: next)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.small) .controlSize(.small)
@@ -202,7 +202,7 @@ struct ConfigSchemaForm: View {
} else { } else {
next.append("") next.append("")
} }
self.store.updateConfigValue(path: path, value: next) store.updateConfigValue(path: path, value: next)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.small) .controlSize(.small)
@@ -238,7 +238,7 @@ struct ConfigSchemaForm: View {
Button("Remove") { Button("Remove") {
var next = dict var next = dict
next.removeValue(forKey: key) next.removeValue(forKey: key)
self.store.updateConfigValue(path: path, value: next) store.updateConfigValue(path: path, value: next)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.small) .controlSize(.small)
@@ -254,7 +254,7 @@ struct ConfigSchemaForm: View {
key = "new-\(index)" key = "new-\(index)"
} }
next[key] = additionalSchema.defaultValue next[key] = additionalSchema.defaultValue
self.store.updateConfigValue(path: path, value: next) store.updateConfigValue(path: path, value: next)
} }
.buttonStyle(.bordered) .buttonStyle(.bordered)
.controlSize(.small) .controlSize(.small)
@@ -270,8 +270,9 @@ struct ConfigSchemaForm: View {
}, },
set: { newValue in set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
self.store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed) store.updateConfigValue(path: path, value: trimmed.isEmpty ? nil : trimmed)
}) }
)
} }
private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding<Bool> { private func boolBinding(_ path: ConfigPath, defaultValue: Bool?) -> Binding<Bool> {
@@ -281,15 +282,16 @@ struct ConfigSchemaForm: View {
return defaultValue ?? false return defaultValue ?? false
}, },
set: { newValue in set: { newValue in
self.store.updateConfigValue(path: path, value: newValue) store.updateConfigValue(path: path, value: newValue)
}) }
)
} }
private func numberBinding( private func numberBinding(
_ path: ConfigPath, _ path: ConfigPath,
isInteger: Bool, isInteger: Bool,
defaultValue: Double?) -> Binding<String> defaultValue: Double?
{ ) -> Binding<String> {
Binding( Binding(
get: { get: {
if let value = store.configValue(at: path) { return String(describing: value) } if let value = store.configValue(at: path) { return String(describing: value) }
@@ -299,21 +301,22 @@ struct ConfigSchemaForm: View {
set: { newValue in set: { newValue in
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { if trimmed.isEmpty {
self.store.updateConfigValue(path: path, value: nil) store.updateConfigValue(path: path, value: nil)
} else if let value = Double(trimmed) { } else if let value = Double(trimmed) {
self.store.updateConfigValue(path: path, value: isInteger ? Int(value) : value) store.updateConfigValue(path: path, value: isInteger ? Int(value) : value)
} }
}) }
)
} }
private func enumBinding( private func enumBinding(
_ path: ConfigPath, _ path: ConfigPath,
options: [Any], options: [Any],
defaultValue: Any?) -> Binding<Int> defaultValue: Any?
{ ) -> Binding<Int> {
Binding( Binding(
get: { get: {
let value = self.store.configValue(at: path) ?? defaultValue let value = store.configValue(at: path) ?? defaultValue
guard let value else { return -1 } guard let value else { return -1 }
return options.firstIndex { option in return options.firstIndex { option in
String(describing: option) == String(describing: value) String(describing: option) == String(describing: value)
@@ -321,11 +324,12 @@ struct ConfigSchemaForm: View {
}, },
set: { index in set: { index in
guard index >= 0, index < options.count else { guard index >= 0, index < options.count else {
self.store.updateConfigValue(path: path, value: nil) store.updateConfigValue(path: path, value: nil)
return return
} }
self.store.updateConfigValue(path: path, value: options[index]) store.updateConfigValue(path: path, value: options[index])
}) }
)
} }
private func mapKeyBinding(path: ConfigPath, key: String) -> Binding<String> { private func mapKeyBinding(path: ConfigPath, key: String) -> Binding<String> {
@@ -335,13 +339,14 @@ struct ConfigSchemaForm: View {
let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines) let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return } guard !trimmed.isEmpty else { return }
guard trimmed != key else { return } guard trimmed != key else { return }
let current = self.store.configValue(at: path) as? [String: Any] ?? [:] let current = store.configValue(at: path) as? [String: Any] ?? [:]
guard current[trimmed] == nil else { return } guard current[trimmed] == nil else { return }
var next = current var next = current
next[trimmed] = current[key] next[trimmed] = current[key]
next.removeValue(forKey: key) next.removeValue(forKey: key)
self.store.updateConfigValue(path: path, value: next) store.updateConfigValue(path: path, value: next)
}) }
)
} }
} }
@@ -350,10 +355,10 @@ struct ChannelConfigForm: View {
let channelId: String let channelId: String
var body: some View { var body: some View {
if self.store.configSchemaLoading { if store.configSchemaLoading {
ProgressView().controlSize(.small) ProgressView().controlSize(.small)
} else if let schema = store.channelConfigSchema(for: channelId) { } else if let schema = store.channelConfigSchema(for: channelId) {
ConfigSchemaForm(store: self.store, schema: schema, path: [.key("channels"), .key(self.channelId)]) ConfigSchemaForm(store: store, schema: schema, path: [.key("channels"), .key(channelId)])
} else { } else {
Text("Schema unavailable for this channel.") Text("Schema unavailable for this channel.")
.font(.caption) .font(.caption)
@@ -434,25 +434,25 @@ extension ChannelsSettings {
private func resolveChannelDetailTitle(_ id: String) -> String { private func resolveChannelDetailTitle(_ id: String) -> String {
switch id { switch id {
case "whatsapp": "WhatsApp Web" case "whatsapp": return "WhatsApp Web"
case "telegram": "Telegram Bot" case "telegram": return "Telegram Bot"
case "discord": "Discord Bot" case "discord": return "Discord Bot"
case "slack": "Slack Bot" case "slack": return "Slack Bot"
case "signal": "Signal REST" case "signal": return "Signal REST"
case "imessage": "iMessage" case "imessage": return "iMessage"
default: self.resolveChannelTitle(id) default: return self.resolveChannelTitle(id)
} }
} }
private func resolveChannelSystemImage(_ id: String) -> String { private func resolveChannelSystemImage(_ id: String) -> String {
switch id { switch id {
case "whatsapp": "message" case "whatsapp": return "message"
case "telegram": "paperplane" case "telegram": return "paperplane"
case "discord": "bubble.left.and.bubble.right" case "discord": return "bubble.left.and.bubble.right"
case "slack": "number" case "slack": return "number"
case "signal": "antenna.radiowaves.left.and.right" case "signal": return "antenna.radiowaves.left.and.right"
case "imessage": "message.fill" case "imessage": return "message.fill"
default: "message" default: return "message"
} }
} }
@@ -57,7 +57,7 @@ extension ChannelsStore {
return value return value
} }
guard path.count >= 2 else { return nil } guard path.count >= 2 else { return nil }
if case .key("channels") = path[0], case .key = path[1] { if case .key("channels") = path[0], case .key(_) = path[1] {
let fallbackPath = Array(path.dropFirst()) let fallbackPath = Array(path.dropFirst())
return valueAtPath(self.configDraft, path: fallbackPath) return valueAtPath(self.configDraft, path: fallbackPath)
} }
@@ -93,10 +93,10 @@ private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
var current: Any? = root var current: Any? = root
for segment in path { for segment in path {
switch segment { switch segment {
case let .key(key): case .key(let key):
guard let dict = current as? [String: Any] else { return nil } guard let dict = current as? [String: Any] else { return nil }
current = dict[key] current = dict[key]
case let .index(index): case .index(let index):
guard let array = current as? [Any], array.indices.contains(index) else { return nil } guard let array = current as? [Any], array.indices.contains(index) else { return nil }
current = array[index] current = array[index]
} }
@@ -107,7 +107,7 @@ private func valueAtPath(_ root: Any, path: ConfigPath) -> Any? {
private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) { private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
guard let segment = path.first else { return } guard let segment = path.first else { return }
switch segment { switch segment {
case let .key(key): case .key(let key):
var dict = root as? [String: Any] ?? [:] var dict = root as? [String: Any] ?? [:]
if path.count == 1 { if path.count == 1 {
if let value { if let value {
@@ -122,7 +122,7 @@ private func setValue(_ root: inout Any, path: ConfigPath, value: Any?) {
setValue(&child, path: Array(path.dropFirst()), value: value) setValue(&child, path: Array(path.dropFirst()), value: value)
dict[key] = child dict[key] = child
root = dict root = dict
case let .index(index): case .index(let index):
var array = root as? [Any] ?? [] var array = root as? [Any] ?? []
if index >= array.count { if index >= array.count {
array.append(contentsOf: repeatElement(NSNull() as Any, count: index - array.count + 1)) array.append(contentsOf: repeatElement(NSNull() as Any, count: index - array.count + 1))
@@ -133,7 +133,7 @@ struct ConfigSchemaNode {
for segment in path { for segment in path {
guard let node = current else { return nil } guard let node = current else { return nil }
switch segment { switch segment {
case let .key(key): case .key(let key):
if node.schemaType == "object" { if node.schemaType == "object" {
if let next = node.properties[key] { if let next = node.properties[key] {
current = next current = next
@@ -174,7 +174,7 @@ func hintForPath(_ path: ConfigPath, hints: [String: ConfigUiHint]) -> ConfigUiH
var match = true var match = true
for (index, seg) in segments.enumerated() { for (index, seg) in segments.enumerated() {
let hintSegment = hintSegments[index] let hintSegment = hintSegments[index]
if hintSegment != "*", hintSegment != seg { if hintSegment != "*" && hintSegment != seg {
match = false match = false
break break
} }
@@ -196,7 +196,7 @@ func isSensitivePath(_ path: ConfigPath) -> Bool {
func pathKey(_ path: ConfigPath) -> String { func pathKey(_ path: ConfigPath) -> String {
path.compactMap { segment -> String? in path.compactMap { segment -> String? in
switch segment { switch segment {
case let .key(key): return key case .key(let key): return key
case .index: return nil case .index: return nil
} }
} }
@@ -47,7 +47,7 @@ extension ConfigSettings {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
} }
if self.store.configDirty, !self.isNixMode { if self.store.configDirty && !self.isNixMode {
Text("Unsaved changes") Text("Unsaved changes")
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
@@ -25,10 +25,8 @@ struct Semver: Comparable, CustomStringConvertible, Sendable {
let major = Int(parts[0]), let major = Int(parts[0]),
let minor = Int(parts[1]) let minor = Int(parts[1])
else { return nil } else { return nil }
// Strip prerelease suffix (e.g., "11-4" → "11", "5-beta.1" → "5") let patch = Int(parts[2]) ?? 0
let patchRaw = String(parts[2]) return Semver(major: major, minor: minor, patch: patch)
let patchNumeric = patchRaw.split { $0 == "-" || $0 == "+" }.first.flatMap { Int($0) } ?? 0
return Semver(major: major, minor: minor, patch: patchNumeric)
} }
func compatible(with required: Semver) -> Bool { func compatible(with required: Semver) -> Bool {
@@ -80,13 +78,8 @@ enum GatewayEnvironment {
} }
static func expectedGatewayVersion() -> Semver? { static func expectedGatewayVersion() -> Semver? {
Semver.parse(self.expectedGatewayVersionString())
}
static func expectedGatewayVersionString() -> String? {
let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String let bundleVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
let trimmed = bundleVersion?.trimmingCharacters(in: .whitespacesAndNewlines) return Semver.parse(bundleVersion)
return (trimmed?.isEmpty == false) ? trimmed : nil
} }
// Exposed for tests so we can inject fake version checks without rewriting bundle metadata. // Exposed for tests so we can inject fake version checks without rewriting bundle metadata.
@@ -105,7 +98,6 @@ enum GatewayEnvironment {
} }
} }
let expected = self.expectedGatewayVersion() let expected = self.expectedGatewayVersion()
let expectedString = self.expectedGatewayVersionString()
let projectRoot = CommandResolver.projectRoot() let projectRoot = CommandResolver.projectRoot()
let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot) let projectEntrypoint = CommandResolver.gatewayEntrypoint(in: projectRoot)
@@ -116,8 +108,8 @@ enum GatewayEnvironment {
kind: .missingNode, kind: .missingNode,
nodeVersion: nil, nodeVersion: nil,
gatewayVersion: nil, gatewayVersion: nil,
requiredGateway: expectedString, requiredGateway: expected?.description,
message: RuntimeLocator.describeFailure(err)) message: RuntimeLocator.describeFailure(err))
case let .success(runtime): case let .success(runtime):
let gatewayBin = CommandResolver.clawdbotExecutable() let gatewayBin = CommandResolver.clawdbotExecutable()
@@ -126,7 +118,7 @@ enum GatewayEnvironment {
kind: .missingGateway, kind: .missingGateway,
nodeVersion: runtime.version.description, nodeVersion: runtime.version.description,
gatewayVersion: nil, gatewayVersion: nil,
requiredGateway: expectedString, requiredGateway: expected?.description,
message: "clawdbot CLI not found in PATH; install the CLI.") message: "clawdbot CLI not found in PATH; install the CLI.")
} }
@@ -134,14 +126,13 @@ enum GatewayEnvironment {
?? self.readLocalGatewayVersion(projectRoot: projectRoot) ?? self.readLocalGatewayVersion(projectRoot: projectRoot)
if let expected, let installed, !installed.compatible(with: expected) { if let expected, let installed, !installed.compatible(with: expected) {
let expectedText = expectedString ?? expected.description
return GatewayEnvironmentStatus( return GatewayEnvironmentStatus(
kind: .incompatible(found: installed.description, required: expectedText), kind: .incompatible(found: installed.description, required: expected.description),
nodeVersion: runtime.version.description, nodeVersion: runtime.version.description,
gatewayVersion: installed.description, gatewayVersion: installed.description,
requiredGateway: expectedText, requiredGateway: expected.description,
message: """ message: """
Gateway version \(installed.description) is incompatible with app \(expectedText); Gateway version \(installed.description) is incompatible with app \(expected.description);
install or update the global package. install or update the global package.
""") """)
} }
@@ -159,7 +150,7 @@ enum GatewayEnvironment {
kind: .ok, kind: .ok,
nodeVersion: runtime.version.description, nodeVersion: runtime.version.description,
gatewayVersion: gatewayVersionText, gatewayVersion: gatewayVersionText,
requiredGateway: expectedString, requiredGateway: expected?.description,
message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)") message: "Node \(runtime.version.description); gateway \(gatewayVersionText) \(gatewayLabelText)")
} }
} }
@@ -227,18 +218,8 @@ enum GatewayEnvironment {
} }
static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async { static func installGlobal(version: Semver?, statusHandler: @escaping @Sendable (String) -> Void) async {
await self.installGlobal(versionString: version?.description, statusHandler: statusHandler)
}
static func installGlobal(versionString: String?, statusHandler: @escaping @Sendable (String) -> Void) async {
let preferred = CommandResolver.preferredPaths().joined(separator: ":") let preferred = CommandResolver.preferredPaths().joined(separator: ":")
let trimmed = versionString?.trimmingCharacters(in: .whitespacesAndNewlines) let target = version?.description ?? "latest"
let target: String
if let trimmed, !trimmed.isEmpty {
target = trimmed
} else {
target = "latest"
}
let npm = CommandResolver.findExecutable(named: "npm") let npm = CommandResolver.findExecutable(named: "npm")
let pnpm = CommandResolver.findExecutable(named: "pnpm") let pnpm = CommandResolver.findExecutable(named: "pnpm")
let bun = CommandResolver.findExecutable(named: "bun") let bun = CommandResolver.findExecutable(named: "bun")
@@ -297,7 +278,8 @@ enum GatewayEnvironment {
process.standardOutput = pipe process.standardOutput = pipe
process.standardError = pipe process.standardError = pipe
do { do {
let data = try process.runAndReadToEnd(from: pipe) try process.run()
process.waitUntilExit()
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
if elapsedMs > 500 { if elapsedMs > 500 {
self.logger.warning( self.logger.warning(
@@ -312,6 +294,7 @@ enum GatewayEnvironment {
bin=\(binary, privacy: .public) bin=\(binary, privacy: .public)
""") """)
} }
let data = pipe.fileHandleForReading.readToEndSafely()
let raw = String(data: data, encoding: .utf8)? let raw = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: .whitespacesAndNewlines)
return Semver.parse(raw) return Semver.parse(raw)
@@ -72,11 +72,11 @@ enum LaunchAgentManager {
let process = Process() let process = Process()
process.launchPath = "/bin/launchctl" process.launchPath = "/bin/launchctl"
process.arguments = args process.arguments = args
let pipe = Pipe() process.standardOutput = Pipe()
process.standardOutput = pipe process.standardError = Pipe()
process.standardError = pipe
do { do {
_ = try process.runAndReadToEnd(from: pipe) try process.run()
process.waitUntilExit()
return process.terminationStatus return process.terminationStatus
} catch { } catch {
return -1 return -1
+3 -1
View File
@@ -16,7 +16,9 @@ enum Launchctl {
process.standardOutput = pipe process.standardOutput = pipe
process.standardError = pipe process.standardError = pipe
do { do {
let data = try process.runAndReadToEnd(from: pipe) try process.run()
process.waitUntilExit()
let data = pipe.fileHandleForReading.readToEndSafely()
let output = String(data: data, encoding: .utf8) ?? "" let output = String(data: data, encoding: .utf8) ?? ""
return Result(status: process.terminationStatus, output: output) return Result(status: process.terminationStatus, output: output)
} catch { } catch {
@@ -580,10 +580,11 @@ final class NodePairingApprovalPrompter {
process.standardError = pipe process.standardError = pipe
do { do {
_ = try process.runAndReadToEnd(from: pipe) try process.run()
} catch { } catch {
return false return false
} }
process.waitUntilExit()
return process.terminationStatus == 0 return process.terminationStatus == 0
}.value }.value
} }
+1 -1
View File
@@ -155,7 +155,7 @@ struct OnboardingView: View {
var canAdvance: Bool { !self.isWizardBlocking } var canAdvance: Bool { !self.isWizardBlocking }
var devLinkCommand: String { var devLinkCommand: String {
let version = GatewayEnvironment.expectedGatewayVersionString() ?? "latest" let version = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
return "npm install -g clawdbot@\(version)" return "npm install -g clawdbot@\(version)"
} }
@@ -203,13 +203,15 @@ actor PortGuardian {
proc.standardOutput = pipe proc.standardOutput = pipe
proc.standardError = Pipe() proc.standardError = Pipe()
do { do {
let data = try proc.runAndReadToEnd(from: pipe) try proc.run()
guard !data.isEmpty else { return nil } proc.waitUntilExit()
return String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
} catch { } catch {
return nil return nil
} }
let data = pipe.fileHandleForReading.readToEndSafely()
guard !data.isEmpty else { return nil }
return String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
} }
private static func parseListeners(from text: String) -> [Listener] { private static func parseListeners(from text: String) -> [Listener] {
@@ -1,11 +0,0 @@
import Foundation
extension Process {
/// Runs the process and drains the given pipe before waiting to avoid blocking on full buffers.
func runAndReadToEnd(from pipe: Pipe) throws -> Data {
try self.run()
let data = pipe.fileHandleForReading.readToEndSafely()
self.waitUntilExit()
return data
}
}
@@ -133,7 +133,8 @@ enum RuntimeLocator {
process.standardError = pipe process.standardError = pipe
do { do {
let data = try process.runAndReadToEnd(from: pipe) try process.run()
process.waitUntilExit()
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
if elapsedMs > 500 { if elapsedMs > 500 {
self.logger.warning( self.logger.warning(
@@ -148,6 +149,7 @@ enum RuntimeLocator {
bin=\(binary, privacy: .public) bin=\(binary, privacy: .public)
""") """)
} }
let data = pipe.fileHandleForReading.readToEndSafely()
return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) return String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)
} catch { } catch {
let elapsedMs = Int(Date().timeIntervalSince(start) * 1000) let elapsedMs = Int(Date().timeIntervalSince(start) * 1000)
@@ -357,7 +357,7 @@ public struct SendParams: Codable, Sendable {
gifplayback: Bool?, gifplayback: Bool?,
channel: String?, channel: String?,
accountid: String?, accountid: String?,
sessionkey: String?, sessionkey: String? = nil,
idempotencykey: String idempotencykey: String
) { ) {
self.to = to self.to = to
@@ -431,7 +431,6 @@ public struct AgentParams: Codable, Sendable {
public let deliver: Bool? public let deliver: Bool?
public let attachments: [AnyCodable]? public let attachments: [AnyCodable]?
public let channel: String? public let channel: String?
public let accountid: String?
public let timeout: Int? public let timeout: Int?
public let lane: String? public let lane: String?
public let extrasystemprompt: String? public let extrasystemprompt: String?
@@ -448,7 +447,6 @@ public struct AgentParams: Codable, Sendable {
deliver: Bool?, deliver: Bool?,
attachments: [AnyCodable]?, attachments: [AnyCodable]?,
channel: String?, channel: String?,
accountid: String?,
timeout: Int?, timeout: Int?,
lane: String?, lane: String?,
extrasystemprompt: String?, extrasystemprompt: String?,
@@ -464,7 +462,6 @@ public struct AgentParams: Codable, Sendable {
self.deliver = deliver self.deliver = deliver
self.attachments = attachments self.attachments = attachments
self.channel = channel self.channel = channel
self.accountid = accountid
self.timeout = timeout self.timeout = timeout
self.lane = lane self.lane = lane
self.extrasystemprompt = extrasystemprompt self.extrasystemprompt = extrasystemprompt
@@ -481,7 +478,6 @@ public struct AgentParams: Codable, Sendable {
case deliver case deliver
case attachments case attachments
case channel case channel
case accountid = "accountId"
case timeout case timeout
case lane case lane
case extrasystemprompt = "extraSystemPrompt" case extrasystemprompt = "extraSystemPrompt"
@@ -6,17 +6,9 @@ import Testing
@Test func semverParsesCommonForms() { @Test func semverParsesCommonForms() {
#expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3)) #expect(Semver.parse("1.2.3") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0)) #expect(Semver.parse("v2.0.0") == Semver(major: 2, minor: 0, patch: 0))
#expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 5)) // prerelease suffix stripped #expect(Semver.parse("3.4.5-beta.1") == Semver(major: 3, minor: 4, patch: 0)) // patch drops trailing text
#expect(Semver.parse("2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11)) // build suffix stripped
#expect(Semver.parse("1.0.5+build.123") == Semver(major: 1, minor: 0, patch: 5)) // metadata suffix stripped
#expect(Semver.parse("1.2.3+build.123") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("1.2.3-rc.1+build.7") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("v1.2.3-rc.1") == Semver(major: 1, minor: 2, patch: 3))
#expect(Semver.parse("1.2.0") == Semver(major: 1, minor: 2, patch: 0))
#expect(Semver.parse(nil) == nil) #expect(Semver.parse(nil) == nil)
#expect(Semver.parse("invalid") == nil) #expect(Semver.parse("invalid") == nil)
#expect(Semver.parse("1.2") == nil)
#expect(Semver.parse("1.2.x") == nil)
} }
@Test func semverCompatibilityRequiresSameMajorAndNotOlder() { @Test func semverCompatibilityRequiresSameMajorAndNotOlder() {
@@ -44,7 +36,6 @@ import Testing
@Test func expectedGatewayVersionFromStringUsesParser() { @Test func expectedGatewayVersionFromStringUsesParser() {
#expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2)) #expect(GatewayEnvironment.expectedGatewayVersion(from: "v9.1.2") == Semver(major: 9, minor: 1, patch: 2))
#expect(GatewayEnvironment.expectedGatewayVersion(from: "2026.1.11-4") == Semver(major: 2026, minor: 1, patch: 11))
#expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil) #expect(GatewayEnvironment.expectedGatewayVersion(from: nil) == nil)
} }
} }
+4 -4
View File
@@ -92,13 +92,13 @@ under `hooks.transformsDir` (see [Webhooks](/automation/webhook)).
Use the Clawdbot helper to wire everything together (installs deps on macOS via brew): Use the Clawdbot helper to wire everything together (installs deps on macOS via brew):
```bash ```bash
clawdbot webhooks gmail setup \ clawdbot hooks gmail setup \
--account clawdbot@gmail.com --account clawdbot@gmail.com
``` ```
Defaults: Defaults:
- Uses Tailscale Funnel for the public push endpoint. - Uses Tailscale Funnel for the public push endpoint.
- Writes `hooks.gmail` config for `clawdbot webhooks gmail run`. - Writes `hooks.gmail` config for `clawdbot hooks gmail run`.
- Enables the Gmail hook preset (`hooks.presets: ["gmail"]`). - Enables the Gmail hook preset (`hooks.presets: ["gmail"]`).
Path note: when `tailscale.mode` is enabled, Clawdbot automatically sets Path note: when `tailscale.mode` is enabled, Clawdbot automatically sets
@@ -124,7 +124,7 @@ Gateway auto-start (recommended):
Manual daemon (starts `gog gmail watch serve` + auto-renew): Manual daemon (starts `gog gmail watch serve` + auto-renew):
```bash ```bash
clawdbot webhooks gmail run clawdbot hooks gmail run
``` ```
## One-time setup ## One-time setup
@@ -191,7 +191,7 @@ Notes:
- `--hook-url` points to Clawdbot `/hooks/gmail` (mapped; isolated run + summary to main). - `--hook-url` points to Clawdbot `/hooks/gmail` (mapped; isolated run + summary to main).
- `--include-body` and `--max-bytes` control the body snippet sent to Clawdbot. - `--include-body` and `--max-bytes` control the body snippet sent to Clawdbot.
Recommended: `clawdbot webhooks gmail run` wraps the same flow and auto-renews the watch. Recommended: `clawdbot hooks gmail run` wraps the same flow and auto-renews the watch.
## Expose the handler (advanced, unsupported) ## Expose the handler (advanced, unsupported)
+5 -5
View File
@@ -16,19 +16,19 @@ read_when:
```bash ```bash
# WhatsApp # WhatsApp
clawdbot message poll --target +15555550123 \ clawdbot message poll --to +15555550123 \
--poll-question "Lunch today?" --poll-option "Yes" --poll-option "No" --poll-option "Maybe" --poll-question "Lunch today?" --poll-option "Yes" --poll-option "No" --poll-option "Maybe"
clawdbot message poll --target 123456789@g.us \ clawdbot message poll --to 123456789@g.us \
--poll-question "Meeting time?" --poll-option "10am" --poll-option "2pm" --poll-option "4pm" --poll-multi --poll-question "Meeting time?" --poll-option "10am" --poll-option "2pm" --poll-option "4pm" --poll-multi
# Discord # Discord
clawdbot message poll --channel discord --target channel:123456789 \ clawdbot message poll --channel discord --to channel:123456789 \
--poll-question "Snack?" --poll-option "Pizza" --poll-option "Sushi" --poll-question "Snack?" --poll-option "Pizza" --poll-option "Sushi"
clawdbot message poll --channel discord --target channel:123456789 \ clawdbot message poll --channel discord --to channel:123456789 \
--poll-question "Plan?" --poll-option "A" --poll-option "B" --poll-duration-hours 48 --poll-question "Plan?" --poll-option "A" --poll-option "B" --poll-duration-hours 48
# MS Teams # MS Teams
clawdbot message poll --channel msteams --target conversation:19:abc@thread.tacv2 \ clawdbot message poll --channel msteams --to conversation:19:abc@thread.tacv2 \
--poll-question "Lunch?" --poll-option "Pizza" --poll-option "Sushi" --poll-question "Lunch?" --poll-option "Pizza" --poll-option "Sushi"
``` ```
+1 -1
View File
@@ -96,7 +96,7 @@ Mapping options (summary):
- TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime. - TS transforms require a TS loader (e.g. `bun` or `tsx`) or precompiled `.js` at runtime.
- Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface - Set `deliver: true` + `channel`/`to` on mappings to route replies to a chat surface
(`channel` defaults to `last` and falls back to WhatsApp). (`channel` defaults to `last` and falls back to WhatsApp).
- `clawdbot webhooks gmail setup` writes `hooks.gmail` config for `clawdbot webhooks gmail run`. - `clawdbot hooks gmail setup` writes `hooks.gmail` config for `clawdbot hooks gmail run`.
See [Gmail Pub/Sub](/automation/gmail-pubsub) for the full Gmail watch flow. See [Gmail Pub/Sub](/automation/gmail-pubsub) for the full Gmail watch flow.
## Responses ## Responses
-1
View File
@@ -365,7 +365,6 @@ Allowlist matching notes:
Native command notes: Native command notes:
- The registered commands mirror Clawdbot’s chat commands. - The registered commands mirror Clawdbot’s chat commands.
- Native commands honor the same allowlists as DMs/guild messages (`channels.discord.dm.allowFrom`, `channels.discord.guilds`, per-channel rules). - Native commands honor the same allowlists as DMs/guild messages (`channels.discord.dm.allowFrom`, `channels.discord.guilds`, per-channel rules).
- Slash commands may still be visible in Discord UI to users who aren’t allowlisted; Clawdbot enforces allowlists on execution and replies “not authorized”.
## Tool actions ## Tool actions
The agent can call `discord` with actions like: The agent can call `discord` with actions like:
+1 -1
View File
@@ -447,7 +447,7 @@ By default, Clawdbot only downloads media from Microsoft/Teams hostnames. Overri
## Polls (Adaptive Cards) ## Polls (Adaptive Cards)
Clawdbot sends Teams polls as Adaptive Cards (there is no native Teams poll API). Clawdbot sends Teams polls as Adaptive Cards (there is no native Teams poll API).
- CLI: `clawdbot message poll --channel msteams --target conversation:<id> ...` - CLI: `clawdbot message poll --channel msteams --to conversation:<id> ...`
- Votes are recorded by the gateway in `~/.clawdbot/msteams-polls.json`. - Votes are recorded by the gateway in `~/.clawdbot/msteams-polls.json`.
- The gateway must stay online to record votes. - The gateway must stay online to record votes.
- Polls do not auto-post result summaries yet (inspect the store file if needed). - Polls do not auto-post result summaries yet (inspect the store file if needed).
+1 -14
View File
@@ -360,19 +360,6 @@ To force a voice note bubble in agent replies, include this tag anywhere in the
The tag is stripped from the delivered text. Other channels ignore this tag. The tag is stripped from the delivered text. Other channels ignore this tag.
For message tool sends, set `asVoice: true` with a voice-compatible audio `media` URL
(`message` is optional when media is present):
```json5
{
"action": "send",
"channel": "telegram",
"to": "123456789",
"media": "https://example.com/voice.ogg",
"asVoice": true
}
```
## Streaming (drafts) ## Streaming (drafts)
Telegram can stream **draft bubbles** while the agent is generating a response. Telegram can stream **draft bubbles** while the agent is generating a response.
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the
@@ -457,7 +444,7 @@ The agent sees reactions as **system notifications** in the conversation history
## Delivery targets (CLI/cron) ## Delivery targets (CLI/cron)
- Use a chat id (`123456789`) or a username (`@name`) as the target. - Use a chat id (`123456789`) or a username (`@name`) as the target.
- Example: `clawdbot message send --channel telegram --target 123456789 --message "hi"`. - Example: `clawdbot message send --channel telegram --to 123456789 --message "hi"`.
## Troubleshooting ## Troubleshooting
+1 -1
View File
@@ -124,7 +124,7 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
## Delivery targets (CLI/cron) ## Delivery targets (CLI/cron)
- Use a chat id as the target. - Use a chat id as the target.
- Example: `clawdbot message send --channel zalo --target 123456789 --message "hi"`. - Example: `clawdbot message send --channel zalo --to 123456789 --message "hi"`.
## Troubleshooting ## Troubleshooting
+2 -2
View File
@@ -15,7 +15,7 @@ Directory lookups for channels that support it (contacts/peers, groups, and “m
- `--json`: output JSON - `--json`: output JSON
## Notes ## Notes
- `directory` is meant to help you find IDs you can paste into other commands (especially `clawdbot message send --target ...`). - `directory` is meant to help you find IDs you can paste into other commands (especially `clawdbot message send --to ...`).
- For many channels, results are config-backed (allowlists / configured groups) rather than a live provider directory. - For many channels, results are config-backed (allowlists / configured groups) rather than a live provider directory.
- Default output is `id` (and sometimes `name`) separated by a tab; use `--json` for scripting. - Default output is `id` (and sometimes `name`) separated by a tab; use `--json` for scripting.
@@ -23,7 +23,7 @@ Directory lookups for channels that support it (contacts/peers, groups, and “m
```bash ```bash
clawdbot directory peers list --channel slack --query "U0" clawdbot directory peers list --channel slack --query "U0"
clawdbot message send --channel slack --target user:U012ABCDEF --message "hello" clawdbot message send --channel slack --to user:U012ABCDEF --message "hello"
``` ```
## ID formats (by channel) ## ID formats (by channel)
+44 -75
View File
@@ -1,24 +1,31 @@
--- ---
summary: "CLI reference for `clawdbot hooks` (agent hooks)" summary: "CLI reference for `clawdbot hooks` (internal hooks + Gmail Pub/Sub + webhook helpers)"
read_when: read_when:
- You want to manage agent hooks - You want to manage internal agent hooks
- You want to install or update hooks - You want to wire Gmail Pub/Sub events into Clawdbot hooks
- You want to run the gog watch service and renew loop
--- ---
# `clawdbot hooks` # `clawdbot hooks`
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.). Webhook helpers and hook-based integrations.
Related: Related:
- Hooks: [Hooks](/hooks) - Internal Hooks: [Internal Agent Hooks](/internal-hooks)
- Webhooks: [Webhook](/automation/webhook)
- Gmail Pub/Sub: [Gmail Pub/Sub](/automation/gmail-pubsub)
## List All Hooks ## Internal Hooks
Manage internal agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
### List All Hooks
```bash ```bash
clawdbot hooks list clawdbot hooks internal list
``` ```
List all discovered hooks from workspace, managed, and bundled directories. List all discovered internal hooks from workspace, managed, and bundled directories.
**Options:** **Options:**
- `--eligible`: Show only eligible hooks (requirements met) - `--eligible`: Show only eligible hooks (requirements met)
@@ -28,7 +35,7 @@ List all discovered hooks from workspace, managed, and bundled directories.
**Example output:** **Example output:**
``` ```
Hooks (2/2 ready) Internal Hooks (2/2 ready)
Ready: Ready:
📝 command-logger ✓ - Log all command events to a centralized audit file 📝 command-logger ✓ - Log all command events to a centralized audit file
@@ -38,7 +45,7 @@ Ready:
**Example (verbose):** **Example (verbose):**
```bash ```bash
clawdbot hooks list --verbose clawdbot hooks internal list --verbose
``` ```
Shows missing requirements for ineligible hooks. Shows missing requirements for ineligible hooks.
@@ -46,15 +53,15 @@ Shows missing requirements for ineligible hooks.
**Example (JSON):** **Example (JSON):**
```bash ```bash
clawdbot hooks list --json clawdbot hooks internal list --json
``` ```
Returns structured JSON for programmatic use. Returns structured JSON for programmatic use.
## Get Hook Information ### Get Hook Information
```bash ```bash
clawdbot hooks info <name> clawdbot hooks internal info <name>
``` ```
Show detailed information about a specific hook. Show detailed information about a specific hook.
@@ -68,7 +75,7 @@ Show detailed information about a specific hook.
**Example:** **Example:**
```bash ```bash
clawdbot hooks info session-memory clawdbot hooks internal info session-memory
``` ```
**Output:** **Output:**
@@ -82,17 +89,17 @@ Details:
Source: clawdbot-bundled Source: clawdbot-bundled
Path: /path/to/clawdbot/hooks/bundled/session-memory/HOOK.md Path: /path/to/clawdbot/hooks/bundled/session-memory/HOOK.md
Handler: /path/to/clawdbot/hooks/bundled/session-memory/handler.ts Handler: /path/to/clawdbot/hooks/bundled/session-memory/handler.ts
Homepage: https://docs.clawd.bot/hooks#session-memory Homepage: https://docs.clawd.bot/internal-hooks#session-memory
Events: command:new Events: command:new
Requirements: Requirements:
Config: ✓ workspace.dir Config: ✓ workspace.dir
``` ```
## Check Hooks Eligibility ### Check Hooks Eligibility
```bash ```bash
clawdbot hooks check clawdbot hooks internal check
``` ```
Show summary of hook eligibility status (how many are ready vs. not ready). Show summary of hook eligibility status (how many are ready vs. not ready).
@@ -103,17 +110,17 @@ Show summary of hook eligibility status (how many are ready vs. not ready).
**Example output:** **Example output:**
``` ```
Hooks Status Internal Hooks Status
Total hooks: 2 Total hooks: 2
Ready: 2 Ready: 2
Not ready: 0 Not ready: 0
``` ```
## Enable a Hook ### Enable a Hook
```bash ```bash
clawdbot hooks enable <name> clawdbot hooks internal enable <name>
``` ```
Enable a specific hook by adding it to your config (`~/.clawdbot/config.json`). Enable a specific hook by adding it to your config (`~/.clawdbot/config.json`).
@@ -124,7 +131,7 @@ Enable a specific hook by adding it to your config (`~/.clawdbot/config.json`).
**Example:** **Example:**
```bash ```bash
clawdbot hooks enable session-memory clawdbot hooks internal enable session-memory
``` ```
**Output:** **Output:**
@@ -141,10 +148,10 @@ clawdbot hooks enable session-memory
**After enabling:** **After enabling:**
- Restart the gateway so hooks reload (menu bar app restart on macOS, or restart your gateway process in dev). - Restart the gateway so hooks reload (menu bar app restart on macOS, or restart your gateway process in dev).
## Disable a Hook ### Disable a Hook
```bash ```bash
clawdbot hooks disable <name> clawdbot hooks internal disable <name>
``` ```
Disable a specific hook by updating your config. Disable a specific hook by updating your config.
@@ -155,7 +162,7 @@ Disable a specific hook by updating your config.
**Example:** **Example:**
```bash ```bash
clawdbot hooks disable command-logger clawdbot hooks internal disable command-logger
``` ```
**Output:** **Output:**
@@ -167,53 +174,6 @@ clawdbot hooks disable command-logger
**After disabling:** **After disabling:**
- Restart the gateway so hooks reload - Restart the gateway so hooks reload
## Install Hooks
```bash
clawdbot hooks install <path-or-spec>
```
Install a hook pack from a local folder/archive or npm.
**What it does:**
- Copies the hook pack into `~/.clawdbot/hooks/<id>`
- Enables the installed hooks in `hooks.internal.entries.*`
- Records the install under `hooks.internal.installs`
**Options:**
- `-l, --link`: Link a local directory instead of copying (adds it to `hooks.internal.load.extraDirs`)
**Supported archives:** `.zip`, `.tgz`, `.tar.gz`, `.tar`
**Examples:**
```bash
# Local directory
clawdbot hooks install ./my-hook-pack
# Local archive
clawdbot hooks install ./my-hook-pack.zip
# NPM package
clawdbot hooks install @clawdbot/my-hook-pack
# Link a local directory without copying
clawdbot hooks install -l ./my-hook-pack
```
## Update Hooks
```bash
clawdbot hooks update <id>
clawdbot hooks update --all
```
Update installed hook packs (npm installs only).
**Options:**
- `--all`: Update all tracked hook packs
- `--dry-run`: Show what would change without writing
## Bundled Hooks ## Bundled Hooks
### session-memory ### session-memory
@@ -223,12 +183,12 @@ Saves session context to memory when you issue `/new`.
**Enable:** **Enable:**
```bash ```bash
clawdbot hooks enable session-memory clawdbot hooks internal enable session-memory
``` ```
**Output:** `~/clawd/memory/YYYY-MM-DD-slug.md` **Output:** `~/clawd/memory/YYYY-MM-DD-slug.md`
**See:** [session-memory documentation](/hooks#session-memory) **See:** [session-memory documentation](/internal-hooks#session-memory)
### command-logger ### command-logger
@@ -237,7 +197,7 @@ Logs all command events to a centralized audit file.
**Enable:** **Enable:**
```bash ```bash
clawdbot hooks enable command-logger clawdbot hooks internal enable command-logger
``` ```
**Output:** `~/.clawdbot/logs/commands.log` **Output:** `~/.clawdbot/logs/commands.log`
@@ -255,4 +215,13 @@ cat ~/.clawdbot/logs/commands.log | jq .
grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq . grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
``` ```
**See:** [command-logger documentation](/hooks#command-logger) **See:** [command-logger documentation](/internal-hooks#command-logger)
## Gmail
```bash
clawdbot hooks gmail setup --account you@example.com
clawdbot hooks gmail run
```
See [Gmail Pub/Sub documentation](/automation/gmail-pubsub) for details.
+8 -18
View File
@@ -40,7 +40,6 @@ This page describes the current CLI behavior. If commands change, update this do
- [`dns`](/cli/dns) - [`dns`](/cli/dns)
- [`docs`](/cli/docs) - [`docs`](/cli/docs)
- [`hooks`](/cli/hooks) - [`hooks`](/cli/hooks)
- [`webhooks`](/cli/webhooks)
- [`pairing`](/cli/pairing) - [`pairing`](/cli/pairing)
- [`plugins`](/cli/plugins) (plugin commands) - [`plugins`](/cli/plugins) (plugin commands)
- [`channels`](/cli/channels) - [`channels`](/cli/channels)
@@ -213,14 +212,6 @@ clawdbot [--dev] [--profile <name>] <command>
console console
pdf pdf
hooks hooks
list
info
check
enable
disable
install
update
webhooks
gmail setup|run gmail setup|run
pairing pairing
list list
@@ -292,7 +283,7 @@ Options:
- `--non-interactive` - `--non-interactive`
- `--mode <local|remote>` - `--mode <local|remote>`
- `--flow <quickstart|advanced>` - `--flow <quickstart|advanced>`
- `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>` - `--auth-choice <setup-token|claude-cli|token|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|codex-cli|antigravity|gemini-api-key|zai-api-key|apiKey|minimax-api|opencode-zen|skip>`
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`) - `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
- `--token <token>` (non-interactive; used with `--auth-choice token`) - `--token <token>` (non-interactive; used with `--auth-choice token`)
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`) - `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
@@ -302,7 +293,6 @@ Options:
- `--openrouter-api-key <key>` - `--openrouter-api-key <key>`
- `--ai-gateway-api-key <key>` - `--ai-gateway-api-key <key>`
- `--moonshot-api-key <key>` - `--moonshot-api-key <key>`
- `--kimi-code-api-key <key>`
- `--gemini-api-key <key>` - `--gemini-api-key <key>`
- `--zai-api-key <key>` - `--zai-api-key <key>`
- `--minimax-api-key <key>` - `--minimax-api-key <key>`
@@ -424,12 +414,12 @@ Subcommands:
- `pairing list <channel> [--json]` - `pairing list <channel> [--json]`
- `pairing approve <channel> <code> [--notify]` - `pairing approve <channel> <code> [--notify]`
### `webhooks gmail` ### `hooks gmail`
Gmail Pub/Sub hook setup + runner. See [/automation/gmail-pubsub](/automation/gmail-pubsub). Gmail Pub/Sub hook setup + runner. See [/automation/gmail-pubsub](/automation/gmail-pubsub).
Subcommands: Subcommands:
- `webhooks gmail setup` (requires `--account <email>`; supports `--project`, `--topic`, `--subscription`, `--label`, `--hook-url`, `--hook-token`, `--push-token`, `--bind`, `--port`, `--path`, `--include-body`, `--max-bytes`, `--renew-minutes`, `--tailscale`, `--tailscale-path`, `--tailscale-target`, `--push-endpoint`, `--json`) - `hooks gmail setup` (requires `--account <email>`; supports `--project`, `--topic`, `--subscription`, `--label`, `--hook-url`, `--hook-token`, `--push-token`, `--bind`, `--port`, `--path`, `--include-body`, `--max-bytes`, `--renew-minutes`, `--tailscale`, `--tailscale-path`, `--tailscale-target`, `--push-endpoint`, `--json`)
- `webhooks gmail run` (runtime overrides for the same flags) - `hooks gmail run` (runtime overrides for the same flags)
### `dns setup` ### `dns setup`
Wide-area discovery DNS helper (CoreDNS + Tailscale). See [/gateway/discovery](/gateway/discovery). Wide-area discovery DNS helper (CoreDNS + Tailscale). See [/gateway/discovery](/gateway/discovery).
@@ -456,8 +446,8 @@ Subcommands:
- `message event <list|create>` - `message event <list|create>`
Examples: Examples:
- `clawdbot message send --target +15555550123 --message "Hi"` - `clawdbot message send --to +15555550123 --message "Hi"`
- `clawdbot message poll --channel discord --target channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi` - `clawdbot message poll --channel discord --to channel:123 --poll-question "Snack?" --poll-option Pizza --poll-option Sushi`
### `agent` ### `agent`
Run one agent turn via the Gateway (or `--local` embedded). Run one agent turn via the Gateway (or `--local` embedded).
@@ -469,7 +459,7 @@ Options:
- `--to <dest>` (for session key and optional delivery) - `--to <dest>` (for session key and optional delivery)
- `--session-id <id>` - `--session-id <id>`
- `--thinking <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only) - `--thinking <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only)
- `--verbose <on|full|off>` - `--verbose <on|off>`
- `--channel <whatsapp|telegram|discord|slack|signal|imessage>` - `--channel <whatsapp|telegram|discord|slack|signal|imessage>`
- `--local` - `--local`
- `--deliver` - `--deliver`
@@ -528,7 +518,7 @@ Surfaces:
Notes: Notes:
- Data comes directly from provider usage endpoints (no estimates). - Data comes directly from provider usage endpoints (no estimates).
- Providers: Anthropic, GitHub Copilot, OpenAI Codex OAuth, plus Gemini CLI/Antigravity when those provider plugins are enabled. - Providers: Anthropic, GitHub Copilot, Gemini CLI, Antigravity, OpenAI Codex OAuth, plus z.ai when an API key is configured.
- If no matching credentials exist, usage is hidden. - If no matching credentials exist, usage is hidden.
- Details: see [Usage tracking](/concepts/usage-tracking). - Details: see [Usage tracking](/concepts/usage-tracking).
+29 -25
View File
@@ -21,7 +21,7 @@ Channel selection:
- If exactly one channel is configured, it becomes the default. - If exactly one channel is configured, it becomes the default.
- Values: `whatsapp|telegram|discord|slack|signal|imessage|msteams` - Values: `whatsapp|telegram|discord|slack|signal|imessage|msteams`
Target formats (`--target`): Target formats (`--to`):
- WhatsApp: E.164 or group JID - WhatsApp: E.164 or group JID
- Telegram: chat id or `@username` - Telegram: chat id or `@username`
- Discord: `channel:<id>` or `user:<id>` (or `<@id>` mention; raw numeric ids are treated as channels) - Discord: `channel:<id>` or `user:<id>` (or `<@id>` mention; raw numeric ids are treated as channels)
@@ -38,7 +38,6 @@ Name lookup:
- `--channel <name>` - `--channel <name>`
- `--account <id>` - `--account <id>`
- `--target <dest>` (target channel or user for send/poll/read/etc)
- `--targets <name>` (repeat; broadcast only) - `--targets <name>` (repeat; broadcast only)
- `--json` - `--json`
- `--dry-run` - `--dry-run`
@@ -50,7 +49,7 @@ Name lookup:
- `send` - `send`
- Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams - Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
- Required: `--target`, plus `--message` or `--media` - Required: `--to`, plus `--message` or `--media`
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback` - Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
- Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it) - Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it)
- Telegram only: `--thread-id` (forum topic id) - Telegram only: `--thread-id` (forum topic id)
@@ -59,47 +58,52 @@ Name lookup:
- `poll` - `poll`
- Channels: WhatsApp/Discord/MS Teams - Channels: WhatsApp/Discord/MS Teams
- Required: `--target`, `--poll-question`, `--poll-option` (repeat) - Required: `--to`, `--poll-question`, `--poll-option` (repeat)
- Optional: `--poll-multi` - Optional: `--poll-multi`
- Discord only: `--poll-duration-hours`, `--message` - Discord only: `--poll-duration-hours`, `--message`
- `react` - `react`
- Channels: Discord/Slack/Telegram/WhatsApp - Channels: Discord/Slack/Telegram/WhatsApp
- Required: `--message-id`, `--target` - Required: `--message-id`, `--to` or `--channel-id`
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me` - Optional: `--emoji`, `--remove`, `--participant`, `--from-me`, `--channel-id`
- Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions) - Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions)
- WhatsApp only: `--participant`, `--from-me` - WhatsApp only: `--participant`, `--from-me`
- `reactions` - `reactions`
- Channels: Discord/Slack - Channels: Discord/Slack
- Required: `--message-id`, `--target` - Required: `--message-id`, `--to` or `--channel-id`
- Optional: `--limit` - Optional: `--limit`, `--channel-id`
- `read` - `read`
- Channels: Discord/Slack - Channels: Discord/Slack
- Required: `--target` - Required: `--to` or `--channel-id`
- Optional: `--limit`, `--before`, `--after` - Optional: `--limit`, `--before`, `--after`, `--channel-id`
- Discord only: `--around` - Discord only: `--around`
- `edit` - `edit`
- Channels: Discord/Slack - Channels: Discord/Slack
- Required: `--message-id`, `--message`, `--target` - Required: `--message-id`, `--message`, `--to` or `--channel-id`
- Optional: `--channel-id`
- `delete` - `delete`
- Channels: Discord/Slack/Telegram - Channels: Discord/Slack/Telegram
- Required: `--message-id`, `--target` - Required: `--message-id`, `--to` or `--channel-id`
- Optional: `--channel-id`
- `pin` / `unpin` - `pin` / `unpin`
- Channels: Discord/Slack - Channels: Discord/Slack
- Required: `--message-id`, `--target` - Required: `--message-id`, `--to` or `--channel-id`
- Optional: `--channel-id`
- `pins` (list) - `pins` (list)
- Channels: Discord/Slack - Channels: Discord/Slack
- Required: `--target` - Required: `--to` or `--channel-id`
- Optional: `--channel-id`
- `permissions` - `permissions`
- Channels: Discord - Channels: Discord
- Required: `--target` - Required: `--to` or `--channel-id`
- Optional: `--channel-id`
- `search` - `search`
- Channels: Discord - Channels: Discord
@@ -110,7 +114,7 @@ Name lookup:
- `thread create` - `thread create`
- Channels: Discord - Channels: Discord
- Required: `--thread-name`, `--target` (channel id) - Required: `--thread-name`, `--to` (channel id) or `--channel-id`
- Optional: `--message-id`, `--auto-archive-min` - Optional: `--message-id`, `--auto-archive-min`
- `thread list` - `thread list`
@@ -120,7 +124,7 @@ Name lookup:
- `thread reply` - `thread reply`
- Channels: Discord - Channels: Discord
- Required: `--target` (thread id), `--message` - Required: `--to` (thread id), `--message`
- Optional: `--media`, `--reply-to` - Optional: `--media`, `--reply-to`
### Emojis ### Emojis
@@ -138,7 +142,7 @@ Name lookup:
- `sticker send` - `sticker send`
- Channels: Discord - Channels: Discord
- Required: `--target`, `--sticker-id` (repeat) - Required: `--to`, `--sticker-id` (repeat)
- Optional: `--message` - Optional: `--message`
- `sticker upload` - `sticker upload`
@@ -149,7 +153,7 @@ Name lookup:
- `role info` (Discord): `--guild-id` - `role info` (Discord): `--guild-id`
- `role add` / `role remove` (Discord): `--guild-id`, `--user-id`, `--role-id` - `role add` / `role remove` (Discord): `--guild-id`, `--user-id`, `--role-id`
- `channel info` (Discord): `--target` - `channel info` (Discord): `--channel-id`
- `channel list` (Discord): `--guild-id` - `channel list` (Discord): `--guild-id`
- `member info` (Discord/Slack): `--user-id` (+ `--guild-id` for Discord) - `member info` (Discord/Slack): `--user-id` (+ `--guild-id` for Discord)
- `voice status` (Discord): `--guild-id`, `--user-id` - `voice status` (Discord): `--guild-id`, `--user-id`
@@ -179,13 +183,13 @@ Name lookup:
Send a Discord reply: Send a Discord reply:
``` ```
clawdbot message send --channel discord \ clawdbot message send --channel discord \
--target channel:123 --message "hi" --reply-to 456 --to channel:123 --message "hi" --reply-to 456
``` ```
Create a Discord poll: Create a Discord poll:
``` ```
clawdbot message poll --channel discord \ clawdbot message poll --channel discord \
--target channel:123 \ --to channel:123 \
--poll-question "Snack?" \ --poll-question "Snack?" \
--poll-option Pizza --poll-option Sushi \ --poll-option Pizza --poll-option Sushi \
--poll-multi --poll-duration-hours 48 --poll-multi --poll-duration-hours 48
@@ -194,13 +198,13 @@ clawdbot message poll --channel discord \
Send a Teams proactive message: Send a Teams proactive message:
``` ```
clawdbot message send --channel msteams \ clawdbot message send --channel msteams \
--target conversation:19:abc@thread.tacv2 --message "hi" --to conversation:19:abc@thread.tacv2 --message "hi"
``` ```
Create a Teams poll: Create a Teams poll:
``` ```
clawdbot message poll --channel msteams \ clawdbot message poll --channel msteams \
--target conversation:19:abc@thread.tacv2 \ --to conversation:19:abc@thread.tacv2 \
--poll-question "Lunch?" \ --poll-question "Lunch?" \
--poll-option Pizza --poll-option Sushi --poll-option Pizza --poll-option Sushi
``` ```
@@ -208,11 +212,11 @@ clawdbot message poll --channel msteams \
React in Slack: React in Slack:
``` ```
clawdbot message react --channel slack \ clawdbot message react --channel slack \
--target C123 --message-id 456 --emoji "✅" --to C123 --message-id 456 --emoji "✅"
``` ```
Send Telegram inline buttons: Send Telegram inline buttons:
``` ```
clawdbot message send --channel telegram --target @mychat --message "Choose:" \ clawdbot message send --channel telegram --to @mychat --message "Choose:" \
--buttons '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]' --buttons '[ [{"text":"Yes","callback_data":"cmd:yes"}], [{"text":"No","callback_data":"cmd:no"}] ]'
``` ```
+1 -12
View File
@@ -25,25 +25,14 @@ clawdbot plugins update <id>
clawdbot plugins update --all clawdbot plugins update --all
``` ```
Bundled plugins ship with Clawdbot but start disabled. Use `plugins enable` to
activate them.
### Install ### Install
```bash ```bash
clawdbot plugins install <path-or-spec> clawdbot plugins install <npm-spec>
``` ```
Security note: treat plugin installs like running code. Prefer pinned versions. Security note: treat plugin installs like running code. Prefer pinned versions.
Supported archives: `.zip`, `.tgz`, `.tar.gz`, `.tar`.
Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`):
```bash
clawdbot plugins install -l ./my-plugin
```
### Update ### Update
```bash ```bash
-6
View File
@@ -15,8 +15,6 @@ If you installed via **npm/pnpm** (global install, no git metadata), use the pac
```bash ```bash
clawdbot update clawdbot update
clawdbot update --channel beta
clawdbot update --tag beta
clawdbot update --restart clawdbot update --restart
clawdbot update --json clawdbot update --json
clawdbot --update clawdbot --update
@@ -25,13 +23,9 @@ clawdbot --update
## Options ## Options
- `--restart`: restart the Gateway daemon after a successful update. - `--restart`: restart the Gateway daemon after a successful update.
- `--channel <stable|beta>`: set the update channel for npm installs (persisted in config).
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
- `--json`: print machine-readable `UpdateRunResult` JSON. - `--json`: print machine-readable `UpdateRunResult` JSON.
- `--timeout <seconds>`: per-step timeout (default is 1200s). - `--timeout <seconds>`: per-step timeout (default is 1200s).
Note: downgrades require confirmation because older versions can break configuration.
## What it does (git checkout) ## What it does (git checkout)
High-level: High-level:
-23
View File
@@ -1,23 +0,0 @@
---
summary: "CLI reference for `clawdbot webhooks` (webhook helpers + Gmail Pub/Sub)"
read_when:
- You want to wire Gmail Pub/Sub events into Clawdbot
- You want webhook helper commands
---
# `clawdbot webhooks`
Webhook helpers and integrations (Gmail Pub/Sub, webhook helpers).
Related:
- Webhooks: [Webhook](/automation/webhook)
- Gmail Pub/Sub: [Gmail Pub/Sub](/automation/gmail-pubsub)
## Gmail
```bash
clawdbot webhooks gmail setup --account you@example.com
clawdbot webhooks gmail run
```
See [Gmail Pub/Sub documentation](/automation/gmail-pubsub) for details.
-4
View File
@@ -85,10 +85,6 @@ When a channel supplies history, it uses a shared wrapper:
- `[Chat messages since your last reply - for context]` - `[Chat messages since your last reply - for context]`
- `[Current message - respond to this]` - `[Current message - respond to this]`
For **non-direct chats** (groups/channels/rooms), the **current message body** is prefixed with the
sender label (same style used for history entries). This keeps real-time and queued/history
messages consistent in the agent prompt.
History buffers are **pending-only**: they include group messages that did *not* History buffers are **pending-only**: they include group messages that did *not*
trigger a run (for example, mention-gated messages) and **exclude** messages trigger a run (for example, mention-gated messages) and **exclude** messages
already in the session transcript. already in the session transcript.
+1 -34
View File
@@ -83,12 +83,7 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no**
- Providers: `google-vertex`, `google-antigravity`, `google-gemini-cli` - Providers: `google-vertex`, `google-antigravity`, `google-gemini-cli`
- Auth: Vertex uses gcloud ADC; Antigravity/Gemini CLI use their respective auth flows - Auth: Vertex uses gcloud ADC; Antigravity/Gemini CLI use their respective auth flows
- Antigravity OAuth is shipped as a bundled plugin (`google-antigravity-auth`, disabled by default). - CLI: `clawdbot onboard --auth-choice antigravity` (others via interactive wizard)
- Enable: `clawdbot plugins enable google-antigravity-auth`
- Login: `clawdbot models auth login --provider google-antigravity --set-default`
- Gemini CLI OAuth is shipped as a bundled plugin (`google-gemini-cli-auth`, disabled by default).
- Enable: `clawdbot plugins enable google-gemini-cli-auth`
- Login: `clawdbot models auth login --provider google-gemini-cli --set-default`
### Z.AI (GLM) ### Z.AI (GLM)
@@ -155,34 +150,6 @@ Moonshot uses OpenAI-compatible endpoints, so configure it as a custom provider:
} }
``` ```
### Kimi Code
Kimi Code uses a dedicated endpoint and key (separate from Moonshot):
- Provider: `kimi-code`
- Auth: `KIMICODE_API_KEY`
- Example model: `kimi-code/kimi-for-coding`
```json5
{
env: { KIMICODE_API_KEY: "sk-..." },
agents: {
defaults: { model: { primary: "kimi-code/kimi-for-coding" } }
},
models: {
mode: "merge",
providers: {
"kimi-code": {
baseUrl: "https://api.kimi.com/coding/v1",
apiKey: "${KIMICODE_API_KEY}",
api: "openai-completions",
models: [{ id: "kimi-for-coding", name: "Kimi For Coding" }]
}
}
}
}
```
### Synthetic ### Synthetic
Synthetic provides Anthropic-compatible models behind the `synthetic` provider: Synthetic provides Anthropic-compatible models behind the `synthetic` provider:
-1
View File
@@ -48,7 +48,6 @@ Row shape (JSON):
- `thinkingLevel`, `verboseLevel`, `systemSent`, `abortedLastRun` - `thinkingLevel`, `verboseLevel`, `systemSent`, `abortedLastRun`
- `sendPolicy` (session override if set) - `sendPolicy` (session override if set)
- `lastChannel`, `lastTo` - `lastChannel`, `lastTo`
- `deliveryContext` (normalized `{ channel, to, accountId }` when available)
- `transcriptPath` (best-effort path derived from store dir + sessionId) - `transcriptPath` (best-effort path derived from store dir + sessionId)
- `messages?` (only when `messageLimit > 0`) - `messages?` (only when `messageLimit > 0`)
+1 -1
View File
@@ -11,7 +11,7 @@ read_when:
- No estimated costs; only the provider-reported windows. - No estimated costs; only the provider-reported windows.
## Where it shows up ## Where it shows up
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). Provider usage shows for the **current model provider** when available. - `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). When OAuth/token profiles exist, the **OAuth/token status block** includes provider usage headers (when available).
- `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only). - `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only).
- CLI: `clawdbot status --usage` prints a full per-provider breakdown. - CLI: `clawdbot status --usage` prints a full per-provider breakdown.
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip). - CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
+1
View File
@@ -894,6 +894,7 @@
"gateway/heartbeat", "gateway/heartbeat",
"gateway/doctor", "gateway/doctor",
"gateway/logging", "gateway/logging",
"logging",
"gateway/security", "gateway/security",
"gateway/sandbox-vs-tool-policy-vs-elevated", "gateway/sandbox-vs-tool-policy-vs-elevated",
"gateway/sandboxing", "gateway/sandboxing",
+1 -3
View File
@@ -17,7 +17,7 @@ Key parameters:
- `background` (bool): background immediately - `background` (bool): background immediately
- `timeout` (seconds, default 1800): kill the process after this timeout - `timeout` (seconds, default 1800): kill the process after this timeout
- `elevated` (bool): run on host if elevated mode is enabled/allowed - `elevated` (bool): run on host if elevated mode is enabled/allowed
- Need a real TTY? Set `pty: true`. - Need a real TTY? Use the tmux skill.
- `workdir`, `env` - `workdir`, `env`
Behavior: Behavior:
@@ -33,14 +33,12 @@ When spawning long-running child processes outside the exec/process tools (for e
Environment overrides: Environment overrides:
- `PI_BASH_YIELD_MS`: default yield (ms) - `PI_BASH_YIELD_MS`: default yield (ms)
- `PI_BASH_MAX_OUTPUT_CHARS`: in‑memory output cap (chars) - `PI_BASH_MAX_OUTPUT_CHARS`: in‑memory output cap (chars)
- `CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS`: pending stdout/stderr cap per stream (chars)
- `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m–3h) - `PI_BASH_JOB_TTL_MS`: TTL for finished sessions (ms, bounded to 1m–3h)
Config (preferred): Config (preferred):
- `tools.exec.backgroundMs` (default 10000) - `tools.exec.backgroundMs` (default 10000)
- `tools.exec.timeoutSec` (default 1800) - `tools.exec.timeoutSec` (default 1800)
- `tools.exec.cleanupMs` (default 1800000) - `tools.exec.cleanupMs` (default 1800000)
- `tools.exec.notifyOnExit` (default true): enqueue a system event + request heartbeat when a backgrounded exec exits.
## process tool ## process tool
+3 -14
View File
@@ -124,21 +124,10 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
// Tooling // Tooling
tools: { tools: {
media: { audio: {
audio: { transcription: {
enabled: true, args: ["--model", "base", "{{MediaPath}}"],
maxBytes: 20971520,
models: [
{ provider: "openai", model: "whisper-1" },
// Optional CLI fallback (Whisper binary):
// { type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
],
timeoutSeconds: 120 timeoutSeconds: 120
},
video: {
enabled: true,
maxBytes: 52428800,
models: [{ provider: "google", model: "gemini-3-flash-preview" }]
} }
} }
}, },
+4 -104
View File
@@ -1745,10 +1745,10 @@ of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
- `backgroundMs`: time before auto-background (ms, default 10000) - `backgroundMs`: time before auto-background (ms, default 10000)
- `timeoutSec`: auto-kill after this runtime (seconds, default 1800) - `timeoutSec`: auto-kill after this runtime (seconds, default 1800)
- `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000) - `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000)
- `notifyOnExit`: enqueue a system event + request heartbeat when backgrounded exec exits (default true)
- `applyPatch.enabled`: enable experimental `apply_patch` (OpenAI/OpenAI Codex only; default false) - `applyPatch.enabled`: enable experimental `apply_patch` (OpenAI/OpenAI Codex only; default false)
- `applyPatch.allowModels`: optional allowlist of model ids (e.g. `gpt-5.2` or `openai/gpt-5.2`) - `applyPatch.allowModels`: optional allowlist of model ids (e.g. `gpt-5.2` or `openai/gpt-5.2`)
Note: `applyPatch` is only under `tools.exec`. Note: `applyPatch` is only under `tools.exec` (no `tools.bash` alias).
Legacy: `tools.bash` is still accepted as an alias.
`tools.web` configures web search + fetch tools: `tools.web` configures web search + fetch tools:
- `tools.web.search.enabled` (default: true when key is present) - `tools.web.search.enabled` (default: true when key is present)
@@ -1769,61 +1769,6 @@ Note: `applyPatch` is only under `tools.exec`.
- `tools.web.fetch.firecrawl.maxAgeMs` (optional) - `tools.web.fetch.firecrawl.maxAgeMs` (optional)
- `tools.web.fetch.firecrawl.timeoutSeconds` (optional) - `tools.web.fetch.firecrawl.timeoutSeconds` (optional)
`tools.media` configures inbound media understanding (image/audio/video):
- `tools.media.models`: shared model list (capability-tagged; used after per-cap lists).
- `tools.media.concurrency`: max concurrent capability runs (default 2).
- `tools.media.image` / `tools.media.audio` / `tools.media.video`:
- `enabled`: opt-out switch (default true when models are configured).
- `prompt`: optional prompt override (image/video append a `maxChars` hint automatically).
- `maxChars`: max output characters (default 500 for image/video; unset for audio).
- `maxBytes`: max media size to send (defaults: image 10MB, audio 20MB, video 50MB).
- `timeoutSeconds`: request timeout (defaults: image 60s, audio 60s, video 120s).
- `language`: optional audio hint.
- `attachments`: attachment policy (`mode`, `maxAttachments`, `prefer`).
- `scope`: optional gating (first match wins) with `match.channel`, `match.chatType`, or `match.keyPrefix`.
- `models`: ordered list of model entries; failures or oversize media fall back to the next entry.
- Each `models[]` entry:
- Provider entry (`type: "provider"` or omitted):
- `provider`: API provider id (`openai`, `anthropic`, `google`/`gemini`, `groq`, etc).
- `model`: model id override (required for image; defaults to `whisper-1`/`whisper-large-v3-turbo` for audio providers, and `gemini-3-flash-preview` for video).
- `profile` / `preferredProfile`: auth profile selection.
- CLI entry (`type: "cli"`):
- `command`: executable to run.
- `args`: templated args (supports `{{MediaPath}}`, `{{Prompt}}`, `{{MaxChars}}`, etc).
- `capabilities`: optional list (`image`, `audio`, `video`) to gate a shared entry. Defaults when omitted: `openai`/`anthropic`/`minimax` → image, `google` → image+audio+video, `groq` → audio.
- `prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language` can be overridden per entry.
If no models are configured (or `enabled: false`), understanding is skipped; the model still receives the original attachments.
Provider auth follows the standard model auth order (auth profiles, env vars like `OPENAI_API_KEY`/`GROQ_API_KEY`/`GEMINI_API_KEY`, or `models.providers.*.apiKey`).
Example:
```json5
{
tools: {
media: {
audio: {
enabled: true,
maxBytes: 20971520,
scope: {
default: "deny",
rules: [{ action: "allow", match: { chatType: "direct" } }]
},
models: [
{ provider: "openai", model: "whisper-1" },
{ type: "cli", command: "whisper", args: ["--model", "base", "{{MediaPath}}"] }
]
},
video: {
enabled: true,
maxBytes: 52428800,
models: [{ provider: "google", model: "gemini-3-flash-preview" }]
}
}
}
}
```
`agents.defaults.subagents` configures sub-agent defaults: `agents.defaults.subagents` configures sub-agent defaults:
- `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call. - `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call.
- `maxConcurrent`: max concurrent sub-agent runs (default 1) - `maxConcurrent`: max concurrent sub-agent runs (default 1)
@@ -2234,49 +2179,6 @@ Notes:
- Model ref: `moonshot/kimi-k2-0905-preview`. - Model ref: `moonshot/kimi-k2-0905-preview`.
- Use `https://api.moonshot.cn/v1` if you need the China endpoint. - Use `https://api.moonshot.cn/v1` if you need the China endpoint.
### Kimi Code
Use Kimi Code's dedicated OpenAI-compatible endpoint (separate from Moonshot):
```json5
{
env: { KIMICODE_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "kimi-code/kimi-for-coding" },
models: { "kimi-code/kimi-for-coding": { alias: "Kimi Code" } }
}
},
models: {
mode: "merge",
providers: {
"kimi-code": {
baseUrl: "https://api.kimi.com/coding/v1",
apiKey: "${KIMICODE_API_KEY}",
api: "openai-completions",
models: [
{
id: "kimi-for-coding",
name: "Kimi For Coding",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 32768,
headers: { "User-Agent": "KimiCLI/0.77" },
compat: { supportsDeveloperRole: false }
}
]
}
}
}
}
```
Notes:
- Set `KIMICODE_API_KEY` in the environment or use `clawdbot onboard --auth-choice kimi-code-api-key`.
- Model ref: `kimi-code/kimi-for-coding`.
### Synthetic (Anthropic-compatible) ### Synthetic (Anthropic-compatible)
Use Synthetic's Anthropic-compatible endpoint: Use Synthetic's Anthropic-compatible endpoint:
@@ -2800,7 +2702,7 @@ Mapping notes:
- If there is no prior delivery route, set `channel` + `to` explicitly (required for Telegram/Discord/Slack/Signal/iMessage/MS Teams). - If there is no prior delivery route, set `channel` + `to` explicitly (required for Telegram/Discord/Slack/Signal/iMessage/MS Teams).
- `model` overrides the LLM for this hook run (`provider/model` or alias; must be allowed if `agents.defaults.models` is set). - `model` overrides the LLM for this hook run (`provider/model` or alias; must be allowed if `agents.defaults.models` is set).
Gmail helper config (used by `clawdbot webhooks gmail setup` / `run`): Gmail helper config (used by `clawdbot hooks gmail setup` / `run`):
```json5 ```json5
{ {
@@ -2946,7 +2848,7 @@ clawdbot dns setup --apply
## Template variables ## Template variables
Template placeholders are expanded in `tools.media.*.models[].args` and `tools.media.models[].args` (and any future templated argument fields). Template placeholders are expanded in `tools.audio.transcription.args` (and any future templated argument fields).
| Variable | Description | | Variable | Description |
|----------|-------------| |----------|-------------|
@@ -2962,8 +2864,6 @@ Template placeholders are expanded in `tools.media.*.models[].args` and `tools.m
| `{{MediaPath}}` | Local media path (if downloaded) | | `{{MediaPath}}` | Local media path (if downloaded) |
| `{{MediaType}}` | Media type (image/audio/document/…) | | `{{MediaType}}` | Media type (image/audio/document/…) |
| `{{Transcript}}` | Audio transcript (when enabled) | | `{{Transcript}}` | Audio transcript (when enabled) |
| `{{Prompt}}` | Resolved media prompt for CLI entries |
| `{{MaxChars}}` | Resolved max output chars for CLI entries |
| `{{ChatType}}` | `"direct"` or `"group"` | | `{{ChatType}}` | `"direct"` or `"group"` |
| `{{GroupSubject}}` | Group subject (best effort) | | `{{GroupSubject}}` | Group subject (best effort) |
| `{{GroupMembers}}` | Group members preview (best effort) | | `{{GroupMembers}}` | Group members preview (best effort) |
+1 -1
View File
@@ -111,7 +111,7 @@ Current migrations:
- `routing.bindings` → top-level `bindings` - `routing.bindings` → top-level `bindings`
- `routing.agents`/`routing.defaultAgentId` → `agents.list` + `agents.list[].default` - `routing.agents`/`routing.defaultAgentId` → `agents.list` + `agents.list[].default`
- `routing.agentToAgent` → `tools.agentToAgent` - `routing.agentToAgent` → `tools.agentToAgent`
- `routing.transcribeAudio` → `tools.media.audio.models` - `routing.transcribeAudio` → `tools.audio.transcription`
- `bindings[].match.accountID` → `bindings[].match.accountId` - `bindings[].match.accountID` → `bindings[].match.accountId`
- `identity` → `agents.list[].identity` - `identity` → `agents.list[].identity`
- `agent.*` → `agents.defaults` + `tools.*` (tools/elevated/exec/sandbox/subagents) - `agent.*` → `agents.defaults` + `tools.*` (tools/elevated/exec/sandbox/subagents)
+1 -1
View File
@@ -281,7 +281,7 @@ Windows installs should use **WSL2** and follow the Linux systemd section above.
## CLI helpers ## CLI helpers
- `clawdbot gateway health|status` — request health/status over the Gateway WS. - `clawdbot gateway health|status` — request health/status over the Gateway WS.
- `clawdbot message send --target <num> --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp). - `clawdbot message send --to <num> --message "hi" [--media ...]` — send via Gateway (idempotent for WhatsApp).
- `clawdbot agent --message "hi" --to <num>` — run an agent turn (waits for final by default). - `clawdbot agent --message "hi" --to <num>` — run an agent turn (waits for final by default).
- `clawdbot gateway call <method> --params '{"k":"v"}'` — raw method invoker for debugging. - `clawdbot gateway call <method> --params '{"k":"v"}'` — raw method invoker for debugging.
- `clawdbot daemon stop|restart` — stop/restart the supervised gateway service (launchd/systemd). - `clawdbot daemon stop|restart` — stop/restart the supervised gateway service (launchd/systemd).
+1 -1
View File
@@ -137,7 +137,7 @@ clawdbot gateway --port 19001
Send a test message (requires a running Gateway): Send a test message (requires a running Gateway):
```bash ```bash
clawdbot message send --target +15555550123 --message "Hello from Clawdbot" clawdbot message send --to +15555550123 --message "Hello from Clawdbot"
``` ```
## Configuration (optional) ## Configuration (optional)
+1 -17
View File
@@ -50,22 +50,6 @@ pnpm add -g clawdbot@latest
``` ```
We do **not** recommend Bun for the Gateway runtime (WhatsApp/Telegram bugs). We do **not** recommend Bun for the Gateway runtime (WhatsApp/Telegram bugs).
To stay on the beta channel for CLI updates:
```bash
clawdbot update --channel beta
```
Switch back to stable later:
```bash
clawdbot update --channel stable
```
Use `--tag <dist-tag|version>` for a one-off install tag/version.
Note: on npm installs, the gateway logs an update hint on startup (checks the current channel tag). Disable via `update.checkOnStart: false`.
Then: Then:
```bash ```bash
@@ -91,7 +75,7 @@ It runs a safe-ish update flow:
- Fetches + rebases against the configured upstream. - Fetches + rebases against the configured upstream.
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`. - Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it can’t detect the install, use “Update (global install)” instead. If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will skip. Use “Update (global install)” instead.
## Update (Control UI / RPC) ## Update (Control UI / RPC)
+47 -73
View File
@@ -1,19 +1,19 @@
--- ---
summary: "Hooks: event-driven automation for commands and lifecycle events" summary: "Internal agent hooks: event-driven automation for commands and lifecycle events"
read_when: read_when:
- You want event-driven automation for /new, /reset, /stop, and agent lifecycle events - You want event-driven automation for /new, /reset, /stop, and agent lifecycle events
- You want to build, install, or debug hooks - You want to build, install, or debug internal hooks
--- ---
# Hooks # Internal Agent Hooks
Hooks provide an extensible event-driven system for automating actions in response to agent commands and events. Hooks are automatically discovered from directories and can be managed via CLI commands, similar to how skills work in Clawdbot. Internal hooks provide an extensible event-driven system for automating actions in response to agent commands and events. Hooks are automatically discovered from directories and can be managed via CLI commands, similar to how skills work in Clawdbot.
## Getting Oriented ## Getting Oriented
Hooks are small scripts that run when something happens. There are two kinds: Hooks are small scripts that run when something happens. There are two kinds:
- **Hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events. - **Internal hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
- **Webhooks**: external HTTP webhooks that let other systems trigger work in Clawdbot. See [Webhook Hooks](/automation/webhook) or use `clawdbot webhooks` for Gmail helper commands. - **Web-based hooks**: external HTTP webhooks that let other systems trigger work in Clawdbot. See [Webhook Hooks](/automation/webhook).
Common uses: Common uses:
- Save a memory snapshot when you reset a session - Save a memory snapshot when you reset a session
@@ -21,11 +21,11 @@ Common uses:
- Trigger follow-up automation when a session starts or ends - Trigger follow-up automation when a session starts or ends
- Write files into the agent workspace or call external APIs when events fire - Write files into the agent workspace or call external APIs when events fire
If you can write a small TypeScript function, you can write a hook. Hooks are discovered automatically, and you enable or disable them via the CLI. If you can write a small TypeScript function, you can write an internal hook. Hooks are discovered automatically, and you enable or disable them via the CLI.
## Overview ## Overview
The hooks system allows you to: The internal hooks system allows you to:
- Save session context to memory when `/new` is issued - Save session context to memory when `/new` is issued
- Log all commands for auditing - Log all commands for auditing
- Trigger custom automations on agent lifecycle events - Trigger custom automations on agent lifecycle events
@@ -43,25 +43,25 @@ Clawdbot ships with two bundled hooks that are automatically discovered:
List available hooks: List available hooks:
```bash ```bash
clawdbot hooks list clawdbot hooks internal list
``` ```
Enable a hook: Enable a hook:
```bash ```bash
clawdbot hooks enable session-memory clawdbot hooks internal enable session-memory
``` ```
Check hook status: Check hook status:
```bash ```bash
clawdbot hooks check clawdbot hooks internal check
``` ```
Get detailed information: Get detailed information:
```bash ```bash
clawdbot hooks info session-memory clawdbot hooks internal info session-memory
``` ```
### Onboarding ### Onboarding
@@ -76,8 +76,6 @@ Hooks are automatically discovered from three directories (in order of precedenc
2. **Managed hooks**: `~/.clawdbot/hooks/` (user-installed, shared across workspaces) 2. **Managed hooks**: `~/.clawdbot/hooks/` (user-installed, shared across workspaces)
3. **Bundled hooks**: `<clawdbot>/dist/hooks/bundled/` (shipped with Clawdbot) 3. **Bundled hooks**: `<clawdbot>/dist/hooks/bundled/` (shipped with Clawdbot)
Managed hook directories can be either a **single hook** or a **hook pack** (package directory).
Each hook is a directory containing: Each hook is a directory containing:
``` ```
@@ -86,30 +84,6 @@ my-hook/
└── handler.ts # Handler implementation └── handler.ts # Handler implementation
``` ```
## Hook Packs (npm/archives)
Hook packs are standard npm packages that export one or more hooks via `clawdbot.hooks` in
`package.json`. Install them with:
```bash
clawdbot hooks install <path-or-spec>
```
Example `package.json`:
```json
{
"name": "@acme/my-hooks",
"version": "0.1.0",
"clawdbot": {
"hooks": ["./hooks/my-hook", "./hooks/other-hook"]
}
}
```
Each entry points to a hook directory containing `HOOK.md` and `handler.ts` (or `index.ts`).
Hook packs can ship dependencies; they will be installed under `~/.clawdbot/hooks/<id>`.
## Hook Structure ## Hook Structure
### HOOK.md Format ### HOOK.md Format
@@ -120,7 +94,7 @@ The `HOOK.md` file contains metadata in YAML frontmatter plus Markdown documenta
--- ---
name: my-hook name: my-hook
description: "Short description of what this hook does" description: "Short description of what this hook does"
homepage: https://docs.clawd.bot/hooks#my-hook homepage: https://docs.clawd.bot/internal-hooks#my-hook
metadata: {"clawdbot":{"emoji":"🔗","events":["command:new"],"requires":{"bins":["node"]}}} metadata: {"clawdbot":{"emoji":"🔗","events":["command:new"],"requires":{"bins":["node"]}}}
--- ---
@@ -162,12 +136,12 @@ The `metadata.clawdbot` object supports:
### Handler Implementation ### Handler Implementation
The `handler.ts` file exports a `HookHandler` function: The `handler.ts` file exports an `InternalHookHandler` function:
```typescript ```typescript
import type { HookHandler } from '../../src/hooks/hooks.js'; import type { InternalHookHandler } from '../../src/hooks/internal-hooks.js';
const myHandler: HookHandler = async (event) => { const myHandler: InternalHookHandler = async (event) => {
// Only trigger on 'new' command // Only trigger on 'new' command
if (event.type !== 'command' || event.action !== 'new') { if (event.type !== 'command' || event.action !== 'new') {
return; return;
@@ -260,9 +234,9 @@ This hook does something useful when you issue `/new`.
### 4. Create handler.ts ### 4. Create handler.ts
```typescript ```typescript
import type { HookHandler } from '../../src/hooks/hooks.js'; import type { InternalHookHandler } from '../../src/hooks/internal-hooks.js';
const handler: HookHandler = async (event) => { const handler: InternalHookHandler = async (event) => {
if (event.type !== 'command' || event.action !== 'new') { if (event.type !== 'command' || event.action !== 'new') {
return; return;
} }
@@ -278,10 +252,10 @@ export default handler;
```bash ```bash
# Verify hook is discovered # Verify hook is discovered
clawdbot hooks list clawdbot hooks internal list
# Enable it # Enable it
clawdbot hooks enable my-hook clawdbot hooks internal enable my-hook
# Restart your gateway process (menu bar app restart on macOS, or restart your dev process) # Restart your gateway process (menu bar app restart on macOS, or restart your dev process)
@@ -375,46 +349,46 @@ The old config format still works for backwards compatibility:
```bash ```bash
# List all hooks # List all hooks
clawdbot hooks list clawdbot hooks internal list
# Show only eligible hooks # Show only eligible hooks
clawdbot hooks list --eligible clawdbot hooks internal list --eligible
# Verbose output (show missing requirements) # Verbose output (show missing requirements)
clawdbot hooks list --verbose clawdbot hooks internal list --verbose
# JSON output # JSON output
clawdbot hooks list --json clawdbot hooks internal list --json
``` ```
### Hook Information ### Hook Information
```bash ```bash
# Show detailed info about a hook # Show detailed info about a hook
clawdbot hooks info session-memory clawdbot hooks internal info session-memory
# JSON output # JSON output
clawdbot hooks info session-memory --json clawdbot hooks internal info session-memory --json
``` ```
### Check Eligibility ### Check Eligibility
```bash ```bash
# Show eligibility summary # Show eligibility summary
clawdbot hooks check clawdbot hooks internal check
# JSON output # JSON output
clawdbot hooks check --json clawdbot hooks internal check --json
``` ```
### Enable/Disable ### Enable/Disable
```bash ```bash
# Enable a hook # Enable a hook
clawdbot hooks enable session-memory clawdbot hooks internal enable session-memory
# Disable a hook # Disable a hook
clawdbot hooks disable command-logger clawdbot hooks internal disable command-logger
``` ```
## Bundled Hooks ## Bundled Hooks
@@ -453,7 +427,7 @@ Saves session context to memory when you issue `/new`.
**Enable**: **Enable**:
```bash ```bash
clawdbot hooks enable session-memory clawdbot hooks internal enable session-memory
``` ```
### command-logger ### command-logger
@@ -494,7 +468,7 @@ grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
**Enable**: **Enable**:
```bash ```bash
clawdbot hooks enable command-logger clawdbot hooks internal enable command-logger
``` ```
## Best Practices ## Best Practices
@@ -505,12 +479,12 @@ Hooks run during command processing. Keep them lightweight:
```typescript ```typescript
// ✓ Good - async work, returns immediately // ✓ Good - async work, returns immediately
const handler: HookHandler = async (event) => { const handler: InternalHookHandler = async (event) => {
void processInBackground(event); // Fire and forget void processInBackground(event); // Fire and forget
}; };
// ✗ Bad - blocks command processing // ✗ Bad - blocks command processing
const handler: HookHandler = async (event) => { const handler: InternalHookHandler = async (event) => {
await slowDatabaseQuery(event); await slowDatabaseQuery(event);
await evenSlowerAPICall(event); await evenSlowerAPICall(event);
}; };
@@ -521,7 +495,7 @@ const handler: HookHandler = async (event) => {
Always wrap risky operations: Always wrap risky operations:
```typescript ```typescript
const handler: HookHandler = async (event) => { const handler: InternalHookHandler = async (event) => {
try { try {
await riskyOperation(event); await riskyOperation(event);
} catch (err) { } catch (err) {
@@ -536,7 +510,7 @@ const handler: HookHandler = async (event) => {
Return early if the event isn't relevant: Return early if the event isn't relevant:
```typescript ```typescript
const handler: HookHandler = async (event) => { const handler: InternalHookHandler = async (event) => {
// Only handle 'new' commands // Only handle 'new' commands
if (event.type !== 'command' || event.action !== 'new') { if (event.type !== 'command' || event.action !== 'new') {
return; return;
@@ -576,7 +550,7 @@ Registered hook: command-logger -> command
List all discovered hooks: List all discovered hooks:
```bash ```bash
clawdbot hooks list --verbose clawdbot hooks internal list --verbose
``` ```
### Check Registration ### Check Registration
@@ -584,7 +558,7 @@ clawdbot hooks list --verbose
In your handler, log when it's called: In your handler, log when it's called:
```typescript ```typescript
const handler: HookHandler = async (event) => { const handler: InternalHookHandler = async (event) => {
console.log('[my-handler] Triggered:', event.type, event.action); console.log('[my-handler] Triggered:', event.type, event.action);
// Your logic // Your logic
}; };
@@ -595,7 +569,7 @@ const handler: HookHandler = async (event) => {
Check why a hook isn't eligible: Check why a hook isn't eligible:
```bash ```bash
clawdbot hooks info my-hook clawdbot hooks internal info my-hook
``` ```
Look for missing requirements in the output. Look for missing requirements in the output.
@@ -620,11 +594,11 @@ Test your handlers in isolation:
```typescript ```typescript
import { test } from 'vitest'; import { test } from 'vitest';
import { createHookEvent } from './src/hooks/hooks.js'; import { createInternalHookEvent } from './src/hooks/internal-hooks.js';
import myHandler from './hooks/my-hook/handler.js'; import myHandler from './hooks/my-hook/handler.js';
test('my handler works', async () => { test('my handler works', async () => {
const event = createHookEvent('command', 'new', 'test-session', { const event = createInternalHookEvent('command', 'new', 'test-session', {
foo: 'bar' foo: 'bar'
}); });
@@ -644,7 +618,7 @@ test('my handler works', async () => {
- **`src/hooks/config.ts`**: Eligibility checking - **`src/hooks/config.ts`**: Eligibility checking
- **`src/hooks/hooks-status.ts`**: Status reporting - **`src/hooks/hooks-status.ts`**: Status reporting
- **`src/hooks/loader.ts`**: Dynamic module loader - **`src/hooks/loader.ts`**: Dynamic module loader
- **`src/cli/hooks-cli.ts`**: CLI commands - **`src/cli/hooks-internal-cli.ts`**: CLI commands
- **`src/gateway/server-startup.ts`**: Loads hooks at gateway start - **`src/gateway/server-startup.ts`**: Loads hooks at gateway start
- **`src/auto-reply/reply/commands-core.ts`**: Triggers command events - **`src/auto-reply/reply/commands-core.ts`**: Triggers command events
@@ -698,7 +672,7 @@ Session reset
3. List all discovered hooks: 3. List all discovered hooks:
```bash ```bash
clawdbot hooks list clawdbot hooks internal list
``` ```
### Hook Not Eligible ### Hook Not Eligible
@@ -706,7 +680,7 @@ Session reset
Check requirements: Check requirements:
```bash ```bash
clawdbot hooks info my-hook clawdbot hooks internal info my-hook
``` ```
Look for missing: Look for missing:
@@ -719,7 +693,7 @@ Look for missing:
1. Verify hook is enabled: 1. Verify hook is enabled:
```bash ```bash
clawdbot hooks list clawdbot hooks internal list
# Should show ✓ next to enabled hooks # Should show ✓ next to enabled hooks
``` ```
@@ -798,7 +772,7 @@ node -e "import('./path/to/handler.ts').then(console.log)"
4. Verify and restart your gateway process: 4. Verify and restart your gateway process:
```bash ```bash
clawdbot hooks list clawdbot hooks internal list
# Should show: 🎯 my-hook ✓ # Should show: 🎯 my-hook ✓
``` ```
@@ -811,7 +785,7 @@ node -e "import('./path/to/handler.ts').then(console.log)"
## See Also ## See Also
- [CLI Reference: hooks](/cli/hooks) - [CLI Reference: hooks internal](/cli/hooks)
- [Bundled Hooks README](https://github.com/clawdbot/clawdbot/tree/main/src/hooks/bundled) - [Bundled Hooks README](https://github.com/clawdbot/clawdbot/tree/main/src/hooks/bundled)
- [Webhook Hooks](/automation/webhook) - [Webhook Hooks](/automation/webhook)
- [Configuration](/gateway/configuration#hooks) - [Configuration](/gateway/configuration#hooks)
+19 -72
View File
@@ -3,73 +3,25 @@ summary: "How inbound audio/voice notes are downloaded, transcribed, and injecte
read_when: read_when:
- Changing audio transcription or media handling - Changing audio transcription or media handling
--- ---
# Audio / Voice Notes — 2026-01-17 # Audio / Voice Notes — 2025-12-05
## What works ## What works
- **Media understanding (audio)**: If `tools.media.audio` is enabled (or a shared `tools.media.models` entry supports audio), Clawdbot: - **Optional transcription**: If `tools.audio.transcription` is set in `~/.clawdbot/clawdbot.json`, Clawdbot will:
1) Locates the first audio attachment (local path or URL) and downloads it if needed. 1) Download inbound audio to a temp path when WhatsApp only provides a URL.
2) Enforces `maxBytes` before sending to each model entry. 2) Run the configured CLI args (templated with `{{MediaPath}}`), expecting transcript on stdout.
3) Runs the first eligible model entry in order (provider or CLI). 3) Replace `Body` with the transcript, set `{{Transcript}}`, and prepend the original media path plus a `Transcript:` section in the command prompt so models see both.
4) If it fails or skips (size/timeout), it tries the next entry. 4) Continue through the normal auto-reply pipeline (templating, sessions, Pi command).
5) On success, it replaces `Body` with an `[Audio]` block and sets `{{Transcript}}`. - **Verbose logging**: In `--verbose`, we log when transcription runs and when the transcript replaces the body.
- **Command parsing**: When transcription succeeds, `CommandBody`/`RawBody` are set to the transcript so slash commands still work.
- **Verbose logging**: In `--verbose`, we log when transcription runs and when it replaces the body.
## Config examples ## Config example (Whisper CLI)
Requires `whisper` CLI installed:
### Provider + CLI fallback (OpenAI + Whisper CLI)
```json5 ```json5
{ {
tools: { tools: {
media: { audio: {
audio: { transcription: {
enabled: true, args: ["--model", "base", "{{MediaPath}}"],
maxBytes: 20971520, timeoutSeconds: 45
models: [
{ provider: "openai", model: "whisper-1" },
{
type: "cli",
command: "whisper",
args: ["--model", "base", "{{MediaPath}}"],
timeoutSeconds: 45
}
]
}
}
}
}
```
### Provider-only with scope gating
```json5
{
tools: {
media: {
audio: {
enabled: true,
scope: {
default: "allow",
rules: [
{ action: "deny", match: { chatType: "group" } }
]
},
models: [
{ provider: "openai", model: "whisper-1" }
]
}
}
}
}
```
### Provider-only (Deepgram)
```json5
{
tools: {
media: {
audio: {
enabled: true,
models: [{ provider: "deepgram", model: "nova-3" }]
} }
} }
} }
@@ -77,17 +29,12 @@ read_when:
``` ```
## Notes & limits ## Notes & limits
- Provider auth follows the standard model auth order (auth profiles, env vars, `models.providers.*.apiKey`). - We don’t ship a transcriber; you opt in with the Whisper CLI on your PATH.
- Deepgram picks up `DEEPGRAM_API_KEY` when `provider: "deepgram"` is used. - Size guard: inbound audio must be ≤5 MB (matches the temp media store and transcript pipeline).
- Deepgram setup details: [Deepgram (audio transcription)](/providers/deepgram). - Outbound caps: web send supports audio/voice up to 16 MB (sent as a voice note with `ptt: true`).
- Audio providers can override `baseUrl`, `headers`, and `providerOptions` via `tools.media.audio`. - If transcription fails, we fall back to the original body/media note; replies still go through.
- Default size cap is 20MB (`tools.media.audio.maxBytes`). Oversize audio is skipped for that model and the next entry is tried. - Transcript is available to templates as `{{Transcript}}`; models get both the media path and a `Transcript:` block in the prompt when using command mode.
- Default `maxChars` for audio is **unset** (full transcript). Set `tools.media.audio.maxChars` or per-entry `maxChars` to trim output.
- Use `tools.media.audio.attachments` to process multiple voice notes (`mode: "all"` + `maxAttachments`).
- Transcript is available to templates as `{{Transcript}}`.
- CLI stdout is capped (5MB); keep CLI output concise.
## Gotchas ## Gotchas
- Scope rules use first-match wins. `chatType` is normalized to `direct`, `group`, or `room`.
- Ensure your CLI exits 0 and prints plain text; JSON needs to be massaged via `jq -r .text`. - Ensure your CLI exits 0 and prints plain text; JSON needs to be massaged via `jq -r .text`.
- Keep timeouts reasonable (`timeoutSeconds`, default 60s) to avoid blocking the reply queue. - Keep timeouts reasonable (`timeoutSeconds`, default 45s) to avoid blocking the reply queue.
+1 -11
View File
@@ -38,23 +38,13 @@ The WhatsApp channel runs via **Baileys Web**. This document captures the curren
- `{{MediaUrl}}` pseudo-URL for the inbound media. - `{{MediaUrl}}` pseudo-URL for the inbound media.
- `{{MediaPath}}` local temp path written before running the command. - `{{MediaPath}}` local temp path written before running the command.
- When a per-session Docker sandbox is enabled, inbound media is copied into the sandbox workspace and `MediaPath`/`MediaUrl` are rewritten to a relative path like `media/inbound/<filename>`. - When a per-session Docker sandbox is enabled, inbound media is copied into the sandbox workspace and `MediaPath`/`MediaUrl` are rewritten to a relative path like `media/inbound/<filename>`.
- Media understanding (if configured via `tools.media.*` or shared `tools.media.models`) runs before templating and can insert `[Image]`, `[Audio]`, and `[Video]` blocks into `Body`. - Audio transcription (if configured via `tools.audio.transcription`) runs before templating and can replace `Body` with the transcript.
- Audio sets `{{Transcript}}` and uses the transcript for command parsing so slash commands still work.
- Video and image descriptions preserve any caption text for command parsing.
- By default only the first matching image/audio/video attachment is processed; set `tools.media.<cap>.attachments` to process multiple attachments.
## Limits & Errors ## Limits & Errors
**Outbound send caps (WhatsApp web send)**
- Images: ~6 MB cap after recompression. - Images: ~6 MB cap after recompression.
- Audio/voice/video: 16 MB cap; documents: 100 MB cap. - Audio/voice/video: 16 MB cap; documents: 100 MB cap.
- Oversize or unreadable media → clear error in logs and the reply is skipped. - Oversize or unreadable media → clear error in logs and the reply is skipped.
**Media understanding caps (transcription/description)**
- Image default: 10 MB (`tools.media.image.maxBytes`).
- Audio default: 20 MB (`tools.media.audio.maxBytes`).
- Video default: 50 MB (`tools.media.video.maxBytes`).
- Oversize media skips understanding, but replies still go through with the original body.
## Notes for Tests ## Notes for Tests
- Cover send + reply flows for image/audio/document cases. - Cover send + reply flows for image/audio/document cases.
- Validate recompression for images (size bound) and voice-note flag for audio. - Validate recompression for images (size bound) and voice-note flag for audio.
-279
View File
@@ -1,279 +0,0 @@
---
summary: "Inbound image/audio/video understanding (optional) with provider + CLI fallbacks"
read_when:
- Designing or refactoring media understanding
- Tuning inbound audio/video/image preprocessing
---
# Media Understanding (Inbound) — 2026-01-17
Clawdbot can optionally **summarize inbound media** (image/audio/video) before the reply pipeline runs. This is **opt-in** and separate from the base attachment flow—if understanding is off, models still receive the original files/URLs as usual.
## Goals
- Optional: pre‑digest inbound media into short text for faster routing + better command parsing.
- Preserve original media delivery to the model (always).
- Support **provider APIs** and **CLI fallbacks**.
- Allow multiple models with ordered fallback (error/size/timeout).
## High‑level behavior
1) Collect inbound attachments (`MediaPaths`, `MediaUrls`, `MediaTypes`).
2) For each enabled capability (image/audio/video), select attachments per policy (default: **first**).
3) Choose the first eligible model entry (size + capability + auth).
4) If a model fails or the media is too large, **fall back to the next entry**.
5) On success:
- `Body` becomes `[Image]`, `[Audio]`, or `[Video]` block.
- Audio sets `{{Transcript}}`; command parsing uses caption text when present,
otherwise the transcript.
- Captions are preserved as `User text:` inside the block.
If understanding fails or is disabled, **the reply flow continues** with the original body + attachments.
## Config overview
`tools.media` supports **shared models** plus per‑capability overrides:
- `tools.media.models`: shared model list (use `capabilities` to gate).
- `tools.media.image` / `tools.media.audio` / `tools.media.video`:
- defaults (`prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`)
- provider overrides (`baseUrl`, `headers`, `providerOptions`)
- Deepgram audio options via `tools.media.audio.providerOptions.deepgram`
- optional **per‑capability `models` list** (preferred before shared models)
- `attachments` policy (`mode`, `maxAttachments`, `prefer`)
- `scope` (optional gating by channel/chatType/session key)
- `tools.media.concurrency`: max concurrent capability runs (default **2**).
```json5
{
tools: {
media: {
models: [ /* shared list */ ],
image: { /* optional overrides */ },
audio: { /* optional overrides */ },
video: { /* optional overrides */ }
}
}
}
```
### Model entries
Each `models[]` entry can be **provider** or **CLI**:
```json5
{
type: "provider", // default if omitted
provider: "openai",
model: "gpt-5.2",
prompt: "Describe the image in <= 500 chars.",
maxChars: 500,
maxBytes: 10485760,
timeoutSeconds: 60,
capabilities: ["image"], // optional, used for multi‑modal entries
profile: "vision-profile",
preferredProfile: "vision-fallback"
}
```
```json5
{
type: "cli",
command: "gemini",
args: [
"-m",
"gemini-3-flash",
"--allowed-tools",
"read_file",
"Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters."
],
maxChars: 500,
maxBytes: 52428800,
timeoutSeconds: 120,
capabilities: ["video", "image"]
}
```
## Defaults and limits
Recommended defaults:
- `maxChars`: **500** for image/video (short, command‑friendly)
- `maxChars`: **unset** for audio (full transcript unless you set a limit)
- `maxBytes`:
- image: **10MB**
- audio: **20MB**
- video: **50MB**
Rules:
- If media exceeds `maxBytes`, that model is skipped and the **next model is tried**.
- If the model returns more than `maxChars`, output is trimmed.
- `prompt` defaults to simple “Describe the {media}.” plus the `maxChars` guidance (image/video only).
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
**active reply model** when its provider supports the capability.
## Capabilities (optional)
If you set `capabilities`, the entry only runs for those media types. For shared
lists, Clawdbot can infer defaults:
- `openai`, `anthropic`, `minimax`: **image**
- `google` (Gemini API): **image + audio + video**
- `groq`: **audio**
- `deepgram`: **audio**
For CLI entries, **set `capabilities` explicitly** to avoid surprising matches.
If you omit `capabilities`, the entry is eligible for the list it appears in.
## Provider support matrix (Clawdbot integrations)
| Capability | Provider integration | Notes |
|------------|----------------------|-------|
| Image | OpenAI / Anthropic / Google / others via `pi-ai` | Any image-capable model in the registry works. |
| Audio | OpenAI, Groq, Deepgram | Provider transcription (Whisper/Deepgram). |
| Video | Google (Gemini API) | Provider video understanding. |
## Recommended providers
**Image**
- Prefer your active model if it supports images.
- Good defaults: `openai/gpt-5.2`, `anthropic/claude-opus-4-5`, `google/gemini-3-pro-preview`.
**Audio**
- `openai/whisper-1`, `groq/whisper-large-v3-turbo`, or `deepgram/nova-3`.
- CLI fallback: `whisper` binary.
- Deepgram setup: [Deepgram (audio transcription)](/providers/deepgram).
**Video**
- `google/gemini-3-flash-preview` (fast), `google/gemini-3-pro-preview` (richer).
- CLI fallback: `gemini` CLI (supports `read_file` on video/audio).
## Attachment policy
Per‑capability `attachments` controls which attachments are processed:
- `mode`: `first` (default) or `all`
- `maxAttachments`: cap the number processed (default **1**)
- `prefer`: `first`, `last`, `path`, `url`
When `mode: "all"`, outputs are labeled `[Image 1/2]`, `[Audio 2/2]`, etc.
## Config examples
### 1) Shared models list + overrides
```json5
{
tools: {
media: {
models: [
{ provider: "openai", model: "gpt-5.2", capabilities: ["image"] },
{ provider: "google", model: "gemini-3-flash-preview", capabilities: ["image", "audio", "video"] },
{
type: "cli",
command: "gemini",
args: [
"-m",
"gemini-3-flash",
"--allowed-tools",
"read_file",
"Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters."
],
capabilities: ["image", "video"]
}
],
audio: {
attachments: { mode: "all", maxAttachments: 2 }
},
video: {
maxChars: 500
}
}
}
}
```
### 2) Audio + Video only (image off)
```json5
{
tools: {
media: {
audio: {
enabled: true,
models: [
{ provider: "openai", model: "whisper-1" },
{
type: "cli",
command: "whisper",
args: ["--model", "base", "{{MediaPath}}"]
}
]
},
video: {
enabled: true,
maxChars: 500,
models: [
{ provider: "google", model: "gemini-3-flash-preview" },
{
type: "cli",
command: "gemini",
args: [
"-m",
"gemini-3-flash",
"--allowed-tools",
"read_file",
"Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters."
]
}
]
}
}
}
}
```
### 3) Optional image understanding
```json5
{
tools: {
media: {
image: {
enabled: true,
maxBytes: 10485760,
maxChars: 500,
models: [
{ provider: "openai", model: "gpt-5.2" },
{ provider: "anthropic", model: "claude-opus-4-5" },
{
type: "cli",
command: "gemini",
args: [
"-m",
"gemini-3-flash",
"--allowed-tools",
"read_file",
"Read the media at {{MediaPath}} and describe it in <= {{MaxChars}} characters."
]
}
]
}
}
}
}
```
### 4) Multi‑modal single entry (explicit capabilities)
```json5
{
tools: {
media: {
image: { models: [{ provider: "google", model: "gemini-3-pro-preview", capabilities: ["image", "video", "audio"] }] },
audio: { models: [{ provider: "google", model: "gemini-3-pro-preview", capabilities: ["image", "video", "audio"] }] },
video: { models: [{ provider: "google", model: "gemini-3-pro-preview", capabilities: ["image", "video", "audio"] }] }
}
}
}
```
## Status output
When media understanding runs, `/status` includes a short summary line:
```
📎 Media: image ok (openai/gpt-5.2) · audio skipped (maxBytes)
```
This shows per‑capability outcomes and the chosen provider/model when applicable.
## Notes
- Understanding is **best‑effort**. Errors do not block replies.
- Attachments are still passed to models even when understanding is disabled.
- Use `scope` to limit where understanding runs (e.g. only DMs).
## Related docs
- [Configuration](/gateway/configuration)
- [Image & Media Support](/nodes/images)
+7 -22
View File
@@ -41,9 +41,6 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
- [Matrix](/channels/matrix) — `@clawdbot/matrix` - [Matrix](/channels/matrix) — `@clawdbot/matrix`
- [Zalo](/channels/zalo) — `@clawdbot/zalo` - [Zalo](/channels/zalo) — `@clawdbot/zalo`
- [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams` - [Microsoft Teams](/channels/msteams) — `@clawdbot/msteams`
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
- Copilot Proxy (provider auth) — bundled as `copilot-proxy` (disabled by default)
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
register: register:
@@ -61,26 +58,16 @@ Plugins run **in‑process** with the Gateway, so treat them as trusted code.
Clawdbot scans, in order: Clawdbot scans, in order:
1) Config paths 1) Global extensions
- `plugins.load.paths` (file or directory) - `~/.clawdbot/extensions/*.ts`
- `~/.clawdbot/extensions/*/index.ts`
2) Workspace extensions 2) Workspace extensions
- `<workspace>/.clawdbot/extensions/*.ts` - `<workspace>/.clawdbot/extensions/*.ts`
- `<workspace>/.clawdbot/extensions/*/index.ts` - `<workspace>/.clawdbot/extensions/*/index.ts`
3) Global extensions 3) Config paths
- `~/.clawdbot/extensions/*.ts` - `plugins.load.paths` (file or directory)
- `~/.clawdbot/extensions/*/index.ts`
4) Bundled extensions (shipped with Clawdbot, **disabled by default**)
- `<clawdbot>/extensions/*`
Bundled plugins must be enabled explicitly via `plugins.entries.<id>.enabled`
or `clawdbot plugins enable <id>`. Installed plugins are enabled by default,
but can be disabled the same way.
If multiple plugins resolve to the same id, the first match in the order above
wins and lower-precedence copies are ignored.
### Package packs ### Package packs
@@ -170,11 +157,9 @@ export default {
```bash ```bash
clawdbot plugins list clawdbot plugins list
clawdbot plugins info <id> clawdbot plugins info <id>
clawdbot plugins install <path> # copy a local file/dir into ~/.clawdbot/extensions/<id> clawdbot plugins install <path> # add a local file/dir to plugins.load.paths
clawdbot plugins install ./extensions/voice-call # relative path ok clawdbot plugins install ./extensions/voice-call # relative path ok
clawdbot plugins install ./plugin.tgz # install from a local tarball clawdbot plugins install ./plugin.tgz # install from a local tarball
clawdbot plugins install ./plugin.zip # install from a local zip
clawdbot plugins install -l ./extensions/voice-call # link (no copy) for dev
clawdbot plugins install @clawdbot/voice-call # install from npm clawdbot plugins install @clawdbot/voice-call # install from npm
clawdbot plugins update <id> clawdbot plugins update <id>
clawdbot plugins update --all clawdbot plugins update --all
+1 -1
View File
@@ -65,7 +65,7 @@ Channel config lives under `channels.zalouser` (not `plugins.entries.*`):
clawdbot channels login --channel zalouser clawdbot channels login --channel zalouser
clawdbot channels logout --channel zalouser clawdbot channels logout --channel zalouser
clawdbot channels status --probe clawdbot channels status --probe
clawdbot message send --channel zalouser --target <threadId> --message "Hello from Clawdbot" clawdbot message send --channel zalouser --to <threadId> --message "Hello from Clawdbot"
clawdbot directory peers list --channel zalouser --query "name" clawdbot directory peers list --channel zalouser --query "name"
``` ```
-89
View File
@@ -1,89 +0,0 @@
---
summary: "Deepgram transcription for inbound voice notes"
read_when:
- You want Deepgram speech-to-text for audio attachments
- You need a quick Deepgram config example
---
# Deepgram (Audio Transcription)
Deepgram is a speech-to-text API. In Clawdbot it is used for **inbound audio/voice note
transcription** via `tools.media.audio`.
When enabled, Clawdbot uploads the audio file to Deepgram and injects the transcript
into the reply pipeline (`{{Transcript}}` + `[Audio]` block). This is **not streaming**;
it uses the pre-recorded transcription endpoint.
Website: https://deepgram.com
Docs: https://developers.deepgram.com
## Quick start
1) Set your API key:
```
DEEPGRAM_API_KEY=dg_...
```
2) Enable the provider:
```json5
{
tools: {
media: {
audio: {
enabled: true,
models: [{ provider: "deepgram", model: "nova-3" }]
}
}
}
}
```
## Options
- `model`: Deepgram model id (default: `nova-3`)
- `language`: language hint (optional)
- `tools.media.audio.providerOptions.deepgram.detect_language`: enable language detection (optional)
- `tools.media.audio.providerOptions.deepgram.punctuate`: enable punctuation (optional)
- `tools.media.audio.providerOptions.deepgram.smart_format`: enable smart formatting (optional)
Example with language:
```json5
{
tools: {
media: {
audio: {
enabled: true,
models: [
{ provider: "deepgram", model: "nova-3", language: "en" }
]
}
}
}
}
```
Example with Deepgram options:
```json5
{
tools: {
media: {
audio: {
enabled: true,
providerOptions: {
deepgram: {
detect_language: true,
punctuate: true,
smart_format: true
}
},
models: [{ provider: "deepgram", model: "nova-3" }]
}
}
}
}
```
## Notes
- Authentication follows the standard provider auth order; `DEEPGRAM_API_KEY` is the simplest path.
- Override endpoints or headers with `tools.media.audio.baseUrl` and `tools.media.audio.headers` when using a proxy.
- Output follows the same audio rules as other providers (size caps, timeouts, transcript injection).
+1 -5
View File
@@ -28,15 +28,11 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/etc.)? See [Chann
- [Anthropic (API + Claude Code CLI)](/providers/anthropic) - [Anthropic (API + Claude Code CLI)](/providers/anthropic)
- [OpenRouter](/providers/openrouter) - [OpenRouter](/providers/openrouter)
- [Vercel AI Gateway](/providers/vercel-ai-gateway) - [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot) - [Moonshot AI (Kimi)](/providers/moonshot)
- [OpenCode Zen](/providers/opencode) - [OpenCode Zen](/providers/opencode)
- [Z.AI](/providers/zai) - [Z.AI](/providers/zai)
- [GLM models](/providers/glm) - [GLM models](/providers/glm)
- [MiniMax](/providers/minimax) - [MiniMax](/providers/minimax)
## Transcription providers
- [Deepgram (audio transcription)](/providers/deepgram)
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration, For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
see [Model providers](/concepts/model-providers). see [Model providers](/concepts/model-providers).
+1 -1
View File
@@ -26,7 +26,7 @@ model as `provider/model`.
- [Anthropic (API + Claude Code CLI)](/providers/anthropic) - [Anthropic (API + Claude Code CLI)](/providers/anthropic)
- [OpenRouter](/providers/openrouter) - [OpenRouter](/providers/openrouter)
- [Vercel AI Gateway](/providers/vercel-ai-gateway) - [Vercel AI Gateway](/providers/vercel-ai-gateway)
- [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot) - [Moonshot AI (Kimi)](/providers/moonshot)
- [Synthetic](/providers/synthetic) - [Synthetic](/providers/synthetic)
- [OpenCode Zen](/providers/opencode) - [OpenCode Zen](/providers/opencode)
- [Z.AI](/providers/zai) - [Z.AI](/providers/zai)
+6 -56
View File
@@ -1,16 +1,13 @@
--- ---
summary: "Configure Moonshot K2 vs Kimi Code (separate providers + keys)" summary: "Use Moonshot AI (Kimi K2) with Clawdbot"
read_when: read_when:
- You want Moonshot K2 (Moonshot Open Platform) vs Kimi Code setup - You want to use Moonshot/Kimi models in Clawdbot
- You need to understand separate endpoints, keys, and model refs - You need the Moonshot auth + config example
- You want copy/paste config for either provider
--- ---
# Moonshot AI (Kimi) # Moonshot AI (Kimi)
Moonshot provides the Kimi API with OpenAI-compatible endpoints. Configure the Moonshot provides the Kimi API with OpenAI-compatible endpoints. Configure the
provider and set the default model to `moonshot/kimi-k2-0905-preview`, or use provider and set the default model to `moonshot/kimi-k2-0905-preview`.
Kimi Code with `kimi-code/kimi-for-coding`.
Current Kimi K2 model IDs: Current Kimi K2 model IDs:
{/* moonshot-kimi-k2-ids:start */} {/* moonshot-kimi-k2-ids:start */}
@@ -24,15 +21,7 @@ Current Kimi K2 model IDs:
clawdbot onboard --auth-choice moonshot-api-key clawdbot onboard --auth-choice moonshot-api-key
``` ```
Kimi Code: ## Config snippet
```bash
clawdbot onboard --auth-choice kimi-code-api-key
```
Note: Moonshot and Kimi Code are separate providers. Keys are not interchangeable, endpoints differ, and model refs differ (Moonshot uses `moonshot/...`, Kimi Code uses `kimi-code/...`).
## Config snippet (Moonshot API)
```json5 ```json5
{ {
@@ -103,48 +92,9 @@ Note: Moonshot and Kimi Code are separate providers. Keys are not interchangeabl
} }
``` ```
## Kimi Code
```json5
{
env: { KIMICODE_API_KEY: "sk-..." },
agents: {
defaults: {
model: { primary: "kimi-code/kimi-for-coding" },
models: {
"kimi-code/kimi-for-coding": { alias: "Kimi Code" }
}
}
},
models: {
mode: "merge",
providers: {
"kimi-code": {
baseUrl: "https://api.kimi.com/coding/v1",
apiKey: "${KIMICODE_API_KEY}",
api: "openai-completions",
models: [
{
id: "kimi-for-coding",
name: "Kimi For Coding",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 262144,
maxTokens: 32768,
headers: { "User-Agent": "KimiCLI/0.77" },
compat: { supportsDeveloperRole: false }
}
]
}
}
}
}
```
## Notes ## Notes
- Moonshot model refs use `moonshot/<modelId>`. Kimi Code model refs use `kimi-code/<modelId>`. - Model refs use `moonshot/<modelId>`.
- Override pricing and context metadata in `models.providers` if needed. - Override pricing and context metadata in `models.providers` if needed.
- If Moonshot publishes different context limits for a model, adjust - If Moonshot publishes different context limits for a model, adjust
`contextWindow` accordingly. `contextWindow` accordingly.
+1 -8
View File
@@ -10,12 +10,6 @@ read_when:
Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tagging/publishing. Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tagging/publishing.
## Operator trigger
When the operator says “release”, immediately do this preflight (no extra questions unless blocked):
- Read this doc and `docs/platforms/mac/release.md`.
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set.
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
1) **Version & metadata** 1) **Version & metadata**
- [ ] Bump `package.json` version (e.g., `1.1.0`). - [ ] Bump `package.json` version (e.g., `1.1.0`).
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs. - [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
@@ -26,7 +20,6 @@ When the operator says “release”, immediately do this preflight (no extra qu
2) **Build & artifacts** 2) **Build & artifacts**
- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/clawdbot/clawdbot/blob/main/src/canvas-host/a2ui/a2ui.bundle.js). - [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/clawdbot/clawdbot/blob/main/src/canvas-host/a2ui/a2ui.bundle.js).
- [ ] `pnpm run build` (regenerates `dist/`). - [ ] `pnpm run build` (regenerates `dist/`).
- [ ] Confirm `dist/build-info.json` exists and includes the expected `commit` hash (CLI banner uses this for npm installs).
- [ ] Optional: `npm pack --pack-destination /tmp` after the build; inspect the tarball contents and keep it handy for the GitHub release (do **not** commit it). - [ ] Optional: `npm pack --pack-destination /tmp` after the build; inspect the tarball contents and keep it handy for the GitHub release (do **not** commit it).
3) **Changelog & docs** 3) **Changelog & docs**
@@ -58,7 +51,7 @@ When the operator says “release”, immediately do this preflight (no extra qu
- [ ] Confirm git status is clean; commit and push as needed. - [ ] Confirm git status is clean; commit and push as needed.
- [ ] `npm login` (verify 2FA) if needed. - [ ] `npm login` (verify 2FA) if needed.
- [ ] `npm publish --access public` (use `--tag beta` for pre-releases). - [ ] `npm publish --access public` (use `--tag beta` for pre-releases).
- [ ] Verify the registry: `npm view clawdbot version`, `npm view clawdbot dist-tags`, and `npx -y clawdbot@X.Y.Z --version` (or `--help`). - [ ] Verify the registry: `npm view clawdbot version` and `npx -y clawdbot@X.Y.Z --version` (or `--help`).
### Troubleshooting (notes from 2.0.0-beta2 release) ### Troubleshooting (notes from 2.0.0-beta2 release)
- **npm pack/publish hangs or produces huge tarball**: the macOS app bundle in `dist/Clawdbot.app` (and release zips) get swept into the package. Fix by whitelisting publish contents via `package.json` `files` (include dist subdirs, docs, skills; exclude app bundles). Confirm with `npm pack --dry-run` that `dist/Clawdbot.app` is not listed. - **npm pack/publish hangs or produces huge tarball**: the macOS app bundle in `dist/Clawdbot.app` (and release zips) get swept into the package. Fix by whitelisting publish contents via `package.json` `files` (include dist subdirs, docs, skills; exclude app bundles). Confirm with `npm pack --dry-run` that `dist/Clawdbot.app` is not listed.
+1 -1
View File
@@ -1495,7 +1495,7 @@ Outbound attachments from the agent must include a `MEDIA:<path-or-url>` line (o
CLI sending: CLI sending:
```bash ```bash
clawdbot message send --target +15555550123 --message "Here you go" --media /path/to/file.png clawdbot message send --to +15555550123 --message "Here you go" --media /path/to/file.png
``` ```
Also check: Also check:
+1 -1
View File
@@ -174,7 +174,7 @@ In a new terminal:
```bash ```bash
clawdbot status clawdbot status
clawdbot health clawdbot health
clawdbot message send --target +15555550123 --message "Hello from Clawdbot" clawdbot message send --to +15555550123 --message "Hello from Clawdbot"
``` ```
If `health` shows “no auth configured”, go back to the wizard and set OAuth/key auth — the agent won’t be able to respond without it. If `health` shows “no auth configured”, go back to the wizard and set OAuth/key auth — the agent won’t be able to respond without it.
+1 -1
View File
@@ -87,7 +87,7 @@ On the first agent run, Clawdbot bootstraps a workspace (default `~/clawd`):
Gmail Pub/Sub setup is currently a manual step. Use: Gmail Pub/Sub setup is currently a manual step. Use:
```bash ```bash
clawdbot webhooks gmail setup --account you@gmail.com clawdbot hooks gmail setup --account you@gmail.com
``` ```
See [/automation/gmail-pubsub](/automation/gmail-pubsub) for details. See [/automation/gmail-pubsub](/automation/gmail-pubsub) for details.
+1 -2
View File
@@ -95,8 +95,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
- **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`. - **Synthetic (Anthropic-compatible)**: prompts for `SYNTHETIC_API_KEY`.
- More detail: [Synthetic](/providers/synthetic) - More detail: [Synthetic](/providers/synthetic)
- **Moonshot (Kimi K2)**: config is auto-written. - **Moonshot (Kimi K2)**: config is auto-written.
- **Kimi Code**: config is auto-written. - More detail: [Moonshot AI](/providers/moonshot)
- More detail: [Moonshot AI (Kimi + Kimi Code)](/providers/moonshot)
- **Skip**: no auth configured yet. - **Skip**: no auth configured yet.
- Pick a default model from detected options (or enter provider/model manually). - Pick a default model from detected options (or enter provider/model manually).
- Wizard runs a model check and warns if the configured model is unknown or missing auth. - Wizard runs a model check and warns if the configured model is unknown or missing auth.
-5
View File
@@ -290,11 +290,6 @@ Live tests discover credentials the same way the CLI does. Practical implication
If you want to rely on env keys (e.g. exported in your `~/.profile`), run local tests after `source ~/.profile`, or use the Docker runners below (they can mount `~/.profile` into the container). If you want to rely on env keys (e.g. exported in your `~/.profile`), run local tests after `source ~/.profile`, or use the Docker runners below (they can mount `~/.profile` into the container).
## Deepgram live (audio transcription)
- Test: `src/media-understanding/providers/deepgram/audio.live.test.ts`
- Enable: `DEEPGRAM_API_KEY=... DEEPGRAM_LIVE_TEST=1 pnpm test:live src/media-understanding/providers/deepgram/audio.live.test.ts`
## Docker runners (optional “works in Linux” checks) ## Docker runners (optional “works in Linux” checks)
These run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted): These run `pnpm test:live` inside the repo Docker image, mounting your local config dir and workspace (and sourcing `~/.profile` if mounted):
+2 -2
View File
@@ -20,7 +20,7 @@ runtime on the current machine.
- Output: - Output:
- default: prints reply text (plus `MEDIA:<url>` lines) - default: prints reply text (plus `MEDIA:<url>` lines)
- `--json`: prints structured payload + metadata - `--json`: prints structured payload + metadata
- Optional delivery back to a channel with `--deliver` + `--channel` (target formats match `clawdbot message --target`). - Optional delivery back to a channel with `--deliver` + `--channel` (target formats match `clawdbot message --to`).
If the Gateway is unreachable, the CLI **falls back** to the embedded local run. If the Gateway is unreachable, the CLI **falls back** to the embedded local run.
@@ -39,6 +39,6 @@ clawdbot agent --to +15555550123 --message "Summon reply" --deliver
- `--deliver`: send the reply to the chosen channel (requires `--to`) - `--deliver`: send the reply to the chosen channel (requires `--to`)
- `--channel`: `whatsapp|telegram|discord|slack|signal|imessage` (default: `whatsapp`) - `--channel`: `whatsapp|telegram|discord|slack|signal|imessage` (default: `whatsapp`)
- `--thinking <off|minimal|low|medium|high|xhigh>`: persist thinking level (GPT-5.2 + Codex models only) - `--thinking <off|minimal|low|medium|high|xhigh>`: persist thinking level (GPT-5.2 + Codex models only)
- `--verbose <on|full|off>`: persist verbose level - `--verbose <on|off>`: persist verbose level
- `--timeout <seconds>`: override agent timeout - `--timeout <seconds>`: override agent timeout
- `--json`: output structured JSON - `--json`: output structured JSON
+1 -1
View File
@@ -37,7 +37,7 @@ The tool accepts a single `input` string that wraps one or more file operations:
- Experimental and disabled by default. Enable with `tools.exec.applyPatch.enabled`. - Experimental and disabled by default. Enable with `tools.exec.applyPatch.enabled`.
- OpenAI-only (including OpenAI Codex). Optionally gate by model via - OpenAI-only (including OpenAI Codex). Optionally gate by model via
`tools.exec.applyPatch.allowModels`. `tools.exec.applyPatch.allowModels`.
- Config is only under `tools.exec`. - Config is only under `tools.exec` (no `tools.bash` alias).
## Example ## Example
+2 -24
View File
@@ -17,15 +17,10 @@ Background sessions are scoped per agent; `process` only sees sessions from the
- `yieldMs` (default 10000): auto-background after delay - `yieldMs` (default 10000): auto-background after delay
- `background` (bool): background immediately - `background` (bool): background immediately
- `timeout` (seconds, default 1800): kill on expiry - `timeout` (seconds, default 1800): kill on expiry
- `pty` (bool): run in a pseudo-terminal when available (TTY-only CLIs, coding agents, terminal UIs)
- `elevated` (bool): run on host if elevated mode is enabled/allowed (only changes behavior when the agent is sandboxed) - `elevated` (bool): run on host if elevated mode is enabled/allowed (only changes behavior when the agent is sandboxed)
- Need a fully interactive session? Use `pty: true` and the `process` tool for stdin/output. - Need a real TTY? Use the tmux skill.
Note: `elevated` is ignored when sandboxing is off (exec already runs on the host). Note: `elevated` is ignored when sandboxing is off (exec already runs on the host).
## Config
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
## Examples ## Examples
Foreground: Foreground:
@@ -39,23 +34,6 @@ Background + poll:
{"tool":"process","action":"poll","sessionId":"<id>"} {"tool":"process","action":"poll","sessionId":"<id>"}
``` ```
Send keys (tmux-style):
```json
{"tool":"process","action":"send-keys","sessionId":"<id>","keys":["Enter"]}
{"tool":"process","action":"send-keys","sessionId":"<id>","keys":["C-c"]}
{"tool":"process","action":"send-keys","sessionId":"<id>","keys":["Up","Up","Enter"]}
```
Submit (send CR only):
```json
{"tool":"process","action":"submit","sessionId":"<id>"}
```
Paste (bracketed by default):
```json
{"tool":"process","action":"paste","sessionId":"<id>","text":"line1\nline2\n"}
```
## apply_patch (experimental) ## apply_patch (experimental)
`apply_patch` is a subtool of `exec` for structured multi-file edits. `apply_patch` is a subtool of `exec` for structured multi-file edits.
@@ -74,4 +52,4 @@ Enable it explicitly:
Notes: Notes:
- Only available for OpenAI/OpenAI Codex models. - Only available for OpenAI/OpenAI Codex models.
- Tool policy still applies; `allow: ["exec"]` implicitly allows `apply_patch`. - Tool policy still applies; `allow: ["exec"]` implicitly allows `apply_patch`.
- Config lives under `tools.exec.applyPatch`. - Config lives under `tools.exec.applyPatch` (no `tools.bash` alias).
+1 -1
View File
@@ -169,7 +169,7 @@ Core parameters:
- `background` (immediate background) - `background` (immediate background)
- `timeout` (seconds; kills the process if exceeded, default 1800) - `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) - `elevated` (bool; run on host if elevated mode is enabled/allowed; only changes behavior when the agent is sandboxed)
- Need a real TTY? Set `pty: true`. - Need a real TTY? Use the tmux skill.
Notes: Notes:
- Returns `status: "running"` with a `sessionId` when backgrounded. - Returns `status: "running"` with a `sessionId` when backgrounded.
+3 -3
View File
@@ -58,7 +58,7 @@ They run immediately, are stripped before the model sees the message, and the re
Text + native (when enabled): Text + native (when enabled):
- `/help` - `/help`
- `/commands` - `/commands`
- `/status` (show current status; includes provider usage/quota for the current model provider when available) - `/status` (show current status; includes provider usage/quota when available, plus OAuth/token status block when OAuth profiles exist)
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size) - `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
- `/usage` (alias: `/status`) - `/usage` (alias: `/status`)
- `/whoami` (show your sender id; alias: `/id`) - `/whoami` (show your sender id; alias: `/id`)
@@ -74,7 +74,7 @@ Text + native (when enabled):
- `/send on|off|inherit` (owner-only) - `/send on|off|inherit` (owner-only)
- `/reset` or `/new` - `/reset` or `/new`
- `/think <off|minimal|low|medium|high|xhigh>` (dynamic choices by model/provider; aliases: `/thinking`, `/t`) - `/think <off|minimal|low|medium|high|xhigh>` (dynamic choices by model/provider; aliases: `/thinking`, `/t`)
- `/verbose on|full|off` (alias: `/v`) - `/verbose on|off` (alias: `/v`)
- `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only) - `/reasoning on|off|stream` (alias: `/reason`; when on, sends a separate message prefixed `Reasoning:`; `stream` = Telegram draft only)
- `/elevated on|off` (alias: `/elev`) - `/elevated on|off` (alias: `/elev`)
- `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`) - `/model <name>` (alias: `/models`; or `/<alias>` from `agents.defaults.models.*.alias`)
@@ -105,7 +105,7 @@ Notes:
## Usage vs cost (what shows where) ## Usage vs cost (what shows where)
- **Provider usage/quota** (example: “Claude 80% left”) shows up in `/status` for the current model provider when usage tracking is enabled. - **Provider usage/quota** (example: “Claude 80% left”) shows up in `/status` when provider usage tracking is enabled.
- **Per-response tokens/cost** is controlled by `/cost on|off` (appended to normal replies). - **Per-response tokens/cost** is controlled by `/cost on|off` (appended to normal replies).
- `/model status` is about **models/auth/endpoints**, not usage. - `/model status` is about **models/auth/endpoints**, not usage.
+2 -3
View File
@@ -33,13 +33,12 @@ read_when:
- **Embedded Pi**: the resolved level is passed to the in-process Pi agent runtime. - **Embedded Pi**: the resolved level is passed to the in-process Pi agent runtime.
## Verbose directives (/verbose or /v) ## Verbose directives (/verbose or /v)
- Levels: `on` (minimal) | `full` | `off` (default). - Levels: `on|full` or `off` (default).
- Directive-only message toggles session verbose and replies `Verbose logging enabled.` / `Verbose logging disabled.`; invalid levels return a hint without changing state. - Directive-only message toggles session verbose and replies `Verbose logging enabled.` / `Verbose logging disabled.`; invalid levels return a hint without changing state.
- `/verbose off` stores an explicit session override; clear it via the Sessions UI by choosing `inherit`. - `/verbose off` stores an explicit session override; clear it via the Sessions UI by choosing `inherit`.
- Inline directive affects only that message; session/global defaults apply otherwise. - Inline directive affects only that message; session/global defaults apply otherwise.
- Send `/verbose` (or `/verbose:`) with no argument to see the current verbose level. - Send `/verbose` (or `/verbose:`) with no argument to see the current verbose level.
- When verbose is on, agents that emit structured tool results (Pi, other JSON agents) send each tool call back as its own metadata-only message, prefixed with `<emoji> <tool-name>: <arg>` when available (path/command). These tool summaries are sent as soon as each tool starts (separate bubbles), not as streaming deltas. - When verbose is on, agents that emit structured tool results (Pi, other JSON agents) send each tool result back as its own metadata-only message, prefixed with `<emoji> <tool-name>: <arg>` when available (path/command); the tool output itself is not forwarded. These tool summaries are sent as soon as each tool finishes (separate bubbles), not as streaming deltas. If you toggle `/verbose on|off` while a run is in-flight, subsequent tool bubbles honor the new setting.
- When verbose is `full`, tool outputs are also forwarded after completion (separate bubble, truncated to a safe length). If you toggle `/verbose on|full|off` while a run is in-flight, subsequent tool bubbles honor the new setting.
## Reasoning visibility (/reasoning) ## Reasoning visibility (/reasoning)
- Levels: `on|off|stream`. - Levels: `on|off|stream`.
+1 -1
View File
@@ -75,7 +75,7 @@ Core:
Session controls: Session controls:
- `/think <off|minimal|low|medium|high>` - `/think <off|minimal|low|medium|high>`
- `/verbose <on|full|off>` - `/verbose <on|off>`
- `/reasoning <on|off|stream>` - `/reasoning <on|off|stream>`
- `/cost <on|off>` - `/cost <on|off>`
- `/elevated <on|off>` (alias: `/elev`) - `/elevated <on|off>` (alias: `/elev`)
-24
View File
@@ -1,24 +0,0 @@
# Copilot Proxy (Clawdbot plugin)
Provider plugin for the **Copilot Proxy** VS Code extension.
## Enable
Bundled plugins are disabled by default. Enable this one:
```bash
clawdbot plugins enable copilot-proxy
```
Restart the Gateway after enabling.
## Authenticate
```bash
clawdbot models auth login --provider copilot-proxy --set-default
```
## Notes
- Copilot Proxy must be running in VS Code.
- Base URL must include `/v1`.
-139
View File
@@ -1,139 +0,0 @@
const DEFAULT_BASE_URL = "http://localhost:3000/v1";
const DEFAULT_API_KEY = "n/a";
const DEFAULT_CONTEXT_WINDOW = 128_000;
const DEFAULT_MAX_TOKENS = 8192;
const DEFAULT_MODEL_IDS = [
"gpt-5.2",
"gpt-5.2-codex",
"gpt-5.1",
"gpt-5.1-codex",
"gpt-5.1-codex-max",
"gpt-5-mini",
"claude-opus-4.5",
"claude-sonnet-4.5",
"claude-haiku-4.5",
"gemini-3-pro",
"gemini-3-flash",
"grok-code-fast-1",
] as const;
function normalizeBaseUrl(value: string): string {
const trimmed = value.trim();
if (!trimmed) return DEFAULT_BASE_URL;
let normalized = trimmed;
while (normalized.endsWith("/")) normalized = normalized.slice(0, -1);
if (!normalized.endsWith("/v1")) normalized = `${normalized}/v1`;
return normalized;
}
function validateBaseUrl(value: string): string | undefined {
const normalized = normalizeBaseUrl(value);
try {
new URL(normalized);
} catch {
return "Enter a valid URL";
}
return undefined;
}
function parseModelIds(input: string): string[] {
const parsed = input
.split(/[\n,]/)
.map((model) => model.trim())
.filter(Boolean);
return Array.from(new Set(parsed));
}
function buildModelDefinition(modelId: string) {
return {
id: modelId,
name: modelId,
api: "openai-completions",
reasoning: false,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_WINDOW,
maxTokens: DEFAULT_MAX_TOKENS,
};
}
const copilotProxyPlugin = {
id: "copilot-proxy",
name: "Copilot Proxy",
description: "Local Copilot Proxy (VS Code LM) provider plugin",
register(api) {
api.registerProvider({
id: "copilot-proxy",
label: "Copilot Proxy",
docsPath: "/providers/models",
auth: [
{
id: "local",
label: "Local proxy",
hint: "Configure base URL + models for the Copilot Proxy server",
kind: "custom",
run: async (ctx) => {
const baseUrlInput = await ctx.prompter.text({
message: "Copilot Proxy base URL",
initialValue: DEFAULT_BASE_URL,
validate: validateBaseUrl,
});
const modelInput = await ctx.prompter.text({
message: "Model IDs (comma-separated)",
initialValue: DEFAULT_MODEL_IDS.join(", "),
validate: (value) =>
parseModelIds(value).length > 0 ? undefined : "Enter at least one model id",
});
const baseUrl = normalizeBaseUrl(baseUrlInput);
const modelIds = parseModelIds(modelInput);
const defaultModelId = modelIds[0] ?? DEFAULT_MODEL_IDS[0];
const defaultModelRef = `copilot-proxy/${defaultModelId}`;
return {
profiles: [
{
profileId: "copilot-proxy:local",
credential: {
type: "token",
provider: "copilot-proxy",
token: DEFAULT_API_KEY,
},
},
],
configPatch: {
models: {
providers: {
"copilot-proxy": {
baseUrl,
apiKey: DEFAULT_API_KEY,
api: "openai-completions",
authHeader: false,
models: modelIds.map((modelId) => buildModelDefinition(modelId)),
},
},
},
agents: {
defaults: {
models: Object.fromEntries(
modelIds.map((modelId) => [`copilot-proxy/${modelId}`, {}]),
),
},
},
},
defaultModel: defaultModelRef,
notes: [
"Start the Copilot Proxy VS Code extension before using these models.",
"Copilot Proxy serves /v1/chat/completions; base URL must include /v1.",
"Model availability depends on your Copilot plan; edit models.providers.copilot-proxy if needed.",
],
};
},
},
],
});
},
};
export default copilotProxyPlugin;
-11
View File
@@ -1,11 +0,0 @@
{
"name": "@clawdbot/copilot-proxy",
"version": "2026.1.16-1",
"type": "module",
"description": "Clawdbot Copilot Proxy provider plugin",
"clawdbot": {
"extensions": [
"./index.ts"
]
}
}
@@ -1,24 +0,0 @@
# Google Antigravity Auth (Clawdbot plugin)
OAuth provider plugin for **Google Antigravity** (Cloud Code Assist).
## Enable
Bundled plugins are disabled by default. Enable this one:
```bash
clawdbot plugins enable google-antigravity-auth
```
Restart the Gateway after enabling.
## Authenticate
```bash
clawdbot models auth login --provider google-antigravity --set-default
```
## Notes
- Antigravity uses Google Cloud project quotas.
- If requests fail, ensure Gemini for Google Cloud is enabled.
-428
View File
@@ -1,428 +0,0 @@
import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { createServer } from "node:http";
// OAuth constants - decoded from pi-ai's base64 encoded values to stay in sync
const decode = (s: string) => Buffer.from(s, "base64").toString();
const CLIENT_ID = decode(
"MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
);
const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
const REDIRECT_URI = "http://localhost:51121/oauth-callback";
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
const TOKEN_URL = "https://oauth2.googleapis.com/token";
const DEFAULT_PROJECT_ID = "rising-fact-p41fc";
const DEFAULT_MODEL = "google-antigravity/claude-opus-4-5-thinking";
const SCOPES = [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
"https://www.googleapis.com/auth/cclog",
"https://www.googleapis.com/auth/experimentsandconfigs",
];
const CODE_ASSIST_ENDPOINTS = [
"https://cloudcode-pa.googleapis.com",
"https://daily-cloudcode-pa.sandbox.googleapis.com",
];
const RESPONSE_PAGE = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Clawdbot Antigravity OAuth</title>
</head>
<body>
<main>
<h1>Authentication complete</h1>
<p>You can return to the terminal.</p>
</main>
</body>
</html>`;
function generatePkce(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString("hex");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
function isWSL(): boolean {
if (process.platform !== "linux") return false;
try {
const release = readFileSync("/proc/version", "utf8").toLowerCase();
return release.includes("microsoft") || release.includes("wsl");
} catch {
return false;
}
}
function isWSL2(): boolean {
if (!isWSL()) return false;
try {
const version = readFileSync("/proc/version", "utf8").toLowerCase();
return version.includes("wsl2") || version.includes("microsoft-standard");
} catch {
return false;
}
}
function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
return isRemote || isWSL2();
}
function buildAuthUrl(params: { challenge: string; state: string }): string {
const url = new URL(AUTH_URL);
url.searchParams.set("client_id", CLIENT_ID);
url.searchParams.set("response_type", "code");
url.searchParams.set("redirect_uri", REDIRECT_URI);
url.searchParams.set("scope", SCOPES.join(" "));
url.searchParams.set("code_challenge", params.challenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("state", params.state);
url.searchParams.set("access_type", "offline");
url.searchParams.set("prompt", "consent");
return url.toString();
}
function parseCallbackInput(
input: string,
): { code: string; state: string } | { error: string } {
const trimmed = input.trim();
if (!trimmed) return { error: "No input provided" };
try {
const url = new URL(trimmed);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
if (!code) return { error: "Missing 'code' parameter in URL" };
if (!state) return { error: "Missing 'state' parameter in URL" };
return { code, state };
} catch {
return { error: "Paste the full redirect URL (not just the code)." };
}
}
async function startCallbackServer(params: { timeoutMs: number }) {
const redirect = new URL(REDIRECT_URI);
const port = redirect.port ? Number(redirect.port) : 51121;
let settled = false;
let resolveCallback: (url: URL) => void;
let rejectCallback: (err: Error) => void;
const callbackPromise = new Promise<URL>((resolve, reject) => {
resolveCallback = (url) => {
if (settled) return;
settled = true;
resolve(url);
};
rejectCallback = (err) => {
if (settled) return;
settled = true;
reject(err);
};
});
const timeout = setTimeout(() => {
rejectCallback(new Error("Timed out waiting for OAuth callback"));
}, params.timeoutMs);
timeout.unref?.();
const server = createServer((request, response) => {
if (!request.url) {
response.writeHead(400, { "Content-Type": "text/plain" });
response.end("Missing URL");
return;
}
const url = new URL(request.url, `${redirect.protocol}//${redirect.host}`);
if (url.pathname !== redirect.pathname) {
response.writeHead(404, { "Content-Type": "text/plain" });
response.end("Not found");
return;
}
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
response.end(RESPONSE_PAGE);
resolveCallback(url);
setImmediate(() => {
server.close();
});
});
await new Promise<void>((resolve, reject) => {
const onError = (err: Error) => {
server.off("error", onError);
reject(err);
};
server.once("error", onError);
server.listen(port, "127.0.0.1", () => {
server.off("error", onError);
resolve();
});
});
return {
waitForCallback: () => callbackPromise,
close: () =>
new Promise<void>((resolve) => {
server.close(() => resolve());
}),
};
}
async function exchangeCode(params: {
code: string;
verifier: string;
}): Promise<{ access: string; refresh: string; expires: number }> {
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code: params.code,
grant_type: "authorization_code",
redirect_uri: REDIRECT_URI,
code_verifier: params.verifier,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Token exchange failed: ${text}`);
}
const data = (await response.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
};
const access = data.access_token?.trim();
const refresh = data.refresh_token?.trim();
const expiresIn = data.expires_in ?? 0;
if (!access) throw new Error("Token exchange returned no access_token");
if (!refresh) throw new Error("Token exchange returned no refresh_token");
const expires = Date.now() + expiresIn * 1000 - 5 * 60 * 1000;
return { access, refresh, expires };
}
async function fetchUserEmail(accessToken: string): Promise<string | undefined> {
try {
const response = await fetch("https://www.googleapis.com/oauth2/v1/userinfo?alt=json", {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) return undefined;
const data = (await response.json()) as { email?: string };
return data.email;
} catch {
return undefined;
}
}
async function fetchProjectId(accessToken: string): Promise<string> {
const headers = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": "google-api-nodejs-client/9.15.1",
"X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
"Client-Metadata": JSON.stringify({
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
}),
};
for (const endpoint of CODE_ASSIST_ENDPOINTS) {
try {
const response = await fetch(`${endpoint}/v1internal:loadCodeAssist`, {
method: "POST",
headers,
body: JSON.stringify({
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
},
}),
});
if (!response.ok) continue;
const data = (await response.json()) as {
cloudaicompanionProject?: string | { id?: string };
};
if (typeof data.cloudaicompanionProject === "string") {
return data.cloudaicompanionProject;
}
if (
data.cloudaicompanionProject &&
typeof data.cloudaicompanionProject === "object" &&
data.cloudaicompanionProject.id
) {
return data.cloudaicompanionProject.id;
}
} catch {
// ignore
}
}
return DEFAULT_PROJECT_ID;
}
async function loginAntigravity(params: {
isRemote: boolean;
openUrl: (url: string) => Promise<void>;
prompt: (message: string) => Promise<string>;
note: (message: string, title?: string) => Promise<void>;
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
}): Promise<{
access: string;
refresh: string;
expires: number;
email?: string;
projectId: string;
}> {
const { verifier, challenge } = generatePkce();
const state = randomBytes(16).toString("hex");
const authUrl = buildAuthUrl({ challenge, state });
let callbackServer: Awaited<ReturnType<typeof startCallbackServer>> | null = null;
const needsManual = shouldUseManualOAuthFlow(params.isRemote);
if (!needsManual) {
try {
callbackServer = await startCallbackServer({ timeoutMs: 5 * 60 * 1000 });
} catch {
callbackServer = null;
}
}
if (!callbackServer) {
await params.note(
[
"Open the URL in your local browser.",
"After signing in, copy the full redirect URL and paste it back here.",
"",
`Auth URL: ${authUrl}`,
`Redirect URI: ${REDIRECT_URI}`,
].join("\n"),
"Google Antigravity OAuth",
);
}
if (!needsManual) {
params.progress.update("Opening Google sign-in…");
try {
await params.openUrl(authUrl);
} catch {
// ignore
}
}
let code = "";
let returnedState = "";
if (callbackServer) {
params.progress.update("Waiting for OAuth callback…");
const callback = await callbackServer.waitForCallback();
code = callback.searchParams.get("code") ?? "";
returnedState = callback.searchParams.get("state") ?? "";
await callbackServer.close();
} else {
params.progress.update("Waiting for redirect URL…");
const input = await params.prompt("Paste the redirect URL: ");
const parsed = parseCallbackInput(input);
if ("error" in parsed) throw new Error(parsed.error);
code = parsed.code;
returnedState = parsed.state;
}
if (!code) throw new Error("Missing OAuth code");
if (returnedState !== state) {
throw new Error("OAuth state mismatch. Please try again.");
}
params.progress.update("Exchanging code for tokens…");
const tokens = await exchangeCode({ code, verifier });
const email = await fetchUserEmail(tokens.access);
const projectId = await fetchProjectId(tokens.access);
params.progress.stop("Antigravity OAuth complete");
return { ...tokens, email, projectId };
}
const antigravityPlugin = {
id: "google-antigravity-auth",
name: "Google Antigravity Auth",
description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
register(api) {
api.registerProvider({
id: "google-antigravity",
label: "Google Antigravity",
docsPath: "/providers/models",
aliases: ["antigravity"],
auth: [
{
id: "oauth",
label: "Google OAuth",
hint: "PKCE + localhost callback",
kind: "oauth",
run: async (ctx) => {
const spin = ctx.prompter.progress("Starting Antigravity OAuth…");
try {
const result = await loginAntigravity({
isRemote: ctx.isRemote,
openUrl: ctx.openUrl,
prompt: async (message) => String(await ctx.prompter.text({ message })),
note: ctx.prompter.note,
progress: spin,
});
const profileId = `google-antigravity:${result.email ?? "default"}`;
return {
profiles: [
{
profileId,
credential: {
type: "oauth",
provider: "google-antigravity",
access: result.access,
refresh: result.refresh,
expires: result.expires,
email: result.email,
projectId: result.projectId,
},
},
],
configPatch: {
agents: {
defaults: {
models: {
[DEFAULT_MODEL]: {},
},
},
},
},
defaultModel: DEFAULT_MODEL,
notes: [
"Antigravity uses Google Cloud project quotas.",
"Enable Gemini for Google Cloud on your project if requests fail.",
],
};
} catch (err) {
spin.stop("Antigravity OAuth failed");
throw err;
}
},
},
],
});
},
};
export default antigravityPlugin;
@@ -1,11 +0,0 @@
{
"name": "@clawdbot/google-antigravity-auth",
"version": "2026.1.16-1",
"type": "module",
"description": "Clawdbot Google Antigravity OAuth provider plugin",
"clawdbot": {
"extensions": [
"./index.ts"
]
}
}
@@ -1,24 +0,0 @@
# Google Gemini CLI Auth (Clawdbot plugin)
OAuth provider plugin for **Gemini CLI** (Google Code Assist).
## Enable
Bundled plugins are disabled by default. Enable this one:
```bash
clawdbot plugins enable google-gemini-cli-auth
```
Restart the Gateway after enabling.
## Authenticate
```bash
clawdbot models auth login --provider google-gemini-cli --set-default
```
## Env vars
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_ID` / `GEMINI_CLI_OAUTH_CLIENT_ID`
- `CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET` / `GEMINI_CLI_OAUTH_CLIENT_SECRET`
@@ -1,88 +0,0 @@
import { loginGeminiCliOAuth } from "./oauth.js";
const PROVIDER_ID = "google-gemini-cli";
const PROVIDER_LABEL = "Gemini CLI OAuth";
const DEFAULT_MODEL = "google-gemini-cli/gemini-3-pro-preview";
const ENV_VARS = [
"CLAWDBOT_GEMINI_OAUTH_CLIENT_ID",
"CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET",
"GEMINI_CLI_OAUTH_CLIENT_ID",
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
];
const geminiCliPlugin = {
id: "google-gemini-cli-auth",
name: "Google Gemini CLI Auth",
description: "OAuth flow for Gemini CLI (Google Code Assist)",
register(api) {
api.registerProvider({
id: PROVIDER_ID,
label: PROVIDER_LABEL,
docsPath: "/providers/models",
aliases: ["gemini-cli"],
envVars: ENV_VARS,
auth: [
{
id: "oauth",
label: "Google OAuth",
hint: "PKCE + localhost callback",
kind: "oauth",
run: async (ctx) => {
const spin = ctx.prompter.progress("Starting Gemini CLI OAuth…");
try {
const result = await loginGeminiCliOAuth({
isRemote: ctx.isRemote,
openUrl: ctx.openUrl,
log: (msg) => ctx.runtime.log(msg),
note: ctx.prompter.note,
prompt: async (message) => String(await ctx.prompter.text({ message })),
progress: spin,
});
spin.stop("Gemini CLI OAuth complete");
const profileId = `google-gemini-cli:${result.email ?? "default"}`;
return {
profiles: [
{
profileId,
credential: {
type: "oauth",
provider: PROVIDER_ID,
access: result.access,
refresh: result.refresh,
expires: result.expires,
email: result.email,
projectId: result.projectId,
},
},
],
configPatch: {
agents: {
defaults: {
models: {
[DEFAULT_MODEL]: {},
},
},
},
},
defaultModel: DEFAULT_MODEL,
notes: [
"If requests fail, set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.",
],
};
} catch (err) {
spin.stop("Gemini CLI OAuth failed");
await ctx.prompter.note(
"Trouble with OAuth? Ensure your Google account has Gemini CLI access.",
"OAuth help",
);
throw err;
}
},
},
],
});
},
};
export default geminiCliPlugin;
-496
View File
@@ -1,496 +0,0 @@
import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { createServer } from "node:http";
const CLIENT_ID_KEYS = ["CLAWDBOT_GEMINI_OAUTH_CLIENT_ID", "GEMINI_CLI_OAUTH_CLIENT_ID"];
const CLIENT_SECRET_KEYS = [
"CLAWDBOT_GEMINI_OAUTH_CLIENT_SECRET",
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
];
const REDIRECT_URI = "http://localhost:8085/oauth2callback";
const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
const TOKEN_URL = "https://oauth2.googleapis.com/token";
const USERINFO_URL = "https://www.googleapis.com/oauth2/v1/userinfo?alt=json";
const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
const SCOPES = [
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
];
const TIER_FREE = "free-tier";
const TIER_LEGACY = "legacy-tier";
const TIER_STANDARD = "standard-tier";
export type GeminiCliOAuthCredentials = {
access: string;
refresh: string;
expires: number;
email?: string;
projectId: string;
};
export type GeminiCliOAuthContext = {
isRemote: boolean;
openUrl: (url: string) => Promise<void>;
log: (msg: string) => void;
note: (message: string, title?: string) => Promise<void>;
prompt: (message: string) => Promise<string>;
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
};
function resolveEnv(keys: string[]): string | undefined {
for (const key of keys) {
const value = process.env[key]?.trim();
if (value) return value;
}
return undefined;
}
function resolveOAuthClientConfig(): { clientId: string; clientSecret?: string } {
const clientId = resolveEnv(CLIENT_ID_KEYS);
if (!clientId) {
throw new Error(
"Missing Gemini OAuth client ID. Set CLAWDBOT_GEMINI_OAUTH_CLIENT_ID (or GEMINI_CLI_OAUTH_CLIENT_ID).",
);
}
const clientSecret = resolveEnv(CLIENT_SECRET_KEYS);
return { clientId, clientSecret };
}
function isWSL(): boolean {
if (process.platform !== "linux") return false;
try {
const release = readFileSync("/proc/version", "utf8").toLowerCase();
return release.includes("microsoft") || release.includes("wsl");
} catch {
return false;
}
}
function isWSL2(): boolean {
if (!isWSL()) return false;
try {
const version = readFileSync("/proc/version", "utf8").toLowerCase();
return version.includes("wsl2") || version.includes("microsoft-standard");
} catch {
return false;
}
}
function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
return isRemote || isWSL2();
}
function generatePkce(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString("hex");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
function buildAuthUrl(challenge: string, verifier: string): string {
const { clientId } = resolveOAuthClientConfig();
const params = new URLSearchParams({
client_id: clientId,
response_type: "code",
redirect_uri: REDIRECT_URI,
scope: SCOPES.join(" "),
code_challenge: challenge,
code_challenge_method: "S256",
state: verifier,
access_type: "offline",
prompt: "consent",
});
return `${AUTH_URL}?${params.toString()}`;
}
function parseCallbackInput(
input: string,
expectedState: string,
): { code: string; state: string } | { error: string } {
const trimmed = input.trim();
if (!trimmed) return { error: "No input provided" };
try {
const url = new URL(trimmed);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state") ?? expectedState;
if (!code) return { error: "Missing 'code' parameter in URL" };
if (!state) return { error: "Missing 'state' parameter. Paste the full URL." };
return { code, state };
} catch {
if (!expectedState) return { error: "Paste the full redirect URL, not just the code." };
return { code: trimmed, state: expectedState };
}
}
async function waitForLocalCallback(params: {
expectedState: string;
timeoutMs: number;
onProgress?: (message: string) => void;
}): Promise<{ code: string; state: string }> {
const port = 8085;
const hostname = "localhost";
const expectedPath = "/oauth2callback";
return new Promise<{ code: string; state: string }>((resolve, reject) => {
let timeout: NodeJS.Timeout | null = null;
const server = createServer((req, res) => {
try {
const requestUrl = new URL(req.url ?? "/", `http://${hostname}:${port}`);
if (requestUrl.pathname !== expectedPath) {
res.statusCode = 404;
res.setHeader("Content-Type", "text/plain");
res.end("Not found");
return;
}
const error = requestUrl.searchParams.get("error");
const code = requestUrl.searchParams.get("code")?.trim();
const state = requestUrl.searchParams.get("state")?.trim();
if (error) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain");
res.end(`Authentication failed: ${error}`);
finish(new Error(`OAuth error: ${error}`));
return;
}
if (!code || !state) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain");
res.end("Missing code or state");
finish(new Error("Missing OAuth code or state"));
return;
}
if (state !== params.expectedState) {
res.statusCode = 400;
res.setHeader("Content-Type", "text/plain");
res.end("Invalid state");
finish(new Error("OAuth state mismatch"));
return;
}
res.statusCode = 200;
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(
"<!doctype html><html><head><meta charset='utf-8'/></head>" +
"<body><h2>Gemini CLI OAuth complete</h2>" +
"<p>You can close this window and return to Clawdbot.</p></body></html>",
);
finish(undefined, { code, state });
} catch (err) {
finish(err instanceof Error ? err : new Error("OAuth callback failed"));
}
});
const finish = (err?: Error, result?: { code: string; state: string }) => {
if (timeout) clearTimeout(timeout);
try {
server.close();
} catch {
// ignore close errors
}
if (err) {
reject(err);
} else if (result) {
resolve(result);
}
};
server.once("error", (err) => {
finish(err instanceof Error ? err : new Error("OAuth callback server error"));
});
server.listen(port, hostname, () => {
params.onProgress?.(`Waiting for OAuth callback on ${REDIRECT_URI}…`);
});
timeout = setTimeout(() => {
finish(new Error("OAuth callback timeout"));
}, params.timeoutMs);
});
}
async function exchangeCodeForTokens(code: string, verifier: string): Promise<GeminiCliOAuthCredentials> {
const { clientId, clientSecret } = resolveOAuthClientConfig();
const body = new URLSearchParams({
client_id: clientId,
code,
grant_type: "authorization_code",
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
});
if (clientSecret) {
body.set("client_secret", clientSecret);
}
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Token exchange failed: ${errorText}`);
}
const data = (await response.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
if (!data.refresh_token) {
throw new Error("No refresh token received. Please try again.");
}
const email = await getUserEmail(data.access_token);
const projectId = await discoverProject(data.access_token);
const expiresAt = Date.now() + data.expires_in * 1000 - 5 * 60 * 1000;
return {
refresh: data.refresh_token,
access: data.access_token,
expires: expiresAt,
projectId,
email,
};
}
async function getUserEmail(accessToken: string): Promise<string | undefined> {
try {
const response = await fetch(USERINFO_URL, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (response.ok) {
const data = (await response.json()) as { email?: string };
return data.email;
}
} catch {
// ignore
}
return undefined;
}
async function discoverProject(accessToken: string): Promise<string> {
const envProject = process.env.GOOGLE_CLOUD_PROJECT || process.env.GOOGLE_CLOUD_PROJECT_ID;
const headers = {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
"User-Agent": "google-api-nodejs-client/9.15.1",
"X-Goog-Api-Client": "gl-node/clawdbot",
};
const loadBody = {
cloudaicompanionProject: envProject,
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
duetProject: envProject,
},
};
let data: {
currentTier?: { id?: string };
cloudaicompanionProject?: string | { id?: string };
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>;
} = {};
try {
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:loadCodeAssist`, {
method: "POST",
headers,
body: JSON.stringify(loadBody),
});
if (!response.ok) {
const errorPayload = await response.json().catch(() => null);
if (isVpcScAffected(errorPayload)) {
data = { currentTier: { id: TIER_STANDARD } };
} else {
throw new Error(`loadCodeAssist failed: ${response.status} ${response.statusText}`);
}
} else {
data = (await response.json()) as typeof data;
}
} catch (err) {
if (err instanceof Error) {
throw err;
}
throw new Error("loadCodeAssist failed");
}
if (data.currentTier) {
const project = data.cloudaicompanionProject;
if (typeof project === "string" && project) return project;
if (typeof project === "object" && project?.id) return project.id;
if (envProject) return envProject;
throw new Error(
"This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.",
);
}
const tier = getDefaultTier(data.allowedTiers);
const tierId = tier?.id || TIER_FREE;
if (tierId !== TIER_FREE && !envProject) {
throw new Error(
"This account requires GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID to be set.",
);
}
const onboardBody: Record<string, unknown> = {
tierId,
metadata: {
ideType: "IDE_UNSPECIFIED",
platform: "PLATFORM_UNSPECIFIED",
pluginType: "GEMINI",
},
};
if (tierId !== TIER_FREE && envProject) {
onboardBody.cloudaicompanionProject = envProject;
(onboardBody.metadata as Record<string, unknown>).duetProject = envProject;
}
const onboardResponse = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal:onboardUser`, {
method: "POST",
headers,
body: JSON.stringify(onboardBody),
});
if (!onboardResponse.ok) {
throw new Error(`onboardUser failed: ${onboardResponse.status} ${onboardResponse.statusText}`);
}
let lro = (await onboardResponse.json()) as {
done?: boolean;
name?: string;
response?: { cloudaicompanionProject?: { id?: string } };
};
if (!lro.done && lro.name) {
lro = await pollOperation(lro.name, headers);
}
const projectId = lro.response?.cloudaicompanionProject?.id;
if (projectId) return projectId;
if (envProject) return envProject;
throw new Error(
"Could not discover or provision a Google Cloud project. Set GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID.",
);
}
function isVpcScAffected(payload: unknown): boolean {
if (!payload || typeof payload !== "object") return false;
const error = (payload as { error?: unknown }).error;
if (!error || typeof error !== "object") return false;
const details = (error as { details?: unknown[] }).details;
if (!Array.isArray(details)) return false;
return details.some(
(item) =>
typeof item === "object" && item && (item as { reason?: string }).reason === "SECURITY_POLICY_VIOLATED",
);
}
function getDefaultTier(
allowedTiers?: Array<{ id?: string; isDefault?: boolean }>,
): { id?: string } | undefined {
if (!allowedTiers?.length) return { id: TIER_LEGACY };
return allowedTiers.find((tier) => tier.isDefault) ?? { id: TIER_LEGACY };
}
async function pollOperation(
operationName: string,
headers: Record<string, string>,
): Promise<{ done?: boolean; response?: { cloudaicompanionProject?: { id?: string } } }> {
for (let attempt = 0; attempt < 24; attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 5000));
const response = await fetch(`${CODE_ASSIST_ENDPOINT}/v1internal/${operationName}`, {
headers,
});
if (!response.ok) continue;
const data = (await response.json()) as {
done?: boolean;
response?: { cloudaicompanionProject?: { id?: string } };
};
if (data.done) return data;
}
throw new Error("Operation polling timeout");
}
export async function loginGeminiCliOAuth(ctx: GeminiCliOAuthContext): Promise<GeminiCliOAuthCredentials> {
const needsManual = shouldUseManualOAuthFlow(ctx.isRemote);
await ctx.note(
needsManual
? [
"You are running in a remote/VPS environment.",
"A URL will be shown for you to open in your LOCAL browser.",
"After signing in, copy the redirect URL and paste it back here.",
].join("\n")
: [
"Browser will open for Google authentication.",
"Sign in with your Google account for Gemini CLI access.",
"The callback will be captured automatically on localhost:8085.",
].join("\n"),
"Gemini CLI OAuth",
);
const { verifier, challenge } = generatePkce();
const authUrl = buildAuthUrl(challenge, verifier);
if (needsManual) {
ctx.progress.update("OAuth URL ready");
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
ctx.progress.update("Waiting for you to paste the callback URL...");
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
const parsed = parseCallbackInput(callbackInput, verifier);
if ("error" in parsed) throw new Error(parsed.error);
if (parsed.state !== verifier) {
throw new Error("OAuth state mismatch - please try again");
}
ctx.progress.update("Exchanging authorization code for tokens...");
return exchangeCodeForTokens(parsed.code, verifier);
}
ctx.progress.update("Complete sign-in in browser...");
try {
await ctx.openUrl(authUrl);
} catch {
ctx.log(`\nOpen this URL in your browser:\n\n${authUrl}\n`);
}
try {
const { code } = await waitForLocalCallback({
expectedState: verifier,
timeoutMs: 5 * 60 * 1000,
onProgress: (msg) => ctx.progress.update(msg),
});
ctx.progress.update("Exchanging authorization code for tokens...");
return await exchangeCodeForTokens(code, verifier);
} catch (err) {
if (
err instanceof Error &&
(err.message.includes("EADDRINUSE") ||
err.message.includes("port") ||
err.message.includes("listen"))
) {
ctx.progress.update("Local callback server failed. Switching to manual mode...");
ctx.log(`\nOpen this URL in your LOCAL browser:\n\n${authUrl}\n`);
const callbackInput = await ctx.prompt("Paste the redirect URL here: ");
const parsed = parseCallbackInput(callbackInput, verifier);
if ("error" in parsed) throw new Error(parsed.error);
if (parsed.state !== verifier) {
throw new Error("OAuth state mismatch - please try again");
}
ctx.progress.update("Exchanging authorization code for tokens...");
return exchangeCodeForTokens(parsed.code, verifier);
}
throw err;
}
}
@@ -1,11 +0,0 @@
{
"name": "@clawdbot/google-gemini-cli-auth",
"version": "2026.1.16-1",
"type": "module",
"description": "Clawdbot Gemini CLI OAuth provider plugin",
"clawdbot": {
"extensions": [
"./index.ts"
]
}
}
-5
View File
@@ -1,10 +1,5 @@
# Changelog # Changelog
## 2026.1.16
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.15 ## 2026.1.15
### Changes ### Changes
+2 -4
View File
@@ -1,12 +1,10 @@
{ {
"name": "@clawdbot/matrix", "name": "@clawdbot/matrix",
"version": "2026.1.16", "version": "2026.1.15",
"type": "module", "type": "module",
"description": "Clawdbot Matrix channel plugin", "description": "Clawdbot Matrix channel plugin",
"clawdbot": { "clawdbot": {
"extensions": [ "extensions": ["./index.ts"]
"./index.ts"
]
}, },
"dependencies": { "dependencies": {
"markdown-it": "14.1.0", "markdown-it": "14.1.0",
-9
View File
@@ -164,15 +164,6 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
}, },
messaging: { messaging: {
normalizeTarget: normalizeMatrixMessagingTarget, normalizeTarget: normalizeMatrixMessagingTarget,
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^(matrix:)?[!#@]/i.test(trimmed)) return true;
return trimmed.includes(":");
},
hint: "<room|alias|user>",
},
}, },
directory: { directory: {
self: async () => null, self: async () => null,
+3 -3
View File
@@ -16,14 +16,14 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b
if (roomId.toLowerCase().startsWith("room:")) { if (roomId.toLowerCase().startsWith("room:")) {
roomId = roomId.slice("room:".length).trim(); roomId = roomId.slice("room:".length).trim();
} }
const groupChannel = params.groupChannel?.trim() ?? ""; const groupRoom = params.groupRoom?.trim() ?? "";
const aliases = groupChannel ? [groupChannel] : []; const aliases = groupRoom ? [groupRoom] : [];
const cfg = params.cfg as CoreConfig; const cfg = params.cfg as CoreConfig;
const resolved = resolveMatrixRoomConfig({ const resolved = resolveMatrixRoomConfig({
rooms: cfg.channels?.matrix?.rooms, rooms: cfg.channels?.matrix?.rooms,
roomId, roomId,
aliases, aliases,
name: groupChannel || undefined, name: groupRoom || undefined,
}).config; }).config;
if (resolved) { if (resolved) {
if (resolved.autoReply === true) return false; if (resolved.autoReply === true) return false;
+20 -34
View File
@@ -8,14 +8,12 @@ import { hasControlCommand } from "../../../../../src/auto-reply/command-detecti
import { shouldHandleTextCommands } from "../../../../../src/auto-reply/commands-registry.js"; import { shouldHandleTextCommands } from "../../../../../src/auto-reply/commands-registry.js";
import { formatAgentEnvelope } from "../../../../../src/auto-reply/envelope.js"; import { formatAgentEnvelope } from "../../../../../src/auto-reply/envelope.js";
import { dispatchReplyFromConfig } from "../../../../../src/auto-reply/reply/dispatch-from-config.js"; import { dispatchReplyFromConfig } from "../../../../../src/auto-reply/reply/dispatch-from-config.js";
import { finalizeInboundContext } from "../../../../../src/auto-reply/reply/inbound-context.js";
import { import {
buildMentionRegexes, buildMentionRegexes,
matchesMentionPatterns, matchesMentionPatterns,
} from "../../../../../src/auto-reply/reply/mentions.js"; } from "../../../../../src/auto-reply/reply/mentions.js";
import { createReplyDispatcherWithTyping } from "../../../../../src/auto-reply/reply/reply-dispatcher.js"; import { createReplyDispatcherWithTyping } from "../../../../../src/auto-reply/reply/reply-dispatcher.js";
import type { ReplyPayload } from "../../../../../src/auto-reply/types.js"; import type { ReplyPayload } from "../../../../../src/auto-reply/types.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../../../../src/channels/command-gating.js";
import { loadConfig } from "../../../../../src/config/config.js"; import { loadConfig } from "../../../../../src/config/config.js";
import { resolveStorePath, updateLastRoute } from "../../../../../src/config/sessions.js"; import { resolveStorePath, updateLastRoute } from "../../../../../src/config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../../../../../src/globals.js"; import { danger, logVerbose, shouldLogVerbose } from "../../../../../src/globals.js";
@@ -295,26 +293,17 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
text: bodyText, text: bodyText,
mentionRegexes, mentionRegexes,
}); });
const commandAuthorized =
(!allowlistOnly && effectiveAllowFrom.length === 0) ||
resolveMatrixAllowListMatches({
allowList: effectiveAllowFrom,
userId: senderId,
userName: senderName,
});
const allowTextCommands = shouldHandleTextCommands({ const allowTextCommands = shouldHandleTextCommands({
cfg, cfg,
surface: "matrix", surface: "matrix",
}); });
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const senderAllowedForCommands = resolveMatrixAllowListMatches({
allowList: effectiveAllowFrom,
userId: senderId,
userName: senderName,
});
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
],
});
if (isRoom && allowTextCommands && hasControlCommand(bodyText, cfg) && !commandAuthorized) {
logVerbose(`matrix: drop control command from unauthorized sender ${senderId}`);
return;
}
const shouldRequireMention = isRoom const shouldRequireMention = isRoom
? roomConfigInfo.config?.autoReply === true ? roomConfigInfo.config?.autoReply === true
? false ? false
@@ -365,21 +354,18 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
}); });
const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined; const groupSystemPrompt = roomConfigInfo.config?.systemPrompt?.trim() || undefined;
const ctxPayload = finalizeInboundContext({ const ctxPayload = {
Body: body, Body: body,
RawBody: bodyText, From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`,
CommandBody: bodyText, To: `room:${roomId}`,
From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`, SessionKey: route.sessionKey,
To: `room:${roomId}`, AccountId: route.accountId,
SessionKey: route.sessionKey, ChatType: isDirectMessage ? "direct" : "room",
AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : "channel",
ConversationLabel: envelopeFrom,
SenderName: senderName, SenderName: senderName,
SenderId: senderId, SenderId: senderId,
SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""), SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""),
GroupSubject: isRoom ? (roomName ?? roomId) : undefined, GroupSubject: isRoom ? (roomName ?? roomId) : undefined,
GroupChannel: isRoom ? (room.getCanonicalAlias?.() ?? roomId) : undefined, GroupRoom: isRoom ? (room.getCanonicalAlias?.() ?? roomId) : undefined,
GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined, GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
Provider: "matrix" as const, Provider: "matrix" as const,
Surface: "matrix" as const, Surface: "matrix" as const,
@@ -391,11 +377,11 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
MediaPath: media?.path, MediaPath: media?.path,
MediaType: media?.contentType, MediaType: media?.contentType,
MediaUrl: media?.path, MediaUrl: media?.path,
CommandAuthorized: commandAuthorized, CommandAuthorized: commandAuthorized,
CommandSource: "text" as const, CommandSource: "text" as const,
OriginatingChannel: "matrix" as const, OriginatingChannel: "matrix" as const,
OriginatingTo: `room:${roomId}`, OriginatingTo: `room:${roomId}`,
}); };
if (isDirectMessage) { if (isDirectMessage) {
const storePath = resolveStorePath(cfg.session?.store, { const storePath = resolveStorePath(cfg.session?.store, {
@@ -2,7 +2,7 @@ import type { MatrixClient } from "matrix-js-sdk";
import { chunkMarkdownText } from "../../../../../src/auto-reply/chunk.js"; import { chunkMarkdownText } from "../../../../../src/auto-reply/chunk.js";
import type { ReplyPayload } from "../../../../../src/auto-reply/types.js"; import type { ReplyPayload } from "../../../../../src/auto-reply/types.js";
import { danger, logVerbose } from "../../../../../src/globals.js"; import { danger } from "../../../../../src/globals.js";
import type { RuntimeEnv } from "../../../../../src/runtime.js"; import type { RuntimeEnv } from "../../../../../src/runtime.js";
import { sendMessageMatrix } from "../send.js"; import { sendMessageMatrix } from "../send.js";
@@ -18,12 +18,7 @@ export async function deliverMatrixReplies(params: {
const chunkLimit = Math.min(params.textLimit, 4000); const chunkLimit = Math.min(params.textLimit, 4000);
let hasReplied = false; let hasReplied = false;
for (const reply of params.replies) { for (const reply of params.replies) {
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0; if (!reply?.text && !reply?.mediaUrl && !(reply?.mediaUrls?.length ?? 0)) {
if (!reply?.text && !hasMedia) {
if (reply?.audioAsVoice) {
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
continue;
}
params.runtime.error?.(danger("matrix reply missing text/media")); params.runtime.error?.(danger("matrix reply missing text/media"));
continue; continue;
} }
@@ -62,7 +57,6 @@ export async function deliverMatrixReplies(params: {
mediaUrl, mediaUrl,
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined, replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
threadId: params.threadId, threadId: params.threadId,
audioAsVoice: reply.audioAsVoice,
}); });
if (shouldIncludeReply(replyToId)) { if (shouldIncludeReply(replyToId)) {
hasReplied = true; hasReplied = true;
@@ -1,22 +0,0 @@
import { describe, expect, it } from "vitest";
import { parsePollStartContent } from "./poll-types.js";
describe("parsePollStartContent", () => {
it("parses legacy m.poll payloads", () => {
const summary = parsePollStartContent({
"m.poll": {
question: { "m.text": "Lunch?" },
kind: "m.poll.disclosed",
max_selections: 1,
answers: [
{ id: "answer1", "m.text": "Yes" },
{ id: "answer2", "m.text": "No" },
],
},
});
expect(summary?.question).toBe("Lunch?");
expect(summary?.answers).toEqual(["Yes", "No"]);
});
});
+28 -35
View File
@@ -7,17 +7,15 @@
* - m.poll.end - Closes a poll * - m.poll.end - Closes a poll
*/ */
import type { TimelineEvents } from "matrix-js-sdk/lib/@types/event.js";
import type { ExtensibleAnyMessageEventContent } from "matrix-js-sdk/lib/@types/extensible_events.js";
import type { PollInput } from "../../../../src/polls.js"; import type { PollInput } from "../../../../src/polls.js";
export const M_POLL_START = "m.poll.start" as const; export const M_POLL_START = "m.poll.start";
export const M_POLL_RESPONSE = "m.poll.response" as const; export const M_POLL_RESPONSE = "m.poll.response";
export const M_POLL_END = "m.poll.end" as const; export const M_POLL_END = "m.poll.end";
export const ORG_POLL_START = "org.matrix.msc3381.poll.start" as const; export const ORG_POLL_START = "org.matrix.msc3381.poll.start";
export const ORG_POLL_RESPONSE = "org.matrix.msc3381.poll.response" as const; export const ORG_POLL_RESPONSE = "org.matrix.msc3381.poll.response";
export const ORG_POLL_END = "org.matrix.msc3381.poll.end" as const; export const ORG_POLL_END = "org.matrix.msc3381.poll.end";
export const POLL_EVENT_TYPES = [ export const POLL_EVENT_TYPES = [
M_POLL_START, M_POLL_START,
@@ -34,7 +32,9 @@ export const POLL_END_TYPES = [M_POLL_END, ORG_POLL_END];
export type PollKind = "m.poll.disclosed" | "m.poll.undisclosed"; export type PollKind = "m.poll.disclosed" | "m.poll.undisclosed";
export type TextContent = ExtensibleAnyMessageEventContent & { export type TextContent = {
"m.text"?: string;
"org.matrix.msc1767.text"?: string;
body?: string; body?: string;
}; };
@@ -42,19 +42,25 @@ export type PollAnswer = {
id: string; id: string;
} & TextContent; } & TextContent;
export type PollStartSubtype = { export type PollStartContent = {
question: TextContent; "m.poll"?: {
kind?: PollKind; question: TextContent;
max_selections?: number; kind?: PollKind;
answers: PollAnswer[]; max_selections?: number;
answers: PollAnswer[];
};
"org.matrix.msc3381.poll.start"?: {
question: TextContent;
kind?: PollKind;
max_selections?: number;
answers: PollAnswer[];
};
"m.relates_to"?: {
rel_type: "m.reference";
event_id: string;
};
}; };
export type LegacyPollStartContent = {
"m.poll"?: PollStartSubtype;
};
export type PollStartContent = TimelineEvents[typeof M_POLL_START] | LegacyPollStartContent;
export type PollSummary = { export type PollSummary = {
eventId: string; eventId: string;
roomId: string; roomId: string;
@@ -76,9 +82,7 @@ export function getTextContent(text?: TextContent): string {
} }
export function parsePollStartContent(content: PollStartContent): PollSummary | null { export function parsePollStartContent(content: PollStartContent): PollSummary | null {
const poll = (content as Record<string, PollStartSubtype | undefined>)[M_POLL_START] const poll = content["m.poll"] ?? content["org.matrix.msc3381.poll.start"];
?? (content as Record<string, PollStartSubtype | undefined>)[ORG_POLL_START]
?? (content as Record<string, PollStartSubtype | undefined>)["m.poll"];
if (!poll) return null; if (!poll) return null;
const question = getTextContent(poll.question); const question = getTextContent(poll.question);
@@ -117,11 +121,6 @@ function buildTextContent(body: string): TextContent {
}; };
} }
function buildPollFallbackText(question: string, answers: string[]): string {
if (answers.length === 0) return question;
return `${question}\n${answers.map((answer, idx) => `${idx + 1}. ${answer}`).join("\n")}`;
}
export function buildPollStartContent(poll: PollInput): PollStartContent { export function buildPollStartContent(poll: PollInput): PollStartContent {
const question = poll.question.trim(); const question = poll.question.trim();
const answers = poll.options const answers = poll.options
@@ -133,19 +132,13 @@ export function buildPollStartContent(poll: PollInput): PollStartContent {
})); }));
const maxSelections = poll.multiple ? Math.max(1, answers.length) : 1; const maxSelections = poll.multiple ? Math.max(1, answers.length) : 1;
const fallbackText = buildPollFallbackText(
question,
answers.map((answer) => getTextContent(answer)),
);
return { return {
[M_POLL_START]: { "m.poll": {
question: buildTextContent(question), question: buildTextContent(question),
kind: poll.multiple ? "m.poll.undisclosed" : "m.poll.disclosed", kind: poll.multiple ? "m.poll.undisclosed" : "m.poll.disclosed",
max_selections: maxSelections, max_selections: maxSelections,
answers, answers,
}, },
"m.text": fallbackText,
"org.matrix.msc1767.text": fallbackText,
}; };
} }
+2 -7
View File
@@ -31,11 +31,6 @@ vi.mock("../../../../src/web/media.js", () => ({
}), }),
})); }));
vi.mock("../../../../src/media/image-ops.js", () => ({
getImageMetadata: vi.fn().mockResolvedValue(null),
resizeToJpeg: vi.fn(),
}));
let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix; let sendMessageMatrix: typeof import("./send.js").sendMessageMatrix;
const makeClient = () => { const makeClient = () => {
@@ -70,13 +65,13 @@ describe("sendMessageMatrix media", () => {
const uploadArg = uploadContent.mock.calls[0]?.[0]; const uploadArg = uploadContent.mock.calls[0]?.[0];
expect(Buffer.isBuffer(uploadArg)).toBe(true); expect(Buffer.isBuffer(uploadArg)).toBe(true);
const content = sendMessage.mock.calls[0]?.[1] as { const content = sendMessage.mock.calls[0]?.[2] as {
url?: string; url?: string;
msgtype?: string; msgtype?: string;
format?: string; format?: string;
formatted_body?: string; formatted_body?: string;
}; };
expect(content.msgtype).toBe("m.image"); expect(content.msgtype).toBe("m.file");
expect(content.format).toBe("org.matrix.custom.html"); expect(content.format).toBe("org.matrix.custom.html");
expect(content.formatted_body).toContain("caption"); expect(content.formatted_body).toContain("caption");
expect(content.url).toBe("mxc://example/file"); expect(content.url).toBe("mxc://example/file");
+22 -145
View File
@@ -1,15 +1,12 @@
import type { AccountDataEvents, MatrixClient } from "matrix-js-sdk"; import type { AccountDataEvents, MatrixClient } from "matrix-js-sdk";
import { EventType, MsgType, RelationType } from "matrix-js-sdk"; import { EventType, MsgType, RelationType } from "matrix-js-sdk";
import type { import type {
RoomMessageEventContent,
ReactionEventContent, ReactionEventContent,
RoomMessageEventContent,
} from "matrix-js-sdk/lib/@types/events.js"; } from "matrix-js-sdk/lib/@types/events.js";
import { chunkMarkdownText, resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js"; import { chunkMarkdownText, resolveTextChunkLimit } from "../../../../src/auto-reply/chunk.js";
import { loadConfig } from "../../../../src/config/config.js"; import { loadConfig } from "../../../../src/config/config.js";
import { isVoiceCompatibleAudio } from "../../../../src/media/audio.js";
import { mediaKindFromMime } from "../../../../src/media/constants.js";
import { getImageMetadata, resizeToJpeg } from "../../../../src/media/image-ops.js";
import type { PollInput } from "../../../../src/polls.js"; import type { PollInput } from "../../../../src/polls.js";
import { loadWebMedia } from "../../../../src/web/media.js"; import { loadWebMedia } from "../../../../src/web/media.js";
import { getActiveMatrixClient } from "./active-client.js"; import { getActiveMatrixClient } from "./active-client.js";
@@ -50,8 +47,6 @@ export type MatrixSendOpts = {
replyToId?: string; replyToId?: string;
threadId?: string | number | null; threadId?: string | number | null;
timeoutMs?: number; timeoutMs?: number;
/** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */
audioAsVoice?: boolean;
}; };
function ensureNodeRuntime() { function ensureNodeRuntime() {
@@ -76,12 +71,6 @@ function normalizeTarget(raw: string): string {
return trimmed; return trimmed;
} }
function normalizeThreadId(raw?: string | number | null): string | null {
if (raw === undefined || raw === null) return null;
const trimmed = String(raw).trim();
return trimmed ? trimmed : null;
}
async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise<string> { async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise<string> {
const trimmed = userId.trim(); const trimmed = userId.trim();
if (!trimmed.startsWith("@")) { if (!trimmed.startsWith("@")) {
@@ -130,18 +119,6 @@ export async function resolveMatrixRoomId(
return target; return target;
} }
type MatrixImageInfo = {
w?: number;
h?: number;
thumbnail_url?: string;
thumbnail_info?: {
w: number;
h: number;
mimetype: string;
size: number;
};
};
function buildMediaContent(params: { function buildMediaContent(params: {
msgtype: MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File; msgtype: MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File;
body: string; body: string;
@@ -150,24 +127,8 @@ function buildMediaContent(params: {
mimetype?: string; mimetype?: string;
size: number; size: number;
relation?: MatrixReplyRelation; relation?: MatrixReplyRelation;
isVoice?: boolean;
durationMs?: number;
imageInfo?: MatrixImageInfo;
}): RoomMessageEventContent { }): RoomMessageEventContent {
const info: Record<string, unknown> = { mimetype: params.mimetype, size: params.size }; const info = { mimetype: params.mimetype, size: params.size };
if (params.durationMs !== undefined) {
info.duration = params.durationMs;
}
if (params.imageInfo) {
if (params.imageInfo.w) info.w = params.imageInfo.w;
if (params.imageInfo.h) info.h = params.imageInfo.h;
if (params.imageInfo.thumbnail_url) {
info.thumbnail_url = params.imageInfo.thumbnail_url;
if (params.imageInfo.thumbnail_info) {
info.thumbnail_info = params.imageInfo.thumbnail_info;
}
}
}
const base: MatrixMessageContent = { const base: MatrixMessageContent = {
msgtype: params.msgtype, msgtype: params.msgtype,
body: params.body, body: params.body,
@@ -175,12 +136,6 @@ function buildMediaContent(params: {
info, info,
url: params.url, url: params.url,
}; };
if (params.isVoice) {
base["org.matrix.msc3245.voice"] = {};
base["org.matrix.msc1767.audio"] = {
duration: params.durationMs,
};
}
if (params.relation) { if (params.relation) {
base["m.relates_to"] = params.relation; base["m.relates_to"] = params.relation;
} }
@@ -216,75 +171,6 @@ function buildReplyRelation(replyToId?: string): MatrixReplyRelation | undefined
return { "m.in_reply_to": { event_id: trimmed } }; return { "m.in_reply_to": { event_id: trimmed } };
} }
function resolveMatrixMsgType(
contentType?: string,
fileName?: string,
): MsgType.Image | MsgType.Audio | MsgType.Video | MsgType.File {
const kind = mediaKindFromMime(contentType ?? "");
switch (kind) {
case "image":
return MsgType.Image;
case "audio":
return MsgType.Audio;
case "video":
return MsgType.Video;
default:
return MsgType.File;
}
}
function resolveMatrixVoiceDecision(opts: {
wantsVoice: boolean;
contentType?: string;
fileName?: string;
}): { useVoice: boolean } {
if (!opts.wantsVoice) return { useVoice: false };
if (isVoiceCompatibleAudio({ contentType: opts.contentType, fileName: opts.fileName })) {
return { useVoice: true };
}
return { useVoice: false };
}
const THUMBNAIL_MAX_SIDE = 800;
const THUMBNAIL_QUALITY = 80;
async function prepareImageInfo(params: {
buffer: Buffer;
client: MatrixClient;
}): Promise<MatrixImageInfo | undefined> {
const meta = await getImageMetadata(params.buffer).catch(() => null);
if (!meta) return undefined;
const imageInfo: MatrixImageInfo = { w: meta.width, h: meta.height };
const maxDim = Math.max(meta.width, meta.height);
if (maxDim > THUMBNAIL_MAX_SIDE) {
try {
const thumbBuffer = await resizeToJpeg({
buffer: params.buffer,
maxSide: THUMBNAIL_MAX_SIDE,
quality: THUMBNAIL_QUALITY,
withoutEnlargement: true,
});
const thumbMeta = await getImageMetadata(thumbBuffer).catch(() => null);
const thumbUri = await params.client.uploadContent(thumbBuffer as MatrixUploadContent, {
type: "image/jpeg",
name: "thumbnail.jpg",
});
imageInfo.thumbnail_url = thumbUri.content_uri;
if (thumbMeta) {
imageInfo.thumbnail_info = {
w: thumbMeta.width,
h: thumbMeta.height,
mimetype: "image/jpeg",
size: thumbBuffer.byteLength,
};
}
} catch {
// Thumbnail generation failed, continue without it
}
}
return imageInfo;
}
async function uploadFile( async function uploadFile(
client: MatrixClient, client: MatrixClient,
file: MatrixUploadContent | Buffer, file: MatrixUploadContent | Buffer,
@@ -352,10 +238,14 @@ export async function sendMessageMatrix(
const textLimit = resolveTextChunkLimit(cfg, "matrix"); const textLimit = resolveTextChunkLimit(cfg, "matrix");
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
const chunks = chunkMarkdownText(trimmedMessage, chunkLimit); const chunks = chunkMarkdownText(trimmedMessage, chunkLimit);
const threadId = normalizeThreadId(opts.threadId); const rawThreadId = opts.threadId;
const threadId =
rawThreadId !== undefined && rawThreadId !== null
? String(rawThreadId).trim()
: null;
const relation = threadId ? undefined : buildReplyRelation(opts.replyToId); const relation = threadId ? undefined : buildReplyRelation(opts.replyToId);
const sendContent = (content: RoomMessageEventContent) => const sendContent = (content: RoomMessageEventContent) =>
threadId ? client.sendMessage(roomId, threadId, content) : client.sendMessage(roomId, content); client.sendMessage(roomId, threadId ?? undefined, content);
let lastMessageId = ""; let lastMessageId = "";
if (opts.mediaUrl) { if (opts.mediaUrl) {
@@ -365,17 +255,9 @@ export async function sendMessageMatrix(
contentType: media.contentType, contentType: media.contentType,
filename: media.fileName, filename: media.fileName,
}); });
const baseMsgType = resolveMatrixMsgType(media.contentType, media.fileName); const msgtype = MsgType.File;
const { useVoice } = resolveMatrixVoiceDecision({
wantsVoice: opts.audioAsVoice === true,
contentType: media.contentType,
fileName: media.fileName,
});
const msgtype = useVoice ? MsgType.Audio : baseMsgType;
const isImage = msgtype === MsgType.Image;
const imageInfo = isImage ? await prepareImageInfo({ buffer: media.buffer, client }) : undefined;
const [firstChunk, ...rest] = chunks; const [firstChunk, ...rest] = chunks;
const body = useVoice ? "Voice message" : (firstChunk ?? media.fileName ?? "(file)"); const body = firstChunk ?? media.fileName ?? "(file)";
const content = buildMediaContent({ const content = buildMediaContent({
msgtype, msgtype,
body, body,
@@ -384,13 +266,10 @@ export async function sendMessageMatrix(
mimetype: media.contentType, mimetype: media.contentType,
size: media.buffer.byteLength, size: media.buffer.byteLength,
relation, relation,
isVoice: useVoice,
imageInfo,
}); });
const response = await sendContent(content); const response = await sendContent(content);
lastMessageId = response.event_id ?? lastMessageId; lastMessageId = response.event_id ?? lastMessageId;
const textChunks = useVoice ? chunks : rest; for (const chunk of rest) {
for (const chunk of textChunks) {
const text = chunk.trim(); const text = chunk.trim();
if (!text) continue; if (!text) continue;
const followup = buildTextContent(text); const followup = buildTextContent(text);
@@ -437,19 +316,17 @@ export async function sendPollMatrix(
try { try {
const roomId = await resolveMatrixRoomId(client, to); const roomId = await resolveMatrixRoomId(client, to);
const pollContent = buildPollStartContent(poll); const pollContent = buildPollStartContent(poll);
const threadId = normalizeThreadId(opts.threadId); const rawThreadId = opts.threadId;
const response = threadId const threadId =
? await client.sendEvent( rawThreadId !== undefined && rawThreadId !== null
roomId, ? String(rawThreadId).trim()
threadId, : null;
M_POLL_START, const response = await client.sendEvent(
pollContent, roomId,
) threadId ?? undefined,
: await client.sendEvent( M_POLL_START as EventType.RoomMessage,
roomId, pollContent as unknown as RoomMessageEventContent,
M_POLL_START, );
pollContent,
);
return { return {
eventId: response.event_id ?? "unknown", eventId: response.event_id ?? "unknown",
+10
View File
@@ -6,6 +6,16 @@ export const matrixOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct", deliveryMode: "direct",
chunker: chunkMarkdownText, chunker: chunkMarkdownText,
textChunkLimit: 4000, textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error("Delivering to Matrix requires --to <room|alias|user>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, deps, replyToId, threadId }) => { sendText: async ({ to, text, deps, replyToId, threadId }) => {
const send = deps?.sendMatrix ?? sendMessageMatrix; const send = deps?.sendMatrix ?? sendMessageMatrix;
const resolvedThreadId = const resolvedThreadId =
-5
View File
@@ -1,10 +1,5 @@
# Changelog # Changelog
## 2026.1.16
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.15 ## 2026.1.15
### Features ### Features
+2 -4
View File
@@ -1,12 +1,10 @@
{ {
"name": "@clawdbot/msteams", "name": "@clawdbot/msteams",
"version": "2026.1.16", "version": "2026.1.15",
"type": "module", "type": "module",
"description": "Clawdbot Microsoft Teams channel plugin", "description": "Clawdbot Microsoft Teams channel plugin",
"clawdbot": { "clawdbot": {
"extensions": [ "extensions": ["./index.ts"]
"./index.ts"
]
}, },
"dependencies": { "dependencies": {
"@microsoft/agents-hosting": "^1.1.1", "@microsoft/agents-hosting": "^1.1.1",
-9
View File
@@ -133,15 +133,6 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
}, },
messaging: { messaging: {
normalizeTarget: normalizeMSTeamsMessagingTarget, normalizeTarget: normalizeMSTeamsMessagingTarget,
targetResolver: {
looksLikeId: (raw) => {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^(conversation:|user:)/i.test(trimmed)) return true;
return trimmed.includes("@thread");
},
hint: "<conversationId|user:ID|conversation:ID>",
},
}, },
directory: { directory: {
self: async () => null, self: async () => null,
@@ -5,7 +5,6 @@ import {
resolveInboundDebounceMs, resolveInboundDebounceMs,
} from "../../../../src/auto-reply/inbound-debounce.js"; } from "../../../../src/auto-reply/inbound-debounce.js";
import { dispatchReplyFromConfig } from "../../../../src/auto-reply/reply/dispatch-from-config.js"; import { dispatchReplyFromConfig } from "../../../../src/auto-reply/reply/dispatch-from-config.js";
import { finalizeInboundContext } from "../../../../src/auto-reply/reply/inbound-context.js";
import { import {
buildPendingHistoryContextFromMap, buildPendingHistoryContextFromMap,
clearHistoryEntries, clearHistoryEntries,
@@ -14,7 +13,6 @@ import {
type HistoryEntry, type HistoryEntry,
} from "../../../../src/auto-reply/reply/history.js"; } from "../../../../src/auto-reply/reply/history.js";
import { resolveMentionGating } from "../../../../src/channels/mention-gating.js"; import { resolveMentionGating } from "../../../../src/channels/mention-gating.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../../../../src/channels/command-gating.js";
import { danger, logVerbose, shouldLogVerbose } from "../../../../src/globals.js"; import { danger, logVerbose, shouldLogVerbose } from "../../../../src/globals.js";
import { enqueueSystemEvent } from "../../../../src/infra/system-events.js"; import { enqueueSystemEvent } from "../../../../src/infra/system-events.js";
import { import {
@@ -126,14 +124,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
const senderName = from.name ?? from.id; const senderName = from.name ?? from.id;
const senderId = from.aadObjectId ?? from.id; const senderId = from.aadObjectId ?? from.id;
const storedAllowFrom = await readChannelAllowFromStore("msteams").catch(() => []); const storedAllowFrom = await readChannelAllowFromStore("msteams").catch(() => []);
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
// Check DM policy for direct messages. // Check DM policy for direct messages.
const dmAllowFrom = msteamsCfg?.allowFrom ?? [];
const effectiveDmAllowFrom = [...dmAllowFrom.map((v) => String(v)), ...storedAllowFrom];
if (isDirectMessage && msteamsCfg) { if (isDirectMessage && msteamsCfg) {
const dmPolicy = msteamsCfg.dmPolicy ?? "pairing"; const dmPolicy = msteamsCfg.dmPolicy ?? "pairing";
const allowFrom = dmAllowFrom; const allowFrom = msteamsCfg.allowFrom ?? [];
if (dmPolicy === "disabled") { if (dmPolicy === "disabled") {
log.debug("dropping dm (dms disabled)"); log.debug("dropping dm (dms disabled)");
@@ -176,18 +171,13 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
} }
} }
const groupPolicy = !isDirectMessage && msteamsCfg ? (msteamsCfg.groupPolicy ?? "allowlist") : "disabled";
const groupAllowFrom =
!isDirectMessage && msteamsCfg
? (msteamsCfg.groupAllowFrom ??
(msteamsCfg.allowFrom && msteamsCfg.allowFrom.length > 0 ? msteamsCfg.allowFrom : []))
: [];
const effectiveGroupAllowFrom =
!isDirectMessage && msteamsCfg
? [...groupAllowFrom.map((v) => String(v)), ...storedAllowFrom]
: [];
if (!isDirectMessage && msteamsCfg) { if (!isDirectMessage && msteamsCfg) {
const groupPolicy = msteamsCfg.groupPolicy ?? "allowlist";
const groupAllowFrom =
msteamsCfg.groupAllowFrom ??
(msteamsCfg.allowFrom && msteamsCfg.allowFrom.length > 0 ? msteamsCfg.allowFrom : []);
const effectiveGroupAllowFrom = [...groupAllowFrom.map((v) => String(v)), ...storedAllowFrom];
if (groupPolicy === "disabled") { if (groupPolicy === "disabled") {
log.debug("dropping group message (groupPolicy: disabled)", { log.debug("dropping group message (groupPolicy: disabled)", {
conversationId, conversationId,
@@ -218,30 +208,6 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
} }
} }
const ownerAllowedForCommands = isMSTeamsGroupAllowed({
groupPolicy: "allowlist",
allowFrom: effectiveDmAllowFrom,
senderId,
senderName,
});
const groupAllowedForCommands = isMSTeamsGroupAllowed({
groupPolicy: "allowlist",
allowFrom: effectiveGroupAllowFrom,
senderId,
senderName,
});
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: effectiveDmAllowFrom.length > 0, allowed: ownerAllowedForCommands },
{ configured: effectiveGroupAllowFrom.length > 0, allowed: groupAllowedForCommands },
],
});
if (hasControlCommand(text, cfg) && !commandAuthorized) {
logVerbose(`msteams: drop control command from unauthorized sender ${senderId}`);
return;
}
// Build conversation reference for proactive replies. // Build conversation reference for proactive replies.
const agent = activity.recipient; const agent = activity.recipient;
const teamId = activity.channelData?.team?.id; const teamId = activity.channelData?.team?.id;
@@ -415,16 +381,15 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
}); });
} }
const ctxPayload = finalizeInboundContext({ const ctxPayload = {
Body: combinedBody, Body: combinedBody,
RawBody: rawBody, RawBody: rawBody,
CommandBody: rawBody, CommandBody: rawBody,
From: teamsFrom, From: teamsFrom,
To: teamsTo, To: teamsTo,
SessionKey: route.sessionKey, SessionKey: route.sessionKey,
AccountId: route.accountId, AccountId: route.accountId,
ChatType: isDirectMessage ? "direct" : isChannel ? "channel" : "group", ChatType: isDirectMessage ? "direct" : isChannel ? "room" : "group",
ConversationLabel: envelopeFrom,
GroupSubject: !isDirectMessage ? conversationType : undefined, GroupSubject: !isDirectMessage ? conversationType : undefined,
SenderName: senderName, SenderName: senderName,
SenderId: senderId, SenderId: senderId,
@@ -432,12 +397,12 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) {
Surface: "msteams" as const, Surface: "msteams" as const,
MessageSid: activity.id, MessageSid: activity.id,
Timestamp: timestamp?.getTime() ?? Date.now(), Timestamp: timestamp?.getTime() ?? Date.now(),
WasMentioned: isDirectMessage || params.wasMentioned || params.implicitMention, WasMentioned: isDirectMessage || params.wasMentioned || params.implicitMention,
CommandAuthorized: commandAuthorized, CommandAuthorized: true,
OriginatingChannel: "msteams" as const, OriginatingChannel: "msteams" as const,
OriginatingTo: teamsTo, OriginatingTo: teamsTo,
...mediaPayload, ...mediaPayload,
}); };
if (shouldLogVerbose()) { if (shouldLogVerbose()) {
logVerbose(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`); logVerbose(`msteams inbound: from=${ctxPayload.From} preview="${preview}"`);
+12
View File
@@ -9,6 +9,18 @@ export const msteamsOutbound: ChannelOutboundAdapter = {
chunker: chunkMarkdownText, chunker: chunkMarkdownText,
textChunkLimit: 4000, textChunkLimit: 4000,
pollMaxOptions: 12, pollMaxOptions: 12,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to MS Teams requires --to <conversationId|user:ID|conversation:ID>",
),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, deps }) => { sendText: async ({ cfg, to, text, deps }) => {
const send = deps?.sendMSTeams ?? ((to, text) => sendMessageMSTeams({ cfg, to, text })); const send = deps?.sendMSTeams ?? ((to, text) => sendMessageMSTeams({ cfg, to, text }));
const result = await send(to, text); const result = await send(to, text);
+2 -2
View File
@@ -26,7 +26,7 @@ export type MSTeamsProactiveContext = {
}; };
/** /**
* Parse the target value into a conversation reference lookup key. * Parse the --to argument into a conversation reference lookup key.
* Supported formats: * Supported formats:
* - conversation:19:abc@thread.tacv2 → lookup by conversation ID * - conversation:19:abc@thread.tacv2 → lookup by conversation ID
* - user:aad-object-id → lookup by user AAD object ID * - user:aad-object-id → lookup by user AAD object ID
@@ -40,7 +40,7 @@ function parseRecipient(to: string): {
const finalize = (type: "conversation" | "user", id: string) => { const finalize = (type: "conversation" | "user", id: string) => {
const normalized = id.trim(); const normalized = id.trim();
if (!normalized) { if (!normalized) {
throw new Error(`Invalid target value: missing ${type} id`); throw new Error(`Invalid --to value: missing ${type} id`);
} }
return { type, id: normalized }; return { type, id: normalized };
}; };
-5
View File
@@ -1,10 +1,5 @@
# Changelog # Changelog
## 2026.1.16
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.15 ## 2026.1.15
### Changes ### Changes
+2 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@clawdbot/voice-call", "name": "@clawdbot/voice-call",
"version": "2026.1.16", "version": "2026.1.15",
"type": "module", "type": "module",
"description": "Clawdbot voice-call plugin", "description": "Clawdbot voice-call plugin",
"dependencies": { "dependencies": {
@@ -9,8 +9,6 @@
"zod": "^4.3.5" "zod": "^4.3.5"
}, },
"clawdbot": { "clawdbot": {
"extensions": [ "extensions": ["./index.ts"]
"./index.ts"
]
} }
} }
-5
View File
@@ -1,10 +1,5 @@
# Changelog # Changelog
## 2026.1.16
### Changes
- Version alignment with core Clawdbot release numbers.
## 2026.1.15 ## 2026.1.15
### Changes ### Changes
+2 -4
View File
@@ -1,12 +1,10 @@
{ {
"name": "@clawdbot/zalo", "name": "@clawdbot/zalo",
"version": "2026.1.16", "version": "2026.1.15",
"type": "module", "type": "module",
"description": "Clawdbot Zalo channel plugin", "description": "Clawdbot Zalo channel plugin",
"clawdbot": { "clawdbot": {
"extensions": [ "extensions": ["./index.ts"]
"./index.ts"
]
}, },
"dependencies": { "dependencies": {
"undici": "7.18.2" "undici": "7.18.2"
Loaded 100 of 549 files, more files were not shown because too many files have changed in this diff. Show more