From 8b4696c087aed2256c74d106b58367406fb96a53 Mon Sep 17 00:00:00 2001
From: zerone0x
Date: Sun, 25 Jan 2026 15:24:02 +0800
Subject: [PATCH 001/196] fix(voice-call): validate provider credentials from
env vars
The `validateProviderConfig()` function now checks both config values
AND environment variables when validating provider credentials. This
aligns the validation behavior with `resolveProvider()` which already
falls back to env vars.
Previously, users who set credentials via environment variables would
get validation errors even though the credentials would be found at
runtime. The error messages correctly suggested env vars as an
alternative, but the validation didn't actually check them.
Affects all three supported providers: Twilio, Telnyx, and Plivo.
Fixes #1709
Co-Authored-By: Claude
---
extensions/voice-call/src/config.test.ts | 196 +++++++++++++++++++++++
extensions/voice-call/src/config.ts | 12 +-
2 files changed, 202 insertions(+), 6 deletions(-)
create mode 100644 extensions/voice-call/src/config.test.ts
diff --git a/extensions/voice-call/src/config.test.ts b/extensions/voice-call/src/config.test.ts
new file mode 100644
index 000000000..3a4311c8a
--- /dev/null
+++ b/extensions/voice-call/src/config.test.ts
@@ -0,0 +1,196 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import { validateProviderConfig, type VoiceCallConfig } from "./config.js";
+
+function createBaseConfig(
+ provider: "telnyx" | "twilio" | "plivo" | "mock",
+): VoiceCallConfig {
+ return {
+ enabled: true,
+ provider,
+ fromNumber: "+15550001234",
+ inboundPolicy: "disabled",
+ allowFrom: [],
+ outbound: { defaultMode: "notify", notifyHangupDelaySec: 3 },
+ maxDurationSeconds: 300,
+ silenceTimeoutMs: 800,
+ transcriptTimeoutMs: 180000,
+ ringTimeoutMs: 30000,
+ maxConcurrentCalls: 1,
+ serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" },
+ tailscale: { mode: "off", path: "/voice/webhook" },
+ tunnel: { provider: "none", allowNgrokFreeTier: true },
+ streaming: {
+ enabled: false,
+ sttProvider: "openai-realtime",
+ sttModel: "gpt-4o-transcribe",
+ silenceDurationMs: 800,
+ vadThreshold: 0.5,
+ streamPath: "/voice/stream",
+ },
+ skipSignatureVerification: false,
+ stt: { provider: "openai", model: "whisper-1" },
+ tts: { provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" },
+ responseModel: "openai/gpt-4o-mini",
+ responseTimeoutMs: 30000,
+ };
+}
+
+describe("validateProviderConfig", () => {
+ const originalEnv = { ...process.env };
+
+ beforeEach(() => {
+ // Clear all relevant env vars before each test
+ delete process.env.TWILIO_ACCOUNT_SID;
+ delete process.env.TWILIO_AUTH_TOKEN;
+ delete process.env.TELNYX_API_KEY;
+ delete process.env.TELNYX_CONNECTION_ID;
+ delete process.env.PLIVO_AUTH_ID;
+ delete process.env.PLIVO_AUTH_TOKEN;
+ });
+
+ afterEach(() => {
+ // Restore original env
+ process.env = { ...originalEnv };
+ });
+
+ describe("twilio provider", () => {
+ it("passes validation when credentials are in config", () => {
+ const config = createBaseConfig("twilio");
+ config.twilio = { accountSid: "AC123", authToken: "secret" };
+
+ const result = validateProviderConfig(config);
+
+ expect(result.valid).toBe(true);
+ expect(result.errors).toEqual([]);
+ });
+
+ it("passes validation when credentials are in environment variables", () => {
+ process.env.TWILIO_ACCOUNT_SID = "AC123";
+ process.env.TWILIO_AUTH_TOKEN = "secret";
+ const config = createBaseConfig("twilio");
+
+ const result = validateProviderConfig(config);
+
+ expect(result.valid).toBe(true);
+ expect(result.errors).toEqual([]);
+ });
+
+ it("passes validation with mixed config and env vars", () => {
+ process.env.TWILIO_AUTH_TOKEN = "secret";
+ const config = createBaseConfig("twilio");
+ config.twilio = { accountSid: "AC123" };
+
+ const result = validateProviderConfig(config);
+
+ expect(result.valid).toBe(true);
+ expect(result.errors).toEqual([]);
+ });
+
+ it("fails validation when accountSid is missing everywhere", () => {
+ process.env.TWILIO_AUTH_TOKEN = "secret";
+ const config = createBaseConfig("twilio");
+
+ const result = validateProviderConfig(config);
+
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain(
+ "plugins.entries.voice-call.config.twilio.accountSid is required (or set TWILIO_ACCOUNT_SID env)",
+ );
+ });
+
+ it("fails validation when authToken is missing everywhere", () => {
+ process.env.TWILIO_ACCOUNT_SID = "AC123";
+ const config = createBaseConfig("twilio");
+
+ const result = validateProviderConfig(config);
+
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain(
+ "plugins.entries.voice-call.config.twilio.authToken is required (or set TWILIO_AUTH_TOKEN env)",
+ );
+ });
+ });
+
+ describe("telnyx provider", () => {
+ it("passes validation when credentials are in config", () => {
+ const config = createBaseConfig("telnyx");
+ config.telnyx = { apiKey: "KEY123", connectionId: "CONN456" };
+
+ const result = validateProviderConfig(config);
+
+ expect(result.valid).toBe(true);
+ expect(result.errors).toEqual([]);
+ });
+
+ it("passes validation when credentials are in environment variables", () => {
+ process.env.TELNYX_API_KEY = "KEY123";
+ process.env.TELNYX_CONNECTION_ID = "CONN456";
+ const config = createBaseConfig("telnyx");
+
+ const result = validateProviderConfig(config);
+
+ expect(result.valid).toBe(true);
+ expect(result.errors).toEqual([]);
+ });
+
+ it("fails validation when apiKey is missing everywhere", () => {
+ process.env.TELNYX_CONNECTION_ID = "CONN456";
+ const config = createBaseConfig("telnyx");
+
+ const result = validateProviderConfig(config);
+
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain(
+ "plugins.entries.voice-call.config.telnyx.apiKey is required (or set TELNYX_API_KEY env)",
+ );
+ });
+ });
+
+ describe("plivo provider", () => {
+ it("passes validation when credentials are in config", () => {
+ const config = createBaseConfig("plivo");
+ config.plivo = { authId: "MA123", authToken: "secret" };
+
+ const result = validateProviderConfig(config);
+
+ expect(result.valid).toBe(true);
+ expect(result.errors).toEqual([]);
+ });
+
+ it("passes validation when credentials are in environment variables", () => {
+ process.env.PLIVO_AUTH_ID = "MA123";
+ process.env.PLIVO_AUTH_TOKEN = "secret";
+ const config = createBaseConfig("plivo");
+
+ const result = validateProviderConfig(config);
+
+ expect(result.valid).toBe(true);
+ expect(result.errors).toEqual([]);
+ });
+
+ it("fails validation when authId is missing everywhere", () => {
+ process.env.PLIVO_AUTH_TOKEN = "secret";
+ const config = createBaseConfig("plivo");
+
+ const result = validateProviderConfig(config);
+
+ expect(result.valid).toBe(false);
+ expect(result.errors).toContain(
+ "plugins.entries.voice-call.config.plivo.authId is required (or set PLIVO_AUTH_ID env)",
+ );
+ });
+ });
+
+ describe("disabled config", () => {
+ it("skips validation when enabled is false", () => {
+ const config = createBaseConfig("twilio");
+ config.enabled = false;
+
+ const result = validateProviderConfig(config);
+
+ expect(result.valid).toBe(true);
+ expect(result.errors).toEqual([]);
+ });
+ });
+});
diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts
index 832e692ca..403a2eb89 100644
--- a/extensions/voice-call/src/config.ts
+++ b/extensions/voice-call/src/config.ts
@@ -352,12 +352,12 @@ export function validateProviderConfig(config: VoiceCallConfig): {
}
if (config.provider === "telnyx") {
- if (!config.telnyx?.apiKey) {
+ if (!config.telnyx?.apiKey && !process.env.TELNYX_API_KEY) {
errors.push(
"plugins.entries.voice-call.config.telnyx.apiKey is required (or set TELNYX_API_KEY env)",
);
}
- if (!config.telnyx?.connectionId) {
+ if (!config.telnyx?.connectionId && !process.env.TELNYX_CONNECTION_ID) {
errors.push(
"plugins.entries.voice-call.config.telnyx.connectionId is required (or set TELNYX_CONNECTION_ID env)",
);
@@ -365,12 +365,12 @@ export function validateProviderConfig(config: VoiceCallConfig): {
}
if (config.provider === "twilio") {
- if (!config.twilio?.accountSid) {
+ if (!config.twilio?.accountSid && !process.env.TWILIO_ACCOUNT_SID) {
errors.push(
"plugins.entries.voice-call.config.twilio.accountSid is required (or set TWILIO_ACCOUNT_SID env)",
);
}
- if (!config.twilio?.authToken) {
+ if (!config.twilio?.authToken && !process.env.TWILIO_AUTH_TOKEN) {
errors.push(
"plugins.entries.voice-call.config.twilio.authToken is required (or set TWILIO_AUTH_TOKEN env)",
);
@@ -378,12 +378,12 @@ export function validateProviderConfig(config: VoiceCallConfig): {
}
if (config.provider === "plivo") {
- if (!config.plivo?.authId) {
+ if (!config.plivo?.authId && !process.env.PLIVO_AUTH_ID) {
errors.push(
"plugins.entries.voice-call.config.plivo.authId is required (or set PLIVO_AUTH_ID env)",
);
}
- if (!config.plivo?.authToken) {
+ if (!config.plivo?.authToken && !process.env.PLIVO_AUTH_TOKEN) {
errors.push(
"plugins.entries.voice-call.config.plivo.authToken is required (or set PLIVO_AUTH_TOKEN env)",
);
From dd6bc5382da3747611e3308592b1fecfe1e8f4c3 Mon Sep 17 00:00:00 2001
From: Alg0rix
Date: Sun, 25 Jan 2026 13:35:32 +0000
Subject: [PATCH 002/196] fix(msteams): correct typing indicator sendActivity
call
---
extensions/msteams/src/reply-dispatcher.ts | 66 +++++++++++-----------
1 file changed, 33 insertions(+), 33 deletions(-)
diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts
index c83867a65..7b50b0629 100644
--- a/extensions/msteams/src/reply-dispatcher.ts
+++ b/extensions/msteams/src/reply-dispatcher.ts
@@ -42,7 +42,7 @@ export function createMSTeamsReplyDispatcher(params: {
}) {
const core = getMSTeamsRuntime();
const sendTypingIndicator = async () => {
- await params.context.sendActivities([{ type: "typing" }]);
+ await params.context.sendActivity([{ type: "typing" }]);
};
const typingCallbacks = createTypingCallbacks({
start: sendTypingIndicator,
@@ -70,38 +70,38 @@ export function createMSTeamsReplyDispatcher(params: {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: params.cfg,
channel: "msteams",
- });
- const messages = renderReplyPayloadsToMessages([payload], {
- textChunkLimit: params.textLimit,
- chunkText: true,
- mediaMode: "split",
- tableMode,
- chunkMode,
- });
- const mediaMaxBytes = resolveChannelMediaMaxBytes({
- cfg: params.cfg,
- resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
- });
- const ids = await sendMSTeamsMessages({
- replyStyle: params.replyStyle,
- adapter: params.adapter,
- appId: params.appId,
- conversationRef: params.conversationRef,
- context: params.context,
- messages,
- // Enable default retry/backoff for throttling/transient failures.
- retry: {},
- onRetry: (event) => {
- params.log.debug("retrying send", {
- replyStyle: params.replyStyle,
- ...event,
- });
- },
- tokenProvider: params.tokenProvider,
- sharePointSiteId: params.sharePointSiteId,
- mediaMaxBytes,
- });
- if (ids.length > 0) params.onSentMessageIds?.(ids);
+ });
+ const messages = renderReplyPayloadsToMessages([payload], {
+ textChunkLimit: params.textLimit,
+ chunkText: true,
+ mediaMode: "split",
+ tableMode,
+ chunkMode,
+ });
+ const mediaMaxBytes = resolveChannelMediaMaxBytes({
+ cfg: params.cfg,
+ resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
+ });
+ const ids = await sendMSTeamsMessages({
+ replyStyle: params.replyStyle,
+ adapter: params.adapter,
+ appId: params.appId,
+ conversationRef: params.conversationRef,
+ context: params.context,
+ messages,
+ // Enable default retry/backoff for throttling/transient failures.
+ retry: {},
+ onRetry: (event) => {
+ params.log.debug("retrying send", {
+ replyStyle: params.replyStyle,
+ ...event,
+ });
+ },
+ tokenProvider: params.tokenProvider,
+ sharePointSiteId: params.sharePointSiteId,
+ mediaMaxBytes,
+ });
+ if (ids.length > 0) params.onSentMessageIds?.(ids);
},
onError: (err, info) => {
const errMsg = formatUnknownError(err);
From e40257af33dc0941b83093d190dc3c6c37fd82fb Mon Sep 17 00:00:00 2001
From: 0xJonHoldsCrypto
Date: Sun, 25 Jan 2026 17:12:17 +0000
Subject: [PATCH 003/196] docs: add Raspberry Pi installation guide
---
docs/platforms/raspberry-pi.md | 354 +++++++++++++++++++++++++++++++++
1 file changed, 354 insertions(+)
create mode 100644 docs/platforms/raspberry-pi.md
diff --git a/docs/platforms/raspberry-pi.md b/docs/platforms/raspberry-pi.md
new file mode 100644
index 000000000..1273d0112
--- /dev/null
+++ b/docs/platforms/raspberry-pi.md
@@ -0,0 +1,354 @@
+---
+summary: "Clawdbot on Raspberry Pi (budget self-hosted setup)"
+read_when:
+ - Setting up Clawdbot on a Raspberry Pi
+ - Running Clawdbot on ARM devices
+ - Building a cheap always-on personal AI
+---
+
+# Clawdbot on Raspberry Pi
+
+## Goal
+
+Run a persistent, always-on Clawdbot Gateway on a Raspberry Pi for **~$35-80** one-time cost (no monthly fees).
+
+Perfect for:
+- 24/7 personal AI assistant
+- Home automation hub
+- Low-power, always-available Telegram/WhatsApp bot
+
+## Hardware Requirements
+
+| Pi Model | RAM | Works? | Notes |
+|----------|-----|--------|-------|
+| **Pi 5** | 4GB/8GB | ✅ Best | Fastest, recommended |
+| **Pi 4** | 4GB | ✅ Good | Sweet spot for most users |
+| **Pi 4** | 2GB | ✅ OK | Works, add swap |
+| **Pi 4** | 1GB | ⚠️ Tight | Possible with swap, minimal config |
+| **Pi 3B+** | 1GB | ⚠️ Slow | Works but sluggish |
+| **Pi Zero 2 W** | 512MB | ❌ | Not recommended |
+
+**Minimum specs:** 1GB RAM, 1 core, 500MB disk
+**Recommended:** 2GB+ RAM, 64-bit OS, 16GB+ SD card (or USB SSD)
+
+## What You'll Need
+
+- Raspberry Pi 4 or 5 (2GB+ recommended)
+- MicroSD card (16GB+) or USB SSD (better performance)
+- Power supply (official Pi PSU recommended)
+- Network connection (Ethernet or WiFi)
+- ~30 minutes
+
+## 1) Flash the OS
+
+Use **Raspberry Pi OS Lite (64-bit)** — no desktop needed for a headless server.
+
+1. Download [Raspberry Pi Imager](https://www.raspberrypi.com/software/)
+2. Choose OS: **Raspberry Pi OS Lite (64-bit)**
+3. Click the gear icon (⚙️) to pre-configure:
+ - Set hostname: `clawdbot`
+ - Enable SSH
+ - Set username/password
+ - Configure WiFi (if not using Ethernet)
+4. Flash to your SD card / USB drive
+5. Insert and boot the Pi
+
+## 2) Connect via SSH
+
+```bash
+ssh pi@clawdbot.local
+# or use the IP address
+ssh pi@192.168.x.x
+```
+
+## 3) System Setup
+
+```bash
+# Update system
+sudo apt update && sudo apt upgrade -y
+
+# Install essential packages
+sudo apt install -y git curl build-essential
+
+# Set timezone (important for cron/reminders)
+sudo timedatectl set-timezone America/Chicago # Change to your timezone
+```
+
+## 4) Install Node.js 22 (ARM64)
+
+```bash
+# Install Node.js via NodeSource
+curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
+sudo apt install -y nodejs
+
+# Verify
+node --version # Should show v22.x.x
+npm --version
+```
+
+## 5) Add Swap (Important for 2GB or less)
+
+Swap prevents out-of-memory crashes:
+
+```bash
+# Create 2GB swap file
+sudo fallocate -l 2G /swapfile
+sudo chmod 600 /swapfile
+sudo mkswap /swapfile
+sudo swapon /swapfile
+
+# Make permanent
+echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
+
+# Optimize for low RAM (reduce swappiness)
+echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.conf
+sudo sysctl -p
+```
+
+## 6) Install Clawdbot
+
+### Option A: Standard Install (Recommended)
+
+```bash
+curl -fsSL https://clawd.bot/install.sh | bash
+```
+
+### Option B: Hackable Install (For tinkering)
+
+```bash
+git clone https://github.com/clawdbot/clawdbot.git
+cd clawdbot
+npm install
+npm run build
+npm link
+```
+
+The hackable install gives you direct access to logs and code — useful for debugging ARM-specific issues.
+
+## 7) Run Onboarding
+
+```bash
+clawdbot onboard --install-daemon
+```
+
+Follow the wizard:
+1. **Gateway mode:** Local
+2. **Auth:** API keys recommended (OAuth can be finicky on headless Pi)
+3. **Channels:** Telegram is easiest to start with
+4. **Daemon:** Yes (systemd)
+
+## 8) Verify Installation
+
+```bash
+# Check status
+clawdbot status
+
+# Check service
+sudo systemctl status clawdbot
+
+# View logs
+journalctl -u clawdbot -f
+```
+
+## 9) Access the Dashboard
+
+Since the Pi is headless, use an SSH tunnel:
+
+```bash
+# From your laptop/desktop
+ssh -L 18789:localhost:18789 pi@clawdbot.local
+
+# Then open in browser
+open http://localhost:18789
+```
+
+Or use Tailscale for always-on access:
+
+```bash
+# On the Pi
+curl -fsSL https://tailscale.com/install.sh | sh
+sudo tailscale up
+
+# Update config
+clawdbot config set gateway.bind tailnet
+sudo systemctl restart clawdbot
+```
+
+---
+
+## Performance Optimizations
+
+### Use a USB SSD (Huge Improvement)
+
+SD cards are slow and wear out. A USB SSD dramatically improves performance:
+
+```bash
+# Check if booting from USB
+lsblk
+```
+
+See [Pi USB boot guide](https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#usb-mass-storage-boot) for setup.
+
+### Reduce Memory Usage
+
+```bash
+# Disable GPU memory allocation (headless)
+echo 'gpu_mem=16' | sudo tee -a /boot/config.txt
+
+# Disable Bluetooth if not needed
+sudo systemctl disable bluetooth
+```
+
+### Monitor Resources
+
+```bash
+# Check memory
+free -h
+
+# Check CPU temperature
+vcgencmd measure_temp
+
+# Live monitoring
+htop
+```
+
+---
+
+## ARM-Specific Notes
+
+### Binary Compatibility
+
+Most Clawdbot features work on ARM64, but some external binaries may need ARM builds:
+
+| Tool | ARM64 Status | Notes |
+|------|--------------|-------|
+| Node.js | ✅ | Works great |
+| WhatsApp (Baileys) | ✅ | Pure JS, no issues |
+| Telegram | ✅ | Pure JS, no issues |
+| gog (Gmail CLI) | ⚠️ | Check for ARM release |
+| Chromium (browser) | ✅ | `sudo apt install chromium-browser` |
+
+If a skill fails, check if its binary has an ARM build. Many Go/Rust tools do; some don't.
+
+### 32-bit vs 64-bit
+
+**Always use 64-bit OS.** Node.js and many modern tools require it. Check with:
+
+```bash
+uname -m
+# Should show: aarch64 (64-bit) not armv7l (32-bit)
+```
+
+---
+
+## Recommended Model Setup
+
+Since the Pi is just the Gateway (models run in the cloud), use API-based models:
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "model": {
+ "primary": "anthropic/claude-sonnet-4-20250514",
+ "fallbacks": ["openai/gpt-4o-mini"]
+ }
+ }
+ }
+}
+```
+
+**Don't try to run local LLMs on a Pi** — even small models are too slow. Let Claude/GPT do the heavy lifting.
+
+---
+
+## Auto-Start on Boot
+
+The onboarding wizard sets this up, but to verify:
+
+```bash
+# Check service is enabled
+sudo systemctl is-enabled clawdbot
+
+# Enable if not
+sudo systemctl enable clawdbot
+
+# Start on boot
+sudo systemctl start clawdbot
+```
+
+---
+
+## Troubleshooting
+
+### Out of Memory (OOM)
+
+```bash
+# Check memory
+free -h
+
+# Add more swap (see Step 5)
+# Or reduce services running on the Pi
+```
+
+### Slow Performance
+
+- Use USB SSD instead of SD card
+- Disable unused services: `sudo systemctl disable cups bluetooth avahi-daemon`
+- Check CPU throttling: `vcgencmd get_throttled` (should return `0x0`)
+
+### Service Won't Start
+
+```bash
+# Check logs
+journalctl -u clawdbot --no-pager -n 100
+
+# Common fix: rebuild
+cd ~/clawdbot # if using hackable install
+npm run build
+sudo systemctl restart clawdbot
+```
+
+### ARM Binary Issues
+
+If a skill fails with "exec format error":
+1. Check if the binary has an ARM64 build
+2. Try building from source
+3. Or use a Docker container with ARM support
+
+### WiFi Drops
+
+For headless Pis on WiFi:
+
+```bash
+# Disable WiFi power management
+sudo iwconfig wlan0 power off
+
+# Make permanent
+echo 'wireless-power off' | sudo tee -a /etc/network/interfaces
+```
+
+---
+
+## Cost Comparison
+
+| Setup | One-Time Cost | Monthly Cost | Notes |
+|-------|---------------|--------------|-------|
+| **Pi 4 (2GB)** | ~$45 | $0 | + power (~$5/yr) |
+| **Pi 4 (4GB)** | ~$55 | $0 | Recommended |
+| **Pi 5 (4GB)** | ~$60 | $0 | Best performance |
+| **Pi 5 (8GB)** | ~$80 | $0 | Overkill but future-proof |
+| DigitalOcean | $0 | $6/mo | $72/year |
+| Hetzner | $0 | €3.79/mo | ~$50/year |
+
+**Break-even:** A Pi pays for itself in ~6-12 months vs cloud VPS.
+
+---
+
+## See Also
+
+- [Linux guide](/platforms/linux) — general Linux setup
+- [DigitalOcean guide](/platforms/digitalocean) — cloud alternative
+- [Hetzner guide](/platforms/hetzner) — Docker setup
+- [Tailscale](/gateway/tailscale) — remote access
+- [Nodes](/nodes) — pair your laptop/phone with the Pi gateway
From 68824c8903ea5cbe429ee36bf63df6aa63b7c6c0 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Sun, 25 Jan 2026 20:58:35 +0000
Subject: [PATCH 004/196] chore: start 2026.1.25 changelog
---
CHANGELOG.md | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4eda32488..1c05e8691 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,11 @@
Docs: https://docs.clawd.bot
+## 2026.1.25
+
+### Changes
+- TBD.
+
## 2026.1.24-3
### Fixes
From ffaeee4c39cbb56be473e719ded16e6b6b8d8986 Mon Sep 17 00:00:00 2001
From: Ross Morsali
Date: Sun, 25 Jan 2026 19:56:04 +0100
Subject: [PATCH 005/196] fix: preserve CLI session IDs for session resume
- Add resumeArgs to DEFAULT_CLAUDE_BACKEND for proper --resume flag usage
- Fix gateway not preserving cliSessionIds/claudeCliSessionId in nextEntry
- Add test for CLI session ID preservation in gateway agent handler
- Update docs with new resumeArgs default
---
docs/gateway/cli-backends.md | 1 +
src/agents/cli-backends.ts | 8 ++
src/gateway/server-methods/agent.test.ts | 163 +++++++++++++++++++++++
src/gateway/server-methods/agent.ts | 2 +
4 files changed, 174 insertions(+)
create mode 100644 src/gateway/server-methods/agent.test.ts
diff --git a/docs/gateway/cli-backends.md b/docs/gateway/cli-backends.md
index 917145cc2..092533c2e 100644
--- a/docs/gateway/cli-backends.md
+++ b/docs/gateway/cli-backends.md
@@ -182,6 +182,7 @@ Clawdbot ships a default for `claude-cli`:
- `command: "claude"`
- `args: ["-p", "--output-format", "json", "--dangerously-skip-permissions"]`
+- `resumeArgs: ["-p", "--output-format", "json", "--dangerously-skip-permissions", "--resume", "{sessionId}"]`
- `modelArg: "--model"`
- `systemPromptArg: "--append-system-prompt"`
- `sessionArg: "--session-id"`
diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts
index a2fcaa8a5..f21c04f52 100644
--- a/src/agents/cli-backends.ts
+++ b/src/agents/cli-backends.ts
@@ -28,6 +28,14 @@ const CLAUDE_MODEL_ALIASES: Record = {
const DEFAULT_CLAUDE_BACKEND: CliBackendConfig = {
command: "claude",
args: ["-p", "--output-format", "json", "--dangerously-skip-permissions"],
+ resumeArgs: [
+ "-p",
+ "--output-format",
+ "json",
+ "--dangerously-skip-permissions",
+ "--resume",
+ "{sessionId}",
+ ],
output: "json",
input: "arg",
modelArg: "--model",
diff --git a/src/gateway/server-methods/agent.test.ts b/src/gateway/server-methods/agent.test.ts
new file mode 100644
index 000000000..149ab4a67
--- /dev/null
+++ b/src/gateway/server-methods/agent.test.ts
@@ -0,0 +1,163 @@
+import { describe, expect, it, vi } from "vitest";
+
+import type { GatewayRequestContext } from "./types.js";
+import { agentHandlers } from "./agent.js";
+
+const mocks = vi.hoisted(() => ({
+ loadSessionEntry: vi.fn(),
+ updateSessionStore: vi.fn(),
+ agentCommand: vi.fn(),
+ registerAgentRunContext: vi.fn(),
+}));
+
+vi.mock("../session-utils.js", () => ({
+ loadSessionEntry: mocks.loadSessionEntry,
+}));
+
+vi.mock("../../config/sessions.js", async () => {
+ const actual = await vi.importActual(
+ "../../config/sessions.js",
+ );
+ return {
+ ...actual,
+ updateSessionStore: mocks.updateSessionStore,
+ resolveAgentIdFromSessionKey: () => "main",
+ resolveExplicitAgentSessionKey: () => undefined,
+ resolveAgentMainSessionKey: () => "agent:main:main",
+ };
+});
+
+vi.mock("../../commands/agent.js", () => ({
+ agentCommand: mocks.agentCommand,
+}));
+
+vi.mock("../../config/config.js", () => ({
+ loadConfig: () => ({}),
+}));
+
+vi.mock("../../agents/agent-scope.js", () => ({
+ listAgentIds: () => ["main"],
+}));
+
+vi.mock("../../infra/agent-events.js", () => ({
+ registerAgentRunContext: mocks.registerAgentRunContext,
+ onAgentEvent: vi.fn(),
+}));
+
+vi.mock("../../sessions/send-policy.js", () => ({
+ resolveSendPolicy: () => "allow",
+}));
+
+vi.mock("../../utils/delivery-context.js", async () => {
+ const actual = await vi.importActual(
+ "../../utils/delivery-context.js",
+ );
+ return {
+ ...actual,
+ normalizeSessionDeliveryFields: () => ({}),
+ };
+});
+
+const makeContext = (): GatewayRequestContext =>
+ ({
+ dedupe: new Map(),
+ addChatRun: vi.fn(),
+ logGateway: { info: vi.fn(), error: vi.fn() },
+ }) as unknown as GatewayRequestContext;
+
+describe("gateway agent handler", () => {
+ it("preserves cliSessionIds from existing session entry", async () => {
+ const existingCliSessionIds = { "claude-cli": "abc-123-def" };
+ const existingClaudeCliSessionId = "abc-123-def";
+
+ mocks.loadSessionEntry.mockReturnValue({
+ cfg: {},
+ storePath: "/tmp/sessions.json",
+ entry: {
+ sessionId: "existing-session-id",
+ updatedAt: Date.now(),
+ cliSessionIds: existingCliSessionIds,
+ claudeCliSessionId: existingClaudeCliSessionId,
+ },
+ canonicalKey: "agent:main:main",
+ });
+
+ let capturedEntry: Record | undefined;
+ mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
+ const store: Record = {};
+ await updater(store);
+ capturedEntry = store["agent:main:main"] as Record;
+ });
+
+ mocks.agentCommand.mockResolvedValue({
+ payloads: [{ text: "ok" }],
+ meta: { durationMs: 100 },
+ });
+
+ const respond = vi.fn();
+ await agentHandlers.agent({
+ params: {
+ message: "test",
+ agentId: "main",
+ sessionKey: "agent:main:main",
+ idempotencyKey: "test-idem",
+ },
+ respond,
+ context: makeContext(),
+ req: { type: "req", id: "1", method: "agent" },
+ client: null,
+ isWebchatConnect: () => false,
+ });
+
+ expect(mocks.updateSessionStore).toHaveBeenCalled();
+ expect(capturedEntry).toBeDefined();
+ expect(capturedEntry?.cliSessionIds).toEqual(existingCliSessionIds);
+ expect(capturedEntry?.claudeCliSessionId).toBe(existingClaudeCliSessionId);
+ });
+
+ it("handles missing cliSessionIds gracefully", async () => {
+ mocks.loadSessionEntry.mockReturnValue({
+ cfg: {},
+ storePath: "/tmp/sessions.json",
+ entry: {
+ sessionId: "existing-session-id",
+ updatedAt: Date.now(),
+ // No cliSessionIds or claudeCliSessionId
+ },
+ canonicalKey: "agent:main:main",
+ });
+
+ let capturedEntry: Record | undefined;
+ mocks.updateSessionStore.mockImplementation(async (_path, updater) => {
+ const store: Record = {};
+ await updater(store);
+ capturedEntry = store["agent:main:main"] as Record;
+ });
+
+ mocks.agentCommand.mockResolvedValue({
+ payloads: [{ text: "ok" }],
+ meta: { durationMs: 100 },
+ });
+
+ const respond = vi.fn();
+ await agentHandlers.agent({
+ params: {
+ message: "test",
+ agentId: "main",
+ sessionKey: "agent:main:main",
+ idempotencyKey: "test-idem-2",
+ },
+ respond,
+ context: makeContext(),
+ req: { type: "req", id: "2", method: "agent" },
+ client: null,
+ isWebchatConnect: () => false,
+ });
+
+ expect(mocks.updateSessionStore).toHaveBeenCalled();
+ expect(capturedEntry).toBeDefined();
+ // Should be undefined, not cause an error
+ expect(capturedEntry?.cliSessionIds).toBeUndefined();
+ expect(capturedEntry?.claudeCliSessionId).toBeUndefined();
+ });
+});
diff --git a/src/gateway/server-methods/agent.ts b/src/gateway/server-methods/agent.ts
index 8c5782e00..d159d1f78 100644
--- a/src/gateway/server-methods/agent.ts
+++ b/src/gateway/server-methods/agent.ts
@@ -251,6 +251,8 @@ export const agentHandlers: GatewayRequestHandlers = {
groupId: resolvedGroupId ?? entry?.groupId,
groupChannel: resolvedGroupChannel ?? entry?.groupChannel,
space: resolvedGroupSpace ?? entry?.space,
+ cliSessionIds: entry?.cliSessionIds,
+ claudeCliSessionId: entry?.claudeCliSessionId,
};
sessionEntry = nextEntry;
const sendPolicy = resolveSendPolicy({
From ae030c32dacdaafeb58147571a1c2e9dbc1d5c03 Mon Sep 17 00:00:00 2001
From: Ross Morsali
Date: Sun, 25 Jan 2026 20:11:57 +0100
Subject: [PATCH 006/196] fix: emit assistant event for CLI backend responses
in TUI
CLI backends (claude-cli etc) don't emit streaming assistant events,
causing TUI to show "(no output)" despite correct processing. Now emits
assistant event with final text before lifecycle end so server-chat
buffer gets populated for WebSocket clients.
---
src/auto-reply/reply/agent-runner-execution.ts | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts
index a428aa6da..47c45b09d 100644
--- a/src/auto-reply/reply/agent-runner-execution.ts
+++ b/src/auto-reply/reply/agent-runner-execution.ts
@@ -179,6 +179,17 @@ export async function runAgentTurnWithFallback(params: {
images: params.opts?.images,
})
.then((result) => {
+ // CLI backends don't emit streaming assistant events, so we need to
+ // emit one with the final text so server-chat can populate its buffer
+ // and send the response to TUI/WebSocket clients.
+ const cliText = result.payloads?.[0]?.text?.trim();
+ if (cliText) {
+ emitAgentEvent({
+ runId,
+ stream: "assistant",
+ data: { text: cliText },
+ });
+ }
emitAgentEvent({
runId,
stream: "lifecycle",
From 6ffc5d93e4d14a7b6dc7cc17187f332b9f143823 Mon Sep 17 00:00:00 2001
From: Ross Morsali
Date: Sun, 25 Jan 2026 21:12:45 +0100
Subject: [PATCH 007/196] test: update CLI runner test to expect --resume for
session resume
---
src/agents/claude-cli-runner.test.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/agents/claude-cli-runner.test.ts b/src/agents/claude-cli-runner.test.ts
index 6414aecb5..7825d00da 100644
--- a/src/agents/claude-cli-runner.test.ts
+++ b/src/agents/claude-cli-runner.test.ts
@@ -61,7 +61,7 @@ describe("runClaudeCliAgent", () => {
expect(argv).toContain("hi");
});
- it("uses provided --session-id when a claude session id is provided", async () => {
+ it("uses --resume when a claude session id is provided", async () => {
runCommandWithTimeoutMock.mockResolvedValueOnce({
stdout: JSON.stringify({ message: "ok", session_id: "sid-2" }),
stderr: "",
@@ -83,7 +83,7 @@ describe("runClaudeCliAgent", () => {
expect(runCommandWithTimeoutMock).toHaveBeenCalledTimes(1);
const argv = runCommandWithTimeoutMock.mock.calls[0]?.[0] as string[];
- expect(argv).toContain("--session-id");
+ expect(argv).toContain("--resume");
expect(argv).toContain("c9d7b831-1c31-4d22-80b9-1e50ca207d4b");
expect(argv).toContain("hi");
});
From e0adf65dac311a6ab253de5f915ef77455b3026f Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Sun, 25 Jan 2026 21:08:23 +0000
Subject: [PATCH 008/196] test: cover CLI chat delta event (#1921) (thanks
@rmorse)
---
CHANGELOG.md | 1 +
src/gateway/server-chat.agent-events.test.ts | 43 ++++++++++++++++++++
2 files changed, 44 insertions(+)
create mode 100644 src/gateway/server-chat.agent-events.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1c05e8691..ee138f13e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ Docs: https://docs.clawd.bot
### Fixes
- Gateway: harden reverse proxy handling for local-client detection and unauthenticated proxied connects. (#1795) Thanks @orlyjamie.
- Security audit: flag loopback Control UI with auth disabled as critical. (#1795) Thanks @orlyjamie.
+- CLI: resume claude-cli sessions and stream CLI replies to TUI clients. (#1921) Thanks @rmorse.
## 2026.1.24-2
diff --git a/src/gateway/server-chat.agent-events.test.ts b/src/gateway/server-chat.agent-events.test.ts
new file mode 100644
index 000000000..14657464a
--- /dev/null
+++ b/src/gateway/server-chat.agent-events.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { createAgentEventHandler, createChatRunState } from "./server-chat.js";
+
+describe("agent event handler", () => {
+ it("emits chat delta for assistant text-only events", () => {
+ const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
+ const broadcast = vi.fn();
+ const nodeSendToSession = vi.fn();
+ const agentRunSeq = new Map();
+ const chatRunState = createChatRunState();
+ chatRunState.registry.add("run-1", { sessionKey: "session-1", clientRunId: "client-1" });
+
+ const handler = createAgentEventHandler({
+ broadcast,
+ nodeSendToSession,
+ agentRunSeq,
+ chatRunState,
+ resolveSessionKeyForRun: () => undefined,
+ clearAgentRunContext: vi.fn(),
+ });
+
+ handler({
+ runId: "run-1",
+ seq: 1,
+ stream: "assistant",
+ ts: Date.now(),
+ data: { text: "Hello world" },
+ });
+
+ const chatCalls = broadcast.mock.calls.filter(([event]) => event === "chat");
+ expect(chatCalls).toHaveLength(1);
+ const payload = chatCalls[0]?.[1] as {
+ state?: string;
+ message?: { content?: Array<{ text?: string }> };
+ };
+ expect(payload.state).toBe("delta");
+ expect(payload.message?.content?.[0]?.text).toBe("Hello world");
+ const sessionChatCalls = nodeSendToSession.mock.calls.filter(([, event]) => event === "chat");
+ expect(sessionChatCalls).toHaveLength(1);
+ nowSpy.mockRestore();
+ });
+});
From 50b4126c79536a9645cddcfe6801916b5f6d9343 Mon Sep 17 00:00:00 2001
From: Vignesh
Date: Sun, 25 Jan 2026 13:42:56 -0800
Subject: [PATCH 009/196] Update deployment link for Railway template
---
docs/railway.mdx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/railway.mdx b/docs/railway.mdx
index 808416f50..b8f994a7d 100644
--- a/docs/railway.mdx
+++ b/docs/railway.mdx
@@ -16,7 +16,7 @@ and you configure everything via the `/setup` web wizard.
## One-click deploy
-Deploy on Railway
+Deploy on Railway
After deploy, find your public URL in **Railway → your service → Settings → Domains**.
From 8f6542409a57c99952c4f03323f52498bc958399 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Sun, 25 Jan 2026 22:13:00 +0000
Subject: [PATCH 010/196] chore: bump versions for 2026.1.25
---
CHANGELOG.md | 1 +
apps/android/app/build.gradle.kts | 4 ++--
apps/ios/Sources/Info.plist | 4 ++--
apps/ios/Tests/Info.plist | 4 ++--
apps/ios/project.yml | 8 ++++----
apps/macos/Sources/Clawdbot/Resources/Info.plist | 4 ++--
docs/platforms/fly.md | 2 +-
docs/platforms/mac/release.md | 14 +++++++-------
docs/reference/RELEASING.md | 2 +-
extensions/bluebubbles/package.json | 2 +-
extensions/copilot-proxy/package.json | 2 +-
extensions/diagnostics-otel/package.json | 2 +-
extensions/discord/package.json | 2 +-
extensions/google-antigravity-auth/package.json | 2 +-
extensions/google-gemini-cli-auth/package.json | 2 +-
extensions/googlechat/package.json | 4 ++--
extensions/imessage/package.json | 2 +-
extensions/line/package.json | 2 +-
extensions/llm-task/package.json | 2 +-
extensions/lobster/package.json | 2 +-
extensions/matrix/package.json | 2 +-
extensions/mattermost/package.json | 2 +-
extensions/memory-core/package.json | 4 ++--
extensions/memory-lancedb/package.json | 2 +-
extensions/msteams/package.json | 2 +-
extensions/nextcloud-talk/package.json | 2 +-
extensions/nostr/package.json | 2 +-
extensions/open-prose/package.json | 2 +-
extensions/signal/package.json | 2 +-
extensions/slack/package.json | 2 +-
extensions/telegram/package.json | 2 +-
extensions/tlon/package.json | 2 +-
extensions/voice-call/CHANGELOG.md | 2 +-
extensions/voice-call/package.json | 2 +-
extensions/whatsapp/package.json | 2 +-
extensions/zalo/package.json | 2 +-
extensions/zalouser/package.json | 2 +-
package.json | 4 ++--
38 files changed, 54 insertions(+), 53 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ee138f13e..afdbb8463 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,7 @@
Docs: https://docs.clawd.bot
## 2026.1.25
+Status: unreleased.
### Changes
- TBD.
diff --git a/apps/android/app/build.gradle.kts b/apps/android/app/build.gradle.kts
index d8d77ebe1..a015c0e36 100644
--- a/apps/android/app/build.gradle.kts
+++ b/apps/android/app/build.gradle.kts
@@ -21,8 +21,8 @@ android {
applicationId = "com.clawdbot.android"
minSdk = 31
targetSdk = 36
- versionCode = 202601240
- versionName = "2026.1.24"
+ versionCode = 202601250
+ versionName = "2026.1.25"
}
buildTypes {
diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist
index 9dd7a0315..e1cf2b71d 100644
--- a/apps/ios/Sources/Info.plist
+++ b/apps/ios/Sources/Info.plist
@@ -19,9 +19,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2026.1.24
+ 2026.1.25
CFBundleVersion
- 20260124
+ 20260125
NSAppTransportSecurity
NSAllowsArbitraryLoadsInWebContent
diff --git a/apps/ios/Tests/Info.plist b/apps/ios/Tests/Info.plist
index 798a77421..6ff977b05 100644
--- a/apps/ios/Tests/Info.plist
+++ b/apps/ios/Tests/Info.plist
@@ -17,8 +17,8 @@
CFBundlePackageType
BNDL
CFBundleShortVersionString
- 2026.1.24
+ 2026.1.25
CFBundleVersion
- 20260124
+ 20260125
diff --git a/apps/ios/project.yml b/apps/ios/project.yml
index 52faeb9d0..0073b4ef9 100644
--- a/apps/ios/project.yml
+++ b/apps/ios/project.yml
@@ -81,8 +81,8 @@ targets:
properties:
CFBundleDisplayName: Clawdbot
CFBundleIconName: AppIcon
- CFBundleShortVersionString: "2026.1.24"
- CFBundleVersion: "20260124"
+ CFBundleShortVersionString: "2026.1.25"
+ CFBundleVersion: "20260125"
UILaunchScreen: {}
UIApplicationSceneManifest:
UIApplicationSupportsMultipleScenes: false
@@ -130,5 +130,5 @@ targets:
path: Tests/Info.plist
properties:
CFBundleDisplayName: ClawdbotTests
- CFBundleShortVersionString: "2026.1.24"
- CFBundleVersion: "20260124"
+ CFBundleShortVersionString: "2026.1.25"
+ CFBundleVersion: "20260125"
diff --git a/apps/macos/Sources/Clawdbot/Resources/Info.plist b/apps/macos/Sources/Clawdbot/Resources/Info.plist
index 1c7d9619f..ee9e3113d 100644
--- a/apps/macos/Sources/Clawdbot/Resources/Info.plist
+++ b/apps/macos/Sources/Clawdbot/Resources/Info.plist
@@ -15,9 +15,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 2026.1.24
+ 2026.1.25
CFBundleVersion
- 202601240
+ 202601250
CFBundleIconFile
Clawdbot
CFBundleURLTypes
diff --git a/docs/platforms/fly.md b/docs/platforms/fly.md
index d43b83ed7..0fdf176ae 100644
--- a/docs/platforms/fly.md
+++ b/docs/platforms/fly.md
@@ -182,7 +182,7 @@ cat > /data/clawdbot.json << 'EOF'
"bind": "auto"
},
"meta": {
- "lastTouchedVersion": "2026.1.24"
+ "lastTouchedVersion": "2026.1.25"
}
}
EOF
diff --git a/docs/platforms/mac/release.md b/docs/platforms/mac/release.md
index d2d267661..d3bfd02c3 100644
--- a/docs/platforms/mac/release.md
+++ b/docs/platforms/mac/release.md
@@ -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.24-3 \
+APP_VERSION=2026.1.25 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: ()" \
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.24-3.zip
+ditto -c -k --sequesterRsrc --keepParent dist/Clawdbot.app dist/Clawdbot-2026.1.25.zip
# Optional: also build a styled DMG for humans (drag to /Applications)
-scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.24-3.dmg
+scripts/create-dmg.sh dist/Clawdbot.app dist/Clawdbot-2026.1.25.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.24-3.dmg
# --apple-id "" --team-id "" --password ""
NOTARIZE=1 NOTARYTOOL_PROFILE=clawdbot-notary \
BUNDLE_ID=com.clawdbot.mac \
-APP_VERSION=2026.1.24-3 \
+APP_VERSION=2026.1.25 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: ()" \
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.24-3.dSYM.zip
+ditto -c -k --keepParent apps/macos/.build/release/Clawdbot.app.dSYM dist/Clawdbot-2026.1.25.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.24-3.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.25.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.24-3.zip` (and `Clawdbot-2026.1.24-3.dSYM.zip`) to the GitHub release for tag `v2026.1.24-3`.
+- Upload `Clawdbot-2026.1.25.zip` (and `Clawdbot-2026.1.25.dSYM.zip`) to the GitHub release for tag `v2026.1.25`.
- 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.
diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md
index 6492bd469..244757a48 100644
--- a/docs/reference/RELEASING.md
+++ b/docs/reference/RELEASING.md
@@ -17,7 +17,7 @@ When the operator says “release”, immediately do this preflight (no extra qu
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
1) **Version & metadata**
-- [ ] Bump `package.json` version (e.g., `2026.1.24`).
+- [ ] Bump `package.json` version (e.g., `2026.1.25`).
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
- [ ] Update CLI/version strings: [`src/cli/program.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/cli/program.ts) and the Baileys user agent in [`src/provider-web.ts`](https://github.com/clawdbot/clawdbot/blob/main/src/provider-web.ts).
- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js) for `clawdbot`.
diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json
index 925b05bc1..7d82036a0 100644
--- a/extensions/bluebubbles/package.json
+++ b/extensions/bluebubbles/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/bluebubbles",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot BlueBubbles channel plugin",
"clawdbot": {
diff --git a/extensions/copilot-proxy/package.json b/extensions/copilot-proxy/package.json
index 792a94225..2a9a63c71 100644
--- a/extensions/copilot-proxy/package.json
+++ b/extensions/copilot-proxy/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/copilot-proxy",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot Copilot Proxy provider plugin",
"clawdbot": {
diff --git a/extensions/diagnostics-otel/package.json b/extensions/diagnostics-otel/package.json
index 2afc99e2e..65a6bf0cd 100644
--- a/extensions/diagnostics-otel/package.json
+++ b/extensions/diagnostics-otel/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/diagnostics-otel",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot diagnostics OpenTelemetry exporter",
"clawdbot": {
diff --git a/extensions/discord/package.json b/extensions/discord/package.json
index dae5fe1f1..90a99d4d3 100644
--- a/extensions/discord/package.json
+++ b/extensions/discord/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/discord",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot Discord channel plugin",
"clawdbot": {
diff --git a/extensions/google-antigravity-auth/package.json b/extensions/google-antigravity-auth/package.json
index 96bffde7c..f1d8f86bd 100644
--- a/extensions/google-antigravity-auth/package.json
+++ b/extensions/google-antigravity-auth/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-antigravity-auth",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot Google Antigravity OAuth provider plugin",
"clawdbot": {
diff --git a/extensions/google-gemini-cli-auth/package.json b/extensions/google-gemini-cli-auth/package.json
index dc8a894d7..7e3fef15b 100644
--- a/extensions/google-gemini-cli-auth/package.json
+++ b/extensions/google-gemini-cli-auth/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/google-gemini-cli-auth",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot Gemini CLI OAuth provider plugin",
"clawdbot": {
diff --git a/extensions/googlechat/package.json b/extensions/googlechat/package.json
index 056bdedb6..af1ccf8e1 100644
--- a/extensions/googlechat/package.json
+++ b/extensions/googlechat/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/googlechat",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot Google Chat channel plugin",
"clawdbot": {
@@ -34,6 +34,6 @@
"clawdbot": "workspace:*"
},
"peerDependencies": {
- "clawdbot": ">=2026.1.24"
+ "clawdbot": ">=2026.1.25"
}
}
diff --git a/extensions/imessage/package.json b/extensions/imessage/package.json
index 79aa7890d..944ad06bf 100644
--- a/extensions/imessage/package.json
+++ b/extensions/imessage/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/imessage",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot iMessage channel plugin",
"clawdbot": {
diff --git a/extensions/line/package.json b/extensions/line/package.json
index b518f5ca5..346d66415 100644
--- a/extensions/line/package.json
+++ b/extensions/line/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/line",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot LINE channel plugin",
"clawdbot": {
diff --git a/extensions/llm-task/package.json b/extensions/llm-task/package.json
index a03344d1a..d6bfbb31d 100644
--- a/extensions/llm-task/package.json
+++ b/extensions/llm-task/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/llm-task",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot JSON-only LLM task plugin",
"clawdbot": {
diff --git a/extensions/lobster/package.json b/extensions/lobster/package.json
index 3926b553b..b73dbac69 100644
--- a/extensions/lobster/package.json
+++ b/extensions/lobster/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/lobster",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
"clawdbot": {
diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json
index 24529ee97..7fa12bc74 100644
--- a/extensions/matrix/package.json
+++ b/extensions/matrix/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/matrix",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot Matrix channel plugin",
"clawdbot": {
diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json
index 77d799c34..60c02d50f 100644
--- a/extensions/mattermost/package.json
+++ b/extensions/mattermost/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/mattermost",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot Mattermost channel plugin",
"clawdbot": {
diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json
index c70c2a63f..c70da1395 100644
--- a/extensions/memory-core/package.json
+++ b/extensions/memory-core/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/memory-core",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot core memory search plugin",
"clawdbot": {
@@ -9,6 +9,6 @@
]
},
"peerDependencies": {
- "clawdbot": ">=2026.1.24"
+ "clawdbot": ">=2026.1.25"
}
}
diff --git a/extensions/memory-lancedb/package.json b/extensions/memory-lancedb/package.json
index 80018044f..e003f5890 100644
--- a/extensions/memory-lancedb/package.json
+++ b/extensions/memory-lancedb/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/memory-lancedb",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot LanceDB-backed long-term memory plugin with auto-recall/capture",
"dependencies": {
diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json
index b336b80e6..b94f8e76a 100644
--- a/extensions/msteams/package.json
+++ b/extensions/msteams/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/msteams",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot Microsoft Teams channel plugin",
"clawdbot": {
diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json
index bf5e443e5..2da3f3b2a 100644
--- a/extensions/nextcloud-talk/package.json
+++ b/extensions/nextcloud-talk/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/nextcloud-talk",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot Nextcloud Talk channel plugin",
"clawdbot": {
diff --git a/extensions/nostr/package.json b/extensions/nostr/package.json
index 3a3e5ac56..b2fb4b799 100644
--- a/extensions/nostr/package.json
+++ b/extensions/nostr/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/nostr",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot Nostr channel plugin for NIP-04 encrypted DMs",
"clawdbot": {
diff --git a/extensions/open-prose/package.json b/extensions/open-prose/package.json
index 873f3458a..052201205 100644
--- a/extensions/open-prose/package.json
+++ b/extensions/open-prose/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/open-prose",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
"clawdbot": {
diff --git a/extensions/signal/package.json b/extensions/signal/package.json
index 034c65dea..65948eb7b 100644
--- a/extensions/signal/package.json
+++ b/extensions/signal/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/signal",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot Signal channel plugin",
"clawdbot": {
diff --git a/extensions/slack/package.json b/extensions/slack/package.json
index 73f2f6ecd..5bd452d2e 100644
--- a/extensions/slack/package.json
+++ b/extensions/slack/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/slack",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot Slack channel plugin",
"clawdbot": {
diff --git a/extensions/telegram/package.json b/extensions/telegram/package.json
index 81b378df2..64d3d7dea 100644
--- a/extensions/telegram/package.json
+++ b/extensions/telegram/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/telegram",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot Telegram channel plugin",
"clawdbot": {
diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json
index dca4f914d..06750126d 100644
--- a/extensions/tlon/package.json
+++ b/extensions/tlon/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/tlon",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot Tlon/Urbit channel plugin",
"clawdbot": {
diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md
index 6123a7315..a8721d47d 100644
--- a/extensions/voice-call/CHANGELOG.md
+++ b/extensions/voice-call/CHANGELOG.md
@@ -1,6 +1,6 @@
# Changelog
-## 2026.1.24
+## 2026.1.25
### Changes
- Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deep‑merges with core).
diff --git a/extensions/voice-call/package.json b/extensions/voice-call/package.json
index 840776c19..31b171f76 100644
--- a/extensions/voice-call/package.json
+++ b/extensions/voice-call/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/voice-call",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot voice-call plugin",
"dependencies": {
diff --git a/extensions/whatsapp/package.json b/extensions/whatsapp/package.json
index 8e18af842..b7b57eb51 100644
--- a/extensions/whatsapp/package.json
+++ b/extensions/whatsapp/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/whatsapp",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot WhatsApp channel plugin",
"clawdbot": {
diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json
index a3a87a878..8f077a6b3 100644
--- a/extensions/zalo/package.json
+++ b/extensions/zalo/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalo",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot Zalo channel plugin",
"clawdbot": {
diff --git a/extensions/zalouser/package.json b/extensions/zalouser/package.json
index 513295b46..0ab93d1ce 100644
--- a/extensions/zalouser/package.json
+++ b/extensions/zalouser/package.json
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/zalouser",
- "version": "2026.1.24",
+ "version": "2026.1.25",
"type": "module",
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
"dependencies": {
diff --git a/package.json b/package.json
index 5d77e25d0..2a841139f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "clawdbot",
- "version": "2026.1.24-3",
+ "version": "2026.1.25",
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",
@@ -220,7 +220,7 @@
"@types/proper-lockfile": "^4.1.4",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1",
- "@typescript/native-preview": "7.0.0-dev.20260124.1",
+ "@typescript/native-preview": "7.0.0-dev.20260125.1",
"@vitest/coverage-v8": "^4.0.18",
"docx-preview": "^0.3.7",
"lit": "^3.3.2",
From 5c231fc21f7d458edf2d766da336c817fb9796de Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 20:01:38 -0600
Subject: [PATCH 011/196] Doctor: warn on gateway exposure (#2016)
Co-authored-by: Alex Alaniz
---
CHANGELOG.md | 2 +-
src/commands/doctor-security.ts | 55 +++++++++++++++++++++++++++++++++
2 files changed, 56 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index afdbb8463..cacc265a3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,7 @@ Docs: https://docs.clawd.bot
Status: unreleased.
### Changes
-- TBD.
+- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
## 2026.1.24-3
diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts
index b3d82247f..483917faa 100644
--- a/src/commands/doctor-security.ts
+++ b/src/commands/doctor-security.ts
@@ -10,6 +10,61 @@ export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
const warnings: string[] = [];
const auditHint = `- Run: ${formatCliCommand("clawdbot security audit --deep")}`;
+ // ===========================================
+ // GATEWAY NETWORK EXPOSURE CHECK
+ // ===========================================
+ // Check for dangerous gateway binding configurations
+ // that expose the gateway to network without proper auth
+
+ const gatewayBind = cfg.gateway?.bind ?? "loopback";
+ const customBindHost = cfg.gateway?.customBindHost?.trim();
+ const authMode = cfg.gateway?.auth?.mode ?? "off";
+ const authToken = cfg.gateway?.auth?.token;
+ const authPassword = cfg.gateway?.auth?.password;
+
+ const isLoopbackBindHost = (host: string) => {
+ const normalized = host.trim().toLowerCase();
+ return (
+ normalized === "localhost" ||
+ normalized === "::1" ||
+ normalized === "[::1]" ||
+ normalized.startsWith("127.")
+ );
+ };
+
+ // Bindings that expose gateway beyond localhost
+ const exposedBindings = ["all", "lan", "0.0.0.0"];
+ const isExposed =
+ exposedBindings.includes(gatewayBind) ||
+ (gatewayBind === "custom" && (!customBindHost || !isLoopbackBindHost(customBindHost)));
+
+ if (isExposed) {
+ if (authMode === "off") {
+ warnings.push(
+ `- CRITICAL: Gateway bound to "${gatewayBind}" with NO authentication.`,
+ ` Anyone on your network (or internet if port-forwarded) can fully control your agent.`,
+ ` Fix: ${formatCliCommand("clawdbot config set gateway.bind loopback")}`,
+ ` Or enable auth: ${formatCliCommand("clawdbot config set gateway.auth.mode token")}`,
+ );
+ } else if (authMode === "token" && !authToken) {
+ warnings.push(
+ `- CRITICAL: Gateway bound to "${gatewayBind}" with empty auth token.`,
+ ` Fix: ${formatCliCommand("clawdbot doctor --fix")} to generate a token`,
+ );
+ } else if (authMode === "password" && !authPassword) {
+ warnings.push(
+ `- CRITICAL: Gateway bound to "${gatewayBind}" with empty password.`,
+ ` Fix: ${formatCliCommand("clawdbot configure")} to set a password`,
+ );
+ } else {
+ // Auth is configured, but still warn about network exposure
+ warnings.push(
+ `- WARNING: Gateway bound to "${gatewayBind}" (network-accessible).`,
+ ` Ensure your auth credentials are strong and not exposed.`,
+ );
+ }
+ }
+
const warnDmPolicy = async (params: {
label: string;
provider: ChannelId;
From 44bf454508322964c66f7c35f72fb935d8608617 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 20:02:28 -0600
Subject: [PATCH 012/196] Docs: update clawtributors
---
README.md | 55 ++++++++++++++++++++++++++++---------------------------
1 file changed, 28 insertions(+), 27 deletions(-)
diff --git a/README.md b/README.md
index ebbdc43d5..47f3a9090 100644
--- a/README.md
+++ b/README.md
@@ -479,31 +479,32 @@ Thanks to all clawtributors:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From 7ea4b06a046ad1bdf979941c605e0fbea81a664d Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 20:05:00 -0600
Subject: [PATCH 013/196] Deps: revert native-preview to published version
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 2a841139f..0c63d5d69 100644
--- a/package.json
+++ b/package.json
@@ -220,7 +220,7 @@
"@types/proper-lockfile": "^4.1.4",
"@types/qrcode-terminal": "^0.12.2",
"@types/ws": "^8.18.1",
- "@typescript/native-preview": "7.0.0-dev.20260125.1",
+ "@typescript/native-preview": "7.0.0-dev.20260124.1",
"@vitest/coverage-v8": "^4.0.18",
"docx-preview": "^0.3.7",
"lit": "^3.3.2",
From 138916a0d1a20e613dd2db98239877244c5ad1e9 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 20:11:21 -0600
Subject: [PATCH 014/196] Deps: sync memory-core lockfile spec
---
pnpm-lock.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 781a461a9..14bef9f5c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -357,7 +357,7 @@ importers:
extensions/memory-core:
dependencies:
clawdbot:
- specifier: '>=2026.1.24'
+ specifier: '>=2026.1.25'
version: link:../..
extensions/memory-lancedb:
From 9c26cded75615cdd2683981a21633b4b6fb799fa Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 20:22:10 -0600
Subject: [PATCH 015/196] Docs: add Vercel AI Gateway sidebar entry (#1901)
Co-authored-by: Jerilyn Zheng
---
CHANGELOG.md | 1 +
docs/docs.json | 1 +
docs/providers/vercel-ai-gateway.md | 1 +
3 files changed, 3 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cacc265a3..5e4a7005d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ Status: unreleased.
### Changes
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
+- Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng.
## 2026.1.24-3
diff --git a/docs/docs.json b/docs/docs.json
index 09b248990..4af7943e0 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -983,6 +983,7 @@
"bedrock",
"providers/moonshot",
"providers/minimax",
+ "providers/vercel-ai-gateway",
"providers/openrouter",
"providers/synthetic",
"providers/opencode",
diff --git a/docs/providers/vercel-ai-gateway.md b/docs/providers/vercel-ai-gateway.md
index bd31f0a87..36cf51cda 100644
--- a/docs/providers/vercel-ai-gateway.md
+++ b/docs/providers/vercel-ai-gateway.md
@@ -1,4 +1,5 @@
---
+title: "Vercel AI Gateway"
summary: "Vercel AI Gateway setup (auth + model selection)"
read_when:
- You want to use Vercel AI Gateway with Clawdbot
From c7fabb43f98e27c95fffc656dded87e7a9371355 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 20:23:40 -0600
Subject: [PATCH 016/196] Agents: expand cron tool description (#1988)
Co-authored-by: Tomas Cupr
---
CHANGELOG.md | 1 +
src/agents/tools/cron-tool.ts | 46 +++++++++++++++++++++++++++++++++--
2 files changed, 45 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5e4a7005d..44a2e6021 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ Status: unreleased.
### Changes
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
- Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng.
+- Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr.
## 2026.1.24-3
diff --git a/src/agents/tools/cron-tool.ts b/src/agents/tools/cron-tool.ts
index a1d218dd7..739b3ada3 100644
--- a/src/agents/tools/cron-tool.ts
+++ b/src/agents/tools/cron-tool.ts
@@ -133,8 +133,50 @@ export function createCronTool(opts?: CronToolOptions): AnyAgentTool {
return {
label: "Cron",
name: "cron",
- description:
- "Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events. Use `jobId` as the canonical identifier; `id` is accepted for compatibility. Use `contextMessages` (0-10) to add previous messages as context to the job text.",
+ description: `Manage Gateway cron jobs (status/list/add/update/remove/run/runs) and send wake events.
+
+ACTIONS:
+- status: Check cron scheduler status
+- list: List jobs (use includeDisabled:true to include disabled)
+- add: Create job (requires job object, see schema below)
+- update: Modify job (requires jobId + patch object)
+- remove: Delete job (requires jobId)
+- run: Trigger job immediately (requires jobId)
+- runs: Get job run history (requires jobId)
+- wake: Send wake event (requires text, optional mode)
+
+JOB SCHEMA (for add action):
+{
+ "name": "string (optional)",
+ "schedule": { ... }, // Required: when to run
+ "payload": { ... }, // Required: what to execute
+ "sessionTarget": "main" | "isolated", // Required
+ "enabled": true | false // Optional, default true
+}
+
+SCHEDULE TYPES (schedule.kind):
+- "at": One-shot at absolute time
+ { "kind": "at", "atMs": }
+- "every": Recurring interval
+ { "kind": "every", "everyMs": , "anchorMs": }
+- "cron": Cron expression
+ { "kind": "cron", "expr": "", "tz": "" }
+
+PAYLOAD TYPES (payload.kind):
+- "systemEvent": Injects text as system event into session
+ { "kind": "systemEvent", "text": "" }
+- "agentTurn": Runs agent with message (isolated sessions only)
+ { "kind": "agentTurn", "message": "", "model": "", "thinking": "", "timeoutSeconds": , "deliver": , "channel": "", "to": "", "bestEffortDeliver": }
+
+CRITICAL CONSTRAINTS:
+- sessionTarget="main" REQUIRES payload.kind="systemEvent"
+- sessionTarget="isolated" REQUIRES payload.kind="agentTurn"
+
+WAKE MODES (for wake action):
+- "next-heartbeat" (default): Wake on next heartbeat
+- "now": Wake immediately
+
+Use jobId as the canonical identifier; id is accepted for compatibility. Use contextMessages (0-10) to add previous messages as context to the job text.`,
parameters: CronToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record;
From a21671ed5b3f034aa89940a53e375d19b199b1de Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 20:25:08 -0600
Subject: [PATCH 017/196] Skills: add missing dependency metadata (#1995)
Co-authored-by: jackheuberger
---
CHANGELOG.md | 1 +
skills/discord/SKILL.md | 1 +
skills/github/SKILL.md | 1 +
skills/notion/SKILL.md | 2 +-
skills/slack/SKILL.md | 1 +
5 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 44a2e6021..425b21b1e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ Status: unreleased.
- Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz.
- Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng.
- Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr.
+- Skills: add missing dependency metadata for GitHub, Notion, Slack, Discord. (#1995) Thanks @jackheuberger.
## 2026.1.24-3
diff --git a/skills/discord/SKILL.md b/skills/discord/SKILL.md
index 0b64f14e1..5525a3bf5 100644
--- a/skills/discord/SKILL.md
+++ b/skills/discord/SKILL.md
@@ -1,6 +1,7 @@
---
name: discord
description: Use when you need to control Discord from Clawdbot via the discord tool: send messages, react, post or upload stickers, upload emojis, run polls, manage threads/pins/search, create/edit/delete channels and categories, fetch permissions or member/role/channel info, or handle moderation actions in Discord DMs or channels.
+metadata: {"clawdbot":{"emoji":"🎮","requires":{"config":["channels.discord"]}}}
---
# Discord Actions
diff --git a/skills/github/SKILL.md b/skills/github/SKILL.md
index 03b2a0033..e7c89f7ba 100644
--- a/skills/github/SKILL.md
+++ b/skills/github/SKILL.md
@@ -1,6 +1,7 @@
---
name: github
description: "Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries."
+metadata: {"clawdbot":{"emoji":"🐙","requires":{"bins":["gh"]},"install":[{"id":"brew","kind":"brew","formula":"gh","bins":["gh"],"label":"Install GitHub CLI (brew)"},{"id":"apt","kind":"apt","package":"gh","bins":["gh"],"label":"Install GitHub CLI (apt)"}]}}
---
# GitHub Skill
diff --git a/skills/notion/SKILL.md b/skills/notion/SKILL.md
index 869871b3c..04921e250 100644
--- a/skills/notion/SKILL.md
+++ b/skills/notion/SKILL.md
@@ -2,7 +2,7 @@
name: notion
description: Notion API for creating and managing pages, databases, and blocks.
homepage: https://developers.notion.com
-metadata: {"clawdbot":{"emoji":"📝"}}
+metadata: {"clawdbot":{"emoji":"📝","requires":{"env":["NOTION_API_KEY"]},"primaryEnv":"NOTION_API_KEY"}}
---
# notion
diff --git a/skills/slack/SKILL.md b/skills/slack/SKILL.md
index df04f858f..b72bab1f3 100644
--- a/skills/slack/SKILL.md
+++ b/skills/slack/SKILL.md
@@ -1,6 +1,7 @@
---
name: slack
description: Use when you need to control Slack from Clawdbot via the slack tool, including reacting to messages or pinning/unpinning items in Slack channels or DMs.
+metadata: {"clawdbot":{"emoji":"💬","requires":{"config":["channels.slack"]}}}
---
# Slack Actions
From 136f0d4d1d5028516f4824314a6db5ebd06871af Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 20:28:53 -0600
Subject: [PATCH 018/196] Docs: add Render deployment guide (#1975)
Co-authored-by: Anurag Goel
---
CHANGELOG.md | 1 +
docs/docs.json | 1 +
docs/render.mdx | 158 ++++++++++++++++++++++++++++++++++++++++++++++++
render.yaml | 21 +++++++
4 files changed, 181 insertions(+)
create mode 100644 docs/render.mdx
create mode 100644 render.yaml
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 425b21b1e..6abd9fc53 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ Status: unreleased.
- Docs: add Vercel AI Gateway to providers sidebar. (#1901) Thanks @jerilynzheng.
- Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr.
- Skills: add missing dependency metadata for GitHub, Notion, Slack, Discord. (#1995) Thanks @jackheuberger.
+- Docs: add Render deployment guide. (#1975) Thanks @anurag.
## 2026.1.24-3
diff --git a/docs/docs.json b/docs/docs.json
index 4af7943e0..983585bff 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -827,6 +827,7 @@
"install/nix",
"install/docker",
"railway",
+ "render",
"install/bun"
]
},
diff --git a/docs/render.mdx b/docs/render.mdx
new file mode 100644
index 000000000..3fcdae07a
--- /dev/null
+++ b/docs/render.mdx
@@ -0,0 +1,158 @@
+---
+title: Deploy on Render
+---
+
+Deploy Clawdbot on Render using Infrastructure as Code. The included `render.yaml` Blueprint defines your entire stack declaratively, service, disk, environment variables, so you can deploy with a single click and version your infrastructure alongside your code.
+
+## Prerequisites
+
+- A [Render account](https://render.com) (free tier available)
+- An API key from your preferred [model provider](/providers)
+
+## Deploy with a Render Blueprint
+
+Deploy to Render
+
+Clicking this link will:
+
+1. Create a new Render service from the `render.yaml` Blueprint at the root of this repo.
+2. Prompt you to set `SETUP_PASSWORD`
+3. Build the Docker image and deploy
+
+Once deployed, your service URL follows the pattern `https://.onrender.com`.
+
+## Understanding the Blueprint
+
+Render Blueprints are YAML files that define your infrastructure. The `render.yaml` in this
+repository configures everything needed to run Clawdbot:
+
+```yaml
+services:
+ - type: web
+ name: clawdbot
+ runtime: docker
+ plan: starter
+ healthCheckPath: /health
+ envVars:
+ - key: PORT
+ value: "8080"
+ - key: SETUP_PASSWORD
+ sync: false # prompts during deploy
+ - key: CLAWDBOT_STATE_DIR
+ value: /data/.clawdbot
+ - key: CLAWDBOT_WORKSPACE_DIR
+ value: /data/workspace
+ - key: CLAWDBOT_GATEWAY_TOKEN
+ generateValue: true # auto-generates a secure token
+ disk:
+ name: clawdbot-data
+ mountPath: /data
+ sizeGB: 1
+```
+
+Key Blueprint features used:
+
+| Feature | Purpose |
+|---------|---------|
+| `runtime: docker` | Builds from the repo's Dockerfile |
+| `healthCheckPath` | Render monitors `/health` and restarts unhealthy instances |
+| `sync: false` | Prompts for value during deploy (secrets) |
+| `generateValue: true` | Auto-generates a cryptographically secure value |
+| `disk` | Persistent storage that survives redeploys |
+
+## Choosing a plan
+
+| Plan | Spin-down | Disk | Best for |
+|------|-----------|------|----------|
+| Free | After 15 min idle | Not available | Testing, demos |
+| Starter | Never | 1GB+ | Personal use, small teams |
+| Standard+ | Never | 1GB+ | Production, multiple channels |
+
+The Blueprint defaults to `starter`. To use free tier, change `plan: free` in your fork's
+`render.yaml` (but note: no persistent disk means config resets on each deploy).
+
+## After deployment
+
+### Complete the setup wizard
+
+1. Navigate to `https://.onrender.com/setup`
+2. Enter your `SETUP_PASSWORD`
+3. Select a model provider and paste your API key
+4. Optionally configure messaging channels (Telegram, Discord, Slack)
+5. Click **Run setup**
+
+### Access the Control UI
+
+The web dashboard is available at `https://.onrender.com/clawdbot`.
+
+## Render Dashboard features
+
+### Logs
+
+View real-time logs in **Dashboard → your service → Logs**. Filter by:
+- Build logs (Docker image creation)
+- Deploy logs (service startup)
+- Runtime logs (application output)
+
+### Shell access
+
+For debugging, open a shell session via **Dashboard → your service → Shell**. The persistent disk is mounted at `/data`.
+
+### Environment variables
+
+Modify variables in **Dashboard → your service → Environment**. Changes trigger an automatic redeploy.
+
+### Auto-deploy
+
+If you use the original Clawdbot repository, Render will not auto-deploy your Clawdbot. To update it, run a manual Blueprint sync from the dashboard.
+
+## Custom domain
+
+1. Go to **Dashboard → your service → Settings → Custom Domains**
+2. Add your domain
+3. Configure DNS as instructed (CNAME to `*.onrender.com`)
+4. Render provisions a TLS certificate automatically
+
+## Scaling
+
+Render supports horizontal and vertical scaling:
+
+- **Vertical**: Change the plan to get more CPU/RAM
+- **Horizontal**: Increase instance count (Standard plan and above)
+
+For Clawdbot, vertical scaling is usually sufficient. Horizontal scaling requires sticky sessions or external state management.
+
+## Backups and migration
+
+Export your configuration and workspace at any time:
+
+```
+https://.onrender.com/setup/export
+```
+
+This downloads a portable backup you can restore on any Clawdbot host.
+
+## Troubleshooting
+
+### Service won't start
+
+Check the deploy logs in the Render Dashboard. Common issues:
+
+- Missing `SETUP_PASSWORD` — the Blueprint prompts for this, but verify it's set
+- Port mismatch — ensure `PORT=8080` matches the Dockerfile's exposed port
+
+### Slow cold starts (free tier)
+
+Free tier services spin down after 15 minutes of inactivity. The first request after spin-down takes a few seconds while the container starts. Upgrade to Starter plan for always-on.
+
+### Data loss after redeploy
+
+This happens on free tier (no persistent disk). Upgrade to a paid plan, or
+regularly export your config via `/setup/export`.
+
+### Health check failures
+
+Render expects a 200 response from `/health` within 30 seconds. If builds succeed but deploys fail, the service may be taking too long to start. Check:
+
+- Build logs for errors
+- Whether the container runs locally with `docker build && docker run`
diff --git a/render.yaml b/render.yaml
new file mode 100644
index 000000000..01923a8f6
--- /dev/null
+++ b/render.yaml
@@ -0,0 +1,21 @@
+services:
+ - type: web
+ name: clawdbot
+ runtime: docker
+ plan: starter
+ healthCheckPath: /health
+ envVars:
+ - key: PORT
+ value: "8080"
+ - key: SETUP_PASSWORD
+ sync: false
+ - key: CLAWDBOT_STATE_DIR
+ value: /data/.clawdbot
+ - key: CLAWDBOT_WORKSPACE_DIR
+ value: /data/workspace
+ - key: CLAWDBOT_GATEWAY_TOKEN
+ generateValue: true
+ disk:
+ name: clawdbot-data
+ mountPath: /data
+ sizeGB: 1
From 6b6284c69cda6193bc0de5d178ed0e8e0ea251e2 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 20:37:20 -0600
Subject: [PATCH 019/196] CI: add PR labeler + label sync
---
.github/labeler.yml | 150 ++++++++++++++++++++++++++++
.github/workflows/auto-response.yml | 59 +++++++++++
.github/workflows/labeler.yml | 17 ++++
scripts/sync-labels.ts | 91 +++++++++++++++++
4 files changed, 317 insertions(+)
create mode 100644 .github/labeler.yml
create mode 100644 .github/workflows/auto-response.yml
create mode 100644 .github/workflows/labeler.yml
create mode 100644 scripts/sync-labels.ts
diff --git a/.github/labeler.yml b/.github/labeler.yml
new file mode 100644
index 000000000..0f3344acc
--- /dev/null
+++ b/.github/labeler.yml
@@ -0,0 +1,150 @@
+"channel: bluebubbles":
+ - "extensions/bluebubbles/**"
+ - "docs/channels/bluebubbles.md"
+"channel: discord":
+ - "src/discord/**"
+ - "extensions/discord/**"
+ - "docs/channels/discord.md"
+"channel: googlechat":
+ - "extensions/googlechat/**"
+ - "docs/channels/googlechat.md"
+"channel: imessage":
+ - "src/imessage/**"
+ - "extensions/imessage/**"
+ - "docs/channels/imessage.md"
+"channel: line":
+ - "extensions/line/**"
+"channel: matrix":
+ - "extensions/matrix/**"
+ - "docs/channels/matrix.md"
+"channel: mattermost":
+ - "extensions/mattermost/**"
+ - "docs/channels/mattermost.md"
+"channel: msteams":
+ - "extensions/msteams/**"
+ - "docs/channels/msteams.md"
+"channel: nextcloud-talk":
+ - "extensions/nextcloud-talk/**"
+ - "docs/channels/nextcloud-talk.md"
+"channel: nostr":
+ - "extensions/nostr/**"
+ - "docs/channels/nostr.md"
+"channel: signal":
+ - "src/signal/**"
+ - "extensions/signal/**"
+ - "docs/channels/signal.md"
+"channel: slack":
+ - "src/slack/**"
+ - "extensions/slack/**"
+ - "docs/channels/slack.md"
+"channel: telegram":
+ - "src/telegram/**"
+ - "extensions/telegram/**"
+ - "docs/channels/telegram.md"
+"channel: tlon":
+ - "extensions/tlon/**"
+ - "docs/channels/tlon.md"
+"channel: voice-call":
+ - "extensions/voice-call/**"
+"channel: whatsapp-web":
+ - "src/web/**"
+ - "extensions/whatsapp/**"
+ - "docs/channels/whatsapp.md"
+"channel: zalo":
+ - "extensions/zalo/**"
+ - "docs/channels/zalo.md"
+"channel: zalouser":
+ - "extensions/zalouser/**"
+ - "docs/channels/zalouser.md"
+
+"app: android":
+ - "apps/android/**"
+ - "docs/platforms/android.md"
+"app: ios":
+ - "apps/ios/**"
+ - "docs/platforms/ios.md"
+"app: macos":
+ - "apps/macos/**"
+ - "docs/platforms/macos.md"
+ - "docs/platforms/mac/**"
+"app: web-ui":
+ - "ui/**"
+ - "src/gateway/control-ui.ts"
+ - "src/gateway/control-ui-shared.ts"
+ - "src/infra/control-ui-assets.ts"
+
+"cli":
+ - "src/cli/**"
+ - "src/commands/**"
+ - "src/tui/**"
+
+"gateway":
+ - "src/gateway/**"
+ - "src/daemon/**"
+ - "docs/gateway/**"
+
+"docs":
+ - "docs/**"
+ - "docs.acp.md"
+ - "README.md"
+ - "README-header.png"
+ - "CHANGELOG.md"
+ - "CONTRIBUTING.md"
+ - "SECURITY.md"
+
+"extensions: bluebubbles":
+ - "extensions/bluebubbles/**"
+"extensions: copilot-proxy":
+ - "extensions/copilot-proxy/**"
+"extensions: diagnostics-otel":
+ - "extensions/diagnostics-otel/**"
+"extensions: discord":
+ - "extensions/discord/**"
+"extensions: google-antigravity-auth":
+ - "extensions/google-antigravity-auth/**"
+"extensions: google-gemini-cli-auth":
+ - "extensions/google-gemini-cli-auth/**"
+"extensions: googlechat":
+ - "extensions/googlechat/**"
+"extensions: imessage":
+ - "extensions/imessage/**"
+"extensions: line":
+ - "extensions/line/**"
+"extensions: llm-task":
+ - "extensions/llm-task/**"
+"extensions: lobster":
+ - "extensions/lobster/**"
+"extensions: matrix":
+ - "extensions/matrix/**"
+"extensions: mattermost":
+ - "extensions/mattermost/**"
+"extensions: memory-core":
+ - "extensions/memory-core/**"
+"extensions: memory-lancedb":
+ - "extensions/memory-lancedb/**"
+"extensions: msteams":
+ - "extensions/msteams/**"
+"extensions: nextcloud-talk":
+ - "extensions/nextcloud-talk/**"
+"extensions: nostr":
+ - "extensions/nostr/**"
+"extensions: open-prose":
+ - "extensions/open-prose/**"
+"extensions: qwen-portal-auth":
+ - "extensions/qwen-portal-auth/**"
+"extensions: signal":
+ - "extensions/signal/**"
+"extensions: slack":
+ - "extensions/slack/**"
+"extensions: telegram":
+ - "extensions/telegram/**"
+"extensions: tlon":
+ - "extensions/tlon/**"
+"extensions: voice-call":
+ - "extensions/voice-call/**"
+"extensions: whatsapp":
+ - "extensions/whatsapp/**"
+"extensions: zalo":
+ - "extensions/zalo/**"
+"extensions: zalouser":
+ - "extensions/zalouser/**"
diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml
new file mode 100644
index 000000000..7f242a094
--- /dev/null
+++ b/.github/workflows/auto-response.yml
@@ -0,0 +1,59 @@
+name: Auto response
+
+on:
+ issues:
+ types: [labeled]
+ pull_request:
+ types: [labeled]
+
+permissions:
+ issues: write
+ pull-requests: write
+
+jobs:
+ auto-response:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Handle labeled items
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const rules = [
+ {
+ label: "skill-clawdhub",
+ close: true,
+ message:
+ "Thanks for the contribution! New skills should be published to Clawdhub for everyone to use. We’re keeping the core lean on skills, so I’m closing this out.",
+ },
+ ];
+
+ const labelName = context.payload.label?.name;
+ if (!labelName) {
+ return;
+ }
+
+ const rule = rules.find((item) => item.label === labelName);
+ if (!rule) {
+ return;
+ }
+
+ const issueNumber = context.payload.issue?.number ?? context.payload.pull_request?.number;
+ if (!issueNumber) {
+ return;
+ }
+
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issueNumber,
+ body: rule.message,
+ });
+
+ if (rule.close) {
+ await github.rest.issues.update({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: issueNumber,
+ state: "closed",
+ });
+ }
diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml
new file mode 100644
index 000000000..6ec73a1a3
--- /dev/null
+++ b/.github/workflows/labeler.yml
@@ -0,0 +1,17 @@
+name: Labeler
+
+on:
+ pull_request_target:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: read
+ pull-requests: write
+
+jobs:
+ label:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/labeler@v5
+ with:
+ configuration-path: .github/labeler.yml
diff --git a/scripts/sync-labels.ts b/scripts/sync-labels.ts
new file mode 100644
index 000000000..0220e911a
--- /dev/null
+++ b/scripts/sync-labels.ts
@@ -0,0 +1,91 @@
+import { execFileSync } from "node:child_process";
+import { readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import yaml from "yaml";
+
+type LabelConfig = Record;
+
+type RepoLabel = {
+ name: string;
+ color?: string;
+};
+
+const COLOR_BY_PREFIX = new Map([
+ ["channel", "1d76db"],
+ ["app", "6f42c1"],
+ ["extensions", "0e8a16"],
+ ["docs", "0075ca"],
+ ["cli", "f9d0c4"],
+ ["gateway", "d4c5f9"],
+]);
+
+const configPath = resolve(".github/labeler.yml");
+const config = yaml.parse(readFileSync(configPath, "utf8")) as LabelConfig;
+
+if (!config || typeof config !== "object") {
+ throw new Error("labeler.yml must be a mapping of label names to globs.");
+}
+
+const labelNames = Object.keys(config).filter(Boolean);
+const repo = resolveRepo();
+const existing = fetchExistingLabels(repo);
+
+const missing = labelNames.filter((label) => !existing.has(label));
+if (!missing.length) {
+ console.log("All labeler labels already exist.");
+ process.exit(0);
+}
+
+for (const label of missing) {
+ const color = pickColor(label);
+ execFileSync(
+ "gh",
+ [
+ "api",
+ "-X",
+ "POST",
+ `repos/${repo}/labels`,
+ "-f",
+ `name=${label}`,
+ "-f",
+ `color=${color}`,
+ ],
+ { stdio: "inherit" },
+ );
+ console.log(`Created label: ${label}`);
+}
+
+function pickColor(label: string): string {
+ const prefix = label.includes(":") ? label.split(":", 1)[0].trim() : label.trim();
+ return COLOR_BY_PREFIX.get(prefix) ?? "ededed";
+}
+
+function resolveRepo(): string {
+ const remote = execFileSync("git", ["config", "--get", "remote.origin.url"], {
+ encoding: "utf8",
+ }).trim();
+
+ if (!remote) {
+ throw new Error("Unable to determine repository from git remote.");
+ }
+
+ if (remote.startsWith("git@github.com:")) {
+ return remote.replace("git@github.com:", "").replace(/\.git$/, "");
+ }
+
+ if (remote.startsWith("https://github.com/")) {
+ return remote.replace("https://github.com/", "").replace(/\.git$/, "");
+ }
+
+ throw new Error(`Unsupported GitHub remote: ${remote}`);
+}
+
+function fetchExistingLabels(repo: string): Map {
+ const raw = execFileSync(
+ "gh",
+ ["api", `repos/${repo}/labels?per_page=100`, "--paginate"],
+ { encoding: "utf8" },
+ );
+ const labels = JSON.parse(raw) as RepoLabel[];
+ return new Map(labels.map((label) => [label.name, label]));
+}
From b25fcaef0f14293886f020231e169be51bb3da45 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 20:38:44 -0600
Subject: [PATCH 020/196] CI: parse labeler without deps
---
scripts/sync-labels.ts | 30 +++++++++++++++++++++++-------
1 file changed, 23 insertions(+), 7 deletions(-)
diff --git a/scripts/sync-labels.ts b/scripts/sync-labels.ts
index 0220e911a..297644c1e 100644
--- a/scripts/sync-labels.ts
+++ b/scripts/sync-labels.ts
@@ -1,9 +1,6 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
-import yaml from "yaml";
-
-type LabelConfig = Record;
type RepoLabel = {
name: string;
@@ -20,13 +17,12 @@ const COLOR_BY_PREFIX = new Map([
]);
const configPath = resolve(".github/labeler.yml");
-const config = yaml.parse(readFileSync(configPath, "utf8")) as LabelConfig;
+const labelNames = extractLabelNames(readFileSync(configPath, "utf8"));
-if (!config || typeof config !== "object") {
- throw new Error("labeler.yml must be a mapping of label names to globs.");
+if (!labelNames.length) {
+ throw new Error("labeler.yml must declare at least one label.");
}
-const labelNames = Object.keys(config).filter(Boolean);
const repo = resolveRepo();
const existing = fetchExistingLabels(repo);
@@ -55,6 +51,26 @@ for (const label of missing) {
console.log(`Created label: ${label}`);
}
+function extractLabelNames(contents: string): string[] {
+ const labels: string[] = [];
+ for (const line of contents.split("\n")) {
+ if (!line.trim() || line.trimStart().startsWith("#")) {
+ continue;
+ }
+ if (/^\s/.test(line)) {
+ continue;
+ }
+ const match = line.match(/^(["'])(.+)\1\s*:/) ?? line.match(/^([^:]+):/);
+ if (match) {
+ const name = (match[2] ?? match[1] ?? "").trim();
+ if (name) {
+ labels.push(name);
+ }
+ }
+ }
+ return labels;
+}
+
function pickColor(label: string): string {
const prefix = label.includes(":") ? label.split(":", 1)[0].trim() : label.trim();
return COLOR_BY_PREFIX.get(prefix) ?? "ededed";
From 28fe95ac5ef56c50bb5c7a8c47307fb83060ba71 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 20:39:44 -0600
Subject: [PATCH 021/196] Docs: note labeler updates
---
.github/labeler.yml | 41 -----------------------------------------
AGENTS.md | 1 +
2 files changed, 1 insertion(+), 41 deletions(-)
diff --git a/.github/labeler.yml b/.github/labeler.yml
index 0f3344acc..0c3d863cf 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -86,65 +86,24 @@
"docs":
- "docs/**"
- "docs.acp.md"
- - "README.md"
- - "README-header.png"
- - "CHANGELOG.md"
- - "CONTRIBUTING.md"
- - "SECURITY.md"
-"extensions: bluebubbles":
- - "extensions/bluebubbles/**"
"extensions: copilot-proxy":
- "extensions/copilot-proxy/**"
"extensions: diagnostics-otel":
- "extensions/diagnostics-otel/**"
-"extensions: discord":
- - "extensions/discord/**"
"extensions: google-antigravity-auth":
- "extensions/google-antigravity-auth/**"
"extensions: google-gemini-cli-auth":
- "extensions/google-gemini-cli-auth/**"
-"extensions: googlechat":
- - "extensions/googlechat/**"
-"extensions: imessage":
- - "extensions/imessage/**"
-"extensions: line":
- - "extensions/line/**"
"extensions: llm-task":
- "extensions/llm-task/**"
"extensions: lobster":
- "extensions/lobster/**"
-"extensions: matrix":
- - "extensions/matrix/**"
-"extensions: mattermost":
- - "extensions/mattermost/**"
"extensions: memory-core":
- "extensions/memory-core/**"
"extensions: memory-lancedb":
- "extensions/memory-lancedb/**"
-"extensions: msteams":
- - "extensions/msteams/**"
-"extensions: nextcloud-talk":
- - "extensions/nextcloud-talk/**"
-"extensions: nostr":
- - "extensions/nostr/**"
"extensions: open-prose":
- "extensions/open-prose/**"
"extensions: qwen-portal-auth":
- "extensions/qwen-portal-auth/**"
-"extensions: signal":
- - "extensions/signal/**"
-"extensions: slack":
- - "extensions/slack/**"
-"extensions: telegram":
- - "extensions/telegram/**"
-"extensions: tlon":
- - "extensions/tlon/**"
-"extensions: voice-call":
- - "extensions/voice-call/**"
-"extensions: whatsapp":
- - "extensions/whatsapp/**"
-"extensions: zalo":
- - "extensions/zalo/**"
-"extensions: zalouser":
- - "extensions/zalouser/**"
diff --git a/AGENTS.md b/AGENTS.md
index deed6d9bd..ac85a00d8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,6 +13,7 @@
- Core channel docs: `docs/channels/`
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
- Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`)
+- When adding channels/extensions/apps/docs, review `.github/labeler.yml` for label coverage.
## Docs Linking (Mintlify)
- Docs are hosted on Mintlify (docs.clawd.bot).
From 9c8e8c5c2d531e58cfe7fe0714a4530fa10c8016 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 20:45:42 -0600
Subject: [PATCH 022/196] CI: increase Node heap size for macOS checks (#1890)
Co-authored-by: Zach Knickerbocker
---
.github/workflows/ci.yml | 2 ++
CHANGELOG.md | 1 +
2 files changed, 3 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fcd8e457c..8cc86bd63 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -342,6 +342,8 @@ jobs:
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
- name: Run ${{ matrix.task }}
+ env:
+ NODE_OPTIONS: --max-old-space-size=4096
run: ${{ matrix.command }}
macos-app:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6abd9fc53..93b171b38 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ Status: unreleased.
- Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr.
- Skills: add missing dependency metadata for GitHub, Notion, Slack, Discord. (#1995) Thanks @jackheuberger.
- Docs: add Render deployment guide. (#1975) Thanks @anurag.
+- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi.
## 2026.1.24-3
From 159f6bfddd6c9e596856fdac65b775c67ed5c364 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 21:02:18 -0600
Subject: [PATCH 023/196] macOS: bump Textual to 0.3.1 (#2033)
Co-authored-by: Garric G. Nahapetian
---
CHANGELOG.md | 1 +
apps/macos/Package.resolved | 4 ++--
apps/shared/ClawdbotKit/Package.swift | 2 +-
3 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 93b171b38..19cea8844 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ Status: unreleased.
- Skills: add missing dependency metadata for GitHub, Notion, Slack, Discord. (#1995) Thanks @jackheuberger.
- Docs: add Render deployment guide. (#1975) Thanks @anurag.
- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi.
+- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn.
## 2026.1.24-3
diff --git a/apps/macos/Package.resolved b/apps/macos/Package.resolved
index ffc524d1c..ef9609649 100644
--- a/apps/macos/Package.resolved
+++ b/apps/macos/Package.resolved
@@ -123,8 +123,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/gonzalezreal/textual",
"state" : {
- "revision" : "a03c1e103d88de4ea0dd8320ea1611ec0d4b29b3",
- "version" : "0.2.0"
+ "revision" : "5b06b811c0f5313b6b84bbef98c635a630638c38",
+ "version" : "0.3.1"
}
}
],
diff --git a/apps/shared/ClawdbotKit/Package.swift b/apps/shared/ClawdbotKit/Package.swift
index 076842fce..88dc28b5c 100644
--- a/apps/shared/ClawdbotKit/Package.swift
+++ b/apps/shared/ClawdbotKit/Package.swift
@@ -15,7 +15,7 @@ let package = Package(
],
dependencies: [
.package(url: "https://github.com/steipete/ElevenLabsKit", exact: "0.1.0"),
- .package(url: "https://github.com/gonzalezreal/textual", exact: "0.2.0"),
+ .package(url: "https://github.com/gonzalezreal/textual", exact: "0.3.1"),
],
targets: [
.target(
From 5d2ef89e0367b2301e2a5125e7e644277a803fa7 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 21:04:41 -0600
Subject: [PATCH 024/196] Browser: add URL fallback for relay tab matching
(#1999)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: João Paulo Furtado
---
CHANGELOG.md | 1 +
src/browser/pw-session.ts | 52 ++++++++++++++++++++++++++++++++++++---
2 files changed, 49 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 19cea8844..23d5d51b3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ Status: unreleased.
- Docs: add Render deployment guide. (#1975) Thanks @anurag.
- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi.
- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn.
+- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.
## 2026.1.24-3
diff --git a/src/browser/pw-session.ts b/src/browser/pw-session.ts
index 0c7fa9f48..e1dbcf7a1 100644
--- a/src/browser/pw-session.ts
+++ b/src/browser/pw-session.ts
@@ -337,12 +337,56 @@ async function pageTargetId(page: Page): Promise {
}
}
-async function findPageByTargetId(browser: Browser, targetId: string): Promise {
+async function findPageByTargetId(
+ browser: Browser,
+ targetId: string,
+ cdpUrl?: string,
+): Promise {
const pages = await getAllPages(browser);
+ // First, try the standard CDP session approach
for (const page of pages) {
const tid = await pageTargetId(page).catch(() => null);
if (tid && tid === targetId) return page;
}
+ // If CDP sessions fail (e.g., extension relay blocks Target.attachToBrowserTarget),
+ // fall back to URL-based matching using the /json/list endpoint
+ if (cdpUrl) {
+ try {
+ const baseUrl = cdpUrl
+ .replace(/\/+$/, "")
+ .replace(/^ws:/, "http:")
+ .replace(/\/cdp$/, "");
+ const response = await fetch(`${baseUrl}/json/list`);
+ if (response.ok) {
+ const targets = (await response.json()) as Array<{
+ id: string;
+ url: string;
+ title?: string;
+ }>;
+ const target = targets.find((t) => t.id === targetId);
+ if (target) {
+ // Try to find a page with matching URL
+ const urlMatch = pages.filter((p) => p.url() === target.url);
+ if (urlMatch.length === 1) {
+ return urlMatch[0];
+ }
+ // If multiple URL matches, use index-based matching as fallback
+ // This works when Playwright and the relay enumerate tabs in the same order
+ if (urlMatch.length > 1) {
+ const sameUrlTargets = targets.filter((t) => t.url === target.url);
+ if (sameUrlTargets.length === urlMatch.length) {
+ const idx = sameUrlTargets.findIndex((t) => t.id === targetId);
+ if (idx >= 0 && idx < urlMatch.length) {
+ return urlMatch[idx];
+ }
+ }
+ }
+ }
+ }
+ } catch {
+ // Ignore fetch errors and fall through to return null
+ }
+ }
return null;
}
@@ -355,7 +399,7 @@ export async function getPageForTargetId(opts: {
if (!pages.length) throw new Error("No pages available in the connected browser.");
const first = pages[0];
if (!opts.targetId) return first;
- const found = await findPageByTargetId(browser, opts.targetId);
+ const found = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!found) {
// Extension relays can block CDP attachment APIs (e.g. Target.attachToBrowserTarget),
// which prevents us from resolving a page's targetId via newCDPSession(). If Playwright
@@ -496,7 +540,7 @@ export async function closePageByTargetIdViaPlaywright(opts: {
targetId: string;
}): Promise {
const { browser } = await connectBrowser(opts.cdpUrl);
- const page = await findPageByTargetId(browser, opts.targetId);
+ const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!page) {
throw new Error("tab not found");
}
@@ -512,7 +556,7 @@ export async function focusPageByTargetIdViaPlaywright(opts: {
targetId: string;
}): Promise {
const { browser } = await connectBrowser(opts.cdpUrl);
- const page = await findPageByTargetId(browser, opts.targetId);
+ const page = await findPageByTargetId(browser, opts.targetId, opts.cdpUrl);
if (!page) {
throw new Error("tab not found");
}
From 6d60c325700e26ad0876be74ceb29d1b0e3a4648 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 21:07:51 -0600
Subject: [PATCH 025/196] Update: ignore dist/control-ui in dirty check (#1976)
Co-authored-by: Glucksberg
---
CHANGELOG.md | 1 +
src/infra/update-check.ts | 7 ++++---
src/infra/update-runner.test.ts | 7 ++++---
src/infra/update-runner.ts | 19 +++++++++++++++++--
4 files changed, 26 insertions(+), 8 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 23d5d51b3..a1e2a9d08 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ Status: unreleased.
- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi.
- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn.
- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.
+- Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg.
## 2026.1.24-3
diff --git a/src/infra/update-check.ts b/src/infra/update-check.ts
index 2e020ff8d..518da3c28 100644
--- a/src/infra/update-check.ts
+++ b/src/infra/update-check.ts
@@ -129,9 +129,10 @@ export async function checkGitUpdateStatus(params: {
).catch(() => null);
const upstream = upstreamRes && upstreamRes.code === 0 ? upstreamRes.stdout.trim() : null;
- const dirtyRes = await runCommandWithTimeout(["git", "-C", root, "status", "--porcelain"], {
- timeoutMs,
- }).catch(() => null);
+ const dirtyRes = await runCommandWithTimeout(
+ ["git", "-C", root, "status", "--porcelain", "--", ":!dist/control-ui/"],
+ { timeoutMs },
+ ).catch(() => null);
const dirty = dirtyRes && dirtyRes.code === 0 ? dirtyRes.stdout.trim().length > 0 : null;
const fetchOk = params.fetch
diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts
index e33159326..6bf450d83 100644
--- a/src/infra/update-runner.test.ts
+++ b/src/infra/update-runner.test.ts
@@ -44,7 +44,7 @@ describe("runGatewayUpdate", () => {
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
[`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" },
- [`git -C ${tempDir} status --porcelain`]: { stdout: " M README.md" },
+ [`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: " M README.md" },
});
const result = await runGatewayUpdate({
@@ -69,7 +69,7 @@ describe("runGatewayUpdate", () => {
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
[`git -C ${tempDir} rev-parse --abbrev-ref HEAD`]: { stdout: "main" },
- [`git -C ${tempDir} status --porcelain`]: { stdout: "" },
+ [`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: "" },
[`git -C ${tempDir} rev-parse --abbrev-ref --symbolic-full-name @{upstream}`]: {
stdout: "origin/main",
},
@@ -103,7 +103,7 @@ describe("runGatewayUpdate", () => {
const { runner, calls } = createRunner({
[`git -C ${tempDir} rev-parse --show-toplevel`]: { stdout: tempDir },
[`git -C ${tempDir} rev-parse HEAD`]: { stdout: "abc123" },
- [`git -C ${tempDir} status --porcelain`]: { stdout: "" },
+ [`git -C ${tempDir} status --porcelain -- :!dist/control-ui/`]: { stdout: "" },
[`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" },
[`git -C ${tempDir} tag --list v* --sort=-v:refname`]: {
stdout: `${stableTag}\n${betaTag}\n`,
@@ -112,6 +112,7 @@ describe("runGatewayUpdate", () => {
"pnpm install": { stdout: "" },
"pnpm build": { stdout: "" },
"pnpm ui:build": { stdout: "" },
+ [`git -C ${tempDir} checkout -- dist/control-ui/`]: { stdout: "" },
"pnpm clawdbot doctor --non-interactive": { stdout: "" },
});
diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts
index 0a5196fd7..c73c3a7e7 100644
--- a/src/infra/update-runner.ts
+++ b/src/infra/update-runner.ts
@@ -346,10 +346,14 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
const channel: UpdateChannel = opts.channel ?? "dev";
const branch = channel === "dev" ? await readBranchName(runCommand, gitRoot, timeoutMs) : null;
const needsCheckoutMain = channel === "dev" && branch !== DEV_BRANCH;
- gitTotalSteps = channel === "dev" ? (needsCheckoutMain ? 10 : 9) : 8;
+ gitTotalSteps = channel === "dev" ? (needsCheckoutMain ? 11 : 10) : 9;
const statusCheck = await runStep(
- step("clean check", ["git", "-C", gitRoot, "status", "--porcelain"], gitRoot),
+ step(
+ "clean check",
+ ["git", "-C", gitRoot, "status", "--porcelain", "--", ":!dist/control-ui/"],
+ gitRoot,
+ ),
);
steps.push(statusCheck);
const hasUncommittedChanges =
@@ -654,6 +658,17 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
);
steps.push(uiBuildStep);
+ // Restore dist/control-ui/ to committed state to prevent dirty repo after update
+ // (ui:build regenerates assets with new hashes, which would block future updates)
+ const restoreUiStep = await runStep(
+ step(
+ "restore control-ui",
+ ["git", "-C", gitRoot, "checkout", "--", "dist/control-ui/"],
+ gitRoot,
+ ),
+ );
+ steps.push(restoreUiStep);
+
const doctorStep = await runStep(
step(
"clawdbot doctor",
From a989fe8af92e5630f6b0f51e4156a0a21a47c346 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 21:08:12 -0600
Subject: [PATCH 026/196] CI: update labeler v5 config
---
.github/labeler.yml | 205 +++++++++++++++++++++++++++++---------------
1 file changed, 134 insertions(+), 71 deletions(-)
diff --git a/.github/labeler.yml b/.github/labeler.yml
index 0c3d863cf..5b34c41e0 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -1,109 +1,172 @@
"channel: bluebubbles":
- - "extensions/bluebubbles/**"
- - "docs/channels/bluebubbles.md"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/bluebubbles/**"
+ - "docs/channels/bluebubbles.md"
"channel: discord":
- - "src/discord/**"
- - "extensions/discord/**"
- - "docs/channels/discord.md"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "src/discord/**"
+ - "extensions/discord/**"
+ - "docs/channels/discord.md"
"channel: googlechat":
- - "extensions/googlechat/**"
- - "docs/channels/googlechat.md"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/googlechat/**"
+ - "docs/channels/googlechat.md"
"channel: imessage":
- - "src/imessage/**"
- - "extensions/imessage/**"
- - "docs/channels/imessage.md"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "src/imessage/**"
+ - "extensions/imessage/**"
+ - "docs/channels/imessage.md"
"channel: line":
- - "extensions/line/**"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/line/**"
"channel: matrix":
- - "extensions/matrix/**"
- - "docs/channels/matrix.md"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/matrix/**"
+ - "docs/channels/matrix.md"
"channel: mattermost":
- - "extensions/mattermost/**"
- - "docs/channels/mattermost.md"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/mattermost/**"
+ - "docs/channels/mattermost.md"
"channel: msteams":
- - "extensions/msteams/**"
- - "docs/channels/msteams.md"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/msteams/**"
+ - "docs/channels/msteams.md"
"channel: nextcloud-talk":
- - "extensions/nextcloud-talk/**"
- - "docs/channels/nextcloud-talk.md"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/nextcloud-talk/**"
+ - "docs/channels/nextcloud-talk.md"
"channel: nostr":
- - "extensions/nostr/**"
- - "docs/channels/nostr.md"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/nostr/**"
+ - "docs/channels/nostr.md"
"channel: signal":
- - "src/signal/**"
- - "extensions/signal/**"
- - "docs/channels/signal.md"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "src/signal/**"
+ - "extensions/signal/**"
+ - "docs/channels/signal.md"
"channel: slack":
- - "src/slack/**"
- - "extensions/slack/**"
- - "docs/channels/slack.md"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "src/slack/**"
+ - "extensions/slack/**"
+ - "docs/channels/slack.md"
"channel: telegram":
- - "src/telegram/**"
- - "extensions/telegram/**"
- - "docs/channels/telegram.md"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "src/telegram/**"
+ - "extensions/telegram/**"
+ - "docs/channels/telegram.md"
"channel: tlon":
- - "extensions/tlon/**"
- - "docs/channels/tlon.md"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/tlon/**"
+ - "docs/channels/tlon.md"
"channel: voice-call":
- - "extensions/voice-call/**"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/voice-call/**"
"channel: whatsapp-web":
- - "src/web/**"
- - "extensions/whatsapp/**"
- - "docs/channels/whatsapp.md"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "src/web/**"
+ - "extensions/whatsapp/**"
+ - "docs/channels/whatsapp.md"
"channel: zalo":
- - "extensions/zalo/**"
- - "docs/channels/zalo.md"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/zalo/**"
+ - "docs/channels/zalo.md"
"channel: zalouser":
- - "extensions/zalouser/**"
- - "docs/channels/zalouser.md"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/zalouser/**"
+ - "docs/channels/zalouser.md"
"app: android":
- - "apps/android/**"
- - "docs/platforms/android.md"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "apps/android/**"
+ - "docs/platforms/android.md"
"app: ios":
- - "apps/ios/**"
- - "docs/platforms/ios.md"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "apps/ios/**"
+ - "docs/platforms/ios.md"
"app: macos":
- - "apps/macos/**"
- - "docs/platforms/macos.md"
- - "docs/platforms/mac/**"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "apps/macos/**"
+ - "docs/platforms/macos.md"
+ - "docs/platforms/mac/**"
"app: web-ui":
- - "ui/**"
- - "src/gateway/control-ui.ts"
- - "src/gateway/control-ui-shared.ts"
- - "src/infra/control-ui-assets.ts"
-
-"cli":
- - "src/cli/**"
- - "src/commands/**"
- - "src/tui/**"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "ui/**"
+ - "src/gateway/control-ui.ts"
+ - "src/gateway/control-ui-shared.ts"
+ - "src/infra/control-ui-assets.ts"
"gateway":
- - "src/gateway/**"
- - "src/daemon/**"
- - "docs/gateway/**"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "src/gateway/**"
+ - "src/daemon/**"
+ - "docs/gateway/**"
"docs":
- - "docs/**"
- - "docs.acp.md"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "docs/**"
+ - "docs.acp.md"
"extensions: copilot-proxy":
- - "extensions/copilot-proxy/**"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/copilot-proxy/**"
"extensions: diagnostics-otel":
- - "extensions/diagnostics-otel/**"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/diagnostics-otel/**"
"extensions: google-antigravity-auth":
- - "extensions/google-antigravity-auth/**"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/google-antigravity-auth/**"
"extensions: google-gemini-cli-auth":
- - "extensions/google-gemini-cli-auth/**"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/google-gemini-cli-auth/**"
"extensions: llm-task":
- - "extensions/llm-task/**"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/llm-task/**"
"extensions: lobster":
- - "extensions/lobster/**"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/lobster/**"
"extensions: memory-core":
- - "extensions/memory-core/**"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/memory-core/**"
"extensions: memory-lancedb":
- - "extensions/memory-lancedb/**"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/memory-lancedb/**"
"extensions: open-prose":
- - "extensions/open-prose/**"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/open-prose/**"
"extensions: qwen-portal-auth":
- - "extensions/qwen-portal-auth/**"
+ - changed-files:
+ - any-glob-to-any-file:
+ - "extensions/qwen-portal-auth/**"
From 47101da4643ab499831a8b0377422d13f46093da Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 21:09:31 -0600
Subject: [PATCH 027/196] Telegram: honor caption param for media sends (#1888)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Marc Güell Segarra
---
CHANGELOG.md | 1 +
src/channels/plugins/actions/telegram.ts | 8 +++-----
2 files changed, 4 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a1e2a9d08..7bb0a459d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@ Status: unreleased.
- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn.
- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.
- Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg.
+- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
## 2026.1.24-3
diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts
index 18a11c797..fe4e41307 100644
--- a/src/channels/plugins/actions/telegram.ts
+++ b/src/channels/plugins/actions/telegram.ts
@@ -13,11 +13,9 @@ const providerId = "telegram";
function readTelegramSendParams(params: Record) {
const to = readStringParam(params, "to", { required: true });
const mediaUrl = readStringParam(params, "media", { trim: false });
- const content =
- readStringParam(params, "message", {
- required: !mediaUrl,
- allowEmpty: true,
- }) ?? "";
+ const message = readStringParam(params, "message", { required: !mediaUrl, allowEmpty: true });
+ const caption = readStringParam(params, "caption", { allowEmpty: true });
+ const content = message || caption || "";
const replyTo = readStringParam(params, "replyTo");
const threadId = readStringParam(params, "threadId");
const buttons = params.buttons;
From 84f8f8b10e540d2c89c1c475bdec3c3c94c6d592 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 21:11:50 -0600
Subject: [PATCH 028/196] Telegram: skip block replies when streaming off
(#1885)
Co-authored-by: Ivan Casco
---
CHANGELOG.md | 1 +
src/auto-reply/reply/agent-runner-execution.ts | 5 +++--
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7bb0a459d..af7ae9ddc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,7 @@ Status: unreleased.
- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.
- Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg.
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
+- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
## 2026.1.24-3
diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts
index 47c45b09d..939fa92f0 100644
--- a/src/auto-reply/reply/agent-runner-execution.ts
+++ b/src/auto-reply/reply/agent-runner-execution.ts
@@ -369,12 +369,13 @@ export async function runAgentTurnWithFallback(params: {
// Use pipeline if available (block streaming enabled), otherwise send directly
if (params.blockStreamingEnabled && params.blockReplyPipeline) {
params.blockReplyPipeline.enqueue(blockPayload);
- } else {
- // Send directly when flushing before tool execution (no streaming).
+ } else if (params.blockStreamingEnabled) {
+ // Send directly when flushing before tool execution (no pipeline but streaming enabled).
// Track sent key to avoid duplicate in final payloads.
directlySentBlockKeys.add(createBlockReplyPayloadKey(blockPayload));
await params.opts?.onBlockReply?.(blockPayload);
}
+ // When streaming is disabled entirely, blocks are accumulated in final text instead.
}
: undefined,
onBlockReplyFlush:
From 9ecbb0ae81db993dc05962abef9118b53eb3d599 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 21:13:36 -0600
Subject: [PATCH 029/196] Auth: print copyable Google auth URL (#1787)
Co-authored-by: Robby
---
CHANGELOG.md | 1 +
extensions/google-antigravity-auth/index.ts | 7 +++++++
2 files changed, 8 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index af7ae9ddc..8d5412dcd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,7 @@ Status: unreleased.
- Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg.
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
+- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.
## 2026.1.24-3
diff --git a/extensions/google-antigravity-auth/index.ts b/extensions/google-antigravity-auth/index.ts
index d6902bffe..f349ada6a 100644
--- a/extensions/google-antigravity-auth/index.ts
+++ b/extensions/google-antigravity-auth/index.ts
@@ -281,6 +281,7 @@ async function loginAntigravity(params: {
openUrl: (url: string) => Promise;
prompt: (message: string) => Promise;
note: (message: string, title?: string) => Promise;
+ log: (message: string) => void;
progress: { update: (msg: string) => void; stop: (msg?: string) => void };
}): Promise<{
access: string;
@@ -314,6 +315,11 @@ async function loginAntigravity(params: {
].join("\n"),
"Google Antigravity OAuth",
);
+ // Output raw URL below the box for easy copying (fixes #1772)
+ params.log("");
+ params.log("Copy this URL:");
+ params.log(authUrl);
+ params.log("");
}
if (!needsManual) {
@@ -382,6 +388,7 @@ const antigravityPlugin = {
openUrl: ctx.openUrl,
prompt: async (message) => String(await ctx.prompter.text({ message })),
note: ctx.prompter.note,
+ log: (message) => ctx.runtime.log(message),
progress: spin,
});
From 73507e8654abf751cce99696e6d91c4ac31ec917 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 21:15:20 -0600
Subject: [PATCH 030/196] Routing: precompile session key regexes (#1697)
Co-authored-by: Ray Tien
---
CHANGELOG.md | 1 +
src/routing/session-key.ts | 30 ++++++++++++++++++------------
2 files changed, 19 insertions(+), 12 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8d5412dcd..e39c291d2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,7 @@ Status: unreleased.
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.
+- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
## 2026.1.24-3
diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts
index 028e657cb..7f9f209ed 100644
--- a/src/routing/session-key.ts
+++ b/src/routing/session-key.ts
@@ -11,6 +11,12 @@ export const DEFAULT_AGENT_ID = "main";
export const DEFAULT_MAIN_KEY = "main";
export const DEFAULT_ACCOUNT_ID = "default";
+// Pre-compiled regex
+const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
+const INVALID_CHARS_RE = /[^a-z0-9_-]+/g;
+const LEADING_DASH_RE = /^-+/;
+const TRAILING_DASH_RE = /-+$/;
+
function normalizeToken(value: string | undefined | null): string {
return (value ?? "").trim().toLowerCase();
}
@@ -52,14 +58,14 @@ export function normalizeAgentId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
if (!trimmed) return DEFAULT_AGENT_ID;
// Keep it path-safe + shell-friendly.
- if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed.toLowerCase();
+ if (VALID_ID_RE.test(trimmed)) return trimmed.toLowerCase();
// Best-effort fallback: collapse invalid characters to "-"
return (
trimmed
.toLowerCase()
- .replace(/[^a-z0-9_-]+/g, "-")
- .replace(/^-+/, "")
- .replace(/-+$/, "")
+ .replace(INVALID_CHARS_RE, "-")
+ .replace(LEADING_DASH_RE, "")
+ .replace(TRAILING_DASH_RE, "")
.slice(0, 64) || DEFAULT_AGENT_ID
);
}
@@ -67,13 +73,13 @@ export function normalizeAgentId(value: string | undefined | null): string {
export function sanitizeAgentId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
if (!trimmed) return DEFAULT_AGENT_ID;
- if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed.toLowerCase();
+ if (VALID_ID_RE.test(trimmed)) return trimmed.toLowerCase();
return (
trimmed
.toLowerCase()
- .replace(/[^a-z0-9_-]+/gi, "-")
- .replace(/^-+/, "")
- .replace(/-+$/, "")
+ .replace(INVALID_CHARS_RE, "-")
+ .replace(LEADING_DASH_RE, "")
+ .replace(TRAILING_DASH_RE, "")
.slice(0, 64) || DEFAULT_AGENT_ID
);
}
@@ -81,13 +87,13 @@ export function sanitizeAgentId(value: string | undefined | null): string {
export function normalizeAccountId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
if (!trimmed) return DEFAULT_ACCOUNT_ID;
- if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed.toLowerCase();
+ if (VALID_ID_RE.test(trimmed)) return trimmed.toLowerCase();
return (
trimmed
.toLowerCase()
- .replace(/[^a-z0-9_-]+/g, "-")
- .replace(/^-+/, "")
- .replace(/-+$/, "")
+ .replace(INVALID_CHARS_RE, "-")
+ .replace(LEADING_DASH_RE, "")
+ .replace(TRAILING_DASH_RE, "")
.slice(0, 64) || DEFAULT_ACCOUNT_ID
);
}
From 1f06f8031e7e16d93d6faee65e999a56179ce19b Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 21:15:34 -0600
Subject: [PATCH 031/196] CI: use app token for labeler
---
.github/workflows/labeler.yml | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml
index 6ec73a1a3..8d078774b 100644
--- a/.github/workflows/labeler.yml
+++ b/.github/workflows/labeler.yml
@@ -12,6 +12,12 @@ jobs:
label:
runs-on: ubuntu-latest
steps:
+ - uses: actions/create-github-app-token@v1
+ id: app-token
+ with:
+ app-id: "2729701"
+ private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- uses: actions/labeler@v5
with:
configuration-path: .github/labeler.yml
+ repo-token: ${{ steps.app-token.outputs.token }}
From 7187c3d06765c9d3a7b1de40430fe1567b174131 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 21:17:42 -0600
Subject: [PATCH 032/196] TUI: guard against overflow width crashes (#1686)
Co-authored-by: Mohammad Jafari
---
CHANGELOG.md | 1 +
src/tui/components/filterable-select-list.ts | 2 +-
src/tui/components/searchable-select-list.ts | 3 ++-
3 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e39c291d2..480767383 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ Status: unreleased.
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
+- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein.
## 2026.1.24-3
diff --git a/src/tui/components/filterable-select-list.ts b/src/tui/components/filterable-select-list.ts
index 67361bcf1..a7b197bf5 100644
--- a/src/tui/components/filterable-select-list.ts
+++ b/src/tui/components/filterable-select-list.ts
@@ -69,7 +69,7 @@ export class FilterableSelectList implements Component {
lines.push(filterLabel + inputText);
// Separator
- lines.push(chalk.dim("─".repeat(width)));
+ lines.push(chalk.dim("─".repeat(Math.max(0, width))));
// Select list
const listLines = this.selectList.render(width);
diff --git a/src/tui/components/searchable-select-list.ts b/src/tui/components/searchable-select-list.ts
index f8e07e790..54fc34918 100644
--- a/src/tui/components/searchable-select-list.ts
+++ b/src/tui/components/searchable-select-list.ts
@@ -214,7 +214,8 @@ export class SearchableSelectList implements Component {
const maxValueWidth = Math.min(30, width - prefixWidth - 4);
const truncatedValue = truncateToWidth(displayValue, maxValueWidth, "");
const valueText = this.highlightMatch(truncatedValue, query);
- const spacing = " ".repeat(Math.max(1, 32 - visibleWidth(valueText)));
+ const spacingWidth = Math.max(1, 32 - visibleWidth(valueText));
+ const spacing = " ".repeat(spacingWidth);
const descriptionStart = prefixWidth + visibleWidth(valueText) + spacing.length;
const remainingWidth = width - descriptionStart - 2;
if (remainingWidth > 10) {
From 7f6422c8977ce782f15809fda26ae67a1d4c7aa9 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 21:20:39 -0600
Subject: [PATCH 033/196] Telegram: preserve topic IDs in restart notifications
(#1807)
Co-authored-by: hsrvc
---
CHANGELOG.md | 1 +
src/agents/tools/sessions-send-helpers.ts | 24 +++++++++++++++++++++--
src/gateway/server-restart-sentinel.ts | 16 ++++++++++-----
3 files changed, 34 insertions(+), 7 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 480767383..dc46291fb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,6 +20,7 @@ Status: unreleased.
- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein.
+- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.
## 2026.1.24-3
diff --git a/src/agents/tools/sessions-send-helpers.ts b/src/agents/tools/sessions-send-helpers.ts
index 5e758d426..c9940de0f 100644
--- a/src/agents/tools/sessions-send-helpers.ts
+++ b/src/agents/tools/sessions-send-helpers.ts
@@ -14,6 +14,7 @@ export type AnnounceTarget = {
channel: string;
to: string;
accountId?: string;
+ threadId?: string; // Forum topic/thread ID
};
export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget | null {
@@ -22,7 +23,22 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget
if (parts.length < 3) return null;
const [channelRaw, kind, ...rest] = parts;
if (kind !== "group" && kind !== "channel") return null;
- const id = rest.join(":").trim();
+
+ // Extract topic/thread ID from rest (supports both :topic: and :thread:)
+ // Telegram uses :topic:, other platforms use :thread:
+ let threadId: string | undefined;
+ const restJoined = rest.join(":");
+ const topicMatch = restJoined.match(/:topic:(\d+)$/);
+ const threadMatch = restJoined.match(/:thread:(\d+)$/);
+ const match = topicMatch || threadMatch;
+
+ if (match) {
+ threadId = match[1]; // Keep as string to match AgentCommandOpts.threadId
+ }
+
+ // Remove :topic:N or :thread:N suffix from ID for target
+ const id = match ? restJoined.replace(/:(topic|thread):\d+$/, "") : restJoined.trim();
+
if (!id) return null;
if (!channelRaw) return null;
const normalizedChannel = normalizeAnyChannelId(channelRaw) ?? normalizeChatChannelId(channelRaw);
@@ -37,7 +53,11 @@ export function resolveAnnounceTargetFromKey(sessionKey: string): AnnounceTarget
const normalized = normalizedChannel
? getChannelPlugin(normalizedChannel)?.messaging?.normalizeTarget?.(kindTarget)
: undefined;
- return { channel, to: normalized ?? kindTarget };
+ return {
+ channel,
+ to: normalized ?? kindTarget,
+ threadId,
+ };
}
export function buildAgentToAgentMessageContext(params: {
diff --git a/src/gateway/server-restart-sentinel.ts b/src/gateway/server-restart-sentinel.ts
index fa33b7c21..28719290e 100644
--- a/src/gateway/server-restart-sentinel.ts
+++ b/src/gateway/server-restart-sentinel.ts
@@ -28,11 +28,16 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
return;
}
- const threadMarker = ":thread:";
- const threadIndex = sessionKey.lastIndexOf(threadMarker);
- const baseSessionKey = threadIndex === -1 ? sessionKey : sessionKey.slice(0, threadIndex);
+ // Extract topic/thread ID from sessionKey (supports both :topic: and :thread:)
+ // Telegram uses :topic:, other platforms use :thread:
+ const topicIndex = sessionKey.lastIndexOf(":topic:");
+ const threadIndex = sessionKey.lastIndexOf(":thread:");
+ const markerIndex = Math.max(topicIndex, threadIndex);
+ const marker = topicIndex > threadIndex ? ":topic:" : ":thread:";
+
+ const baseSessionKey = markerIndex === -1 ? sessionKey : sessionKey.slice(0, markerIndex);
const threadIdRaw =
- threadIndex === -1 ? undefined : sessionKey.slice(threadIndex + threadMarker.length);
+ markerIndex === -1 ? undefined : sessionKey.slice(markerIndex + marker.length);
const sessionThreadId = threadIdRaw?.trim() || undefined;
const { cfg, entry } = loadSessionEntry(sessionKey);
@@ -42,7 +47,7 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
// Handles race condition where store wasn't flushed before restart
const sentinelContext = payload.deliveryContext;
let sessionDeliveryContext = deliveryContextFromSession(entry);
- if (!sessionDeliveryContext && threadIndex !== -1 && baseSessionKey) {
+ if (!sessionDeliveryContext && markerIndex !== -1 && baseSessionKey) {
const { entry: baseEntry } = loadSessionEntry(baseSessionKey);
sessionDeliveryContext = deliveryContextFromSession(baseEntry);
}
@@ -74,6 +79,7 @@ export async function scheduleRestartSentinelWake(params: { deps: CliDeps }) {
const threadId =
payload.threadId ??
+ parsedTarget?.threadId ?? // From resolveAnnounceTargetFromKey (extracts :topic:N)
sessionThreadId ??
(origin?.threadId != null ? String(origin.threadId) : undefined);
From 1b598ad70923e6f6c3f6f7bc12ffc75f06e07004 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 21:22:25 -0600
Subject: [PATCH 034/196] Config: apply config.env before substitution (#1813)
Co-authored-by: SPANISH FLU
---
CHANGELOG.md | 1 +
src/config/io.ts | 10 ++++++++++
2 files changed, 11 insertions(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dc46291fb..6aacd64aa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,6 +21,7 @@ Status: unreleased.
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein.
- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.
+- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.
## 2026.1.24-3
diff --git a/src/config/io.ts b/src/config/io.ts
index da3a7fb23..9078ef2a2 100644
--- a/src/config/io.ts
+++ b/src/config/io.ts
@@ -211,6 +211,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
parseJson: (raw) => deps.json5.parse(raw),
});
+ // Apply config.env to process.env BEFORE substitution so ${VAR} can reference config-defined vars
+ if (resolved && typeof resolved === "object" && "env" in resolved) {
+ applyConfigEnv(resolved as ClawdbotConfig, deps.env);
+ }
+
// Substitute ${VAR} env var references
const substituted = resolveConfigEnvVars(resolved, deps.env);
@@ -365,6 +370,11 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
};
}
+ // Apply config.env to process.env BEFORE substitution so ${VAR} can reference config-defined vars
+ if (resolved && typeof resolved === "object" && "env" in resolved) {
+ applyConfigEnv(resolved as ClawdbotConfig, deps.env);
+ }
+
// Substitute ${VAR} env var references
let substituted: unknown;
try {
From 678ad9e3aed137e7ab65736185aacdc468f8e707 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 21:23:18 -0600
Subject: [PATCH 035/196] CI: expand web-ui label globs
---
.github/labeler.yml | 2 ++
1 file changed, 2 insertions(+)
diff --git a/.github/labeler.yml b/.github/labeler.yml
index 5b34c41e0..5d2837a6c 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -115,6 +115,8 @@
- "ui/**"
- "src/gateway/control-ui.ts"
- "src/gateway/control-ui-shared.ts"
+ - "src/gateway/protocol/**"
+ - "src/gateway/server-methods/chat.ts"
- "src/infra/control-ui-assets.ts"
"gateway":
From 7e4e24445e21d0727895a2667ef6eac515e6904c Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 21:28:46 -0600
Subject: [PATCH 036/196] Slack: clear ack reaction after streaming replies
(#2044)
Co-authored-by: Shaurya Pratap Singh
---
CHANGELOG.md | 1 +
src/slack/monitor/message-handler/dispatch.ts | 4 +++-
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6aacd64aa..2cd23f0f1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,7 @@ Status: unreleased.
- TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein.
- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.
- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.
+- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
## 2026.1.24-3
diff --git a/src/slack/monitor/message-handler/dispatch.ts b/src/slack/monitor/message-handler/dispatch.ts
index d31885cfa..38b69f049 100644
--- a/src/slack/monitor/message-handler/dispatch.ts
+++ b/src/slack/monitor/message-handler/dispatch.ts
@@ -141,7 +141,9 @@ export async function dispatchPreparedSlackMessage(prepared: PreparedSlackMessag
});
markDispatchIdle();
- if (!queuedFinal) {
+ const anyReplyDelivered = queuedFinal || (counts.block ?? 0) > 0 || (counts.final ?? 0) > 0;
+
+ if (!anyReplyDelivered) {
if (prepared.isRoomish) {
clearHistoryEntriesIfEnabled({
historyMap: ctx.channelHistories,
From 8b91ceb7c96f5eb15e3cda39d6fa6a769dddcfad Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 21:46:15 -0600
Subject: [PATCH 037/196] macOS: preserve custom SSH usernames (#2046)
Co-authored-by: Alexis Gallagher
---
CHANGELOG.md | 1 +
apps/macos/Sources/Clawdbot/AppState.swift | 15 +++++++++++----
2 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2cd23f0f1..5e3ab78da 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,7 @@ Status: unreleased.
- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.
- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
+- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
## 2026.1.24-3
diff --git a/apps/macos/Sources/Clawdbot/AppState.swift b/apps/macos/Sources/Clawdbot/AppState.swift
index eeaf034d0..6ccb83369 100644
--- a/apps/macos/Sources/Clawdbot/AppState.swift
+++ b/apps/macos/Sources/Clawdbot/AppState.swift
@@ -413,10 +413,17 @@ final class AppState {
}
private func updateRemoteTarget(host: String) {
- let parsed = CommandResolver.parseSSHTarget(self.remoteTarget)
- let user = parsed?.user ?? NSUserName()
- let port = parsed?.port ?? 22
- let assembled = port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)"
+ let trimmed = self.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard let parsed = CommandResolver.parseSSHTarget(trimmed) else { return }
+ let trimmedUser = parsed.user?.trimmingCharacters(in: .whitespacesAndNewlines)
+ let user = (trimmedUser?.isEmpty ?? true) ? nil : trimmedUser
+ let port = parsed.port
+ let assembled: String
+ if let user {
+ assembled = port == 22 ? "\(user)@\(host)" : "\(user)@\(host):\(port)"
+ } else {
+ assembled = port == 22 ? host : "\(host):\(port)"
+ }
if assembled != self.remoteTarget {
self.remoteTarget = assembled
}
From 15f7648e1e8a82dce8053d0b3e559eab26078de1 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 22:18:47 -0600
Subject: [PATCH 038/196] Docs: credit Control UI refresh contributors (#1852)
---
CHANGELOG.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5e3ab78da..921ecaca7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ Status: unreleased.
- Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr.
- Skills: add missing dependency metadata for GitHub, Notion, Slack, Discord. (#1995) Thanks @jackheuberger.
- Docs: add Render deployment guide. (#1975) Thanks @anurag.
+- Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD.
- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi.
- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn.
- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.
@@ -58,7 +59,7 @@ Status: unreleased.
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
-- UI: refresh Control UI dashboard design system (typography, colors, spacing). (#1786) Thanks @mousberg.
+- UI: refresh Control UI dashboard design system (colors, icons, typography). (#1745, #1786) Thanks @EnzeD, @mousberg.
- 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.
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags
From 0648d660a8673d03507c1babef4ae43595f429cd Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 22:22:52 -0600
Subject: [PATCH 039/196] Docs: use generic Pi hostnames
---
docs/platforms/raspberry-pi.md | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/docs/platforms/raspberry-pi.md b/docs/platforms/raspberry-pi.md
index 1273d0112..b34e3fcfe 100644
--- a/docs/platforms/raspberry-pi.md
+++ b/docs/platforms/raspberry-pi.md
@@ -46,7 +46,7 @@ Use **Raspberry Pi OS Lite (64-bit)** — no desktop needed for a headless serve
1. Download [Raspberry Pi Imager](https://www.raspberrypi.com/software/)
2. Choose OS: **Raspberry Pi OS Lite (64-bit)**
3. Click the gear icon (⚙️) to pre-configure:
- - Set hostname: `clawdbot`
+ - Set hostname: `gateway-host`
- Enable SSH
- Set username/password
- Configure WiFi (if not using Ethernet)
@@ -56,9 +56,9 @@ Use **Raspberry Pi OS Lite (64-bit)** — no desktop needed for a headless serve
## 2) Connect via SSH
```bash
-ssh pi@clawdbot.local
+ssh user@gateway-host
# or use the IP address
-ssh pi@192.168.x.x
+ssh user@192.168.x.x
```
## 3) System Setup
@@ -156,7 +156,7 @@ Since the Pi is headless, use an SSH tunnel:
```bash
# From your laptop/desktop
-ssh -L 18789:localhost:18789 pi@clawdbot.local
+ssh -L 18789:localhost:18789 user@gateway-host
# Then open in browser
open http://localhost:18789
From 5d6a9da370b89fee5f57098bef68cd6ba6f6bf3a Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 22:26:00 -0600
Subject: [PATCH 040/196] Onboarding: add Venice API key flags (#1893)
---
CHANGELOG.md | 1 +
src/cli/program/register.onboard.ts | 4 +++-
.../local/auth-choice.ts | 21 +++++++++++++++++++
3 files changed, 25 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 921ecaca7..e5813b5d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ Status: unreleased.
- Skills: add missing dependency metadata for GitHub, Notion, Slack, Discord. (#1995) Thanks @jackheuberger.
- Docs: add Render deployment guide. (#1975) Thanks @anurag.
- Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD.
+- Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub.
- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi.
- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn.
- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.
diff --git a/src/cli/program/register.onboard.ts b/src/cli/program/register.onboard.ts
index 281464b6f..ee9d5ccd2 100644
--- a/src/cli/program/register.onboard.ts
+++ b/src/cli/program/register.onboard.ts
@@ -52,7 +52,7 @@ export function registerOnboardCommand(program: Command) {
.option("--mode ", "Wizard mode: local|remote")
.option(
"--auth-choice ",
- "Auth: setup-token|claude-cli|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
+ "Auth: setup-token|claude-cli|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|kimi-code-api-key|synthetic-api-key|venice-api-key|codex-cli|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip",
)
.option(
"--token-provider ",
@@ -74,6 +74,7 @@ export function registerOnboardCommand(program: Command) {
.option("--zai-api-key ", "Z.AI API key")
.option("--minimax-api-key ", "MiniMax API key")
.option("--synthetic-api-key ", "Synthetic API key")
+ .option("--venice-api-key ", "Venice API key")
.option("--opencode-zen-api-key ", "OpenCode Zen API key")
.option("--gateway-port ", "Gateway port")
.option("--gateway-bind ", "Gateway bind: loopback|tailnet|lan|auto|custom")
@@ -123,6 +124,7 @@ export function registerOnboardCommand(program: Command) {
zaiApiKey: opts.zaiApiKey as string | undefined,
minimaxApiKey: opts.minimaxApiKey as string | undefined,
syntheticApiKey: opts.syntheticApiKey as string | undefined,
+ veniceApiKey: opts.veniceApiKey as string | undefined,
opencodeZenApiKey: opts.opencodeZenApiKey as string | undefined,
gatewayPort:
typeof gatewayPort === "number" && Number.isFinite(gatewayPort)
diff --git a/src/commands/onboard-non-interactive/local/auth-choice.ts b/src/commands/onboard-non-interactive/local/auth-choice.ts
index 6762fb7d2..02e0a75b9 100644
--- a/src/commands/onboard-non-interactive/local/auth-choice.ts
+++ b/src/commands/onboard-non-interactive/local/auth-choice.ts
@@ -20,6 +20,7 @@ import {
applyOpencodeZenConfig,
applyOpenrouterConfig,
applySyntheticConfig,
+ applyVeniceConfig,
applyVercelAiGatewayConfig,
applyZaiConfig,
setAnthropicApiKey,
@@ -30,6 +31,7 @@ import {
setOpencodeZenApiKey,
setOpenrouterApiKey,
setSyntheticApiKey,
+ setVeniceApiKey,
setVercelAiGatewayApiKey,
setZaiApiKey,
} from "../../onboard-auth.js";
@@ -272,6 +274,25 @@ export async function applyNonInteractiveAuthChoice(params: {
return applySyntheticConfig(nextConfig);
}
+ if (authChoice === "venice-api-key") {
+ const resolved = await resolveNonInteractiveApiKey({
+ provider: "venice",
+ cfg: baseConfig,
+ flagValue: opts.veniceApiKey,
+ flagName: "--venice-api-key",
+ envVar: "VENICE_API_KEY",
+ runtime,
+ });
+ if (!resolved) return null;
+ if (resolved.source !== "profile") await setVeniceApiKey(resolved.key);
+ nextConfig = applyAuthProfileConfig(nextConfig, {
+ profileId: "venice:default",
+ provider: "venice",
+ mode: "api_key",
+ });
+ return applyVeniceConfig(nextConfig);
+ }
+
if (
authChoice === "minimax-cloud" ||
authChoice === "minimax-api" ||
From 51720980736b170569665e4863c4b7937a525e16 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 22:30:18 -0600
Subject: [PATCH 041/196] Tlon: format reply IDs as @ud (#1837)
---
CHANGELOG.md | 1 +
extensions/tlon/src/urbit/send.ts | 25 +++++++++++++++++++------
2 files changed, 20 insertions(+), 6 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e5813b5d1..2b1dfa6fe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ Status: unreleased.
- Docs: add Render deployment guide. (#1975) Thanks @anurag.
- Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD.
- Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub.
+- Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a.
- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi.
- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn.
- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.
diff --git a/extensions/tlon/src/urbit/send.ts b/extensions/tlon/src/urbit/send.ts
index 35f7f2d74..621bbd69a 100644
--- a/extensions/tlon/src/urbit/send.ts
+++ b/extensions/tlon/src/urbit/send.ts
@@ -63,16 +63,28 @@ export async function sendGroupMessage({
const story = [{ inline: [text] }];
const sentAt = Date.now();
+ // Format reply ID as @ud (with dots) - required for Tlon to recognize thread replies
+ let formattedReplyId = replyToId;
+ if (replyToId && /^\d+$/.test(replyToId)) {
+ try {
+ formattedReplyId = formatUd(BigInt(replyToId));
+ } catch {
+ // Fall back to raw ID if formatting fails
+ }
+ }
+
const action = {
channel: {
nest: `chat/${hostShip}/${channelName}`,
- action: replyToId
+ action: formattedReplyId
? {
- reply: {
- id: replyToId,
- delta: {
- add: {
- memo: {
+ // Thread reply - needs post wrapper around reply action
+ // ReplyActionAdd takes Memo: {content, author, sent} - no kind/blob/meta
+ post: {
+ reply: {
+ id: formattedReplyId,
+ action: {
+ add: {
content: story,
author: fromShip,
sent: sentAt,
@@ -82,6 +94,7 @@ export async function sendGroupMessage({
},
}
: {
+ // Regular post
post: {
add: {
content: story,
From d696ee3dfd64286c35313f69e658a0748640dc83 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 22:32:38 -0600
Subject: [PATCH 042/196] Docs: add Claude Max API Proxy guide (#1875)
Co-authored-by: atalovesyou
---
CHANGELOG.md | 1 +
docs/providers/claude-max-api-proxy.md | 145 +++++++++++++++++++++++++
docs/providers/index.md | 4 +
3 files changed, 150 insertions(+)
create mode 100644 docs/providers/claude-max-api-proxy.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2b1dfa6fe..a1d4cd7d7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ Status: unreleased.
- Agents: expand cron tool description with full schema docs. (#1988) Thanks @tomascupr.
- Skills: add missing dependency metadata for GitHub, Notion, Slack, Discord. (#1995) Thanks @jackheuberger.
- Docs: add Render deployment guide. (#1975) Thanks @anurag.
+- Docs: add Claude Max API Proxy guide. (#1875) Thanks @atalovesyou.
- Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD.
- Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub.
- Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a.
diff --git a/docs/providers/claude-max-api-proxy.md b/docs/providers/claude-max-api-proxy.md
new file mode 100644
index 000000000..255be62fc
--- /dev/null
+++ b/docs/providers/claude-max-api-proxy.md
@@ -0,0 +1,145 @@
+---
+summary: "Use Claude Max/Pro subscription as an OpenAI-compatible API endpoint"
+read_when:
+ - You want to use Claude Max subscription with OpenAI-compatible tools
+ - You want a local API server that wraps Claude Code CLI
+ - You want to save money by using subscription instead of API keys
+---
+# Claude Max API Proxy
+
+**claude-max-api-proxy** is a community tool that exposes your Claude Max/Pro subscription as an OpenAI-compatible API endpoint. This allows you to use your subscription with any tool that supports the OpenAI API format.
+
+## Why Use This?
+
+| Approach | Cost | Best For |
+|----------|------|----------|
+| Anthropic API | Pay per token (~$15/M input, $75/M output for Opus) | Production apps, high volume |
+| Claude Max subscription | $200/month flat | Personal use, development, unlimited usage |
+
+If you have a Claude Max subscription and want to use it with OpenAI-compatible tools, this proxy can save you significant money.
+
+## How It Works
+
+```
+Your App → claude-max-api-proxy → Claude Code CLI → Anthropic (via subscription)
+ (OpenAI format) (converts format) (uses your login)
+```
+
+The proxy:
+1. Accepts OpenAI-format requests at `http://localhost:3456/v1/chat/completions`
+2. Converts them to Claude Code CLI commands
+3. Returns responses in OpenAI format (streaming supported)
+
+## Installation
+
+```bash
+# Requires Node.js 20+ and Claude Code CLI
+npm install -g claude-max-api-proxy
+
+# Verify Claude CLI is authenticated
+claude --version
+```
+
+## Usage
+
+### Start the server
+
+```bash
+claude-max-api
+# Server runs at http://localhost:3456
+```
+
+### Test it
+
+```bash
+# Health check
+curl http://localhost:3456/health
+
+# List models
+curl http://localhost:3456/v1/models
+
+# Chat completion
+curl http://localhost:3456/v1/chat/completions \
+ -H "Content-Type: application/json" \
+ -d '{
+ "model": "claude-opus-4",
+ "messages": [{"role": "user", "content": "Hello!"}]
+ }'
+```
+
+### With Clawdbot
+
+You can point Clawdbot at the proxy as a custom OpenAI-compatible endpoint:
+
+```json5
+{
+ env: {
+ OPENAI_API_KEY: "not-needed",
+ OPENAI_BASE_URL: "http://localhost:3456/v1"
+ },
+ agents: {
+ defaults: {
+ model: { primary: "openai/claude-opus-4" }
+ }
+ }
+}
+```
+
+## Available Models
+
+| Model ID | Maps To |
+|----------|---------|
+| `claude-opus-4` | Claude Opus 4 |
+| `claude-sonnet-4` | Claude Sonnet 4 |
+| `claude-haiku-4` | Claude Haiku 4 |
+
+## Auto-Start on macOS
+
+Create a LaunchAgent to run the proxy automatically:
+
+```bash
+cat > ~/Library/LaunchAgents/com.claude-max-api.plist << 'EOF'
+
+
+
+
+ Label
+ com.claude-max-api
+ RunAtLoad
+
+ KeepAlive
+
+ ProgramArguments
+
+ /usr/local/bin/node
+ /usr/local/lib/node_modules/claude-max-api-proxy/dist/server/standalone.js
+
+ EnvironmentVariables
+
+ PATH
+ /usr/local/bin:/opt/homebrew/bin:~/.local/bin:/usr/bin:/bin
+
+
+
+EOF
+
+launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.claude-max-api.plist
+```
+
+## Links
+
+- **npm:** https://www.npmjs.com/package/claude-max-api-proxy
+- **GitHub:** https://github.com/atalovesyou/claude-max-api-proxy
+- **Issues:** https://github.com/atalovesyou/claude-max-api-proxy/issues
+
+## Notes
+
+- This is a **community tool**, not officially supported by Anthropic or Clawdbot
+- Requires an active Claude Max/Pro subscription with Claude Code CLI authenticated
+- The proxy runs locally and does not send data to any third-party servers
+- Streaming responses are fully supported
+
+## See Also
+
+- [Anthropic provider](/providers/anthropic) - Native Clawdbot integration with Claude Code CLI OAuth
+- [OpenAI provider](/providers/openai) - For OpenAI/Codex subscriptions
diff --git a/docs/providers/index.md b/docs/providers/index.md
index c4f020192..b4779d201 100644
--- a/docs/providers/index.md
+++ b/docs/providers/index.md
@@ -51,5 +51,9 @@ See [Venice AI](/providers/venice).
- [Deepgram (audio transcription)](/providers/deepgram)
+## Community tools
+
+- [Claude Max API Proxy](/providers/claude-max-api-proxy) - Use Claude Max/Pro subscription as an OpenAI-compatible API endpoint
+
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
see [Model providers](/concepts/model-providers).
From 10914d62496d8786ffcffd7cb2ca7d5d85b9f3f6 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 22:33:03 -0600
Subject: [PATCH 043/196] Docs: add DigitalOcean deployment guide (#1870)
Co-authored-by: 0xJonHoldsCrypto <0xJonHoldsCrypto@users.noreply.github.com>
---
CHANGELOG.md | 1 +
docs/platforms/digitalocean.md | 239 +++++++++++++++++++++++++++++++++
2 files changed, 240 insertions(+)
create mode 100644 docs/platforms/digitalocean.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a1d4cd7d7..cd5436c1d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ Status: unreleased.
- Skills: add missing dependency metadata for GitHub, Notion, Slack, Discord. (#1995) Thanks @jackheuberger.
- Docs: add Render deployment guide. (#1975) Thanks @anurag.
- Docs: add Claude Max API Proxy guide. (#1875) Thanks @atalovesyou.
+- Docs: add DigitalOcean deployment guide. (#1870) Thanks @0xJonHoldsCrypto.
- Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD.
- Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub.
- Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a.
diff --git a/docs/platforms/digitalocean.md b/docs/platforms/digitalocean.md
new file mode 100644
index 000000000..1b8e1d90d
--- /dev/null
+++ b/docs/platforms/digitalocean.md
@@ -0,0 +1,239 @@
+---
+summary: "Clawdbot on DigitalOcean (cheapest paid VPS option)"
+read_when:
+ - Setting up Clawdbot on DigitalOcean
+ - Looking for cheap VPS hosting for Clawdbot
+---
+
+# Clawdbot on DigitalOcean
+
+## Goal
+
+Run a persistent Clawdbot Gateway on DigitalOcean for **$6/month** (or $4/mo with reserved pricing).
+
+If you want something even cheaper, see [Oracle Cloud (Free Tier)](#oracle-cloud-free-alternative) at the bottom — it's **actually free forever**.
+
+## Cost Comparison (2026)
+
+| Provider | Plan | Specs | Price/mo | Notes |
+|----------|------|-------|----------|-------|
+| **Oracle Cloud** | Always Free ARM | 4 OCPU, 24GB RAM | **$0** | Best value, requires ARM-compatible setup |
+| **Hetzner** | CX22 | 2 vCPU, 4GB RAM | €3.79 (~$4) | Cheapest paid, EU datacenters |
+| **DigitalOcean** | Basic | 1 vCPU, 1GB RAM | $6 | Easy UI, good docs |
+| **Vultr** | Cloud Compute | 1 vCPU, 1GB RAM | $6 | Many locations |
+| **Linode** | Nanode | 1 vCPU, 1GB RAM | $5 | Now part of Akamai |
+
+**Recommendation:**
+- **Free:** Oracle Cloud ARM (if you can handle the signup process)
+- **Paid:** Hetzner CX22 (best specs per dollar) — see [Hetzner guide](/platforms/hetzner)
+- **Easy:** DigitalOcean (this guide) — beginner-friendly UI
+
+---
+
+## Prerequisites
+
+- DigitalOcean account ([signup with $200 free credit](https://m.do.co/c/signup))
+- SSH key pair (or willingness to use password auth)
+- ~20 minutes
+
+## 1) Create a Droplet
+
+1. Log into [DigitalOcean](https://cloud.digitalocean.com/)
+2. Click **Create → Droplets**
+3. Choose:
+ - **Region:** Closest to you (or your users)
+ - **Image:** Ubuntu 24.04 LTS
+ - **Size:** Basic → Regular → **$6/mo** (1 vCPU, 1GB RAM, 25GB SSD)
+ - **Authentication:** SSH key (recommended) or password
+4. Click **Create Droplet**
+5. Note the IP address
+
+## 2) Connect via SSH
+
+```bash
+ssh root@YOUR_DROPLET_IP
+```
+
+## 3) Install Clawdbot
+
+```bash
+# Update system
+apt update && apt upgrade -y
+
+# Install Node.js 22
+curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
+apt install -y nodejs
+
+# Install Clawdbot
+curl -fsSL https://clawd.bot/install.sh | bash
+
+# Verify
+clawdbot --version
+```
+
+## 4) Run Onboarding
+
+```bash
+clawdbot onboard --install-daemon
+```
+
+The wizard will walk you through:
+- Model auth (API keys or OAuth)
+- Channel setup (Telegram, WhatsApp, Discord, etc.)
+- Gateway token (auto-generated)
+- Daemon installation (systemd)
+
+## 5) Verify the Gateway
+
+```bash
+# Check status
+clawdbot status
+
+# Check service
+systemctl status clawdbot
+
+# View logs
+journalctl -u clawdbot -f
+```
+
+## 6) Access the Dashboard
+
+The gateway binds to loopback by default. To access the Control UI:
+
+**Option A: SSH Tunnel (recommended)**
+```bash
+# From your local machine
+ssh -L 18789:localhost:18789 root@YOUR_DROPLET_IP
+
+# Then open: http://localhost:18789
+```
+
+**Option B: Tailscale (easier long-term)**
+```bash
+# On the droplet
+curl -fsSL https://tailscale.com/install.sh | sh
+tailscale up
+
+# Configure gateway to bind to Tailscale
+clawdbot config set gateway.bind tailnet
+clawdbot gateway restart
+```
+
+Then access via your Tailscale IP: `http://100.x.x.x:18789`
+
+## 7) Connect Your Channels
+
+### Telegram
+```bash
+clawdbot pairing list telegram
+clawdbot pairing approve telegram
+```
+
+### WhatsApp
+```bash
+clawdbot channels login whatsapp
+# Scan QR code
+```
+
+See [Channels](/channels) for other providers.
+
+---
+
+## Optimizations for 1GB RAM
+
+The $6 droplet only has 1GB RAM. To keep things running smoothly:
+
+### Add swap (recommended)
+```bash
+fallocate -l 2G /swapfile
+chmod 600 /swapfile
+mkswap /swapfile
+swapon /swapfile
+echo '/swapfile none swap sw 0 0' >> /etc/fstab
+```
+
+### Use a lighter model
+If you're hitting OOMs, consider:
+- Using API-based models (Claude, GPT) instead of local models
+- Setting `agents.defaults.model.primary` to a smaller model
+
+### Monitor memory
+```bash
+free -h
+htop
+```
+
+---
+
+## Persistence
+
+All state lives in:
+- `~/.clawdbot/` — config, credentials, session data
+- `~/clawd/` — workspace (SOUL.md, memory, etc.)
+
+These survive reboots. Back them up periodically:
+```bash
+tar -czvf clawdbot-backup.tar.gz ~/.clawdbot ~/clawd
+```
+
+---
+
+## Oracle Cloud Free Alternative
+
+Oracle Cloud offers **Always Free** ARM instances that are significantly more powerful:
+
+| What you get | Specs |
+|--------------|-------|
+| **4 OCPUs** | ARM Ampere A1 |
+| **24GB RAM** | More than enough |
+| **200GB storage** | Block volume |
+| **Forever free** | No credit card charges |
+
+### Quick setup:
+1. Sign up at [oracle.com/cloud/free](https://www.oracle.com/cloud/free/)
+2. Create a VM.Standard.A1.Flex instance (ARM)
+3. Choose Oracle Linux or Ubuntu
+4. Allocate up to 4 OCPU / 24GB RAM within free tier
+5. Follow the same Clawdbot install steps above
+
+**Caveats:**
+- Signup can be finicky (retry if it fails)
+- ARM architecture — most things work, but some binaries need ARM builds
+- Oracle may reclaim idle instances (keep them active)
+
+For the full Oracle guide, see the [community docs](https://gist.github.com/rssnyder/51e3cfedd730e7dd5f4a816143b25dbd).
+
+---
+
+## Troubleshooting
+
+### Gateway won't start
+```bash
+clawdbot gateway status
+clawdbot doctor --non-interactive
+journalctl -u clawdbot --no-pager -n 50
+```
+
+### Port already in use
+```bash
+lsof -i :18789
+kill
+```
+
+### Out of memory
+```bash
+# Check memory
+free -h
+
+# Add more swap
+# Or upgrade to $12/mo droplet (2GB RAM)
+```
+
+---
+
+## See Also
+
+- [Hetzner guide](/platforms/hetzner) — cheaper, more powerful
+- [Docker install](/install/docker) — containerized setup
+- [Tailscale](/gateway/tailscale) — secure remote access
+- [Configuration](/gateway/configuration) — full config reference
From a2d9127ff64b9417e3a953404d5a7b1a544e497e Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 22:33:35 -0600
Subject: [PATCH 044/196] Docs: add Raspberry Pi install guide (#1871)
Co-authored-by: 0xJonHoldsCrypto <0xJonHoldsCrypto@users.noreply.github.com>
---
CHANGELOG.md | 1 +
docs/platforms/raspberry-pi.md | 354 +++++++++++++++++++++++++++++++++
2 files changed, 355 insertions(+)
create mode 100644 docs/platforms/raspberry-pi.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cd5436c1d..27fa13f18 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ Status: unreleased.
- Docs: add Render deployment guide. (#1975) Thanks @anurag.
- Docs: add Claude Max API Proxy guide. (#1875) Thanks @atalovesyou.
- Docs: add DigitalOcean deployment guide. (#1870) Thanks @0xJonHoldsCrypto.
+- Docs: add Raspberry Pi install guide. (#1871) Thanks @0xJonHoldsCrypto.
- Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD.
- Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub.
- Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a.
diff --git a/docs/platforms/raspberry-pi.md b/docs/platforms/raspberry-pi.md
new file mode 100644
index 000000000..b34e3fcfe
--- /dev/null
+++ b/docs/platforms/raspberry-pi.md
@@ -0,0 +1,354 @@
+---
+summary: "Clawdbot on Raspberry Pi (budget self-hosted setup)"
+read_when:
+ - Setting up Clawdbot on a Raspberry Pi
+ - Running Clawdbot on ARM devices
+ - Building a cheap always-on personal AI
+---
+
+# Clawdbot on Raspberry Pi
+
+## Goal
+
+Run a persistent, always-on Clawdbot Gateway on a Raspberry Pi for **~$35-80** one-time cost (no monthly fees).
+
+Perfect for:
+- 24/7 personal AI assistant
+- Home automation hub
+- Low-power, always-available Telegram/WhatsApp bot
+
+## Hardware Requirements
+
+| Pi Model | RAM | Works? | Notes |
+|----------|-----|--------|-------|
+| **Pi 5** | 4GB/8GB | ✅ Best | Fastest, recommended |
+| **Pi 4** | 4GB | ✅ Good | Sweet spot for most users |
+| **Pi 4** | 2GB | ✅ OK | Works, add swap |
+| **Pi 4** | 1GB | ⚠️ Tight | Possible with swap, minimal config |
+| **Pi 3B+** | 1GB | ⚠️ Slow | Works but sluggish |
+| **Pi Zero 2 W** | 512MB | ❌ | Not recommended |
+
+**Minimum specs:** 1GB RAM, 1 core, 500MB disk
+**Recommended:** 2GB+ RAM, 64-bit OS, 16GB+ SD card (or USB SSD)
+
+## What You'll Need
+
+- Raspberry Pi 4 or 5 (2GB+ recommended)
+- MicroSD card (16GB+) or USB SSD (better performance)
+- Power supply (official Pi PSU recommended)
+- Network connection (Ethernet or WiFi)
+- ~30 minutes
+
+## 1) Flash the OS
+
+Use **Raspberry Pi OS Lite (64-bit)** — no desktop needed for a headless server.
+
+1. Download [Raspberry Pi Imager](https://www.raspberrypi.com/software/)
+2. Choose OS: **Raspberry Pi OS Lite (64-bit)**
+3. Click the gear icon (⚙️) to pre-configure:
+ - Set hostname: `gateway-host`
+ - Enable SSH
+ - Set username/password
+ - Configure WiFi (if not using Ethernet)
+4. Flash to your SD card / USB drive
+5. Insert and boot the Pi
+
+## 2) Connect via SSH
+
+```bash
+ssh user@gateway-host
+# or use the IP address
+ssh user@192.168.x.x
+```
+
+## 3) System Setup
+
+```bash
+# Update system
+sudo apt update && sudo apt upgrade -y
+
+# Install essential packages
+sudo apt install -y git curl build-essential
+
+# Set timezone (important for cron/reminders)
+sudo timedatectl set-timezone America/Chicago # Change to your timezone
+```
+
+## 4) Install Node.js 22 (ARM64)
+
+```bash
+# Install Node.js via NodeSource
+curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
+sudo apt install -y nodejs
+
+# Verify
+node --version # Should show v22.x.x
+npm --version
+```
+
+## 5) Add Swap (Important for 2GB or less)
+
+Swap prevents out-of-memory crashes:
+
+```bash
+# Create 2GB swap file
+sudo fallocate -l 2G /swapfile
+sudo chmod 600 /swapfile
+sudo mkswap /swapfile
+sudo swapon /swapfile
+
+# Make permanent
+echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
+
+# Optimize for low RAM (reduce swappiness)
+echo 'vm.swappiness=10' | sudo tee -a /etc/sysctl.conf
+sudo sysctl -p
+```
+
+## 6) Install Clawdbot
+
+### Option A: Standard Install (Recommended)
+
+```bash
+curl -fsSL https://clawd.bot/install.sh | bash
+```
+
+### Option B: Hackable Install (For tinkering)
+
+```bash
+git clone https://github.com/clawdbot/clawdbot.git
+cd clawdbot
+npm install
+npm run build
+npm link
+```
+
+The hackable install gives you direct access to logs and code — useful for debugging ARM-specific issues.
+
+## 7) Run Onboarding
+
+```bash
+clawdbot onboard --install-daemon
+```
+
+Follow the wizard:
+1. **Gateway mode:** Local
+2. **Auth:** API keys recommended (OAuth can be finicky on headless Pi)
+3. **Channels:** Telegram is easiest to start with
+4. **Daemon:** Yes (systemd)
+
+## 8) Verify Installation
+
+```bash
+# Check status
+clawdbot status
+
+# Check service
+sudo systemctl status clawdbot
+
+# View logs
+journalctl -u clawdbot -f
+```
+
+## 9) Access the Dashboard
+
+Since the Pi is headless, use an SSH tunnel:
+
+```bash
+# From your laptop/desktop
+ssh -L 18789:localhost:18789 user@gateway-host
+
+# Then open in browser
+open http://localhost:18789
+```
+
+Or use Tailscale for always-on access:
+
+```bash
+# On the Pi
+curl -fsSL https://tailscale.com/install.sh | sh
+sudo tailscale up
+
+# Update config
+clawdbot config set gateway.bind tailnet
+sudo systemctl restart clawdbot
+```
+
+---
+
+## Performance Optimizations
+
+### Use a USB SSD (Huge Improvement)
+
+SD cards are slow and wear out. A USB SSD dramatically improves performance:
+
+```bash
+# Check if booting from USB
+lsblk
+```
+
+See [Pi USB boot guide](https://www.raspberrypi.com/documentation/computers/raspberry-pi.html#usb-mass-storage-boot) for setup.
+
+### Reduce Memory Usage
+
+```bash
+# Disable GPU memory allocation (headless)
+echo 'gpu_mem=16' | sudo tee -a /boot/config.txt
+
+# Disable Bluetooth if not needed
+sudo systemctl disable bluetooth
+```
+
+### Monitor Resources
+
+```bash
+# Check memory
+free -h
+
+# Check CPU temperature
+vcgencmd measure_temp
+
+# Live monitoring
+htop
+```
+
+---
+
+## ARM-Specific Notes
+
+### Binary Compatibility
+
+Most Clawdbot features work on ARM64, but some external binaries may need ARM builds:
+
+| Tool | ARM64 Status | Notes |
+|------|--------------|-------|
+| Node.js | ✅ | Works great |
+| WhatsApp (Baileys) | ✅ | Pure JS, no issues |
+| Telegram | ✅ | Pure JS, no issues |
+| gog (Gmail CLI) | ⚠️ | Check for ARM release |
+| Chromium (browser) | ✅ | `sudo apt install chromium-browser` |
+
+If a skill fails, check if its binary has an ARM build. Many Go/Rust tools do; some don't.
+
+### 32-bit vs 64-bit
+
+**Always use 64-bit OS.** Node.js and many modern tools require it. Check with:
+
+```bash
+uname -m
+# Should show: aarch64 (64-bit) not armv7l (32-bit)
+```
+
+---
+
+## Recommended Model Setup
+
+Since the Pi is just the Gateway (models run in the cloud), use API-based models:
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "model": {
+ "primary": "anthropic/claude-sonnet-4-20250514",
+ "fallbacks": ["openai/gpt-4o-mini"]
+ }
+ }
+ }
+}
+```
+
+**Don't try to run local LLMs on a Pi** — even small models are too slow. Let Claude/GPT do the heavy lifting.
+
+---
+
+## Auto-Start on Boot
+
+The onboarding wizard sets this up, but to verify:
+
+```bash
+# Check service is enabled
+sudo systemctl is-enabled clawdbot
+
+# Enable if not
+sudo systemctl enable clawdbot
+
+# Start on boot
+sudo systemctl start clawdbot
+```
+
+---
+
+## Troubleshooting
+
+### Out of Memory (OOM)
+
+```bash
+# Check memory
+free -h
+
+# Add more swap (see Step 5)
+# Or reduce services running on the Pi
+```
+
+### Slow Performance
+
+- Use USB SSD instead of SD card
+- Disable unused services: `sudo systemctl disable cups bluetooth avahi-daemon`
+- Check CPU throttling: `vcgencmd get_throttled` (should return `0x0`)
+
+### Service Won't Start
+
+```bash
+# Check logs
+journalctl -u clawdbot --no-pager -n 100
+
+# Common fix: rebuild
+cd ~/clawdbot # if using hackable install
+npm run build
+sudo systemctl restart clawdbot
+```
+
+### ARM Binary Issues
+
+If a skill fails with "exec format error":
+1. Check if the binary has an ARM64 build
+2. Try building from source
+3. Or use a Docker container with ARM support
+
+### WiFi Drops
+
+For headless Pis on WiFi:
+
+```bash
+# Disable WiFi power management
+sudo iwconfig wlan0 power off
+
+# Make permanent
+echo 'wireless-power off' | sudo tee -a /etc/network/interfaces
+```
+
+---
+
+## Cost Comparison
+
+| Setup | One-Time Cost | Monthly Cost | Notes |
+|-------|---------------|--------------|-------|
+| **Pi 4 (2GB)** | ~$45 | $0 | + power (~$5/yr) |
+| **Pi 4 (4GB)** | ~$55 | $0 | Recommended |
+| **Pi 5 (4GB)** | ~$60 | $0 | Best performance |
+| **Pi 5 (8GB)** | ~$80 | $0 | Overkill but future-proof |
+| DigitalOcean | $0 | $6/mo | $72/year |
+| Hetzner | $0 | €3.79/mo | ~$50/year |
+
+**Break-even:** A Pi pays for itself in ~6-12 months vs cloud VPS.
+
+---
+
+## See Also
+
+- [Linux guide](/platforms/linux) — general Linux setup
+- [DigitalOcean guide](/platforms/digitalocean) — cloud alternative
+- [Hetzner guide](/platforms/hetzner) — Docker setup
+- [Tailscale](/gateway/tailscale) — remote access
+- [Nodes](/nodes) — pair your laptop/phone with the Pi gateway
From 9ba142e8a5ac5dcb22577a25a47dd395f26031ab Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 22:34:09 -0600
Subject: [PATCH 045/196] Docs: add GCP Compute Engine deployment guide (#1848)
Co-authored-by: hougangdev
---
CHANGELOG.md | 1 +
docs/docs.json | 9 +
docs/platforms/gcp.md | 498 ++++++++++++++++++++++++++++++++++++++++
docs/platforms/index.md | 1 +
docs/vps.md | 1 +
5 files changed, 510 insertions(+)
create mode 100644 docs/platforms/gcp.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 27fa13f18..35f3ad89c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,7 @@ Status: unreleased.
- Docs: add Claude Max API Proxy guide. (#1875) Thanks @atalovesyou.
- Docs: add DigitalOcean deployment guide. (#1870) Thanks @0xJonHoldsCrypto.
- Docs: add Raspberry Pi install guide. (#1871) Thanks @0xJonHoldsCrypto.
+- Docs: add GCP Compute Engine deployment guide. (#1848) Thanks @hougangdev.
- Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD.
- Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub.
- Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a.
diff --git a/docs/docs.json b/docs/docs.json
index 983585bff..b0f0ee802 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -788,6 +788,14 @@
{
"source": "/install/railway/",
"destination": "/railway"
+ },
+ {
+ "source": "/gcp",
+ "destination": "/platforms/gcp"
+ },
+ {
+ "source": "/gcp/",
+ "destination": "/platforms/gcp"
}
],
"navigation": {
@@ -1057,6 +1065,7 @@
"platforms/linux",
"platforms/fly",
"platforms/hetzner",
+ "platforms/gcp",
"platforms/exe-dev"
]
},
diff --git a/docs/platforms/gcp.md b/docs/platforms/gcp.md
new file mode 100644
index 000000000..cffa03ace
--- /dev/null
+++ b/docs/platforms/gcp.md
@@ -0,0 +1,498 @@
+---
+summary: "Run Clawdbot Gateway 24/7 on a GCP Compute Engine VM (Docker) with durable state"
+read_when:
+ - You want Clawdbot running 24/7 on GCP
+ - You want a production-grade, always-on Gateway on your own VM
+ - You want full control over persistence, binaries, and restart behavior
+---
+
+# Clawdbot on GCP Compute Engine (Docker, Production VPS Guide)
+
+## Goal
+
+Run a persistent Clawdbot Gateway on a GCP Compute Engine VM using Docker, with durable state, baked-in binaries, and safe restart behavior.
+
+If you want "Clawdbot 24/7 for ~$5-12/mo", this is a reliable setup on Google Cloud.
+Pricing varies by machine type and region; pick the smallest VM that fits your workload and scale up if you hit OOMs.
+
+## What are we doing (simple terms)?
+
+- Create a GCP project and enable billing
+- Create a Compute Engine VM
+- Install Docker (isolated app runtime)
+- Start the Clawdbot Gateway in Docker
+- Persist `~/.clawdbot` + `~/clawd` on the host (survives restarts/rebuilds)
+- Access the Control UI from your laptop via an SSH tunnel
+
+The Gateway can be accessed via:
+- SSH port forwarding from your laptop
+- Direct port exposure if you manage firewalling and tokens yourself
+
+This guide uses Debian on GCP Compute Engine.
+Ubuntu also works; map packages accordingly.
+For the generic Docker flow, see [Docker](/install/docker).
+
+---
+
+## Quick path (experienced operators)
+
+1) Create GCP project + enable Compute Engine API
+2) Create Compute Engine VM (e2-small, Debian 12, 20GB)
+3) SSH into the VM
+4) Install Docker
+5) Clone Clawdbot repository
+6) Create persistent host directories
+7) Configure `.env` and `docker-compose.yml`
+8) Bake required binaries, build, and launch
+
+---
+
+## What you need
+
+- GCP account (free tier eligible for e2-micro)
+- gcloud CLI installed (or use Cloud Console)
+- SSH access from your laptop
+- Basic comfort with SSH + copy/paste
+- ~20-30 minutes
+- Docker and Docker Compose
+- Model auth credentials
+- Optional provider credentials
+ - WhatsApp QR
+ - Telegram bot token
+ - Gmail OAuth
+
+---
+
+## 1) Install gcloud CLI (or use Console)
+
+**Option A: gcloud CLI** (recommended for automation)
+
+Install from https://cloud.google.com/sdk/docs/install
+
+Initialize and authenticate:
+
+```bash
+gcloud init
+gcloud auth login
+```
+
+**Option B: Cloud Console**
+
+All steps can be done via the web UI at https://console.cloud.google.com
+
+---
+
+## 2) Create a GCP project
+
+**CLI:**
+
+```bash
+gcloud projects create my-clawdbot-project --name="Clawdbot Gateway"
+gcloud config set project my-clawdbot-project
+```
+
+Enable billing at https://console.cloud.google.com/billing (required for Compute Engine).
+
+Enable the Compute Engine API:
+
+```bash
+gcloud services enable compute.googleapis.com
+```
+
+**Console:**
+
+1. Go to IAM & Admin > Create Project
+2. Name it and create
+3. Enable billing for the project
+4. Navigate to APIs & Services > Enable APIs > search "Compute Engine API" > Enable
+
+---
+
+## 3) Create the VM
+
+**Machine types:**
+
+| Type | Specs | Cost | Notes |
+|------|-------|------|-------|
+| e2-small | 2 vCPU, 2GB RAM | ~$12/mo | Recommended |
+| e2-micro | 2 vCPU (shared), 1GB RAM | Free tier eligible | May OOM under load |
+
+**CLI:**
+
+```bash
+gcloud compute instances create clawdbot-gateway \
+ --zone=us-central1-a \
+ --machine-type=e2-small \
+ --boot-disk-size=20GB \
+ --image-family=debian-12 \
+ --image-project=debian-cloud
+```
+
+**Console:**
+
+1. Go to Compute Engine > VM instances > Create instance
+2. Name: `clawdbot-gateway`
+3. Region: `us-central1`, Zone: `us-central1-a`
+4. Machine type: `e2-small`
+5. Boot disk: Debian 12, 20GB
+6. Create
+
+---
+
+## 4) SSH into the VM
+
+**CLI:**
+
+```bash
+gcloud compute ssh clawdbot-gateway --zone=us-central1-a
+```
+
+**Console:**
+
+Click the "SSH" button next to your VM in the Compute Engine dashboard.
+
+Note: SSH key propagation can take 1-2 minutes after VM creation. If connection is refused, wait and retry.
+
+---
+
+## 5) Install Docker (on the VM)
+
+```bash
+sudo apt-get update
+sudo apt-get install -y git curl ca-certificates
+curl -fsSL https://get.docker.com | sudo sh
+sudo usermod -aG docker $USER
+```
+
+Log out and back in for the group change to take effect:
+
+```bash
+exit
+```
+
+Then SSH back in:
+
+```bash
+gcloud compute ssh clawdbot-gateway --zone=us-central1-a
+```
+
+Verify:
+
+```bash
+docker --version
+docker compose version
+```
+
+---
+
+## 6) Clone the Clawdbot repository
+
+```bash
+git clone https://github.com/clawdbot/clawdbot.git
+cd clawdbot
+```
+
+This guide assumes you will build a custom image to guarantee binary persistence.
+
+---
+
+## 7) Create persistent host directories
+
+Docker containers are ephemeral.
+All long-lived state must live on the host.
+
+```bash
+mkdir -p ~/.clawdbot
+mkdir -p ~/clawd
+```
+
+---
+
+## 8) Configure environment variables
+
+Create `.env` in the repository root.
+
+```bash
+CLAWDBOT_IMAGE=clawdbot:latest
+CLAWDBOT_GATEWAY_TOKEN=change-me-now
+CLAWDBOT_GATEWAY_BIND=lan
+CLAWDBOT_GATEWAY_PORT=18789
+
+CLAWDBOT_CONFIG_DIR=/home/$USER/.clawdbot
+CLAWDBOT_WORKSPACE_DIR=/home/$USER/clawd
+
+GOG_KEYRING_PASSWORD=change-me-now
+XDG_CONFIG_HOME=/home/node/.clawdbot
+```
+
+Generate strong secrets:
+
+```bash
+openssl rand -hex 32
+```
+
+**Do not commit this file.**
+
+---
+
+## 9) Docker Compose configuration
+
+Create or update `docker-compose.yml`.
+
+```yaml
+services:
+ clawdbot-gateway:
+ image: ${CLAWDBOT_IMAGE}
+ build: .
+ restart: unless-stopped
+ env_file:
+ - .env
+ environment:
+ - HOME=/home/node
+ - NODE_ENV=production
+ - TERM=xterm-256color
+ - CLAWDBOT_GATEWAY_BIND=${CLAWDBOT_GATEWAY_BIND}
+ - CLAWDBOT_GATEWAY_PORT=${CLAWDBOT_GATEWAY_PORT}
+ - CLAWDBOT_GATEWAY_TOKEN=${CLAWDBOT_GATEWAY_TOKEN}
+ - GOG_KEYRING_PASSWORD=${GOG_KEYRING_PASSWORD}
+ - XDG_CONFIG_HOME=${XDG_CONFIG_HOME}
+ - PATH=/home/linuxbrew/.linuxbrew/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+ volumes:
+ - ${CLAWDBOT_CONFIG_DIR}:/home/node/.clawdbot
+ - ${CLAWDBOT_WORKSPACE_DIR}:/home/node/clawd
+ ports:
+ # Recommended: keep the Gateway loopback-only on the VM; access via SSH tunnel.
+ # To expose it publicly, remove the `127.0.0.1:` prefix and firewall accordingly.
+ - "127.0.0.1:${CLAWDBOT_GATEWAY_PORT}:18789"
+
+ # Optional: only if you run iOS/Android nodes against this VM and need Canvas host.
+ # If you expose this publicly, read /gateway/security and firewall accordingly.
+ # - "18793:18793"
+ command:
+ [
+ "node",
+ "dist/index.js",
+ "gateway",
+ "--bind",
+ "${CLAWDBOT_GATEWAY_BIND}",
+ "--port",
+ "${CLAWDBOT_GATEWAY_PORT}"
+ ]
+```
+
+---
+
+## 10) Bake required binaries into the image (critical)
+
+Installing binaries inside a running container is a trap.
+Anything installed at runtime will be lost on restart.
+
+All external binaries required by skills must be installed at image build time.
+
+The examples below show three common binaries only:
+- `gog` for Gmail access
+- `goplaces` for Google Places
+- `wacli` for WhatsApp
+
+These are examples, not a complete list.
+You may install as many binaries as needed using the same pattern.
+
+If you add new skills later that depend on additional binaries, you must:
+1. Update the Dockerfile
+2. Rebuild the image
+3. Restart the containers
+
+**Example Dockerfile**
+
+```dockerfile
+FROM node:22-bookworm
+
+RUN apt-get update && apt-get install -y socat && rm -rf /var/lib/apt/lists/*
+
+# Example binary 1: Gmail CLI
+RUN curl -L https://github.com/steipete/gog/releases/latest/download/gog_Linux_x86_64.tar.gz \
+ | tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/gog
+
+# Example binary 2: Google Places CLI
+RUN curl -L https://github.com/steipete/goplaces/releases/latest/download/goplaces_Linux_x86_64.tar.gz \
+ | tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/goplaces
+
+# Example binary 3: WhatsApp CLI
+RUN curl -L https://github.com/steipete/wacli/releases/latest/download/wacli_Linux_x86_64.tar.gz \
+ | tar -xz -C /usr/local/bin && chmod +x /usr/local/bin/wacli
+
+# Add more binaries below using the same pattern
+
+WORKDIR /app
+COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
+COPY ui/package.json ./ui/package.json
+COPY scripts ./scripts
+
+RUN corepack enable
+RUN pnpm install --frozen-lockfile
+
+COPY . .
+RUN pnpm build
+RUN pnpm ui:install
+RUN pnpm ui:build
+
+ENV NODE_ENV=production
+
+CMD ["node","dist/index.js"]
+```
+
+---
+
+## 11) Build and launch
+
+```bash
+docker compose build
+docker compose up -d clawdbot-gateway
+```
+
+Verify binaries:
+
+```bash
+docker compose exec clawdbot-gateway which gog
+docker compose exec clawdbot-gateway which goplaces
+docker compose exec clawdbot-gateway which wacli
+```
+
+Expected output:
+
+```
+/usr/local/bin/gog
+/usr/local/bin/goplaces
+/usr/local/bin/wacli
+```
+
+---
+
+## 12) Verify Gateway
+
+```bash
+docker compose logs -f clawdbot-gateway
+```
+
+Success:
+
+```
+[gateway] listening on ws://0.0.0.0:18789
+```
+
+---
+
+## 13) Access from your laptop
+
+Create an SSH tunnel to forward the Gateway port:
+
+```bash
+gcloud compute ssh clawdbot-gateway --zone=us-central1-a -- -L 18789:127.0.0.1:18789
+```
+
+Open in your browser:
+
+`http://127.0.0.1:18789/`
+
+Paste your gateway token.
+
+---
+
+## What persists where (source of truth)
+
+Clawdbot runs in Docker, but Docker is not the source of truth.
+All long-lived state must survive restarts, rebuilds, and reboots.
+
+| Component | Location | Persistence mechanism | Notes |
+|---|---|---|---|
+| Gateway config | `/home/node/.clawdbot/` | Host volume mount | Includes `clawdbot.json`, tokens |
+| Model auth profiles | `/home/node/.clawdbot/` | Host volume mount | OAuth tokens, API keys |
+| Skill configs | `/home/node/.clawdbot/skills/` | Host volume mount | Skill-level state |
+| Agent workspace | `/home/node/clawd/` | Host volume mount | Code and agent artifacts |
+| WhatsApp session | `/home/node/.clawdbot/` | Host volume mount | Preserves QR login |
+| Gmail keyring | `/home/node/.clawdbot/` | Host volume + password | Requires `GOG_KEYRING_PASSWORD` |
+| External binaries | `/usr/local/bin/` | Docker image | Must be baked at build time |
+| Node runtime | Container filesystem | Docker image | Rebuilt every image build |
+| OS packages | Container filesystem | Docker image | Do not install at runtime |
+| Docker container | Ephemeral | Restartable | Safe to destroy |
+
+---
+
+## Updates
+
+To update Clawdbot on the VM:
+
+```bash
+cd ~/clawdbot
+git pull
+docker compose build
+docker compose up -d
+```
+
+---
+
+## Troubleshooting
+
+**SSH connection refused**
+
+SSH key propagation can take 1-2 minutes after VM creation. Wait and retry.
+
+**OS Login issues**
+
+Check your OS Login profile:
+
+```bash
+gcloud compute os-login describe-profile
+```
+
+Ensure your account has the required IAM permissions (Compute OS Login or Compute OS Admin Login).
+
+**Out of memory (OOM)**
+
+If using e2-micro and hitting OOM, upgrade to e2-small or e2-medium:
+
+```bash
+# Stop the VM first
+gcloud compute instances stop clawdbot-gateway --zone=us-central1-a
+
+# Change machine type
+gcloud compute instances set-machine-type clawdbot-gateway \
+ --zone=us-central1-a \
+ --machine-type=e2-small
+
+# Start the VM
+gcloud compute instances start clawdbot-gateway --zone=us-central1-a
+```
+
+---
+
+## Service accounts (security best practice)
+
+For personal use, your default user account works fine.
+
+For automation or CI/CD pipelines, create a dedicated service account with minimal permissions:
+
+1. Create a service account:
+ ```bash
+ gcloud iam service-accounts create clawdbot-deploy \
+ --display-name="Clawdbot Deployment"
+ ```
+
+2. Grant Compute Instance Admin role (or narrower custom role):
+ ```bash
+ gcloud projects add-iam-policy-binding my-clawdbot-project \
+ --member="serviceAccount:clawdbot-deploy@my-clawdbot-project.iam.gserviceaccount.com" \
+ --role="roles/compute.instanceAdmin.v1"
+ ```
+
+Avoid using the Owner role for automation. Use the principle of least privilege.
+
+See https://cloud.google.com/iam/docs/understanding-roles for IAM role details.
+
+---
+
+## Next steps
+
+- Set up messaging channels: [Channels](/channels)
+- Pair local devices as nodes: [Nodes](/nodes)
+- Configure the Gateway: [Gateway configuration](/gateway/configuration)
diff --git a/docs/platforms/index.md b/docs/platforms/index.md
index 1b5c85129..d53073026 100644
--- a/docs/platforms/index.md
+++ b/docs/platforms/index.md
@@ -27,6 +27,7 @@ Native companion apps for Windows are also planned; the Gateway is recommended v
- Railway (one-click): [Railway](/railway)
- Fly.io: [Fly.io](/platforms/fly)
- Hetzner (Docker): [Hetzner](/platforms/hetzner)
+- GCP (Compute Engine): [GCP](/platforms/gcp)
- exe.dev (VM + HTTPS proxy): [exe.dev](/platforms/exe-dev)
## Common links
diff --git a/docs/vps.md b/docs/vps.md
index a6d267513..23e88255b 100644
--- a/docs/vps.md
+++ b/docs/vps.md
@@ -14,6 +14,7 @@ deployments work at a high level.
- **Railway** (one‑click + browser setup): [Railway](/railway)
- **Fly.io**: [Fly.io](/platforms/fly)
- **Hetzner (Docker)**: [Hetzner](/platforms/hetzner)
+- **GCP (Compute Engine)**: [GCP](/platforms/gcp)
- **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
From e040f6338a1c7e88b2c85d74d7daa153a4910206 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 22:38:04 -0600
Subject: [PATCH 046/196] Docs: update clawtributors list
---
README.md | 8 ++++----
scripts/clawtributors-map.json | 5 ++++-
2 files changed, 8 insertions(+), 5 deletions(-)
diff --git a/README.md b/README.md
index 47f3a9090..217a4b61c 100644
--- a/README.md
+++ b/README.md
@@ -484,7 +484,7 @@ Thanks to all clawtributors:
-
+
@@ -504,7 +504,7 @@ Thanks to all clawtributors:
-
-
-
+
+
+
diff --git a/scripts/clawtributors-map.json b/scripts/clawtributors-map.json
index 8899afc93..d652938a6 100644
--- a/scripts/clawtributors-map.json
+++ b/scripts/clawtributors-map.json
@@ -12,7 +12,10 @@
"manmal",
"thesash",
"rhjoh",
- "ysqander"
+ "ysqander",
+ "atalovesyou",
+ "0xJonHoldsCrypto",
+ "hougangdev"
],
"seedCommit": "d6863f87",
"placeholderAvatar": "assets/avatar-placeholder.svg",
From 34ce004151c5a03d4beab5cfeddb36cce8373165 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 22:40:00 -0600
Subject: [PATCH 047/196] Gateway: prefer newest session entries in merge
(#1823)
---
CHANGELOG.md | 1 +
src/gateway/session-utils.ts | 48 +++++++++++++++++++++++++++---------
2 files changed, 38 insertions(+), 11 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 35f3ad89c..262c69057 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,7 @@ Status: unreleased.
- Docs: credit both contributors for Control UI refresh. (#1852) Thanks @EnzeD.
- Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub.
- Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a.
+- Gateway: prefer newest session metadata when combining stores. (#1823) Thanks @emanuelst.
- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi.
- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn.
- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.
diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts
index c4046a08e..1cb4cc5c3 100644
--- a/src/gateway/session-utils.ts
+++ b/src/gateway/session-utils.ts
@@ -381,6 +381,31 @@ export function resolveGatewaySessionStoreTarget(params: { cfg: ClawdbotConfig;
};
}
+// Merge with existing entry based on latest timestamp to ensure data consistency and avoid overwriting with less complete data.
+function mergeSessionEntryIntoCombined(params: {
+ combined: Record;
+ entry: SessionEntry;
+ agentId: string;
+ canonicalKey: string;
+}) {
+ const { combined, entry, agentId, canonicalKey } = params;
+ const existing = combined[canonicalKey];
+
+ if (existing && (existing.updatedAt ?? 0) > (entry.updatedAt ?? 0)) {
+ combined[canonicalKey] = {
+ ...entry,
+ ...existing,
+ spawnedBy: canonicalizeSpawnedByForAgent(agentId, existing.spawnedBy ?? entry.spawnedBy),
+ };
+ } else {
+ combined[canonicalKey] = {
+ ...existing,
+ ...entry,
+ spawnedBy: canonicalizeSpawnedByForAgent(agentId, entry.spawnedBy ?? existing?.spawnedBy),
+ };
+ }
+}
+
export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): {
storePath: string;
store: Record;
@@ -393,10 +418,12 @@ export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): {
const combined: Record = {};
for (const [key, entry] of Object.entries(store)) {
const canonicalKey = canonicalizeSessionKeyForAgent(defaultAgentId, key);
- combined[canonicalKey] = {
- ...entry,
- spawnedBy: canonicalizeSpawnedByForAgent(defaultAgentId, entry.spawnedBy),
- };
+ mergeSessionEntryIntoCombined({
+ combined,
+ entry,
+ agentId: defaultAgentId,
+ canonicalKey,
+ });
}
return { storePath, store: combined };
}
@@ -408,13 +435,12 @@ export function loadCombinedSessionStoreForGateway(cfg: ClawdbotConfig): {
const store = loadSessionStore(storePath);
for (const [key, entry] of Object.entries(store)) {
const canonicalKey = canonicalizeSessionKeyForAgent(agentId, key);
- // Merge with existing entry if present (avoid overwriting with less complete data)
- const existing = combined[canonicalKey];
- combined[canonicalKey] = {
- ...existing,
- ...entry,
- spawnedBy: canonicalizeSpawnedByForAgent(agentId, entry.spawnedBy ?? existing?.spawnedBy),
- };
+ mergeSessionEntryIntoCombined({
+ combined,
+ entry,
+ agentId,
+ canonicalKey,
+ });
}
}
From 08183fe0090d692725b9eac3fb38e09dc4c88e44 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Sun, 25 Jan 2026 22:49:09 -0600
Subject: [PATCH 048/196] Web UI: keep sub-agent announce replies visible
(#1977)
---
CHANGELOG.md | 1 +
ui/src/ui/controllers/chat.test.ts | 99 ++++++++++++++++++++++++++++++
ui/src/ui/controllers/chat.ts | 13 +++-
3 files changed, 111 insertions(+), 2 deletions(-)
create mode 100644 ui/src/ui/controllers/chat.test.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 262c69057..21a066ff7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ Status: unreleased.
- Onboarding: add Venice API key to non-interactive flow. (#1893) Thanks @jonisjongithub.
- Tlon: format thread reply IDs as @ud. (#1837) Thanks @wca4a.
- Gateway: prefer newest session metadata when combining stores. (#1823) Thanks @emanuelst.
+- Web UI: keep sub-agent announce replies visible in WebChat. (#1977) Thanks @andrescardonas7.
- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi.
- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn.
- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.
diff --git a/ui/src/ui/controllers/chat.test.ts b/ui/src/ui/controllers/chat.test.ts
new file mode 100644
index 000000000..c75ceefc4
--- /dev/null
+++ b/ui/src/ui/controllers/chat.test.ts
@@ -0,0 +1,99 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ handleChatEvent,
+ type ChatEventPayload,
+ type ChatState,
+} from "./chat";
+
+function createState(overrides: Partial = {}): ChatState {
+ return {
+ client: null,
+ connected: true,
+ sessionKey: "main",
+ chatLoading: false,
+ chatMessages: [],
+ chatThinkingLevel: null,
+ chatSending: false,
+ chatMessage: "",
+ chatRunId: null,
+ chatStream: null,
+ chatStreamStartedAt: null,
+ lastError: null,
+ ...overrides,
+ };
+}
+
+describe("handleChatEvent", () => {
+ it("returns null when payload is missing", () => {
+ const state = createState();
+ expect(handleChatEvent(state, undefined)).toBe(null);
+ });
+
+ it("returns null when sessionKey does not match", () => {
+ const state = createState({ sessionKey: "main" });
+ const payload: ChatEventPayload = {
+ runId: "run-1",
+ sessionKey: "other",
+ state: "final",
+ };
+ expect(handleChatEvent(state, payload)).toBe(null);
+ });
+
+ it("returns null for delta from another run", () => {
+ const state = createState({
+ sessionKey: "main",
+ chatRunId: "run-user",
+ chatStream: "Hello",
+ });
+ const payload: ChatEventPayload = {
+ runId: "run-announce",
+ sessionKey: "main",
+ state: "delta",
+ message: { role: "assistant", content: [{ type: "text", text: "Done" }] },
+ };
+ expect(handleChatEvent(state, payload)).toBe(null);
+ expect(state.chatRunId).toBe("run-user");
+ expect(state.chatStream).toBe("Hello");
+ });
+
+ it("returns 'final' for final from another run (e.g. sub-agent announce) without clearing state", () => {
+ const state = createState({
+ sessionKey: "main",
+ chatRunId: "run-user",
+ chatStream: "Working...",
+ chatStreamStartedAt: 123,
+ });
+ const payload: ChatEventPayload = {
+ runId: "run-announce",
+ sessionKey: "main",
+ state: "final",
+ message: {
+ role: "assistant",
+ content: [{ type: "text", text: "Sub-agent findings" }],
+ },
+ };
+ expect(handleChatEvent(state, payload)).toBe("final");
+ expect(state.chatRunId).toBe("run-user");
+ expect(state.chatStream).toBe("Working...");
+ expect(state.chatStreamStartedAt).toBe(123);
+ });
+
+ it("processes final from own run and clears state", () => {
+ const state = createState({
+ sessionKey: "main",
+ chatRunId: "run-1",
+ chatStream: "Reply",
+ chatStreamStartedAt: 100,
+ });
+ const payload: ChatEventPayload = {
+ runId: "run-1",
+ sessionKey: "main",
+ state: "final",
+ };
+ expect(handleChatEvent(state, payload)).toBe("final");
+ expect(state.chatRunId).toBe(null);
+ expect(state.chatStream).toBe(null);
+ expect(state.chatStreamStartedAt).toBe(null);
+ });
+});
diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts
index 53027c6ea..3d967f672 100644
--- a/ui/src/ui/controllers/chat.ts
+++ b/ui/src/ui/controllers/chat.ts
@@ -1,5 +1,5 @@
-import type { GatewayBrowserClient } from "../gateway";
import { extractText } from "../chat/message-extract";
+import type { GatewayBrowserClient } from "../gateway";
import { generateUUID } from "../uuid";
export type ChatState = {
@@ -115,8 +115,17 @@ export function handleChatEvent(
) {
if (!payload) return null;
if (payload.sessionKey !== state.sessionKey) return null;
- if (payload.runId && state.chatRunId && payload.runId !== state.chatRunId)
+
+ // Final from another run (e.g. sub-agent announce): refresh history to show new message.
+ // See https://github.com/clawdbot/clawdbot/issues/1909
+ if (
+ payload.runId &&
+ state.chatRunId &&
+ payload.runId !== state.chatRunId
+ ) {
+ if (payload.state === "final") return "final";
return null;
+ }
if (payload.state === "delta") {
const next = extractText(payload.message);
From fabdf2f6f749a43e1f0b4be4f0da2557c74bdd52 Mon Sep 17 00:00:00 2001
From: joeynyc
Date: Sun, 25 Jan 2026 13:45:09 -0500
Subject: [PATCH 049/196] feat(webchat): add image paste support
- Add paste event handler to chat textarea to capture clipboard images
- Add image preview UI with thumbnails and remove buttons
- Update sendChatMessage to pass attachments to chat.send RPC
- Add CSS styles for attachment preview (light/dark theme support)
Closes #1681 (image paste support portion)
Co-Authored-By: Claude Opus 4.5
---
ui/src/styles/chat/layout.css | 88 ++++++++++++++++++-
ui/src/ui/app-chat.ts | 16 +++-
ui/src/ui/app-render.ts | 2 +
ui/src/ui/app.ts | 1 +
ui/src/ui/controllers/chat.ts | 57 +++++++++++-
ui/src/ui/views/chat.ts | 160 ++++++++++++++++++++++++++--------
6 files changed, 282 insertions(+), 42 deletions(-)
diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css
index e137cb8c8..951266a98 100644
--- a/ui/src/styles/chat/layout.css
+++ b/ui/src/styles/chat/layout.css
@@ -103,7 +103,7 @@
bottom: 0;
flex-shrink: 0;
display: flex;
- align-items: stretch;
+ flex-direction: column;
gap: 12px;
margin-top: auto; /* Push to bottom of flex container */
padding: 12px 4px 4px;
@@ -111,6 +111,92 @@
z-index: 10;
}
+/* Image attachments preview */
+.chat-attachments {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 8px;
+ background: var(--panel);
+ border-radius: 8px;
+ border: 1px solid var(--border);
+}
+
+.chat-attachment {
+ position: relative;
+ width: 80px;
+ height: 80px;
+ border-radius: 6px;
+ overflow: hidden;
+ border: 1px solid var(--border);
+ background: var(--bg);
+}
+
+.chat-attachment__img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.chat-attachment__remove {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ border: none;
+ background: rgba(0, 0, 0, 0.7);
+ color: #fff;
+ font-size: 12px;
+ line-height: 1;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transition: opacity 150ms ease-out;
+}
+
+.chat-attachment:hover .chat-attachment__remove {
+ opacity: 1;
+}
+
+.chat-attachment__remove:hover {
+ background: rgba(220, 38, 38, 0.9);
+}
+
+.chat-attachment__remove svg {
+ width: 12px;
+ height: 12px;
+ stroke: currentColor;
+ fill: none;
+ stroke-width: 2px;
+}
+
+/* Light theme attachment overrides */
+:root[data-theme="light"] .chat-attachments {
+ background: #f8fafc;
+ border-color: rgba(16, 24, 40, 0.1);
+}
+
+:root[data-theme="light"] .chat-attachment {
+ border-color: rgba(16, 24, 40, 0.15);
+ background: #fff;
+}
+
+:root[data-theme="light"] .chat-attachment__remove {
+ background: rgba(0, 0, 0, 0.6);
+}
+
+/* Compose input row - horizontal layout */
+.chat-compose__row {
+ display: flex;
+ align-items: stretch;
+ gap: 12px;
+ flex: 1;
+}
+
:root[data-theme="light"] .chat-compose {
background: linear-gradient(to bottom, transparent, var(--bg-content) 20%);
}
diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts
index 81aae3c88..3ff74935d 100644
--- a/ui/src/ui/app-chat.ts
+++ b/ui/src/ui/app-chat.ts
@@ -1,4 +1,4 @@
-import { abortChatRun, loadChatHistory, sendChatMessage } from "./controllers/chat";
+import { abortChatRun, loadChatHistory, sendChatMessage, type ChatAttachment } from "./controllers/chat";
import { loadSessions } from "./controllers/sessions";
import { generateUUID } from "./uuid";
import { resetToolStream } from "./app-tool-stream";
@@ -12,6 +12,7 @@ import type { ClawdbotApp } from "./app";
type ChatHost = {
connected: boolean;
chatMessage: string;
+ chatAttachments: ChatAttachment[];
chatQueue: Array<{ id: string; text: string; createdAt: number }>;
chatRunId: string | null;
chatSending: boolean;
@@ -61,10 +62,10 @@ function enqueueChatMessage(host: ChatHost, text: string) {
async function sendChatMessageNow(
host: ChatHost,
message: string,
- opts?: { previousDraft?: string; restoreDraft?: boolean },
+ opts?: { previousDraft?: string; restoreDraft?: boolean; attachments?: ChatAttachment[] },
) {
resetToolStream(host as unknown as Parameters[0]);
- const ok = await sendChatMessage(host as unknown as ClawdbotApp, message);
+ const ok = await sendChatMessage(host as unknown as ClawdbotApp, message, opts?.attachments);
if (!ok && opts?.previousDraft != null) {
host.chatMessage = opts.previousDraft;
}
@@ -104,7 +105,11 @@ export async function handleSendChat(
if (!host.connected) return;
const previousDraft = host.chatMessage;
const message = (messageOverride ?? host.chatMessage).trim();
- if (!message) return;
+ const attachments = host.chatAttachments ?? [];
+ const hasAttachments = attachments.length > 0;
+
+ // Allow sending with just attachments (no message text required)
+ if (!message && !hasAttachments) return;
if (isChatStopCommand(message)) {
await handleAbortChat(host);
@@ -113,6 +118,8 @@ export async function handleSendChat(
if (messageOverride == null) {
host.chatMessage = "";
+ // Clear attachments when sending
+ host.chatAttachments = [];
}
if (isChatBusy(host)) {
@@ -123,6 +130,7 @@ export async function handleSendChat(
await sendChatMessageNow(host, message, {
previousDraft: messageOverride == null ? previousDraft : undefined,
restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
+ attachments: hasAttachments ? attachments : undefined,
});
}
diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts
index db29bd7ec..38b16b084 100644
--- a/ui/src/ui/app-render.ts
+++ b/ui/src/ui/app-render.ts
@@ -477,6 +477,8 @@ export function renderApp(state: AppViewState) {
},
onChatScroll: (event) => state.handleChatScroll(event),
onDraftChange: (next) => (state.chatMessage = next),
+ attachments: state.chatAttachments,
+ onAttachmentsChange: (next) => (state.chatAttachments = next),
onSend: () => state.handleSendChat(),
canAbort: Boolean(state.chatRunId),
onAbort: () => void state.handleAbortChat(),
diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts
index 0e21d283a..310305ff9 100644
--- a/ui/src/ui/app.ts
+++ b/ui/src/ui/app.ts
@@ -129,6 +129,7 @@ export class ClawdbotApp extends LitElement {
@state() chatAvatarUrl: string | null = null;
@state() chatThinkingLevel: string | null = null;
@state() chatQueue: ChatQueueItem[] = [];
+ @state() chatAttachments: Array<{ id: string; dataUrl: string; mimeType: string }> = [];
// Sidebar state for tool output viewing
@state() sidebarOpen = false;
@state() sidebarContent: string | null = null;
diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts
index 3d967f672..644d49358 100644
--- a/ui/src/ui/controllers/chat.ts
+++ b/ui/src/ui/controllers/chat.ts
@@ -2,6 +2,12 @@ import { extractText } from "../chat/message-extract";
import type { GatewayBrowserClient } from "../gateway";
import { generateUUID } from "../uuid";
+export type ChatAttachment = {
+ id: string;
+ dataUrl: string;
+ mimeType: string;
+};
+
export type ChatState = {
client: GatewayBrowserClient | null;
connected: boolean;
@@ -11,6 +17,7 @@ export type ChatState = {
chatThinkingLevel: string | null;
chatSending: boolean;
chatMessage: string;
+ chatAttachments: ChatAttachment[];
chatRunId: string | null;
chatStream: string | null;
chatStreamStartedAt: number | null;
@@ -43,17 +50,44 @@ export async function loadChatHistory(state: ChatState) {
}
}
-export async function sendChatMessage(state: ChatState, message: string): Promise {
+function dataUrlToBase64(dataUrl: string): { content: string; mimeType: string } | null {
+ const match = /^data:([^;]+);base64,(.+)$/.exec(dataUrl);
+ if (!match) return null;
+ return { mimeType: match[1], content: match[2] };
+}
+
+export async function sendChatMessage(
+ state: ChatState,
+ message: string,
+ attachments?: ChatAttachment[],
+): Promise {
if (!state.client || !state.connected) return false;
const msg = message.trim();
- if (!msg) return false;
+ const hasAttachments = attachments && attachments.length > 0;
+ if (!msg && !hasAttachments) return false;
const now = Date.now();
+
+ // Build user message content blocks
+ const contentBlocks: Array<{ type: string; text?: string; source?: unknown }> = [];
+ if (msg) {
+ contentBlocks.push({ type: "text", text: msg });
+ }
+ // Add image previews to the message for display
+ if (hasAttachments) {
+ for (const att of attachments) {
+ contentBlocks.push({
+ type: "image",
+ source: { type: "base64", media_type: att.mimeType, data: att.dataUrl },
+ });
+ }
+ }
+
state.chatMessages = [
...state.chatMessages,
{
role: "user",
- content: [{ type: "text", text: msg }],
+ content: contentBlocks,
timestamp: now,
},
];
@@ -64,12 +98,29 @@ export async function sendChatMessage(state: ChatState, message: string): Promis
state.chatRunId = runId;
state.chatStream = "";
state.chatStreamStartedAt = now;
+
+ // Convert attachments to API format
+ const apiAttachments = hasAttachments
+ ? attachments
+ .map((att) => {
+ const parsed = dataUrlToBase64(att.dataUrl);
+ if (!parsed) return null;
+ return {
+ type: "image",
+ mimeType: parsed.mimeType,
+ content: parsed.content,
+ };
+ })
+ .filter((a): a is NonNullable => a !== null)
+ : undefined;
+
try {
await state.client.request("chat.send", {
sessionKey: state.sessionKey,
message: msg,
deliver: false,
idempotencyKey: runId,
+ attachments: apiAttachments,
});
return true;
} catch (err) {
diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts
index dd61ca0ec..17fc8401f 100644
--- a/ui/src/ui/views/chat.ts
+++ b/ui/src/ui/views/chat.ts
@@ -22,6 +22,12 @@ export type CompactionIndicatorStatus = {
completedAt: number | null;
};
+export type ChatAttachment = {
+ id: string;
+ dataUrl: string;
+ mimeType: string;
+};
+
export type ChatProps = {
sessionKey: string;
onSessionKeyChange: (next: string) => void;
@@ -52,6 +58,9 @@ export type ChatProps = {
splitRatio?: number;
assistantName: string;
assistantAvatar: string | null;
+ // Image attachments
+ attachments?: ChatAttachment[];
+ onAttachmentsChange?: (attachments: ChatAttachment[]) => void;
// Event handlers
onRefresh: () => void;
onToggleFocusMode: () => void;
@@ -95,6 +104,82 @@ function renderCompactionIndicator(status: CompactionIndicatorStatus | null | un
return nothing;
}
+function generateAttachmentId(): string {
+ return `att-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
+}
+
+function handlePaste(
+ e: ClipboardEvent,
+ props: ChatProps,
+) {
+ const items = e.clipboardData?.items;
+ if (!items || !props.onAttachmentsChange) return;
+
+ const imageItems: DataTransferItem[] = [];
+ for (let i = 0; i < items.length; i++) {
+ const item = items[i];
+ if (item.type.startsWith("image/")) {
+ imageItems.push(item);
+ }
+ }
+
+ if (imageItems.length === 0) return;
+
+ e.preventDefault();
+
+ for (const item of imageItems) {
+ const file = item.getAsFile();
+ if (!file) continue;
+
+ const reader = new FileReader();
+ reader.onload = () => {
+ const dataUrl = reader.result as string;
+ const newAttachment: ChatAttachment = {
+ id: generateAttachmentId(),
+ dataUrl,
+ mimeType: file.type,
+ };
+ const current = props.attachments ?? [];
+ props.onAttachmentsChange?.([...current, newAttachment]);
+ };
+ reader.readAsDataURL(file);
+ }
+}
+
+function renderAttachmentPreview(props: ChatProps) {
+ const attachments = props.attachments ?? [];
+ if (attachments.length === 0) return nothing;
+
+ return html`
+
+ ${attachments.map(
+ (att) => html`
+
+

+
+
+ `,
+ )}
+
+ `;
+}
+
export function renderChat(props: ChatProps) {
const canCompose = props.connected;
const isBusy = props.sending || props.stream !== null;
@@ -109,8 +194,11 @@ export function renderChat(props: ChatProps) {
avatar: props.assistantAvatar ?? props.assistantAvatarUrl ?? null,
};
+ const hasAttachments = (props.attachments?.length ?? 0) > 0;
const composePlaceholder = props.connected
- ? "Message (↩ to send, Shift+↩ for line breaks)"
+ ? hasAttachments
+ ? "Add a message or paste more images..."
+ : "Message (↩ to send, Shift+↩ for line breaks, paste images)"
: "Connect to the gateway to start chatting…";
const splitRatio = props.splitRatio ?? 0.6;
@@ -235,39 +323,43 @@ export function renderChat(props: ChatProps) {
: nothing}
-
-
-
-
+ ${renderAttachmentPreview(props)}
+
+
+
+
+
+
From 9ba4b1e32b5a4f50b2c9b2294d6ba21d66e38bb7 Mon Sep 17 00:00:00 2001
From: Clawd
Date: Sun, 25 Jan 2026 22:46:09 +0300
Subject: [PATCH 050/196] fix(webchat): improve image paste UI layout and
display
- Fix preview container width (use inline-flex + fit-content)
- Fix flex layout conflict in components.css (grid -> flex column)
- Change preview thumbnail to object-fit: contain (no cropping)
- Add image rendering in sent message bubbles
- Add CSS for chat-message-images display
Improves upon #1900
---
ui/src/styles/chat/layout.css | 33 +++++++++++++++-
ui/src/styles/components.css | 5 +--
ui/src/ui/chat/grouped-render.ts | 66 +++++++++++++++++++++++++++++++-
3 files changed, 98 insertions(+), 6 deletions(-)
diff --git a/ui/src/styles/chat/layout.css b/ui/src/styles/chat/layout.css
index 951266a98..e11fedb71 100644
--- a/ui/src/styles/chat/layout.css
+++ b/ui/src/styles/chat/layout.css
@@ -113,13 +113,16 @@
/* Image attachments preview */
.chat-attachments {
- display: flex;
+ display: inline-flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px;
background: var(--panel);
border-radius: 8px;
border: 1px solid var(--border);
+ width: fit-content;
+ max-width: 100%;
+ align-self: flex-start; /* Don't stretch in flex column parent */
}
.chat-attachment {
@@ -135,7 +138,7 @@
.chat-attachment__img {
width: 100%;
height: 100%;
- object-fit: cover;
+ object-fit: contain;
}
.chat-attachment__remove {
@@ -189,6 +192,32 @@
background: rgba(0, 0, 0, 0.6);
}
+/* Message images (sent images displayed in chat) */
+.chat-message-images {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 8px;
+}
+
+.chat-message-image {
+ max-width: 300px;
+ max-height: 200px;
+ border-radius: 8px;
+ object-fit: contain;
+ cursor: pointer;
+ transition: transform 150ms ease-out;
+}
+
+.chat-message-image:hover {
+ transform: scale(1.02);
+}
+
+/* User message images align right */
+.chat-group.user .chat-message-images {
+ justify-content: flex-end;
+}
+
/* Compose input row - horizontal layout */
.chat-compose__row {
display: flex;
diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css
index a78e0ef0a..27dfe62d1 100644
--- a/ui/src/styles/components.css
+++ b/ui/src/styles/components.css
@@ -1303,9 +1303,8 @@
/* Chat compose */
.chat-compose {
margin-top: 12px;
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- align-items: end;
+ display: flex;
+ flex-direction: column;
gap: 10px;
}
diff --git a/ui/src/ui/chat/grouped-render.ts b/ui/src/ui/chat/grouped-render.ts
index ea1c7ffda..4a9ccec14 100644
--- a/ui/src/ui/chat/grouped-render.ts
+++ b/ui/src/ui/chat/grouped-render.ts
@@ -13,6 +13,48 @@ import {
} from "./message-extract";
import { extractToolCards, renderToolCardSidebar } from "./tool-cards";
+type ImageBlock = {
+ url: string;
+ alt?: string;
+};
+
+function extractImages(message: unknown): ImageBlock[] {
+ const m = message as Record;
+ const content = m.content;
+ const images: ImageBlock[] = [];
+
+ if (Array.isArray(content)) {
+ for (const block of content) {
+ if (typeof block !== "object" || block === null) continue;
+ const b = block as Record;
+
+ if (b.type === "image") {
+ // Handle source object format (from sendChatMessage)
+ const source = b.source as Record | undefined;
+ if (source?.type === "base64" && typeof source.data === "string") {
+ const data = source.data as string;
+ const mediaType = (source.media_type as string) || "image/png";
+ // If data is already a data URL, use it directly
+ const url = data.startsWith("data:")
+ ? data
+ : `data:${mediaType};base64,${data}`;
+ images.push({ url });
+ } else if (typeof b.url === "string") {
+ images.push({ url: b.url });
+ }
+ } else if (b.type === "image_url") {
+ // OpenAI format
+ const imageUrl = b.image_url as Record | undefined;
+ if (typeof imageUrl?.url === "string") {
+ images.push({ url: imageUrl.url });
+ }
+ }
+ }
+ }
+
+ return images;
+}
+
export function renderReadingIndicatorGroup(assistant?: AssistantIdentity) {
return html`
@@ -163,6 +205,25 @@ function isAvatarUrl(value: string): boolean {
);
}
+function renderMessageImages(images: ImageBlock[]) {
+ if (images.length === 0) return nothing;
+
+ return html`
+
+ ${images.map(
+ (img) => html`
+

window.open(img.url, "_blank")}
+ />
+ `,
+ )}
+
+ `;
+}
+
function renderGroupedMessage(
message: unknown,
opts: { isStreaming: boolean; showReasoning: boolean },
@@ -179,6 +240,8 @@ function renderGroupedMessage(
const toolCards = extractToolCards(message);
const hasToolCards = toolCards.length > 0;
+ const images = extractImages(message);
+ const hasImages = images.length > 0;
const extractedText = extractTextCached(message);
const extractedThinking =
@@ -207,11 +270,12 @@ function renderGroupedMessage(
)}`;
}
- if (!markdown && !hasToolCards) return nothing;
+ if (!markdown && !hasToolCards && !hasImages) return nothing;
return html`
${canCopyMarkdown ? renderCopyAsMarkdownButton(markdown!) : nothing}
+ ${renderMessageImages(images)}
${reasoningMarkdown
? html`
${unsafeHTML(
toSanitizedMarkdownHtml(reasoningMarkdown),
From 6859e1e6a66691282f2394cd8f8ab2eef3e8c45d Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Mon, 26 Jan 2026 05:32:29 +0000
Subject: [PATCH 051/196] fix(webchat): support image-only sends
---
CHANGELOG.md | 3 ++
src/gateway/protocol/schema/logs-chat.ts | 2 +-
src/gateway/server-methods/chat.ts | 9 +++++
...erver.chat.gateway-server-chat.e2e.test.ts | 33 +++++++++++++++++
ui/src/ui/app-chat.ts | 36 ++++++++++++++-----
ui/src/ui/app-render.ts | 1 +
ui/src/ui/app-view-state.ts | 3 +-
ui/src/ui/app.ts | 4 +--
ui/src/ui/controllers/chat.ts | 7 +---
ui/src/ui/ui-types.ts | 7 ++++
ui/src/ui/views/chat.ts | 15 ++++----
11 files changed, 93 insertions(+), 27 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 21a066ff7..9742150a3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,6 +34,9 @@ Status: unreleased.
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
+### Fixes
+- Web UI: improve WebChat image paste previews and allow image-only sends. (#1925) Thanks @smartprogrammer93.
+
## 2026.1.24-3
### Fixes
diff --git a/src/gateway/protocol/schema/logs-chat.ts b/src/gateway/protocol/schema/logs-chat.ts
index 7b684771a..dc04a29d5 100644
--- a/src/gateway/protocol/schema/logs-chat.ts
+++ b/src/gateway/protocol/schema/logs-chat.ts
@@ -35,7 +35,7 @@ export const ChatHistoryParamsSchema = Type.Object(
export const ChatSendParamsSchema = Type.Object(
{
sessionKey: NonEmptyString,
- message: NonEmptyString,
+ message: Type.String(),
thinking: Type.Optional(Type.String()),
deliver: Type.Optional(Type.Boolean()),
attachments: Type.Optional(Type.Array(Type.Unknown())),
diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts
index 50f441779..9010a6f21 100644
--- a/src/gateway/server-methods/chat.ts
+++ b/src/gateway/server-methods/chat.ts
@@ -338,6 +338,15 @@ export const chatHandlers: GatewayRequestHandlers = {
: undefined,
}))
.filter((a) => a.content) ?? [];
+ const rawMessage = p.message.trim();
+ if (!rawMessage && normalizedAttachments.length === 0) {
+ respond(
+ false,
+ undefined,
+ errorShape(ErrorCodes.INVALID_REQUEST, "message or attachment required"),
+ );
+ return;
+ }
let parsedMessage = p.message;
let parsedImages: ChatImageContent[] = [];
if (normalizedAttachments.length > 0) {
diff --git a/src/gateway/server.chat.gateway-server-chat.e2e.test.ts b/src/gateway/server.chat.gateway-server-chat.e2e.test.ts
index 54f772580..6827b24c4 100644
--- a/src/gateway/server.chat.gateway-server-chat.e2e.test.ts
+++ b/src/gateway/server.chat.gateway-server-chat.e2e.test.ts
@@ -208,6 +208,39 @@ describe("gateway server chat", () => {
| undefined;
expect(imgOpts?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
+ const callsBeforeImageOnly = spy.mock.calls.length;
+ const reqIdOnly = "chat-img-only";
+ ws.send(
+ JSON.stringify({
+ type: "req",
+ id: reqIdOnly,
+ method: "chat.send",
+ params: {
+ sessionKey: "main",
+ message: "",
+ idempotencyKey: "idem-img-only",
+ attachments: [
+ {
+ type: "image",
+ mimeType: "image/png",
+ fileName: "dot.png",
+ content: `data:image/png;base64,${pngB64}`,
+ },
+ ],
+ },
+ }),
+ );
+
+ const imgOnlyRes = await onceMessage(ws, (o) => o.type === "res" && o.id === reqIdOnly, 8000);
+ expect(imgOnlyRes.ok).toBe(true);
+ expect(imgOnlyRes.payload?.runId).toBeDefined();
+
+ await waitFor(() => spy.mock.calls.length > callsBeforeImageOnly, 8000);
+ const imgOnlyOpts = spy.mock.calls.at(-1)?.[1] as
+ | { images?: Array<{ type: string; data: string; mimeType: string }> }
+ | undefined;
+ expect(imgOnlyOpts?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
+
const historyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
tempDirs.push(historyDir);
testState.sessionStorePath = path.join(historyDir, "sessions.json");
diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts
index 3ff74935d..c5f883716 100644
--- a/ui/src/ui/app-chat.ts
+++ b/ui/src/ui/app-chat.ts
@@ -1,4 +1,4 @@
-import { abortChatRun, loadChatHistory, sendChatMessage, type ChatAttachment } from "./controllers/chat";
+import { abortChatRun, loadChatHistory, sendChatMessage } from "./controllers/chat";
import { loadSessions } from "./controllers/sessions";
import { generateUUID } from "./uuid";
import { resetToolStream } from "./app-tool-stream";
@@ -8,12 +8,13 @@ import { normalizeBasePath } from "./navigation";
import type { GatewayHelloOk } from "./gateway";
import { parseAgentSessionKey } from "../../../src/sessions/session-key-utils.js";
import type { ClawdbotApp } from "./app";
+import type { ChatAttachment, ChatQueueItem } from "./ui-types";
type ChatHost = {
connected: boolean;
chatMessage: string;
chatAttachments: ChatAttachment[];
- chatQueue: Array<{ id: string; text: string; createdAt: number }>;
+ chatQueue: ChatQueueItem[];
chatRunId: string | null;
chatSending: boolean;
sessionKey: string;
@@ -46,15 +47,17 @@ export async function handleAbortChat(host: ChatHost) {
await abortChatRun(host as unknown as ClawdbotApp);
}
-function enqueueChatMessage(host: ChatHost, text: string) {
+function enqueueChatMessage(host: ChatHost, text: string, attachments?: ChatAttachment[]) {
const trimmed = text.trim();
- if (!trimmed) return;
+ const hasAttachments = Boolean(attachments && attachments.length > 0);
+ if (!trimmed && !hasAttachments) return;
host.chatQueue = [
...host.chatQueue,
{
id: generateUUID(),
text: trimmed,
createdAt: Date.now(),
+ attachments: hasAttachments ? attachments?.map((att) => ({ ...att })) : undefined,
},
];
}
@@ -62,19 +65,31 @@ function enqueueChatMessage(host: ChatHost, text: string) {
async function sendChatMessageNow(
host: ChatHost,
message: string,
- opts?: { previousDraft?: string; restoreDraft?: boolean; attachments?: ChatAttachment[] },
+ opts?: {
+ previousDraft?: string;
+ restoreDraft?: boolean;
+ attachments?: ChatAttachment[];
+ previousAttachments?: ChatAttachment[];
+ restoreAttachments?: boolean;
+ },
) {
resetToolStream(host as unknown as Parameters[0]);
const ok = await sendChatMessage(host as unknown as ClawdbotApp, message, opts?.attachments);
if (!ok && opts?.previousDraft != null) {
host.chatMessage = opts.previousDraft;
}
+ if (!ok && opts?.previousAttachments) {
+ host.chatAttachments = opts.previousAttachments;
+ }
if (ok) {
setLastActiveSessionKey(host as unknown as Parameters[0], host.sessionKey);
}
if (ok && opts?.restoreDraft && opts.previousDraft?.trim()) {
host.chatMessage = opts.previousDraft;
}
+ if (ok && opts?.restoreAttachments && opts.previousAttachments?.length) {
+ host.chatAttachments = opts.previousAttachments;
+ }
scheduleChatScroll(host as unknown as Parameters[0]);
if (ok && !host.chatRunId) {
void flushChatQueue(host);
@@ -87,7 +102,7 @@ async function flushChatQueue(host: ChatHost) {
const [next, ...rest] = host.chatQueue;
if (!next) return;
host.chatQueue = rest;
- const ok = await sendChatMessageNow(host, next.text);
+ const ok = await sendChatMessageNow(host, next.text, { attachments: next.attachments });
if (!ok) {
host.chatQueue = [next, ...host.chatQueue];
}
@@ -106,7 +121,8 @@ export async function handleSendChat(
const previousDraft = host.chatMessage;
const message = (messageOverride ?? host.chatMessage).trim();
const attachments = host.chatAttachments ?? [];
- const hasAttachments = attachments.length > 0;
+ const attachmentsToSend = messageOverride == null ? attachments : [];
+ const hasAttachments = attachmentsToSend.length > 0;
// Allow sending with just attachments (no message text required)
if (!message && !hasAttachments) return;
@@ -123,14 +139,16 @@ export async function handleSendChat(
}
if (isChatBusy(host)) {
- enqueueChatMessage(host, message);
+ enqueueChatMessage(host, message, attachmentsToSend);
return;
}
await sendChatMessageNow(host, message, {
previousDraft: messageOverride == null ? previousDraft : undefined,
restoreDraft: Boolean(messageOverride && opts?.restoreDraft),
- attachments: hasAttachments ? attachments : undefined,
+ attachments: hasAttachments ? attachmentsToSend : undefined,
+ previousAttachments: messageOverride == null ? attachments : undefined,
+ restoreAttachments: Boolean(messageOverride && opts?.restoreDraft),
});
}
diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts
index 38b16b084..fe67c86f1 100644
--- a/ui/src/ui/app-render.ts
+++ b/ui/src/ui/app-render.ts
@@ -431,6 +431,7 @@ export function renderApp(state: AppViewState) {
onSessionKeyChange: (next) => {
state.sessionKey = next;
state.chatMessage = "";
+ state.chatAttachments = [];
state.chatStream = null;
state.chatStreamStartedAt = null;
state.chatRunId = null;
diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts
index f589c760c..069465e32 100644
--- a/ui/src/ui/app-view-state.ts
+++ b/ui/src/ui/app-view-state.ts
@@ -19,7 +19,7 @@ import type {
SkillStatusReport,
StatusSummary,
} from "./types";
-import type { ChatQueueItem, CronFormState } from "./ui-types";
+import type { ChatAttachment, ChatQueueItem, CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events";
import type { SkillMessage } from "./controllers/skills";
import type {
@@ -49,6 +49,7 @@ export type AppViewState = {
chatLoading: boolean;
chatSending: boolean;
chatMessage: string;
+ chatAttachments: ChatAttachment[];
chatMessages: unknown[];
chatToolMessages: unknown[];
chatStream: string | null;
diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts
index 310305ff9..649e76342 100644
--- a/ui/src/ui/app.ts
+++ b/ui/src/ui/app.ts
@@ -24,7 +24,7 @@ import type {
StatusSummary,
NostrProfile,
} from "./types";
-import { type ChatQueueItem, type CronFormState } from "./ui-types";
+import { type ChatAttachment, type ChatQueueItem, type CronFormState } from "./ui-types";
import type { EventLogEntry } from "./app-events";
import { DEFAULT_CRON_FORM, DEFAULT_LOG_LEVEL_FILTERS } from "./app-defaults";
import type {
@@ -129,7 +129,7 @@ export class ClawdbotApp extends LitElement {
@state() chatAvatarUrl: string | null = null;
@state() chatThinkingLevel: string | null = null;
@state() chatQueue: ChatQueueItem[] = [];
- @state() chatAttachments: Array<{ id: string; dataUrl: string; mimeType: string }> = [];
+ @state() chatAttachments: ChatAttachment[] = [];
// Sidebar state for tool output viewing
@state() sidebarOpen = false;
@state() sidebarContent: string | null = null;
diff --git a/ui/src/ui/controllers/chat.ts b/ui/src/ui/controllers/chat.ts
index 644d49358..518c35fe1 100644
--- a/ui/src/ui/controllers/chat.ts
+++ b/ui/src/ui/controllers/chat.ts
@@ -1,12 +1,7 @@
import { extractText } from "../chat/message-extract";
import type { GatewayBrowserClient } from "../gateway";
import { generateUUID } from "../uuid";
-
-export type ChatAttachment = {
- id: string;
- dataUrl: string;
- mimeType: string;
-};
+import type { ChatAttachment } from "../ui-types";
export type ChatState = {
client: GatewayBrowserClient | null;
diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts
index 428c4c381..196d6d114 100644
--- a/ui/src/ui/ui-types.ts
+++ b/ui/src/ui/ui-types.ts
@@ -1,7 +1,14 @@
+export type ChatAttachment = {
+ id: string;
+ dataUrl: string;
+ mimeType: string;
+};
+
export type ChatQueueItem = {
id: string;
text: string;
createdAt: number;
+ attachments?: ChatAttachment[];
};
export const CRON_CHANNEL_LAST = "last";
diff --git a/ui/src/ui/views/chat.ts b/ui/src/ui/views/chat.ts
index 17fc8401f..a9b4da572 100644
--- a/ui/src/ui/views/chat.ts
+++ b/ui/src/ui/views/chat.ts
@@ -1,7 +1,7 @@
import { html, nothing } from "lit";
import { repeat } from "lit/directives/repeat.js";
import type { SessionsListResult } from "../types";
-import type { ChatQueueItem } from "../ui-types";
+import type { ChatAttachment, ChatQueueItem } from "../ui-types";
import type { ChatItem, MessageGroup } from "../types/chat-types";
import { icons } from "../icons";
import {
@@ -22,12 +22,6 @@ export type CompactionIndicatorStatus = {
completedAt: number | null;
};
-export type ChatAttachment = {
- id: string;
- dataUrl: string;
- mimeType: string;
-};
-
export type ChatProps = {
sessionKey: string;
onSessionKeyChange: (next: string) => void;
@@ -305,7 +299,12 @@ export function renderChat(props: ChatProps) {
${props.queue.map(
(item) => html`
-
${item.text}
+
+ ${item.text ||
+ (item.attachments?.length
+ ? `Image (${item.attachments.length})`
+ : "")}
+