Compare commits

..
Author SHA1 Message Date
Peter Steinberger 557db51b8a fix: wire venice onboarding + docs (#1666) (thanks @jonisjongithub) 2026-01-25 01:11:40 +00:00
jonisjongithub 41c98942d8 docs: remove rate limits claim from Venice docs 2026-01-25 00:57:11 +00:00
jonisjongithub 4c4ca12e08 feat: add Venice AI provider integration
Venice AI is a privacy-focused AI inference provider with support for
uncensored models and access to major proprietary models via their
anonymized proxy.

This integration adds:

- Complete model catalog with 25 models:
  - 15 private models (Llama, Qwen, DeepSeek, Venice Uncensored, etc.)
  - 10 anonymized models (Claude, GPT-5.2, Gemini, Grok, Kimi, MiniMax)
- Auto-discovery from Venice API with fallback to static catalog
- VENICE_API_KEY environment variable support
- Interactive onboarding via 'venice-api-key' auth choice
- Model selection prompt showing all available Venice models
- Provider auto-registration when API key is detected
- Comprehensive documentation covering:
  - Privacy modes (private vs anonymized)
  - All 25 models with context windows and features
  - Streaming, function calling, and vision support
  - Model selection recommendations

Privacy modes:
- Private: Fully private, no logging (open-source models)
- Anonymized: Proxied through Venice (proprietary models)

Default model: venice/llama-3.3-70b (good balance of capability + privacy)
Venice API: https://api.venice.ai/api/v1 (OpenAI-compatible)
2026-01-25 00:57:11 +00:00
Tyler Yust 92e794dc18 feat: add chunking mode option for BlueBubbles (#1645)
* feat: add chunking mode for outbound messages

- Introduced `chunkMode` option in various account configurations to allow splitting messages by "length" or "newline".
- Updated message processing to handle chunking based on the selected mode.
- Added tests for new chunking functionality, ensuring correct behavior for both modes.

* feat: enhance chunking mode documentation and configuration

- Added `chunkMode` option to the BlueBubbles account configuration, allowing users to choose between "length" and "newline" for message chunking.
- Updated documentation to clarify the behavior of the `chunkMode` setting.
- Adjusted account merging logic to incorporate the new `chunkMode` configuration.

* refactor: simplify chunk mode handling for BlueBubbles

- Removed `chunkMode` configuration from various account schemas and types, centralizing chunk mode logic to BlueBubbles only.
- Updated `processMessage` to default to "newline" for BlueBubbles chunking.
- Adjusted tests to reflect changes in chunk mode handling for BlueBubbles, ensuring proper functionality.

* fix: update default chunk mode to 'length' for BlueBubbles

- Changed the default value of `chunkMode` from 'newline' to 'length' in the BlueBubbles configuration and related processing functions.
- Updated documentation to reflect the new default behavior for chunking messages.
- Adjusted tests to ensure the correct default value is returned for BlueBubbles chunk mode.
2026-01-25 00:47:10 +00:00
Peter Steinberger 6375ee836f docs: clarify remote transport IP reporting 2026-01-25 00:39:54 +00:00
Peter Steinberger a6c97b5a48 fix: reload TUI history after reconnect 2026-01-25 00:36:36 +00:00
Peter Steinberger 5ea15ff7fe docs: add aws mention to vps hub 2026-01-25 00:23:24 +00:00
Peter Steinberger dd57483e5e docs: add vps hosting hub 2026-01-25 00:20:07 +00:00
Peter Steinberger 3696aade09 chore: refresh pnpm lock 2026-01-25 00:19:02 +00:00
Richard PinedoandRichard 426168a338 Add link understanding tool support (#1637)
* Add

* Fix

---------

Co-authored-by: Richard <dasilva333@DESKTOP-74E3GJO.localdomain>
2026-01-25 00:15:54 +00:00
Peter Steinberger 2f58d59f22 docs: add nodes note to cloud guides 2026-01-25 00:13:44 +00:00
Peter Steinberger cbe19ad2f2 docs: add hosting hub links 2026-01-25 00:12:31 +00:00
Peter Steinberger d57b88c7af docs: add railway quick checklist 2026-01-25 00:10:25 +00:00
Peter Steinberger ce89bc2b40 docs: add anthropic auth error troubleshooting 2026-01-25 00:07:19 +00:00
Peter Steinberger 85b27fe5fe docs: fix ollama links 2026-01-25 00:05:38 +00:00
Peter Steinberger c147962434 fix: normalize googlechat targets 2026-01-25 00:04:47 +00:00
Vignesh 72858a5311 Merge pull request #1658 from clawdbot/fix/railway-redirect-loop
Docs: fix /railway redirect loop
2026-01-24 16:04:32 -08:00
Vignesh Natarajan 21445cfc0a Docs: fix /railway redirect loop 2026-01-24 16:03:55 -08:00
Peter Steinberger 5ad203e47b fix: default custom provider model fields 2026-01-25 00:02:53 +00:00
Vignesh 3b53213b41 Merge pull request #1657 from clawdbot/docs/railway
Docs: add Railway deployment guide
2026-01-24 16:01:43 -08:00
Vignesh Natarajan 81c6ab0ec0 Docs: clarify Railway service domain 2026-01-24 16:01:00 -08:00
Vignesh Natarajan 3ea887be5a Docs: add Railway deployment guide 2026-01-24 15:58:58 -08:00
Peter Steinberger c565de0f71 docs: add anthropic troubleshooting 2026-01-24 23:58:45 +00:00
Peter Steinberger 913d2f4b3e docs: add gateway stop/start detail 2026-01-24 23:37:18 +00:00
Peter Steinberger 8e159ab0b7 fix: follow up config.patch restarts/docs/tests (#1653)
* fix: land config.patch restarts/docs/tests (#1624) (thanks @Glucksberg)

* docs: update changelog entry for config.patch follow-up (#1653) (thanks @Glucksberg)
2026-01-24 23:33:13 +00:00
Peter SteinbergerandIan Hildebrand 5570e1a946 fix: polish Google Chat plugin (#1635) (thanks @iHildy)
Co-authored-by: Ian Hildebrand <ian@jedi.net>
2026-01-24 23:30:45 +00:00
iHildy 99dae0302b Revert unrelated changes to control-ui dist files 2026-01-24 23:30:45 +00:00
iHildy c64184fcfa googlechat: implement typing indicator via message editing 2026-01-24 23:30:45 +00:00
iHildy 70e7034a1c docs(googlechat): update Tailscale setup for private dashboard and public webhook 2026-01-24 23:30:45 +00:00
iHildy 5991bed32e feat(googlechat): support Google Workspace Add-on event format 2026-01-24 23:30:45 +00:00
iHildy 0f6e39b9e8 feat: add beta googlechat channel 2026-01-24 23:30:45 +00:00
iHildy b76cd6695d feat: add beta googlechat channel 2026-01-24 23:30:45 +00:00
GlucksbergandClaude Opus 4.5 60661441b1 feat(gateway-tool): add config.patch action for safe partial config updates (#1624)
* fix(ui): enable save button only when config has changes

The save button in the Control UI config editor was not properly gating
on whether actual changes were made. This adds:
- `configRawOriginal` state to track the original raw config for comparison
- Change detection for both form mode (via computeDiff) and raw mode
- `hasChanges` check in canSave/canApply logic
- Set `configFormDirty` when raw mode edits occur
- Handle raw mode UI correctly (badge shows "Unsaved changes", no diff panel)

Fixes #1609

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

* feat(gateway-tool): add config.patch action for safe partial config updates

Exposes the existing config.patch server method to agents, allowing safe
partial config updates that merge with existing config instead of replacing it.

- Add config.patch to GATEWAY_ACTIONS in gateway tool
- Add restart + sentinel logic to config.patch server method
- Extend ConfigPatchParamsSchema with sessionKey, note, restartDelayMs
- Add unit test for config.patch gateway tool action

Closes #1617

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 23:30:32 +00:00
Peter Steinberger 0752ae6d6d fix: return TwiML for outbound conversation calls 2026-01-24 23:20:52 +00:00
Peter Steinberger 1b17453942 docs: highlight Ollama provider discovery 2026-01-24 23:09:53 +00:00
Peter Steinberger ee2918c3b1 fix: preserve BlueBubbles reply tag GUIDs 2026-01-24 23:09:28 +00:00
Peter Steinberger e5aa84ee48 docs: expand Ollama configuration examples 2026-01-24 23:05:57 +00:00
Tyler Yust 445b58550c feat(bluebubbles): improve reaction handling and inline reply tags (#1641)
* refactor: update reply formatting to use inline [[reply_to:N]] tag and normalize message IDs

* test: add unit tests for tapback text parsing in BlueBubbles webhook

* refactor: update message ID handling to use GUIDs instead of UUIDs for consistency
2026-01-24 22:42:42 +00:00
Peter Steinberger c2d68a87f7 docs: add whatsapp group jid faq 2026-01-24 22:41:43 +00:00
Abhay 51e3d16be9 feat: Add Ollama provider with automatic model discovery (#1606)
* feat: Add Ollama provider with automatic model discovery

- Add Ollama provider builder with automatic model detection
- Discover available models from local Ollama instance via /api/tags API
- Make resolveImplicitProviders async to support dynamic model discovery
- Add comprehensive Ollama documentation with setup and usage guide
- Add tests for Ollama provider integration
- Update provider index and model providers documentation

Closes #1531

* fix: Correct Ollama provider type definitions and error handling

- Fix input property type to match ModelDefinitionConfig
- Import ModelDefinitionConfig type properly
- Fix error template literal to use String() for type safety
- Simplify return type signature of discoverOllamaModels

* fix: Suppress unhandled promise warnings from ensureClawdbotModelsJson in tests

- Cast unused promise returns to 'unknown' to suppress TypeScript warnings
- Tests that don't await the promise are intentionally not awaiting it
- This fixes the failing test suite caused by unawaited async calls

* fix: Skip Ollama model discovery during tests

- Check for VITEST or NODE_ENV=test before making HTTP requests
- Prevents test timeouts and hangs from network calls
- Ollama discovery will still work in production/normal usage

* fix: Set VITEST environment variable in test setup

- Ensures Ollama discovery is skipped in all test runs
- Prevents network calls during tests that could cause timeouts

* test: Temporarily skip Ollama provider tests to diagnose CI failures

* fix: Make Ollama provider opt-in to avoid breaking existing tests

**Root Cause:**
The Ollama provider was being added to ALL configurations by default
(with a fallback API key of 'ollama-local'), which broke tests that
expected NO providers when no API keys were configured.

**Solution:**
- Removed the default fallback API key for Ollama
- Ollama provider now requires explicit configuration via:
  - OLLAMA_API_KEY environment variable, OR
  - Ollama profile in auth store
- Updated documentation to reflect the explicit configuration requirement
- Added a test to verify Ollama is not added by default

This fixes all 4 failing test suites:
- checks (node, test, pnpm test)
- checks (bun, test, bunx vitest run)
- checks-windows (node, test, pnpm test)
- checks-macos (test, pnpm test)

Closes #1531
2026-01-24 22:38:52 +00:00
Peter Steinberger c00cbd080d docs: add verbose installer example 2026-01-24 22:38:13 +00:00
Peter Steinberger dd150d69c6 fix: use active auth profile for auto-compaction 2026-01-24 22:23:49 +00:00
9ceac415c5 fix: auto-compact on context overflow promptError before returning error (#1627)
* fix: detect Anthropic 'Request size exceeds model context window' as context overflow

Anthropic now returns 'Request size exceeds model context window' instead of
the previously detected 'prompt is too long' format. This new error message
was not recognized by isContextOverflowError(), causing auto-compaction to
NOT trigger. Users would see the raw error twice without any recovery attempt.

Changes:
- Add 'exceeds model context window' and 'request size exceeds' to
  isContextOverflowError() detection patterns
- Add tests that fail without the fix, verifying both the raw error
  string and the JSON-wrapped format from Anthropic's API
- Add test for formatAssistantErrorText to ensure the friendly
  'Context overflow' message is shown instead of the raw error

Note: The upstream pi-ai package (@mariozechner/pi-ai) also needs a fix
in its OVERFLOW_PATTERNS regex: /exceeds the context window/i should be
changed to /exceeds.*context window/i to match both 'the' and 'model'
variants for triggering auto-compaction retry.

* fix(tests): remove unused imports and helper from test files

Remove WorkspaceBootstrapFile references and _makeFile helper that were
incorrectly copied from another test file. These caused type errors and
were unrelated to the context overflow detection tests.

* fix: trigger auto-compaction on context overflow promptError

When the LLM rejects a request with a context overflow error that surfaces
as a promptError (thrown exception rather than streamed error), the existing
auto-compaction in pi-coding-agent never triggers. This happens because the
error bypasses the agent's message_end → agent_end → _checkCompaction path.

This fix adds a fallback compaction attempt directly in the run loop:
- Detects context overflow in promptError (excluding compaction_failure)
- Calls compactEmbeddedPiSessionDirect (bypassing lane queues since already in-lane)
- Retries the prompt after successful compaction
- Limits to one compaction attempt per run to prevent infinite loops

Fixes: context overflow errors shown to user without auto-compaction attempt

* style: format compact.ts and run.ts with oxfmt

* fix: tighten context overflow match (#1627) (thanks @rodrigouroz)

---------

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-24 22:09:24 +00:00
Peter Steinberger ac00065727 fix: normalize telegram fetch for long-polling 2026-01-24 21:58:42 +00:00
Peter Steinberger 30534c5c33 docs: add Bedrock EC2 role notes (#1625) (thanks @sergical) 2026-01-24 21:18:18 +00:00
Sergiy DybskiyandClaude Opus 4.5 97755683c7 docs: add EC2 instance role setup for Bedrock (#1625)
- Add EC2 Instance Roles section with workaround for IMDS credential detection
- Include step-by-step IAM role and instance profile setup
- Document required permissions (bedrock:InvokeModel, ListFoundationModels)
- Update example model to Claude Opus 4.5 (latest)

The AWS SDK auto-detects EC2 instance roles via IMDS, but Clawdbot's
credential detection only checks environment variables. The workaround
is to set AWS_PROFILE=default to signal credentials are available.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 21:17:21 +00:00
Peter Steinberger a4f6b3528a fix: cover elevated ask approvals (#1636) 2026-01-24 21:12:46 +00:00
Peter Steinberger 9f8e66359e fix: default direct gateway port + docs (#1603) (thanks @ngutman) 2026-01-24 21:10:54 +00:00
Hunter MillerandPeter Steinberger 8a2720db4c fix(tlon): Fix Zod v4 record() and @urbit/aura v3 API changes (#1631)
* fix(tlon): Fix Zod v4 record() and @urbit/aura v3 API changes

- Fix Zod v4.3.6 bug: single-arg z.record() fails with toJSONSchema()
  - Use two-arg form: z.record(z.string(), schema)
  - Fixes 'Cannot read properties of undefined (reading _zod)' error

- Fix @urbit/aura v3.0.0 API migration:
  - unixToDa() → da.fromUnix()
  - formatUd() → scot('ud', ...)
  - Fixes '(0 , _aura.unixToDa) is not a function' error

These were blocking Tlon plugin loading and outbound messaging.

* fix: add tlon schema/aura tests (#1631) (thanks @arthyn)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-24 21:09:18 +00:00
Nimrod Gutman 5330595a5a feat(macos): add direct gateway transport 2026-01-24 21:02:13 +00:00
Peter Steinberger 2c5141d7df docs: clarify beta promotion flow 2026-01-24 20:59:41 +00:00
483fba41b9 feat(discord): add exec approval forwarding to DMs (#1621)
* feat(discord): add exec approval forwarding to DMs

Add support for forwarding exec approval requests to Discord DMs,
allowing users to approve/deny command execution via interactive buttons.

Features:
- New DiscordExecApprovalHandler that connects to gateway and listens
  for exec.approval.requested/resolved events
- Sends DMs with embeds showing command details and 3 buttons:
  Allow once, Always allow, Deny
- Configurable via channels.discord.execApprovals with:
  - enabled: boolean
  - approvers: Discord user IDs to notify
  - agentFilter: only forward for specific agents
  - sessionFilter: only forward for matching session patterns
- Updates message embed when approval is resolved or expires

Also fixes exec completion routing: when async exec completes after
approval, the heartbeat now uses a specialized prompt to ensure the
model relays the result to the user instead of responding HEARTBEAT_OK.

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

* feat: generic exec approvals forwarding (#1621) (thanks @czekaj)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-24 20:56:40 +00:00
Ivan Casco fe7436a1f6 fix(exec): only set security=full when elevated mode is full (#1616) 2026-01-24 20:55:21 +00:00
Peter Steinberger a1ed671636 docs: add backup strategy faq 2026-01-24 20:34:05 +00:00
Peter Steinberger 8c47d226ad docs: add subscription requirement faq 2026-01-24 20:32:33 +00:00
Peter Steinberger e1942603e9 docs: add xfinity unblock link 2026-01-24 20:30:03 +00:00
Peter Steinberger 926c2647b8 docs: mention local-only model option 2026-01-24 20:15:58 +00:00
Peter Steinberger c427f4a2fc docs: add imessage mac requirement faq 2026-01-24 20:13:13 +00:00
Peter Steinberger f99f9a6b64 docs: add self-update faq entry 2026-01-24 20:10:47 +00:00
Petter BlombergandPeter Steinberger 39d8c441eb fix: reduce log noise for node disconnect/late invoke errors (#1607)
* fix: reduce log noise for node disconnect/late invoke errors

- Handle both 'node not connected' and 'node disconnected' errors at info level
- Return success with late:true for unknown invoke IDs instead of error
- Add 30-second throttle to skills change listener to prevent rapid-fire probes
- Add tests for isNodeUnavailableError and late invoke handling

* fix: clean up skills refresh timer and listener on shutdown

Store the return value from registerSkillsChangeListener() and call it
on gateway shutdown. Also clear any pending refresh timer. This follows
the same pattern used for agentUnsub and heartbeatUnsub.

* refactor: simplify KISS/YAGNI - inline checks, remove unit tests for internal utilities

* fix: reduce gateway log noise (#1607) (thanks @petter-b)

* test: align agent id casing expectations (#1607)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-24 20:05:41 +00:00
Peter Steinberger 40ef3b5d30 docs: add linux install faq entry 2026-01-24 19:58:42 +00:00
Peter Steinberger 390b730b37 fix: unify reasoning tags + agent ids (#1613) (thanks @kyleok) (#1629) 2026-01-24 19:56:02 +00:00
Ganghyun KimandClaude Code 71457fa100 fix(tui): strip <final> tags in TUI display (#1613)
Add <final> tag handling to stripThinkingTags() to prevent reasoning-tag
provider responses from leaking incomplete tags during streaming.

When using providers like google-antigravity/*, ollama, or minimax, the
model wraps responses in <think>...</think> and <final>...</final> tags.
The TUI was only stripping <think> tags, causing <final> to leak through
and display as the response ~50% of the time.

This is a defense-in-depth fix for the TUI layer.

Fixes: #1561

Co-authored-by: Claude Code <noreply@anthropic.com>
2026-01-24 19:52:34 +00:00
Peter Steinberger da7a45b3a5 docs: clarify migration state vs workspace 2026-01-24 19:50:02 +00:00
Denys VitaliandClaude 15a9c21203 Add Build & Release Docker Image workflows (#1602)
* ci: build & release docker image

* ci: sync docker-release workflow updates

Squashes:
- ci: use correct runs-on
- ci: build images

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

* Remove submodule checkout from docker-release.yml

Removed submodule checkout step from Docker release workflow.

* Simplify Docker release workflow by removing submodule checkout

Removed submodule checkout step from Docker release workflow.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-24 19:23:55 +00:00
Peter Steinberger 6d79c6cd26 fix: clean docker onboarding warnings + preserve agentId casing 2026-01-24 19:07:01 +00:00
Peter Steinberger bcedeb4e1f chore: bump 2026.1.24 2026-01-24 15:00:00 +00:00
Peter Steinberger f076eba98a docs: add hackable install faq 2026-01-24 14:52:26 +00:00
Peter Steinberger f3bd6bf342 docs: add docs ssl error faq 2026-01-24 14:51:26 +00:00
Peter Steinberger c29c9a1e3e docs: add pi sizing guidance 2026-01-24 14:51:26 +00:00
Peter Steinberger 7a384ea07c docs: add update fly signal 2026-01-24 14:37:31 +00:00
Peter Steinberger 5fc866e8fe docs: add openai subscription faq 2026-01-24 14:37:17 +00:00
Peter Steinberger 437535ee94 docs: clarify gpt-5.2 vs glm 2026-01-24 14:37:17 +00:00
Peter Steinberger 3b929ff843 docs: add glm budget option 2026-01-24 14:37:17 +00:00
Peter Steinberger 93737ee152 test: align agent id normalization 2026-01-24 14:36:31 +00:00
Peter Steinberger 765626b492 test: trim cron agentId label 2026-01-24 14:36:31 +00:00
Peter Steinberger 42b8fce4e5 docs: link models concept in faq 2026-01-24 14:28:38 +00:00
Peter Steinberger c27294133e docs: add recommended models faq 2026-01-24 14:28:38 +00:00
Peter Steinberger 94095386b3 docs: add installer verbose troubleshooting 2026-01-24 14:25:29 +00:00
Peter Steinberger 7a524e8667 docs: add migration scheduling and concurrency faqs 2026-01-24 14:21:26 +00:00
Peter Steinberger 876bbb742a test: skip opencode alpha GLM in live suite 2026-01-24 14:08:16 +00:00
Peter Steinberger ef7971e3a4 fix: normalize heartbeat targets 2026-01-24 13:53:00 +00:00
Peter Steinberger 9d742ba51f fix: hide message_id hints in web chat 2026-01-24 13:52:31 +00:00
Peter Steinberger 386d21b6d1 fix: sync tests with config normalization 2026-01-24 13:32:26 +00:00
Peter Steinberger c8afa8207c chore: prepare 2026.1.23-1 2026-01-24 13:28:22 +00:00
Peter Steinberger d0e21f05a6 fix: drop opencode alpha GLM model 2026-01-24 13:19:55 +00:00
Peter Steinberger 11f039ef85 fix: include tts dist in npm package 2026-01-24 13:18:20 +00:00
Peter Steinberger e90e3ba954 docs: link macos node to cli node 2026-01-24 13:17:28 +00:00
Peter Steinberger 0de7852d46 docs: finalize 2026.1.23 changelog 2026-01-24 13:16:05 +00:00
Nicolas Zullo 834663dfef feat(templates): add emoji reactions guidance to AGENTS.md (#1591)
## What
Add emoji reactions guidance to the default AGENTS.md template.

## Why  
Reactions are a natural, human-like way to acknowledge messages without cluttering chat. This should be default behavior.

## Testing
- Tested locally on Discord DM ✅
- Tested locally on Discord guild channel ✅

## AI-Assisted
This change was drafted with help from my Clawdbot instance (Clawd 🦞). 
We tested the behavior together before submitting.
2026-01-24 13:12:16 +00:00
Peter Steinberger a72d7a9f36 docs: add vps install faq 2026-01-24 13:11:31 +00:00
Peter Steinberger 67e57e7c99 docs: add beta vs dev install faq 2026-01-24 13:11:31 +00:00
Peter Steinberger 4c98d6c121 docs: add latest version faq 2026-01-24 13:11:31 +00:00
Peter Steinberger 174a1cb68a docs: clarify mac mini + imessage ssh 2026-01-24 13:11:31 +00:00
Peter Steinberger a4d56bd06e docs: add mac mini faq 2026-01-24 13:11:31 +00:00
Peter Steinberger c9e98376b3 chore: update appcast for 2026.1.23 2026-01-24 13:03:00 +00:00
Peter Steinberger 62c9255b6a fix: harden outbound mirroring normalization 2026-01-24 12:57:58 +00:00
Peter Steinberger 8b4e40c602 build: refresh control-ui dist + release docs 2026-01-24 12:51:32 +00:00
Peter Steinberger 6a9d7f7a01 docs: clarify node host sizing 2026-01-24 12:50:22 +00:00
Peter Steinberger 39d8e9be0f docs: add node vs ssh faq 2026-01-24 12:48:29 +00:00
hsrvc ac45c8b404 fix: preserve Telegram topic (message_thread_id) in sub-agent announcements
When native slash commands are executed in Telegram topics/forums, the
originating topic context was not being preserved. This caused sub-agent
announcements to be delivered to the wrong topic.

Root cause: Native slash command context did not set OriginatingChannel
and OriginatingTo, causing session delivery context to fallback to the
user's personal ID instead of the group ID + topic.

Fix: Added OriginatingChannel and OriginatingTo to native slash command
context, ensuring topic information is preserved for sub-agent announcements.

Related session fields:
- lastThreadId: preserved via MessageThreadId
- lastTo: now correctly set to group ID via OriginatingTo
- deliveryContext: includes threadId for proper routing
2026-01-24 12:26:29 +00:00
Peter Steinberger fa746b05de fix: preserve agent id casing 2026-01-24 12:23:44 +00:00
Peter Steinberger 49c518951c fix: align bluebubbles outbound group sessions 2026-01-24 12:23:26 +00:00
Peter Steinberger 0dca8acbe2 docs: reorder 2026.1.23 changelog 2026-01-24 12:10:59 +00:00
Peter Steinberger c42e9b1d19 fix: log discord deploy error details 2026-01-24 12:10:59 +00:00
Peter Steinberger 298901208d fix: align agent id normalization 2026-01-24 12:10:08 +00:00
Peter Steinberger ef9ba66798 chore: tune fly deployment defaults 2026-01-24 11:58:25 +00:00
Peter Steinberger 4b6cdd1d3c fix: normalize session keys and outbound mirroring 2026-01-24 11:57:11 +00:00
Peter Steinberger eaeb52f70a chore: update protocol artifacts 2026-01-24 11:28:24 +00:00
Luke be1cdc9370 fix(agents): treat provider request-aborted as timeout for fallback (#1576)
* fix(agents): treat request-aborted as timeout for fallback

* test(e2e): add provider timeout fallback
2026-01-24 11:27:24 +00:00
Peter Steinberger 8002143d92 fix: guard cli session update 2026-01-24 11:21:34 +00:00
Peter Steinberger 4a9123d415 chore: suppress remaining deprecation warnings 2026-01-24 11:16:46 +00:00
278 changed files with 16405 additions and 1724 deletions

No files matched your search

+143
View File
@@ -0,0 +1,143 @@
name: Docker Release
on:
push:
branches:
- main
tags:
- "v*"
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# Build amd64 image
build-amd64:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
outputs:
image-digest: ${{ steps.build.outputs.digest }}
image-metadata: ${{ steps.meta.outputs.json }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{version}},suffix=-amd64
type=semver,pattern={{version}},suffix=-arm64
type=ref,event=branch,suffix=-amd64
type=ref,event=branch,suffix=-arm64
- name: Build and push amd64 image
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
push: true
# Build arm64 image
build-arm64:
runs-on: ubuntu-24.04-arm
permissions:
packages: write
contents: read
outputs:
image-digest: ${{ steps.build.outputs.digest }}
image-metadata: ${{ steps.meta.outputs.json }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=semver,pattern={{version}},suffix=-amd64
type=semver,pattern={{version}},suffix=-arm64
type=ref,event=branch,suffix=-amd64
type=ref,event=branch,suffix=-arm64
- name: Build and push arm64 image
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/arm64
labels: ${{ steps.meta.outputs.labels }}
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
provenance: false
push: true
# Create multi-platform manifest
create-manifest:
runs-on: ubuntu-latest
permissions:
packages: write
contents: read
needs: [build-amd64, build-arm64]
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for manifest
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
- name: Create and push manifest
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
${{ needs.build-amd64.outputs.image-digest }} \
${{ needs.build-arm64.outputs.image-digest }}
env:
DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }}
+1
View File
@@ -106,6 +106,7 @@
## Agent-Specific Notes
- Vocabulary: "makeup" = "mac app".
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
- Signal: "update fly" => `fly ssh console -a flawd-bot -C "bash -lc 'cd /data/clawd/clawdbot && git pull --rebase origin main'"` then `fly machines restart e825232f34d058 -a flawd-bot`.
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
- Never update the Carbon dependency.
+80 -58
View File
@@ -2,77 +2,99 @@
Docs: https://docs.clawd.bot
## 2026.1.23 (Unreleased)
## 2026.1.24
### Highlights
- TTS: allow model-driven TTS tags by default for expressive audio replies (laughter, singing cues, etc.).
- Ollama: provider discovery + docs. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama
### Changes
- Gateway: add /tools/invoke HTTP endpoint for direct tool calls and document it. (#1575) Thanks @vignesh07.
- Agents: keep system prompt time zone-only and move current time to `session_status` for better cache hits.
- Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).
- Heartbeat: add per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer.
- Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`.
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.
- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc.
- Models: add Venice AI provider support (onboarding, non-interactive auth, docs). (#1666) Thanks @jonisjongithub. https://docs.clawd.bot/providers/venice
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
- Docs: add verbose installer troubleshooting guidance.
- Docs: update Fly.io guide notes.
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
### Fixes
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
- Web UI: hide internal `message_id` hints in chat bubbles.
- Heartbeat: normalize target identifiers for consistent routing.
- TUI: reload history after gateway reconnect to restore session state. (#1663)
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
- Agents: use the active auth profile for auto-compaction recovery.
- Models: default missing custom provider fields so minimal configs are accepted.
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
- Google Chat: normalize space targets without double `spaces/` prefix.
## 2026.1.23-1
### Fixes
- Packaging: include dist/tts output in npm tarball (fixes missing dist/tts/tts.js).
## 2026.1.23
### Highlights
- TTS: move Telegram TTS into core + enable model-driven TTS tags by default for expressive audio replies. (#1559) Thanks @Glucksberg. https://docs.clawd.bot/tts
- Gateway: add `/tools/invoke` HTTP endpoint for direct tool calls (auth + tool policy enforced). (#1575) Thanks @vignesh07. https://docs.clawd.bot/gateway/tools-invoke-http-api
- Heartbeat: per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer. https://docs.clawd.bot/gateway/heartbeat
- Deploy: add Fly.io deployment support + guide. (#1570) https://docs.clawd.bot/platforms/fly
- Channels: add Tlon/Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a. https://docs.clawd.bot/channels/tlon
### Changes
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt. https://docs.clawd.bot/multi-agent-sandbox-tools
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3. https://docs.clawd.bot/bedrock
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`. (commit 71203829d) https://docs.clawd.bot/cli/system
- CLI: add live auth probes to `clawdbot models status` for per-profile verification. (commit 40181afde) https://docs.clawd.bot/cli/models
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it. (commit 2c85b1b40)
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node). (commit c3cb26f7c)
- Plugins: add optional `llm-task` JSON-only tool for workflows. (#1498) Thanks @vignesh07. https://docs.clawd.bot/tools/llm-task
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
- Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.
- TTS: move Telegram TTS into core with auto-replies, commands, and gateway methods. (#1559) Thanks @Glucksberg.
- Agents: keep system prompt time zone-only and move current time to `session_status` for better cache hits. (commit 66eec295b)
- Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc. https://docs.clawd.bot/automation/cron-vs-heartbeat
- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc. https://docs.clawd.bot/gateway/heartbeat
### Fixes
- Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518)
- Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.
- Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.
- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.
- Agents: treat request-aborted AbortError timeouts as fallback triggers. (#1576) Thanks @lc0rp.
- Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops).
- Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)
- Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
- Heartbeat: accept plugin channel ids for heartbeat target validation + UI hints.
- Messaging/Sessions: mirror outbound sends into target session keys (threads + dmScope), create session entries on send, and normalize session key casing. (#1520, commit 4b6cdd1d3)
- Sessions: reject array-backed session stores to prevent silent wipes. (#1469)
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank.
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
- Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes.
- Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp).
- Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
- Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts).
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
- TUI: include Gateway slash commands in autocomplete and `/help`.
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
- CLI: suppress diagnostic session/run noise during auth probes.
- CLI: hide auth probe timeout warnings from embedded runs.
- CLI: render auth probe results as a table in `clawdbot models status`.
- CLI: suppress probe-only embedded logs unless `--verbose` is set.
- CLI: move auth probe errors below the table to reduce wrapping.
- CLI: prevent ANSI color bleed when table cells wrap.
- CLI: explain when auth profiles are excluded by auth.order in probe details.
- CLI: drop the em dash when the banner tagline wraps to a second line.
- CLI: inline auth probe errors in status rows to reduce wrapping.
- Telegram: render markdown in media captions. (#1478)
- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests.
- Agents: trigger model fallback when auth profiles are all in cooldown or unavailable. (#1522)
- Daemon: use platform PATH delimiters when building minimal service paths.
- Tests: skip embedded runner ordering assertion on Windows to avoid CI timeouts.
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
- Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts). (commit 5662a9cdf)
- Daemon: use platform PATH delimiters when building minimal service paths. (commit a4e57d3ac)
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
- TUI: render Gateway slash-command replies as system output (for example, `/context`).
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
- Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops). (commit 8ea8801d0)
- Agents: ignore IDENTITY.md template placeholders when parsing identity. (#1556)
- Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
- Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests. (commit 084002998)
- Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
- Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes. (commit f70ac0c7c)
- Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp). (commit d905ca0e0)
- Telegram: render markdown in media captions. (#1478)
- MS Teams: remove `.default` suffix from Graph scopes and Bot Framework probe scopes. (#1507, #1574) Thanks @Evizero.
- Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS. (commit 69f645c66)
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank. (commit d57cb2e1a)
- TUI: forward unknown slash commands, include Gateway commands in autocomplete, and render slash replies as system output. (commit 1af227b61, commit 8195497ce, commit 6fba598ea)
- CLI: auth probe output polish (table output, inline errors, reduced noise, and wrap fixes in `clawdbot models status`). (commit da3f2b489, commit 00ae21bed, commit 31e59cd58, commit f7dc27f2d, commit 438e782f8, commit 886752217, commit aabe0bed3, commit 81535d512, commit c63144ab1)
- Media: only parse `MEDIA:` tags when they start the line to avoid stripping prose mentions. (#1206)
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.
- MS Teams (plugin): remove `.default` suffix from Bot Framework probe scope to avoid double-appending. (#1574) Thanks @Evizero.
- Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)
- Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)
- Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.
## 2026.1.22
+31 -30
View File
@@ -17,7 +17,7 @@
</p>
**Clawdbot** is a *personal AI assistant* you run on your own devices.
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
@@ -65,7 +65,7 @@ clawdbot gateway --port 18789 --verbose
# Send a message
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Google Chat/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
clawdbot agent --message "Ship checklist" --thinking high
```
@@ -106,7 +106,7 @@ Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted i
Full security guide: [Security](https://docs.clawd.bot/gateway/security)
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Slack:
Default behavior on Telegram/WhatsApp/Signal/iMessage/Microsoft Teams/Discord/Google Chat/Slack:
- **DM pairing** (`dmPolicy="pairing"` / `channels.discord.dm.policy="pairing"` / `channels.slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
- Approve with: `clawdbot pairing approve <channel> <code>` (then the sender is added to a local allowlist store).
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the channel allowlist (`allowFrom` / `channels.discord.dm.allowFrom` / `channels.slack.dm.allowFrom`).
@@ -116,7 +116,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
## Highlights
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events.
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Google Chat, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
@@ -138,7 +138,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
### Channels
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [BlueBubbles](https://docs.clawd.bot/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (extension), [Matrix](https://docs.clawd.bot/channels/matrix) (extension), [Zalo](https://docs.clawd.bot/channels/zalo) (extension), [Zalo Personal](https://docs.clawd.bot/channels/zalouser) (extension), [WebChat](https://docs.clawd.bot/web/webchat).
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Google Chat](https://docs.clawd.bot/channels/googlechat) (Chat API), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [BlueBubbles](https://docs.clawd.bot/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (extension), [Matrix](https://docs.clawd.bot/channels/matrix) (extension), [Zalo](https://docs.clawd.bot/channels/zalo) (extension), [Zalo Personal](https://docs.clawd.bot/channels/zalouser) (extension), [WebChat](https://docs.clawd.bot/web/webchat).
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels).
### Apps + nodes
@@ -169,7 +169,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
## How it works (short)
```
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
WhatsApp / Telegram / Slack / Discord / Google Chat / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
│
▼
┌───────────────────────────────┐
@@ -252,7 +252,7 @@ ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can searc
## Chat commands
Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands are owner-only):
Send these in WhatsApp/Telegram/Slack/Google Chat/Microsoft Teams/WebChat (group commands are owner-only):
- `/status` — compact session status (model + tokens, cost when available)
- `/new` or `/reset` — reset the session
@@ -477,30 +477,31 @@ Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and
Thanks to all clawtributors:
<p align="left">
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a>
<a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a>
<a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a>
<a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a>
<a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a>
<a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a>
<a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a>
<a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a>
<a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a>
<a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a>
<a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a>
<a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a>
<a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
<a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a>
<a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a>
<a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a>
<a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a>
<a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
<a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a>
<a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a>
<a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a>
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/iHildy"><img src="https://avatars.githubusercontent.com/u/25069719?v=4&s=48" width="48" height="48" alt="iHildy" title="iHildy"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/Glucksberg"><img src="https://avatars.githubusercontent.com/u/80581902?v=4&s=48" width="48" height="48" alt="Glucksberg" title="Glucksberg"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a>
<a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a>
<a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/patelhiren"><img src="https://avatars.githubusercontent.com/u/172098?v=4&s=48" width="48" height="48" alt="patelhiren" title="patelhiren"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a> <a href="https://github.com/jonisjongithub"><img src="https://avatars.githubusercontent.com/u/86072337?v=4&s=48" width="48" height="48" alt="jonisjongithub" title="jonisjongithub"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a>
<a href="https://github.com/JustYannicc"><img src="https://avatars.githubusercontent.com/u/52761674?v=4&s=48" width="48" height="48" alt="JustYannicc" title="JustYannicc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/apps/google-labs-jules"><img src="https://avatars.githubusercontent.com/in/842251?v=4&s=48" width="48" height="48" alt="google-labs-jules[bot]" title="google-labs-jules[bot]"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a>
<a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/pvoo"><img src="https://avatars.githubusercontent.com/u/20116814?v=4&s=48" width="48" height="48" alt="pvoo" title="pvoo"/></a>
<a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/stefangalescu"><img src="https://avatars.githubusercontent.com/u/52995748?v=4&s=48" width="48" height="48" alt="stefangalescu" title="stefangalescu"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a>
<a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/denysvitali"><img src="https://avatars.githubusercontent.com/u/4939519?v=4&s=48" width="48" height="48" alt="denysvitali" title="denysvitali"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a>
<a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a>
<a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/Takhoffman"><img src="https://avatars.githubusercontent.com/u/781889?v=4&s=48" width="48" height="48" alt="Takhoffman" title="Takhoffman"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/neooriginal"><img src="https://avatars.githubusercontent.com/u/54811660?v=4&s=48" width="48" height="48" alt="neooriginal" title="neooriginal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/travisirby"><img src="https://avatars.githubusercontent.com/u/5958376?v=4&s=48" width="48" height="48" alt="travisirby" title="travisirby"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a>
<a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/kyleok"><img src="https://avatars.githubusercontent.com/u/58307870?v=4&s=48" width="48" height="48" alt="kyleok" title="kyleok"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a>
<a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/pookNast"><img src="https://avatars.githubusercontent.com/u/14242552?v=4&s=48" width="48" height="48" alt="pookNast" title="pookNast"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a>
<a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a>
<a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a>
<a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a>
<a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a>
<a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/svkozak"><img src="https://avatars.githubusercontent.com/u/31941359?v=4&s=48" width="48" height="48" alt="svkozak" title="svkozak"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a>
<a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a>
<a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/andreabadesso"><img src="https://avatars.githubusercontent.com/u/3586068?v=4&s=48" width="48" height="48" alt="andreabadesso" title="andreabadesso"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a>
<a href="https://github.com/Evizero"><img src="https://avatars.githubusercontent.com/u/10854026?v=4&s=48" width="48" height="48" alt="Evizero" title="Evizero"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/itsjaydesu"><img src="https://avatars.githubusercontent.com/u/220390?v=4&s=48" width="48" height="48" alt="itsjaydesu" title="itsjaydesu"/></a> <a href="https://github.com/ivancasco"><img src="https://avatars.githubusercontent.com/u/2452858?v=4&s=48" width="48" height="48" alt="ivancasco" title="ivancasco"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a>
<a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/travisp"><img src="https://avatars.githubusercontent.com/u/165698?v=4&s=48" width="48" height="48" alt="travisp" title="travisp"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a>
<a href="https://github.com/search?q=william%20arzt"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="william arzt" title="william arzt"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/abhaymundhara"><img src="https://avatars.githubusercontent.com/u/62872231?v=4&s=48" width="48" height="48" alt="abhaymundhara" title="abhaymundhara"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/arthyn"><img src="https://avatars.githubusercontent.com/u/5466421?v=4&s=48" width="48" height="48" alt="arthyn" title="arthyn"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a>
<a href="https://github.com/dasilva333"><img src="https://avatars.githubusercontent.com/u/947827?v=4&s=48" width="48" height="48" alt="dasilva333" title="dasilva333"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/EnzeD"><img src="https://avatars.githubusercontent.com/u/9866900?v=4&s=48" width="48" height="48" alt="EnzeD" title="EnzeD"/></a> <a href="https://github.com/fal3"><img src="https://avatars.githubusercontent.com/u/6484295?v=4&s=48" width="48" height="48" alt="fal3" title="fal3"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/search?q=ganghyun%20kim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ganghyun kim" title="ganghyun kim"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a>
<a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a>
<a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odnxe"><img src="https://avatars.githubusercontent.com/u/403141?v=4&s=48" width="48" height="48" alt="odnxe" title="odnxe"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a>
<a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a>
<a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a>
<a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/sergical"><img src="https://avatars.githubusercontent.com/u/3760543?v=4&s=48" width="48" height="48" alt="sergical" title="sergical"/></a> <a href="https://github.com/shiv19"><img src="https://avatars.githubusercontent.com/u/9407019?v=4&s=48" width="48" height="48" alt="shiv19" title="shiv19"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a>
<a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a>
<a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a>
<a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
+87 -190
View File
@@ -2,6 +2,93 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Clawdbot</title>
<item>
<title>2026.1.23</title>
<pubDate>Sat, 24 Jan 2026 13:02:18 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>7750</sparkle:version>
<sparkle:shortVersionString>2026.1.23</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.23</h2>
<h3>Highlights</h3>
<ul>
<li>TTS: allow model-driven TTS tags by default for expressive audio replies (laughter, singing cues, etc.).</li>
</ul>
<h3>Changes</h3>
<ul>
<li>Gateway: add /tools/invoke HTTP endpoint for direct tool calls and document it. (#1575) Thanks @vignesh07.</li>
<li>Agents: keep system prompt time zone-only and move current time to <code>session_status</code> for better cache hits.</li>
<li>Agents: remove redundant bash tool alias from tool registration/display. (#1571) Thanks @Takhoffman.</li>
<li>Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).</li>
<li>Heartbeat: add per-channel visibility controls (OK/alerts/indicator). (#1452) Thanks @dlauer.</li>
<li>Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.</li>
<li>CLI: restart the gateway by default after <code>clawdbot update</code>; add <code>--no-restart</code> to skip it.</li>
<li>CLI: add live auth probes to <code>clawdbot models status</code> for per-profile verification.</li>
<li>CLI: add <code>clawdbot system</code> for system events + heartbeat controls; remove standalone <code>wake</code>.</li>
<li>Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.</li>
<li>Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.</li>
<li>Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc.</li>
<li>Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.</li>
<li>Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.</li>
<li>Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.</li>
<li>TTS: move Telegram TTS into core with auto-replies, commands, and gateway methods. (#1559) Thanks @Glucksberg.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Sessions: accept non-UUID sessionIds for history/send/status while preserving agent scoping. (#1518)</li>
<li>Gateway: compare Linux process start time to avoid PID recycling lock loops; keep locks unless stale. (#1572) Thanks @steipete.</li>
<li>Messaging: mirror outbound sends into target session keys (threads + dmScope) and create session entries on send. (#1520)</li>
<li>Sessions: normalize session key casing to lowercase for consistent routing.</li>
<li>BlueBubbles: normalize group session keys for outbound mirroring. (#1520)</li>
<li>Skills: gate bird Homebrew install to macOS. (#1569) Thanks @bradleypriest.</li>
<li>Slack: honor open groupPolicy for unlisted channels in message + slash gating. (#1563) Thanks @itsjaydesu.</li>
<li>Agents: show tool error fallback when the last assistant turn only invoked tools (prevents silent stops).</li>
<li>Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)</li>
<li>Agents: drop orphaned OpenAI Responses reasoning blocks on model switches. (#1562) Thanks @roshanasingh4.</li>
<li>Docker: update gateway command in docker-compose and Hetzner guide. (#1514)</li>
<li>Sessions: reject array-backed session stores to prevent silent wipes. (#1469)</li>
<li>Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.</li>
<li>UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.</li>
<li>UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank.</li>
<li>Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.</li>
<li>Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.</li>
<li>Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.</li>
<li>Discord: retry rate-limited allowlist resolution + command deploy to avoid gateway crashes.</li>
<li>Mentions: ignore mentionPattern matches when another explicit mention is present in group chats (Slack/Discord/Telegram/WhatsApp).</li>
<li>Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.</li>
<li>Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts).</li>
<li>TUI: forward unknown slash commands (for example, <code>/context</code>) to the Gateway.</li>
<li>TUI: include Gateway slash commands in autocomplete and <code>/help</code>.</li>
<li>CLI: skip usage lines in <code>clawdbot models status</code> when provider usage is unavailable.</li>
<li>CLI: suppress diagnostic session/run noise during auth probes.</li>
<li>CLI: hide auth probe timeout warnings from embedded runs.</li>
<li>CLI: render auth probe results as a table in <code>clawdbot models status</code>.</li>
<li>CLI: suppress probe-only embedded logs unless <code>--verbose</code> is set.</li>
<li>CLI: move auth probe errors below the table to reduce wrapping.</li>
<li>CLI: prevent ANSI color bleed when table cells wrap.</li>
<li>CLI: explain when auth profiles are excluded by auth.order in probe details.</li>
<li>CLI: drop the em dash when the banner tagline wraps to a second line.</li>
<li>CLI: inline auth probe errors in status rows to reduce wrapping.</li>
<li>Telegram: render markdown in media captions. (#1478)</li>
<li>Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests.</li>
<li>Agents: trigger model fallback when auth profiles are all in cooldown or unavailable. (#1522)</li>
<li>Daemon: use platform PATH delimiters when building minimal service paths.</li>
<li>Tests: skip embedded runner ordering assertion on Windows to avoid CI timeouts.</li>
<li>Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.</li>
<li>TUI: render Gateway slash-command replies as system output (for example, <code>/context</code>).</li>
<li>Media: only parse <code>MEDIA:</code> tags when they start the line to avoid stripping prose mentions. (#1206)</li>
<li>Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.</li>
<li>Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)</li>
<li>Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.</li>
<li>MS Teams (plugin): remove <code>.default</code> suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.</li>
<li>MS Teams (plugin): remove <code>.default</code> suffix from Bot Framework probe scope to avoid double-appending. (#1574) Thanks @Evizero.</li>
<li>Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)</li>
<li>Agents: warn and ignore tool allowlists that only reference unknown or unloaded plugin tools. (#1566)</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.23/Clawdbot-2026.1.23.zip" length="22326233" type="application/octet-stream" sparkle:edSignature="p40dFczUfmMpsif4BrEUYVqUPG2WiBXleWgefwu4WiqjuyXbw7CAaH5CpQKig/k2qRLlE59kX7AR/qJqmy+yCA=="/>
</item>
<item>
<title>2026.1.22</title>
<pubDate>Fri, 23 Jan 2026 08:58:14 +0000</pubDate>
@@ -124,195 +211,5 @@
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="22284796" type="application/octet-stream" sparkle:edSignature="pXji4NMA/cu35iMxln385d6LnsT4yIZtFtFiR7sIimKeSC2CsyeWzzSD0EhJsN98PdSoy69iEFZt4I2ZtNCECg=="/>
</item>
<item>
<title>2026.1.21</title>
<pubDate>Wed, 21 Jan 2026 08:18:22 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>7116</sparkle:version>
<sparkle:shortVersionString>2026.1.21</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.21</h2>
<h3>Changes</h3>
<ul>
<li>Control UI: add copy-as-markdown with error feedback. (#1345) https://docs.clawd.bot/web/control-ui</li>
<li>Control UI: drop the legacy list view. (#1345) https://docs.clawd.bot/web/control-ui</li>
<li>TUI: add syntax highlighting for code blocks. (#1200) https://docs.clawd.bot/tui</li>
<li>TUI: session picker shows derived titles, fuzzy search, relative times, and last message preview. (#1271) https://docs.clawd.bot/tui</li>
<li>TUI: add a searchable model picker for quicker model selection. (#1198) https://docs.clawd.bot/tui</li>
<li>TUI: add input history (up/down) for submitted messages. (#1348) https://docs.clawd.bot/tui</li>
<li>ACP: add <code>clawdbot acp</code> for IDE integrations. https://docs.clawd.bot/cli/acp</li>
<li>ACP: add <code>clawdbot acp client</code> interactive harness for debugging. https://docs.clawd.bot/cli/acp</li>
<li>Skills: add download installs with OS-filtered options. https://docs.clawd.bot/tools/skills</li>
<li>Skills: add the local sherpa-onnx-tts skill. https://docs.clawd.bot/tools/skills</li>
<li>Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: add SQLite embedding cache to speed up reindexing and frequent updates. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: add OpenAI batch indexing for embeddings when configured. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: enable OpenAI batch indexing by default for OpenAI embeddings. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: allow parallel OpenAI batch indexing jobs (default concurrency: 2). https://docs.clawd.bot/concepts/memory</li>
<li>Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: add <code>--verbose</code> logging for memory status + batch indexing details. https://docs.clawd.bot/concepts/memory</li>
<li>Memory: add native Gemini embeddings provider for memory search. (#1151) https://docs.clawd.bot/concepts/memory</li>
<li>Browser: allow config defaults for efficient snapshots in the tool/CLI. (#1336) https://docs.clawd.bot/tools/browser</li>
<li>Nostr: add the Nostr channel plugin with profile management + onboarding defaults. (#1323) https://docs.clawd.bot/channels/nostr</li>
<li>Matrix: migrate to matrix-bot-sdk with E2EE support, location handling, and group allowlist upgrades. (#1298) https://docs.clawd.bot/channels/matrix</li>
<li>Slack: add HTTP webhook mode via Bolt HTTP receiver. (#1143) https://docs.clawd.bot/channels/slack</li>
<li>Telegram: enrich forwarded-message context with normalized origin details + legacy fallback. (#1090) https://docs.clawd.bot/channels/telegram</li>
<li>Discord: fall back to <code>/skill</code> when native command limits are exceeded. (#1287)</li>
<li>Discord: expose <code>/skill</code> globally. (#1287)</li>
<li>Zalouser: add channel dock metadata, config schema, setup wiring, probe, and status issues. (#1219) https://docs.clawd.bot/plugins/zalouser</li>
<li>Plugins: require manifest-embedded config schemas with preflight validation warnings. (#1272) https://docs.clawd.bot/plugins/manifest</li>
<li>Plugins: move channel catalog metadata into plugin manifests. (#1290) https://docs.clawd.bot/plugins/manifest</li>
<li>Plugins: align Nextcloud Talk policy helpers with core patterns. (#1290) https://docs.clawd.bot/plugins/manifest</li>
<li>Plugins/UI: let channel plugin metadata drive UI labels/icons and cron channel options. (#1306) https://docs.clawd.bot/web/control-ui</li>
<li>Plugins: add plugin slots with a dedicated memory slot selector. https://docs.clawd.bot/plugins/agent-tools</li>
<li>Plugins: ship the bundled BlueBubbles channel plugin (disabled by default). https://docs.clawd.bot/channels/bluebubbles</li>
<li>Plugins: migrate bundled messaging extensions to the plugin SDK and resolve plugin-sdk imports in the loader.</li>
<li>Plugins: migrate the Zalo plugin to the shared plugin SDK runtime. https://docs.clawd.bot/channels/zalo</li>
<li>Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime. https://docs.clawd.bot/plugins/zalouser</li>
<li>Plugins: allow optional agent tools with explicit allowlists and add the plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools</li>
<li>Plugins: auto-enable bundled channel/provider plugins when configuration is present.</li>
<li>Plugins: sync plugin sources on channel switches and update npm-installed plugins during <code>clawdbot update</code>.</li>
<li>Plugins: share npm plugin update logic between <code>clawdbot update</code> and <code>clawdbot plugins update</code>.</li>
<li>Gateway/API: add <code>/v1/responses</code> (OpenResponses) with item-based input + semantic streaming events. (#1229)</li>
<li>Gateway/API: expand <code>/v1/responses</code> to support file/image inputs, tool_choice, usage, and output limits. (#1229)</li>
<li>Usage: add <code>/usage cost</code> summaries and macOS menu cost charts. https://docs.clawd.bot/reference/api-usage-costs</li>
<li>Security: warn when <=300B models run without sandboxing while web tools are enabled. https://docs.clawd.bot/cli/security</li>
<li>Exec: add host/security/ask routing for gateway + node exec. https://docs.clawd.bot/tools/exec</li>
<li>Exec: add <code>/exec</code> directive for per-session exec defaults (host/security/ask/node). https://docs.clawd.bot/tools/exec</li>
<li>Exec approvals: migrate approvals to <code>~/.clawdbot/exec-approvals.json</code> with per-agent allowlists + skill auto-allow toggle, and add approvals UI + node exec lifecycle events. https://docs.clawd.bot/tools/exec-approvals</li>
<li>Nodes: add headless node host (<code>clawdbot node start</code>) for <code>system.run</code>/<code>system.which</code>. https://docs.clawd.bot/cli/node</li>
<li>Nodes: add node daemon service install/status/start/stop/restart. https://docs.clawd.bot/cli/node</li>
<li>Bridge: add <code>skills.bins</code> RPC to support node host auto-allow skill bins.</li>
<li>Sessions: add daily reset policy with per-type overrides and idle windows (default 4am local), preserving legacy idle-only configs. (#1146) https://docs.clawd.bot/concepts/session</li>
<li>Sessions: allow <code>sessions_spawn</code> to override thinking level for sub-agent runs. https://docs.clawd.bot/tools/subagents</li>
<li>Channels: unify thread/topic allowlist matching + command/mention gating helpers across core providers. https://docs.clawd.bot/concepts/groups</li>
<li>Models: add Qwen Portal OAuth provider support. (#1120) https://docs.clawd.bot/providers/qwen</li>
<li>Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels. https://docs.clawd.bot/start/onboarding</li>
<li>Docs: clarify allowlist input types and onboarding behavior for messaging channels. https://docs.clawd.bot/start/onboarding</li>
<li>Docs: refresh Android node discovery docs for the Gateway WS service type. https://docs.clawd.bot/platforms/android</li>
<li>Docs: surface Amazon Bedrock in provider lists and clarify Bedrock auth env vars. (#1289) https://docs.clawd.bot/bedrock</li>
<li>Docs: clarify WhatsApp voice notes. https://docs.clawd.bot/channels/whatsapp</li>
<li>Docs: clarify Windows WSL portproxy LAN access notes. https://docs.clawd.bot/platforms/windows</li>
<li>Docs: refresh bird skill install metadata and usage notes. (#1302) https://docs.clawd.bot/tools/browser-login</li>
<li>Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.</li>
<li>Agents: clarify node_modules read-only guidance in agent instructions.</li>
<li>Config: stamp last-touched metadata on write and warn if the config is newer than the running build.</li>
<li>macOS: hide usage section when usage is unavailable instead of showing provider errors.</li>
<li>Android: migrate node transport to the Gateway WebSocket protocol with TLS pinning support + gateway discovery naming.</li>
<li>Android: send structured payloads in node events/invokes and include user-agent metadata in gateway connects.</li>
<li>Android: remove legacy bridge transport code now that nodes use the gateway protocol.</li>
<li>Android: bump okhttp + dnsjava to satisfy lint dependency checks.</li>
<li>Build: update workspace + core/plugin deps.</li>
<li>Build: use tsgo for dev/watch builds by default (opt out with <code>CLAWDBOT_TS_COMPILER=tsc</code>).</li>
<li>Repo: remove the Peekaboo git submodule now that the SPM release is used.</li>
<li>macOS: switch PeekabooBridge integration to the tagged Swift Package Manager release.</li>
<li>macOS: stop syncing Peekaboo in postinstall.</li>
<li>Swabble: use the tagged Commander Swift package release.</li>
</ul>
<h3>Breaking</h3>
<ul>
<li><strong>BREAKING:</strong> Reject invalid/unknown config entries and refuse to start the gateway for safety. Run <code>clawdbot doctor --fix</code> to repair, then update plugins (<code>clawdbot plugins update</code>) if you use any.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>Discovery: shorten Bonjour DNS-SD service type to <code>_clawdbot-gw._tcp</code> and update discovery clients/docs.</li>
<li>Diagnostics: export OTLP logs, correct queue depth tracking, and document message-flow telemetry.</li>
<li>Diagnostics: emit message-flow diagnostics across channels via shared dispatch. (#1244)</li>
<li>Diagnostics: gate heartbeat/webhook logging. (#1244)</li>
<li>Gateway: strip inbound envelope headers from chat history messages to keep clients clean.</li>
<li>Gateway: clarify unauthorized handshake responses with token/password mismatch guidance.</li>
<li>Gateway: allow mobile node client ids for iOS + Android handshake validation. (#1354)</li>
<li>Gateway: clarify connect/validation errors for gateway params. (#1347)</li>
<li>Gateway: preserve restart wake routing + thread replies across restarts. (#1337)</li>
<li>Gateway: reschedule per-agent heartbeats on config hot reload without restarting the runner.</li>
<li>Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.</li>
<li>Cron: auto-deliver isolated agent output to explicit targets without tool calls. (#1285)</li>
<li>Agents: preserve subagent announce thread/topic routing + queued replies across channels. (#1241)</li>
<li>Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)</li>
<li>Agents: avoid treating timeout errors with "aborted" messages as user aborts, so model fallback still runs. (#1137)</li>
<li>Agents: sanitize oversized image payloads before send and surface image-dimension errors.</li>
<li>Sessions: fall back to session labels when listing display names. (#1124)</li>
<li>Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)</li>
<li>Config: log invalid config issues once per run and keep invalid-config errors stackless.</li>
<li>Config: allow Perplexity as a web_search provider in config validation. (#1230)</li>
<li>Config: allow custom fields under <code>skills.entries.<name>.config</code> for skill credentials/config. (#1226)</li>
<li>Doctor: clarify plugin auto-enable hint text in the startup banner.</li>
<li>Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)</li>
<li>Docs: make docs:list fail fast with a clear error if the docs directory is missing.</li>
<li>Plugins: add Nextcloud Talk manifest for plugin config validation. (#1297)</li>
<li>Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.</li>
<li>CLI: preserve cron delivery settings when editing message payloads. (#1322)</li>
<li>CLI: keep <code>clawdbot logs</code> output resilient to broken pipes while preserving progress output.</li>
<li>CLI: avoid duplicating --profile/--dev flags when formatting commands.</li>
<li>CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207)</li>
<li>CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195)</li>
<li>CLI: skip runner rebuilds when dist is fresh. (#1231)</li>
<li>CLI: add WSL2/systemd unavailable hints in daemon status/doctor output.</li>
<li>Status: route native <code>/status</code> to the active agent so model selection reflects the correct profile. (#1301)</li>
<li>Status: show both usage windows with reset hints when usage data is available. (#1101)</li>
<li>UI: keep config form enums typed, preserve empty strings, protect sensitive defaults, and deepen config search. (#1315)</li>
<li>UI: preserve ordered list numbering in chat markdown. (#1341)</li>
<li>UI: allow Control UI to read gatewayUrl from URL params for remote WebSocket targets. (#1342)</li>
<li>UI: prevent double-scroll in Control UI chat by locking chat layout to the viewport. (#1283)</li>
<li>UI: enable shell mode for sync Windows spawns to avoid <code>pnpm ui:build</code> EINVAL. (#1212)</li>
<li>TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202)</li>
<li>TUI: align custom editor initialization with the latest pi-tui API. (#1298)</li>
<li>TUI: show generic empty-state text for searchable pickers. (#1201)</li>
<li>TUI: highlight model search matches and stabilize search ordering.</li>
<li>Configure: hide OpenRouter auto routing model from the model picker. (#1182)</li>
<li>Memory: show total file counts + scan issues in <code>clawdbot memory status</code>.</li>
<li>Memory: fall back to non-batch embeddings after repeated batch failures.</li>
<li>Memory: apply OpenAI batch defaults even without explicit remote config.</li>
<li>Memory: index atomically so failed reindex preserves the previous memory database. (#1151)</li>
<li>Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)</li>
<li>Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.</li>
<li>Memory: parallelize embedding indexing with rate-limit retries.</li>
<li>Memory: split overly long lines to keep embeddings under token limits.</li>
<li>Memory: skip empty chunks to avoid invalid embedding inputs.</li>
<li>Memory: split embedding batches to avoid OpenAI token limits during indexing.</li>
<li>Memory: probe sqlite-vec availability in <code>clawdbot memory status</code>.</li>
<li>Exec approvals: enforce allowlist when ask is off.</li>
<li>Exec approvals: prefer raw command for node approvals/events.</li>
<li>Tools: show exec elevated flag before the command and keep it outside markdown in tool summaries.</li>
<li>Tools: return a companion-app-required message when node exec is requested with no paired node.</li>
<li>Tools: return a companion-app-required message when <code>system.run</code> is requested without a supporting node.</li>
<li>Exec: default gateway/node exec security to allowlist when unset (sandbox stays deny).</li>
<li>Exec: prefer bash when fish is default shell, falling back to sh if bash is missing. (#1297)</li>
<li>Exec: merge login-shell PATH for host=gateway exec while keeping daemon PATH minimal. (#1304)</li>
<li>Streaming: emit assistant deltas for OpenAI-compatible SSE chunks. (#1147)</li>
<li>Discord: make resolve warnings avoid raw JSON payloads on rate limits.</li>
<li>Discord: process message handlers in parallel across sessions to avoid event queue blocking. (#1295)</li>
<li>Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.</li>
<li>Discord: only emit slow listener warnings after 30s.</li>
<li>Discord: inherit parent channel allowlists for thread slash commands and reactions. (#1123)</li>
<li>Telegram: honor pairing allowlists for native slash commands.</li>
<li>Telegram: preserve hidden text_link URLs by expanding entities in inbound text. (#1118)</li>
<li>Slack: resolve Bolt import interop for Bun + Node. (#1191)</li>
<li>Web search: infer Perplexity base URL from API key source (direct vs OpenRouter).</li>
<li>Web fetch: harden SSRF protection with shared hostname checks and redirect limits. (#1346)</li>
<li>Browser: register AI snapshot refs for act commands. (#1282)</li>
<li>Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)</li>
<li>Anthropic: default API prompt caching to 1h with configurable TTL override.</li>
<li>Anthropic: ignore TTL for OAuth.</li>
<li>Auth profiles: keep auto-pinned preference while allowing rotation on failover. (#1138)</li>
<li>Auth profiles: user pins stay locked. (#1138)</li>
<li>Model catalog: avoid caching import failures, log transient discovery errors, and keep partial results. (#1332)</li>
<li>Tests: stabilize Windows gateway/CLI tests by skipping sidecars, normalizing argv, and extending timeouts.</li>
<li>Tests: stabilize plugin SDK resolution and embedded agent timeouts.</li>
<li>Windows: install gateway scheduled task as the current user.</li>
<li>Windows: show friendly guidance instead of failing on access denied.</li>
<li>macOS: load menu session previews asynchronously so items populate while the menu is open.</li>
<li>macOS: use label colors for session preview text so previews render in menu subviews.</li>
<li>macOS: suppress usage error text in the menubar cost view.</li>
<li>macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166)</li>
<li>macOS: avoid touching launchd in Remote over SSH so quitting the app no longer disables the remote gateway. (#1105)</li>
<li>macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)</li>
<li>Daemon: include HOME in service environments to avoid missing HOME errors. (#1214)</li>
</ul>
Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @NicholaiVogel, @RyanLisse, @ThePickle31, @VACInc, @Whoaa512, @YuriNachos, @aaronveklabs, @abdaraxus, @alauppe, @ameno-, @artuskg, @austinm911, @bradleypriest, @cheeeee, @dougvk, @fogboots, @gnarco, @gumadeiras, @jdrhyne, @joelklabo, @longmaba, @mukhtharcm, @odysseus0, @oscargavin, @rhjoh, @sebslight, @sibbl, @sleontenko, @steipete, @suminhthanh, @thewilloftheshadow, @tyler6204, @vignesh07, @visionik, @ysqander, @zerone0x.
<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.21/Clawdbot-2026.1.21.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
</item>
</channel>
</rss>
+2 -2
View File
@@ -21,8 +21,8 @@ android {
applicationId = "com.clawdbot.android"
minSdk = 31
targetSdk = 36
versionCode = 202601230
versionName = "2026.1.23"
versionCode = 202601240
versionName = "2026.1.24"
}
buildTypes {
+2 -2
View File
@@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.23</string>
<string>2026.1.24</string>
<key>CFBundleVersion</key>
<string>20260123</string>
<string>20260124</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>
+2 -2
View File
@@ -17,8 +17,8 @@
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.23</string>
<string>2026.1.24</string>
<key>CFBundleVersion</key>
<string>20260123</string>
<string>20260124</string>
</dict>
</plist>
+4 -4
View File
@@ -81,8 +81,8 @@ targets:
properties:
CFBundleDisplayName: Clawdbot
CFBundleIconName: AppIcon
CFBundleShortVersionString: "2026.1.23"
CFBundleVersion: "20260123"
CFBundleShortVersionString: "2026.1.24"
CFBundleVersion: "20260124"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: ClawdbotTests
CFBundleShortVersionString: "2026.1.23"
CFBundleVersion: "20260123"
CFBundleShortVersionString: "2026.1.24"
CFBundleVersion: "20260124"
+82 -28
View File
@@ -24,6 +24,11 @@ final class AppState {
case remote
}
enum RemoteTransport: String {
case ssh
case direct
}
var isPaused: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
}
@@ -166,6 +171,10 @@ final class AppState {
}
}
var remoteTransport: RemoteTransport {
didSet { self.syncGatewayConfigIfNeeded() }
}
var canvasEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
}
@@ -200,6 +209,10 @@ final class AppState {
}
}
var remoteUrl: String {
didSet { self.syncGatewayConfigIfNeeded() }
}
var remoteIdentity: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
}
@@ -263,13 +276,15 @@ final class AppState {
}
let configRoot = ClawdbotConfigFile.loadDict()
let configGateway = configRoot["gateway"] as? [String: Any]
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot)
let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot)
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
self.remoteTransport = configRemoteTransport
self.connectionMode = resolvedConnectionMode
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
if resolvedConnectionMode == .remote,
configRemoteTransport != .direct,
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let host = AppState.remoteHost(from: configRemoteUrl)
{
@@ -277,6 +292,7 @@ final class AppState {
} else {
self.remoteTarget = storedRemoteTarget
}
self.remoteUrl = configRemoteUrl ?? ""
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
@@ -354,10 +370,11 @@ final class AppState {
private func applyConfigOverrides(_ root: [String: Any]) {
let gateway = root["gateway"] as? [String: Any]
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String
let remoteUrl = GatewayRemoteConfig.resolveUrlString(root: root)
let hasRemoteUrl = !(remoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
let remoteTransport = GatewayRemoteConfig.resolveTransport(root: root)
let desiredMode: ConnectionMode? = switch modeRaw {
case "local":
@@ -378,8 +395,17 @@ final class AppState {
self.connectionMode = .remote
}
if remoteTransport != self.remoteTransport {
self.remoteTransport = remoteTransport
}
let remoteUrlText = remoteUrl ?? ""
if remoteUrlText != self.remoteUrl {
self.remoteUrl = remoteUrlText
}
let targetMode = desiredMode ?? self.connectionMode
if targetMode == .remote,
remoteTransport != .direct,
let host = AppState.remoteHost(from: remoteUrl)
{
self.updateRemoteTarget(host: host)
@@ -402,6 +428,8 @@ final class AppState {
let connectionMode = self.connectionMode
let remoteTarget = self.remoteTarget
let remoteIdentity = self.remoteIdentity
let remoteTransport = self.remoteTransport
let remoteUrl = self.remoteUrl
let desiredMode: String? = switch connectionMode {
case .local:
"local"
@@ -435,39 +463,63 @@ final class AppState {
var remote = gateway["remote"] as? [String: Any] ?? [:]
var remoteChanged = false
if let host = remoteHost {
let existingUrl = (remote["url"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl {
remote["url"] = desiredUrl
if remoteTransport == .direct {
let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedUrl.isEmpty {
if remote["url"] != nil {
remote.removeValue(forKey: "url")
remoteChanged = true
}
} else {
let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) ?? trimmedUrl
if (remote["url"] as? String) != normalizedUrl {
remote["url"] = normalizedUrl
remoteChanged = true
}
}
if (remote["transport"] as? String) != RemoteTransport.direct.rawValue {
remote["transport"] = RemoteTransport.direct.rawValue
remoteChanged = true
}
}
} else {
if remote["transport"] != nil {
remote.removeValue(forKey: "transport")
remoteChanged = true
}
if let host = remoteHost {
let existingUrl = (remote["url"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl {
remote["url"] = desiredUrl
remoteChanged = true
}
}
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
if !sanitizedTarget.isEmpty {
if (remote["sshTarget"] as? String) != sanitizedTarget {
remote["sshTarget"] = sanitizedTarget
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
if !sanitizedTarget.isEmpty {
if (remote["sshTarget"] as? String) != sanitizedTarget {
remote["sshTarget"] = sanitizedTarget
remoteChanged = true
}
} else if remote["sshTarget"] != nil {
remote.removeValue(forKey: "sshTarget")
remoteChanged = true
}
} else if remote["sshTarget"] != nil {
remote.removeValue(forKey: "sshTarget")
remoteChanged = true
}
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedIdentity.isEmpty {
if (remote["sshIdentity"] as? String) != trimmedIdentity {
remote["sshIdentity"] = trimmedIdentity
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedIdentity.isEmpty {
if (remote["sshIdentity"] as? String) != trimmedIdentity {
remote["sshIdentity"] = trimmedIdentity
remoteChanged = true
}
} else if remote["sshIdentity"] != nil {
remote.removeValue(forKey: "sshIdentity")
remoteChanged = true
}
} else if remote["sshIdentity"] != nil {
remote.removeValue(forKey: "sshIdentity")
remoteChanged = true
}
if remoteChanged {
@@ -621,8 +673,10 @@ extension AppState {
state.iconOverride = .system
state.heartbeatsEnabled = true
state.connectionMode = .local
state.remoteTransport = .ssh
state.canvasEnabled = true
state.remoteTarget = "user@example.com"
state.remoteUrl = "wss://gateway.example.ts.net"
state.remoteIdentity = "~/.ssh/id_ed25519"
state.remoteProjectRoot = "~/Projects/clawdbot"
state.remoteCliPath = ""
@@ -40,6 +40,16 @@ extension ChannelsSettings {
return .orange
}
var googlechatTint: Color {
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
else { return .secondary }
if !status.configured { return .secondary }
if status.lastError != nil { return .orange }
if status.probe?.ok == false { return .orange }
if status.running { return .green }
return .orange
}
var signalTint: Color {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return .secondary }
@@ -85,6 +95,14 @@ extension ChannelsSettings {
return "Configured"
}
var googlechatSummary: String {
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
else { return "Checking…" }
if !status.configured { return "Not configured" }
if status.running { return "Running" }
return "Configured"
}
var signalSummary: String {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return "Checking…" }
@@ -193,6 +211,37 @@ extension ChannelsSettings {
return lines.isEmpty ? nil : lines.joined(separator: " · ")
}
var googlechatDetails: String? {
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
else { return nil }
var lines: [String] = []
if let source = status.credentialSource {
lines.append("Credential: \(source)")
}
if let audienceType = status.audienceType {
let audience = status.audience ?? ""
let label = audience.isEmpty ? audienceType : "\(audienceType) \(audience)"
lines.append("Audience: \(label)")
}
if let probe = status.probe {
if probe.ok {
if let elapsed = probe.elapsedMs {
lines.append("Probe \(Int(elapsed))ms")
}
} else {
let code = probe.status.map { String($0) } ?? "unknown"
lines.append("Probe failed (\(code))")
}
}
if let last = self.date(fromMs: status.lastProbeAt) {
lines.append("Last probe \(relativeAge(from: last))")
}
if let err = status.lastError, !err.isEmpty {
lines.append("Error: \(err)")
}
return lines.isEmpty ? nil : lines.joined(separator: " · ")
}
var signalDetails: String? {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return nil }
@@ -244,7 +293,7 @@ extension ChannelsSettings {
}
var orderedChannels: [ChannelItem] {
let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"]
let fallback = ["whatsapp", "telegram", "discord", "googlechat", "slack", "signal", "imessage"]
let order = self.store.snapshot?.channelOrder ?? fallback
let channels = order.enumerated().map { index, id in
ChannelItem(
@@ -307,6 +356,8 @@ extension ChannelsSettings {
return self.telegramTint
case "discord":
return self.discordTint
case "googlechat":
return self.googlechatTint
case "signal":
return self.signalTint
case "imessage":
@@ -326,6 +377,8 @@ extension ChannelsSettings {
return self.telegramSummary
case "discord":
return self.discordSummary
case "googlechat":
return self.googlechatSummary
case "signal":
return self.signalSummary
case "imessage":
@@ -345,6 +398,8 @@ extension ChannelsSettings {
return self.telegramDetails
case "discord":
return self.discordDetails
case "googlechat":
return self.googlechatDetails
case "signal":
return self.signalDetails
case "imessage":
@@ -377,6 +432,10 @@ extension ChannelsSettings {
return self
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
.lastProbeAt)
case "googlechat":
return self
.date(fromMs: self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)?
.lastProbeAt)
case "signal":
return self
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
@@ -411,6 +470,10 @@ extension ChannelsSettings {
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case "googlechat":
guard let status = self.channelStatus("googlechat", as: ChannelsStatusSnapshot.GoogleChatStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case "signal":
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return false }
@@ -85,6 +85,28 @@ struct ChannelsStatusSnapshot: Codable {
let lastProbeAt: Double?
}
struct GoogleChatProbe: Codable {
let ok: Bool
let status: Int?
let error: String?
let elapsedMs: Double?
}
struct GoogleChatStatus: Codable {
let configured: Bool
let credentialSource: String?
let audienceType: String?
let audience: String?
let webhookPath: String?
let webhookUrl: String?
let running: Bool
let lastStartAt: Double?
let lastStopAt: Double?
let lastError: String?
let probe: GoogleChatProbe?
let lastProbeAt: Double?
}
struct SignalProbe: Codable {
let ok: Bool
let status: Int?
@@ -11,6 +11,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
case whatsapp
case telegram
case discord
case googlechat
case slack
case signal
case imessage
@@ -0,0 +1,47 @@
import ClawdbotDiscovery
import Foundation
enum GatewayDiscoveryHelpers {
static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
let user = NSUserName()
var target = "\(user)@\(host)"
if gateway.sshPort != 22 {
target += ":\(gateway.sshPort)"
}
return target
}
static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
self.directGatewayUrl(
tailnetDns: gateway.tailnetDns,
lanHost: gateway.lanHost,
gatewayPort: gateway.gatewayPort)
}
static func directGatewayUrl(
tailnetDns: String?,
lanHost: String?,
gatewayPort: Int?) -> String?
{
if let tailnetDns = self.sanitizedTailnetHost(tailnetDns) {
return "wss://\(tailnetDns)"
}
guard let lanHost = self.trimmed(lanHost), !lanHost.isEmpty else { return nil }
let port = gatewayPort ?? 18789
return "ws://\(lanHost):\(port)"
}
static func sanitizedTailnetHost(_ host: String?) -> String? {
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
if host.hasSuffix(".internal.") || host.hasSuffix(".internal") {
return nil
}
return host
}
private static func trimmed(_ value: String?) -> String? {
value?.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
@@ -4,6 +4,8 @@ import SwiftUI
struct GatewayDiscoveryInlineList: View {
var discovery: GatewayDiscoveryModel
var currentTarget: String?
var currentUrl: String?
var transport: AppState.RemoteTransport
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
@State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID?
@@ -25,9 +27,8 @@ struct GatewayDiscoveryInlineList: View {
} else {
VStack(alignment: .leading, spacing: 6) {
ForEach(self.discovery.gateways.prefix(6)) { gateway in
let target = self.suggestedSSHTarget(gateway)
let selected = (target != nil && self.currentTarget?
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
let display = self.displayInfo(for: gateway)
let selected = display.selected
Button {
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
@@ -40,7 +41,7 @@ struct GatewayDiscoveryInlineList: View {
.font(.callout.weight(.semibold))
.lineLimit(1)
.truncationMode(.tail)
Text(target ?? "Gateway pairing only")
Text(display.label)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -83,27 +84,26 @@ struct GatewayDiscoveryInlineList: View {
.fill(Color(NSColor.controlBackgroundColor)))
}
}
.help("Click a discovered gateway to fill the SSH target.")
.help(self.transport == .direct
? "Click a discovered gateway to fill the gateway URL."
: "Click a discovered gateway to fill the SSH target.")
}
private func suggestedSSHTarget(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
guard let host else { return nil }
let user = NSUserName()
return GatewayDiscoveryModel.buildSSHTarget(
user: user,
host: host,
port: gateway.sshPort)
}
private func sanitizedTailnetHost(_ host: String?) -> String? {
guard let host else { return nil }
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
if trimmed.hasSuffix(".internal.") || trimmed.hasSuffix(".internal") {
return nil
private func displayInfo(
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (label: String, selected: Bool)
{
switch self.transport {
case .direct:
let url = GatewayDiscoveryHelpers.directUrl(for: gateway)
let label = url ?? "Gateway pairing only"
let selected = url != nil && self.trimmed(self.currentUrl) == url
return (label, selected)
case .ssh:
let target = GatewayDiscoveryHelpers.sshTarget(for: gateway)
let label = target ?? "Gateway pairing only"
let selected = target != nil && self.trimmed(self.currentTarget) == target
return (label, selected)
}
return trimmed
}
private func rowBackground(selected: Bool, hovered: Bool) -> Color {
@@ -111,6 +111,10 @@ struct GatewayDiscoveryInlineList: View {
if hovered { return Color.secondary.opacity(0.08) }
return Color.clear
}
private func trimmed(_ value: String?) -> String {
value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
}
struct GatewayDiscoveryMenu: View {
@@ -311,6 +311,19 @@ actor GatewayEndpointStore {
token: token,
password: password))
case .remote:
let root = ClawdbotConfigFile.loadDict()
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
self.cancelRemoteEnsure()
self.setState(.unavailable(
mode: .remote,
reason: "gateway.remote.url missing or invalid for direct transport"))
return
}
self.cancelRemoteEnsure()
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
return
}
let port = await self.deps.remotePortIfRunning()
guard let port else {
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
@@ -341,6 +354,25 @@ actor GatewayEndpointStore {
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
}
let root = ClawdbotConfigFile.loadDict()
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
}
guard let port = GatewayRemoteConfig.defaultPort(for: url),
let portInt = UInt16(exactly: port)
else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Invalid gateway.remote.url port"])
}
self.logger.info("remote transport direct; skipping SSH tunnel")
return portInt
}
let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
throw NSError(
@@ -401,6 +433,21 @@ actor GatewayEndpointStore {
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
}
let root = ClawdbotConfigFile.loadDict()
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
}
let token = self.deps.token()
let password = self.deps.password()
self.cancelRemoteEnsure()
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
return (url, token, password)
}
self.kickRemoteEnsureIfNeeded(detail: detail)
guard let ensure = self.remoteEnsure else {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
@@ -0,0 +1,64 @@
import Foundation
enum GatewayRemoteConfig {
static func resolveTransport(root: [String: Any]) -> AppState.RemoteTransport {
guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let raw = remote["transport"] as? String
else {
return .ssh
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return trimmed == AppState.RemoteTransport.direct.rawValue ? .direct : .ssh
}
static func resolveUrlString(root: [String: Any]) -> String? {
guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let urlRaw = remote["url"] as? String
else {
return nil
}
let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
static func resolveGatewayUrl(root: [String: Any]) -> URL? {
guard let raw = self.resolveUrlString(root: root) else { return nil }
return self.normalizeGatewayUrl(raw)
}
static func normalizeGatewayUrlString(_ raw: String) -> String? {
self.normalizeGatewayUrl(raw)?.absoluteString
}
static func normalizeGatewayUrl(_ raw: String) -> URL? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil }
let scheme = url.scheme?.lowercased() ?? ""
guard scheme == "ws" || scheme == "wss" else { return nil }
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !host.isEmpty else { return nil }
if scheme == "ws", url.port == nil {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return url
}
components.port = 18789
return components.url
}
return url
}
static func defaultPort(for url: URL) -> Int? {
if let port = url.port { return port }
let scheme = url.scheme?.lowercased() ?? ""
switch scheme {
case "wss":
return 443
case "ws":
return 18789
default:
return nil
}
}
}
+162 -69
View File
@@ -17,6 +17,7 @@ struct GeneralSettings: View {
@State private var showRemoteAdvanced = false
private let isPreview = ProcessInfo.processInfo.isPreview
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
private var remoteLabelWidth: CGFloat { 88 }
var body: some View {
ScrollView(.vertical) {
@@ -104,7 +105,7 @@ struct GeneralSettings: View {
Picker("Mode", selection: self.$state.connectionMode) {
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
Text("Remote (another host)").tag(AppState.ConnectionMode.remote)
}
.pickerStyle(.menu)
.labelsHidden()
@@ -136,60 +137,51 @@ struct GeneralSettings: View {
private var remoteCard: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center, spacing: 10) {
Text("SSH")
.font(.callout.weight(.semibold))
.frame(width: 48, alignment: .leading)
TextField("user@host[:22]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
Button {
Task { await self.testRemote() }
} label: {
if self.remoteStatus == .checking {
ProgressView().controlSize(.small)
} else {
Text("Test remote")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
self.remoteTransportRow
if self.state.remoteTransport == .ssh {
self.remoteSshRow
} else {
self.remoteDirectRow
}
GatewayDiscoveryInlineList(
discovery: self.gatewayDiscovery,
currentTarget: self.state.remoteTarget)
currentTarget: self.state.remoteTarget,
currentUrl: self.state.remoteUrl,
transport: self.state.remoteTransport)
{ gateway in
self.applyDiscoveredGateway(gateway)
}
.padding(.leading, 58)
.padding(.leading, self.remoteLabelWidth + 10)
self.remoteStatusView
.padding(.leading, 58)
.padding(.leading, self.remoteLabelWidth + 10)
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
VStack(alignment: .leading, spacing: 8) {
LabeledContent("Identity file") {
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
LabeledContent("Project root") {
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
LabeledContent("CLI path") {
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
if self.state.remoteTransport == .ssh {
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
VStack(alignment: .leading, spacing: 8) {
LabeledContent("Identity file") {
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
LabeledContent("Project root") {
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
LabeledContent("CLI path") {
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
}
.padding(.top, 4)
} label: {
Text("Advanced")
.font(.callout.weight(.semibold))
}
.padding(.top, 4)
} label: {
Text("Advanced")
.font(.callout.weight(.semibold))
}
// Diagnostics
@@ -219,16 +211,89 @@ struct GeneralSettings: View {
}
}
Text("Tip: enable Tailscale for stable remote access.")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
if self.state.remoteTransport == .ssh {
Text("Tip: enable Tailscale for stable remote access.")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
} else {
Text("Tip: use Tailscale Serve so the gateway has a valid HTTPS cert.")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.transition(.opacity)
.onAppear { self.gatewayDiscovery.start() }
.onDisappear { self.gatewayDiscovery.stop() }
}
private var remoteTransportRow: some View {
HStack(alignment: .center, spacing: 10) {
Text("Transport")
.font(.callout.weight(.semibold))
.frame(width: self.remoteLabelWidth, alignment: .leading)
Picker("Transport", selection: self.$state.remoteTransport) {
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
}
.pickerStyle(.segmented)
.frame(maxWidth: 320)
}
}
private var remoteSshRow: some View {
HStack(alignment: .center, spacing: 10) {
Text("SSH target")
.font(.callout.weight(.semibold))
.frame(width: self.remoteLabelWidth, alignment: .leading)
TextField("user@host[:22]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
Button {
Task { await self.testRemote() }
} label: {
if self.remoteStatus == .checking {
ProgressView().controlSize(.small)
} else {
Text("Test remote")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
private var remoteDirectRow: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .center, spacing: 10) {
Text("Gateway")
.font(.callout.weight(.semibold))
.frame(width: self.remoteLabelWidth, alignment: .leading)
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
.textFieldStyle(.roundedBorder)
.frame(maxWidth: .infinity)
Button {
Task { await self.testRemote() }
} label: {
if self.remoteStatus == .checking {
ProgressView().controlSize(.small)
} else {
Text("Test remote")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || self.state.remoteUrl
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
Text("Direct mode requires a ws:// or wss:// URL (Tailscale Serve uses wss://<magicdns>).")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, self.remoteLabelWidth + 10)
}
}
private var controlStatusLine: String {
switch ControlChannel.shared.state {
case .connected: "Connected"
@@ -458,24 +523,36 @@ extension GeneralSettings {
func testRemote() async {
self.remoteStatus = .checking
let settings = CommandResolver.connectionSettings()
guard !settings.target.isEmpty else {
self.remoteStatus = .failed("Set an SSH target first")
return
if self.state.remoteTransport == .direct {
let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedUrl.isEmpty else {
self.remoteStatus = .failed("Set a gateway URL first")
return
}
guard Self.isValidWsUrl(trimmedUrl) else {
self.remoteStatus = .failed("Gateway URL must start with ws:// or wss://")
return
}
} else {
guard !settings.target.isEmpty else {
self.remoteStatus = .failed("Set an SSH target first")
return
}
// Step 1: basic SSH reachability check
let sshResult = await ShellExecutor.run(
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
cwd: nil,
env: nil,
timeout: 8)
guard sshResult.ok else {
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
return
}
}
// Step 1: basic SSH reachability check
let sshResult = await ShellExecutor.run(
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
cwd: nil,
env: nil,
timeout: 8)
guard sshResult.ok else {
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
return
}
// Step 2: control channel health over tunnel
// Step 2: control channel health check
let originalMode = AppStateStore.shared.connectionMode
do {
try await ControlChannel.shared.configure(mode: .remote(
@@ -502,6 +579,14 @@ extension GeneralSettings {
}
}
private static func isValidWsUrl(_ raw: String) -> Bool {
guard let url = URL(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false }
let scheme = url.scheme?.lowercased() ?? ""
guard scheme == "ws" || scheme == "wss" else { return false }
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return !host.isEmpty
}
private static func sshCheckCommand(target: String, identity: String) -> [String] {
var args: [String] = [
"/usr/bin/ssh",
@@ -570,12 +655,18 @@ extension GeneralSettings {
let host = gateway.tailnetDns ?? gateway.lanHost
guard let host else { return }
let user = NSUserName()
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
user: user,
host: host,
port: gateway.sshPort)
self.state.remoteCliPath = gateway.cliPath ?? ""
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
if self.state.remoteTransport == .direct {
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
self.state.remoteUrl = url
}
} else {
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
user: user,
host: host,
port: gateway.sshPort)
self.state.remoteCliPath = gateway.cliPath ?? ""
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
}
}
}
@@ -598,7 +689,9 @@ extension GeneralSettings {
static func exerciseForTesting() {
let state = AppState(preview: true)
state.connectionMode = .remote
state.remoteTransport = .ssh
state.remoteTarget = "user@host:2222"
state.remoteUrl = "wss://gateway.example.ts.net"
state.remoteIdentity = "/tmp/id_ed25519"
state.remoteProjectRoot = "/tmp/clawdbot"
state.remoteCliPath = "/tmp/clawdbot"
@@ -1,4 +1,5 @@
import AppKit
import Foundation
import Observation
import SwiftUI
@@ -517,11 +518,25 @@ extension MenuSessionsInjector {
switch mode {
case .remote:
platform = "remote"
let target = AppStateStore.shared.remoteTarget
if let parsed = CommandResolver.parseSSHTarget(target) {
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
if AppStateStore.shared.remoteTransport == .direct {
let trimmedUrl = AppStateStore.shared.remoteUrl
.trimmingCharacters(in: .whitespacesAndNewlines)
if let url = URL(string: trimmedUrl), let urlHost = url.host, !urlHost.isEmpty {
if let port = url.port {
host = "\(urlHost):\(port)"
} else {
host = urlHost
}
} else {
host = trimmedUrl.nonEmpty
}
} else {
host = target.nonEmpty
let target = AppStateStore.shared.remoteTarget
if let parsed = CommandResolver.parseSSHTarget(target) {
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
} else {
host = target.nonEmpty
}
}
case .local:
platform = "local"
@@ -25,7 +25,11 @@ extension OnboardingView {
self.preferredGatewayID = gateway.stableID
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
if let host = gateway.tailnetDns ?? gateway.lanHost {
if self.state.remoteTransport == .direct {
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
self.state.remoteUrl = url
}
} else if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
let user = NSUserName()
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
user: user,
@@ -177,42 +177,67 @@ extension OnboardingView {
VStack(alignment: .leading, spacing: 10) {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow {
Text("SSH target")
Text("Transport")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("user@host[:port]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
Picker("Transport", selection: self.$state.remoteTransport) {
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
}
.pickerStyle(.segmented)
.frame(width: fieldWidth)
}
GridRow {
Text("Identity file")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
if self.state.remoteTransport == .direct {
GridRow {
Text("Gateway URL")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
}
GridRow {
Text("Project root")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
GridRow {
Text("CLI path")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField(
"/Applications/Clawdbot.app/.../clawdbot",
text: self.$state.remoteCliPath)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
if self.state.remoteTransport == .ssh {
GridRow {
Text("SSH target")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("user@host[:port]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
GridRow {
Text("Identity file")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
GridRow {
Text("Project root")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
GridRow {
Text("CLI path")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField(
"/Applications/Clawdbot.app/.../clawdbot",
text: self.$state.remoteCliPath)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
}
}
Text("Tip: keep Tailscale enabled so your gateway stays reachable.")
Text(self.state.remoteTransport == .direct
? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert."
: "Tip: keep Tailscale enabled so your gateway stays reachable.")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -225,7 +250,10 @@ extension OnboardingView {
}
func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
if let host = gateway.tailnetDns ?? gateway.lanHost {
if self.state.remoteTransport == .direct {
return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only"
}
if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
return "\(host)\(portSuffix)"
}
@@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2026.1.23</string>
<string>2026.1.24</string>
<key>CFBundleVersion</key>
<string>202601230</string>
<string>202601240</string>
<key>CFBundleIconFile</key>
<string>Clawdbot</string>
<key>CFBundleURLTypes</key>
@@ -964,6 +964,7 @@ public struct SessionsPreviewParams: Codable, Sendable {
public struct SessionsResolveParams: Codable, Sendable {
public let key: String?
public let sessionid: String?
public let label: String?
public let agentid: String?
public let spawnedby: String?
@@ -972,6 +973,7 @@ public struct SessionsResolveParams: Codable, Sendable {
public init(
key: String?,
sessionid: String?,
label: String?,
agentid: String?,
spawnedby: String?,
@@ -979,6 +981,7 @@ public struct SessionsResolveParams: Codable, Sendable {
includeunknown: Bool?
) {
self.key = key
self.sessionid = sessionid
self.label = label
self.agentid = agentid
self.spawnedby = spawnedby
@@ -987,6 +990,7 @@ public struct SessionsResolveParams: Codable, Sendable {
}
private enum CodingKeys: String, CodingKey {
case key
case sessionid = "sessionId"
case label
case agentid = "agentId"
case spawnedby = "spawnedBy"
@@ -11,6 +11,7 @@ import Testing
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.googlechat.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
}
@@ -19,6 +20,7 @@ import Testing
#expect(GatewayAgentChannel(raw: nil) == .last)
#expect(GatewayAgentChannel(raw: " ") == .last)
#expect(GatewayAgentChannel(raw: "WEBCHAT") == .webchat)
#expect(GatewayAgentChannel(raw: "googlechat") == .googlechat)
#expect(GatewayAgentChannel(raw: "BLUEBUBBLES") == .bluebubbles)
#expect(GatewayAgentChannel(raw: "unknown") == .last)
}
@@ -175,4 +175,10 @@ import Testing
customBindHost: "192.168.1.10")
#expect(host == "192.168.1.10")
}
@Test func normalizeGatewayUrlAddsDefaultPortForWs() {
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway")
#expect(url?.port == 18789)
#expect(url?.absoluteString == "ws://gateway:18789")
}
}
@@ -964,6 +964,7 @@ public struct SessionsPreviewParams: Codable, Sendable {
public struct SessionsResolveParams: Codable, Sendable {
public let key: String?
public let sessionid: String?
public let label: String?
public let agentid: String?
public let spawnedby: String?
@@ -972,6 +973,7 @@ public struct SessionsResolveParams: Codable, Sendable {
public init(
key: String?,
sessionid: String?,
label: String?,
agentid: String?,
spawnedby: String?,
@@ -979,6 +981,7 @@ public struct SessionsResolveParams: Codable, Sendable {
includeunknown: Bool?
) {
self.key = key
self.sessionid = sessionid
self.label = label
self.agentid = agentid
self.spawnedby = spawnedby
@@ -987,6 +990,7 @@ public struct SessionsResolveParams: Codable, Sendable {
}
private enum CodingKeys: String, CodingKey {
case key
case sessionid = "sessionId"
case label
case agentid = "agentId"
case spawnedby = "spawnedBy"
-1
View File
@@ -1 +0,0 @@
@import"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Unbounded:wght@400;500;600&family=Work+Sans:wght@400;500;600;700&display=swap";:root{--bg: #0a0f14;--bg-accent: #111826;--bg-grad-1: #162031;--bg-grad-2: #1f2a22;--bg-overlay: rgba(255, 255, 255, .05);--bg-glow: rgba(245, 159, 74, .12);--panel: rgba(14, 20, 30, .88);--panel-strong: rgba(18, 26, 38, .96);--chrome: rgba(9, 14, 20, .72);--chrome-strong: rgba(9, 14, 20, .86);--text: rgba(244, 246, 251, .96);--chat-text: rgba(231, 237, 244, .92);--muted: rgba(156, 169, 189, .72);--border: rgba(255, 255, 255, .09);--border-strong: rgba(255, 255, 255, .16);--accent: #f59f4a;--accent-2: #34c7b7;--ok: #2bd97f;--warn: #f2c94c;--danger: #ff6b6b;--focus: rgba(245, 159, 74, .35);--grid-line: rgba(255, 255, 255, .04);--theme-switch-x: 50%;--theme-switch-y: 50%;--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--font-body: "Work Sans", system-ui, sans-serif;--font-display: "Unbounded", "Times New Roman", serif;color-scheme:dark}:root[data-theme=light]{--bg: #f5f1ea;--bg-accent: #ffffff;--bg-grad-1: #f1e6d6;--bg-grad-2: #e5eef4;--bg-overlay: rgba(28, 32, 46, .05);--bg-glow: rgba(52, 199, 183, .14);--panel: rgba(255, 255, 255, .9);--panel-strong: rgba(255, 255, 255, .97);--chrome: rgba(255, 255, 255, .75);--chrome-strong: rgba(255, 255, 255, .88);--text: rgba(27, 36, 50, .98);--chat-text: rgba(36, 48, 66, .9);--muted: rgba(80, 94, 114, .7);--border: rgba(18, 24, 40, .12);--border-strong: rgba(18, 24, 40, .2);--accent: #e28a3f;--accent-2: #1ba99d;--ok: #1aa86c;--warn: #b3771c;--danger: #d44848;--focus: rgba(226, 138, 63, .35);--grid-line: rgba(18, 24, 40, .06);color-scheme:light}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:15px/1.5 var(--font-body);background:radial-gradient(1200px 900px at 15% -10%,var(--bg-grad-1) 0%,transparent 55%) fixed,radial-gradient(900px 700px at 80% 10%,var(--bg-grad-2) 0%,transparent 60%) fixed,linear-gradient(160deg,var(--bg) 0%,var(--bg-accent) 100%) fixed;color:var(--text)}body:before{content:"";position:fixed;inset:0;background:linear-gradient(140deg,var(--bg-overlay) 0%,rgba(255,255,255,0) 40%),radial-gradient(620px 420px at 75% 75%,var(--bg-glow),transparent 60%);pointer-events:none;z-index:0}@keyframes theme-circle-transition{0%{clip-path:circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}to{clip-path:circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}}html.theme-transition{view-transition-name:theme}html.theme-transition::view-transition-old(theme){mix-blend-mode:normal;animation:none;z-index:1}html.theme-transition::view-transition-new(theme){mix-blend-mode:normal;z-index:2;animation:theme-circle-transition .45s ease-out forwards}@media(prefers-reduced-motion:reduce){html.theme-transition::view-transition-old(theme),html.theme-transition::view-transition-new(theme){animation:none!important}}clawdbot-app{display:block;position:relative;z-index:1;min-height:100vh}a{color:inherit}button,input,textarea,select{font:inherit;color:inherit}@keyframes rise{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}@keyframes dashboard-enter{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.shell{--shell-pad: 16px;--shell-gap: 16px;--shell-nav-width: 220px;--shell-topbar-height: 56px;--shell-focus-duration: .22s;--shell-focus-ease: cubic-bezier(.2, .85, .25, 1);min-height:100vh;display:grid;grid-template-columns:var(--shell-nav-width) minmax(0,1fr);grid-template-rows:var(--shell-topbar-height) 1fr;grid-template-areas:"topbar topbar" "nav content";gap:0;animation:dashboard-enter .6s ease-out;transition:grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease)}.shell--chat{min-height:100vh;height:100vh;overflow:hidden}@supports (height: 100dvh){.shell--chat{height:100dvh}}.shell--nav-collapsed,.shell--chat-focus{grid-template-columns:0px minmax(0,1fr)}.shell--onboarding{grid-template-rows:0 1fr}.shell--onboarding .topbar{display:none}.shell--onboarding .content{padding-top:0}.shell--chat-focus .content{padding-top:0;gap:0}.topbar{grid-area:topbar;position:sticky;top:0;z-index:40;display:flex;justify-content:space-between;align-items:center;gap:16px;padding:0 20px;height:var(--shell-topbar-height);border-bottom:1px solid var(--border);background:var(--panel);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.topbar-left{display:flex;align-items:center;gap:12px}.topbar .nav-collapse-toggle{width:44px;height:44px;margin-bottom:0}.topbar .nav-collapse-toggle__icon{font-size:22px}.brand{display:flex;flex-direction:column;gap:2px}.brand-title{font-family:var(--font-display);font-size:16px;letter-spacing:1px;text-transform:uppercase;font-weight:600;line-height:1.1}.brand-sub{font-size:10px;color:var(--muted);letter-spacing:.8px;text-transform:uppercase;line-height:1}.topbar-status{display:flex;align-items:center;gap:8px}.toLine truncated
+1
View File
@@ -0,0 +1 @@
@import"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Unbounded:wght@400;500;600&family=Work+Sans:wght@400;500;600;700&display=swap";:root{--bg: #0a0f14;--bg-accent: #111826;--bg-grad-1: #162031;--bg-grad-2: #1f2a22;--bg-overlay: rgba(255, 255, 255, .05);--bg-glow: rgba(245, 159, 74, .12);--panel: rgba(14, 20, 30, .88);--panel-strong: rgba(18, 26, 38, .96);--chrome: rgba(9, 14, 20, .72);--chrome-strong: rgba(9, 14, 20, .86);--text: rgba(244, 246, 251, .96);--chat-text: rgba(231, 237, 244, .92);--muted: rgba(156, 169, 189, .72);--border: rgba(255, 255, 255, .09);--border-strong: rgba(255, 255, 255, .16);--accent: #f59f4a;--accent-2: #34c7b7;--ok: #2bd97f;--warn: #f2c94c;--danger: #ff6b6b;--focus: rgba(245, 159, 74, .35);--grid-line: rgba(255, 255, 255, .04);--theme-switch-x: 50%;--theme-switch-y: 50%;--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--font-body: "Work Sans", system-ui, sans-serif;--font-display: "Unbounded", "Times New Roman", serif;color-scheme:dark}:root[data-theme=light]{--bg: #f5f1ea;--bg-accent: #ffffff;--bg-grad-1: #f1e6d6;--bg-grad-2: #e5eef4;--bg-overlay: rgba(28, 32, 46, .05);--bg-glow: rgba(52, 199, 183, .14);--panel: rgba(255, 255, 255, .9);--panel-strong: rgba(255, 255, 255, .97);--chrome: rgba(255, 255, 255, .75);--chrome-strong: rgba(255, 255, 255, .88);--text: rgba(27, 36, 50, .98);--chat-text: rgba(36, 48, 66, .9);--muted: rgba(80, 94, 114, .7);--border: rgba(18, 24, 40, .12);--border-strong: rgba(18, 24, 40, .2);--accent: #e28a3f;--accent-2: #1ba99d;--ok: #1aa86c;--warn: #b3771c;--danger: #d44848;--focus: rgba(226, 138, 63, .35);--grid-line: rgba(18, 24, 40, .06);color-scheme:light}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:15px/1.5 var(--font-body);background:radial-gradient(1200px 900px at 15% -10%,var(--bg-grad-1) 0%,transparent 55%) fixed,radial-gradient(900px 700px at 80% 10%,var(--bg-grad-2) 0%,transparent 60%) fixed,linear-gradient(160deg,var(--bg) 0%,var(--bg-accent) 100%) fixed;color:var(--text)}body:before{content:"";position:fixed;inset:0;background:linear-gradient(140deg,var(--bg-overlay) 0%,rgba(255,255,255,0) 40%),radial-gradient(620px 420px at 75% 75%,var(--bg-glow),transparent 60%);pointer-events:none;z-index:0}@keyframes theme-circle-transition{0%{clip-path:circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}to{clip-path:circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}}html.theme-transition{view-transition-name:theme}html.theme-transition::view-transition-old(theme){mix-blend-mode:normal;animation:none;z-index:1}html.theme-transition::view-transition-new(theme){mix-blend-mode:normal;z-index:2;animation:theme-circle-transition .45s ease-out forwards}@media(prefers-reduced-motion:reduce){html.theme-transition::view-transition-old(theme),html.theme-transition::view-transition-new(theme){animation:none!important}}clawdbot-app{display:block;position:relative;z-index:1;min-height:100vh}a{color:inherit}button,input,textarea,select{font:inherit;color:inherit}@keyframes rise{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}@keyframes dashboard-enter{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.shell{--shell-pad: 16px;--shell-gap: 16px;--shell-nav-width: 220px;--shell-topbar-height: 56px;--shell-focus-duration: .22s;--shell-focus-ease: cubic-bezier(.2, .85, .25, 1);height:100vh;display:grid;grid-template-columns:var(--shell-nav-width) minmax(0,1fr);grid-template-rows:var(--shell-topbar-height) 1fr;grid-template-areas:"topbar topbar" "nav content";gap:0;animation:dashboard-enter .6s ease-out;transition:grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease);overflow:hidden}@supports (height: 100dvh){.shell{height:100dvh}}.shell--chat{min-height:100vh;height:100vh;overflow:hidden}@supports (height: 100dvh){.shell--chat{height:100dvh}}.shell--nav-collapsed,.shell--chat-focus{grid-template-columns:0px minmax(0,1fr)}.shell--onboarding{grid-template-rows:0 1fr}.shell--onboarding .topbar{display:none}.shell--onboarding .content{padding-top:0}.shell--chat-focus .content{padding-top:0;gap:0}.topbar{grid-area:topbar;position:sticky;top:0;z-index:40;display:flex;justify-content:space-between;align-items:center;gap:16px;padding:0 20px;height:var(--shell-topbar-height);border-bottom:1px solid var(--border);background:var(--panel);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.topbar-left{display:flex;align-items:center;gap:12px}.topbar .nav-collapse-toggle{width:44px;height:44px;margin-bottom:0}.topbar .nav-collapse-toggle__icon{font-size:22px}.brand{display:flex;flex-direction:column;gap:2px}.brand-title{font-family:var(--font-display);font-size:16px;letter-spacing:1px;text-transform:uppercase;font-weight:600;line-height:1.1}.brand-sub{font-size:10px;color:var(--muted);letter-spacing:.8px;text-transform:uppercase;line-heightLine truncated
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index-DsXRcnEw.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/icons.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyleSheets\"in Document.prototype&&\"replace\"in CSSStyleSheet.prototype,s=Symbol(),o=new WeakMapLine truncated
+2 -2
View File
@@ -6,8 +6,8 @@
<title>Clawdbot Control</title>
<meta name="color-scheme" content="dark light" />
<link rel="icon" href="./favicon.ico" sizes="any" />
<script type="module" crossorigin src="./assets/index-bYQnHP3a.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BPDeGGxb.css">
<script type="module" crossorigin src="./assets/index-DsXRcnEw.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BvhR9FCb.css">
</head>
<body>
<clawdbot-app></clawdbot-app>
+67 -4
View File
@@ -75,10 +75,10 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
auth: "aws-sdk",
models: [
{
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
name: "Claude 3.7 Sonnet (Bedrock)",
id: "anthropic.claude-opus-4-5-20251101-v1:0",
name: "Claude Opus 4.5 (Bedrock)",
reasoning: true,
input: ["text"],
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192
@@ -89,12 +89,75 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
},
agents: {
defaults: {
model: { primary: "amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0" }
model: { primary: "amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0" }
}
}
}
```
## EC2 Instance Roles
When running Clawdbot on an EC2 instance with an IAM role attached, the AWS SDK
will automatically use the instance metadata service (IMDS) for authentication.
However, Clawdbot's credential detection currently only checks for environment
variables, not IMDS credentials.
**Workaround:** Set `AWS_PROFILE=default` to signal that AWS credentials are
available. The actual authentication still uses the instance role via IMDS.
```bash
# Add to ~/.bashrc or your shell profile
export AWS_PROFILE=default
export AWS_REGION=us-east-1
```
**Required IAM permissions** for the EC2 instance role:
- `bedrock:InvokeModel`
- `bedrock:InvokeModelWithResponseStream`
- `bedrock:ListFoundationModels` (for automatic discovery)
Or attach the managed policy `AmazonBedrockFullAccess`.
**Quick setup:**
```bash
# 1. Create IAM role and instance profile
aws iam create-role --role-name EC2-Bedrock-Access \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "ec2.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}'
aws iam attach-role-policy --role-name EC2-Bedrock-Access \
--policy-arn arn:aws:iam::aws:policy/AmazonBedrockFullAccess
aws iam create-instance-profile --instance-profile-name EC2-Bedrock-Access
aws iam add-role-to-instance-profile \
--instance-profile-name EC2-Bedrock-Access \
--role-name EC2-Bedrock-Access
# 2. Attach to your EC2 instance
aws ec2 associate-iam-instance-profile \
--instance-id i-xxxxx \
--iam-instance-profile Name=EC2-Bedrock-Access
# 3. On the EC2 instance, enable discovery
clawdbot config set models.bedrockDiscovery.enabled true
clawdbot config set models.bedrockDiscovery.region us-east-1
# 4. Set the workaround env vars
echo 'export AWS_PROFILE=default' >> ~/.bashrc
echo 'export AWS_REGION=us-east-1' >> ~/.bashrc
source ~/.bashrc
# 5. Verify models are discovered
clawdbot models list
```
## Notes
- Bedrock requires **model access** enabled in your AWS account/region.
+1
View File
@@ -196,6 +196,7 @@ Provider options:
- `channels.bluebubbles.sendReadReceipts`: Send read receipts (default: `true`).
- `channels.bluebubbles.blockStreaming`: Enable block streaming (default: `true`).
- `channels.bluebubbles.textChunkLimit`: Outbound chunk size in chars (default: 4000).
- `channels.bluebubbles.chunkMode`: `length` (default) splits only when exceeding `textChunkLimit`; `newline` splits on every newline and sends each line immediately during streaming.
- `channels.bluebubbles.mediaMaxMb`: Inbound media cap in MB (default: 8).
- `channels.bluebubbles.historyLimit`: Max group messages for context (0 disables).
- `channels.bluebubbles.dmHistoryLimit`: DM history limit.
+220
View File
@@ -0,0 +1,220 @@
---
summary: "Google Chat app support status, capabilities, and configuration"
read_when:
- Working on Google Chat channel features
---
# Google Chat (Chat API)
Status: ready for DMs + spaces via Google Chat API webhooks (HTTP only).
## Quick setup (beginner)
1) Create a Google Cloud project and enable the **Google Chat API**.
- Go to: [Google Chat API Credentials](https://console.cloud.google.com/apis/api/chat.googleapis.com/credentials)
- Enable the API if it is not already enabled.
2) Create a **Service Account**:
- Press **Create Credentials** > **Service Account**.
- Name it whatever you want (e.g., `clawdbot-chat`).
- Leave permissions blank (press **Continue**).
- Leave principals with access blank (press **Done**).
3) Create and download the **JSON Key**:
- In the list of service accounts, click on the one you just created.
- Go to the **Keys** tab.
- Click **Add Key** > **Create new key**.
- Select **JSON** and press **Create**.
4) Store the downloaded JSON file on your gateway host (e.g., `~/.clawdbot/googlechat-service-account.json`).
5) Create a Google Chat app in the [Google Cloud Console Chat Configuration](https://console.cloud.google.com/apis/api/chat.googleapis.com/hangouts-chat):
- Fill in the **Application info**:
- **App name**: (e.g. `Clawdbot`)
- **Avatar URL**: (e.g. `https://clawd.bot/logo.png`)
- **Description**: (e.g. `Personal AI Assistant`)
- Enable **Interactive features**.
- Under **Functionality**, check **Join spaces and group conversations**.
- Under **Connection settings**, select **HTTP endpoint URL**.
- Under **Triggers**, select **Use a common HTTP endpoint URL for all triggers** and set it to your gateway's public URL followed by `/googlechat`.
- *Tip: Run `clawdbot status` to find your gateway's public URL.*
- Under **Visibility**, check **Make this Chat app available to specific people and groups in <Your Domain>**.
- Enter your email address (e.g. `user@example.com`) in the text box.
- Click **Save** at the bottom.
6) **Enable the app status**:
- After saving, **refresh the page**.
- Look for the **App status** section (usually near the top or bottom after saving).
- Change the status to **Live - available to users**.
- Click **Save** again.
7) Configure Clawdbot with the service account path + webhook audience:
- Env: `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE=/path/to/service-account.json`
- Or config: `channels.googlechat.serviceAccountFile: "/path/to/service-account.json"`.
8) Set the webhook audience type + value (matches your Chat app config).
9) Start the gateway. Google Chat will POST to your webhook path.
## Add to Google Chat
Once the gateway is running and your email is added to the visibility list:
1) Go to [Google Chat](https://chat.google.com/).
2) Click the **+** (plus) icon next to **Direct Messages**.
3) In the search bar (where you usually add people), type the **App name** you configured in the Google Cloud Console.
- **Note**: The bot will *not* appear in the "Marketplace" browse list because it is a private app. You must search for it by name.
4) Select your bot from the results.
5) Click **Add** or **Chat** to start a 1:1 conversation.
6) Send "Hello" to trigger the assistant!
## Public URL (Webhook-only)
Google Chat webhooks require a public HTTPS endpoint. For security, **only expose the `/googlechat` path** to the internet. Keep the Clawdbot dashboard and other sensitive endpoints on your private network.
### Option A: Tailscale Funnel (Recommended)
Use Tailscale Serve for the private dashboard and Funnel for the public webhook path. This keeps `/` private while exposing only `/googlechat`.
1. **Check what address your gateway is bound to:**
```bash
ss -tlnp | grep 18789
```
Note the IP address (e.g., `127.0.0.1`, `0.0.0.0`, or your Tailscale IP like `100.x.x.x`).
2. **Expose the dashboard to the tailnet only (port 8443):**
```bash
# If bound to localhost (127.0.0.1 or 0.0.0.0):
tailscale serve --bg --https 8443 http://127.0.0.1:18789
# If bound to Tailscale IP only (e.g., 100.106.161.80):
tailscale serve --bg --https 8443 http://100.106.161.80:18789
```
3. **Expose only the webhook path publicly:**
```bash
# If bound to localhost (127.0.0.1 or 0.0.0.0):
tailscale funnel --bg --set-path /googlechat http://127.0.0.1:18789/googlechat
# If bound to Tailscale IP only (e.g., 100.106.161.80):
tailscale funnel --bg --set-path /googlechat http://100.106.161.80:18789/googlechat
```
4. **Authorize the node for Funnel access:**
If prompted, visit the authorization URL shown in the output to enable Funnel for this node in your tailnet policy.
5. **Verify the configuration:**
```bash
tailscale serve status
tailscale funnel status
```
Your public webhook URL will be:
`https://<node-name>.<tailnet>.ts.net/googlechat`
Your private dashboard stays tailnet-only:
`https://<node-name>.<tailnet>.ts.net:8443/`
Use the public URL (without `:8443`) in the Google Chat app config.
> Note: This configuration persists across reboots. To remove it later, run `tailscale funnel reset` and `tailscale serve reset`.
### Option B: Reverse Proxy (Caddy)
If you use a reverse proxy like Caddy, only proxy the specific path:
```caddy
your-domain.com {
reverse_proxy /googlechat* localhost:18789
}
```
With this config, any request to `your-domain.com/` will be ignored or returned as 404, while `your-domain.com/googlechat` is safely routed to Clawdbot.
### Option C: Cloudflare Tunnel
Configure your tunnel's ingress rules to only route the webhook path:
- **Path**: `/googlechat` -> `http://localhost:18789/googlechat`
- **Default Rule**: HTTP 404 (Not Found)
## How it works
1. Google Chat sends webhook POSTs to the gateway. Each request includes an `Authorization: Bearer <token>` header.
2. Clawdbot verifies the token against the configured `audienceType` + `audience`:
- `audienceType: "app-url"` → audience is your HTTPS webhook URL.
- `audienceType: "project-number"` → audience is the Cloud project number.
3. Messages are routed by space:
- DMs use session key `agent:<agentId>:googlechat:dm:<spaceId>`.
- Spaces use session key `agent:<agentId>:googlechat:group:<spaceId>`.
4. DM access is pairing by default. Unknown senders receive a pairing code; approve with:
- `clawdbot pairing approve googlechat <code>`
5. Group spaces require @-mention by default. Use `botUser` if mention detection needs the app’s user name.
## Targets
Use these identifiers for delivery and allowlists:
- Direct messages: `users/<userId>` or `users/<email>` (email addresses are accepted).
- Spaces: `spaces/<spaceId>`.
## Config highlights
```json5
{
channels: {
"googlechat": {
enabled: true,
serviceAccountFile: "/path/to/service-account.json",
audienceType: "app-url",
audience: "https://gateway.example.com/googlechat",
webhookPath: "/googlechat",
botUser: "users/1234567890", // optional; helps mention detection
dm: {
policy: "pairing",
allowFrom: ["users/1234567890", "name@example.com"]
},
groupPolicy: "allowlist",
groups: {
"spaces/AAAA": {
allow: true,
requireMention: true,
users: ["users/1234567890"],
systemPrompt: "Short answers only."
}
},
actions: { reactions: true },
typingIndicator: "message",
mediaMaxMb: 20
}
}
}
```
Notes:
- Service account credentials can also be passed inline with `serviceAccount` (JSON string).
- Default webhook path is `/googlechat` if `webhookPath` isn’t set.
- Reactions are available via the `reactions` tool and `channels action` when `actions.reactions` is enabled.
- `typingIndicator` supports `none`, `message` (default), and `reaction` (reaction requires user OAuth).
- Attachments are downloaded through the Chat API and stored in the media pipeline (size capped by `mediaMaxMb`).
## Troubleshooting
### 405 Method Not Allowed
If Google Cloud Logs Explorer shows errors like:
```
status code: 405, reason phrase: HTTP error response: HTTP/1.1 405 Method Not Allowed
```
This means the webhook handler isn't registered. Common causes:
1. **Channel not configured**: The `channels.googlechat` section is missing from your config. Verify with:
```bash
clawdbot config get channels.googlechat
```
If it returns "Config path not found", add the configuration (see [Config highlights](#config-highlights)).
2. **Plugin not enabled**: Check plugin status:
```bash
clawdbot plugins list | grep googlechat
```
If it shows "disabled", add `plugins.entries.googlechat.enabled: true` to your config.
3. **Gateway not restarted**: After adding config, restart the gateway:
```bash
clawdbot gateway restart
```
Verify the channel is running:
```bash
clawdbot channels status
# Should show: Google Chat default: enabled, configured, ...
```
### Other issues
- Check `clawdbot channels status --probe` for auth errors or missing audience config.
- If no messages arrive, confirm the Chat app's webhook URL + event subscriptions.
- If mention gating blocks replies, set `botUser` to the app's user resource name and verify `requireMention`.
- Use `clawdbot logs --follow` while sending a test message to see if requests reach the gateway.
Related docs:
- [Gateway configuration](/gateway/configuration)
- [Security](/gateway/security)
- [Reactions](/tools/reactions)
+1
View File
@@ -15,6 +15,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [Telegram](/channels/telegram) — Bot API via grammY; supports groups.
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
- [Google Chat](/channels/googlechat) — Google Chat API app via HTTP webhook.
- [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs (plugin, installed separately).
- [Signal](/channels/signal) — signal-cli; privacy-focused.
- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe).
+1 -1
View File
@@ -1,7 +1,7 @@
---
summary: "CLI reference for `clawdbot channels` (accounts, status, login/logout, logs)"
read_when:
- You want to add/remove channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/Signal/iMessage)
- You want to add/remove channel accounts (WhatsApp/Telegram/Discord/Google Chat/Slack/Mattermost (plugin)/Signal/iMessage)
- You want to check channel status or tail channel logs
---
+3 -3
View File
@@ -355,7 +355,7 @@ Options:
## Channel helpers
### `channels`
Manage chat channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams).
Manage chat channel accounts (WhatsApp/Telegram/Discord/Google Chat/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams).
Subcommands:
- `channels list`: show configured channels and auth profiles (Claude Code + Codex CLI OAuth sync included).
@@ -368,7 +368,7 @@ Subcommands:
- `channels logout`: log out of a channel session (if supported).
Common options:
- `--channel <name>`: `whatsapp|telegram|discord|slack|mattermost|signal|imessage|msteams`
- `--channel <name>`: `whatsapp|telegram|discord|googlechat|slack|mattermost|signal|imessage|msteams`
- `--account <id>`: channel account id (default `default`)
- `--name <label>`: display name for the account
@@ -666,7 +666,7 @@ Subcommands:
Common RPCs:
- `config.apply` (validate + write config + restart + wake)
- `config.patch` (merge a partial update without clobbering unrelated keys)
- `config.patch` (merge a partial update + restart + wake)
- `update.run` (run update + restart + wake)
Tip: when calling `config.set`/`config.apply`/`config.patch` directly, pass `baseHash` from
+6 -5
View File
@@ -8,7 +8,7 @@ read_when:
# `clawdbot message`
Single outbound command for sending messages and channel actions
(Discord/Slack/Mattermost (plugin)/Telegram/WhatsApp/Signal/iMessage/MS Teams).
(Discord/Google Chat/Slack/Mattermost (plugin)/Telegram/WhatsApp/Signal/iMessage/MS Teams).
## Usage
@@ -19,12 +19,13 @@ clawdbot message <subcommand> [flags]
Channel selection:
- `--channel` required if more than one channel is configured.
- If exactly one channel is configured, it becomes the default.
- Values: `whatsapp|telegram|discord|slack|mattermost|signal|imessage|msteams` (Mattermost requires plugin)
- Values: `whatsapp|telegram|discord|googlechat|slack|mattermost|signal|imessage|msteams` (Mattermost requires plugin)
Target formats (`--target`):
- WhatsApp: E.164 or group JID
- Telegram: chat id or `@username`
- Discord: `channel:<id>` or `user:<id>` (or `<@id>` mention; raw numeric ids are treated as channels)
- Google Chat: `spaces/<spaceId>` or `users/<userId>`
- Slack: `channel:<id>` or `user:<id>` (raw channel id is accepted)
- Mattermost (plugin): `channel:<id>`, `user:<id>`, or `@username` (bare ids are treated as channels)
- Signal: `+E.164`, `group:<id>`, `signal:+E.164`, `signal:group:<id>`, or `username:<name>`/`u:<name>`
@@ -50,7 +51,7 @@ Name lookup:
### Core
- `send`
- Channels: WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams
- Channels: WhatsApp/Telegram/Discord/Google Chat/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams
- Required: `--target`, plus `--message` or `--media`
- Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback`
- Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it)
@@ -65,14 +66,14 @@ Name lookup:
- Discord only: `--poll-duration-hours`, `--message`
- `react`
- Channels: Discord/Slack/Telegram/WhatsApp
- Channels: Discord/Google Chat/Slack/Telegram/WhatsApp
- Required: `--message-id`, `--target`
- Optional: `--emoji`, `--remove`, `--participant`, `--from-me`
- Note: `--remove` requires `--emoji` (omit `--emoji` to clear own reactions where supported; see /tools/reactions)
- WhatsApp only: `--participant`, `--from-me`
- `reactions`
- Channels: Discord/Slack
- Channels: Discord/Google Chat/Slack
- Required: `--message-id`, `--target`
- Optional: `--limit`
+1 -1
View File
@@ -17,7 +17,7 @@ clawdbot status --usage
```
Notes:
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Google Chat + Slack + Signal).
- Output includes per-agent session stores when multiple agents are configured.
- Overview includes Gateway + node host service install/runtime status when available.
- Overview includes update channel + git SHA (for source checkouts).
+34
View File
@@ -236,6 +236,30 @@ MiniMax is configured via `models.providers` because it uses custom endpoints:
See [/providers/minimax](/providers/minimax) for setup details, model options, and config snippets.
### Ollama
Ollama is a local LLM runtime that provides an OpenAI-compatible API:
- Provider: `ollama`
- Auth: None required (local server)
- Example model: `ollama/llama3.3`
- Installation: https://ollama.ai
```bash
# Install Ollama, then pull a model:
ollama pull llama3.3
```
```json5
{
agents: {
defaults: { model: { primary: "ollama/llama3.3" } }
}
}
```
Ollama is automatically detected when running locally at `http://127.0.0.1:11434/v1`. See [/providers/ollama](/providers/ollama) for model recommendations and custom configuration.
### Local proxies (LM Studio, vLLM, LiteLLM, etc.)
Example (OpenAI‑compatible):
@@ -271,6 +295,16 @@ Example (OpenAI‑compatible):
}
```
Notes:
- For custom providers, `reasoning`, `input`, `cost`, `contextWindow`, and `maxTokens` are optional.
When omitted, Clawdbot defaults to:
- `reasoning: false`
- `input: ["text"]`
- `cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }`
- `contextWindow: 200000`
- `maxTokens: 8192`
- Recommended: set explicit values that match your proxy/model limits.
## CLI examples
```bash
+18
View File
@@ -149,6 +149,14 @@
"source": "/providers/discord/",
"destination": "/channels/discord"
},
{
"source": "/providers/googlechat",
"destination": "/channels/googlechat"
},
{
"source": "/providers/googlechat/",
"destination": "/channels/googlechat"
},
{
"source": "/providers/grammy",
"destination": "/channels/grammy"
@@ -772,6 +780,14 @@
{
"source": "/plugins",
"destination": "/plugin"
},
{
"source": "/install/railway",
"destination": "/railway"
},
{
"source": "/install/railway/",
"destination": "/railway"
}
],
"navigation": {
@@ -810,6 +826,7 @@
"install/ansible",
"install/nix",
"install/docker",
"railway",
"install/bun"
]
},
@@ -943,6 +960,7 @@
"channels/grammy",
"channels/discord",
"channels/slack",
"channels/googlechat",
"channels/mattermost",
"channels/signal",
"channels/imessage",
+74 -11
View File
@@ -50,6 +50,7 @@ Params:
- `raw` (string) — JSON5 payload for the entire config
- `baseHash` (optional) — config hash from `config.get` (required when a config already exists)
- `sessionKey` (optional) — last active session key for the wake-up ping
- `note` (optional) — note to include in the restart sentinel
- `restartDelayMs` (optional) — delay before restart (default 2000)
Example (via `gateway call`):
@@ -71,10 +72,15 @@ unrelated keys. It applies JSON merge patch semantics:
- objects merge recursively
- `null` deletes a key
- arrays replace
Like `config.apply`, it validates, writes the config, stores a restart sentinel, and schedules
the Gateway restart (with an optional wake when `sessionKey` is provided).
Params:
- `raw` (string) — JSON5 payload containing just the keys to change
- `baseHash` (required) — config hash from `config.get`
- `sessionKey` (optional) — last active session key for the wake-up ping
- `note` (optional) — note to include in the restart sentinel
- `restartDelayMs` (optional) — delay before restart (default 2000)
Example:
@@ -82,7 +88,9 @@ Example:
clawdbot gateway call config.get --params '{}' # capture payload.hash
clawdbot gateway call config.patch --params '{
"raw": "{\\n channels: { telegram: { groups: { \\"*\\": { requireMention: false } } } }\\n}\\n",
"baseHash": "<hash-from-config.get>"
"baseHash": "<hash-from-config.get>",
"sessionKey": "agent:main:whatsapp:dm:+15555550123",
"restartDelayMs": 1000
}'
```
@@ -399,7 +407,7 @@ Optional per-agent identity used for defaults and UX. This is written by the mac
If set, Clawdbot derives defaults (only when you haven’t set them explicitly):
- `messages.ackReaction` from the **active agent**’s `identity.emoji` (falls back to 👀)
- `agents.list[].groupChat.mentionPatterns` from the agent’s `identity.name`/`identity.emoji` (so “@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp)
- `agents.list[].groupChat.mentionPatterns` from the agent’s `identity.name`/`identity.emoji` (so “@Samantha” works in groups across Telegram/Slack/Discord/Google Chat/iMessage/WhatsApp)
- `identity.avatar` accepts a workspace-relative image path or a remote URL/data URL. Local files must live inside the agent workspace.
`identity.avatar` accepts:
@@ -543,7 +551,7 @@ Notes:
- Outbound commands default to account `default` if present; otherwise the first configured account id (sorted).
- The legacy single-account Baileys auth dir is migrated by `clawdbot doctor` into `whatsapp/default`.
### `channels.telegram.accounts` / `channels.discord.accounts` / `channels.slack.accounts` / `channels.mattermost.accounts` / `channels.signal.accounts` / `channels.imessage.accounts`
### `channels.telegram.accounts` / `channels.discord.accounts` / `channels.googlechat.accounts` / `channels.slack.accounts` / `channels.mattermost.accounts` / `channels.signal.accounts` / `channels.imessage.accounts`
Run multiple accounts per channel (each account has its own `accountId` and optional `name`):
@@ -574,7 +582,7 @@ Notes:
### Group chat mention gating (`agents.list[].groupChat` + `messages.groupChat`)
Group messages default to **require mention** (either metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, and iMessage group chats.
Group messages default to **require mention** (either metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, Google Chat, and iMessage group chats.
**Mention types:**
- **Metadata mentions**: Native platform @-mentions (e.g., WhatsApp tap-to-mention). Ignored in WhatsApp self-chat mode (see `channels.whatsapp.allowFrom`).
@@ -1120,6 +1128,44 @@ Reaction notification modes:
Outbound text is chunked by `channels.discord.textChunkLimit` (default 2000). Discord clients can clip very tall messages, so `channels.discord.maxLinesPerMessage` (default 17) splits long multi-line replies even when under 2000 chars.
Retry policy defaults and behavior are documented in [Retry policy](/concepts/retry).
### `channels.googlechat` (Chat API webhook)
Google Chat runs over HTTP webhooks with app-level auth (service account).
Multi-account support lives under `channels.googlechat.accounts` (see the multi-account section above). Env vars only apply to the default account.
```json5
{
channels: {
"googlechat": {
enabled: true,
serviceAccountFile: "/path/to/service-account.json",
audienceType: "app-url", // app-url | project-number
audience: "https://gateway.example.com/googlechat",
webhookPath: "/googlechat",
botUser: "users/1234567890", // optional; improves mention detection
dm: {
enabled: true,
policy: "pairing", // pairing | allowlist | open | disabled
allowFrom: ["users/1234567890"] // optional; "open" requires ["*"]
},
groupPolicy: "allowlist",
groups: {
"spaces/AAAA": { allow: true, requireMention: true }
},
actions: { reactions: true },
typingIndicator: "message",
mediaMaxMb: 20
}
}
}
```
Notes:
- Service account JSON can be inline (`serviceAccount`) or file-based (`serviceAccountFile`).
- Env fallbacks for the default account: `GOOGLE_CHAT_SERVICE_ACCOUNT` or `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE`.
- `audienceType` + `audience` must match the Chat app’s webhook auth config.
- Use `spaces/<spaceId>` or `users/<userId|email>` when setting delivery targets.
### `channels.slack` (socket mode)
Slack runs in Socket Mode and requires both a bot token and app token:
@@ -1434,7 +1480,7 @@ WhatsApp inbound prefix is configured via `channels.whatsapp.messagePrefix` (dep
agent has `identity.name` set.
`ackReaction` sends a best-effort emoji reaction to acknowledge inbound messages
on channels that support reactions (Slack/Discord/Telegram). Defaults to the
on channels that support reactions (Slack/Discord/Telegram/Google Chat). Defaults to the
active agent’s `identity.emoji` when set, otherwise `"👀"`. Set it to `""` to disable.
`ackReactionScope` controls when reactions fire:
@@ -1444,7 +1490,7 @@ active agent’s `identity.emoji` when set, otherwise `"👀"`. Set it to `""` t
- `all`: all messages
`removeAckAfterReply` removes the bot’s ack reaction after a reply is sent
(Slack/Discord/Telegram only). Default: `false`.
(Slack/Discord/Telegram/Google Chat only). Default: `false`.
#### `messages.tts`
@@ -1829,11 +1875,12 @@ Block streaming:
```
- `agents.defaults.blockStreamingCoalesce`: merge streamed blocks before sending.
Defaults to `{ idleMs: 1000 }` and inherits `minChars` from `blockStreamingChunk`
with `maxChars` capped to the channel text limit. Signal/Slack/Discord default
with `maxChars` capped to the channel text limit. Signal/Slack/Discord/Google Chat default
to `minChars: 1500` unless overridden.
Channel overrides: `channels.whatsapp.blockStreamingCoalesce`, `channels.telegram.blockStreamingCoalesce`,
`channels.discord.blockStreamingCoalesce`, `channels.slack.blockStreamingCoalesce`, `channels.mattermost.blockStreamingCoalesce`,
`channels.signal.blockStreamingCoalesce`, `channels.imessage.blockStreamingCoalesce`, `channels.msteams.blockStreamingCoalesce`
`channels.signal.blockStreamingCoalesce`, `channels.imessage.blockStreamingCoalesce`, `channels.msteams.blockStreamingCoalesce`,
`channels.googlechat.blockStreamingCoalesce`
(and per-account variants).
- `agents.defaults.humanDelay`: randomized pause between **block replies** after the first.
Modes: `off` (default), `natural` (800–2500ms), `custom` (use `minMs`/`maxMs`).
@@ -2825,13 +2872,14 @@ Auth and Tailscale:
Remote client defaults (CLI):
- `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`.
- `gateway.remote.transport` selects the macOS remote transport (`ssh` default, `direct` for ws/wss). When `direct`, `gateway.remote.url` must be `ws://` or `wss://`. `ws://host` defaults to port `18789`.
- `gateway.remote.token` supplies the token for remote calls (leave unset for no auth).
- `gateway.remote.password` supplies the password for remote calls (leave unset for no auth).
macOS app behavior:
- Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
- If `gateway.mode` is unset but `gateway.remote.url` is set, the macOS app treats it as remote mode.
- When you change connection mode in the macOS app, it writes `gateway.mode` (and `gateway.remote.url` in remote mode) back to the config file.
- When you change connection mode in the macOS app, it writes `gateway.mode` (and `gateway.remote.url` + `gateway.remote.transport` in remote mode) back to the config file.
```json5
{
@@ -2846,6 +2894,21 @@ macOS app behavior:
}
```
Direct transport example (macOS app):
```json5
{
gateway: {
mode: "remote",
remote: {
transport: "direct",
url: "wss://gateway.example.ts.net",
token: "your-token"
}
}
}
```
### `gateway.reload` (Config hot reload)
The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically.
@@ -2964,7 +3027,7 @@ Mapping notes:
- Templates like `{{messages[0].subject}}` read from the payload.
- `transform` can point to a JS/TS module that returns a hook action.
- `deliver: true` sends the final reply to a channel; `channel` defaults to `last` (falls back to WhatsApp).
- 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/Google Chat/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).
Gmail helper config (used by `clawdbot webhooks gmail setup` / `run`):
@@ -3140,7 +3203,7 @@ Template placeholders are expanded in `tools.media.*.models[].args` and `tools.m
| `{{GroupMembers}}` | Group members preview (best effort) |
| `{{SenderName}}` | Sender display name (best effort) |
| `{{SenderE164}}` | Sender phone number (best effort) |
| `{{Provider}}` | Provider hint (whatsapp|telegram|discord|slack|signal|imessage|msteams|webchat|…) |
| `{{Provider}}` | Provider hint (whatsapp|telegram|discord|googlechat|slack|signal|imessage|msteams|webchat|…) |
## Cron (Gateway scheduler)
+2 -2
View File
@@ -82,7 +82,7 @@ and logged; a message that is only `HEARTBEAT_OK` is dropped.
every: "30m", // default: 30m (0m disables)
model: "anthropic/claude-opus-4-5",
includeReasoning: false, // default: false (deliver separate Reasoning: message when available)
target: "last", // last | whatsapp | telegram | discord | slack | signal | imessage | none
target: "last", // last | none | <channel id> (core or plugin, e.g. "bluebubbles")
to: "+15551234567", // optional channel-specific override
prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
ackMaxChars: 300 // max chars allowed after HEARTBEAT_OK
@@ -144,7 +144,7 @@ Example: two agents, only the second agent runs heartbeats.
- Session key formats: see [Sessions](/concepts/session) and [Groups](/concepts/groups).
- `target`:
- `last` (default): deliver to the last used external channel.
- explicit channel: `whatsapp` / `telegram` / `discord` / `slack` / `msteams` / `signal` / `imessage`.
- explicit channel: `whatsapp` / `telegram` / `discord` / `googlechat` / `slack` / `msteams` / `signal` / `imessage`.
- `none`: run the heartbeat but **do not deliver** externally.
- `to`: optional recipient override (channel-specific id, e.g. E.164 for WhatsApp or a Telegram chat id).
- `prompt`: overrides the default prompt body (not merged).
+18
View File
@@ -31,6 +31,24 @@ See also: [Health checks](/gateway/health) and [Logging](/logging).
## Common Issues
### No API key found for provider "anthropic"
This means the **agent’s auth store is empty** or missing Anthropic credentials.
Auth is **per agent**, so a new agent won’t inherit the main agent’s keys.
Fix options:
- Re-run onboarding and choose **Anthropic** for that agent.
- Or paste a setup-token on the **gateway host**:
```bash
clawdbot models auth setup-token --provider anthropic
```
- Or copy `auth-profiles.json` from the main agent dir to the new agent dir.
Verify:
```bash
clawdbot models status
```
### OAuth token refresh failed (Anthropic Claude subscription)
This means the stored Anthropic OAuth token expired and the refresh failed.
+454 -2
View File
@@ -9,24 +9,45 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [What is Clawdbot?](#what-is-clawdbot)
- [What is Clawdbot, in one paragraph?](#what-is-clawdbot-in-one-paragraph)
- [What’s the value proposition?](#whats-the-value-proposition)
- [Quick start and first-run setup](#quick-start-and-first-run-setup)
- [What’s the recommended way to install and set up Clawdbot?](#whats-the-recommended-way-to-install-and-set-up-clawdbot)
- [How do I open the dashboard after onboarding?](#how-do-i-open-the-dashboard-after-onboarding)
- [How do I authenticate the dashboard (token) on localhost vs remote?](#how-do-i-authenticate-the-dashboard-token-on-localhost-vs-remote)
- [What runtime do I need?](#what-runtime-do-i-need)
- [Does it run on Raspberry Pi?](#does-it-run-on-raspberry-pi)
- [Can I migrate my setup to a new machine (Mac mini) without redoing onboarding?](#can-i-migrate-my-setup-to-a-new-machine-mac-mini-without-redoing-onboarding)
- [Where do I see what’s new in the latest version?](#where-do-i-see-whats-new-in-the-latest-version)
- [I can't access docs.clawd.bot (SSL error). What now?](#i-cant-access-docsclawdbot-ssl-error-what-now)
- [What’s the difference between stable and beta?](#whats-the-difference-between-stable-and-beta)
- [How do I install the beta version, and what’s the difference between beta and dev?](#how-do-i-install-the-beta-version-and-whats-the-difference-between-beta-and-dev)
- [How do I try the latest bits?](#how-do-i-try-the-latest-bits)
- [Installer stuck? How do I get more feedback?](#installer-stuck-how-do-i-get-more-feedback)
- [The docs didn’t answer my question — how do I get a better answer?](#the-docs-didnt-answer-my-question--how-do-i-get-a-better-answer)
- [How do I install Clawdbot on Linux?](#how-do-i-install-clawdbot-on-linux)
- [How do I install Clawdbot on a VPS?](#how-do-i-install-clawdbot-on-a-vps)
- [Where are the cloud/VPS install guides?](#where-are-the-cloudvps-install-guides)
- [Can I ask Clawd to update itself?](#can-i-ask-clawd-to-update-itself)
- [What does the onboarding wizard actually do?](#what-does-the-onboarding-wizard-actually-do)
- [Do I need a Claude or OpenAI subscription to run this?](#do-i-need-a-claude-or-openai-subscription-to-run-this)
- [How does Anthropic "setup-token" auth work?](#how-does-anthropic-setup-token-auth-work)
- [Where do I find an Anthropic setup-token?](#where-do-i-find-an-anthropic-setup-token)
- [Do you support Claude subscription auth (Claude Code OAuth)?](#do-you-support-claude-subscription-auth-claude-code-oauth)
- [Why am I seeing `HTTP 429: rate_limit_error` from Anthropic?](#why-am-i-seeing-http-429-rate_limit_error-from-anthropic)
- [Is AWS Bedrock supported?](#is-aws-bedrock-supported)
- [How does Codex auth work?](#how-does-codex-auth-work)
- [Do you support OpenAI subscription auth (Codex OAuth)?](#do-you-support-openai-subscription-auth-codex-oauth)
- [Is a local model OK for casual chats?](#is-a-local-model-ok-for-casual-chats)
- [How do I keep hosted model traffic in a specific region?](#how-do-i-keep-hosted-model-traffic-in-a-specific-region)
- [Do I have to buy a Mac Mini to install this?](#do-i-have-to-buy-a-mac-mini-to-install-this)
- [Do I need a Mac mini for iMessage support?](#do-i-need-a-mac-mini-for-imessage-support)
- [If I buy a Mac mini to run Clawdbot, can I connect it to my MacBook Pro?](#if-i-buy-a-mac-mini-to-run-clawdbot-can-i-connect-it-to-my-macbook-pro)
- [Can I use Bun?](#can-i-use-bun)
- [Telegram: what goes in `allowFrom`?](#telegram-what-goes-in-allowfrom)
- [Can multiple people use one WhatsApp number with different Clawdbots?](#can-multiple-people-use-one-whatsapp-number-with-different-clawdbots)
- [Can I run a "fast chat" agent and an "Opus for coding" agent?](#can-i-run-a-fast-chat-agent-and-an-opus-for-coding-agent)
- [Does Homebrew work on Linux?](#does-homebrew-work-on-linux)
- [What’s the difference between the hackable (git) install and npm install?](#whats-the-difference-between-the-hackable-git-install-and-npm-install)
- [Can I switch between npm and git installs later?](#can-i-switch-between-npm-and-git-installs-later)
- [Should I run the Gateway on my laptop or a VPS?](#should-i-run-the-gateway-on-my-laptop-or-a-vps)
- [Skills and automation](#skills-and-automation)
@@ -34,6 +55,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Can I load skills from a custom folder?](#can-i-load-skills-from-a-custom-folder)
- [How can I use different models for different tasks?](#how-can-i-use-different-models-for-different-tasks)
- [How do I install skills on Linux?](#how-do-i-install-skills-on-linux)
- [Can Clawdbot run tasks on a schedule or continuously in the background?](#can-clawdbot-run-tasks-on-a-schedule-or-continuously-in-the-background)
- [Can I run Apple/macOS-only skills from Linux?](#can-i-run-applemacos-only-skills-from-linux)
- [Do you have a Notion or HeyGen integration?](#do-you-have-a-notion-or-heygen-integration)
- [How do I install the Chrome extension for browser takeover?](#how-do-i-install-the-chrome-extension-for-browser-takeover)
@@ -45,6 +67,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Where things live on disk](#where-things-live-on-disk)
- [Where does Clawdbot store its data?](#where-does-clawdbot-store-its-data)
- [Where should AGENTS.md / SOUL.md / USER.md / MEMORY.md live?](#where-should-agentsmd--soulmd--usermd--memorymd-live)
- [What’s the recommended backup strategy?](#whats-the-recommended-backup-strategy)
- [How do I completely uninstall Clawdbot?](#how-do-i-completely-uninstall-clawdbot)
- [Can agents work outside the workspace?](#can-agents-work-outside-the-workspace)
- [I’m in remote mode — where is the session store?](#im-in-remote-mode-where-is-the-session-store)
@@ -59,6 +82,8 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [How do I use Brave for browser control?](#how-do-i-use-brave-for-browser-control)
- [Remote gateways + nodes](#remote-gateways-nodes)
- [How do commands propagate between Telegram, the gateway, and nodes?](#how-do-commands-propagate-between-telegram-the-gateway-and-nodes)
- [How can my agent access my computer if the Gateway is hosted remotely?](#how-can-my-agent-access-my-computer-if-the-gateway-is-hosted-remotely)
- [Is there a benefit to using a node on my personal laptop instead of SSH from a VPS?](#is-there-a-benefit-to-using-a-node-on-my-personal-laptop-instead-of-ssh-from-a-vps)
- [Do nodes run a gateway service?](#do-nodes-run-a-gateway-service)
- [Is there an API / RPC way to apply config?](#is-there-an-api-rpc-way-to-apply-config)
- [What’s a minimal “sane” config for a first install?](#whats-a-minimal-sane-config-for-a-first-install)
@@ -76,11 +101,15 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Why am I seeing “LLM request rejected: messages.N.content.X.tool_use.input: Field required”?](#why-am-i-seeing-llm-request-rejected-messagesncontentxtool_useinput-field-required)
- [Why am I getting heartbeat messages every 30 minutes?](#why-am-i-getting-heartbeat-messages-every-30-minutes)
- [Do I need to add a “bot account” to a WhatsApp group?](#do-i-need-to-add-a-bot-account-to-a-whatsapp-group)
- [How do I get the JID of a WhatsApp group?](#how-do-i-get-the-jid-of-a-whatsapp-group)
- [Why doesn’t Clawdbot reply in a group?](#why-doesnt-clawdbot-reply-in-a-group)
- [Do groups/threads share context with DMs?](#do-groupsthreads-share-context-with-dms)
- [How many workspaces and agents can I create?](#how-many-workspaces-and-agents-can-i-create)
- [Can I run multiple bots or chats at the same time (Slack), and how should I set that up?](#can-i-run-multiple-bots-or-chats-at-the-same-time-slack-and-how-should-i-set-that-up)
- [Models: defaults, selection, aliases, switching](#models-defaults-selection-aliases-switching)
- [What is the “default model”?](#what-is-the-default-model)
- [What model do you recommend?](#what-model-do-you-recommend)
- [What do Clawd, Flawd, and Krill use for models?](#what-do-clawd-flawd-and-krill-use-for-models)
- [How do I switch models on the fly (without restarting)?](#how-do-i-switch-models-on-the-fly-without-restarting)
- [Why do I see “Model … is not allowed” and then no reply?](#why-do-i-see-model-is-not-allowed-and-then-no-reply)
- [Why do I see “Unknown model: minimax/MiniMax-M2.1”?](#why-do-i-see-unknown-model-minimaxminimax-m21)
@@ -111,6 +140,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Logging and debugging](#logging-and-debugging)
- [Where are logs?](#where-are-logs)
- [How do I start/stop/restart the Gateway service?](#how-do-i-startstoprestart-the-gateway-service)
- [How do I completely stop then start the Gateway?](#how-do-i-completely-stop-then-start-the-gateway)
- [ELI5: `clawdbot gateway restart` vs `clawdbot gateway`](#eli5-clawdbot-gateway-restart-vs-clawdbot-gateway)
- [What’s the fastest way to get more details when something fails?](#whats-the-fastest-way-to-get-more-details-when-something-fails)
- [Media & attachments](#media-attachments)
@@ -179,7 +209,29 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
### What is Clawdbot, in one paragraph?
Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Mattermost (plugin), Discord, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the always-on control plane; the assistant is the product.
Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Mattermost (plugin), Discord, Google Chat, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the always-on control plane; the assistant is the product.
### What’s the value proposition?
Clawdbot is not “just a Claude wrapper.” It’s a **local-first control plane** that lets you run a
capable assistant on **your own hardware**, reachable from the chat apps you already use, with
stateful sessions, memory, and tools — without handing control of your workflows to a hosted
SaaS.
Highlights:
- **Your devices, your data:** run the Gateway wherever you want (Mac, Linux, VPS) and keep the
workspace + session history local.
- **Real channels, not a web sandbox:** WhatsApp/Telegram/Slack/Discord/Signal/iMessage/etc,
plus mobile voice and Canvas on supported platforms.
- **Model-agnostic:** use Anthropic, OpenAI, MiniMax, OpenRouter, etc., with per‑agent routing
and failover.
- **Local-only option:** run local models so **all data can stay on your device** if you want.
- **Multi-agent routing:** separate agents per channel, account, or task, each with its own
workspace and defaults.
- **Open source and hackable:** inspect, extend, and self-host without vendor lock‑in.
Docs: [Gateway](/gateway), [Channels](/channels), [Multi‑agent](/concepts/multi-agent),
[Memory](/concepts/memory).
## Quick start and first-run setup
@@ -229,6 +281,209 @@ See [Dashboard](/web/dashboard) and [Web surfaces](/web) for bind modes and auth
Node **>= 22** is required. `pnpm` is recommended. Bun is **not recommended** for the Gateway.
### Does it run on Raspberry Pi?
Yes. The Gateway is lightweight — docs list **512MB–1GB RAM**, **1 core**, and about **500MB**
disk as enough for personal use, and note that a **Raspberry Pi 4 can run it**.
If you want extra headroom (logs, media, other services), **2GB is recommended**, but it’s
not a hard minimum.
### Can I migrate my setup to a new machine (Mac mini) without redoing onboarding?
Yes. Copy the **state directory** and **workspace**, then run Doctor once. This
keeps your bot “exactly the same” (memory, session history, auth, and channel
state) as long as you copy **both** locations:
1) Install Clawdbot on the new machine.
2) Copy `$CLAWDBOT_STATE_DIR` (default: `~/.clawdbot`) from the old machine.
3) Copy your workspace (default: `~/clawd`).
4) Run `clawdbot doctor` and restart the Gateway service.
That preserves config, auth profiles, WhatsApp creds, sessions, and memory. If you’re in
remote mode, remember the gateway host owns the session store and workspace.
**Important:** if you only commit/push your workspace to GitHub, you’re backing
up **memory + bootstrap files**, but **not** session history or auth. Those live
under `~/.clawdbot/` (for example `~/.clawdbot/agents/<agentId>/sessions/`).
Related: [Where things live on disk](/help/faq#where-does-clawdbot-store-its-data),
[Agent workspace](/concepts/agent-workspace), [Doctor](/gateway/doctor),
[Remote mode](/gateway/remote).
### Where do I see what’s new in the latest version?
Check the GitHub changelog:
https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md
Newest entries are at the top. If the top section is marked **Unreleased**, the next dated
section is the latest shipped version. Entries are grouped by **Highlights**, **Changes**, and
**Fixes** (plus docs/other sections when needed).
### I can't access docs.clawd.bot (SSL error). What now?
Some Comcast/Xfinity connections incorrectly block `docs.clawd.bot` via Xfinity
Advanced Security. Disable it or allowlist `docs.clawd.bot`, then retry. More
detail: [Troubleshooting](/help/troubleshooting#docsclawdbot-shows-an-ssl-error-comcastxfinity).
Please help us unblock it by reporting here: https://spa.xfinity.com/check_url_status.
If you still can't reach the site, the docs are mirrored on GitHub:
https://github.com/clawdbot/clawdbot/tree/main/docs
### What’s the difference between stable and beta?
**Stable** and **beta** are **npm dist‑tags**, not separate code lines:
- `latest` = stable
- `beta` = early build for testing
We ship builds to **beta**, test them, and once a build is solid we **promote
that same version to `latest`**. That’s why beta and stable can point at the
**same version**.
See what changed:
https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md
### How do I install the beta version, and what’s the difference between beta and dev?
**Beta** is the npm dist‑tag `beta` (may match `latest`).
**Dev** is the moving head of `main` (git); when published, it uses the npm dist‑tag `dev`.
One‑liners (macOS/Linux):
```bash
curl -fsSL --proto '=https' --tlsv1.2 https://clawd.bot/install.sh | bash -s -- --beta
```
```bash
curl -fsSL --proto '=https' --tlsv1.2 https://clawd.bot/install.sh | bash -s -- --install-method git
```
Windows installer (PowerShell):
https://clawd.bot/install.ps1
More detail: [Development channels](/install/development-channels) and [Installer flags](/install/installer).
### How do I try the latest bits?
Two options:
1) **Dev channel (git checkout):**
```bash
clawdbot update --channel dev
```
This switches to the `main` branch and updates from source.
2) **Hackable install (from the installer site):**
```bash
curl -fsSL https://clawd.bot/install.sh | bash -s -- --install-method git
```
That gives you a local repo you can edit, then update via git.
If you prefer a clean clone manually, use:
```bash
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
pnpm install
pnpm build
```
Docs: [Update](/cli/update), [Development channels](/install/development-channels),
[Install](/install).
### Installer stuck? How do I get more feedback?
Re-run the installer with **verbose output**:
```bash
curl -fsSL https://clawd.bot/install.sh | bash -s -- --verbose
```
Beta install with verbose:
```bash
curl -fsSL https://clawd.bot/install.sh | bash -s -- --beta --verbose
```
For a hackable (git) install:
```bash
curl -fsSL https://clawd.bot/install.sh | bash -s -- --install-method git --verbose
```
More options: [Installer flags](/install/installer).
### The docs didn’t answer my question — how do I get a better answer?
Use the **hackable (git) install** so you have the full source and docs locally, then ask
your bot (or Claude/Codex) *from that folder* so it can read the repo and answer precisely.
```bash
curl -fsSL https://clawd.bot/install.sh | bash -s -- --install-method git
```
More detail: [Install](/install) and [Installer flags](/install/installer).
### How do I install Clawdbot on Linux?
Short answer: follow the Linux guide, then run the onboarding wizard.
- Linux quick path + service install: [Linux](/platforms/linux).
- Full walkthrough: [Getting Started](/start/getting-started).
- Installer + updates: [Install & updates](/install/updating).
### How do I install Clawdbot on a VPS?
Any Linux VPS works. Install on the server, then use SSH/Tailscale to reach the Gateway.
Guides: [exe.dev](/platforms/exe-dev), [Hetzner](/platforms/hetzner), [Fly.io](/platforms/fly).
Remote access: [Gateway remote](/gateway/remote).
### Where are the cloud/VPS install guides?
We keep a **hosting hub** with the common providers. Pick one and follow the guide:
- [VPS hosting](/vps) (all providers in one place)
- [Railway](/railway) (one‑click, browser‑based setup)
- [Fly.io](/platforms/fly)
- [Hetzner](/platforms/hetzner)
- [exe.dev](/platforms/exe-dev)
How it works in the cloud: the **Gateway runs on the server**, and you access it
from your laptop/phone via the Control UI (or Tailscale/SSH). Your state + workspace
live on the server, so treat the host as the source of truth and back it up.
You can pair **nodes** (Mac/iOS/Android/headless) to that cloud Gateway to access
local screen/camera/canvas or run commands on your laptop while keeping the
Gateway in the cloud.
Hub: [Platforms](/platforms). Remote access: [Gateway remote](/gateway/remote).
Nodes: [Nodes](/nodes), [Nodes CLI](/cli/nodes).
### Can I ask Clawd to update itself?
Short answer: **possible, not recommended**. The update flow can restart the
Gateway (which drops the active session), may need a clean git checkout, and
can prompt for confirmation. Safer: run updates from a shell as the operator.
Use the CLI:
```bash
clawdbot update
clawdbot update status
clawdbot update --channel stable|beta|dev
clawdbot update --tag <dist-tag|version>
clawdbot update --no-restart
```
If you must automate from an agent:
```bash
clawdbot update --yes --no-restart
clawdbot gateway restart
```
Docs: [Update](/cli/update), [Updating](/install/updating).
### What does the onboarding wizard actually do?
`clawdbot onboard` is the recommended setup path. In **local mode** it walks you through:
@@ -242,6 +497,15 @@ Node **>= 22** is required. `pnpm` is recommended. Bun is **not recommended** fo
It also warns if your configured model is unknown or missing auth.
### Do I need a Claude or OpenAI subscription to run this?
No. You can run Clawdbot with **API keys** (Anthropic/OpenAI/others) or with
**local‑only models** so your data stays on your device. Subscriptions (Claude
Pro/Max or OpenAI Codex) are optional ways to authenticate those providers.
Docs: [Anthropic](/providers/anthropic), [OpenAI](/providers/openai),
[Local models](/gateway/local-models), [Models](/concepts/models).
### How does Anthropic "setup-token" auth work?
`claude setup-token` generates a **token string** via the Claude Code CLI (it is not available in the web console). You can run it on **any machine**. If Claude Code CLI credentials are present on the gateway host, Clawdbot can reuse them; otherwise choose **Anthropic token (paste setup-token)** and paste the string. The token is stored as an auth profile for the **anthropic** provider and used like an API key or OAuth profile. More detail: [OAuth](/concepts/oauth).
@@ -266,6 +530,16 @@ Yes. Clawdbot can **reuse Claude Code CLI credentials** (OAuth) and also support
Note: Claude subscription access is governed by Anthropic’s terms. For production or multi‑user workloads, API keys are usually the safer choice.
### Why am I seeing `HTTP 429: rate_limit_error` from Anthropic?
That means your **Anthropic quota/rate limit** is exhausted for the current window. If you
use a **Claude subscription** (setup‑token or Claude Code OAuth), wait for the window to
reset or upgrade your plan. If you use an **Anthropic API key**, check the Anthropic Console
for usage/billing and raise limits as needed.
Tip: set a **fallback model** so Clawdbot can keep replying while a provider is rate‑limited.
See [Models](/cli/models) and [OAuth](/concepts/oauth).
### Is AWS Bedrock supported?
Yes — via pi‑ai’s **Amazon Bedrock (Converse)** provider with **manual config**. You must supply AWS credentials/region on the gateway host and add a Bedrock provider entry in your models config. See [Amazon Bedrock](/bedrock) and [Model providers](/providers/models). If you prefer a managed key flow, an OpenAI‑compatible proxy in front of Bedrock is still a valid option.
@@ -274,6 +548,14 @@ Yes — via pi‑ai’s **Amazon Bedrock (Converse)** provider with **manual con
Clawdbot supports **OpenAI Code (Codex)** via OAuth or by reusing your Codex CLI login (`~/.codex/auth.json`). The wizard can import the CLI login or run the OAuth flow and will set the default model to `openai-codex/gpt-5.2` when appropriate. See [Model providers](/concepts/model-providers) and [Wizard](/start/wizard).
### Do you support OpenAI subscription auth (Codex OAuth)?
Yes. Clawdbot fully supports **OpenAI Code (Codex) subscription OAuth** and can also reuse an
existing Codex CLI login (`~/.codex/auth.json`) on the gateway host. The onboarding wizard
can import the CLI login or run the OAuth flow for you.
See [OAuth](/concepts/oauth), [Model providers](/concepts/model-providers), and [Wizard](/start/wizard).
### Is a local model OK for casual chats?
Usually no. Clawdbot needs large context + strong safety; small cards truncate and leak. If you must, run the **largest** MiniMax M2.1 build you can locally (LM Studio) and see [/gateway/local-models](/gateway/local-models). Smaller/quantized models increase prompt-injection risk — see [Security](/gateway/security).
@@ -282,6 +564,44 @@ Usually no. Clawdbot needs large context + strong safety; small cards truncate a
Pick region-pinned endpoints. OpenRouter exposes US-hosted options for MiniMax, Kimi, and GLM; choose the US-hosted variant to keep data in-region. You can still list Anthropic/OpenAI alongside these by using `models.mode: "merge"` so fallbacks stay available while respecting the regioned provider you select.
### Do I have to buy a Mac Mini to install this?
No. Clawdbot runs on macOS or Linux (Windows via WSL2). A Mac mini is optional — some people
buy one as an always‑on host, but a small VPS, home server, or Raspberry Pi‑class box works too.
You only need a Mac **for macOS‑only tools**. For iMessage, you can keep the Gateway on Linux
and run `imsg` on any Mac over SSH by pointing `channels.imessage.cliPath` at an SSH wrapper.
If you want other macOS‑only tools, run the Gateway on a Mac or pair a macOS node.
Docs: [iMessage](/channels/imessage), [Nodes](/nodes), [Mac remote mode](/platforms/mac/remote).
### Do I need a Mac mini for iMessage support?
You need **some macOS device** signed into Messages. It does **not** have to be a Mac mini —
any Mac works. Clawdbot’s iMessage integrations run on macOS (BlueBubbles or `imsg`), while
the Gateway can run elsewhere.
Common setups:
- Run the Gateway on Linux/VPS, and point `channels.imessage.cliPath` at an SSH wrapper that
runs `imsg` on the Mac.
- Run everything on the Mac if you want the simplest single‑machine setup.
Docs: [iMessage](/channels/imessage), [BlueBubbles](/channels/bluebubbles),
[Mac remote mode](/platforms/mac/remote).
### If I buy a Mac mini to run Clawdbot, can I connect it to my MacBook Pro?
Yes. The **Mac mini can run the Gateway**, and your MacBook Pro can connect as a
**node** (companion device). Nodes don’t run the Gateway — they provide extra
capabilities like screen/camera/canvas and `system.run` on that device.
Common pattern:
- Gateway on the Mac mini (always‑on).
- MacBook Pro runs the macOS app or a node host and pairs to the Gateway.
- Use `clawdbot nodes status` / `clawdbot nodes list` to see it.
Docs: [Nodes](/nodes), [Nodes CLI](/cli/nodes).
### Can I use Bun?
Bun is **not recommended**. We see runtime bugs, especially with WhatsApp and Telegram.
@@ -327,9 +647,20 @@ brew install <formula>
If you run Clawdbot via systemd, ensure the service PATH includes `/home/linuxbrew/.linuxbrew/bin` (or your brew prefix) so `brew`-installed tools resolve in non‑login shells.
Recent builds also prepend common user bin dirs on Linux systemd services (for example `~/.local/bin`, `~/.npm-global/bin`, `~/.local/share/pnpm`, `~/.bun/bin`) and honor `PNPM_HOME`, `NPM_CONFIG_PREFIX`, `BUN_INSTALL`, `VOLTA_HOME`, `ASDF_DATA_DIR`, `NVM_DIR`, and `FNM_DIR` when set.
### What’s the difference between the hackable (git) install and npm install?
- **Hackable (git) install:** full source checkout, editable, best for contributors.
You run builds locally and can patch code/docs.
- **npm install:** global CLI install, no repo, best for “just run it.”
Updates come from npm dist‑tags.
Docs: [Getting started](/start/getting-started), [Updating](/install/updating).
### Can I switch between npm and git installs later?
Yes. Install the other flavor, then run Doctor so the gateway service points at the new entrypoint.
This **does not delete your data** — it only changes the Clawdbot code install. Your state
(`~/.clawdbot`) and workspace (`~/clawd`) stay untouched.
From npm → git:
@@ -352,6 +683,8 @@ clawdbot gateway restart
Doctor detects a gateway service entrypoint mismatch and offers to rewrite the service config to match the current install (use `--repair` in automation).
Backup tips: see [Backup strategy](/help/faq#whats-the-recommended-backup-strategy).
### Should I run the Gateway on my laptop or a VPS?
Short answer: **if you want 24/7 reliability, use a VPS**. If you want the
@@ -403,6 +736,17 @@ npm i -g clawdhub
pnpm add -g clawdhub
```
### Can Clawdbot run tasks on a schedule or continuously in the background?
Yes. Use the Gateway scheduler:
- **Cron jobs** for scheduled or recurring tasks (persist across restarts).
- **Heartbeat** for “main session” periodic checks.
- **Isolated jobs** for autonomous agents that post summaries or deliver to chats.
Docs: [Cron jobs](/automation/cron-jobs), [Cron vs Heartbeat](/automation/cron-vs-heartbeat),
[Heartbeat](/gateway/heartbeat).
### Is there a way to run Apple/macOS-only skills if my Gateway runs on Linux?
Not directly. macOS skills are gated by `metadata.clawdbot.os` plus required binaries, and skills only appear in the system prompt when they are eligible on the **Gateway host**. On Linux, `darwin`-only skills (like `imsg`, `apple-notes`, `apple-reminders`) will not load unless you override the gating.
@@ -571,6 +915,18 @@ workspace, not your local laptop).
See [Agent workspace](/concepts/agent-workspace) and [Memory](/concepts/memory).
### What’s the recommended backup strategy?
Put your **agent workspace** in a **private** git repo and back it up somewhere
private (for example GitHub private). This captures memory + AGENTS/SOUL/USER
files, and lets you restore the assistant’s “mind” later.
Do **not** commit anything under `~/.clawdbot` (credentials, sessions, tokens).
If you need a full restore, back up both the workspace and the state directory
separately (see the migration question above).
Docs: [Agent workspace](/concepts/agent-workspace).
### How do I completely uninstall Clawdbot?
See the dedicated guide: [Uninstall](/install/uninstall).
@@ -751,10 +1107,29 @@ pair devices you trust, and review [Security](/gateway/security).
Docs: [Nodes](/nodes), [Gateway protocol](/gateway/protocol), [macOS remote mode](/platforms/mac/remote), [Security](/gateway/security).
### Is there a benefit to using a node on my personal laptop instead of SSH from a VPS?
Yes — nodes are the first‑class way to reach your laptop from a remote Gateway, and they
unlock more than shell access. The Gateway runs on macOS/Linux (Windows via WSL2) and is
lightweight (a small VPS or Raspberry Pi-class box is fine; 4 GB RAM is plenty), so a common
setup is an always‑on host plus your laptop as a node.
- **No inbound SSH required.** Nodes connect out to the Gateway WebSocket and use device pairing.
- **Safer execution controls.** `system.run` is gated by node allowlists/approvals on that laptop.
- **More device tools.** Nodes expose `canvas`, `camera`, and `screen` in addition to `system.run`.
- **Local browser automation.** Keep the Gateway on a VPS, but run Chrome locally and relay control
with the Chrome extension + `clawdbot browser serve`.
SSH is fine for ad‑hoc shell access, but nodes are simpler for ongoing agent workflows and
device automation.
Docs: [Nodes](/nodes), [Nodes CLI](/cli/nodes), [Chrome extension](/tools/chrome-extension).
### Do nodes run a gateway service?
No. Only **one gateway** should run per host unless you intentionally run isolated profiles (see [Multiple gateways](/gateway/multiple-gateways)). Nodes are peripherals that connect
to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app).
to the gateway (iOS/Android nodes, or macOS “node mode” in the menubar app). For headless node
hosts and CLI control, see [Node host CLI](/cli/node).
A full restart is required for `gateway`, `discovery`, and `canvasHost` changes.
@@ -996,6 +1371,25 @@ If you want only **you** to be able to trigger group replies:
}
```
### How do I get the JID of a WhatsApp group?
Option 1 (fastest): tail logs and send a test message in the group:
```bash
clawdbot logs --follow --json
```
Look for `chatId` (or `from`) ending in `@g.us`, like:
`1234567890-1234567890@g.us`.
Option 2 (if already configured/allowlisted): list groups from config:
```bash
clawdbot directory groups list --channel whatsapp
```
Docs: [WhatsApp](/channels/whatsapp), [Directory](/cli/directory), [Logs](/cli/logs).
### Why doesn’t Clawdbot reply in a group?
Two common causes:
@@ -1021,6 +1415,24 @@ Tips:
- Prune old sessions (delete JSONL or store entries) if disk grows.
- Use `clawdbot doctor` to spot stray workspaces and profile mismatches.
### Can I run multiple bots or chats at the same time (Slack), and how should I set that up?
Yes. Use **Multi‑Agent Routing** to run multiple isolated agents and route inbound messages by
channel/account/peer. Slack is supported as a channel and can be bound to specific agents.
Browser access is powerful but not “do anything a human can” — anti‑bot, CAPTCHAs, and MFA can
still block automation. For the most reliable browser control, use the Chrome extension relay
on the machine that runs the browser (and keep the Gateway anywhere).
Best‑practice setup:
- Always‑on Gateway host (VPS/Mac mini).
- One agent per role (bindings).
- Slack channel(s) bound to those agents.
- Local browser via extension relay (or a node) when needed.
Docs: [Multi‑Agent Routing](/concepts/multi-agent), [Slack](/channels/slack),
[Browser](/tools/browser), [Chrome extension](/tools/chrome-extension), [Nodes](/nodes).
## Models: defaults, selection, aliases, switching
### What is the “default model”?
@@ -1033,6 +1445,26 @@ agents.defaults.model.primary
Models are referenced as `provider/model` (example: `anthropic/claude-opus-4-5`). If you omit the provider, Clawdbot currently assumes `anthropic` as a temporary deprecation fallback — but you should still **explicitly** set `provider/model`.
### What model do you recommend?
**Recommended default:** `anthropic/claude-opus-4-5`.
**Good alternative:** `anthropic/claude-sonnet-4-5`.
**Reliable (less character):** `openai/gpt-5.2` — nearly as good as Opus, just less personality.
**Budget:** `zai/glm-4.7`.
MiniMax M2.1 has its own docs: [MiniMax](/providers/minimax) and
[Local models](/gateway/local-models).
Strong warning: weaker/over-quantized models are more vulnerable to prompt
injection and unsafe behavior. See [Security](/gateway/security).
More context: [Models](/concepts/models).
### What do Clawd, Flawd, and Krill use for models?
- **Clawd + Flawd:** Anthropic Opus (`anthropic/claude-opus-4-5`) — see [Anthropic](/providers/anthropic).
- **Krill:** MiniMax M2.1 (`minimax/MiniMax-M2.1`) — see [MiniMax](/providers/minimax).
### How do I switch models on the fly (without restarting)?
Use the `/model` command as a standalone message:
@@ -1506,6 +1938,26 @@ clawdbot gateway restart
If you run the gateway manually, `clawdbot gateway --force` can reclaim the port. See [Gateway](/gateway).
### How do I completely stop then start the Gateway?
If you installed the service:
```bash
clawdbot gateway stop
clawdbot gateway start
```
This stops/starts the **supervised service** (launchd on macOS, systemd on Linux).
Use this when the Gateway runs in the background as a daemon.
If you’re running in the foreground, stop with Ctrl‑C, then:
```bash
clawdbot gateway run
```
Docs: [Gateway service runbook](/gateway).
### ELI5: `clawdbot gateway restart` vs `clawdbot gateway`
- `clawdbot gateway restart`: restarts the **background service** (launchd/systemd).
+16
View File
@@ -33,6 +33,22 @@ Almost always a Node/npm PATH issue. Start here:
- [Install (Node/npm PATH sanity)](/install#nodejs--npm-path-sanity)
### Installer fails (or you need full logs)
Re-run the installer in verbose mode to see the full trace and npm output:
```bash
curl -fsSL https://clawd.bot/install.sh | bash -s -- --verbose
```
For beta installs:
```bash
curl -fsSL https://clawd.bot/install.sh | bash -s -- --beta --verbose
```
You can also set `CLAWDBOT_VERBOSE=1` instead of the flag.
### Gateway “unauthorized”, can’t connect, or keeps reconnecting
- [Gateway troubleshooting](/gateway/troubleshooting)
+9 -7
View File
@@ -11,10 +11,13 @@ Last updated: 2026-01-21
Clawdbot ships three update channels:
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`). npm dist-tag: `latest`.
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`). npm dist-tag: `beta`.
- **stable**: npm dist-tag `latest`.
- **beta**: npm dist-tag `beta` (builds under test).
- **dev**: moving head of `main` (git). npm dist-tag: `dev` (when published).
We ship builds to **beta**, test them, then **promote a vetted build to `latest`**
without changing the version number — dist-tags are the source of truth for npm installs.
## Switching channels
Git checkout:
@@ -25,7 +28,7 @@ clawdbot update --channel beta
clawdbot update --channel dev
```
- `stable`/`beta` check out the latest matching tag.
- `stable`/`beta` check out the latest matching tag (often the same tag).
- `dev` switches to `main` and rebases on the upstream.
npm/pnpm global install:
@@ -56,12 +59,11 @@ When you switch channels with `clawdbot update`, Clawdbot also syncs plugin sour
## Tagging best practices
- Stable: tag each release (`vYYYY.M.D` or `vYYYY.M.D-<patch>`).
- Beta: use `vYYYY.M.D-beta.N` (increment `N`).
- Tag releases you want git checkouts to land on (`vYYYY.M.D` or `vYYYY.M.D-<patch>`).
- Keep tags immutable: never move or reuse a tag.
- Publish dist-tags alongside git tags:
- npm dist-tags remain the source of truth for npm installs:
- `latest` → stable
- `beta` → prerelease
- `beta` → candidate build
- `dev` → main snapshot (optional)
## macOS app availability
+8 -6
View File
@@ -32,7 +32,7 @@ cd clawdbot
fly apps create my-clawdbot
# Create a persistent volume (1GB is usually enough)
fly volumes create clawdbot_data --size 1 --region lhr
fly volumes create clawdbot_data --size 1 --region iad
```
**Tip:** Choose a region close to you. Common options: `lhr` (London), `iad` (Virginia), `sjc` (San Jose).
@@ -43,7 +43,7 @@ Edit `fly.toml` to match your app name and requirements:
```toml
app = "my-clawdbot" # Your app name
primary_region = "lhr"
primary_region = "iad"
[build]
dockerfile = "Dockerfile"
@@ -134,14 +134,14 @@ fly ssh console
Create the config directory and file:
```bash
mkdir -p /data/.clawdbot
cat > /data/.clawdbot/clawdbot.json << 'EOF'
mkdir -p /data
cat > /data/clawdbot.json << 'EOF'
{
"agents": {
"defaults": {
"model": {
"primary": "anthropic/claude-opus-4-5",
"failover": ["anthropic/claude-sonnet-4-5", "openai/gpt-4o"]
"fallbacks": ["anthropic/claude-sonnet-4-5", "openai/gpt-4o"]
},
"maxConcurrent": 4
},
@@ -181,12 +181,14 @@ cat > /data/.clawdbot/clawdbot.json << 'EOF'
"bind": "auto"
},
"meta": {
"lastTouchedVersion": "2026.1.23"
"lastTouchedVersion": "2026.1.24"
}
}
EOF
```
**Note:** With `CLAWDBOT_STATE_DIR=/data`, the config path is `/data/clawdbot.json`.
**Note:** The Discord token can come from either:
- Environment variable: `DISCORD_BOT_TOKEN` (recommended for secrets)
- Config file: `channels.discord.token`
+2
View File
@@ -23,6 +23,8 @@ Native companion apps for Windows are also planned; the Gateway is recommended v
## VPS & hosting
- VPS hub: [VPS hosting](/vps)
- Railway (one-click): [Railway](/railway)
- Fly.io: [Fly.io](/platforms/fly)
- Hetzner (Docker): [Hetzner](/platforms/hetzner)
- exe.dev (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev)
+7 -7
View File
@@ -30,17 +30,17 @@ Notes:
# From repo root; set release IDs so Sparkle feed is enabled.
# APP_BUILD must be numeric + monotonic for Sparkle compare.
BUNDLE_ID=com.clawdbot.mac \
APP_VERSION=2026.1.23 \
APP_VERSION=2026.1.24 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-app.sh
# Zip for distribution (includes resource forks for Sparkle delta support)
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.23.zip
ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.24.zip
# Optional: also build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.23.dmg
scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.24.dmg
# Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once:
@@ -48,26 +48,26 @@ scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.23.dmg
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
BUNDLE_ID=com.clawdbot.mac \
APP_VERSION=2026.1.23 \
APP_VERSION=2026.1.24 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.23.dSYM.zip
ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.24.dSYM.zip
```
## Appcast entry
Use the release note generator so Sparkle renders formatted HTML notes:
```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.23.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/Clawdbot-2026.1.24.zip https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml
```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
## Publish & verify
- Upload `Clawdbot-2026.1.23.zip` (and `Clawdbot-2026.1.23.dSYM.zip`) to the GitHub release for tag `v2026.1.23`.
- Upload `Clawdbot-2026.1.24.zip` (and `Clawdbot-2026.1.24.dSYM.zip`) to the GitHub release for tag `v2026.1.24`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml`.
- Sanity checks:
- `curl -I https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml` returns 200.
+13 -3
View File
@@ -10,7 +10,13 @@ This flow lets the macOS app act as a full remote control for a Clawdbot gateway
## Modes
- **Local (this Mac)**: Everything runs on the laptop. No SSH involved.
- **Remote over SSH**: Clawdbot commands are executed on the remote host. The mac app opens an SSH connection with `-o BatchMode` plus your chosen identity/key.
- **Remote over SSH (default)**: Clawdbot commands are executed on the remote host. The mac app opens an SSH connection with `-o BatchMode` plus your chosen identity/key and a local port-forward.
- **Remote direct (ws/wss)**: No SSH tunnel. The mac app connects to the gateway URL directly (for example, via Tailscale Serve or a public HTTPS reverse proxy).
## Remote transports
Remote mode supports two transports:
- **SSH tunnel** (default): Uses `ssh -N -L ...` to forward the gateway port to localhost. The gateway will see the node’s IP as `127.0.0.1` because the tunnel is loopback.
- **Direct (ws/wss)**: Connects straight to the gateway URL. The gateway sees the real client IP.
## Prereqs on the remote host
1) Install Node + pnpm and build/install the Clawdbot CLI (`pnpm install && pnpm build && pnpm link --global`).
@@ -20,16 +26,19 @@ This flow lets the macOS app act as a full remote control for a Clawdbot gateway
## macOS app setup
1) Open *Settings → General*.
2) Under **Clawdbot runs**, pick **Remote over SSH** and set:
- **Transport**: **SSH tunnel** or **Direct (ws/wss)**.
- **SSH target**: `user@host` (optional `:port`).
- If the gateway is on the same LAN and advertises Bonjour, pick it from the discovered list to auto-fill this field.
- **Gateway URL** (Direct only): `wss://gateway.example.ts.net` (or `ws://...` for local/LAN).
- **Identity file** (advanced): path to your key.
- **Project root** (advanced): remote checkout path used for commands.
- **CLI path** (advanced): optional path to a runnable `clawdbot` entrypoint/binary (auto-filled when advertised).
3) Hit **Test remote**. Success indicates the remote `clawdbot status --json` runs correctly. Failures usually mean PATH/CLI issues; exit 127 means the CLI isn’t found remotely.
4) Health checks and Web Chat will now run through this SSH tunnel automatically.
## Web Chat over SSH
- Web Chat connects to the gateway over the forwarded WebSocket control port (default 18789).
## Web Chat
- **SSH tunnel**: Web Chat connects to the gateway over the forwarded WebSocket control port (default 18789).
- **Direct (ws/wss)**: Web Chat connects straight to the configured gateway URL.
- There is no separate WebChat HTTP server anymore.
## Permissions
@@ -49,6 +58,7 @@ This flow lets the macOS app act as a full remote control for a Clawdbot gateway
- **exit 127 / not found**: `clawdbot` isn’t on PATH for non-login shells. Add it to `/etc/paths`, your shell rc, or symlink into `/usr/local/bin`/`/opt/homebrew/bin`.
- **Health probe failed**: check SSH reachability, PATH, and that Baileys is logged in (`clawdbot status --json`).
- **Web Chat stuck**: confirm the gateway is running on the remote host and the forwarded port matches the gateway WS port; the UI requires a healthy WS connection.
- **Node IP shows 127.0.0.1**: expected with the SSH tunnel. Switch **Transport** to **Direct (ws/wss)** if you want the gateway to see the real client IP.
- **Voice Wake**: trigger phrases are forwarded automatically in remote mode; no separate forwarder is needed.
## Notification sounds
+3
View File
@@ -180,6 +180,9 @@ components can talk to a remote Gateway as if it were on localhost.
or restarts it if needed.
- **SSH shape:** `ssh -N -L <local>:127.0.0.1:<remote>` with BatchMode +
ExitOnForwardFailure + keepalive options.
- **IP reporting:** the SSH tunnel uses loopback, so the gateway will see the node
IP as `127.0.0.1`. Use **Direct (ws/wss)** transport if you want the real client
IP to appear (see [macOS remote access](/platforms/mac/remote)).
For setup steps, see [macOS remote access](/platforms/mac/remote). For protocol
details, see [Gateway protocol](/gateway/protocol).
+23
View File
@@ -105,3 +105,26 @@ clawdbot onboard --auth-choice claude-cli
accepts both OAuth and setup-token credentials. Older configs using `"token"` are
auto-migrated on load.
- Auth details + reuse rules are in [/concepts/oauth](/concepts/oauth).
## Troubleshooting
**401 errors / token suddenly invalid**
- Claude subscription auth can expire or be revoked. Re-run `claude setup-token`
and paste it into the **gateway host**.
- If the Claude CLI login lives on a different machine, use
`clawdbot models auth paste-token --provider anthropic` on the gateway host.
**No API key found for provider "anthropic"**
- Auth is **per agent**. New agents don’t inherit the main agent’s keys.
- Re-run onboarding for that agent, or paste a setup-token / API key on the
gateway host, then verify with `clawdbot models status`.
**No credentials found for profile `anthropic:default` or `anthropic:claude-cli`**
- Run `clawdbot models status` to see which auth profile is active.
- Re-run onboarding, or paste a setup-token / API key for that profile.
**No available auth profile (all in cooldown/unavailable)**
- Check `clawdbot models status --json` for `auth.unusableProfiles`.
- Add another Anthropic profile or wait for cooldown.
More: [/gateway/troubleshooting](/gateway/troubleshooting) and [/help/faq](/help/faq).
+2
View File
@@ -35,6 +35,8 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
- [Z.AI](/providers/zai)
- [GLM models](/providers/glm)
- [MiniMax](/providers/minimax)
- [Venice AI (privacy-focused)](/providers/venice)
- [Ollama (local models)](/providers/ollama)
## Transcription providers
+219
View File
@@ -0,0 +1,219 @@
---
summary: "Run Clawdbot with Ollama (local LLM runtime)"
read_when:
- You want to run Clawdbot with local models via Ollama
- You need Ollama setup and configuration guidance
---
# Ollama
Ollama is a local LLM runtime that makes it easy to run open-source models on your machine. Clawdbot integrates with Ollama's OpenAI-compatible API and can **auto-discover tool-capable models** when you opt in with `OLLAMA_API_KEY` (or an auth profile) and do not define an explicit `models.providers.ollama` entry.
## Quick start
1) Install Ollama: https://ollama.ai
2) Pull a model:
```bash
ollama pull llama3.3
# or
ollama pull qwen2.5-coder:32b
# or
ollama pull deepseek-r1:32b
```
3) Enable Ollama for Clawdbot (any value works; Ollama doesn't require a real key):
```bash
# Set environment variable
export OLLAMA_API_KEY="ollama-local"
# Or configure in your config file
clawdbot config set models.providers.ollama.apiKey "ollama-local"
```
4) Use Ollama models:
```json5
{
agents: {
defaults: {
model: { primary: "ollama/llama3.3" }
}
}
}
```
## Model discovery (implicit provider)
When you set `OLLAMA_API_KEY` (or an auth profile) and **do not** define `models.providers.ollama`, Clawdbot discovers models from the local Ollama instance at `http://127.0.0.1:11434`:
- Queries `/api/tags` and `/api/show`
- Keeps only models that report `tools` capability
- Marks `reasoning` when the model reports `thinking`
- Reads `contextWindow` from `model_info["<arch>.context_length"]` when available
- Sets `maxTokens` to 10× the context window
- Sets all costs to `0`
This avoids manual model entries while keeping the catalog aligned with Ollama's capabilities.
To see what models are available:
```bash
ollama list
clawdbot models list
```
To add a new model, simply pull it with Ollama:
```bash
ollama pull mistral
```
The new model will be automatically discovered and available to use.
If you set `models.providers.ollama` explicitly, auto-discovery is skipped and you must define models manually (see below).
## Configuration
### Basic setup (implicit discovery)
The simplest way to enable Ollama is via environment variable:
```bash
export OLLAMA_API_KEY="ollama-local"
```
### Explicit setup (manual models)
Use explicit config when:
- Ollama runs on another host/port.
- You want to force specific context windows or model lists.
- You want to include models that do not report tool support.
```json5
{
models: {
providers: {
ollama: {
// Use a host that includes /v1 for OpenAI-compatible APIs
baseUrl: "http://ollama-host:11434/v1",
apiKey: "ollama-local",
api: "openai-completions",
models: [
{
id: "llama3.3",
name: "Llama 3.3",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 8192,
maxTokens: 8192 * 10
}
]
}
}
}
}
```
If `OLLAMA_API_KEY` is set, you can omit `apiKey` in the provider entry and Clawdbot will fill it for availability checks.
### Custom base URL (explicit config)
If Ollama is running on a different host or port (explicit config disables auto-discovery, so define models manually):
```json5
{
models: {
providers: {
ollama: {
apiKey: "ollama-local",
baseUrl: "http://ollama-host:11434/v1"
}
}
}
}
```
### Model selection
Once configured, all your Ollama models are available:
```json5
{
agents: {
defaults: {
model: {
primary: "ollama/llama3.3",
fallback: ["ollama/qwen2.5-coder:32b"]
}
}
}
}
```
## Advanced
### Reasoning models
Clawdbot marks models as reasoning-capable when Ollama reports `thinking` in `/api/show`:
```bash
ollama pull deepseek-r1:32b
```
### Model Costs
Ollama is free and runs locally, so all model costs are set to $0.
### Context windows
For auto-discovered models, Clawdbot uses the context window reported by Ollama when available, otherwise it defaults to `8192`. You can override `contextWindow` and `maxTokens` in explicit provider config.
## Troubleshooting
### Ollama not detected
Make sure Ollama is running and that you set `OLLAMA_API_KEY` (or an auth profile), and that you did **not** define an explicit `models.providers.ollama` entry:
```bash
ollama serve
```
And that the API is accessible:
```bash
curl http://localhost:11434/api/tags
```
### No models available
Clawdbot only auto-discovers models that report tool support. If your model isn't listed, either:
- Pull a tool-capable model, or
- Define the model explicitly in `models.providers.ollama`.
To add models:
```bash
ollama list # See what's installed
ollama pull llama3.3 # Pull a model
```
### Connection refused
Check that Ollama is running on the correct port:
```bash
# Check if Ollama is running
ps aux | grep ollama
# Or restart Ollama
ollama serve
```
## See Also
- [Model Providers](/concepts/model-providers) - Overview of all providers
- [Model Selection](/concepts/models) - How to choose models
- [Configuration](/gateway/configuration) - Full config reference
+252
View File
@@ -0,0 +1,252 @@
---
summary: "Use Venice AI privacy-focused models in Clawdbot"
read_when:
- You want privacy-focused inference in Clawdbot
- You want Venice AI setup guidance
---
# Venice AI Provider
Venice AI provides privacy-focused AI inference with support for uncensored models and access to major proprietary models through their anonymized proxy. All inference is private by default—no training on your data, no logging.
## Privacy Modes
Venice offers two privacy levels — understanding this is key to choosing your model:
| Mode | Description | Models |
|------|-------------|--------|
| **Private** | Fully private. Prompts/responses are **never stored or logged**. Ephemeral. | Llama, Qwen, DeepSeek, Venice Uncensored, etc. |
| **Anonymized** | Proxied through Venice with metadata stripped. The underlying provider (OpenAI, Anthropic) sees anonymized requests. | Claude, GPT, Gemini, Grok, Kimi, MiniMax |
## Features
- **Privacy-focused**: Choose between "private" (fully private) and "anonymized" (proxied) modes
- **Uncensored models**: Access to models without content restrictions
- **Major model access**: Use Claude, GPT-5.2, Gemini, Grok via Venice's anonymized proxy
- **OpenAI-compatible API**: Standard `/v1` endpoints for easy integration
- **Streaming**: ✅ Supported on all models
- **Function calling**: ✅ Supported on select models (check model capabilities)
- **Vision**: ✅ Supported on models with vision capability
- **No hard rate limits**: Fair-use throttling may apply for extreme usage
## Setup
### 1. Get API Key
1. Sign up at [venice.ai](https://venice.ai)
2. Go to **Settings → API Keys → Create new key**
3. Copy your API key (format: `vapi_xxxxxxxxxxxx`)
### 2. Configure Clawdbot
**Option A: Environment Variable**
```bash
export VENICE_API_KEY="vapi_xxxxxxxxxxxx"
```
**Option B: Interactive Setup (Recommended)**
```bash
clawdbot onboard --auth-choice venice-api-key
```
This will:
1. Prompt for your API key (or use existing `VENICE_API_KEY`)
2. Show all available Venice models
3. Let you pick your default model
4. Configure the provider automatically
**Option C: Non-interactive**
```bash
clawdbot onboard --non-interactive \
--auth-choice venice-api-key \
--venice-api-key "vapi_xxxxxxxxxxxx"
```
### 3. Verify Setup
```bash
clawdbot chat --model venice/llama-3.3-70b "Hello, are you working?"
```
## Model Selection
After setup, Clawdbot shows all available Venice models. Pick based on your needs:
- **Privacy**: Choose "private" models for fully private inference
- **Capability**: Choose "anonymized" models to access Claude, GPT, Gemini via Venice's proxy
Change your default model anytime:
```bash
clawdbot models set venice/claude-opus-45
clawdbot models set venice/llama-3.3-70b
```
List all available models:
```bash
clawdbot models list | grep venice
```
## Configure via `clawdbot configure`
1. Run `clawdbot configure`
2. Select **Model/auth**
3. Choose **Venice AI**
## Which Model Should I Use?
| Use Case | Recommended Model | Why |
|----------|-------------------|-----|
| **General chat** | `llama-3.3-70b` | Good all-around, fully private |
| **Privacy + Claude quality** | `claude-opus-45` | Best reasoning via anonymized proxy |
| **Coding** | `qwen3-coder-480b-a35b-instruct` | Code-optimized, 262k context |
| **Vision tasks** | `qwen3-vl-235b-a22b` | Best private vision model |
| **Uncensored** | `venice-uncensored` | No content restrictions |
| **Fast + cheap** | `qwen3-4b` | Lightweight, still capable |
| **Complex reasoning** | `deepseek-v3.2` | Strong reasoning, private |
## Available Models (25 Total)
### Private Models (15) — Fully Private, No Logging
| Model ID | Name | Context (tokens) | Features |
|----------|------|------------------|----------|
| `llama-3.3-70b` | Llama 3.3 70B | 131k | General |
| `llama-3.2-3b` | Llama 3.2 3B | 131k | Fast, lightweight |
| `hermes-3-llama-3.1-405b` | Hermes 3 Llama 3.1 405B | 131k | Complex tasks |
| `qwen3-235b-a22b-thinking-2507` | Qwen3 235B Thinking | 131k | Reasoning |
| `qwen3-235b-a22b-instruct-2507` | Qwen3 235B Instruct | 131k | General |
| `qwen3-coder-480b-a35b-instruct` | Qwen3 Coder 480B | 262k | Code |
| `qwen3-next-80b` | Qwen3 Next 80B | 262k | General |
| `qwen3-vl-235b-a22b` | Qwen3 VL 235B | 262k | Vision |
| `qwen3-4b` | Venice Small (Qwen3 4B) | 32k | Fast, reasoning |
| `deepseek-v3.2` | DeepSeek V3.2 | 163k | Reasoning |
| `venice-uncensored` | Venice Uncensored | 32k | Uncensored |
| `mistral-31-24b` | Venice Medium (Mistral) | 131k | Vision |
| `google-gemma-3-27b-it` | Gemma 3 27B Instruct | 202k | Vision |
| `openai-gpt-oss-120b` | OpenAI GPT OSS 120B | 131k | General |
| `zai-org-glm-4.7` | GLM 4.7 | 202k | Reasoning, multilingual |
### Anonymized Models (10) — Via Venice Proxy
| Model ID | Original | Context (tokens) | Features |
|----------|----------|------------------|----------|
| `claude-opus-45` | Claude Opus 4.5 | 202k | Reasoning, vision |
| `claude-sonnet-45` | Claude Sonnet 4.5 | 202k | Reasoning, vision |
| `openai-gpt-52` | GPT-5.2 | 262k | Reasoning |
| `openai-gpt-52-codex` | GPT-5.2 Codex | 262k | Reasoning, vision |
| `gemini-3-pro-preview` | Gemini 3 Pro | 202k | Reasoning, vision |
| `gemini-3-flash-preview` | Gemini 3 Flash | 262k | Reasoning, vision |
| `grok-41-fast` | Grok 4.1 Fast | 262k | Reasoning, vision |
| `grok-code-fast-1` | Grok Code Fast 1 | 262k | Reasoning, code |
| `kimi-k2-thinking` | Kimi K2 Thinking | 262k | Reasoning |
| `minimax-m21` | MiniMax M2.1 | 202k | Reasoning |
## Model Discovery
Clawdbot automatically discovers models from the Venice API when `VENICE_API_KEY` is set. If the API is unreachable, it falls back to a static catalog.
The `/models` endpoint is public (no auth needed for listing), but inference requires a valid API key.
## Streaming & Tool Support
| Feature | Support |
|---------|---------|
| **Streaming** | ✅ All models |
| **Function calling** | ✅ Most models (check `supportsFunctionCalling` in API) |
| **Vision/Images** | ✅ Models marked with "Vision" feature |
| **JSON mode** | ✅ Supported via `response_format` |
## Pricing
Venice uses a credit-based system. Check [venice.ai/pricing](https://venice.ai/pricing) for current rates:
- **Private models**: Generally lower cost
- **Anonymized models**: Similar to direct API pricing + small Venice fee
## Comparison: Venice vs Direct API
| Aspect | Venice (Anonymized) | Direct API |
|--------|---------------------|------------|
| **Privacy** | Metadata stripped, anonymized | Your account linked |
| **Latency** | +10-50ms (proxy) | Direct |
| **Features** | Most features supported | Full features |
| **Billing** | Venice credits | Provider billing |
## Usage Examples
```bash
# Use default private model
clawdbot chat --model venice/llama-3.3-70b
# Use Claude via Venice (anonymized)
clawdbot chat --model venice/claude-opus-45
# Use uncensored model
clawdbot chat --model venice/venice-uncensored
# Use vision model with image
clawdbot chat --model venice/qwen3-vl-235b-a22b
# Use coding model
clawdbot chat --model venice/qwen3-coder-480b-a35b-instruct
```
## Troubleshooting
### API key not recognized
```bash
echo $VENICE_API_KEY
clawdbot models list | grep venice
```
Ensure the key starts with `vapi_`.
### Model not available
The Venice model catalog updates dynamically. Run `clawdbot models list` to see currently available models. Some models may be temporarily offline.
### Connection issues
Venice API is at `https://api.venice.ai/api/v1`. Ensure your network allows HTTPS connections.
## Config file example
```json5
{
env: { VENICE_API_KEY: "vapi_..." },
agents: { defaults: { model: { primary: "venice/llama-3.3-70b" } } },
models: {
mode: "merge",
providers: {
venice: {
baseUrl: "https://api.venice.ai/api/v1",
apiKey: "${VENICE_API_KEY}",
api: "openai-completions",
models: [
{
id: "llama-3.3-70b",
name: "Llama 3.3 70B",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 131072,
maxTokens: 8192
}
]
}
}
}
}
```
## Links
- [Venice AI](https://venice.ai)
- [API Documentation](https://docs.venice.ai)
- [Pricing](https://venice.ai/pricing)
- [Status](https://status.venice.ai)
+94
View File
@@ -0,0 +1,94 @@
---
title: Deploy on Railway
---
Deploy Clawdbot on Railway with a one-click template and finish setup in your browser.
This is the easiest “no terminal on the server” path: Railway runs the Gateway for you,
and you configure everything via the `/setup` web wizard.
## Quick checklist (new users)
1) Click **Deploy on Railway** (below).
2) Add a **Volume** mounted at `/data`.
3) Set the required **Variables** (at least `SETUP_PASSWORD`).
4) Enable **HTTP Proxy** on port `8080`.
5) Open `https://<your-railway-domain>/setup` and finish the wizard.
## One-click deploy
<a href="https://railway.com/deploy/clawdbot-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a>
After deploy, find your public URL in **Railway → your service → Settings → Domains**.
Railway will either:
- give you a generated domain (often `https://<something>.up.railway.app`), or
- use your custom domain if you attached one.
Then open:
- `https://<your-railway-domain>/setup` — setup wizard (password protected)
- `https://<your-railway-domain>/clawdbot` — Control UI
## What you get
- Hosted Clawdbot Gateway + Control UI
- Web setup wizard at `/setup` (no terminal commands)
- Persistent storage via Railway Volume (`/data`) so config/credentials/workspace survive redeploys
- Backup export at `/setup/export` to migrate off Railway later
## Required Railway settings
### Public Networking
Enable **HTTP Proxy** for the service.
- Port: `8080`
### Volume (required)
Attach a volume mounted at:
- `/data`
### Variables
Set these variables on the service:
- `SETUP_PASSWORD` (required)
- `CLAWDBOT_STATE_DIR=/data/.clawdbot` (recommended)
- `CLAWDBOT_WORKSPACE_DIR=/data/workspace` (recommended)
- `CLAWDBOT_GATEWAY_TOKEN` (recommended; treat as an admin secret)
## Setup flow
1) Visit `https://<your-railway-domain>/setup` and enter your `SETUP_PASSWORD`.
2) Choose a model/auth provider and paste your key.
3) (Optional) Add Telegram/Discord/Slack tokens.
4) Click **Run setup**.
If Telegram DMs are set to pairing, the setup wizard can approve the pairing code.
## Getting chat tokens
### Telegram bot token
1) Message `@BotFather` in Telegram
2) Run `/newbot`
3) Copy the token (looks like `123456789:AA...`)
4) Paste it into `/setup`
### Discord bot token
1) Go to https://discord.com/developers/applications
2) **New Application** → choose a name
3) **Bot** → **Add Bot**
4) Copy the **Bot Token** and paste into `/setup`
5) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
## Backups & migration
Download a backup at:
- `https://<your-railway-domain>/setup/export`
This exports your Clawdbot state + workspace so you can migrate to another host without losing config or memory.
@@ -0,0 +1,75 @@
---
title: Outbound Session Mirroring Refactor (Issue #1520)
description: Track outbound session mirroring refactor notes, decisions, tests, and open items.
---
# Outbound Session Mirroring Refactor (Issue #1520)
## Status
- In progress.
- Core + plugin channel routing updated for outbound mirroring.
- Gateway send now derives target session when sessionKey is omitted.
## Context
Outbound sends were mirrored into the *current* agent session (tool session key) rather than the target channel session. Inbound routing uses channel/peer session keys, so outbound responses landed in the wrong session and first-contact targets often lacked session entries.
## Goals
- Mirror outbound messages into the target channel session key.
- Create session entries on outbound when missing.
- Keep thread/topic scoping aligned with inbound session keys.
- Cover core channels plus bundled extensions.
## Implementation Summary
- New outbound session routing helper:
- `src/infra/outbound/outbound-session.ts`
- `resolveOutboundSessionRoute` builds target sessionKey using `buildAgentSessionKey` (dmScope + identityLinks).
- `ensureOutboundSessionEntry` writes minimal `MsgContext` via `recordSessionMetaFromInbound`.
- `runMessageAction` (send) derives target sessionKey and passes it to `executeSendAction` for mirroring.
- `message-tool` no longer mirrors directly; it only resolves agentId from the current session key.
- Plugin send path mirrors via `appendAssistantMessageToSessionTranscript` using the derived sessionKey.
- Gateway send derives a target session key when none is provided (default agent), and ensures a session entry.
## Thread/Topic Handling
- Slack: replyTo/threadId -> `resolveThreadSessionKeys` (suffix).
- Discord: threadId/replyTo -> `resolveThreadSessionKeys` with `useSuffix=false` to match inbound (thread channel id already scopes session).
- Telegram: topic IDs map to `chatId:topic:<id>` via `buildTelegramGroupPeerId`.
## Extensions Covered
- Matrix, MS Teams, Mattermost, BlueBubbles, Nextcloud Talk, Zalo, Zalo Personal, Nostr, Tlon.
- Notes:
- Mattermost targets now strip `@` for DM session key routing.
- Zalo Personal uses DM peer kind for 1:1 targets (group only when `group:` is present).
- BlueBubbles group targets strip `chat_*` prefixes to match inbound session keys.
- Slack auto-thread mirroring matches channel ids case-insensitively.
- Gateway send lowercases provided session keys before mirroring.
## Decisions
- **Gateway send session derivation**: if `sessionKey` is provided, use it. If omitted, derive a sessionKey from target + default agent and mirror there.
- **Session entry creation**: always use `recordSessionMetaFromInbound` with `Provider/From/To/ChatType/AccountId/Originating*` aligned to inbound formats.
- **Target normalization**: outbound routing uses resolved targets (post `resolveChannelTarget`) when available.
- **Session key casing**: canonicalize session keys to lowercase on write and during migrations.
## Tests Added/Updated
- `src/infra/outbound/outbound-session.test.ts`
- Slack thread session key.
- Telegram topic session key.
- dmScope identityLinks with Discord.
- `src/agents/tools/message-tool.test.ts`
- Derives agentId from session key (no sessionKey passed through).
- `src/gateway/server-methods/send.test.ts`
- Derives session key when omitted and creates session entry.
## Open Items / Follow-ups
- Voice-call plugin uses custom `voice:<phone>` session keys. Outbound mapping is not standardized here; if message-tool should support voice-call sends, add explicit mapping.
- Confirm if any external plugin uses non-standard `From/To` formats beyond the bundled set.
## Files Touched
- `src/infra/outbound/outbound-session.ts`
- `src/infra/outbound/outbound-send-service.ts`
- `src/infra/outbound/message-action-runner.ts`
- `src/agents/tools/message-tool.ts`
- `src/gateway/server-methods/send.ts`
- Tests in:
- `src/infra/outbound/outbound-session.test.ts`
- `src/agents/tools/message-tool.test.ts`
- `src/gateway/server-methods/send.test.ts`
+27
View File
@@ -78,3 +78,30 @@ When the operator says “release”, immediately do this preflight (no extra qu
- [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main).
- [ ] From a clean temp directory (no `package.json`), run `npx -y clawdbot@X.Y.Z send --help` to confirm install/CLI entrypoints work.
- [ ] Announce/share release notes.
## Plugin publish scope (npm)
We only publish **existing npm plugins** under the `@clawdbot/*` scope. Bundled
plugins that are not on npm stay **disk-tree only** (still shipped in
`extensions/**`).
Process to derive the list:
1) `npm search @clawdbot --json` and capture the package names.
2) Compare with `extensions/*/package.json` names.
3) Publish only the **intersection** (already on npm).
Current npm plugin list (update as needed):
- @clawdbot/bluebubbles
- @clawdbot/diagnostics-otel
- @clawdbot/discord
- @clawdbot/lobster
- @clawdbot/matrix
- @clawdbot/msteams
- @clawdbot/nextcloud-talk
- @clawdbot/nostr
- @clawdbot/voice-call
- @clawdbot/zalo
- @clawdbot/zalouser
Release notes must also call out **new optional bundled plugins** that are **not
on by default** (example: `tlon`).
+15
View File
@@ -92,6 +92,21 @@ In group chats where you receive every message, be **smart about when to contrib
Participate, don't dominate.
### 😊 React Like a Human!
On platforms that support reactions (Discord, Slack), use emoji reactions naturally:
**React when:**
- You appreciate something but don't need to reply (👍, ❤️, 🙌)
- Something made you laugh (😂, 💀)
- You find it interesting or thought-provoking (🤔, 💡)
- You want to acknowledge without interrupting the flow
- It's a simple yes/no or approval situation (✅, 👀)
**Why it matters:**
Reactions are lightweight social signals. Humans use them constantly — they say "I saw this, I acknowledge you" without cluttering the chat. You should too.
**Don't overdo it:** One reaction per message max. Pick the one that fits best.
## Tools
Skills provide your tools. When you need one, check its `SKILL.md`. Keep local notes (camera names, SSH details, voice preferences) in `TOOLS.md`.
+7 -6
View File
@@ -48,7 +48,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
- Model/auth (OpenAI Code (Codex) subscription OAuth, Anthropic API key (recommended) or setup-token (paste), plus MiniMax/GLM/Moonshot/AI Gateway options)
- Workspace location + bootstrap files
- Gateway settings (port/bind/auth/tailscale)
- Providers (Telegram, WhatsApp, Discord, Mattermost (plugin), Signal)
- Providers (Telegram, WhatsApp, Discord, Google Chat, Mattermost (plugin), Signal)
- Daemon install (LaunchAgent / systemd user unit)
- Health check
- Skills (recommended)
@@ -114,10 +114,11 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` (
- Non‑loopback binds still require auth.
5) **Channels**
- WhatsApp: optional QR login.
- Telegram: bot token.
- Discord: bot token.
- Mattermost (plugin): bot token + base URL.
- WhatsApp: optional QR login.
- Telegram: bot token.
- Discord: bot token.
- Google Chat: service account JSON + webhook audience.
- Mattermost (plugin): bot token + base URL.
- Signal: optional `signal-cli` install + account config.
- iMessage: local `imsg` CLI path + DB access.
- DM security: default is pairing. First DM sends a code; approve via `clawdbot pairing approve <channel> <code>` or use allowlists.
@@ -313,5 +314,5 @@ will prompt to install it (npm or a local path) before it can be configured.
- macOS app onboarding: [Onboarding](/start/onboarding)
- Config reference: [Gateway configuration](/gateway/configuration)
- Providers: [WhatsApp](/channels/whatsapp), [Telegram](/channels/telegram), [Discord](/channels/discord), [Signal](/channels/signal), [iMessage](/channels/imessage)
- Providers: [WhatsApp](/channels/whatsapp), [Telegram](/channels/telegram), [Discord](/channels/discord), [Google Chat](/channels/googlechat), [Signal](/channels/signal), [iMessage](/channels/imessage)
- Skills: [Skills](/tools/skills), [Skills config](/tools/skills-config)
+1 -1
View File
@@ -41,7 +41,7 @@ clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel
- `--local`: run locally (requires model provider API keys in your shell)
- `--deliver`: send the reply to the chosen channel
- `--channel`: delivery channel (`whatsapp|telegram|discord|slack|signal|imessage`, default: `whatsapp`)
- `--channel`: delivery channel (`whatsapp|telegram|discord|googlechat|slack|signal|imessage`, default: `whatsapp`)
- `--reply-to`: delivery target override
- `--reply-channel`: delivery channel override
- `--reply-account`: delivery account id override
+4 -3
View File
@@ -6,9 +6,10 @@ read_when:
# Elevated Mode (/elevated directives)
## What it does
- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full` (approvals still apply).
- `/elevated on` runs on the gateway host and keeps exec approvals (same as `/elevated ask`).
- `/elevated full` runs on the gateway host **and** auto-approves exec (skips exec approvals).
- `/elevated ask` runs on the gateway host but keeps exec approvals (same as `/elevated on`).
- `on`/`ask` do **not** force `exec.security=full`; configured security/ask policy still applies.
- Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host).
- Directive forms: `/elevated on|off|ask|full`, `/elev on|off|ask|full`.
- Only `on|off|ask|full` are accepted; anything else returns a hint and does not change state.
@@ -18,8 +19,8 @@ read_when:
- **Per-session state**: `/elevated on|off|ask|full` sets the elevated level for the current session key.
- **Inline directive**: `/elevated on|ask|full` inside a message applies to that message only.
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
- **Host execution**: elevated forces `exec` onto the gateway host with full security.
- **Approvals**: `full` skips exec approvals; `on`/`ask` still honor them.
- **Host execution**: elevated forces `exec` onto the gateway host; `full` also sets `security=full`.
- **Approvals**: `full` skips exec approvals; `on`/`ask` honor them when allowlist/ask rules require.
- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status.
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
+30
View File
@@ -157,6 +157,36 @@ Actions:
- **Always allow** → add to allowlist + run
- **Deny** → block
## Approval forwarding to chat channels
You can forward exec approval prompts to any chat channel (including plugin channels) and approve
them with `/approve`. This uses the normal outbound delivery pipeline.
Config:
```json5
{
approvals: {
exec: {
enabled: true,
mode: "session", // "session" | "targets" | "both"
agentFilter: ["main"],
sessionFilter: ["discord"], // substring or regex
targets: [
{ channel: "slack", to: "U12345678" },
{ channel: "telegram", to: "123456789" }
]
}
}
}
```
Reply in chat:
```
/approve <id> allow-once
/approve <id> allow-always
/approve <id> deny
```
### macOS IPC flow
```
Gateway -> Node Service (WS)
+1 -1
View File
@@ -24,7 +24,7 @@ Background sessions are scoped per agent; `process` only sees sessions from the
- `security` (`deny | allowlist | full`): enforcement mode for `gateway`/`node`
- `ask` (`off | on-miss | always`): approval prompts for `gateway`/`node`
- `node` (string): node id/name for `host=node`
- `elevated` (bool): alias for `host=gateway` + `security=full` when sandboxed and allowed
- `elevated` (bool): request elevated mode (gateway host); `security=full` is only forced when elevated resolves to `full`
Notes:
- `host` defaults to `sandbox`.
+2 -1
View File
@@ -325,7 +325,7 @@ Notes:
- Uses the image model directly (independent of the main chat model).
### `message`
Send messages and channel actions across Discord/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams.
Send messages and channel actions across Discord/Google Chat/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams.
Core actions:
- `send` (text + optional media; MS Teams also supports `card` for Adaptive Cards)
@@ -368,6 +368,7 @@ Core actions:
- `restart` (authorizes + sends `SIGUSR1` for in-process restart; `clawdbot gateway` restart in-place)
- `config.get` / `config.schema`
- `config.apply` (validate + write config + restart + wake)
- `config.patch` (merge partial update + restart + wake)
- `update.run` (run update + restart + wake)
Notes:
+1
View File
@@ -14,6 +14,7 @@ Shared reaction semantics across channels:
Channel notes:
- **Discord/Slack**: empty `emoji` removes all of the bot's reactions on the message; `remove: true` removes just that emoji.
- **Google Chat**: empty `emoji` removes the app's reactions on the message; `remove: true` removes just that emoji.
- **Telegram**: empty `emoji` removes the bot's reactions; `remove: true` also removes reactions but still requires a non-empty `emoji` for tool validation.
- **WhatsApp**: empty `emoji` removes the bot reaction; `remove: true` maps to empty emoji (still requires `emoji`).
- **Signal**: inbound reaction notifications emit system events when `channels.signal.reactionNotifications` is enabled.
+2 -1
View File
@@ -39,7 +39,7 @@ They run immediately, are stripped before the model sees the message, and the re
```
- `commands.text` (default `true`) enables parsing `/...` in chat messages.
- On surfaces without native commands (WhatsApp/WebChat/Signal/iMessage/MS Teams), text commands still work even if you set this to `false`.
- On surfaces without native commands (WhatsApp/WebChat/Signal/iMessage/Google Chat/MS Teams), text commands still work even if you set this to `false`.
- `commands.native` (default `"auto"`) registers native commands.
- Auto: on for Discord/Telegram; off for Slack (until you add slash commands); ignored for providers without native support.
- Set `channels.discord.commands.native`, `channels.telegram.commands.native`, or `channels.slack.commands.native` to override per provider (bool or `"auto"`).
@@ -61,6 +61,7 @@ Text + native (when enabled):
- `/skill <name> [input]` (run a skill by name)
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
- `/allowlist` (list/add/remove allowlist entries)
- `/approve <id> allow-once|allow-always|deny` (resolve exec approval prompts)
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
- `/whoami` (show your sender id; alias: `/id`)
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
+36
View File
@@ -0,0 +1,36 @@
---
summary: "VPS hosting hub for Clawdbot (Railway/Fly/Hetzner/exe.dev)"
read_when:
- You want to run the Gateway in the cloud
- You need a quick map of VPS/hosting guides
---
# VPS hosting
This hub links to the supported VPS/hosting guides and explains how cloud
deployments work at a high level.
## Pick a provider
- **Railway** (one‑click + browser setup): [Railway](/railway)
- **Fly.io**: [Fly.io](/platforms/fly)
- **Hetzner (Docker)**: [Hetzner](/platforms/hetzner)
- **exe.dev** (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev)
- **AWS (EC2/Lightsail/free tier)**: works well too. Video guide:
https://x.com/techfrenAJ/status/2014934471095812547
## How cloud setups work
- The **Gateway runs on the VPS** and owns state + workspace.
- You connect from your laptop/phone via the **Control UI** or **Tailscale/SSH**.
- Treat the VPS as the source of truth and **back up** the state + workspace.
Remote access: [Gateway remote](/gateway/remote)
Platforms hub: [Platforms](/platforms)
## Using nodes with a VPS
You can keep the Gateway in the cloud and pair **nodes** on your local devices
(Mac/iOS/Android/headless). Nodes provide local screen/camera/canvas and `system.run`
capabilities while the Gateway stays in the cloud.
Docs: [Nodes](/nodes), [Nodes CLI](/cli/nodes)
+2 -1
View File
@@ -47,7 +47,8 @@ function mergeBlueBubblesAccountConfig(
};
const { accounts: _ignored, ...rest } = base;
const account = resolveAccountConfig(cfg, accountId) ?? {};
return { ...rest, ...account };
const chunkMode = account.chunkMode ?? rest.chunkMode ?? "length";
return { ...rest, ...account, chunkMode };
}
export function resolveBlueBubblesAccount(params: {
@@ -38,6 +38,7 @@ const bluebubblesAccountSchema = z.object({
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
textChunkLimit: z.number().int().positive().optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
mediaMaxMb: z.number().int().positive().optional(),
sendReadReceipts: z.boolean().optional(),
blockStreaming: z.boolean().optional(),
+137 -12
View File
@@ -1189,9 +1189,53 @@ describe("BlueBubbles webhook monitor", () => {
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
expect(callArgs.ctx.ReplyToBody).toBe("original message");
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
// Body uses just the ID (no sender) for token savings
expect(callArgs.ctx.Body).toContain("[Replying to id:msg-0]");
expect(callArgs.ctx.Body).toContain("original message");
// Body uses inline [[reply_to:N]] tag format
expect(callArgs.ctx.Body).toContain("[[reply_to:msg-0]]");
});
it("preserves part index prefixes in reply tags when short IDs are unavailable", async () => {
const account = createMockAccount({ dmPolicy: "open" });
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "replying now",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
chatGuid: "iMessage;-;+15551234567",
replyTo: {
guid: "p:1/msg-0",
text: "original message",
handle: { address: "+15550000000", displayName: "Alice" },
},
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
expect(callArgs.ctx.ReplyToId).toBe("p:1/msg-0");
expect(callArgs.ctx.ReplyToIdFull).toBe("p:1/msg-0");
expect(callArgs.ctx.Body).toContain("[[reply_to:p:1/msg-0]]");
});
it("hydrates missing reply sender/body from the recent-message cache", async () => {
@@ -1260,9 +1304,8 @@ describe("BlueBubbles webhook monitor", () => {
expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0");
expect(callArgs.ctx.ReplyToBody).toBe("original message (cached)");
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
// Body uses just the short ID (no sender) for token savings
expect(callArgs.ctx.Body).toContain("[Replying to id:1]");
expect(callArgs.ctx.Body).toContain("original message (cached)");
// Body uses inline [[reply_to:N]] tag format with short ID
expect(callArgs.ctx.Body).toContain("[[reply_to:1]]");
});
it("falls back to threadOriginatorGuid when reply metadata is absent", async () => {
@@ -1305,6 +1348,88 @@ describe("BlueBubbles webhook monitor", () => {
});
});
describe("tapback text parsing", () => {
it("does not rewrite tapback-like text without metadata", async () => {
const account = createMockAccount({ dmPolicy: "open" });
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "Loved this idea",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
expect(callArgs.ctx.RawBody).toBe("Loved this idea");
expect(callArgs.ctx.Body).toContain("Loved this idea");
expect(callArgs.ctx.Body).not.toContain("reacted with");
});
it("parses tapback text with custom emoji when metadata is present", async () => {
const account = createMockAccount({ dmPolicy: "open" });
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: 'Reacted 😅 to "nice one"',
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-2",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
expect(callArgs.ctx.RawBody).toBe("reacted with 😅");
expect(callArgs.ctx.Body).toContain("reacted with 😅");
expect(callArgs.ctx.Body).not.toContain("[[reply_to:");
});
});
describe("ack reactions", () => {
it("sends ack reaction when configured", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
@@ -1759,7 +1884,7 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("reaction added"),
expect.stringContaining("reacted with ❤️ [[reply_to:"),
expect.any(Object),
);
});
@@ -1799,7 +1924,7 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("reaction removed"),
expect.stringContaining("removed ❤️ reaction [[reply_to:"),
expect.any(Object),
);
});
@@ -1905,7 +2030,7 @@ describe("BlueBubbles webhook monitor", () => {
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-uuid-12345",
guid: "p:1/msg-uuid-12345",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
@@ -1921,7 +2046,7 @@ describe("BlueBubbles webhook monitor", () => {
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
// MessageSid should be short ID "1" instead of full UUID
expect(callArgs.ctx.MessageSid).toBe("1");
expect(callArgs.ctx.MessageSidFull).toBe("msg-uuid-12345");
expect(callArgs.ctx.MessageSidFull).toBe("p:1/msg-uuid-12345");
});
it("resolves short ID back to UUID", async () => {
@@ -1945,7 +2070,7 @@ describe("BlueBubbles webhook monitor", () => {
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-uuid-12345",
guid: "p:1/msg-uuid-12345",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
@@ -1958,7 +2083,7 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
// The short ID "1" should resolve back to the full UUID
expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345");
expect(resolveBlueBubblesMessageId("1")).toBe("p:1/msg-uuid-12345");
});
it("returns UUID unchanged when not in cache", () => {
+205 -35
View File
@@ -55,7 +55,7 @@ type BlueBubblesReplyCacheEntry = {
// Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body.
const blueBubblesReplyCacheByMessageId = new Map<string, BlueBubblesReplyCacheEntry>();
// Bidirectional maps for short ID ↔ UUID resolution (token savings optimization)
// Bidirectional maps for short ID ↔ message GUID resolution (token savings optimization)
const blueBubblesShortIdToUuid = new Map<string, string>();
const blueBubblesUuidToShortId = new Map<string, string>();
let blueBubblesShortIdCounter = 0;
@@ -78,7 +78,7 @@ function rememberBlueBubblesReplyCache(
return { ...entry, shortId: "" };
}
// Check if we already have a short ID for this UUID
// Check if we already have a short ID for this GUID
let shortId = blueBubblesUuidToShortId.get(messageId);
if (!shortId) {
shortId = generateShortId();
@@ -86,7 +86,7 @@ function rememberBlueBubblesReplyCache(
blueBubblesUuidToShortId.set(messageId, shortId);
}
const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, shortId };
const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, messageId, shortId };
// Refresh insertion order.
blueBubblesReplyCacheByMessageId.delete(messageId);
@@ -122,8 +122,8 @@ function rememberBlueBubblesReplyCache(
}
/**
* Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles UUID.
* Returns the input unchanged if it's already a UUID or not found in the mapping.
* Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles GUID.
* Returns the input unchanged if it's already a GUID or not found in the mapping.
*/
export function resolveBlueBubblesMessageId(
shortOrUuid: string,
@@ -159,7 +159,7 @@ export function _resetBlueBubblesShortIdState(): void {
}
/**
* Gets the short ID for a UUID, if one exists.
* Gets the short ID for a message GUID, if one exists.
*/
function getShortIdForUuid(uuid: string): string | undefined {
return blueBubblesUuidToShortId.get(uuid.trim());
@@ -390,29 +390,15 @@ function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
return "";
}
const REPLY_BODY_TRUNCATE_LENGTH = 60;
function formatReplyContext(message: {
// Returns inline reply tag like "[[reply_to:4]]" for prepending to message body
function formatReplyTag(message: {
replyToId?: string;
replyToShortId?: string;
replyToBody?: string;
replyToSender?: string;
}): string | null {
if (!message.replyToId && !message.replyToBody && !message.replyToSender) return null;
// Prefer short ID for token savings
const displayId = message.replyToShortId || message.replyToId;
// Only include sender if we don't have an ID (fallback)
const label = displayId ? `id:${displayId}` : (message.replyToSender?.trim() || "unknown");
const rawBody = message.replyToBody?.trim();
if (!rawBody) {
return `[Replying to ${label}]\n[/Replying]`;
}
// Truncate long reply bodies for token savings
const body =
rawBody.length > REPLY_BODY_TRUNCATE_LENGTH
? `${rawBody.slice(0, REPLY_BODY_TRUNCATE_LENGTH)}…`
: rawBody;
return `[Replying to ${label}]\n${body}\n[/Replying]`;
// Prefer short ID
const rawId = message.replyToShortId || message.replyToId;
if (!rawId) return null;
return `[[reply_to:${rawId}]]`;
}
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
@@ -629,6 +615,10 @@ type NormalizedWebhookMessage = {
fromMe?: boolean;
attachments?: BlueBubblesAttachment[];
balloonBundleId?: string;
associatedMessageGuid?: string;
associatedMessageType?: number;
associatedMessageEmoji?: string;
isTapback?: boolean;
participants?: BlueBubblesParticipant[];
replyToId?: string;
replyToBody?: string;
@@ -665,6 +655,113 @@ const REACTION_TYPE_MAP = new Map<number, { emoji: string; action: "added" | "re
[3005, { emoji: "❓", action: "removed" }],
]);
// Maps tapback text patterns (e.g., "Loved", "Liked") to emoji + action
const TAPBACK_TEXT_MAP = new Map<string, { emoji: string; action: "added" | "removed" }>([
["loved", { emoji: "❤️", action: "added" }],
["liked", { emoji: "👍", action: "added" }],
["disliked", { emoji: "👎", action: "added" }],
["laughed at", { emoji: "😂", action: "added" }],
["emphasized", { emoji: "‼️", action: "added" }],
["questioned", { emoji: "❓", action: "added" }],
// Removal patterns (e.g., "Removed a heart from")
["removed a heart from", { emoji: "❤️", action: "removed" }],
["removed a like from", { emoji: "👍", action: "removed" }],
["removed a dislike from", { emoji: "👎", action: "removed" }],
["removed a laugh from", { emoji: "😂", action: "removed" }],
["removed an emphasis from", { emoji: "‼️", action: "removed" }],
["removed a question from", { emoji: "❓", action: "removed" }],
]);
const TAPBACK_EMOJI_REGEX =
/(?:\p{Regional_Indicator}{2})|(?:[0-9#*]\uFE0F?\u20E3)|(?:\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?)*)/u;
function extractFirstEmoji(text: string): string | null {
const match = text.match(TAPBACK_EMOJI_REGEX);
return match ? match[0] : null;
}
function extractQuotedTapbackText(text: string): string | null {
const match = text.match(/[“"]([^”"]+)[”"]/s);
return match ? match[1] : null;
}
function isTapbackAssociatedType(type: number | undefined): boolean {
return typeof type === "number" && Number.isFinite(type) && type >= 2000 && type < 4000;
}
function resolveTapbackActionHint(type: number | undefined): "added" | "removed" | undefined {
if (typeof type !== "number" || !Number.isFinite(type)) return undefined;
if (type >= 3000 && type < 4000) return "removed";
if (type >= 2000 && type < 3000) return "added";
return undefined;
}
function resolveTapbackContext(message: NormalizedWebhookMessage): {
emojiHint?: string;
actionHint?: "added" | "removed";
replyToId?: string;
} | null {
const associatedType = message.associatedMessageType;
const hasTapbackType = isTapbackAssociatedType(associatedType);
const hasTapbackMarker = Boolean(message.associatedMessageEmoji) || Boolean(message.isTapback);
if (!hasTapbackType && !hasTapbackMarker) return null;
const replyToId = message.associatedMessageGuid?.trim() || message.replyToId?.trim() || undefined;
const actionHint = resolveTapbackActionHint(associatedType);
const emojiHint =
message.associatedMessageEmoji?.trim() || REACTION_TYPE_MAP.get(associatedType ?? -1)?.emoji;
return { emojiHint, actionHint, replyToId };
}
// Detects tapback text patterns like 'Loved "message"' and converts to structured format
function parseTapbackText(params: {
text: string;
emojiHint?: string;
actionHint?: "added" | "removed";
requireQuoted?: boolean;
}): {
emoji: string;
action: "added" | "removed";
quotedText: string;
} | null {
const trimmed = params.text.trim();
const lower = trimmed.toLowerCase();
if (!trimmed) return null;
for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) {
if (lower.startsWith(pattern)) {
// Extract quoted text if present (e.g., 'Loved "hello"' -> "hello")
const afterPattern = trimmed.slice(pattern.length).trim();
if (params.requireQuoted) {
const strictMatch = afterPattern.match(/^[“"](.+)[”"]$/s);
if (!strictMatch) return null;
return { emoji, action, quotedText: strictMatch[1] };
}
const quotedText =
extractQuotedTapbackText(afterPattern) ?? extractQuotedTapbackText(trimmed) ?? afterPattern;
return { emoji, action, quotedText };
}
}
if (lower.startsWith("reacted")) {
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
if (!emoji) return null;
const quotedText = extractQuotedTapbackText(trimmed);
if (params.requireQuoted && !quotedText) return null;
const fallback = trimmed.slice("reacted".length).trim();
return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback };
}
if (lower.startsWith("removed")) {
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
if (!emoji) return null;
const quotedText = extractQuotedTapbackText(trimmed);
if (params.requireQuoted && !quotedText) return null;
const fallback = trimmed.slice("removed".length).trim();
return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback };
}
return null;
}
function maskSecret(value: string): string {
if (value.length <= 6) return "***";
return `${value.slice(0, 2)}***${value.slice(-2)}`;
@@ -805,6 +902,25 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
readString(message, "messageId") ??
undefined;
const balloonBundleId = readString(message, "balloonBundleId");
const associatedMessageGuid =
readString(message, "associatedMessageGuid") ??
readString(message, "associated_message_guid") ??
readString(message, "associatedMessageId") ??
undefined;
const associatedMessageType =
readNumberLike(message, "associatedMessageType") ??
readNumberLike(message, "associated_message_type");
const associatedMessageEmoji =
readString(message, "associatedMessageEmoji") ??
readString(message, "associated_message_emoji") ??
readString(message, "reactionEmoji") ??
readString(message, "reaction_emoji") ??
undefined;
const isTapback =
readBoolean(message, "isTapback") ??
readBoolean(message, "is_tapback") ??
readBoolean(message, "tapback") ??
undefined;
const timestampRaw =
readNumber(message, "date") ??
@@ -835,6 +951,10 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
fromMe,
attachments: extractAttachments(message),
balloonBundleId,
associatedMessageGuid,
associatedMessageType,
associatedMessageEmoji,
isTapback,
participants: normalizedParticipants,
replyToId: replyMetadata.replyToId,
replyToBody: replyMetadata.replyToBody,
@@ -856,8 +976,13 @@ function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedW
if (!associatedGuid || associatedType === undefined) return null;
const mapping = REACTION_TYPE_MAP.get(associatedType);
const emoji = mapping?.emoji ?? `reaction:${associatedType}`;
const action = mapping?.action ?? "added";
const associatedEmoji =
readString(message, "associatedMessageEmoji") ??
readString(message, "associated_message_emoji") ??
readString(message, "reactionEmoji") ??
readString(message, "reaction_emoji");
const emoji = (associatedEmoji?.trim() || mapping?.emoji) ?? `reaction:${associatedType}`;
const action = mapping?.action ?? resolveTapbackActionHint(associatedType) ?? "added";
const handleValue = message.handle ?? message.sender;
const handle =
@@ -1122,7 +1247,21 @@ async function processMessage(
const text = message.text.trim();
const attachments = message.attachments ?? [];
const placeholder = buildMessagePlaceholder(message);
const rawBody = text || placeholder;
// Check if text is a tapback pattern (e.g., 'Loved "hello"') and transform to emoji format
// For tapbacks, we'll append [[reply_to:N]] at the end; for regular messages, prepend it
const tapbackContext = resolveTapbackContext(message);
const tapbackParsed = parseTapbackText({
text,
emojiHint: tapbackContext?.emojiHint,
actionHint: tapbackContext?.actionHint,
requireQuoted: !tapbackContext,
});
const isTapbackMessage = Boolean(tapbackParsed);
const rawBody = tapbackParsed
? tapbackParsed.action === "removed"
? `removed ${tapbackParsed.emoji} reaction`
: `reacted with ${tapbackParsed.emoji}`
: text || placeholder;
const cacheMessageId = message.messageId?.trim();
let messageShortId: string | undefined;
@@ -1449,7 +1588,11 @@ async function processMessage(
let replyToSender = message.replyToSender;
let replyToShortId: string | undefined;
if (replyToId && (!replyToBody || !replyToSender)) {
if (isTapbackMessage && tapbackContext?.replyToId) {
replyToId = tapbackContext.replyToId;
}
if (replyToId) {
const cached = resolveReplyContextFromCache({
accountId: account.accountId,
replyToId,
@@ -1477,8 +1620,15 @@ async function processMessage(
replyToShortId = getShortIdForUuid(replyToId);
}
const replyContext = formatReplyContext({ replyToId, replyToShortId, replyToBody, replyToSender });
const baseBody = replyContext ? `${rawBody}\n\n${replyContext}` : rawBody;
// Use inline [[reply_to:N]] tag format
// For tapbacks/reactions: append at end (e.g., "reacted with ❤️ [[reply_to:4]]")
// For regular replies: prepend at start (e.g., "[[reply_to:4]] Awesome")
const replyTag = formatReplyTag({ replyToId, replyToShortId });
const baseBody = replyTag
? isTapbackMessage
? `${rawBody} ${replyTag}`
: `${replyTag} ${rawBody}`
: rawBody;
const fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`;
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
const groupMembers = isGroup
@@ -1701,16 +1851,21 @@ async function processMessage(
account.config.textChunkLimit && account.config.textChunkLimit > 0
? account.config.textChunkLimit
: DEFAULT_TEXT_LIMIT;
const chunkMode = account.config.chunkMode ?? "length";
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: config,
channel: "bluebubbles",
accountId: account.accountId,
});
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
const chunks =
chunkMode === "newline"
? core.channel.text.chunkTextWithMode(text, textLimit, chunkMode)
: core.channel.text.chunkMarkdownText(text, textLimit);
if (!chunks.length && text) chunks.push(text);
if (!chunks.length) return;
for (const chunk of chunks) {
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
cfg: config,
accountId: account.accountId,
@@ -1719,6 +1874,17 @@ async function processMessage(
maybeEnqueueOutboundMessageId(result.messageId, chunk);
sentMessage = true;
statusSink?.({ lastOutboundAt: Date.now() });
// In newline mode, restart typing after each chunk if more chunks remain
// Small delay allows the Apple API to finish clearing the typing state from message send
if (chunkMode === "newline" && i < chunks.length - 1 && chatGuidForActions) {
await new Promise((r) => setTimeout(r, 150));
sendBlueBubblesTyping(chatGuidForActions, true, {
cfg: config,
accountId: account.accountId,
}).catch(() => {
// Ignore typing errors
});
}
}
},
onReplyStart: async () => {
@@ -1871,7 +2037,11 @@ async function processReaction(
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
// Use short ID for token savings
const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId;
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${messageDisplayId}`;
// Format: "Tyler reacted with ❤️ [[reply_to:5]]" or "Tyler removed ❤️ reaction [[reply_to:5]]"
const text =
reaction.action === "removed"
? `${senderLabel} removed ${reaction.emoji} reaction [[reply_to:${messageDisplayId}]]${chatLabel}`
: `${senderLabel} reacted with ${reaction.emoji} [[reply_to:${messageDisplayId}]]${chatLabel}`;
core.system.enqueueSystemEvent(text, {
sessionKey: route.sessionKey,
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
+2
View File
@@ -38,6 +38,8 @@ export type BlueBubblesAccountConfig = {
dms?: Record<string, unknown>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "newline" (default) splits on every newline; "length" splits by size. */
chunkMode?: "length" | "newline";
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: Record<string, unknown>;
@@ -0,0 +1,11 @@
{
"id": "googlechat",
"channels": [
"googlechat"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+20
View File
@@ -0,0 +1,20 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { googlechatDock, googlechatPlugin } from "./src/channel.js";
import { handleGoogleChatWebhookRequest } from "./src/monitor.js";
import { setGoogleChatRuntime } from "./src/runtime.js";
const plugin = {
id: "googlechat",
name: "Google Chat",
description: "Clawdbot Google Chat channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setGoogleChatRuntime(api.runtime);
api.registerChannel({ plugin: googlechatPlugin, dock: googlechatDock });
api.registerHttpHandler(handleGoogleChatWebhookRequest);
},
};
export default plugin;
+39
View File
@@ -0,0 +1,39 @@
{
"name": "@clawdbot/googlechat",
"version": "2026.1.22",
"type": "module",
"description": "Clawdbot Google Chat channel plugin",
"clawdbot": {
"extensions": [
"./index.ts"
],
"channel": {
"id": "googlechat",
"label": "Google Chat",
"selectionLabel": "Google Chat (Chat API)",
"detailLabel": "Google Chat",
"docsPath": "/channels/googlechat",
"docsLabel": "googlechat",
"blurb": "Google Workspace Chat app via HTTP webhooks.",
"aliases": [
"gchat",
"google-chat"
],
"order": 55
},
"install": {
"npmSpec": "@clawdbot/googlechat",
"localPath": "extensions/googlechat",
"defaultChoice": "npm"
}
},
"dependencies": {
"google-auth-library": "^10.5.0"
},
"devDependencies": {
"clawdbot": "workspace:*"
},
"peerDependencies": {
"clawdbot": ">=2026.1.24-0"
}
}
+133
View File
@@ -0,0 +1,133 @@
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
import type { GoogleChatAccountConfig, GoogleChatConfig } from "./types.config.js";
export type GoogleChatCredentialSource = "file" | "inline" | "env" | "none";
export type ResolvedGoogleChatAccount = {
accountId: string;
name?: string;
enabled: boolean;
config: GoogleChatAccountConfig;
credentialSource: GoogleChatCredentialSource;
credentials?: Record<string, unknown>;
credentialsFile?: string;
};
const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT";
const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
const accounts = (cfg.channels?.["googlechat"] as GoogleChatConfig | undefined)?.accounts;
if (!accounts || typeof accounts !== "object") return [];
return Object.keys(accounts).filter(Boolean);
}
export function listGoogleChatAccountIds(cfg: ClawdbotConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
return ids.sort((a, b) => a.localeCompare(b));
}
export function resolveDefaultGoogleChatAccountId(cfg: ClawdbotConfig): string {
const channel = cfg.channels?.["googlechat"] as GoogleChatConfig | undefined;
if (channel?.defaultAccount?.trim()) return channel.defaultAccount.trim();
const ids = listGoogleChatAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
function resolveAccountConfig(
cfg: ClawdbotConfig,
accountId: string,
): GoogleChatAccountConfig | undefined {
const accounts = (cfg.channels?.["googlechat"] as GoogleChatConfig | undefined)?.accounts;
if (!accounts || typeof accounts !== "object") return undefined;
return accounts[accountId] as GoogleChatAccountConfig | undefined;
}
function mergeGoogleChatAccountConfig(
cfg: ClawdbotConfig,
accountId: string,
): GoogleChatAccountConfig {
const raw = (cfg.channels?.["googlechat"] ?? {}) as GoogleChatConfig;
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
const account = resolveAccountConfig(cfg, accountId) ?? {};
return { ...base, ...account } as GoogleChatAccountConfig;
}
function parseServiceAccount(value: unknown): Record<string, unknown> | null {
if (value && typeof value === "object") return value as Record<string, unknown>;
if (typeof value !== "string") return null;
const trimmed = value.trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed) as Record<string, unknown>;
} catch {
return null;
}
}
function resolveCredentialsFromConfig(params: {
accountId: string;
account: GoogleChatAccountConfig;
}): {
credentials?: Record<string, unknown>;
credentialsFile?: string;
source: GoogleChatCredentialSource;
} {
const { account, accountId } = params;
const inline = parseServiceAccount(account.serviceAccount);
if (inline) {
return { credentials: inline, source: "inline" };
}
const file = account.serviceAccountFile?.trim();
if (file) {
return { credentialsFile: file, source: "file" };
}
if (accountId === DEFAULT_ACCOUNT_ID) {
const envJson = process.env[ENV_SERVICE_ACCOUNT];
const envInline = parseServiceAccount(envJson);
if (envInline) {
return { credentials: envInline, source: "env" };
}
const envFile = process.env[ENV_SERVICE_ACCOUNT_FILE]?.trim();
if (envFile) {
return { credentialsFile: envFile, source: "env" };
}
}
return { source: "none" };
}
export function resolveGoogleChatAccount(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}): ResolvedGoogleChatAccount {
const accountId = normalizeAccountId(params.accountId);
const baseEnabled =
(params.cfg.channels?.["googlechat"] as GoogleChatConfig | undefined)?.enabled !== false;
const merged = mergeGoogleChatAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const credentials = resolveCredentialsFromConfig({ accountId, account: merged });
return {
accountId,
name: merged.name?.trim() || undefined,
enabled,
config: merged,
credentialSource: credentials.source,
credentials: credentials.credentials,
credentialsFile: credentials.credentialsFile,
};
}
export function listEnabledGoogleChatAccounts(cfg: ClawdbotConfig): ResolvedGoogleChatAccount[] {
return listGoogleChatAccountIds(cfg)
.map((accountId) => resolveGoogleChatAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}
+162
View File
@@ -0,0 +1,162 @@
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
ClawdbotConfig,
} from "clawdbot/plugin-sdk";
import {
createActionGate,
jsonResult,
readNumberParam,
readReactionParams,
readStringParam,
} from "clawdbot/plugin-sdk";
import { listEnabledGoogleChatAccounts, resolveGoogleChatAccount } from "./accounts.js";
import {
createGoogleChatReaction,
deleteGoogleChatReaction,
listGoogleChatReactions,
sendGoogleChatMessage,
uploadGoogleChatAttachment,
} from "./api.js";
import { getGoogleChatRuntime } from "./runtime.js";
import { resolveGoogleChatOutboundSpace } from "./targets.js";
const providerId = "googlechat";
function listEnabledAccounts(cfg: ClawdbotConfig) {
return listEnabledGoogleChatAccounts(cfg).filter(
(account) => account.enabled && account.credentialSource !== "none",
);
}
function isReactionsEnabled(accounts: ReturnType<typeof listEnabledAccounts>, cfg: ClawdbotConfig) {
for (const account of accounts) {
const gate = createActionGate(
(account.config.actions ?? (cfg.channels?.["googlechat"] as { actions?: unknown })?.actions) as Record<
string,
boolean | undefined
>,
);
if (gate("reactions")) return true;
}
return false;
}
function resolveAppUserNames(account: { config: { botUser?: string | null } }) {
return new Set(["users/app", account.config.botUser?.trim()].filter(Boolean) as string[]);
}
export const googlechatMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const accounts = listEnabledAccounts(cfg as ClawdbotConfig);
if (accounts.length === 0) return [];
const actions = new Set<ChannelMessageActionName>([]);
actions.add("send");
if (isReactionsEnabled(accounts, cfg as ClawdbotConfig)) {
actions.add("react");
actions.add("reactions");
}
return Array.from(actions);
},
extractToolSend: ({ args }) => {
const action = typeof args.action === "string" ? args.action.trim() : "";
if (action !== "sendMessage") return null;
const to = typeof args.to === "string" ? args.to : undefined;
if (!to) return null;
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
return { to, accountId };
},
handleAction: async ({ action, params, cfg, accountId }) => {
const account = resolveGoogleChatAccount({
cfg: cfg as ClawdbotConfig,
accountId,
});
if (account.credentialSource === "none") {
throw new Error("Google Chat credentials are missing.");
}
if (action === "send") {
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "message", {
required: true,
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "media", { trim: false });
const threadId = readStringParam(params, "threadId") ?? readStringParam(params, "replyTo");
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
if (mediaUrl) {
const core = getGoogleChatRuntime();
const maxBytes = (account.config.mediaMaxMb ?? 20) * 1024 * 1024;
const loaded = await core.channel.media.fetchRemoteMedia(mediaUrl, { maxBytes });
const upload = await uploadGoogleChatAttachment({
account,
space,
filename: loaded.filename ?? "attachment",
buffer: loaded.buffer,
contentType: loaded.contentType,
});
await sendGoogleChatMessage({
account,
space,
text: content,
thread: threadId ?? undefined,
attachments: upload.attachmentUploadToken
? [{ attachmentUploadToken: upload.attachmentUploadToken, contentName: loaded.filename }]
: undefined,
});
return jsonResult({ ok: true, to: space });
}
await sendGoogleChatMessage({
account,
space,
text: content,
thread: threadId ?? undefined,
});
return jsonResult({ ok: true, to: space });
}
if (action === "react") {
const messageName = readStringParam(params, "messageId", { required: true });
const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove a Google Chat reaction.",
});
if (remove || isEmpty) {
const reactions = await listGoogleChatReactions({ account, messageName });
const appUsers = resolveAppUserNames(account);
const toRemove = reactions.filter((reaction) => {
const userName = reaction.user?.name?.trim();
if (appUsers.size > 0 && !appUsers.has(userName ?? "")) return false;
if (emoji) return reaction.emoji?.unicode === emoji;
return true;
});
for (const reaction of toRemove) {
if (!reaction.name) continue;
await deleteGoogleChatReaction({ account, reactionName: reaction.name });
}
return jsonResult({ ok: true, removed: toRemove.length });
}
const reaction = await createGoogleChatReaction({
account,
messageName,
emoji,
});
return jsonResult({ ok: true, reaction });
}
if (action === "reactions") {
const messageName = readStringParam(params, "messageId", { required: true });
const limit = readNumberParam(params, "limit", { integer: true });
const reactions = await listGoogleChatReactions({
account,
messageName,
limit: limit ?? undefined,
});
return jsonResult({ ok: true, reactions });
}
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
},
};
+62
View File
@@ -0,0 +1,62 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { downloadGoogleChatMedia } from "./api.js";
vi.mock("./auth.js", () => ({
getGoogleChatAccessToken: vi.fn().mockResolvedValue("token"),
}));
const account = {
accountId: "default",
enabled: true,
credentialSource: "inline",
config: {},
} as ResolvedGoogleChatAccount;
describe("downloadGoogleChatMedia", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("rejects when content-length exceeds max bytes", async () => {
const body = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3]));
controller.close();
},
});
const response = new Response(body, {
status: 200,
headers: { "content-length": "50", "content-type": "application/octet-stream" },
});
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
await expect(
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
).rejects.toThrow(/max bytes/i);
});
it("rejects when streamed payload exceeds max bytes", async () => {
const chunks = [new Uint8Array(6), new Uint8Array(6)];
let index = 0;
const body = new ReadableStream({
pull(controller) {
if (index < chunks.length) {
controller.enqueue(chunks[index++]);
} else {
controller.close();
}
},
});
const response = new Response(body, {
status: 200,
headers: { "content-type": "application/octet-stream" },
});
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(response));
await expect(
downloadGoogleChatMedia({ account, resourceName: "media/123", maxBytes: 10 }),
).rejects.toThrow(/max bytes/i);
});
});
+259
View File
@@ -0,0 +1,259 @@
import crypto from "node:crypto";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { getGoogleChatAccessToken } from "./auth.js";
import type { GoogleChatReaction } from "./types.js";
const CHAT_API_BASE = "https://chat.googleapis.com/v1";
const CHAT_UPLOAD_BASE = "https://chat.googleapis.com/upload/v1";
async function fetchJson<T>(
account: ResolvedGoogleChatAccount,
url: string,
init: RequestInit,
): Promise<T> {
const token = await getGoogleChatAccessToken(account);
const res = await fetch(url, {
...init,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...(init.headers ?? {}),
},
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
}
return (await res.json()) as T;
}
async function fetchOk(
account: ResolvedGoogleChatAccount,
url: string,
init: RequestInit,
): Promise<void> {
const token = await getGoogleChatAccessToken(account);
const res = await fetch(url, {
...init,
headers: {
Authorization: `Bearer ${token}`,
...(init.headers ?? {}),
},
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
}
}
async function fetchBuffer(
account: ResolvedGoogleChatAccount,
url: string,
init?: RequestInit,
options?: { maxBytes?: number },
): Promise<{ buffer: Buffer; contentType?: string }> {
const token = await getGoogleChatAccessToken(account);
const res = await fetch(url, {
...init,
headers: {
Authorization: `Bearer ${token}`,
...(init?.headers ?? {}),
},
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Google Chat API ${res.status}: ${text || res.statusText}`);
}
const maxBytes = options?.maxBytes;
const lengthHeader = res.headers.get("content-length");
if (maxBytes && lengthHeader) {
const length = Number(lengthHeader);
if (Number.isFinite(length) && length > maxBytes) {
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
}
}
if (!maxBytes || !res.body) {
const buffer = Buffer.from(await res.arrayBuffer());
const contentType = res.headers.get("content-type") ?? undefined;
return { buffer, contentType };
}
const reader = res.body.getReader();
const chunks: Buffer[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
total += value.length;
if (total > maxBytes) {
await reader.cancel();
throw new Error(`Google Chat media exceeds max bytes (${maxBytes})`);
}
chunks.push(Buffer.from(value));
}
const buffer = Buffer.concat(chunks, total);
const contentType = res.headers.get("content-type") ?? undefined;
return { buffer, contentType };
}
export async function sendGoogleChatMessage(params: {
account: ResolvedGoogleChatAccount;
space: string;
text?: string;
thread?: string;
attachments?: Array<{ attachmentUploadToken: string; contentName?: string }>;
}): Promise<{ messageName?: string } | null> {
const { account, space, text, thread, attachments } = params;
const body: Record<string, unknown> = {};
if (text) body.text = text;
if (thread) body.thread = { name: thread };
if (attachments && attachments.length > 0) {
body.attachment = attachments.map((item) => ({
attachmentDataRef: { attachmentUploadToken: item.attachmentUploadToken },
...(item.contentName ? { contentName: item.contentName } : {}),
}));
}
const url = `${CHAT_API_BASE}/${space}/messages`;
const result = await fetchJson<{ name?: string }>(account, url, {
method: "POST",
body: JSON.stringify(body),
});
return result ? { messageName: result.name } : null;
}
export async function updateGoogleChatMessage(params: {
account: ResolvedGoogleChatAccount;
messageName: string;
text: string;
}): Promise<{ messageName?: string }> {
const { account, messageName, text } = params;
const url = `${CHAT_API_BASE}/${messageName}?updateMask=text`;
const result = await fetchJson<{ name?: string }>(account, url, {
method: "PATCH",
body: JSON.stringify({ text }),
});
return { messageName: result.name };
}
export async function deleteGoogleChatMessage(params: {
account: ResolvedGoogleChatAccount;
messageName: string;
}): Promise<void> {
const { account, messageName } = params;
const url = `${CHAT_API_BASE}/${messageName}`;
await fetchOk(account, url, { method: "DELETE" });
}
export async function uploadGoogleChatAttachment(params: {
account: ResolvedGoogleChatAccount;
space: string;
filename: string;
buffer: Buffer;
contentType?: string;
}): Promise<{ attachmentUploadToken?: string }> {
const { account, space, filename, buffer, contentType } = params;
const boundary = `clawdbot-${crypto.randomUUID()}`;
const metadata = JSON.stringify({ filename });
const header = `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${metadata}\r\n`;
const mediaHeader = `--${boundary}\r\nContent-Type: ${contentType ?? "application/octet-stream"}\r\n\r\n`;
const footer = `\r\n--${boundary}--\r\n`;
const body = Buffer.concat([
Buffer.from(header, "utf8"),
Buffer.from(mediaHeader, "utf8"),
buffer,
Buffer.from(footer, "utf8"),
]);
const token = await getGoogleChatAccessToken(account);
const url = `${CHAT_UPLOAD_BASE}/${space}/attachments:upload?uploadType=multipart`;
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": `multipart/related; boundary=${boundary}`,
},
body,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Google Chat upload ${res.status}: ${text || res.statusText}`);
}
const payload = (await res.json()) as {
attachmentDataRef?: { attachmentUploadToken?: string };
};
return { attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken };
}
export async function downloadGoogleChatMedia(params: {
account: ResolvedGoogleChatAccount;
resourceName: string;
maxBytes?: number;
}): Promise<{ buffer: Buffer; contentType?: string }> {
const { account, resourceName, maxBytes } = params;
const url = `${CHAT_API_BASE}/media/${resourceName}?alt=media`;
return await fetchBuffer(account, url, undefined, { maxBytes });
}
export async function createGoogleChatReaction(params: {
account: ResolvedGoogleChatAccount;
messageName: string;
emoji: string;
}): Promise<GoogleChatReaction> {
const { account, messageName, emoji } = params;
const url = `${CHAT_API_BASE}/${messageName}/reactions`;
return await fetchJson<GoogleChatReaction>(account, url, {
method: "POST",
body: JSON.stringify({ emoji: { unicode: emoji } }),
});
}
export async function listGoogleChatReactions(params: {
account: ResolvedGoogleChatAccount;
messageName: string;
limit?: number;
}): Promise<GoogleChatReaction[]> {
const { account, messageName, limit } = params;
const url = new URL(`${CHAT_API_BASE}/${messageName}/reactions`);
if (limit && limit > 0) url.searchParams.set("pageSize", String(limit));
const result = await fetchJson<{ reactions?: GoogleChatReaction[] }>(account, url.toString(), {
method: "GET",
});
return result.reactions ?? [];
}
export async function deleteGoogleChatReaction(params: {
account: ResolvedGoogleChatAccount;
reactionName: string;
}): Promise<void> {
const { account, reactionName } = params;
const url = `${CHAT_API_BASE}/${reactionName}`;
await fetchOk(account, url, { method: "DELETE" });
}
export async function findGoogleChatDirectMessage(params: {
account: ResolvedGoogleChatAccount;
userName: string;
}): Promise<{ name?: string; displayName?: string } | null> {
const { account, userName } = params;
const url = new URL(`${CHAT_API_BASE}/spaces:findDirectMessage`);
url.searchParams.set("name", userName);
return await fetchJson<{ name?: string; displayName?: string }>(account, url.toString(), {
method: "GET",
});
}
export async function probeGoogleChat(account: ResolvedGoogleChatAccount): Promise<{
ok: boolean;
status?: number;
error?: string;
}> {
try {
const url = new URL(`${CHAT_API_BASE}/spaces`);
url.searchParams.set("pageSize", "1");
await fetchJson<Record<string, unknown>>(account, url.toString(), { method: "GET" });
return { ok: true };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
+113
View File
@@ -0,0 +1,113 @@
import { GoogleAuth, OAuth2Client } from "google-auth-library";
import type { ResolvedGoogleChatAccount } from "./accounts.js";
const CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot";
const CHAT_ISSUER = "chat@system.gserviceaccount.com";
// Google Workspace Add-ons use a different service account pattern
const ADDON_ISSUER_PATTERN = /^service-\d+@gcp-sa-gsuiteaddons\.iam\.gserviceaccount\.com$/;
const CHAT_CERTS_URL =
"https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com";
const authCache = new Map<string, { key: string; auth: GoogleAuth }>();
const verifyClient = new OAuth2Client();
let cachedCerts: { fetchedAt: number; certs: Record<string, string> } | null = null;
function buildAuthKey(account: ResolvedGoogleChatAccount): string {
if (account.credentialsFile) return `file:${account.credentialsFile}`;
if (account.credentials) return `inline:${JSON.stringify(account.credentials)}`;
return "none";
}
function getAuthInstance(account: ResolvedGoogleChatAccount): GoogleAuth {
const key = buildAuthKey(account);
const cached = authCache.get(account.accountId);
if (cached && cached.key === key) return cached.auth;
if (account.credentialsFile) {
const auth = new GoogleAuth({ keyFile: account.credentialsFile, scopes: [CHAT_SCOPE] });
authCache.set(account.accountId, { key, auth });
return auth;
}
if (account.credentials) {
const auth = new GoogleAuth({ credentials: account.credentials, scopes: [CHAT_SCOPE] });
authCache.set(account.accountId, { key, auth });
return auth;
}
const auth = new GoogleAuth({ scopes: [CHAT_SCOPE] });
authCache.set(account.accountId, { key, auth });
return auth;
}
export async function getGoogleChatAccessToken(
account: ResolvedGoogleChatAccount,
): Promise<string> {
const auth = getAuthInstance(account);
const client = await auth.getClient();
const access = await client.getAccessToken();
const token = typeof access === "string" ? access : access?.token;
if (!token) {
throw new Error("Missing Google Chat access token");
}
return token;
}
async function fetchChatCerts(): Promise<Record<string, string>> {
const now = Date.now();
if (cachedCerts && now - cachedCerts.fetchedAt < 10 * 60 * 1000) {
return cachedCerts.certs;
}
const res = await fetch(CHAT_CERTS_URL);
if (!res.ok) {
throw new Error(`Failed to fetch Chat certs (${res.status})`);
}
const certs = (await res.json()) as Record<string, string>;
cachedCerts = { fetchedAt: now, certs };
return certs;
}
export type GoogleChatAudienceType = "app-url" | "project-number";
export async function verifyGoogleChatRequest(params: {
bearer?: string | null;
audienceType?: GoogleChatAudienceType | null;
audience?: string | null;
}): Promise<{ ok: boolean; reason?: string }> {
const bearer = params.bearer?.trim();
if (!bearer) return { ok: false, reason: "missing token" };
const audience = params.audience?.trim();
if (!audience) return { ok: false, reason: "missing audience" };
const audienceType = params.audienceType ?? null;
if (audienceType === "app-url") {
try {
const ticket = await verifyClient.verifyIdToken({
idToken: bearer,
audience,
});
const payload = ticket.getPayload();
const email = payload?.email ?? "";
const ok = payload?.email_verified && (email === CHAT_ISSUER || ADDON_ISSUER_PATTERN.test(email));
return ok ? { ok: true } : { ok: false, reason: `invalid issuer: ${email}` };
} catch (err) {
return { ok: false, reason: err instanceof Error ? err.message : "invalid token" };
}
}
if (audienceType === "project-number") {
try {
const certs = await fetchChatCerts();
await verifyClient.verifySignedJwtWithCertsAsync(bearer, certs, audience, [CHAT_ISSUER]);
return { ok: true };
} catch (err) {
return { ok: false, reason: err instanceof Error ? err.message : "invalid token" };
}
}
return { ok: false, reason: "unsupported audience type" };
}
export const GOOGLE_CHAT_SCOPE = CHAT_SCOPE;
+579
View File
@@ -0,0 +1,579 @@
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
getChatChannelMeta,
migrateBaseNameToDefaultAccount,
missingTargetError,
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
resolveChannelMediaMaxBytes,
resolveGoogleChatGroupRequireMention,
setAccountEnabledInConfigSection,
type ChannelDock,
type ChannelMessageActionAdapter,
type ChannelPlugin,
type ClawdbotConfig,
} from "clawdbot/plugin-sdk";
import { GoogleChatConfigSchema } from "clawdbot/plugin-sdk";
import {
listGoogleChatAccountIds,
resolveDefaultGoogleChatAccountId,
resolveGoogleChatAccount,
type ResolvedGoogleChatAccount,
} from "./accounts.js";
import { googlechatMessageActions } from "./actions.js";
import { sendGoogleChatMessage, uploadGoogleChatAttachment, probeGoogleChat } from "./api.js";
import { googlechatOnboardingAdapter } from "./onboarding.js";
import { getGoogleChatRuntime } from "./runtime.js";
import { resolveGoogleChatWebhookPath, startGoogleChatMonitor } from "./monitor.js";
import {
isGoogleChatSpaceTarget,
isGoogleChatUserTarget,
normalizeGoogleChatTarget,
resolveGoogleChatOutboundSpace,
} from "./targets.js";
const meta = getChatChannelMeta("googlechat");
const formatAllowFromEntry = (entry: string) =>
entry
.trim()
.replace(/^(googlechat|google-chat|gchat):/i, "")
.replace(/^user:/i, "")
.replace(/^users\//i, "")
.toLowerCase();
export const googlechatDock: ChannelDock = {
id: "googlechat",
capabilities: {
chatTypes: ["direct", "group", "thread"],
reactions: true,
media: true,
threads: true,
blockStreaming: true,
},
outbound: { textChunkLimit: 4000 },
config: {
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveGoogleChatAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.dm?.allowFrom ??
[]
).map((entry) => String(entry)),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry))
.filter(Boolean)
.map(formatAllowFromEntry),
},
groups: {
resolveRequireMention: resolveGoogleChatGroupRequireMention,
},
threading: {
resolveReplyToMode: ({ cfg }) => cfg.channels?.["googlechat"]?.replyToMode ?? "off",
buildToolContext: ({ context, hasRepliedRef }) => {
const threadId = context.MessageThreadId ?? context.ReplyToId;
return {
currentChannelId: context.To?.trim() || undefined,
currentThreadTs: threadId != null ? String(threadId) : undefined,
hasRepliedRef,
};
},
},
};
const googlechatActions: ChannelMessageActionAdapter = {
listActions: (ctx) => googlechatMessageActions.listActions?.(ctx) ?? [],
extractToolSend: (ctx) => googlechatMessageActions.extractToolSend?.(ctx) ?? null,
handleAction: async (ctx) => {
if (!googlechatMessageActions.handleAction) {
throw new Error("Google Chat actions are not available.");
}
return await googlechatMessageActions.handleAction(ctx);
},
};
export const googlechatPlugin: ChannelPlugin<ResolvedGoogleChatAccount> = {
id: "googlechat",
meta: { ...meta },
onboarding: googlechatOnboardingAdapter,
pairing: {
idLabel: "googlechatUserId",
normalizeAllowEntry: (entry) => formatAllowFromEntry(entry),
notifyApproval: async ({ cfg, id }) => {
const account = resolveGoogleChatAccount({ cfg: cfg as ClawdbotConfig });
if (account.credentialSource === "none") return;
const user = normalizeGoogleChatTarget(id) ?? id;
const target = isGoogleChatUserTarget(user) ? user : `users/${user}`;
const space = await resolveGoogleChatOutboundSpace({ account, target });
await sendGoogleChatMessage({
account,
space,
text: PAIRING_APPROVED_MESSAGE,
});
},
},
capabilities: {
chatTypes: ["direct", "group", "thread"],
reactions: true,
threads: true,
media: true,
nativeCommands: false,
blockStreaming: true,
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.googlechat"] },
configSchema: buildChannelConfigSchema(GoogleChatConfigSchema),
config: {
listAccountIds: (cfg) => listGoogleChatAccountIds(cfg as ClawdbotConfig),
resolveAccount: (cfg, accountId) =>
resolveGoogleChatAccount({ cfg: cfg as ClawdbotConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultGoogleChatAccountId(cfg as ClawdbotConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg as ClawdbotConfig,
sectionKey: "googlechat",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg as ClawdbotConfig,
sectionKey: "googlechat",
accountId,
clearBaseFields: [
"serviceAccount",
"serviceAccountFile",
"audienceType",
"audience",
"webhookPath",
"webhookUrl",
"botUser",
"name",
],
}),
isConfigured: (account) => account.credentialSource !== "none",
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.credentialSource !== "none",
credentialSource: account.credentialSource,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveGoogleChatAccount({
cfg: cfg as ClawdbotConfig,
accountId,
}).config.dm?.allowFrom ?? []
).map((entry) => String(entry)),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry))
.filter(Boolean)
.map(formatAllowFromEntry),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
(cfg as ClawdbotConfig).channels?.["googlechat"]?.accounts?.[resolvedAccountId],
);
const allowFromPath = useAccountPath
? `channels.googlechat.accounts.${resolvedAccountId}.dm.`
: "channels.googlechat.dm.";
return {
policy: account.config.dm?.policy ?? "pairing",
allowFrom: account.config.dm?.allowFrom ?? [],
allowFromPath,
approveHint: formatPairingApproveHint("googlechat"),
normalizeEntry: (raw) => formatAllowFromEntry(raw),
};
},
collectWarnings: ({ account, cfg }) => {
const warnings: string[] = [];
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
if (groupPolicy === "open") {
warnings.push(
`- Google Chat spaces: groupPolicy="open" allows any space to trigger (mention-gated). Set channels.googlechat.groupPolicy="allowlist" and configure channels.googlechat.groups.`,
);
}
if (account.config.dm?.policy === "open") {
warnings.push(
`- Google Chat DMs are open to anyone. Set channels.googlechat.dm.policy="pairing" or "allowlist".`,
);
}
return warnings;
},
},
groups: {
resolveRequireMention: resolveGoogleChatGroupRequireMention,
},
threading: {
resolveReplyToMode: ({ cfg }) => cfg.channels?.["googlechat"]?.replyToMode ?? "off",
},
messaging: {
normalizeTarget: normalizeGoogleChatTarget,
targetResolver: {
looksLikeId: (raw, normalized) => {
const value = normalized ?? raw.trim();
return isGoogleChatSpaceTarget(value) || isGoogleChatUserTarget(value);
},
hint: "<spaces/{space}|users/{user}>",
},
},
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveGoogleChatAccount({
cfg: cfg as ClawdbotConfig,
accountId,
});
const q = query?.trim().toLowerCase() || "";
const allowFrom = account.config.dm?.allowFrom ?? [];
const peers = Array.from(
new Set(
allowFrom
.map((entry) => String(entry).trim())
.filter((entry) => Boolean(entry) && entry !== "*")
.map((entry) => normalizeGoogleChatTarget(entry) ?? entry),
),
)
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "user", id }) as const);
return peers;
},
listGroups: async ({ cfg, accountId, query, limit }) => {
const account = resolveGoogleChatAccount({
cfg: cfg as ClawdbotConfig,
accountId,
});
const groups = account.config.groups ?? {};
const q = query?.trim().toLowerCase() || "";
const entries = Object.keys(groups)
.filter((key) => key && key !== "*")
.filter((key) => (q ? key.toLowerCase().includes(q) : true))
.slice(0, limit && limit > 0 ? limit : undefined)
.map((id) => ({ kind: "group", id }) as const);
return entries;
},
},
resolver: {
resolveTargets: async ({ inputs, kind }) => {
const resolved = inputs.map((input) => {
const normalized = normalizeGoogleChatTarget(input);
if (!normalized) {
return { input, resolved: false, note: "empty target" };
}
if (kind === "user" && isGoogleChatUserTarget(normalized)) {
return { input, resolved: true, id: normalized };
}
if (kind === "group" && isGoogleChatSpaceTarget(normalized)) {
return { input, resolved: true, id: normalized };
}
return {
input,
resolved: false,
note: "use spaces/{space} or users/{user}",
};
});
return resolved;
},
},
actions: googlechatActions,
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg as ClawdbotConfig,
channelKey: "googlechat",
accountId,
name,
}),
validateInput: ({ accountId, input }) => {
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "GOOGLE_CHAT_SERVICE_ACCOUNT env vars can only be used for the default account.";
}
if (!input.useEnv && !input.token && !input.tokenFile) {
return "Google Chat requires --token (service account JSON) or --token-file.";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg: cfg as ClawdbotConfig,
channelKey: "googlechat",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig as ClawdbotConfig,
channelKey: "googlechat",
})
: namedConfig;
const patch = input.useEnv
? {}
: input.tokenFile
? { serviceAccountFile: input.tokenFile }
: input.token
? { serviceAccount: input.token }
: {};
const audienceType = input.audienceType?.trim();
const audience = input.audience?.trim();
const webhookPath = input.webhookPath?.trim();
const webhookUrl = input.webhookUrl?.trim();
const configPatch = {
...patch,
...(audienceType ? { audienceType } : {}),
...(audience ? { audience } : {}),
...(webhookPath ? { webhookPath } : {}),
...(webhookUrl ? { webhookUrl } : {}),
};
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
"googlechat": {
...(next.channels?.["googlechat"] ?? {}),
enabled: true,
...configPatch,
},
},
} as ClawdbotConfig;
}
return {
...next,
channels: {
...next.channels,
"googlechat": {
...(next.channels?.["googlechat"] ?? {}),
enabled: true,
accounts: {
...(next.channels?.["googlechat"]?.accounts ?? {}),
[accountId]: {
...(next.channels?.["googlechat"]?.accounts?.[accountId] ?? {}),
enabled: true,
...configPatch,
},
},
},
},
} as ClawdbotConfig;
},
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) =>
getGoogleChatRuntime().channel.text.chunkMarkdownText(text, limit),
textChunkLimit: 4000,
resolveTarget: ({ to, allowFrom, mode }) => {
const trimmed = to?.trim() ?? "";
const allowListRaw = (allowFrom ?? []).map((entry) => String(entry).trim()).filter(Boolean);
const allowList = allowListRaw
.filter((entry) => entry !== "*")
.map((entry) => normalizeGoogleChatTarget(entry))
.filter((entry): entry is string => Boolean(entry));
if (trimmed) {
const normalized = normalizeGoogleChatTarget(trimmed);
if (!normalized) {
if ((mode === "implicit" || mode === "heartbeat") && allowList.length > 0) {
return { ok: true, to: allowList[0] };
}
return {
ok: false,
error: missingTargetError(
"Google Chat",
"<spaces/{space}|users/{user}> or channels.googlechat.dm.allowFrom[0]",
),
};
}
return { ok: true, to: normalized };
}
if (allowList.length > 0) {
return { ok: true, to: allowList[0] };
}
return {
ok: false,
error: missingTargetError(
"Google Chat",
"<spaces/{space}|users/{user}> or channels.googlechat.dm.allowFrom[0]",
),
};
},
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) => {
const account = resolveGoogleChatAccount({
cfg: cfg as ClawdbotConfig,
accountId,
});
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
const thread = (threadId ?? replyToId ?? undefined) as string | undefined;
const result = await sendGoogleChatMessage({
account,
space,
text,
thread,
});
return {
channel: "googlechat",
messageId: result?.messageName ?? "",
chatId: space,
};
},
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId, threadId }) => {
if (!mediaUrl) {
throw new Error("Google Chat mediaUrl is required.");
}
const account = resolveGoogleChatAccount({
cfg: cfg as ClawdbotConfig,
accountId,
});
const space = await resolveGoogleChatOutboundSpace({ account, target: to });
const thread = (threadId ?? replyToId ?? undefined) as string | undefined;
const runtime = getGoogleChatRuntime();
const maxBytes = resolveChannelMediaMaxBytes({
cfg: cfg as ClawdbotConfig,
resolveChannelLimitMb: ({ cfg, accountId }) =>
(cfg.channels?.["googlechat"] as { accounts?: Record<string, { mediaMaxMb?: number }>; mediaMaxMb?: number } | undefined)
?.accounts?.[accountId]?.mediaMaxMb ??
(cfg.channels?.["googlechat"] as { mediaMaxMb?: number } | undefined)?.mediaMaxMb,
accountId,
});
const loaded = await runtime.channel.media.fetchRemoteMedia(mediaUrl, {
maxBytes: maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
});
const upload = await uploadGoogleChatAttachment({
account,
space,
filename: loaded.filename ?? "attachment",
buffer: loaded.buffer,
contentType: loaded.contentType,
});
const result = await sendGoogleChatMessage({
account,
space,
text,
thread,
attachments: upload.attachmentUploadToken
? [{ attachmentUploadToken: upload.attachmentUploadToken, contentName: loaded.filename }]
: undefined,
});
return {
channel: "googlechat",
messageId: result?.messageName ?? "",
chatId: space,
};
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: (accounts) =>
accounts.flatMap((entry) => {
const accountId = String(entry.accountId ?? DEFAULT_ACCOUNT_ID);
const enabled = entry.enabled !== false;
const configured = entry.configured === true;
if (!enabled || !configured) return [];
const issues = [];
if (!entry.audience) {
issues.push({
channel: "googlechat",
accountId,
kind: "config",
message: "Google Chat audience is missing (set channels.googlechat.audience).",
fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.",
});
}
if (!entry.audienceType) {
issues.push({
channel: "googlechat",
accountId,
kind: "config",
message: "Google Chat audienceType is missing (app-url or project-number).",
fix: "Set channels.googlechat.audienceType and channels.googlechat.audience.",
});
}
return issues;
}),
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
credentialSource: snapshot.credentialSource ?? "none",
audienceType: snapshot.audienceType ?? null,
audience: snapshot.audience ?? null,
webhookPath: snapshot.webhookPath ?? null,
webhookUrl: snapshot.webhookUrl ?? null,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account }) => probeGoogleChat(account),
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.credentialSource !== "none",
credentialSource: account.credentialSource,
audienceType: account.config.audienceType,
audience: account.config.audience,
webhookPath: account.config.webhookPath,
webhookUrl: account.config.webhookUrl,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
dmPolicy: account.config.dm?.policy ?? "pairing",
probe,
}),
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
ctx.log?.info(`[${account.accountId}] starting Google Chat webhook`);
ctx.setStatus({
accountId: account.accountId,
running: true,
lastStartAt: Date.now(),
webhookPath: resolveGoogleChatWebhookPath({ account }),
audienceType: account.config.audienceType,
audience: account.config.audience,
});
const unregister = await startGoogleChatMonitor({
account,
config: ctx.cfg as ClawdbotConfig,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
webhookPath: account.config.webhookPath,
webhookUrl: account.config.webhookUrl,
statusSink: (patch) => ctx.setStatus({ accountId: account.accountId, ...patch }),
});
return () => {
unregister?.();
ctx.setStatus({
accountId: account.accountId,
running: false,
lastStopAt: Date.now(),
});
};
},
},
};
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { isSenderAllowed } from "./monitor.js";
describe("isSenderAllowed", () => {
it("matches allowlist entries with users/<email>", () => {
expect(
isSenderAllowed("users/123", "Jane@Example.com", ["users/jane@example.com"]),
).toBe(true);
});
it("matches allowlist entries with raw email", () => {
expect(isSenderAllowed("users/123", "Jane@Example.com", ["jane@example.com"])).toBe(
true,
);
});
it("still matches user id entries", () => {
expect(isSenderAllowed("users/abc", "jane@example.com", ["users/abc"])).toBe(true);
});
it("rejects non-matching emails", () => {
expect(isSenderAllowed("users/123", "jane@example.com", ["users/other@example.com"])).toBe(
false,
);
});
});
+889
View File
@@ -0,0 +1,889 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { resolveMentionGatingWithBypass } from "clawdbot/plugin-sdk";
import {
type ResolvedGoogleChatAccount
} from "./accounts.js";
import {
downloadGoogleChatMedia,
deleteGoogleChatMessage,
sendGoogleChatMessage,
updateGoogleChatMessage,
} from "./api.js";
import { verifyGoogleChatRequest, type GoogleChatAudienceType } from "./auth.js";
import { getGoogleChatRuntime } from "./runtime.js";
import type {
GoogleChatAnnotation,
GoogleChatAttachment,
GoogleChatEvent,
GoogleChatSpace,
GoogleChatMessage,
GoogleChatUser,
} from "./types.js";
export type GoogleChatRuntimeEnv = {
log?: (message: string) => void;
error?: (message: string) => void;
};
export type GoogleChatMonitorOptions = {
account: ResolvedGoogleChatAccount;
config: ClawdbotConfig;
runtime: GoogleChatRuntimeEnv;
abortSignal: AbortSignal;
webhookPath?: string;
webhookUrl?: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};
type GoogleChatCoreRuntime = ReturnType<typeof getGoogleChatRuntime>;
type WebhookTarget = {
account: ResolvedGoogleChatAccount;
config: ClawdbotConfig;
runtime: GoogleChatRuntimeEnv;
core: GoogleChatCoreRuntime;
path: string;
audienceType?: GoogleChatAudienceType;
audience?: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
mediaMaxMb: number;
};
const webhookTargets = new Map<string, WebhookTarget[]>();
function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, message: string) {
if (core.logging.shouldLogVerbose()) {
runtime.log?.(`[googlechat] ${message}`);
}
}
function normalizeWebhookPath(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return "/";
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
if (withSlash.length > 1 && withSlash.endsWith("/")) {
return withSlash.slice(0, -1);
}
return withSlash;
}
function resolveWebhookPath(webhookPath?: string, webhookUrl?: string): string | null {
const trimmedPath = webhookPath?.trim();
if (trimmedPath) return normalizeWebhookPath(trimmedPath);
if (webhookUrl?.trim()) {
try {
const parsed = new URL(webhookUrl);
return normalizeWebhookPath(parsed.pathname || "/");
} catch {
return null;
}
}
return "/googlechat";
}
async function readJsonBody(req: IncomingMessage, maxBytes: number) {
const chunks: Buffer[] = [];
let total = 0;
return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
let resolved = false;
const doResolve = (value: { ok: boolean; value?: unknown; error?: string }) => {
if (resolved) return;
resolved = true;
req.removeAllListeners();
resolve(value);
};
req.on("data", (chunk: Buffer) => {
total += chunk.length;
if (total > maxBytes) {
doResolve({ ok: false, error: "payload too large" });
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
try {
const raw = Buffer.concat(chunks).toString("utf8");
if (!raw.trim()) {
doResolve({ ok: false, error: "empty payload" });
return;
}
doResolve({ ok: true, value: JSON.parse(raw) as unknown });
} catch (err) {
doResolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
}
});
req.on("error", (err) => {
doResolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
});
});
}
export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void {
const key = normalizeWebhookPath(target.path);
const normalizedTarget = { ...target, path: key };
const existing = webhookTargets.get(key) ?? [];
const next = [...existing, normalizedTarget];
webhookTargets.set(key, next);
return () => {
const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
if (updated.length > 0) {
webhookTargets.set(key, updated);
} else {
webhookTargets.delete(key);
}
};
}
function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | undefined {
const normalized = value?.trim().toLowerCase();
if (normalized === "app-url" || normalized === "app_url" || normalized === "app") {
return "app-url";
}
if (normalized === "project-number" || normalized === "project_number" || normalized === "project") {
return "project-number";
}
return undefined;
}
export async function handleGoogleChatWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
const url = new URL(req.url ?? "/", "http://localhost");
const path = normalizeWebhookPath(url.pathname);
const targets = webhookTargets.get(path);
if (!targets || targets.length === 0) return false;
if (req.method !== "POST") {
res.statusCode = 405;
res.setHeader("Allow", "POST");
res.end("Method Not Allowed");
return true;
}
const authHeader = String(req.headers.authorization ?? "");
const bearer = authHeader.toLowerCase().startsWith("bearer ")
? authHeader.slice("bearer ".length)
: "";
const body = await readJsonBody(req, 1024 * 1024);
if (!body.ok) {
res.statusCode = body.error === "payload too large" ? 413 : 400;
res.end(body.error ?? "invalid payload");
return true;
}
let raw = body.value;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
res.statusCode = 400;
res.end("invalid payload");
return true;
}
// Transform Google Workspace Add-on format to standard Chat API format
const rawObj = raw as {
commonEventObject?: { hostApp?: string };
chat?: {
messagePayload?: { space?: GoogleChatSpace; message?: GoogleChatMessage };
user?: GoogleChatUser;
eventTime?: string;
};
authorizationEventObject?: { systemIdToken?: string };
};
if (rawObj.commonEventObject?.hostApp === "CHAT" && rawObj.chat?.messagePayload) {
const chat = rawObj.chat;
const messagePayload = chat.messagePayload;
raw = {
type: "MESSAGE",
space: messagePayload?.space,
message: messagePayload?.message,
user: chat.user,
eventTime: chat.eventTime,
};
// For Add-ons, the bearer token may be in authorizationEventObject.systemIdToken
const systemIdToken = rawObj.authorizationEventObject?.systemIdToken;
if (!bearer && systemIdToken) {
Object.assign(req.headers, { authorization: `Bearer ${systemIdToken}` });
}
}
const event = raw as GoogleChatEvent;
const eventType = event.type ?? (raw as { eventType?: string }).eventType;
if (typeof eventType !== "string") {
res.statusCode = 400;
res.end("invalid payload");
return true;
}
if (!event.space || typeof event.space !== "object" || Array.isArray(event.space)) {
res.statusCode = 400;
res.end("invalid payload");
return true;
}
if (eventType === "MESSAGE") {
if (!event.message || typeof event.message !== "object" || Array.isArray(event.message)) {
res.statusCode = 400;
res.end("invalid payload");
return true;
}
}
// Re-extract bearer in case it was updated from Add-on format
const authHeaderNow = String(req.headers.authorization ?? "");
const effectiveBearer = authHeaderNow.toLowerCase().startsWith("bearer ")
? authHeaderNow.slice("bearer ".length)
: bearer;
let selected: WebhookTarget | undefined;
for (const target of targets) {
const audienceType = target.audienceType;
const audience = target.audience;
const verification = await verifyGoogleChatRequest({
bearer: effectiveBearer,
audienceType,
audience,
});
if (verification.ok) {
selected = target;
break;
}
}
if (!selected) {
res.statusCode = 401;
res.end("unauthorized");
return true;
}
selected.statusSink?.({ lastInboundAt: Date.now() });
processGoogleChatEvent(event, selected).catch((err) => {
selected?.runtime.error?.(
`[${selected.account.accountId}] Google Chat webhook failed: ${String(err)}`,
);
});
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end("{}");
return true;
}
async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTarget) {
const eventType = event.type ?? (event as { eventType?: string }).eventType;
if (eventType !== "MESSAGE") return;
if (!event.message || !event.space) return;
await processMessageWithPipeline({
event,
account: target.account,
config: target.config,
runtime: target.runtime,
core: target.core,
statusSink: target.statusSink,
mediaMaxMb: target.mediaMaxMb,
});
}
function normalizeUserId(raw?: string | null): string {
const trimmed = raw?.trim() ?? "";
if (!trimmed) return "";
return trimmed.replace(/^users\//i, "").toLowerCase();
}
export function isSenderAllowed(
senderId: string,
senderEmail: string | undefined,
allowFrom: string[],
) {
if (allowFrom.includes("*")) return true;
const normalizedSenderId = normalizeUserId(senderId);
const normalizedEmail = senderEmail?.trim().toLowerCase() ?? "";
return allowFrom.some((entry) => {
const normalized = String(entry).trim().toLowerCase();
if (!normalized) return false;
if (normalized === normalizedSenderId) return true;
if (normalizedEmail && normalized === normalizedEmail) return true;
if (normalizedEmail && normalized.replace(/^users\//i, "") === normalizedEmail) return true;
if (normalized.replace(/^users\//i, "") === normalizedSenderId) return true;
if (normalized.replace(/^(googlechat|google-chat|gchat):/i, "") === normalizedSenderId) {
return true;
}
return false;
});
}
function resolveGroupConfig(params: {
groupId: string;
groupName?: string | null;
groups?: Record<string, { requireMention?: boolean; allow?: boolean; enabled?: boolean; users?: Array<string | number>; systemPrompt?: string }>;
}) {
const { groupId, groupName, groups } = params;
const entries = groups ?? {};
const keys = Object.keys(entries);
if (keys.length === 0) {
return { entry: undefined, allowlistConfigured: false };
}
const normalizedName = groupName?.trim().toLowerCase();
const candidates = [groupId, groupName ?? "", normalizedName ?? ""].filter(Boolean);
let entry = candidates.map((candidate) => entries[candidate]).find(Boolean);
if (!entry && normalizedName) {
entry = entries[normalizedName];
}
const fallback = entries["*"];
return { entry: entry ?? fallback, allowlistConfigured: true, fallback };
}
function extractMentionInfo(annotations: GoogleChatAnnotation[], botUser?: string | null) {
const mentionAnnotations = annotations.filter((entry) => entry.type === "USER_MENTION");
const hasAnyMention = mentionAnnotations.length > 0;
const botTargets = new Set(["users/app", botUser?.trim()].filter(Boolean) as string[]);
const wasMentioned = mentionAnnotations.some((entry) => {
const userName = entry.userMention?.user?.name;
if (!userName) return false;
if (botTargets.has(userName)) return true;
return normalizeUserId(userName) === "app";
});
return { hasAnyMention, wasMentioned };
}
/**
* Resolve bot display name with fallback chain:
* 1. Account config name
* 2. Agent name from config
* 3. "Clawdbot" as generic fallback
*/
function resolveBotDisplayName(params: {
accountName?: string;
agentId: string;
config: ClawdbotConfig;
}): string {
const { accountName, agentId, config } = params;
if (accountName?.trim()) return accountName.trim();
const agent = config.agents?.list?.find((a) => a.id === agentId);
if (agent?.name?.trim()) return agent.name.trim();
return "Clawdbot";
}
async function processMessageWithPipeline(params: {
event: GoogleChatEvent;
account: ResolvedGoogleChatAccount;
config: ClawdbotConfig;
runtime: GoogleChatRuntimeEnv;
core: GoogleChatCoreRuntime;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
mediaMaxMb: number;
}): Promise<void> {
const { event, account, config, runtime, core, statusSink, mediaMaxMb } = params;
const space = event.space;
const message = event.message;
if (!space || !message) return;
const spaceId = space.name ?? "";
if (!spaceId) return;
const spaceType = (space.type ?? "").toUpperCase();
const isGroup = spaceType !== "DM";
const sender = message.sender ?? event.user;
const senderId = sender?.name ?? "";
const senderName = sender?.displayName ?? "";
const senderEmail = sender?.email ?? undefined;
const allowBots = account.config.allowBots === true;
if (!allowBots) {
if (sender?.type?.toUpperCase() === "BOT") {
logVerbose(core, runtime, `skip bot-authored message (${senderId || "unknown"})`);
return;
}
if (senderId === "users/app") {
logVerbose(core, runtime, "skip app-authored message");
return;
}
}
const messageText = (message.argumentText ?? message.text ?? "").trim();
const attachments = message.attachment ?? [];
const hasMedia = attachments.length > 0;
const rawBody = messageText || (hasMedia ? "<media:attachment>" : "");
if (!rawBody) return;
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const groupConfigResolved = resolveGroupConfig({
groupId: spaceId,
groupName: space.displayName ?? null,
groups: account.config.groups ?? undefined,
});
const groupEntry = groupConfigResolved.entry;
const groupUsers = groupEntry?.users ?? account.config.groupAllowFrom ?? [];
let effectiveWasMentioned: boolean | undefined;
if (isGroup) {
if (groupPolicy === "disabled") {
logVerbose(core, runtime, `drop group message (groupPolicy=disabled, space=${spaceId})`);
return;
}
const groupAllowlistConfigured = groupConfigResolved.allowlistConfigured;
const groupAllowed =
Boolean(groupEntry) || Boolean((account.config.groups ?? {})["*"]);
if (groupPolicy === "allowlist") {
if (!groupAllowlistConfigured) {
logVerbose(
core,
runtime,
`drop group message (groupPolicy=allowlist, no allowlist, space=${spaceId})`,
);
return;
}
if (!groupAllowed) {
logVerbose(core, runtime, `drop group message (not allowlisted, space=${spaceId})`);
return;
}
}
if (groupEntry?.enabled === false || groupEntry?.allow === false) {
logVerbose(core, runtime, `drop group message (space disabled, space=${spaceId})`);
return;
}
if (groupUsers.length > 0) {
const ok = isSenderAllowed(senderId, senderEmail, groupUsers.map((v) => String(v)));
if (!ok) {
logVerbose(core, runtime, `drop group message (sender not allowed, ${senderId})`);
return;
}
}
}
const dmPolicy = account.config.dm?.policy ?? "pairing";
const configAllowFrom = (account.config.dm?.allowFrom ?? []).map((v) => String(v));
const shouldComputeAuth = core.channel.commands.shouldComputeCommandAuthorized(rawBody, config);
const storeAllowFrom =
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
? await core.channel.pairing.readAllowFromStore("googlechat").catch(() => [])
: [];
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const commandAllowFrom = isGroup ? groupUsers.map((v) => String(v)) : effectiveAllowFrom;
const useAccessGroups = config.commands?.useAccessGroups !== false;
const senderAllowedForCommands = isSenderAllowed(senderId, senderEmail, commandAllowFrom);
const commandAuthorized = shouldComputeAuth
? core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups,
authorizers: [
{ configured: commandAllowFrom.length > 0, allowed: senderAllowedForCommands },
],
})
: undefined;
if (isGroup) {
const requireMention = groupEntry?.requireMention ?? account.config.requireMention ?? true;
const annotations = message.annotations ?? [];
const mentionInfo = extractMentionInfo(annotations, account.config.botUser);
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg: config,
surface: "googlechat",
});
const mentionGate = resolveMentionGatingWithBypass({
isGroup: true,
requireMention,
canDetectMention: true,
wasMentioned: mentionInfo.wasMentioned,
implicitMention: false,
hasAnyMention: mentionInfo.hasAnyMention,
allowTextCommands,
hasControlCommand: core.channel.text.hasControlCommand(rawBody, config),
commandAuthorized: commandAuthorized === true,
});
effectiveWasMentioned = mentionGate.effectiveWasMentioned;
if (mentionGate.shouldSkip) {
logVerbose(core, runtime, `drop group message (mention required, space=${spaceId})`);
return;
}
}
if (!isGroup) {
if (dmPolicy === "disabled" || account.config.dm?.enabled === false) {
logVerbose(core, runtime, `Blocked Google Chat DM from ${senderId} (dmPolicy=disabled)`);
return;
}
if (dmPolicy !== "open") {
const allowed = senderAllowedForCommands;
if (!allowed) {
if (dmPolicy === "pairing") {
const { code, created } = await core.channel.pairing.upsertPairingRequest({
channel: "googlechat",
id: senderId,
meta: { name: senderName || undefined, email: senderEmail },
});
if (created) {
logVerbose(core, runtime, `googlechat pairing request sender=${senderId}`);
try {
await sendGoogleChatMessage({
account,
space: spaceId,
text: core.channel.pairing.buildPairingReply({
channel: "googlechat",
idLine: `Your Google Chat user id: ${senderId}`,
code,
}),
});
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
logVerbose(core, runtime, `pairing reply failed for ${senderId}: ${String(err)}`);
}
}
} else {
logVerbose(
core,
runtime,
`Blocked unauthorized Google Chat sender ${senderId} (dmPolicy=${dmPolicy})`,
);
}
return;
}
}
}
if (
isGroup &&
core.channel.commands.isControlCommandMessage(rawBody, config) &&
commandAuthorized !== true
) {
logVerbose(core, runtime, `googlechat: drop control command from ${senderId}`);
return;
}
const route = core.channel.routing.resolveAgentRoute({
cfg: config,
channel: "googlechat",
accountId: account.accountId,
peer: {
kind: isGroup ? "group" : "dm",
id: spaceId,
},
});
let mediaPath: string | undefined;
let mediaType: string | undefined;
if (attachments.length > 0) {
const first = attachments[0];
const attachmentData = await downloadAttachment(first, account, mediaMaxMb, core);
if (attachmentData) {
mediaPath = attachmentData.path;
mediaType = attachmentData.contentType;
}
}
const fromLabel = isGroup
? space.displayName || `space:${spaceId}`
: senderName || `user:${senderId}`;
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
agentId: route.agentId,
});
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config);
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const body = core.channel.reply.formatAgentEnvelope({
channel: "Google Chat",
from: fromLabel,
timestamp: event.eventTime ? Date.parse(event.eventTime) : undefined,
previousTimestamp,
envelope: envelopeOptions,
body: rawBody,
});
const groupSystemPrompt = groupConfigResolved.entry?.systemPrompt?.trim() || undefined;
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
RawBody: rawBody,
CommandBody: rawBody,
From: `googlechat:${senderId}`,
To: `googlechat:${spaceId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "channel" : "direct",
ConversationLabel: fromLabel,
SenderName: senderName || undefined,
SenderId: senderId,
SenderUsername: senderEmail,
WasMentioned: isGroup ? effectiveWasMentioned : undefined,
CommandAuthorized: commandAuthorized,
Provider: "googlechat",
Surface: "googlechat",
MessageSid: message.name,
MessageSidFull: message.name,
ReplyToId: message.thread?.name,
ReplyToIdFull: message.thread?.name,
MediaPath: mediaPath,
MediaType: mediaType,
MediaUrl: mediaPath,
GroupSpace: isGroup ? space.displayName ?? undefined : undefined,
GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined,
OriginatingChannel: "googlechat",
OriginatingTo: `googlechat:${spaceId}`,
});
void core.channel.session
.recordSessionMetaFromInbound({
storePath,
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
ctx: ctxPayload,
})
.catch((err) => {
runtime.error?.(`googlechat: failed updating session meta: ${String(err)}`);
});
// Typing indicator setup
// Note: Reaction mode requires user OAuth, not available with service account auth.
// If reaction is configured, we fall back to message mode with a warning.
let typingIndicator = account.config.typingIndicator ?? "message";
if (typingIndicator === "reaction") {
runtime.error?.(
`[${account.accountId}] typingIndicator="reaction" requires user OAuth (not supported with service account). Falling back to "message" mode.`,
);
typingIndicator = "message";
}
let typingMessageName: string | undefined;
// Start typing indicator (message mode only, reaction mode not supported with app auth)
if (typingIndicator === "message") {
try {
const botName = resolveBotDisplayName({
accountName: account.config.name,
agentId: route.agentId,
config,
});
const result = await sendGoogleChatMessage({
account,
space: spaceId,
text: `_${botName} is typing..._`,
thread: message.thread?.name,
});
typingMessageName = result?.messageName;
} catch (err) {
runtime.error?.(`Failed sending typing message: ${String(err)}`);
}
}
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
deliver: async (payload) => {
await deliverGoogleChatReply({
payload,
account,
spaceId,
runtime,
core,
statusSink,
typingMessageName,
});
// Only use typing message for first delivery
typingMessageName = undefined;
},
onError: (err, info) => {
runtime.error?.(
`[${account.accountId}] Google Chat ${info.kind} reply failed: ${String(err)}`,
);
},
},
});
}
async function downloadAttachment(
attachment: GoogleChatAttachment,
account: ResolvedGoogleChatAccount,
mediaMaxMb: number,
core: GoogleChatCoreRuntime,
): Promise<{ path: string; contentType?: string } | null> {
const resourceName = attachment.attachmentDataRef?.resourceName;
if (!resourceName) return null;
const maxBytes = Math.max(1, mediaMaxMb) * 1024 * 1024;
const downloaded = await downloadGoogleChatMedia({ account, resourceName, maxBytes });
const saved = await core.channel.media.saveMediaBuffer(
downloaded.buffer,
downloaded.contentType ?? attachment.contentType,
"inbound",
maxBytes,
attachment.contentName,
);
return { path: saved.path, contentType: saved.contentType };
}
async function deliverGoogleChatReply(params: {
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string };
account: ResolvedGoogleChatAccount;
spaceId: string;
runtime: GoogleChatRuntimeEnv;
core: GoogleChatCoreRuntime;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
typingMessageName?: string;
}): Promise<void> {
const { payload, account, spaceId, runtime, core, statusSink, typingMessageName } = params;
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
if (mediaList.length > 0) {
let suppressCaption = false;
if (typingMessageName) {
try {
await deleteGoogleChatMessage({
account,
messageName: typingMessageName,
});
} catch (err) {
runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`);
const fallbackText = payload.text?.trim()
? payload.text
: mediaList.length > 1
? "Sent attachments."
: "Sent attachment.";
try {
await updateGoogleChatMessage({
account,
messageName: typingMessageName,
text: fallbackText,
});
suppressCaption = Boolean(payload.text?.trim());
} catch (updateErr) {
runtime.error?.(`Google Chat typing update failed: ${String(updateErr)}`);
}
}
}
let first = true;
for (const mediaUrl of mediaList) {
const caption = first && !suppressCaption ? payload.text : undefined;
first = false;
try {
const loaded = await core.channel.media.fetchRemoteMedia(mediaUrl, {
maxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024,
});
const upload = await uploadAttachmentForReply({
account,
spaceId,
buffer: loaded.buffer,
contentType: loaded.contentType,
filename: loaded.filename ?? "attachment",
});
if (!upload.attachmentUploadToken) {
throw new Error("missing attachment upload token");
}
await sendGoogleChatMessage({
account,
space: spaceId,
text: caption,
thread: payload.replyToId,
attachments: [
{ attachmentUploadToken: upload.attachmentUploadToken, contentName: loaded.filename },
],
});
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error?.(`Google Chat attachment send failed: ${String(err)}`);
}
}
return;
}
if (payload.text) {
const chunkLimit = account.config.textChunkLimit ?? 4000;
const chunks = core.channel.text.chunkMarkdownText(payload.text, chunkLimit);
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
try {
// Edit typing message with first chunk if available
if (i === 0 && typingMessageName) {
await updateGoogleChatMessage({
account,
messageName: typingMessageName,
text: chunk,
});
} else {
await sendGoogleChatMessage({
account,
space: spaceId,
text: chunk,
thread: payload.replyToId,
});
}
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error?.(`Google Chat message send failed: ${String(err)}`);
}
}
}
}
async function uploadAttachmentForReply(params: {
account: ResolvedGoogleChatAccount;
spaceId: string;
buffer: Buffer;
contentType?: string;
filename: string;
}) {
const { account, spaceId, buffer, contentType, filename } = params;
const { uploadGoogleChatAttachment } = await import("./api.js");
return await uploadGoogleChatAttachment({
account,
space: spaceId,
filename,
buffer,
contentType,
});
}
export function monitorGoogleChatProvider(options: GoogleChatMonitorOptions): () => void {
const core = getGoogleChatRuntime();
const webhookPath = resolveWebhookPath(options.webhookPath, options.webhookUrl);
if (!webhookPath) {
options.runtime.error?.(`[${options.account.accountId}] invalid webhook path`);
return () => {};
}
const audienceType = normalizeAudienceType(options.account.config.audienceType);
const audience = options.account.config.audience?.trim();
const mediaMaxMb = options.account.config.mediaMaxMb ?? 20;
const unregister = registerGoogleChatWebhookTarget({
account: options.account,
config: options.config,
runtime: options.runtime,
core,
path: webhookPath,
audienceType,
audience,
statusSink: options.statusSink,
mediaMaxMb,
});
return unregister;
}
export async function startGoogleChatMonitor(params: GoogleChatMonitorOptions): Promise<() => void> {
return monitorGoogleChatProvider(params);
}
export function resolveGoogleChatWebhookPath(params: {
account: ResolvedGoogleChatAccount;
}): string {
return resolveWebhookPath(
params.account.config.webhookPath,
params.account.config.webhookUrl,
) ?? "/googlechat";
}
export function computeGoogleChatMediaMaxMb(params: { account: ResolvedGoogleChatAccount }) {
return params.account.config.mediaMaxMb ?? 20;
}
+278
View File
@@ -0,0 +1,278 @@
import type { ClawdbotConfig, DmPolicy } from "clawdbot/plugin-sdk";
import {
addWildcardAllowFrom,
formatDocsLink,
promptAccountId,
type ChannelOnboardingAdapter,
type ChannelOnboardingDmPolicy,
type WizardPrompter,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
migrateBaseNameToDefaultAccount,
} from "clawdbot/plugin-sdk";
import {
listGoogleChatAccountIds,
resolveDefaultGoogleChatAccountId,
resolveGoogleChatAccount,
} from "./accounts.js";
const channel = "googlechat" as const;
const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT";
const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE";
function setGoogleChatDmPolicy(cfg: ClawdbotConfig, policy: DmPolicy) {
const allowFrom =
policy === "open"
? addWildcardAllowFrom(cfg.channels?.["googlechat"]?.dm?.allowFrom)
: undefined;
return {
...cfg,
channels: {
...cfg.channels,
"googlechat": {
...(cfg.channels?.["googlechat"] ?? {}),
dm: {
...(cfg.channels?.["googlechat"]?.dm ?? {}),
policy,
...(allowFrom ? { allowFrom } : {}),
},
},
},
};
}
function parseAllowFromInput(raw: string): string[] {
return raw
.split(/[\n,;]+/g)
.map((entry) => entry.trim())
.filter(Boolean);
}
async function promptAllowFrom(params: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
}): Promise<ClawdbotConfig> {
const current = params.cfg.channels?.["googlechat"]?.dm?.allowFrom ?? [];
const entry = await params.prompter.text({
message: "Google Chat allowFrom (user id or email)",
placeholder: "users/123456789, name@example.com",
initialValue: current[0] ? String(current[0]) : undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
const parts = parseAllowFromInput(String(entry));
const unique = [...new Set(parts)];
return {
...params.cfg,
channels: {
...params.cfg.channels,
"googlechat": {
...(params.cfg.channels?.["googlechat"] ?? {}),
enabled: true,
dm: {
...(params.cfg.channels?.["googlechat"]?.dm ?? {}),
policy: "allowlist",
allowFrom: unique,
},
},
},
};
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Google Chat",
channel,
policyKey: "channels.googlechat.dm.policy",
allowFromKey: "channels.googlechat.dm.allowFrom",
getCurrent: (cfg) => cfg.channels?.["googlechat"]?.dm?.policy ?? "pairing",
setPolicy: (cfg, policy) => setGoogleChatDmPolicy(cfg, policy),
promptAllowFrom,
};
function applyAccountConfig(params: {
cfg: ClawdbotConfig;
accountId: string;
patch: Record<string, unknown>;
}): ClawdbotConfig {
const { cfg, accountId, patch } = params;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
"googlechat": {
...(cfg.channels?.["googlechat"] ?? {}),
enabled: true,
...patch,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
"googlechat": {
...(cfg.channels?.["googlechat"] ?? {}),
enabled: true,
accounts: {
...(cfg.channels?.["googlechat"]?.accounts ?? {}),
[accountId]: {
...(cfg.channels?.["googlechat"]?.accounts?.[accountId] ?? {}),
enabled: true,
...patch,
},
},
},
},
};
}
async function promptCredentials(params: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<ClawdbotConfig> {
const { cfg, prompter, accountId } = params;
const envReady =
accountId === DEFAULT_ACCOUNT_ID &&
(Boolean(process.env[ENV_SERVICE_ACCOUNT]) ||
Boolean(process.env[ENV_SERVICE_ACCOUNT_FILE]));
if (envReady) {
const useEnv = await prompter.confirm({
message: "Use GOOGLE_CHAT_SERVICE_ACCOUNT env vars?",
initialValue: true,
});
if (useEnv) {
return applyAccountConfig({ cfg, accountId, patch: {} });
}
}
const method = await prompter.select({
message: "Google Chat auth method",
options: [
{ value: "file", label: "Service account JSON file" },
{ value: "inline", label: "Paste service account JSON" },
],
initialValue: "file",
});
if (method === "file") {
const path = await prompter.text({
message: "Service account JSON path",
placeholder: "/path/to/service-account.json",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
return applyAccountConfig({
cfg,
accountId,
patch: { serviceAccountFile: String(path).trim() },
});
}
const json = await prompter.text({
message: "Service account JSON (single line)",
placeholder: "{\"type\":\"service_account\", ... }",
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
return applyAccountConfig({
cfg,
accountId,
patch: { serviceAccount: String(json).trim() },
});
}
async function promptAudience(params: {
cfg: ClawdbotConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<ClawdbotConfig> {
const account = resolveGoogleChatAccount({
cfg: params.cfg,
accountId: params.accountId,
});
const currentType = account.config.audienceType ?? "app-url";
const currentAudience = account.config.audience ?? "";
const audienceType = (await params.prompter.select({
message: "Webhook audience type",
options: [
{ value: "app-url", label: "App URL (recommended)" },
{ value: "project-number", label: "Project number" },
],
initialValue: currentType === "project-number" ? "project-number" : "app-url",
})) as "app-url" | "project-number";
const audience = await params.prompter.text({
message: audienceType === "project-number" ? "Project number" : "App URL",
placeholder: audienceType === "project-number" ? "1234567890" : "https://your.host/googlechat",
initialValue: currentAudience || undefined,
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
});
return applyAccountConfig({
cfg: params.cfg,
accountId: params.accountId,
patch: { audienceType, audience: String(audience).trim() },
});
}
async function noteGoogleChatSetup(prompter: WizardPrompter) {
await prompter.note(
[
"Google Chat apps use service-account auth and an HTTPS webhook.",
"Set the Chat API scopes in your service account and configure the Chat app URL.",
"Webhook verification requires audience type + audience value.",
`Docs: ${formatDocsLink("/channels/googlechat", "channels/googlechat")}`,
].join("\n"),
"Google Chat setup",
);
}
export const googlechatOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
dmPolicy,
getStatus: async ({ cfg }) => {
const configured = listGoogleChatAccountIds(cfg).some(
(accountId) => resolveGoogleChatAccount({ cfg, accountId }).credentialSource !== "none",
);
return {
channel,
configured,
statusLines: [
`Google Chat: ${configured ? "configured" : "needs service account"}`,
],
selectionHint: configured ? "configured" : "needs auth",
};
},
configure: async ({
cfg,
prompter,
accountOverrides,
shouldPromptAccountIds,
}) => {
const override = accountOverrides["googlechat"]?.trim();
const defaultAccountId = resolveDefaultGoogleChatAccountId(cfg);
let accountId = override ? normalizeAccountId(override) : defaultAccountId;
if (shouldPromptAccountIds && !override) {
accountId = await promptAccountId({
cfg,
prompter,
label: "Google Chat",
currentId: accountId,
listAccountIds: listGoogleChatAccountIds,
defaultAccountId,
});
}
let next = cfg;
await noteGoogleChatSetup(prompter);
next = await promptCredentials({ cfg: next, prompter, accountId });
next = await promptAudience({ cfg: next, prompter, accountId });
const namedConfig = migrateBaseNameToDefaultAccount({
cfg: next,
channelKey: "googlechat",
});
return { cfg: namedConfig, accountId };
},
};
+14
View File
@@ -0,0 +1,14 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setGoogleChatRuntime(next: PluginRuntime) {
runtime = next;
}
export function getGoogleChatRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Google Chat runtime not initialized");
}
return runtime;
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
isGoogleChatSpaceTarget,
isGoogleChatUserTarget,
normalizeGoogleChatTarget,
} from "./targets.js";
describe("normalizeGoogleChatTarget", () => {
it("normalizes provider prefixes", () => {
expect(normalizeGoogleChatTarget("googlechat:users/123")).toBe("users/123");
expect(normalizeGoogleChatTarget("google-chat:spaces/AAA")).toBe("spaces/AAA");
expect(normalizeGoogleChatTarget("gchat:user:User@Example.com")).toBe(
"users/user@example.com",
);
});
it("normalizes email targets to users/<email>", () => {
expect(normalizeGoogleChatTarget("User@Example.com")).toBe("users/user@example.com");
expect(normalizeGoogleChatTarget("users/User@Example.com")).toBe("users/user@example.com");
});
it("preserves space targets", () => {
expect(normalizeGoogleChatTarget("space:spaces/BBB")).toBe("spaces/BBB");
expect(normalizeGoogleChatTarget("spaces/CCC")).toBe("spaces/CCC");
});
});
describe("target helpers", () => {
it("detects user and space targets", () => {
expect(isGoogleChatUserTarget("users/abc")).toBe(true);
expect(isGoogleChatSpaceTarget("spaces/abc")).toBe(true);
expect(isGoogleChatUserTarget("spaces/abc")).toBe(false);
});
});
+55
View File
@@ -0,0 +1,55 @@
import type { ResolvedGoogleChatAccount } from "./accounts.js";
import { findGoogleChatDirectMessage } from "./api.js";
export function normalizeGoogleChatTarget(raw?: string | null): string | undefined {
const trimmed = raw?.trim();
if (!trimmed) return undefined;
const withoutPrefix = trimmed.replace(/^(googlechat|google-chat|gchat):/i, "");
const normalized = withoutPrefix
.replace(/^user:(users\/)?/i, "users/")
.replace(/^space:(spaces\/)?/i, "spaces/");
if (isGoogleChatUserTarget(normalized)) {
const suffix = normalized.slice("users/".length);
return suffix.includes("@") ? `users/${suffix.toLowerCase()}` : normalized;
}
if (isGoogleChatSpaceTarget(normalized)) return normalized;
if (normalized.includes("@")) return `users/${normalized.toLowerCase()}`;
return normalized;
}
export function isGoogleChatUserTarget(value: string): boolean {
return value.toLowerCase().startsWith("users/");
}
export function isGoogleChatSpaceTarget(value: string): boolean {
return value.toLowerCase().startsWith("spaces/");
}
function stripMessageSuffix(target: string): string {
const index = target.indexOf("/messages/");
if (index === -1) return target;
return target.slice(0, index);
}
export async function resolveGoogleChatOutboundSpace(params: {
account: ResolvedGoogleChatAccount;
target: string;
}): Promise<string> {
const normalized = normalizeGoogleChatTarget(params.target);
if (!normalized) {
throw new Error("Missing Google Chat target.");
}
const base = stripMessageSuffix(normalized);
if (isGoogleChatSpaceTarget(base)) return base;
if (isGoogleChatUserTarget(base)) {
const dm = await findGoogleChatDirectMessage({
account: params.account,
userName: base,
});
if (!dm?.name) {
throw new Error(`No Google Chat DM found for ${base}`);
}
return dm.name;
}
return base;
}
@@ -0,0 +1,3 @@
import type { GoogleChatAccountConfig, GoogleChatConfig } from "clawdbot/plugin-sdk";
export type { GoogleChatAccountConfig, GoogleChatConfig };
+73
View File
@@ -0,0 +1,73 @@
export type GoogleChatSpace = {
name?: string;
displayName?: string;
type?: string;
};
export type GoogleChatUser = {
name?: string;
displayName?: string;
email?: string;
type?: string;
};
export type GoogleChatThread = {
name?: string;
threadKey?: string;
};
export type GoogleChatAttachmentDataRef = {
resourceName?: string;
attachmentUploadToken?: string;
};
export type GoogleChatAttachment = {
name?: string;
contentName?: string;
contentType?: string;
thumbnailUri?: string;
downloadUri?: string;
source?: string;
attachmentDataRef?: GoogleChatAttachmentDataRef;
driveDataRef?: Record<string, unknown>;
};
export type GoogleChatUserMention = {
user?: GoogleChatUser;
type?: string;
};
export type GoogleChatAnnotation = {
type?: string;
startIndex?: number;
length?: number;
userMention?: GoogleChatUserMention;
slashCommand?: Record<string, unknown>;
richLinkMetadata?: Record<string, unknown>;
customEmojiMetadata?: Record<string, unknown>;
};
export type GoogleChatMessage = {
name?: string;
text?: string;
argumentText?: string;
sender?: GoogleChatUser;
thread?: GoogleChatThread;
attachment?: GoogleChatAttachment[];
annotations?: GoogleChatAnnotation[];
};
export type GoogleChatEvent = {
type?: string;
eventType?: string;
eventTime?: string;
space?: GoogleChatSpace;
user?: GoogleChatUser;
message?: GoogleChatMessage;
};
export type GoogleChatReaction = {
name?: string;
user?: GoogleChatUser;
emoji?: { unicode?: string };
};
+2 -2
View File
@@ -8,7 +8,7 @@
"./index.ts"
]
},
"dependencies": {
"clawdbot": "workspace:*"
"peerDependencies": {
"clawdbot": ">=2026.1.23-1"
}
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { TlonAuthorizationSchema, TlonConfigSchema } from "./config-schema.js";
describe("Tlon config schema", () => {
it("accepts channelRules with string keys", () => {
const parsed = TlonAuthorizationSchema.parse({
channelRules: {
"chat/~zod/test": {
mode: "open",
allowedShips: ["~zod"],
},
},
});
expect(parsed.channelRules?.["chat/~zod/test"]?.mode).toBe("open");
});
it("accepts accounts with string keys", () => {
const parsed = TlonConfigSchema.parse({
accounts: {
primary: {
ship: "~zod",
url: "https://example.com",
code: "code-123",
},
},
});
expect(parsed.accounts?.primary?.ship).toBe("~zod");
});
});
+2 -2
View File
@@ -10,7 +10,7 @@ export const TlonChannelRuleSchema = z.object({
});
export const TlonAuthorizationSchema = z.object({
channelRules: z.record(TlonChannelRuleSchema).optional(),
channelRules: z.record(z.string(), TlonChannelRuleSchema).optional(),
});
export const TlonAccountSchema = z.object({
@@ -37,7 +37,7 @@ export const TlonConfigSchema = z.object({
showModelSignature: z.boolean().optional(),
authorization: TlonAuthorizationSchema.optional(),
defaultAuthorizedShips: z.array(ShipSchema).optional(),
accounts: z.record(TlonAccountSchema).optional(),
accounts: z.record(z.string(), TlonAccountSchema).optional(),
});
export const tlonChannelConfigSchema = buildChannelConfigSchema(TlonConfigSchema);
+38
View File
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@urbit/aura", () => ({
scot: vi.fn(() => "mocked-ud"),
da: {
fromUnix: vi.fn(() => 123n),
},
}));
describe("sendDm", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("uses aura v3 helpers for the DM id", async () => {
const { sendDm } = await import("./send.js");
const aura = await import("@urbit/aura");
const scot = vi.mocked(aura.scot);
const fromUnix = vi.mocked(aura.da.fromUnix);
const sentAt = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(sentAt);
const poke = vi.fn(async () => ({}));
const result = await sendDm({
api: { poke },
fromShip: "~zod",
toShip: "~nec",
text: "hi",
});
expect(fromUnix).toHaveBeenCalledWith(sentAt);
expect(scot).toHaveBeenCalledWith("ud", 123n);
expect(poke).toHaveBeenCalledTimes(1);
expect(result.messageId).toBe("~zod/mocked-ud");
});
});
+2 -2
View File
@@ -1,4 +1,4 @@
import { unixToDa, formatUd } from "@urbit/aura";
import { scot, da } from "@urbit/aura";
export type TlonPokeApi = {
poke: (params: { app: string; mark: string; json: unknown }) => Promise<unknown>;
@@ -14,7 +14,7 @@ type SendTextParams = {
export async function sendDm({ api, fromShip, toShip, text }: SendTextParams) {
const story = [{ inline: [text] }];
const sentAt = Date.now();
const idUd = formatUd(unixToDa(sentAt));
const idUd = scot('ud', da.fromUnix(sentAt));
const id = `${fromShip}/${idUd}`;
const delta = {
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import type { WebhookContext } from "../types.js";
import { TwilioProvider } from "./twilio.js";
const STREAM_URL = "wss://example.ngrok.app/voice/stream";
function createProvider(): TwilioProvider {
return new TwilioProvider(
{ accountSid: "AC123", authToken: "secret" },
{ publicUrl: "https://example.ngrok.app", streamPath: "/voice/stream" },
);
}
function createContext(
rawBody: string,
query?: WebhookContext["query"],
): WebhookContext {
return {
headers: {},
rawBody,
url: "https://example.ngrok.app/voice/twilio",
method: "POST",
query,
};
}
describe("TwilioProvider", () => {
it("returns streaming TwiML for outbound conversation calls before in-progress", () => {
const provider = createProvider();
const ctx = createContext("CallStatus=initiated&Direction=outbound-api", {
callId: "call-1",
});
const result = provider.parseWebhookEvent(ctx);
expect(result.providerResponseBody).toContain(STREAM_URL);
expect(result.providerResponseBody).toContain("<Connect>");
});
it("returns empty TwiML for status callbacks", () => {
const provider = createProvider();
const ctx = createContext("CallStatus=ringing&Direction=outbound-api", {
callId: "call-1",
type: "status",
});
const result = provider.parseWebhookEvent(ctx);
expect(result.providerResponseBody).toBe(
'<?xml version="1.0" encoding="UTF-8"?><Response></Response>',
);
});
it("returns streaming TwiML for inbound calls", () => {
const provider = createProvider();
const ctx = createContext("CallStatus=ringing&Direction=inbound");
const result = provider.parseWebhookEvent(ctx);
expect(result.providerResponseBody).toContain(STREAM_URL);
expect(result.providerResponseBody).toContain("<Connect>");
});
});
@@ -294,6 +294,7 @@ export class TwilioProvider implements VoiceCallProvider {
const isStatusCallback = type === "status";
const callStatus = params.get("CallStatus");
const direction = params.get("Direction");
const isOutbound = direction?.startsWith("outbound") ?? false;
const callIdFromQuery =
typeof ctx.query?.callId === "string" && ctx.query.callId.trim()
? ctx.query.callId.trim()
@@ -313,6 +314,14 @@ export class TwilioProvider implements VoiceCallProvider {
if (this.notifyCalls.has(callIdFromQuery)) {
return TwilioProvider.EMPTY_TWIML;
}
// Conversation mode: return streaming TwiML immediately for outbound calls.
if (isOutbound) {
const streamUrl = this.getStreamUrl();
return streamUrl
? this.getStreamConnectXml(streamUrl)
: TwilioProvider.PAUSE_TWIML;
}
}
// Status callbacks should not receive TwiML.
+1 -1
View File
@@ -15,7 +15,7 @@ primary_region = "iad" # change to your closest region
NODE_OPTIONS = "--max-old-space-size=1536"
[processes]
app = "node dist/index.js gateway --port 3000 --bind lan"
app = "node dist/index.js gateway --allow-unconfigured --port 3000 --bind lan"
[http_service]
internal_port = 3000
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clawdbot",
"version": "2026.1.23",
"version": "2026.1.24-0",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",
@@ -43,6 +43,7 @@
"dist/slack/**",
"dist/telegram/**",
"dist/tui/**",
"dist/tts/**",
"dist/web/**",
"dist/wizard/**",
"dist/*.js",
Loaded 100 of 278 files, more files were not shown because too many files have changed in this diff. Show more