From e40257af33dc0941b83093d190dc3c6c37fd82fb Mon Sep 17 00:00:00 2001
From: 0xJonHoldsCrypto
Date: Sun, 25 Jan 2026 17:12:17 +0000
Subject: [PATCH 01/49] 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 02/49] 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 03/49] 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 04/49] 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 05/49] 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 06/49] 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 07/49] 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 08/49] 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 09/49] 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 10/49] 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 11/49] 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 12/49] 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 13/49] 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 14/49] 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 15/49] 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 16/49] 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 17/49] 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 18/49] 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 19/49] 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 20/49] 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 21/49] 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 22/49] 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 23/49] 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 24/49] 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 25/49] 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 26/49] 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 27/49] 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 28/49] 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 29/49] 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 30/49] 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 31/49] 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 32/49] 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 33/49] 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 34/49] 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 35/49] 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 36/49] 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 37/49] 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 38/49] 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 39/49] 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 40/49] 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 41/49] 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 42/49] 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 43/49] 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 44/49] 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 45/49] 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 46/49] 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 47/49] 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 48/49] 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 49/49] 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})`
+ : "")}
+