# llms-full (private-aware)
> Built from GitHub files and website pages. Large files may be truncated.

--- scripts/postinstall.js ---
import { spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";

function isBunInstall() {
  const ua = process.env.npm_config_user_agent ?? "";
  return ua.includes("bun/");
}

function getRepoRoot() {
  const here = path.dirname(fileURLToPath(import.meta.url));
  return path.resolve(here, "..");
}

function run(cmd, args, opts = {}) {
  const res = spawnSync(cmd, args, { stdio: "inherit", ...opts });
  if (typeof res.status === "number") return res.status;
  return 1;
}

function applyPatchIfNeeded(opts) {
  const patchPath = path.resolve(opts.patchPath);
  if (!fs.existsSync(patchPath)) {
    throw new Error(`missing patch: ${patchPath}`);
  }

  let targetDir = path.resolve(opts.targetDir);
  if (!fs.existsSync(targetDir) || !fs.statSync(targetDir).isDirectory()) {
    console.warn(`[postinstall] skip missing target: ${targetDir}`);
    return;
  }

  // Resolve symlinks to avoid "beyond a symbolic link" errors from git apply
  // (bun/pnpm use symlinks in node_modules)
  targetDir = fs.realpathSync(targetDir);

  const gitArgsBase = ["apply", "--unsafe-paths", "--whitespace=nowarn"];
  const reverseCheck = [
    ...gitArgsBase,
    "--reverse",
    "--check",
    "--directory",
    targetDir,
    patchPath,
  ];
  const forwardCheck = [
    ...gitArgsBase,
    "--check",
    "--directory",
    targetDir,
    patchPath,
  ];
  const apply = [...gitArgsBase, "--directory", targetDir, patchPath];

  // Already applied?
  if (run("git", reverseCheck, { stdio: "ignore" }) === 0) {
    return;
  }

  if (run("git", forwardCheck, { stdio: "ignore" }) !== 0) {
    throw new Error(`patch does not apply cleanly: ${path.basename(patchPath)}`);
  }

  const status = run("git", apply);
  if (status !== 0) {
    throw new Error(`failed applying patch: ${path.basename(patchPath)}`);
  }
}

function extractPackageName(key) {
  if (key.startsWith("@")) {
    const idx = key.indexOf("@", 1);
    if (idx === -1) return key;
    return key.slice(0, idx);
  }
  const idx = key.lastIndexOf("@");
  if (idx <= 0) return key;
  return key.slice(0, idx);
}

function main() {
  if (!isBunInstall()) return;

  const repoRoot = getRepoRoot();
  process.chdir(repoRoot);

  const pkgPath = path.join(repoRoot, "package.json");
  const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
  const patched = pkg?.pnpm?.patchedDependencies ?? {};

  // Bun does not support pnpm.patchedDependencies. Apply these patch files to
  // node_modules packages as a best-effort compatibility layer.
  for (const [key, relPatchPath] of Object.entries(patched)) {
    if (typeof relPatchPath !== "string" || !relPatchPath.trim()) continue;
    const pkgName = extractPackageName(String(key));
    if (!pkgName) continue;
    applyPatchIfNeeded({
      targetDir: path.join("node_modules", ...pkgName.split("/")),
      patchPath: relPatchPath,
    });
  }
}

try {
  main();
} catch (err) {
  console.error(String(err));
  process.exit(1);
}


--- docs/install/bun.md ---
---
summary: "Bun workflow (preferred): installs, patches, and gotchas vs pnpm"
read_when:
  - You want the fastest local dev loop (bun + watch)
  - You hit Bun install/patch/lifecycle script issues
---

# Bun

Goal: run this repo with **Bun** (optional) without losing pnpm patch behavior.

## Status

- Bun is an optional local runtime for running TypeScript directly (`bun run …`, `bun --watch …`).
- `pnpm` is the default for builds and remains fully supported (and used by some docs tooling).
- Bun cannot use `pnpm-lock.yaml` and will ignore it.

## Install

Default:

```sh
bun install
```

Note: `bun.lock`/`bun.lockb` are gitignored, so there’s no repo churn either way. If you want *no lockfile writes*:

```sh
bun install --no-save
```

## Build / Test (Bun)

```sh
bun run build
bun run vitest run
```

## pnpm patchedDependencies under Bun

pnpm supports `package.json#pnpm.patchedDependencies` and records it in `pnpm-lock.yaml`.
Bun does not support pnpm patches, so we apply them in `postinstall` when Bun is detected:

- [`scripts/postinstall.js`](https://github.com/clawdbot/clawdbot/blob/main/scripts/postinstall.js) runs only for Bun installs and applies every entry from `package.json#pnpm.patchedDependencies` into `node_modules/...` using `git apply` (idempotent).

To add a new patch that works in both pnpm + Bun:

1. Add an entry to `package.json#pnpm.patchedDependencies`
2. Add the patch file under `patches/`
3. Run `pnpm install` (updates `pnpm-lock.yaml` patch hash)

## Bun lifecycle scripts (blocked by default)

Bun may block dependency lifecycle scripts unless explicitly trusted (`bun pm untrusted` / `bun pm trust`).
For this repo, the commonly blocked scripts are not required:

- `@whiskeysockets/baileys` `preinstall`: checks Node major >= 20 (we run Node 22+).
- `protobufjs` `postinstall`: emits warnings about incompatible version schemes (no build artifacts).

If you hit a real runtime issue that requires these scripts, trust them explicitly:

```sh
bun pm trust @whiskeysockets/baileys protobufjs
```

## Caveats

- Some scripts still hardcode pnpm (e.g. `docs:build`, `ui:*`, `protocol:check`). Run those via pnpm for now.


## Links discovered
- [`scripts/postinstall.js`](https://github.com/clawdbot/clawdbot/blob/main/scripts/postinstall.js)

--- docs/install/docker.md ---
---
summary: "Optional Docker-based setup and onboarding for Clawdbot"
read_when:
  - You want a containerized gateway instead of local installs
  - You are validating the Docker flow
---

# Docker (optional)

Docker is **optional**. Use it only if you want a containerized gateway or to validate the Docker flow.

This guide covers:
- Containerized Gateway (full Clawdbot in Docker)
- Per-session Agent Sandbox (host gateway + Docker-isolated agent tools)

## Requirements

- Docker Desktop (or Docker Engine) + Docker Compose v2
- Enough disk for images + logs

## Containerized Gateway (Docker Compose)

### Quick start (recommended)

From repo root:

```bash
./docker-setup.sh
```

This script:
- builds the gateway image
- runs the onboarding wizard
- runs WhatsApp login
- starts the gateway via Docker Compose

It writes config/workspace on the host:
- `~/.clawdbot/`
- `~/clawd`

### Manual flow (compose)

```bash
docker build -t clawdbot:local -f Dockerfile .
docker compose run --rm clawdbot-cli onboard
docker compose run --rm clawdbot-cli login
docker compose up -d clawdbot-gateway
```

### Health check

```bash
docker compose exec clawdbot-gateway node dist/index.js health --token "$CLAWDBOT_GATEWAY_TOKEN"
```

### E2E smoke test (Docker)

```bash
scripts/e2e/onboard-docker.sh
```

### QR import smoke test (Docker)

```bash
pnpm test:docker:qr
```

### Notes

- Gateway bind defaults to `lan` for container use.
- The gateway container is the source of truth for sessions (`~/.clawdbot/agents/<agentId>/sessions/`).

## Agent Sandbox (host gateway + Docker tools)

### What it does

When `agent.sandbox` is enabled, **non-main sessions** run tools inside a Docker
container. The gateway stays on your host, but the tool execution is isolated:
- scope: `"agent"` by default (one container + workspace per agent)
- scope: `"session"` for per-session isolation
- per-scope workspace folder mounted at `/workspace`
- optional agent workspace access (`agent.sandbox.workspaceAccess`)
- allow/deny tool policy (deny wins)
- inbound media is copied into the active sandbox workspace (`media/inbound/*`) so tools can read it (with `workspaceAccess: "rw"`, this lands in the agent workspace)

Warning: `scope: "shared"` disables cross-session isolation. All sessions share
one container and one workspace.

### Per-agent sandbox profiles (multi-agent)

If you use multi-agent routing, each agent can override sandbox + tool settings:
`routing.agents[id].sandbox` and `routing.agents[id].tools`. This lets you run
mixed access levels in one gateway:
- Full access (personal agent)
- Read-only tools + read-only workspace (family/work agent)
- No filesystem/shell tools (public agent)

See [Multi-Agent Sandbox & Tools](/multi-agent-sandbox-tools) for examples,
precedence, and troubleshooting.

### Default behavior

- Image: `clawdbot-sandbox:bookworm-slim`
- One container per agent
- Agent workspace access: `workspaceAccess: "none"` (default) uses `~/.clawdbot/sandboxes`
  - `"ro"` keeps the sandbox workspace at `/workspace` and mounts the agent workspace read-only at `/agent` (disables `write`/`edit`)
  - `"rw"` mounts the agent workspace read/write at `/workspace`
- Auto-prune: idle > 24h OR age > 7d
- Network: `none` by default (explicitly opt-in if you need egress)
- Default allow: `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`
- Default deny: `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`

### Enable sandboxing

```json5
{
  agent: {
    sandbox: {
      mode: "non-main", // off | non-main | all
      scope: "agent", // session | agent | shared (agent is default)
      workspaceAccess: "none", // none | ro | rw
      workspaceRoot: "~/.clawdbot/sandboxes",
      docker: {
        image: "clawdbot-sandbox:bookworm-slim",
        workdir: "/workspace",
        readOnlyRoot: true,
        tmpfs: ["/tmp", "/var/tmp", "/run"],
        network: "none",
        user: "1000:1000",
        capDrop: ["ALL"],
        env: { LANG: "C.UTF-8" },
        setupCommand: "apt-get update && apt-get install -y git curl jq",
        pidsLimit: 256,
        memory: "1g",
        memorySwap: "2g",
        cpus: 1,
        ulimits: {
          nofile: { soft: 1024, hard: 2048 },
          nproc: 256
        },
        seccompProfile: "/path/to/seccomp.json",
        apparmorProfile: "clawdbot-sandbox",
        dns: ["1.1.1.1", "8.8.8.8"],
        extraHosts: ["internal.service:10.0.0.5"]
      },
      tools: {
        allow: ["bash", "process", "read", "write", "edit", "sessions_list", "sessions_history", "sessions_send", "sessions_spawn"],
        deny: ["browser", "canvas", "nodes", "cron", "discord", "gateway"]
      },
      prune: {
        idleHours: 24, // 0 disables idle pruning
        maxAgeDays: 7  // 0 disables max-age pruning
      }
    }
  }
}
```

Hardening knobs live under `agent.sandbox.docker`:
`network`, `user`, `pidsLimit`, `memory`, `memorySwap`, `cpus`, `ulimits`,
`seccompProfile`, `apparmorProfile`, `dns`, `extraHosts`.

Multi-agent: override `agent.sandbox.{docker,browser,prune}.*` per agent via `routing.agents.<agentId>.sandbox.{docker,browser,prune}.*`
(ignored when `agent.sandbox.scope` / `routing.agents.<agentId>.sandbox.scope` is `"shared"`).

### Build the default sandbox image

```bash
scripts/sandbox-setup.sh
```

This builds `clawdbot-sandbox:bookworm-slim` using `Dockerfile.sandbox`.

### Sandbox common image (optional)
If you want a sandbox image with common build tooling (Node, Go, Rust, etc.), build the common image:

```bash
scripts/sandbox-common-setup.sh
```

This builds `clawdbot-sandbox-common:bookworm-slim`. To use it:

```json5
{
  agent: { sandbox: { docker: { image: "clawdbot-sandbox-common:bookworm-slim" } } }
}
```

### Sandbox browser image

To run the browser tool inside the sandbox, build the browser image:

```bash
scripts/sandbox-browser-setup.sh
```

This builds `clawdbot-sandbox-browser:bookworm-slim` using
`Dockerfile.sandbox-browser`. The container runs Chromium with CDP enabled and
an optional noVNC observer (headful via Xvfb).

Notes:
- Headful (Xvfb) reduces bot blocking vs headless.
- Headless can still be used by setting `agent.sandbox.browser.headless=true`.
- No full desktop environment (GNOME) is needed; Xvfb provides the display.

Use config:

```json5
{
  agent: {
    sandbox: {
      browser: { enabled: true }
    }
  }
}
```

Custom browser image:

```json5
{
  agent: {
    sandbox: { browser: { image: "my-clawdbot-browser" } }
  }
}
```

When enabled, the agent receives:
- a sandbox browser control URL (for the `browser` tool)
- a noVNC URL (if enabled and headless=false)

Remember: if you use an allowlist for tools, add `browser` (and remove it from
deny) or the tool remains blocked.
Prune rules (`agent.sandbox.prune`) apply to browser containers too.

### Custom sandbox image

Build your own image and point config to it:

```bash
docker build -t my-clawdbot-sbx -f Dockerfile.sandbox .
```

```json5
{
  agent: {
    sandbox: { docker: { image: "my-clawdbot-sbx" } }
  }
}
```

### Tool policy (allow/deny)

- `deny` wins over `allow`.
- If `allow` is empty: all tools (except deny) are available.
- If `allow` is non-empty: only tools in `allow` are available (minus deny).

### Pruning strategy

Two knobs:
- `prune.idleHours`: remove containers not used in X hours (0 = disable)
- `prune.maxAgeDays`: remove containers older than X days (0 = disable)

Example:
- Keep busy sessions but cap lifetime:
  `idleHours: 24`, `maxAgeDays: 7`
- Never prune:
  `idleHours: 0`, `maxAgeDays: 0`

### Security notes

- Hard wall only applies to **tools** (bash/read/write/edit).  
- Host-only tools like browser/camera/canvas are blocked by default.  
- Allowing `browser` in sandbox **breaks isolation** (browser runs on host).

## Troubleshooting

- Image missing: build with [`scripts/sandbox-setup.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/sandbox-setup.sh) or set `agent.sandbox.docker.image`.
- Container not running: it will auto-create per session on demand.
- Permission errors in sandbox: set `docker.user` to a UID:GID that matches your
  mounted workspace ownership (or chown the workspace folder).


## Links discovered
- [Multi-Agent Sandbox & Tools](https://github.com/clawdbot/clawdbot/blob/main/multi-agent-sandbox-tools.md)
- [`scripts/sandbox-setup.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/sandbox-setup.sh)

--- docs/start/getting-started.md ---
---
summary: "Beginner guide: from repo checkout to first message (wizard, auth, providers, pairing)"
read_when:
  - First time setup from zero
  - You want the fastest path from checkout → onboarding → first message
---

# Getting Started

Goal: go from **zero** → **first working chat** (with sane defaults) as quickly as possible.

Recommended path: use the **CLI onboarding wizard** (`clawdbot onboard`). It sets up:
- model/auth (OAuth recommended)
- gateway settings
- providers (WhatsApp/Telegram/Discord/…)
- pairing defaults (secure DMs)
- workspace bootstrap + skills
- optional background daemon

If you want the deeper reference pages, jump to: [Wizard](/start/wizard), [Setup](/start/setup), [Pairing](/start/pairing), [Security](/gateway/security).

## 0) Prereqs

- Node `>=22`
- `pnpm` (recommended) or `bun` (optional)
- Git

macOS: if you plan to build the apps, install Xcode / CLT. For the CLI + gateway only, Node is enough.
Windows: use **WSL2** (Ubuntu recommended). WSL2 is strongly recommended; native Windows is untested and more problematic. Install WSL2 first, then run the Linux steps inside WSL. See [Windows (WSL2)](/platforms/windows).

## 1) Check out from source

```bash
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot
pnpm install
```

Note: Bun is optional if you prefer running TypeScript directly:

```bash
bun install
```

## 2) Control UI (auto + fallback)

The Gateway serves the browser dashboard (Control UI) when assets exist.
The wizard tries to build these for you. If it fails, run:

```bash
pnpm ui:install
pnpm ui:build
```

If you skip UI build, the gateway still works — you just won’t get the dashboard.

## 3) Run the onboarding wizard

```bash
pnpm clawdbot onboard
```

What you’ll choose:
- **Local vs Remote** gateway
- **Auth**: Anthropic OAuth or OpenAI OAuth (recommended), API key (optional), or skip for now
- **Providers**: WhatsApp QR login, Telegram/Discord bot tokens, etc.
- **Daemon**: optional background install (launchd/systemd; WSL2 uses systemd)
  - **Runtime**: Node (recommended; required for WhatsApp) or Bun (faster, but incompatible with WhatsApp)

Wizard doc: [Wizard](/start/wizard)

### Auth: where it lives (important)

- OAuth credentials (legacy import): `~/.clawdbot/credentials/oauth.json`
- Auth profiles (OAuth + API keys): `~/.clawdbot/agents/<agentId>/agent/auth-profiles.json`

Headless/server tip: do OAuth on a normal machine first, then copy `oauth.json` to the gateway host.

## 4) Start the Gateway

If the wizard didn’t start it for you:

```bash
# If you installed the CLI (npm/pnpm link --global):
clawdbot gateway --port 18789 --verbose
# From this repo:
node dist/entry.js gateway --port 18789 --verbose
```

Dashboard (local loopback): `http://127.0.0.1:18789/`

⚠️ **WhatsApp + Bun warning:** Baileys (WhatsApp Web library) uses a WebSocket
path that is currently incompatible with Bun and can cause memory corruption on
reconnect. If you use WhatsApp, run the Gateway with **Node** until this is
resolved. Baileys: https://github.com/WhiskeySockets/Baileys · Bun issue:
https://github.com/oven-sh/bun/issues/5951
## 5) Pair + connect your first chat surface

### WhatsApp (QR login)

```bash
pnpm clawdbot login
```

Scan via WhatsApp → Settings → Linked Devices.

WhatsApp doc: [WhatsApp](/providers/whatsapp)

### Telegram / Discord / others

The wizard can write tokens/config for you. If you prefer manual config, start with:
- Telegram: [Telegram](/providers/telegram)
- Discord: [Discord](/providers/discord)

**Telegram DM tip:** your first DM returns a pairing code. Approve it (see next step) or the bot won’t respond.

## 6) DM safety (pairing approvals)

Default posture: unknown DMs get a short code and messages are not processed until approved.
If your first DM gets no reply, approve the pairing:

Approve:

```bash
pnpm clawdbot pairing list --provider telegram
pnpm clawdbot pairing approve --provider telegram <CODE>
```

Pairing doc: [Pairing](/start/pairing)

## 7) Verify end-to-end

In a new terminal:

```bash
pnpm clawdbot health
pnpm clawdbot send --to +15555550123 --message "Hello from Clawdbot"
```

If `health` shows “no auth configured”, go back to the wizard and set OAuth/key auth — the agent won’t be able to respond without it.

## Next steps (optional, but great)

- macOS menu bar app + voice wake: [macOS app](/platforms/macos)
- iOS/Android nodes (Canvas/camera/voice): [Nodes](/nodes)
- Remote access (SSH tunnel / Tailscale Serve): [Remote access](/gateway/remote) and [Tailscale](/gateway/tailscale)


## Links discovered
- [Wizard](https://github.com/clawdbot/clawdbot/blob/main/start/wizard.md)
- [Setup](https://github.com/clawdbot/clawdbot/blob/main/start/setup.md)
- [Pairing](https://github.com/clawdbot/clawdbot/blob/main/start/pairing.md)
- [Security](https://github.com/clawdbot/clawdbot/blob/main/gateway/security.md)
- [Windows (WSL2)](https://github.com/clawdbot/clawdbot/blob/main/platforms/windows.md)
- [WhatsApp](https://github.com/clawdbot/clawdbot/blob/main/providers/whatsapp.md)
- [Telegram](https://github.com/clawdbot/clawdbot/blob/main/providers/telegram.md)
- [Discord](https://github.com/clawdbot/clawdbot/blob/main/providers/discord.md)
- [macOS app](https://github.com/clawdbot/clawdbot/blob/main/platforms/macos.md)
- [Nodes](https://github.com/clawdbot/clawdbot/blob/main/nodes.md)
- [Remote access](https://github.com/clawdbot/clawdbot/blob/main/gateway/remote.md)
- [Tailscale](https://github.com/clawdbot/clawdbot/blob/main/gateway/tailscale.md)

--- docs/install/nix.md ---
---
summary: "Install Clawdbot declaratively with Nix"
read_when:
  - You want reproducible, rollback-able installs
  - You're already using Nix/NixOS/Home Manager
  - You want everything pinned and managed declaratively
---

# Nix Installation

The recommended way to run Clawdbot with Nix is via **[nix-clawdbot](https://github.com/clawdbot/nix-clawdbot)** — a batteries-included Home Manager module.

## Quick Start

Paste this to your AI agent (Claude, Cursor, etc.):

```text
I want to set up nix-clawdbot on my Mac.
Repository: github:clawdbot/nix-clawdbot

What I need you to do:
1. Check if Determinate Nix is installed (if not, install it)
2. Create a local flake at ~/code/clawdbot-local using templates/agent-first/flake.nix
3. Help me create a Telegram bot (@BotFather) and get my chat ID (@userinfobot)
4. Set up secrets (bot token, Anthropic key) - plain files at ~/.secrets/ is fine
5. Fill in the template placeholders and run home-manager switch
6. Verify: launchd running, bot responds to messages

Reference the nix-clawdbot README for module options.
```

> **📦 Full guide: [github.com/clawdbot/nix-clawdbot](https://github.com/clawdbot/nix-clawdbot)**
>
> The nix-clawdbot repo is the source of truth for Nix installation. This page is just a quick overview.

## What you get

- Gateway + macOS app + tools (whisper, spotify, cameras) — all pinned
- Launchd service that survives reboots
- Plugin system with declarative config
- Instant rollback: `home-manager switch --rollback`

---

## Nix Mode Runtime Behavior

When `CLAWDBOT_NIX_MODE=1` is set (automatic with nix-clawdbot):

Clawdbot supports a **Nix mode** that makes configuration deterministic and disables auto-install flows.
Enable it by exporting:

```bash
CLAWDBOT_NIX_MODE=1
```

On macOS, the GUI app does not automatically inherit shell env vars. You can
also enable Nix mode via defaults:

```bash
defaults write com.clawdbot.mac clawdbot.nixMode -bool true
```

### Config + state paths

Clawdbot reads JSON5 config from `CLAWDBOT_CONFIG_PATH` and stores mutable data in `CLAWDBOT_STATE_DIR`.

- `CLAWDBOT_STATE_DIR` (default: `~/.clawdbot`)
- `CLAWDBOT_CONFIG_PATH` (default: `$CLAWDBOT_STATE_DIR/clawdbot.json`)

When running under Nix, set these explicitly to Nix-managed locations so runtime state and config
stay out of the immutable store.

### Runtime behavior in Nix mode

- Auto-install and self-mutation flows are disabled
- Missing dependencies surface Nix-specific remediation messages
- UI surfaces a read-only Nix mode banner when present

## Packaging note (macOS)

The macOS packaging flow expects a stable Info.plist template at:

```
apps/macos/Sources/Clawdbot/Resources/Info.plist
```

[`scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh) copies this template into the app bundle and patches dynamic fields
(bundle ID, version/build, Git SHA, Sparkle keys). This keeps the plist deterministic for SwiftPM
packaging and Nix builds (which do not rely on a full Xcode toolchain).

## Related

- [nix-clawdbot](https://github.com/clawdbot/nix-clawdbot) — full setup guide
- [Wizard](/start/wizard) — non-Nix CLI setup
- [Docker](/install/docker) — containerized setup


## Links discovered
- [nix-clawdbot](https://github.com/clawdbot/nix-clawdbot)
- [github.com/clawdbot/nix-clawdbot](https://github.com/clawdbot/nix-clawdbot)
- [`scripts/package-mac-app.sh`](https://github.com/clawdbot/clawdbot/blob/main/scripts/package-mac-app.sh)
- [Wizard](https://github.com/clawdbot/clawdbot/blob/main/start/wizard.md)
- [Docker](https://github.com/clawdbot/clawdbot/blob/main/install/docker.md)

--- src/commands/signal-install.ts ---
import { createWriteStream } from "node:fs";
import fs from "node:fs/promises";
import { request } from "node:https";
import os from "node:os";
import path from "node:path";
import { pipeline } from "node:stream/promises";

import { runCommandWithTimeout } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { CONFIG_DIR } from "../utils.js";

type ReleaseAsset = {
  name?: string;
  browser_download_url?: string;
};

type NamedAsset = {
  name: string;
  browser_download_url: string;
};

type ReleaseResponse = {
  tag_name?: string;
  assets?: ReleaseAsset[];
};

export type SignalInstallResult = {
  ok: boolean;
  cliPath?: string;
  version?: string;
  error?: string;
};

function looksLikeArchive(name: string): boolean {
  return (
    name.endsWith(".tar.gz") || name.endsWith(".tgz") || name.endsWith(".zip")
  );
}

function pickAsset(assets: ReleaseAsset[], platform: NodeJS.Platform) {
  const withName = assets.filter((asset): asset is NamedAsset =>
    Boolean(asset.name && asset.browser_download_url),
  );
  const byName = (pattern: RegExp) =>
    withName.find((asset) => pattern.test(asset.name.toLowerCase()));

  if (platform === "linux") {
    return (
      byName(/linux-native/) ||
      byName(/linux/) ||
      withName.find((asset) => looksLikeArchive(asset.name.toLowerCase()))
    );
  }

  if (platform === "darwin") {
    return (
      byName(/macos|osx|darwin/) ||
      withName.find((asset) => looksLikeArchive(asset.name.toLowerCase()))
    );
  }

  if (platform === "win32") {
    return (
      byName(/windows|win/) ||
      withName.find((asset) => looksLikeArchive(asset.name.toLowerCase()))
    );
  }

  return withName.find((asset) => looksLikeArchive(asset.name.toLowerCase()));
}

async function downloadToFile(
  url: string,
  dest: string,
  maxRedirects = 5,
): Promise<void> {
  await new Promise<void>((resolve, reject) => {
    const req = request(url, (res) => {
      if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) {
        const location = res.headers.location;
        if (!location || maxRedirects <= 0) {
          reject(new Error("Redirect loop or missing Location header"));
          return;
        }
        const redirectUrl = new URL(location, url).href;
        resolve(downloadToFile(redirectUrl, dest, maxRedirects - 1));
        return;
      }
      if (!res.statusCode || res.statusCode >= 400) {
        reject(new Error(`HTTP ${res.statusCode ?? "?"} downloading file`));
        return;
      }
      const out = createWriteStream(dest);
      pipeline(res, out).then(resolve).catch(reject);
    });
    req.on("error", reject);
    req.end();
  });
}

async function findSignalCliBinary(root: string): Promise<string | null> {
  const candidates: string[] = [];
  const enqueue = async (dir: string, depth: number) => {
    if (depth > 3) return;
    const entries = await fs
      .readdir(dir, { withFileTypes: true })
      .catch(() => []);
    for (const entry of entries) {
      const full = path.join(dir, entry.name);
      if (entry.isDirectory()) {
        await enqueue(full, depth + 1);
      } else if (entry.isFile() && entry.name === "signal-cli") {
        candidates.push(full);
      }
    }
  };
  await enqueue(root, 0);
  return candidates[0] ?? null;
}

export async function installSignalCli(
  runtime: RuntimeEnv,
): Promise<SignalInstallResult> {
  if (process.platform === "win32") {
    return {
      ok: false,
      error: "Signal CLI auto-install is not supported on Windows yet.",
    };
  }

  const apiUrl =
    "https://api.github.com/repos/AsamK/signal-cli/releases/latest";
  const response = await fetch(apiUrl, {
    headers: {
      "User-Agent": "clawdbot",
      Accept: "application/vnd.github+json",
    },
  });

  if (!response.ok) {
    return {
      ok: false,
      error: `Failed to fetch release info (${response.status})`,
    };
  }

  const payload = (await response.json()) as ReleaseResponse;
  const version = payload.tag_name?.replace(/^v/, "") ?? "unknown";
  const assets = payload.assets ?? [];
  const asset = pickAsset(assets, process.platform);
  const assetName = asset?.name ?? "";
  const assetUrl = asset?.browser_download_url ?? "";

  if (!assetName || !assetUrl) {
    return {
      ok: false,
      error: "No compatible release asset found for this platform.",
    };
  }

  const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-signal-"));
  const archivePath = path.join(tmpDir, assetName);

  runtime.log(`Downloading signal-cli ${version} (${assetName})…`);
  await downloadToFile(assetUrl, archivePath);

  const installRoot = path.join(CONFIG_DIR, "tools", "signal-cli", version);
  await fs.mkdir(installRoot, { recursive: true });

  if (assetName.endsWith(".zip")) {
    await runCommandWithTimeout(
      ["unzip", "-q", archivePath, "-d", installRoot],
      {
        timeoutMs: 60_000,
      },
    );
  } else if (assetName.endsWith(".tar.gz") || assetName.endsWith(".tgz")) {
    await runCommandWithTimeout(
      ["tar", "-xzf", archivePath, "-C", installRoot],
      {
        timeoutMs: 60_000,
      },
    );
  } else {
    return { ok: false, error: `Unsupported archive type: ${assetName}` };
  }

  const cliPath = await findSignalCliBinary(installRoot);
  if (!cliPath) {
    return {
      ok: false,
      error: `signal-cli binary not found after extracting ${assetName}`,
    };
  }

  await fs.chmod(cliPath, 0o755).catch(() => {});

  return { ok: true, cliPath, version };
}


--- src/agents/skills-install.ts ---
import fs from "node:fs";
import path from "node:path";

import type { ClawdbotConfig } from "../config/config.js";
import { resolveBrewExecutable } from "../infra/brew.js";
import { runCommandWithTimeout } from "../process/exec.js";
import { resolveUserPath } from "../utils.js";
import {
  hasBinary,
  loadWorkspaceSkillEntries,
  resolveSkillsInstallPreferences,
  type SkillEntry,
  type SkillInstallSpec,
  type SkillsInstallPreferences,
} from "./skills.js";

export type SkillInstallRequest = {
  workspaceDir: string;
  skillName: string;
  installId: string;
  timeoutMs?: number;
  config?: ClawdbotConfig;
};

export type SkillInstallResult = {
  ok: boolean;
  message: string;
  stdout: string;
  stderr: string;
  code: number | null;
};

function summarizeInstallOutput(text: string): string | undefined {
  const raw = text.trim();
  if (!raw) return undefined;
  const lines = raw
    .split("\n")
    .map((line) => line.trim())
    .filter(Boolean);
  if (lines.length === 0) return undefined;

  const preferred =
    lines.find((line) => /^error\b/i.test(line)) ??
    lines.find((line) => /\b(err!|error:|failed)\b/i.test(line)) ??
    lines.at(-1);

  if (!preferred) return undefined;
  const normalized = preferred.replace(/\s+/g, " ").trim();
  const maxLen = 200;
  return normalized.length > maxLen
    ? `${normalized.slice(0, maxLen - 1)}…`
    : normalized;
}

function formatInstallFailureMessage(result: {
  code: number | null;
  stdout: string;
  stderr: string;
}): string {
  const code =
    typeof result.code === "number" ? `exit ${result.code}` : "unknown exit";
  const summary =
    summarizeInstallOutput(result.stderr) ??
    summarizeInstallOutput(result.stdout);
  if (!summary) return `Install failed (${code})`;
  return `Install failed (${code}): ${summary}`;
}

function resolveInstallId(spec: SkillInstallSpec, index: number): string {
  return (spec.id ?? `${spec.kind}-${index}`).trim();
}

function findInstallSpec(
  entry: SkillEntry,
  installId: string,
): SkillInstallSpec | undefined {
  const specs = entry.clawdbot?.install ?? [];
  for (const [index, spec] of specs.entries()) {
    if (resolveInstallId(spec, index) === installId) return spec;
  }
  return undefined;
}

function buildNodeInstallCommand(
  packageName: string,
  prefs: SkillsInstallPreferences,
): string[] {
  switch (prefs.nodeManager) {
    case "pnpm":
      return ["pnpm", "add", "-g", packageName];
    case "yarn":
      return ["yarn", "global", "add", packageName];
    case "bun":
      return ["bun", "add", "-g", packageName];
    default:
      return ["npm", "install", "-g", packageName];
  }
}

function buildInstallCommand(
  spec: SkillInstallSpec,
  prefs: SkillsInstallPreferences,
): {
  argv: string[] | null;
  error?: string;
} {
  switch (spec.kind) {
    case "brew": {
      if (!spec.formula) return { argv: null, error: "missing brew formula" };
      return { argv: ["brew", "install", spec.formula] };
    }
    case "node": {
      if (!spec.package) return { argv: null, error: "missing node package" };
      return {
        argv: buildNodeInstallCommand(spec.package, prefs),
      };
    }
    case "go": {
      if (!spec.module) return { argv: null, error: "missing go module" };
      return { argv: ["go", "install", spec.module] };
    }
    case "uv": {
      if (!spec.package) return { argv: null, error: "missing uv package" };
      return { argv: ["uv", "tool", "install", spec.package] };
    }
    default:
      return { argv: null, error: "unsupported installer" };
  }
}

async function resolveBrewBinDir(
  timeoutMs: number,
  brewExe?: string,
): Promise<string | undefined> {
  const exe = brewExe ?? (hasBinary("brew") ? "brew" : resolveBrewExecutable());
  if (!exe) return undefined;

  const prefixResult = await runCommandWithTimeout([exe, "--prefix"], {
    timeoutMs: Math.min(timeoutMs, 30_000),
  });
  if (prefixResult.code === 0) {
    const prefix = prefixResult.stdout.trim();
    if (prefix) return path.join(prefix, "bin");
  }

  const envPrefix = process.env.HOMEBREW_PREFIX?.trim();
  if (envPrefix) return path.join(envPrefix, "bin");

  for (const candidate of ["/opt/homebrew/bin", "/usr/local/bin"]) {
    try {
      if (fs.existsSync(candidate)) return candidate;
    } catch {
      // ignore
    }
  }
  return undefined;
}

export async function installSkill(
  params: SkillInstallRequest,
): Promise<SkillInstallResult> {
  const timeoutMs = Math.min(
    Math.max(params.timeoutMs ?? 300_000, 1_000),
    900_000,
  );
  const workspaceDir = resolveUserPath(params.workspaceDir);
  const entries = loadWorkspaceSkillEntries(workspaceDir);
  const entry = entries.find((item) => item.skill.name === params.skillName);
  if (!entry) {
    return {
      ok: false,
      message: `Skill not found: ${params.skillName}`,
      stdout: "",
      stderr: "",
      code: null,
    };
  }

  const spec = findInstallSpec(entry, params.installId);
  if (!spec) {
    return {
      ok: false,
      message: `Installer not found: ${params.installId}`,
      stdout: "",
      stderr: "",
      code: null,
    };
  }

  const prefs = resolveSkillsInstallPreferences(params.config);
  const command = buildInstallCommand(spec, prefs);
  if (command.error) {
    return {
      ok: false,
      message: command.error,
      stdout: "",
      stderr: "",
      code: null,
    };
  }

  const brewExe = hasBinary("brew") ? "brew" : resolveBrewExecutable();
  if (spec.kind === "brew" && !brewExe) {
    return {
      ok: false,
      message: "brew not installed",
      stdout: "",
      stderr: "",
      code: null,
    };
  }
  if (spec.kind === "uv" && !hasBinary("uv")) {
    if (brewExe) {
      const brewResult = await runCommandWithTimeout(
        [brewExe, "install", "uv"],
        {
          timeoutMs,
        },
      );
      if (brewResult.code !== 0) {
        return {
          ok: false,
          message: "Failed to install uv (brew)",
          stdout: brewResult.stdout.trim(),
          stderr: brewResult.stderr.trim(),
          code: brewResult.code,
        };
      }
    } else {
      return {
        ok: false,
        message: "uv not installed (install via brew)",
        stdout: "",
        stderr: "",
        code: null,
      };
    }
  }
  if (!command.argv || command.argv.length === 0) {
    return {
      ok: false,
      message: "invalid install command",
      stdout: "",
      stderr: "",
      code: null,
    };
  }

  if (spec.kind === "brew" && brewExe && command.argv[0] === "brew") {
    command.argv[0] = brewExe;
  }

  if (spec.kind === "go" && !hasBinary("go")) {
    if (brewExe) {
      const brewResult = await runCommandWithTimeout(
        [brewExe, "install", "go"],
        {
          timeoutMs,
        },
      );
      if (brewResult.code !== 0) {
        return {
          ok: false,
          message: "Failed to install go (brew)",
          stdout: brewResult.stdout.trim(),
          stderr: brewResult.stderr.trim(),
          code: brewResult.code,
        };
      }
    } else {
      return {
        ok: false,
        message: "go not installed (install via brew)",
        stdout: "",
        stderr: "",
        code: null,
      };
    }
  }

  let env: NodeJS.ProcessEnv | undefined;
  if (spec.kind === "go" && brewExe) {
    const brewBin = await resolveBrewBinDir(timeoutMs, brewExe);
    if (brewBin) env = { GOBIN: brewBin };
  }

  const result = await (async () => {
    const argv = command.argv;
    if (!argv || argv.length === 0) {
      return { code: null, stdout: "", stderr: "invalid install command" };
    }
    try {
      return await runCommandWithTimeout(argv, {
        timeoutMs,
        env,
      });
    } catch (err) {
      const stderr = err instanceof Error ? err.message : String(err);
      return { code: null, stdout: "", stderr };
    }
  })();

  const success = result.code === 0;
  return {
    ok: success,
    message: success ? "Installed" : formatInstallFailureMessage(result),
    stdout: result.stdout.trim(),
    stderr: result.stderr.trim(),
    code: result.code,
  };
}


--- docs/install/updating.md ---
---
summary: "Updating Clawdbot safely (npm or source), plus rollback strategy"
read_when:
  - Updating Clawdbot
  - Something breaks after an update
---

# Updating

Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart → verify.

## Before you update

- Know how you installed: **npm** (global) vs **from source** (git clone).
- Know how your Gateway is running: **foreground terminal** vs **supervised service** (launchd/systemd).
- Snapshot your tailoring:
  - Config: `~/.clawdbot/clawdbot.json`
  - Credentials: `~/.clawdbot/credentials/`
  - Workspace: `~/clawd`

## Update (npm install)

Global install (pick one):

```bash
npm i -g clawdbot@latest
```

```bash
pnpm add -g clawdbot@latest
```

Then:

```bash
clawdbot doctor
clawdbot gateway restart
clawdbot health
```

Notes:
- If your Gateway runs as a service, `clawdbot gateway restart` is preferred over killing PIDs.
- If you’re pinned to a specific version, see “Rollback / pinning” below.

## Update (Control UI / RPC)

The Control UI has **Update & Restart** (RPC: `update.run`). It:
1) Runs a git update (clean rebase) or package manager update.
2) Writes a restart sentinel with a structured report (stdout/stderr tail).
3) Restarts the gateway and pings the last active session with the report.

If the rebase fails, the gateway aborts and restarts without applying the update.

## Update (from source)

From the repo checkout:

```bash
git pull
pnpm install
pnpm build
pnpm ui:install
pnpm ui:build
pnpm clawdbot doctor
pnpm clawdbot health
```

Notes:
- `pnpm build` matters when you run the packaged `clawdbot` binary ([`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js)) or use Node to run `dist/`.
- If you run directly from TypeScript (`pnpm clawdbot ...` / `bun run clawdbot ...`), a rebuild is usually unnecessary, but **config migrations still apply** → run doctor.

## Always run: `clawdbot doctor`

Doctor is the “safe update” command. It’s intentionally boring: repair + migrate + warn.

Typical things it does:
- Migrate deprecated config keys / legacy config file locations.
- Audit DM policies and warn on risky “open” settings.
- Check Gateway health and can offer to restart.
- Detect and migrate older gateway services (launchd/systemd; legacy schtasks) to current Clawdbot services.
- On Linux, ensure systemd user lingering (so the Gateway survives logout).

Details: [Doctor](/gateway/doctor)

## Start / stop / restart the Gateway

CLI (works regardless of OS):

```bash
clawdbot gateway stop
clawdbot gateway restart
clawdbot gateway --port 18789
```

If you’re supervised:
- macOS launchd (app-bundled LaunchAgent): `launchctl kickstart -k gui/$UID/com.clawdbot.gateway`
- Linux systemd user service: `systemctl --user restart clawdbot-gateway.service`
- Windows (WSL2): `systemctl --user restart clawdbot-gateway.service`

Runbook + exact service labels: [Gateway runbook](/gateway)

## Rollback / pinning (when something breaks)

### Pin (npm)

Install a known-good version:

```bash
npm i -g clawdbot@2026.1.7
```

Then restart + re-run doctor:

```bash
clawdbot doctor
clawdbot gateway restart
```

### Pin (source) by date

Pick a commit from a date (example: “state of main as of 2026-01-01”):

```bash
git fetch origin
git checkout "$(git rev-list -n 1 --before=\"2026-01-01\" origin/main)"
```

Then reinstall deps + restart:

```bash
pnpm install
pnpm build
clawdbot gateway restart
```

If you want to go back to latest later:

```bash
git checkout main
git pull
```

## If you’re stuck

- Run `clawdbot doctor` again and read the output carefully (it often tells you the fix).
- Check: [Troubleshooting](/gateway/troubleshooting)
- Ask in Discord: https://discord.gg/clawd


## Links discovered
- [`dist/entry.js`](https://github.com/clawdbot/clawdbot/blob/main/dist/entry.js)
- [Doctor](https://github.com/clawdbot/clawdbot/blob/main/gateway/doctor.md)
- [Gateway runbook](https://github.com/clawdbot/clawdbot/blob/main/gateway.md)
- [Troubleshooting](https://github.com/clawdbot/clawdbot/blob/main/gateway/troubleshooting.md)

--- ui/src/ui/views/overview.ts ---
import { html } from "lit";

import type { GatewayHelloOk } from "../gateway";
import { formatAgo, formatDurationMs } from "../format";
import { formatNextRun } from "../presenter";
import type { UiSettings } from "../storage";

export type OverviewProps = {
  connected: boolean;
  hello: GatewayHelloOk | null;
  settings: UiSettings;
  password: string;
  lastError: string | null;
  presenceCount: number;
  sessionsCount: number | null;
  cronEnabled: boolean | null;
  cronNext: number | null;
  lastProvidersRefresh: number | null;
  onSettingsChange: (next: UiSettings) => void;
  onPasswordChange: (next: string) => void;
  onSessionKeyChange: (next: string) => void;
  onConnect: () => void;
  onRefresh: () => void;
};

export function renderOverview(props: OverviewProps) {
  const snapshot = props.hello?.snapshot as
    | { uptimeMs?: number; policy?: { tickIntervalMs?: number } }
    | undefined;
  const uptime = snapshot?.uptimeMs ? formatDurationMs(snapshot.uptimeMs) : "n/a";
  const tick = snapshot?.policy?.tickIntervalMs
    ? `${snapshot.policy.tickIntervalMs}ms`
    : "n/a";

  return html`
    <section class="grid grid-cols-2">
      <div class="card">
        <div class="card-title">Gateway Access</div>
        <div class="card-sub">Where the dashboard connects and how it authenticates.</div>
        <div class="form-grid" style="margin-top: 16px;">
          <label class="field">
            <span>WebSocket URL</span>
            <input
              .value=${props.settings.gatewayUrl}
              @input=${(e: Event) => {
                const v = (e.target as HTMLInputElement).value;
                props.onSettingsChange({ ...props.settings, gatewayUrl: v });
              }}
              placeholder="ws://100.x.y.z:18789"
            />
          </label>
          <label class="field">
            <span>Gateway Token</span>
            <input
              .value=${props.settings.token}
              @input=${(e: Event) => {
                const v = (e.target as HTMLInputElement).value;
                props.onSettingsChange({ ...props.settings, token: v });
              }}
              placeholder="CLAWDBOT_GATEWAY_TOKEN"
            />
          </label>
          <label class="field">
            <span>Password (not stored)</span>
            <input
              type="password"
              .value=${props.password}
              @input=${(e: Event) => {
                const v = (e.target as HTMLInputElement).value;
                props.onPasswordChange(v);
              }}
              placeholder="system or shared password"
            />
          </label>
          <label class="field">
            <span>Default Session Key</span>
            <input
              .value=${props.settings.sessionKey}
              @input=${(e: Event) => {
                const v = (e.target as HTMLInputElement).value;
                props.onSessionKeyChange(v);
              }}
            />
          </label>
        </div>
        <div class="row" style="margin-top: 14px;">
          <button class="btn" @click=${() => props.onConnect()}>Connect</button>
          <button class="btn" @click=${() => props.onRefresh()}>Refresh</button>
          <span class="muted">Click Connect to apply connection changes.</span>
        </div>
      </div>

      <div class="card">
        <div class="card-title">Snapshot</div>
        <div class="card-sub">Latest gateway handshake information.</div>
        <div class="stat-grid" style="margin-top: 16px;">
          <div class="stat">
            <div class="stat-label">Status</div>
            <div class="stat-value ${props.connected ? "ok" : "warn"}">
              ${props.connected ? "Connected" : "Disconnected"}
            </div>
          </div>
          <div class="stat">
            <div class="stat-label">Uptime</div>
            <div class="stat-value">${uptime}</div>
          </div>
          <div class="stat">
            <div class="stat-label">Tick Interval</div>
            <div class="stat-value">${tick}</div>
          </div>
          <div class="stat">
            <div class="stat-label">Last Providers Refresh</div>
            <div class="stat-value">
              ${props.lastProvidersRefresh
                ? formatAgo(props.lastProvidersRefresh)
                : "n/a"}
            </div>
          </div>
        </div>
        ${props.lastError
          ? html`<div class="callout danger" style="margin-top: 14px;">
              ${props.lastError}
            </div>`
          : html`<div class="callout" style="margin-top: 14px;">
              Use Connections to link WhatsApp, Telegram, Discord, Signal, or iMessage.
            </div>`}
      </div>
    </section>

    <section class="grid grid-cols-3" style="margin-top: 18px;">
      <div class="card stat-card">
        <div class="stat-label">Instances</div>
        <div class="stat-value">${props.presenceCount}</div>
        <div class="muted">Presence beacons in the last 5 minutes.</div>
      </div>
      <div class="card stat-card">
        <div class="stat-label">Sessions</div>
        <div class="stat-value">${props.sessionsCount ?? "n/a"}</div>
        <div class="muted">Recent session keys tracked by the gateway.</div>
      </div>
      <div class="card stat-card">
        <div class="stat-label">Cron</div>
        <div class="stat-value">
          ${props.cronEnabled == null
            ? "n/a"
            : props.cronEnabled
              ? "Enabled"
              : "Disabled"}
        </div>
        <div class="muted">Next wake ${formatNextRun(props.cronNext)}</div>
      </div>
    </section>

    <section class="card" style="margin-top: 18px;">
      <div class="card-title">Notes</div>
      <div class="card-sub">Quick reminders for remote control setups.</div>
      <div class="note-grid" style="margin-top: 14px;">
        <div>
          <div class="note-title">Tailscale serve</div>
          <div class="muted">
            Prefer serve mode to keep the gateway on loopback with tailnet auth.
          </div>
        </div>
        <div>
          <div class="note-title">Session hygiene</div>
          <div class="muted">Use /new or sessions.patch to reset context.</div>
        </div>
        <div>
          <div class="note-title">Cron reminders</div>
          <div class="muted">Use isolated sessions for recurring runs.</div>
        </div>
      </div>
    </section>
  `;
}


--- docs/index.md ---
---
summary: "Top-level overview of Clawdbot, features, and purpose"
read_when:
  - Introducing Clawdbot to newcomers
---
# CLAWDBOT 🦞

> *"EXFOLIATE! EXFOLIATE!"* — A space lobster, probably

<p align="center">
  <img src="whatsapp-clawd.jpg" alt="CLAWDBOT" width="420" />
</p>

<p align="center">
  <strong>Any OS + WhatsApp/Telegram/Discord/iMessage gateway for AI agents (Pi).</strong><br />
  Send a message, get an agent response — from your pocket.
</p>

<p align="center">
  <a href="https://github.com/clawdbot/clawdbot">GitHub</a> ·
  <a href="https://github.com/clawdbot/clawdbot/releases">Releases</a> ·
  <a href="https://docs.clawd.bot">Docs</a> ·
  <a href="https://docs.clawd.bot/start/clawd">Clawd setup</a>
</p>

CLAWDBOT bridges WhatsApp (via WhatsApp Web / Baileys), Telegram (Bot API / grammY), Discord (Bot API / discord.js), and iMessage (imsg CLI) to coding agents like [Pi](https://github.com/badlogic/pi-mono).
It’s built for [Clawd](https://clawd.me), a space lobster who needed a TARDIS.

## Start here

- **New install from zero:** https://docs.clawd.bot/start/getting-started
- **Guided setup (recommended):** https://docs.clawd.bot/start/wizard (`clawdbot onboard`)
- **Open the dashboard (local Gateway):** http://127.0.0.1:18789/ (or http://localhost:18789/)

If the Gateway is running on the same computer, that link opens the browser Control UI
immediately. If it fails, start the Gateway first: `clawdbot gateway`.

## Dashboard (browser Control UI)

The dashboard is the browser Control UI for chat, config, nodes, sessions, and more.
Local default: http://127.0.0.1:18789/
Remote access: https://docs.clawd.bot/web and https://docs.clawd.bot/gateway/tailscale

## How it works

```
WhatsApp / Telegram / Discord
        │
        ▼
  ┌───────────────────────────┐
  │          Gateway          │  ws://127.0.0.1:18789 (loopback-only)
  │     (single source)       │  tcp://0.0.0.0:18790 (Bridge)
  │                           │  http://<gateway-host>:18793
  │                           │    /__clawdbot__/canvas/ (Canvas host)
  └───────────┬───────────────┘
              │
              ├─ Pi agent (RPC)
              ├─ CLI (clawdbot …)
              ├─ Chat UI (SwiftUI)
              ├─ macOS app (Clawdbot.app)
              ├─ iOS node via Bridge + pairing
              └─ Android node via Bridge + pairing
```

Most operations flow through the **Gateway** (`clawdbot gateway`), a single long-running process that owns provider connections and the WebSocket control plane.

## Network model

- **One Gateway per host**: it is the only process allowed to own the WhatsApp Web session.
- **Loopback-first**: Gateway WS defaults to `ws://127.0.0.1:18789`.
  - For Tailnet access, run `clawdbot gateway --bind tailnet --token ...` (token is required for non-loopback binds).
- **Bridge for nodes**: optional LAN/tailnet-facing bridge on `tcp://0.0.0.0:18790` for paired nodes (Bonjour-discoverable).
- **Canvas host**: HTTP file server on `canvasHost.port` (default `18793`), serving `/__clawdbot__/canvas/` for node WebViews; see [`docs/configuration.md`](https://docs.clawd.bot/gateway/configuration) (`canvasHost`).
- **Remote use**: SSH tunnel or tailnet/VPN; see [`docs/remote.md`](https://docs.clawd.bot/gateway/remote) and [`docs/discovery.md`](https://docs.clawd.bot/gateway/discovery).

## Features (high level)

- 📱 **WhatsApp Integration** — Uses Baileys for WhatsApp Web protocol
- ✈️ **Telegram Bot** — DMs + groups via grammY
- 🎮 **Discord Bot** — DMs + guild channels via discord.js
- 💬 **iMessage** — Local imsg CLI integration (macOS)
- 🤖 **Agent bridge** — Pi (RPC mode) with tool streaming
- ⏱️ **Streaming + chunking** — Block streaming + Telegram draft streaming details ([/concepts/streaming](/concepts/streaming))
- 🧠 **Multi-agent routing** — Route provider accounts/peers to isolated agents (workspace + per-agent sessions)
- 🔐 **Subscription auth** — Anthropic (Claude Pro/Max) + OpenAI (ChatGPT/Codex) via OAuth
- 💬 **Sessions** — Direct chats collapse into shared `main` (default); groups are isolated
- 👥 **Group Chat Support** — Mention-based by default; owner can toggle `/activation always|mention`
- 📎 **Media Support** — Send and receive images, audio, documents
- 🎤 **Voice notes** — Optional transcription hook
- 🖥️ **WebChat + macOS app** — Local UI + menu bar companion for ops and voice wake
- 📱 **iOS node** — Pairs as a node and exposes a Canvas surface
- 📱 **Android node** — Pairs as a node and exposes Canvas + Chat + Camera

Note: legacy Claude/Codex/Gemini/Opencode paths have been removed; Pi is the only coding-agent path.

## Quick start

Runtime requirement: **Node ≥ 22**.

```bash
# From source (recommended while the npm package is still settling)
pnpm install
pnpm build
pnpm link --global

# Pair WhatsApp Web (shows QR)
clawdbot login

# Run the Gateway (leave running)
clawdbot gateway --port 18789
```

Multi-instance quickstart (optional):

```bash
CLAWDBOT_CONFIG_PATH=~/.clawdbot/a.json \
CLAWDBOT_STATE_DIR=~/.clawdbot-a \
clawdbot gateway --port 19001
```

Send a test message (requires a running Gateway):

```bash
clawdbot send --to +15555550123 --message "Hello from CLAWDBOT"
```

## Configuration (optional)

Config lives at `~/.clawdbot/clawdbot.json`.

- If you **do nothing**, CLAWDBOT uses the bundled Pi binary in RPC mode with per-sender sessions.
- If you want to lock it down, start with `whatsapp.allowFrom` and (for groups) mention rules.

Example:

```json5
{
  whatsapp: {
    allowFrom: ["+15555550123"],
    groups: { "*": { requireMention: true } }
  },
  routing: { groupChat: { mentionPatterns: ["@clawd"] } }
}
```

## Docs

- Start here:
  - [Docs hubs (all pages linked)](https://docs.clawd.bot/start/hubs)
  - [FAQ](https://docs.clawd.bot/start/faq) ← *common questions answered*
  - [Configuration](https://docs.clawd.bot/gateway/configuration)
  - [Slash commands](https://docs.clawd.bot/tools/slash-commands)
  - [Multi-agent routing](https://docs.clawd.bot/concepts/multi-agent)
  - [Updating / rollback](https://docs.clawd.bot/install/updating)
  - [Pairing (DM + nodes)](https://docs.clawd.bot/start/pairing)
  - [Nix mode](https://docs.clawd.bot/install/nix)
  - [Clawd personal assistant setup](https://docs.clawd.bot/start/clawd)
  - [Skills](https://docs.clawd.bot/tools/skills)
  - [Skills config](https://docs.clawd.bot/tools/skills-config)
  - [Workspace templates](https://docs.clawd.bot/reference/templates/AGENTS)
  - [RPC adapters](https://docs.clawd.bot/reference/rpc)
  - [Gateway runbook](https://docs.clawd.bot/gateway)
  - [Nodes (iOS/Android)](https://docs.clawd.bot/nodes)
  - [Web surfaces (Control UI)](https://docs.clawd.bot/web)
  - [Discovery + transports](https://docs.clawd.bot/gateway/discovery)
  - [Remote access](https://docs.clawd.bot/gateway/remote)
- Providers and UX:
  - [WebChat](https://docs.clawd.bot/web/webchat)
  - [Control UI (browser)](https://docs.clawd.bot/web/control-ui)
  - [Telegram](https://docs.clawd.bot/providers/telegram)
  - [Discord](https://docs.clawd.bot/providers/discord)
  - [iMessage](https://docs.clawd.bot/providers/imessage)
  - [Groups](https://docs.clawd.bot/concepts/groups)
  - [WhatsApp group messages](https://docs.clawd.bot/concepts/group-messages)
  - [Media: images](https://docs.clawd.bot/nodes/images)
  - [Media: audio](https://docs.clawd.bot/nodes/audio)
- Companion apps:
  - [macOS app](https://docs.clawd.bot/platforms/macos)
  - [iOS app](https://docs.clawd.bot/platforms/ios)
  - [Android app](https://docs.clawd.bot/platforms/android)
  - [Windows (WSL2)](https://docs.clawd.bot/platforms/windows)
  - [Linux app](https://docs.clawd.bot/platforms/linux)
- Ops and safety:
  - [Sessions](https://docs.clawd.bot/concepts/session)
  - [Cron jobs](https://docs.clawd.bot/automation/cron-jobs)
  - [Webhooks](https://docs.clawd.bot/automation/webhook)
  - [Gmail hooks (Pub/Sub)](https://docs.clawd.bot/automation/gmail-pubsub)
  - [Security](https://docs.clawd.bot/gateway/security)
  - [Troubleshooting](https://docs.clawd.bot/gateway/troubleshooting)

## The name

**CLAWDBOT = CLAW + TARDIS** — because every space lobster needs a time-and-space machine.

---

*"We're all just playing with our own prompts."* — an AI, probably high on tokens

## Credits

- **Peter Steinberger** ([@steipete](https://twitter.com/steipete)) — Creator, lobster whisperer
- **Mario Zechner** ([@badlogicc](https://twitter.com/badlogicgames)) — Pi creator, security pen-tester
- **Clawd** — The space lobster who demanded a better name

## Core Contributors

- **Maxim Vovshin** (@Hyaxia, 36747317+Hyaxia@users.noreply.github.com) — Blogwatcher skill
- **Nacho Iacovino** (@nachoiacovino, nacho.iacovino@gmail.com) — Location parsing (Telegram + WhatsApp)

## License

MIT — Free as a lobster in the ocean 🦞

---

*"We're all just playing with our own prompts."* — An AI, probably high on tokens


## Links discovered
- [Pi](https://github.com/badlogic/pi-mono)
- [Clawd](https://clawd.me)
- [`docs/configuration.md`](https://docs.clawd.bot/gateway/configuration)
- [`docs/remote.md`](https://docs.clawd.bot/gateway/remote)
- [`docs/discovery.md`](https://docs.clawd.bot/gateway/discovery)
- [/concepts/streaming](https://github.com/clawdbot/clawdbot/blob/main/concepts/streaming.md)
- [Docs hubs (all pages linked)](https://docs.clawd.bot/start/hubs)
- [FAQ](https://docs.clawd.bot/start/faq)
- [Configuration](https://docs.clawd.bot/gateway/configuration)
- [Slash commands](https://docs.clawd.bot/tools/slash-commands)
- [Multi-agent routing](https://docs.clawd.bot/concepts/multi-agent)
- [Updating / rollback](https://docs.clawd.bot/install/updating)
- [Pairing (DM + nodes)](https://docs.clawd.bot/start/pairing)
- [Nix mode](https://docs.clawd.bot/install/nix)
- [Clawd personal assistant setup](https://docs.clawd.bot/start/clawd)
- [Skills](https://docs.clawd.bot/tools/skills)
- [Skills config](https://docs.clawd.bot/tools/skills-config)
- [Workspace templates](https://docs.clawd.bot/reference/templates/AGENTS)
- [RPC adapters](https://docs.clawd.bot/reference/rpc)
- [Gateway runbook](https://docs.clawd.bot/gateway)
- [Nodes (iOS/Android)](https://docs.clawd.bot/nodes)
- [Web surfaces (Control UI)](https://docs.clawd.bot/web)
- [Discovery + transports](https://docs.clawd.bot/gateway/discovery)
- [Remote access](https://docs.clawd.bot/gateway/remote)
- [WebChat](https://docs.clawd.bot/web/webchat)
- [Control UI (browser)](https://docs.clawd.bot/web/control-ui)
- [Telegram](https://docs.clawd.bot/providers/telegram)
- [Discord](https://docs.clawd.bot/providers/discord)
- [iMessage](https://docs.clawd.bot/providers/imessage)
- [Groups](https://docs.clawd.bot/concepts/groups)
- [WhatsApp group messages](https://docs.clawd.bot/concepts/group-messages)
- [Media: images](https://docs.clawd.bot/nodes/images)
- [Media: audio](https://docs.clawd.bot/nodes/audio)
- [macOS app](https://docs.clawd.bot/platforms/macos)
- [iOS app](https://docs.clawd.bot/platforms/ios)
- [Android app](https://docs.clawd.bot/platforms/android)
- [Windows (WSL2)](https://docs.clawd.bot/platforms/windows)
- [Linux app](https://docs.clawd.bot/platforms/linux)
- [Sessions](https://docs.clawd.bot/concepts/session)
- [Cron jobs](https://docs.clawd.bot/automation/cron-jobs)
- [Webhooks](https://docs.clawd.bot/automation/webhook)
- [Gmail hooks (Pub/Sub)](https://docs.clawd.bot/automation/gmail-pubsub)
- [Security](https://docs.clawd.bot/gateway/security)
- [Troubleshooting](https://docs.clawd.bot/gateway/troubleshooting)
- [@steipete](https://twitter.com/steipete)
- [@badlogicc](https://twitter.com/badlogicgames)
- [GitHub](https://github.com/clawdbot/clawdbot)
- [Releases](https://github.com/clawdbot/clawdbot/releases)
- [Docs](https://docs.clawd.bot)
- [Clawd setup](https://docs.clawd.bot/start/clawd)

--- src/cli/browser-cli-examples.ts ---
export const browserCoreExamples = [
  "clawdbot browser status",
  "clawdbot browser start",
  "clawdbot browser stop",
  "clawdbot browser tabs",
  "clawdbot browser open https://example.com",
  "clawdbot browser focus abcd1234",
  "clawdbot browser close abcd1234",
  "clawdbot browser screenshot",
  "clawdbot browser screenshot --full-page",
  "clawdbot browser screenshot --ref 12",
  "clawdbot browser snapshot",
  "clawdbot browser snapshot --format aria --limit 200",
];

export const browserActionExamples = [
  "clawdbot browser navigate https://example.com",
  "clawdbot browser resize 1280 720",
  "clawdbot browser click 12 --double",
  'clawdbot browser type 23 "hello" --submit',
  "clawdbot browser press Enter",
  "clawdbot browser hover 44",
  "clawdbot browser drag 10 11",
  "clawdbot browser select 9 OptionA OptionB",
  "clawdbot browser upload /tmp/file.pdf",
  'clawdbot browser fill --fields \'[{"ref":"1","value":"Ada"}]\'',
  "clawdbot browser dialog --accept",
  'clawdbot browser wait --text "Done"',
  "clawdbot browser evaluate --fn '(el) => el.textContent' --ref 7",
  "clawdbot browser console --level error",
  "clawdbot browser pdf",
];


--- skills/1password/references/cli-examples.md ---
# op CLI examples (from op help)

## Sign in

- `op signin`
- `op signin --account <shorthand|signin-address|account-id|user-id>`

## Read

- `op read op://app-prod/db/password`
- `op read "op://app-prod/db/one-time password?attribute=otp"`
- `op read "op://app-prod/ssh key/private key?ssh-format=openssh"`
- `op read --out-file ./key.pem op://app-prod/server/ssh/key.pem`

## Run

- `export DB_PASSWORD="op://app-prod/db/password"`
- `op run --no-masking -- printenv DB_PASSWORD`
- `op run --env-file="./.env" -- printenv DB_PASSWORD`

## Inject

- `echo "db_password: {{ op://app-prod/db/password }}" | op inject`
- `op inject -i config.yml.tpl -o config.yml`

## Whoami / accounts

- `op whoami`
- `op account list`


--- skills/openai-whisper-api/SKILL.md ---
---
name: openai-whisper-api
description: Transcribe audio via OpenAI Audio Transcriptions API (Whisper).
homepage: https://platform.openai.com/docs/guides/speech-to-text
metadata: {"clawdbot":{"emoji":"☁️","requires":{"bins":["curl"],"env":["OPENAI_API_KEY"]},"primaryEnv":"OPENAI_API_KEY"}}
---

# OpenAI Whisper API (curl)

Transcribe an audio file via OpenAI’s `/v1/audio/transcriptions` endpoint.

## Quick start

```bash
{baseDir}/scripts/transcribe.sh /path/to/audio.m4a
```

Defaults:
- Model: `whisper-1`
- Output: `<input>.txt`

## Useful flags

```bash
{baseDir}/scripts/transcribe.sh /path/to/audio.ogg --model whisper-1 --out /tmp/transcript.txt
{baseDir}/scripts/transcribe.sh /path/to/audio.m4a --language en
{baseDir}/scripts/transcribe.sh /path/to/audio.m4a --prompt "Speaker names: Peter, Daniel"
{baseDir}/scripts/transcribe.sh /path/to/audio.m4a --json --out /tmp/transcript.json
```

## API key

Set `OPENAI_API_KEY`, or configure it in `~/.clawdbot/clawdbot.json`:

```json5
{
  skills: {
    "openai-whisper-api": {
      apiKey: "OPENAI_KEY_HERE"
    }
  }
}
```


--- skills/model-usage/references/codexbar-cli.md ---
# CodexBar CLI quick ref (usage + cost)

## Install
- App: Preferences -> Advanced -> Install CLI
- Repo: ./bin/install-codexbar-cli.sh

## Commands
- Usage snapshot (web/cli sources):
  - codexbar usage --format json --pretty
  - codexbar --provider all --format json
- Local cost usage (Codex + Claude only):
  - codexbar cost --format json --pretty
  - codexbar cost --provider codex|claude --format json

## Cost JSON fields
The payload is an array (one per provider).
- provider, source, updatedAt
- sessionTokens, sessionCostUSD
- last30DaysTokens, last30DaysCostUSD
- daily[]: date, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens, totalTokens, totalCost, modelsUsed, modelBreakdowns[]
- modelBreakdowns[]: modelName, cost
- totals: totalInputTokens, totalOutputTokens, cacheReadTokens, cacheCreationTokens, totalTokens, totalCost

## Notes
- Cost usage is local-only. It reads JSONL logs under:
  - Codex: ~/.codex/sessions/**/*.jsonl
  - Claude: ~/.config/claude/projects/**/*.jsonl or ~/.claude/projects/**/*.jsonl
- If web usage is required (non-local), use codexbar usage (not cost).


--- skills/himalaya/references/configuration.md ---
# Himalaya Configuration Reference

Configuration file location: `~/.config/himalaya/config.toml`

## Minimal IMAP + SMTP Setup

```toml
[accounts.default]
email = "user@example.com"
display-name = "Your Name"
default = true

# IMAP backend for reading emails
backend.type = "imap"
backend.host = "imap.example.com"
backend.port = 993
backend.encryption.type = "tls"
backend.login = "user@example.com"
backend.auth.type = "password"
backend.auth.raw = "your-password"

# SMTP backend for sending emails
message.send.backend.type = "smtp"
message.send.backend.host = "smtp.example.com"
message.send.backend.port = 587
message.send.backend.encryption.type = "start-tls"
message.send.backend.login = "user@example.com"
message.send.backend.auth.type = "password"
message.send.backend.auth.raw = "your-password"
```

## Password Options

### Raw password (testing only, not recommended)
```toml
backend.auth.raw = "your-password"
```

### Password from command (recommended)
```toml
backend.auth.cmd = "pass show email/imap"
# backend.auth.cmd = "security find-generic-password -a user@example.com -s imap -w"
```

### System keyring (requires keyring feature)
```toml
backend.auth.keyring = "imap-example"
```
Then run `himalaya account configure <account>` to store the password.

## Gmail Configuration

```toml
[accounts.gmail]
email = "you@gmail.com"
display-name = "Your Name"
default = true

backend.type = "imap"
backend.host = "imap.gmail.com"
backend.port = 993
backend.encryption.type = "tls"
backend.login = "you@gmail.com"
backend.auth.type = "password"
backend.auth.cmd = "pass show google/app-password"

message.send.backend.type = "smtp"
message.send.backend.host = "smtp.gmail.com"
message.send.backend.port = 587
message.send.backend.encryption.type = "start-tls"
message.send.backend.login = "you@gmail.com"
message.send.backend.auth.type = "password"
message.send.backend.auth.cmd = "pass show google/app-password"
```

**Note:** Gmail requires an App Password if 2FA is enabled.

## iCloud Configuration

```toml
[accounts.icloud]
email = "you@icloud.com"
display-name = "Your Name"

backend.type = "imap"
backend.host = "imap.mail.me.com"
backend.port = 993
backend.encryption.type = "tls"
backend.login = "you@icloud.com"
backend.auth.type = "password"
backend.auth.cmd = "pass show icloud/app-password"

message.send.backend.type = "smtp"
message.send.backend.host = "smtp.mail.me.com"
message.send.backend.port = 587
message.send.backend.encryption.type = "start-tls"
message.send.backend.login = "you@icloud.com"
message.send.backend.auth.type = "password"
message.send.backend.auth.cmd = "pass show icloud/app-password"
```

**Note:** Generate an app-specific password at appleid.apple.com

## Folder Aliases

Map custom folder names:
```toml
[accounts.default.folder.alias]
inbox = "INBOX"
sent = "Sent"
drafts = "Drafts"
trash = "Trash"
```

## Multiple Accounts

```toml
[accounts.personal]
email = "personal@example.com"
default = true
# ... backend config ...

[accounts.work]
email = "work@company.com"
# ... backend config ...
```

Switch accounts with `--account`:
```bash
himalaya --account work envelope list
```

## Notmuch Backend (local mail)

```toml
[accounts.local]
email = "user@example.com"

backend.type = "notmuch"
backend.db-path = "~/.mail/.notmuch"
```

## OAuth2 Authentication (for providers that support it)

```toml
backend.auth.type = "oauth2"
backend.auth.client-id = "your-client-id"
backend.auth.client-secret.cmd = "pass show oauth/client-secret"
backend.auth.access-token.cmd = "pass show oauth/access-token"
backend.auth.refresh-token.cmd = "pass show oauth/refresh-token"
backend.auth.auth-url = "https://provider.com/oauth/authorize"
backend.auth.token-url = "https://provider.com/oauth/token"
```

## Additional Options

### Signature
```toml
[accounts.default]
signature = "Best regards,\nYour Name"
signature-delim = "-- \n"
```

### Downloads directory
```toml
[accounts.default]
downloads-dir = "~/Downloads/himalaya"
```

### Editor for composing
Set via environment variable:
```bash
export EDITOR="vim"
```


--- skills/1password/references/get-started.md ---
# 1Password CLI get-started (summary)

- Works on macOS, Windows, and Linux.
  - macOS/Linux shells: bash, zsh, sh, fish.
  - Windows shell: PowerShell.
- Requires a 1Password subscription and the desktop app to use app integration.
- macOS requirement: Big Sur 11.0.0 or later.
- Linux app integration requires PolKit + an auth agent.
- Install the CLI per the official doc for your OS.
- Enable desktop app integration in the 1Password app:
  - Open and unlock the app, then select your account/collection.
  - macOS: Settings > Developer > Integrate with 1Password CLI (Touch ID optional).
  - Windows: turn on Windows Hello, then Settings > Developer > Integrate.
  - Linux: Settings > Security > Unlock using system authentication, then Settings > Developer > Integrate.
- After integration, run any command to sign in (example in docs: `op vault list`).
- If multiple accounts: use `op signin` to pick one, or `--account` / `OP_ACCOUNT`.
- For non-integration auth, use `op account add`.


--- skills/himalaya/references/message-composition.md ---
# Message Composition with MML (MIME Meta Language)

Himalaya uses MML for composing emails. MML is a simple XML-based syntax that compiles to MIME messages.

## Basic Message Structure

An email message is a list of **headers** followed by a **body**, separated by a blank line:

```
From: sender@example.com
To: recipient@example.com
Subject: Hello World

This is the message body.
```

## Headers

Common headers:
- `From`: Sender address
- `To`: Primary recipient(s)
- `Cc`: Carbon copy recipients
- `Bcc`: Blind carbon copy recipients
- `Subject`: Message subject
- `Reply-To`: Address for replies (if different from From)
- `In-Reply-To`: Message ID being replied to

### Address Formats

```
To: user@example.com
To: John Doe <john@example.com>
To: "John Doe" <john@example.com>
To: user1@example.com, user2@example.com, "Jane" <jane@example.com>
```

## Plain Text Body

Simple plain text email:
```
From: alice@localhost
To: bob@localhost
Subject: Plain Text Example

Hello, this is a plain text email.
No special formatting needed.

Best,
Alice
```

## MML for Rich Emails

### Multipart Messages

Alternative text/html parts:
```
From: alice@localhost
To: bob@localhost
Subject: Multipart Example

<#multipart type=alternative>
This is the plain text version.
<#part type=text/html>
<html><body><h1>This is the HTML version</h1></body></html>
<#/multipart>
```

### Attachments

Attach a file:
```
From: alice@localhost
To: bob@localhost
Subject: With Attachment

Here is the document you requested.

<#part filename=/path/to/document.pdf><#/part>
```

Attachment with custom name:
```
<#part filename=/path/to/file.pdf name=report.pdf><#/part>
```

Multiple attachments:
```
<#part filename=/path/to/doc1.pdf><#/part>
<#part filename=/path/to/doc2.pdf><#/part>
```

### Inline Images

Embed an image inline:
```
From: alice@localhost
To: bob@localhost
Subject: Inline Image

<#multipart type=related>
<#part type=text/html>
<html><body>
<p>Check out this image:</p>
<img src="cid:image1">
</body></html>
<#part disposition=inline id=image1 filename=/path/to/image.png><#/part>
<#/multipart>
```

### Mixed Content (Text + Attachments)

```
From: alice@localhost
To: bob@localhost
Subject: Mixed Content

<#multipart type=mixed>
<#part type=text/plain>
Please find the attached files.

Best,
Alice
<#part filename=/path/to/file1.pdf><#/part>
<#part filename=/path/to/file2.zip><#/part>
<#/multipart>
```

## MML Tag Reference

### `<#multipart>`
Groups multiple parts together.
- `type=alternative`: Different representations of same content
- `type=mixed`: Independent parts (text + attachments)
- `type=related`: Parts that reference each other (HTML + images)

### `<#part>`
Defines a message part.
- `type=<mime-type>`: Content type (e.g., `text/html`, `application/pdf`)
- `filename=<path>`: File to attach
- `name=<name>`: Display name for attachment
- `disposition=inline`: Display inline instead of as attachment
- `id=<cid>`: Content ID for referencing in HTML

## Composing from CLI

### Interactive compose
Opens your `$EDITOR`:
```bash
himalaya message write
```

### Reply (opens editor with quoted message)
```bash
himalaya message reply 42
himalaya message reply 42 --all  # reply-all
```

### Forward
```bash
himalaya message forward 42
```

### Send from stdin
```bash
cat message.txt | himalaya template send
```

### Prefill headers from CLI
```bash
himalaya message write \
  -H "To:recipient@example.com" \
  -H "Subject:Quick Message" \
  "Message body here"
```

## Tips

- The editor opens with a template; fill in headers and body.
- Save and exit the editor to send; exit without saving to cancel.
- MML parts are compiled to proper MIME when sending.
- Use `himalaya message export --full` to inspect the raw MIME structure of received emails.


--- vendor/a2ui/renderers/angular/src/public-api.ts ---
/*
 Copyright 2025 Google LLC

 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at

      https://www.apache.org/licenses/LICENSE-2.0

 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */

export * from './lib/rendering/index';
export * from './lib/data/index';
export * from './lib/config';
export * from './lib/catalog/default';
export { Surface } from './lib/catalog/surface';


--- CHANGELOG.md ---
# Changelog

**Why this looks different:** the project was renamed from **Clawdis → Clawdbot**. To make the transition clear, releases now use **date-based versions** (`YYYY.M.D`) and the changelog is **compressed** into milestone summaries. Full detail still lives in git history and the docs.

## Unreleased

### Breaking
- **SECURITY (update ASAP):** inbound DMs are now **locked down by default** on Telegram/WhatsApp/Signal/iMessage/Discord/Slack.
  - Previously, if you didn’t configure an allowlist, your bot could be **open to anyone** (especially discoverable Telegram bots).
  - New default: DM pairing (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`).
  - To keep old “open to everyone” behavior: set `dmPolicy="open"` and include `"*"` in the relevant `allowFrom` (Discord/Slack: `discord.dm.allowFrom` / `slack.dm.allowFrom`).
  - Approve requests via `clawdbot pairing list --provider <provider>` + `clawdbot pairing approve --provider <provider> <code>` (Telegram also supports `clawdbot telegram pairing ...`).
- Sandbox: default `agent.sandbox.scope` to `"agent"` (one container/workspace per agent). Use `"session"` for per-session isolation; `"shared"` disables cross-session isolation.
- Timestamps in agent envelopes are now UTC (compact `YYYY-MM-DDTHH:mmZ`); removed `messages.timestampPrefix`. Add `agent.userTimezone` to tell the model the user’s local time (system prompt only).
- Model config schema changes (auth profiles + model lists); doctor auto-migrates and the gateway rewrites legacy configs on startup.
- Commands: gate all slash commands to authorized senders; add `/compact` to manually compact session context.
- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior.
- Auto-reply: removed `autoReply` from Discord/Slack/Telegram channel configs; use `requireMention` instead (Telegram topics now support `requireMention` overrides).

### Fixes
- macOS: harden Voice Wake tester/runtime (pause trigger, mic persistence, local-only tester) and keep transcript logs private. Thanks @xadenryan for PR #438.
- macOS: preserve node bridge tunnel port override so remote nodes connect on the bridge port. Thanks @sircrumpet for PR #364.
- Doctor/Daemon: surface gateway runtime state + port collision diagnostics; warn on legacy workspace dirs.
- Gateway/CLI: include gateway target/source details in close/timeout errors and verbose health/status output.
- Gateway/CLI: honor `gateway.auth.password` for local CLI calls when env is unset. Thanks @jeffersonwarrior for PR #301.
- Discord: format slow listener logs in seconds to match shared duration style.
- CLI: show colored table output for `clawdbot cron list` (JSON behind `--json`).
- CLI: add cron `create`/`remove`/`delete` aliases for job management.
- Agent: avoid duplicating context/skills when SDK rebuilds the system prompt. (#418)
- Agent: replace SDK base system prompt with ClaudeBot prompt, add skills guidance, and document the layout.
- Signal: reconnect SSE monitor with abortable backoff; log stream errors. Thanks @nexty5870 for PR #430.
- Gateway: pass resolved provider as messageProvider for agent runs so provider-specific tools are available. Thanks @imfing for PR #389.
- Doctor: add state integrity checks + repair prompts for missing sessions/state dirs, transcript mismatches, and permission issues; document full doctor flow and workspace backup tips.
- Discord/Telegram: add per-request retry policy with configurable delays and docs.
- Telegram: run long polling via grammY runner with per-chat sequentialization and concurrency tied to `agent.maxConcurrent`. Thanks @mukhtharcm for PR #366.
- macOS: prevent gateway launchd startup race where the app could kill a just-started gateway; avoid unnecessary `bootout` and ensure the job is enabled at login. Fixes #306. Thanks @gupsammy for PR #387.
- macOS: ignore ciao announcement cancellation rejections during Bonjour shutdown to avoid unhandled exits. Thanks @emanuelst for PR #419.
- Pairing: generate DM pairing codes with CSPRNG, expire pending codes after 1 hour, and avoid re-sending codes for already pending requests.
- Pairing: lock + atomically write pairing stores with 0600 perms and stop logging pairing codes in provider logs.
- WhatsApp: add self-phone mode (no pairing replies for outbound DMs) and onboarding prompt for personal vs separate numbers (auto allowlist + response prefix for personal).
- Discord: include all inbound attachments in `MediaPaths`/`MediaUrls` (back-compat `MediaPath`/`MediaUrl` still first).
- Sandbox: add `agent.sandbox.workspaceAccess` (`none`/`ro`/`rw`) to control agent workspace visibility inside the container; `ro` hard-disables `write`/`edit`.
- Telegram: default `replyToMode` to `"first"`, add forum topic reply threading for tool sends, and update Telegram docs. Thanks @mneves75 for PR #326.
- Agent: suppress duplicate messaging tool confirmations and honor per-provider reply threading in auto-replies. Thanks @mneves75 for PR #326.
- Routing: allow per-agent sandbox overrides (including `workspaceAccess` and `sandbox.tools`) plus per-agent tool policies in multi-agent configs. Thanks @pasogott for PR #380.
- Sandbox: allow per-agent `routing.agents.<agentId>.sandbox.{docker,browser,prune}.*` overrides for multi-agent gateways (ignored when `scope: "shared"`).
- Tools: make per-agent tool policies override global defaults and run bash synchronously when `process` is disallowed.
- Tools: scope `process` sessions per agent to prevent cross-agent visibility.
- Cron: clamp timer delay to avoid TimeoutOverflowWarning. Thanks @emanuelst for PR #412.
- Web UI: allow reconnect + password URL auth for the control UI and always scrub auth params from the URL. Thanks @oswalpalash for PR #414.
- Web UI: add Connect button on Overview to apply connection changes. Thanks @wizaj for PR #385.
- Web UI: keep Focus toggle on the top bar (swap with theme toggle) so it stays visible. Thanks @RobOK2050 for reporting. (#440)
- ClawdbotKit: fix SwiftPM resource bundling path for `tool-display.json`. Thanks @fcatuhe for PR #398.
- Tools: add Telegram/WhatsApp reaction tools (with per-provider gating). Thanks @zats for PR #353.
- Tools: flatten literal-union schemas for Claude on Vertex AI. Thanks @carlulsoe for PR #409.
- Tools: keep tool failure logs concise (no stack traces); full stack only in debug logs.
- Tools: add nodes tool run invoke-timeout support. Thanks @sircrumpet for PR #433.
- Tools: unify reaction removal semantics across Discord/Slack/Telegram/WhatsApp and allow WhatsApp reaction routing across accounts.
- Android: fix APK output filename renaming after AGP updates. Thanks @Syhids for PR #410.
- Android: rotate camera photos by EXIF orientation. Thanks @fcatuhe for PR #403.
- Gateway/CLI: add daemon runtime selection (Node recommended; Bun optional) and document WhatsApp/Baileys Bun WebSocket instability on reconnect.
- CLI: add `clawdbot docs` live docs search with pretty output.
- CLI: add `clawdbot agents` (list/add/delete) with wizarded workspace/setup, provider login, and full prune on delete.
- Discord/Slack: fork thread sessions (agent-scoped) and inject thread starters for context. Thanks @thewilloftheshadow for PR #400.
- Agent: treat compaction retry AbortError as a fallback trigger without swallowing non-abort errors. Thanks @erikpr1994 for PR #341.
- Agent: add opt-in session pruning for tool results to reduce context bloat. Thanks @maxsumrall for PR #381.
- Agent: protect bootstrap prefix from context pruning. Thanks @maxsumrall for PR #381.
- Agent: deliver final replies for non-streaming models when block chunking is enabled. Thank you @mneves75 for PR #369!
- Agent: trim bootstrap context injections and keep group guidance concise (emoji reactions allowed). Thanks @tobiasbischoff for PR #370.
- Agent: return a friendly context overflow response (413/request_too_large). Thanks @alejandroOPI for PR #395.
- Sub-agents: allow `sessions_spawn` model overrides and error on invalid models. Thanks @azade-c for PR #298.
- Sub-agents: skip invalid model overrides with a warning and keep the run alive; tool exceptions now return tool errors instead of crashing the agent.
- Sessions: forward explicit sessionKey through gateway/chat/node bridge to avoid sub-agent sessionId mixups.
- Heartbeat: default interval 30m; clarified default prompt usage and HEARTBEAT.md template behavior.
- Onboarding: write auth profiles to the multi-agent path (`~/.clawdbot/agents/main/agent/`) so the gateway finds credentials on first startup. Thanks @minghinmatthewlam for PR #327.
- Docs: add missing `ui:install` setup step in the README. Thanks @hugobarauna for PR #300.
- Docs: sanitize AGENTS guidance and add Clawdis migration troubleshooting note. Thanks @buddyh for PR #348.
- Docs: add ClawdHub guide and hubs link for browsing, install, and sync workflows.
- Docs: add FAQ for PNPM/Bun lockfile migration warning; link AgentSkills spec + ClawdHub guide (`/clawdhub`) from skills docs.
- Docs: add showcase projects (xuezh, gohome, roborock, padel-cli). Thanks @joshp123.
- Docs: add Couch Potato Dev Mode showcase entry. Thanks @dbhurley for PR #442.
- Build: import tool-display JSON as a module instead of runtime file reads. Thanks @mukhtharcm for PR #312.
- Status: add provider usage snapshots to `/status`, `clawdbot status --usage`, and the macOS menu bar.
- Build: fix macOS packaging QR smoke test for the bun-compiled relay. Thanks @dbhurley for PR #358.
- Browser: fix `browser snapshot`/`browser act` timeouts under Bun by patching Playwright’s CDP WebSocket selection. Thanks @azade-c for PR #307.
- Browser: detect Chrome/Chromium installs on Windows for the browser tool. Thanks @mrdbstn for PR #439.
- Browser: add `--browser-profile` flag and honor profile in tabs routes + browser tool. Thanks @jamesgroat for PR #324.
- Gmail: include tailscale command exit codes/output when hook setup fails (easier debugging).
- Telegram: stop typing after tool results. Thanks @AbhisekBasu1 for PR #322.
- Telegram: include sender identity in group envelope headers. (#336)
- Telegram: support forum topics with topic-isolated sessions and message_thread_id routing. Thanks @HazAT, @nachoiacovino, @RandyVentures for PR #321/#333/#334.
- Telegram: add draft streaming via `sendMessageDraft` with `telegram.streamMode`, plus `/reasoning stream` for draft-only reasoning.
- Telegram: honor `/activation` session mode for group mention gating and clarify group activation docs. Thanks @julianengel for PR #377.
- Telegram: isolate forum topic transcripts per thread and validate Gemini turn ordering in multi-topic sessions. Thanks @hsrvc for PR #407.
- Telegram: render Telegram-safe HTML for outbound formatting and fall back to plain text on parse errors. Thanks @RandyVentures for PR #435.
- iMessage: ignore disconnect errors during shutdown (avoid unhandled promise rejections). Thanks @antons for PR #359.
- Messages: stop defaulting ack reactions to 👀 when identity emoji is missing.
- Auto-reply: require slash for control commands to avoid false triggers in normal text.
- Commands: accept optional `:` in slash commands and show current levels for /think, /verbose, /reasoning, and /elevated when no args are provided. Thanks @lutr0 for PR #382.
- Auto-reply: add `/reasoning on|off` to expose model reasoning blocks (italic).
- Auto-reply: place reasoning blocks before the final reply text when appended.
- Auto-reply: flag error payloads and improve Bun socket error messaging. Thanks @emanuelst for PR #331.
- Auto-reply: add per-channel/topic skill filters + system prompts for Discord/Slack/Telegram. Thanks @kitze for PR #286.
- Auto-reply: refresh `/status` output with build info, compact context, and queue depth.
- Commands: add `/stop` to the registry and route native aborts to the active chat session. Thanks @nachoiacovino for PR #295.
- Commands: allow `/<alias>` shorthand for `/model` using `agent.models.*.alias`, without shadowing built-ins. Thanks @azade-c for PR #393.
- Commands: unify native + text chat commands behind `commands.*` config (Discord/Slack/Telegram). Thanks @thewilloftheshadow for PR #275.
- Auto-reply: treat steer during compaction as a follow-up, queued until compaction completes.
- Auth: lock auth profile refreshes to avoid multi-instance OAuth logouts; keep credentials on refresh failure.
- Auth/Doctor: migrate Anthropic OAuth configs from `anthropic:default` → `anthropic:<email>` and surface a doctor hint on refresh failures. Thanks @RandyVentures for PR #361. (#363)
- Auth: delete legacy `auth.json` after migration to prevent stale OAuth token overwrites. Thanks @reeltimeapps for PR #368.
- Auth: auto-sync OAuth creds from Claude CLI/Codex CLI into `anthropic:claude-cli`/`openai-codex:codex-cli` and offer them as onboarding/config choices (avoids `refresh_token_reused`). Thanks @pepicrft for PR #374.
- Gateway/CLI: stop forcing localhost URL in remote mode so remote gateway config works. Thanks @oswalpalash for PR #293.
- Onboarding: prompt immediately for OpenAI Codex redirect URL on remote/headless logins.
- Configure: add OpenAI Codex (ChatGPT OAuth) auth choice (align with onboarding).
- Doctor: suggest adding the workspace memory system when missing (opt-out via `--no-workspace-suggestions`).
- Doctor: normalize default workspace path to `~/clawd` (avoid `~/clawdbot`).
- Doctor: add `--yes` and `--non-interactive` for headless/automation runs (`--non-interactive` only applies safe migrations).
- Doctor/CLI: scan for extra gateway-like services (optional `--deep`) and show cleanup hints.
- Gateway/CLI: auto-migrate legacy sessions + agent state layouts on startup (safe; WhatsApp auth still requires `clawdbot doctor`).
- Workspace: only create `BOOTSTRAP.md` for brand-new workspaces (don’t recreate after deletion).
- Build: fix duplicate protocol export, align Codex OAuth options, and add proper-lockfile typings.
- Build: install Bun in the Dockerfile so `pnpm build` can run Bun scripts. Thanks @loukotal for PR #284.
- Typing indicators: stop typing once the reply dispatcher drains to prevent stuck typing across Discord/Telegram/WhatsApp.
- Typing indicators: fix a race that could keep the typing indicator stuck after quick replies. Thanks @thewilloftheshadow for PR #270.
- Google: merge consecutive messages to satisfy strict role alternation for Google provider models. Thanks @Asleep123 for PR #266.
- Postinstall: handle targetDir symlinks in the install script. Thanks @obviyus for PR #272.
- Status: show configured model in `/status` (override-aware). Thanks @azade-c for PR #396.
- WhatsApp/Telegram: add groupPolicy handling for group messages and normalize allowFrom matching (tg/telegram prefixes). Thanks @mneves75.
- Auto-reply: add configurable ack reactions for inbound messages (default 👀 or `identity.emoji`) with scope controls. Thanks @obviyus for PR #178.
- Polls: unify WhatsApp + Discord poll sends via the gateway + CLI (`clawdbot poll`). (#123) — thanks @dbhurley
- Onboarding: resolve CLI entrypoint when running via `npx` so gateway daemon install works without a build step.
- Onboarding: when OpenAI Codex OAuth is used, default to `openai-codex/gpt-5.2` and warn if the selected model lacks auth.
- CLI: auto-migrate legacy config entries on command start (same behavior as gateway startup).
- Gateway: add `gateway stop|restart` helpers and surface launchd/systemd/schtasks stop hints when the gateway is already running.
- Cron/Heartbeat: enqueue cron system events in the resolved main session so heartbeats drain the right queue. Thanks @zats for PR #350.
- Gateway: honor `agent.timeoutSeconds` for `chat.send` and share timeout defaults across chat/cron/auto-reply. Thanks @MSch for PR #229.
- Auth: prioritize OAuth profiles but fall back to API keys when refresh fails; stored profiles now load without explicit auth order.
- Auth/CLI: normalize provider ids and Z.AI aliases across auth profile ordering and models list/status. Thanks @mneves75 for PR #303.
- Control UI: harden config Form view with schema normalization, map editing, and guardrails to prevent data loss on save.
- Cron: normalize cron.add/update inputs, align channel enums/status fields across gateway/CLI/UI/macOS, and add protocol conformance tests. Thanks @mneves75 for PR #256.
- Docs: add group chat participation guidance to the AGENTS template.
- Gmail: stop restart loop when `gog gmail watch serve` fails to bind (address already in use).
- Linux: auto-attempt lingering during onboarding (try without sudo, fallback to sudo) and prompt on install/restart to keep the gateway alive after logout/idle. Thanks @tobiasbischoff for PR #237.
- Skills: add Linuxbrew paths to gateway PATH bootstrap so the Skills UI can run brew installers under systemd/minimal environments.
- TUI: migrate key handling to the updated pi-tui Key matcher API.
- TUI: add `/elev` alias for `/elevated`.
- Logging: redact sensitive tokens in verbose tool summaries by default (configurable patterns).
- macOS: keep app connection settings local in remote mode to avoid overwriting gateway config. Thanks @ngutman for PR #310.
- macOS: honor discovered gateway ports (Bonjour TXT) so remote tunnels connect to the right ports. Thanks @kkarimi for PR #375.
- macOS: prefer gateway config reads/writes in local mode (fall back to disk if the gateway is unavailable).
- macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`.
- macOS: Connections settings now use a custom sidebar to avoid toolbar toggle issues, with rounded styling and full-width row hit targets.
- macOS: drop deprecated `afterMs` from agent wait params to match gateway schema.
- Auth: add OpenAI Codex OAuth support and migrate legacy oauth.json into auth.json.
- Model: `/model` list shows auth source (masked key or OAuth email) per provider.
- Model: `/model list` is an alias for `/model`.
- Model: `/model` output now includes auth source location (env/auth.json/models.json).
- Model: avoid duplicate `missing (missing)` auth labels in `/model` list output.
- Auth: when `openai` has no API key but Codex OAuth exists, suggest `openai-codex/gpt-5.2` vs `OPENAI_API_KEY`.
- Docs: clarify auth storage, migration, and OpenAI Codex OAuth onboarding.
- Docs: clarify per-session sandbox isolation and `perSession` sharing risks.
- Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments.
- Sandbox: enable session tools in sandboxed sessions with spawned-only visibility by default (opt-in `agent.sandbox.sessionToolsVisibility = "all"`).
- Control UI: show a reading indicator bubble while the assistant is responding.
- Control UI: animate reading indicator dots (honors reduced-motion).
- Control UI: stabilize chat streaming during tool runs (no flicker/vanishing text; correct run scoping).
- Google: recover from corrupted transcripts that start with an assistant tool call to avoid Cloud Code Assist 400 ordering errors. Thanks @jonasjancarik for PR #421. (#406)
- Control UI: let config-form enums select empty-string values. Thanks @sreekaransrinath for PR #268.
- Control UI: scroll chat to bottom on initial load. Thanks @kiranjd for PR #274.
- Control UI: add Chat focus mode toggle to collapse header + sidebar.
- Control UI: tighten focus mode spacing (reduce top padding, add comfortable compose inset).
- Control UI: standardize UI build instructions on `bun run ui:*` (fallback supported).
- Status: show runtime (docker/direct) and move shortcuts to `/help`.
- Status: show model auth source (api-key/oauth).
- Status: fix zero token counters for Anthropic (Opus) sessions by normalizing usage fields and ignoring empty usage updates.
- Block streaming: avoid splitting Markdown fenced blocks and reopen fences when forced to split.
- Block streaming: preserve leading indentation in block replies (lists, indented fences).
- Docs: document systemd lingering and logged-in session requirements on macOS/Windows.
- Auto-reply: centralize tool/block/final dispatch across providers for consistent streaming + heartbeat/prefix handling. Thanks @MSch for PR #225.
- Routing: route replies back to the originating provider/chat when multiple providers share the same session. Thanks @jalehman for PR #328.
- Heartbeat: make HEARTBEAT_OK ack padding configurable across heartbeat and cron delivery. (#238) — thanks @jalehman
- Skills: emit MEDIA token after Nano Banana Pro image generation. Thanks @Iamadig for PR #271.
- WhatsApp: set sender E.164 for direct chats so owner commands work in DMs.
- Slack: keep auto-replies in the original thread when responding to thread messages. Thanks @scald for PR #251.
- Slack: send typing status updates via assistant threads. Thanks @thewilloftheshadow for PR #320.
- Slack: fix Slack provider startup under Bun by using a named import for Bolt `App`. Thanks @snopoke for PR #299.
- Discord: surface missing-permission hints (muted/role overrides) when replies fail.
- Discord: use channel IDs for DMs instead of user IDs. Thanks @VACInc for PR #261.
- Discord: treat empty message content as media placeholder so voice messages are not dropped; enables `routing.transcribeAudio`. Thanks @VACInc for PR #339.
- Docs: clarify Slack manifest scopes (current vs optional) with references. Thanks @jarvis-medmatic for PR #235.
- Control UI: avoid Slack config ReferenceError by reading slack config snapshots. Thanks @sreekaransrinath for PR #249.
- Auth: rotate across multiple OAuth profiles with cooldown tracking and email-based profile IDs. Thanks @mukhtharcm for PR #269.
- Auth: fix multi-account OAuth rotation so round-robin alternates instead of pinning to lastGood. Thanks @mukhtharcm for PR #281.
- Auth: lock auth profile usage updates and fail fast on 429s during rotation. Thanks @mukhtharcm for PR #342.
- Configure: stop auto-writing `auth.order` for newly added auth profiles (round-robin default unless explicitly pinned).
- Telegram: honor routing.groupChat.mentionPatterns for group mention gating. Thanks Kevin Kern (@regenrek) for PR #242.
- Telegram: gate groups via `telegram.groups` allowlist (align with WhatsApp/iMessage). Thanks @kitze for PR #241.
- Telegram: support media groups (multi-image messages). Thanks @obviyus for PR #220.
- Telegram/WhatsApp: parse shared locations (pins, places, live) and expose structured ctx fields. Thanks @nachoiacovino for PR #194.
- Auto-reply: block unauthorized `/reset` and infer WhatsApp senders from E.164 inputs.
- Auto-reply: reset corrupted Gemini sessions when function-call ordering breaks. Thanks @VACInc for PR #297.
- Auto-reply: track compaction count in session status; verbose mode announces auto-compactions.
- Telegram: notify users when inbound media exceeds size limits. Thanks @jarvis-medmatic for PR #283.
- Telegram: send GIF media as animations (auto-play) and improve filename sniffing.
- Bash tool: inherit gateway PATH so Nix-provided tools resolve during commands. Thanks @joshp123 for PR #202.
- Delivery chunking: keep Markdown fenced code blocks valid when splitting long replies (close + reopen fences).
- Auth: prefer OAuth profiles over API keys during round-robin selection (prevents OAuth “lost after one message” when both are configured).
- Models: extend `clawdbot models` status output with a masked auth overview (profiles, env sources, and OAuth counts).

### Maintenance
- Skills: add Himalaya email CLI skill. Thanks @dantelex for PR #335.
- Agent: add `skipBootstrap` config option. Thanks @onutc for PR #292.
- UI: add favicon.ico derived from the macOS app icon. Thanks @jeffersonwarrior for PR #305.
- Tooling: replace tsx with bun for TypeScript execution. Thanks @obviyus for PR #278.
- Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome.
- Skills: add CodexBar model usage helper with macOS requirement metadata.
- Skills: add 1Password CLI skill with op examples.
- Lint: organize imports and wrap long lines in reply commands.
- Refactor: centralize group allowlist/mention policy across providers.
- Deps: update to latest across the repo.

## 2026.1.7

### Fixes
- Android: bump version to 2026.1.7, add version code, and name APK outputs. Thanks @fcatuhe for PR #402.

## 2026.1.5-3

### Fixes
- NPM package: include missing runtime dist folders (slack/signal/imessage/tui/wizard/control-ui/daemon) to avoid `ERR_MODULE_NOT_FOUND` in Node 25 npx installs.

## 2026.1.5-2

### Fixes
- NPM package: include `dist/sessions` so `clawdbot agent` resolves session helpers in npx installs.
- Node 25: avoid unsupported directory import by targeting `qrcode-terminal/vendor/QRCode/*.js` modules.

## 2026.1.5-1

### Fixes
- NPM package: include `dist/sessions` so `clawdbot agent` resolves session helpers in npx installs.
- Node 25: avoid unsupported directory import by targeting `qrcode-terminal/vendor/QRCode/index.js`.

## 2026.1.5

### Highlights
- Models: add image-specific model config (`agent.imageModel` + fallbacks) and scan support.
- Agent tools: new `image` tool routed to the image model (when configured).
- Config: default model shorthands (`opus`, `sonnet`, `gpt`, `gpt-mini`, `gemini`, `gemini-flash`).
- Docs: document built-in model shorthands + precedence (user config wins).
- Bun: optional local install/build workflow without maintaining a Bun lockfile (see `docs/bun.md`).

### Fixes
- Control UI: render Markdown in tool result cards.
- Control UI: prevent overlapping action buttons in Discord guild rules on narrow layouts.
- Android: tapping the foreground service notification brings the app to the front. (#179) — thanks @Syhids
- Cron tool uses `id` for update/remove/run/runs (aligns with gateway params). (#180) — thanks @adamgall
- Control UI: chat view uses page scroll with sticky header/sidebar and fixed composer (no inner scroll frame).
- macOS: treat location permission as always-only to avoid iOS-only enums. (#165) — thanks @Nachx639
- macOS: make generated gateway protocol models `Sendable` for Swift 6 strict concurrency. (#195) — thanks @andranik-sahakyan
- macOS: bundle QR code renderer modules so DMG gateway boot doesn't crash on missing qrcode-terminal vendor files.
- macOS: parse JSON5 config safely to avoid wiping user settings when comments are present.
- WhatsApp: suppress typing indicator during heartbeat background tasks. (#190) — thanks @mcinteerj
- WhatsApp: mark offline history sync messages as read without auto-reply. (#193) — thanks @mcinteerj
- Discord: avoid duplicate replies when a provider emits late streaming `text_end` events (OpenAI/GPT).
- CLI: use tailnet IP for local gateway calls when bind is tailnet/auto (fixes #176).
- Env: load global `$CLAWDBOT_STATE_DIR/.env` (`~/.clawdbot/.env`) as a fallback after CWD `.env`.
- Env: optional login-shell env fallback (opt-in; imports expected keys without overriding existing env).
- Agent tools: OpenAI-compatible tool JSON Schemas (fix `browser`, normalize union schemas).
- Onboarding: when running from source, auto-build missing Control UI assets (`bun run ui:build`).
- Discord/Slack: route reaction + system notifications to the correct session (no main-session bleed).
- Agent tools: honor `agent.tools` allow/deny policy even when sandbox is off.
- Discord: avoid duplicate replies when OpenAI emits repeated `message_end` events.
- Commands: unify /status (inline) and command auth across providers; group bypass for authorized control commands; remove Discord /clawd slash handler.
- CLI: run `clawdbot agent` via the Gateway by default; use `--local` to force embedded mode.

## 2026.1.5

### Fixes
- Control UI: render Markdown in chat messages (sanitized).


## 2026.1.4

### Highlights
- Rename completion: all CLIs, paths, bundle IDs, env vars, and docs standardized on **Clawdbot**.
- Agent-to-agent relay: `sessions_send` ping‑pong with `REPLY_SKIP` plus announce step with `ANNOUNCE_SKIP`.
- Gateway quality-of-life: config hot reload, port config support, and Control UI base paths.
- Sandbox additions: per-session Docker sandbox with hardened limits + optional sandboxed Chromium.
- New node capability: `location.get` across macOS/iOS/Android (CLI + tools).
- Models CLI: scan OpenRouter free models (tools/images), manage aliases/fallbacks, and show last-used model in status.

### Breaking
- Tool names drop the `clawdbot_` prefix (`browser`, `canvas`, `nodes`, `cron`, `gateway`).
- Bash tool removes node-pty `stdinMode: "pty"` support (use tmux for real TTYs).
- Primary session key is fixed to `main` (or `global` for global scope).

### Fixes
- Doctor migrates legacy Clawdis config/service installs and normalizes sandbox Docker names.
- Doctor checks sandbox image availability and offers to build or fall back to legacy images.
- Presence beacons keep node lists fresh; Instances view stays accurate.
- Block streaming/chunking reliability (Telegram/Discord ordering, fewer duplicates).
- WhatsApp GIF playback for MP4-based GIFs.
- Onboarding + Control UI basePath handling fixes and UI polish.
- Clearer tool summaries, reduced log noise, and safer watchdog/queue behavior.
- Canvas host watcher resilience; build and packaging edge cases cleaned up.

### Docs
- Sandbox setup, hot reload, port config, and session announce step coverage.
- Skills and onboarding clarifications + additional examples.

## 2026.1.3 (beta 5)

### Breaking
- Skills config moved under `skills.*` (new `skills.entries`, `skills.allowBundled`).
- Group session keys now `surface:group:<id>` / `surface:channel:<id>`; legacy `group:*` removed.
- Discord config refactor; `discord.allowFrom` + `discord.requireMention` removed.
- Discord/Telegram require `enabled: true` in config when using env tokens.
- Routing `allowFrom`/mention settings moved to per-surface group settings.

### Highlights
- Talk Mode (continuous voice) with ElevenLabs TTS on macOS/iOS/Android.
- Discord: expanded tool actions, richer routing, and threaded reply tags.
- Auto-reply queue modes + session model overrides; TUI upgrades.
- Nix mode (declarative config) and Docker setup flow.
- Onboarding wizard + configure/doctor/update flows.
- Signal + iMessage providers; new skills (Trello, Things, Notes/Reminders, tmux coding).
- Browser tooling upgrades (remote CDP, no-sandbox, profiles).

### Fixes
- macOS codesign/TCC hardening and menu/UI stability improvements.
- Streaming/typing fixes; per-provider chunk limit tuning.
- Remote gateway auth + token handling tightened.
- Camera capture reliability and media sizing fixes.

## 2025.12.27 (betas 3–4)

### Highlights
- First-class tools replace `clawdbot-*` skills (browser, canvas, nodes, cron).
- Per-session model selection and custom model providers.
- Group activation commands; Discord provider for DMs/guilds.
- Gateway webhooks + Gmail Pub/Sub hooks.
- Command queue modes + `agent.maxConcurrent` cap.
- Background bash tasks with `process` tool; gateway in-process restart.

### Fixes
- Packaging fixes, heartbeat cleanup, WhatsApp reconnect reliability.
- macOS menu/Chat UI polish and presence reporting fixes.

## 2025.12.21 (beta 2)

### Highlights
- Bundled gateway packaging + DMG distribution pipeline.
- Skills platform (bundled/managed/workspace) with install gating + UI.
- Onboarding polish and agent UX improvements.
- Canvas host served from Gateway; browser control simplification.

## 2025.12.19 (beta 1)

### Highlights
- First Clawdbot release: Gateway WS control plane + optional Bridge.
- macOS menu bar companion app with Voice Wake + WebChat.
- iOS node pairing with Canvas surface.
- WhatsApp groups, thinking/verbose directives, health/status tooling.

### Breaking
- Switched to Pi-only agent runtime; legacy providers removed.
- Gateway became the single source of truth (no ad-hoc direct sends).

## 2025.12.05–2025.12.03 (pre-Clawdbot)

### Highlights
- Pi-only agent path and web-only gateway workflow.
- Thinking/verbose directives, group chat support, and heartbeat controls.
- `clawdbot agent` CLI added; session tables and health reporting.

## 2025.11.28–2025.11.25 (early web-only)

- Heartbeat CLI + interval handling.
- Media MIME sniffing, size caps, and timeout fallbacks.
- Web provider reconnects and early stability fixes.


--- CONTRIBUTING.md ---
# Contributing to Clawdbot

Welcome to the lobster tank! 🦞

## Quick Links
- **GitHub:** https://github.com/clawdbot/clawdbot
- **Discord:** https://discord.gg/qkhbAGHRBT
- **X/Twitter:** [@steipete](https://x.com/steipete) / [@clawdbot](https://x.com/clawdbot)

## Maintainers

- **Peter Steinberger** - Benevolent Dictator
  - GitHub: [@steipete](https://github.com/steipete) · X: [@steipete](https://x.com/steipete)

- **Shadow** - Discord + Slack subsystem
  - GitHub: [@4shadowed](https://github.com/4shadowed) · X: [@4shad0wed](https://x.com/4shad0wed)

- **Jos** - Telegram, API, Nix mode
  - GitHub: [@joshp123](https://github.com/joshp123) · X: [@jjpcodes](https://x.com/jjpcodes)

## How to Contribute
1. **Bugs & small fixes** → Open a PR!
2. **New features / architecture** → Start a [GitHub Discussion](https://github.com/clawdbot/clawdbot/discussions) or ask in Discord first
3. **Questions** → Discord #setup-help

## Before You PR
- Test locally with your Clawdbot instance
- Run linter: `npm run lint`
- Keep PRs focused (one thing per PR)
- Describe what & why

## AI/Vibe-Coded PRs Welcome! 🤖

Built with Codex, Claude, or other AI tools? **Awesome - just mark it!**

Please include in your PR:
- [ ] Mark as AI-assisted in the PR title or description
- [ ] Note the degree of testing (untested / lightly tested / fully tested)
- [ ] Include prompts or session logs if possible (super helpful!)
- [ ] Confirm you understand what the code does

AI PRs are first-class citizens here. We just want transparency so reviewers know what to look for.


## Links discovered
- [@steipete](https://x.com/steipete)
- [@clawdbot](https://x.com/clawdbot)
- [@steipete](https://github.com/steipete)
- [@4shadowed](https://github.com/4shadowed)
- [@4shad0wed](https://x.com/4shad0wed)
- [@joshp123](https://github.com/joshp123)
- [@jjpcodes](https://x.com/jjpcodes)
- [GitHub Discussion](https://github.com/clawdbot/clawdbot/discussions)

--- Swabble/CHANGELOG.md ---
# Changelog

## 0.2.0 — 2025-12-23

### Highlights
- Added `SwabbleKit` (multi-platform wake-word gate utilities with segment-aware gap detection).
- Swabble package now supports iOS + macOS consumers; CLI remains macOS 26-only.

### Changes
- CLI wake-word matching/stripping routed through `SwabbleKit` helpers.
- Speech pipeline types now explicitly gated to macOS 26 / iOS 26 availability.


--- scripts/release-check.ts ---
#!/usr/bin/env bun

import { execSync } from "node:child_process";

type PackFile = { path: string };
type PackResult = { files?: PackFile[] };

const requiredPaths = ["dist/discord/send.js", "dist/hooks/gmail.js"];
const forbiddenPrefixes = ["dist/Clawdbot.app/"];

function runPackDry(): PackResult[] {
  const raw = execSync("npm pack --dry-run --json", {
    encoding: "utf8",
    stdio: ["ignore", "pipe", "pipe"],
  });
  return JSON.parse(raw) as PackResult[];
}

function main() {
  const results = runPackDry();
  const files = results.flatMap((entry) => entry.files ?? []);
  const paths = new Set(files.map((file) => file.path));

  const missing = requiredPaths.filter((path) => !paths.has(path));
  const forbidden = [...paths].filter((path) =>
    forbiddenPrefixes.some((prefix) => path.startsWith(prefix)),
  );

  if (missing.length > 0 || forbidden.length > 0) {
    if (missing.length > 0) {
      console.error("release-check: missing files in npm pack:");
      for (const path of missing) {
        console.error(`  - ${path}`);
      }
    }
    if (forbidden.length > 0) {
      console.error("release-check: forbidden files in npm pack:");
      for (const path of forbidden) {
        console.error(`  - ${path}`);
      }
    }
    process.exit(1);
  }

  console.log("release-check: npm pack contents look OK.");
}

main();


--- vendor/a2ui/CONTRIBUTING.md ---
# How to contribute to A2UI

We'd love to accept your patches and contributions to this project.

## Before you begin

### Sign our Contributor License Agreement

Contributions to this project must be accompanied by a
[Contributor License Agreement](https://cla.developers.google.com/about) (CLA).
You (or your employer) retain the copyright to your contribution; this simply
gives us permission to use and redistribute your contributions as part of the
project.

If you or your current employer have already signed the Google CLA (even if it
was for a different project), you probably don't need to do it again.

Visit <https://cla.developers.google.com/> to see your current agreements or to
sign a new one.

### Review our community guidelines

This project follows
[Google's Open Source Community Guidelines](https://opensource.google/conduct/).

## Contribution process

### Code reviews

All submissions, including submissions by project members, require review. We
use GitHub pull requests for this purpose. Consult
[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
information on using pull requests.

### Contributor Guide

You may follow these steps to contribute:

1. **Fork the official repository.** This will create a copy of the official repository in your own account.
2. **Sync the branches.** This will ensure that your copy of the repository is up-to-date with the latest changes from the official repository.
3. **Work on your forked repository's feature branch.** This is where you will make your changes to the code.
4. **Commit your updates on your forked repository's feature branch.** This will save your changes to your copy of the repository.
5. **Submit a pull request to the official repository's main branch.** This will request that your changes be merged into the official repository.
6. **Resolve any linting errors.** This will ensure that your changes are formatted correctly.

Here are some additional things to keep in mind during the process:

- **Test your changes.** Before you submit a pull request, make sure that your changes work as expected.
- **Be patient.** It may take some time for your pull request to be reviewed and merged.


## Links discovered
- [Contributor License Agreement](https://cla.developers.google.com/about)
- [Google's Open Source Community Guidelines](https://opensource.google/conduct/)
- [GitHub Help](https://help.github.com/articles/about-pull-requests/)

--- src/commands/doctor-security.ts ---
import { note } from "@clack/prompts";

import type { ClawdbotConfig } from "../config/config.js";
import { readProviderAllowFromStore } from "../pairing/pairing-store.js";
import { readTelegramAllowFromStore } from "../telegram/pairing-store.js";
import { resolveTelegramToken } from "../telegram/token.js";
import { normalizeE164 } from "../utils.js";

export async function noteSecurityWarnings(cfg: ClawdbotConfig) {
  const warnings: string[] = [];

  const warnDmPolicy = async (params: {
    label: string;
    provider:
      | "telegram"
      | "signal"
      | "imessage"
      | "discord"
      | "slack"
      | "whatsapp";
    dmPolicy: string;
    allowFrom?: Array<string | number> | null;
    allowFromPath: string;
    approveHint: string;
    normalizeEntry?: (raw: string) => string;
  }) => {
    const dmPolicy = params.dmPolicy;
    const configAllowFrom = (params.allowFrom ?? []).map((v) =>
      String(v).trim(),
    );
    const hasWildcard = configAllowFrom.includes("*");
    const storeAllowFrom = await readProviderAllowFromStore(
      params.provider,
    ).catch(() => []);
    const normalizedCfg = configAllowFrom
      .filter((v) => v !== "*")
      .map((v) => (params.normalizeEntry ? params.normalizeEntry(v) : v))
      .map((v) => v.trim())
      .filter(Boolean);
    const normalizedStore = storeAllowFrom
      .map((v) => (params.normalizeEntry ? params.normalizeEntry(v) : v))
      .map((v) => v.trim())
      .filter(Boolean);
    const allowCount = Array.from(
      new Set([...normalizedCfg, ...normalizedStore]),
    ).length;

    if (dmPolicy === "open") {
      const policyPath = `${params.allowFromPath}policy`;
      const allowFromPath = `${params.allowFromPath}allowFrom`;
      warnings.push(
        `- ${params.label} DMs: OPEN (${policyPath}="open"). Anyone can DM it.`,
      );
      if (!hasWildcard) {
        warnings.push(
          `- ${params.label} DMs: config invalid — "open" requires ${allowFromPath} to include "*".`,
        );
      }
      return;
    }

    if (dmPolicy === "disabled") {
      const policyPath = `${params.allowFromPath}policy`;
      warnings.push(
        `- ${params.label} DMs: disabled (${policyPath}="disabled").`,
      );
      return;
    }

    if (allowCount === 0) {
      const policyPath = `${params.allowFromPath}policy`;
      warnings.push(
        `- ${params.label} DMs: locked (${policyPath}="${dmPolicy}") with no allowlist; unknown senders will be blocked / get a pairing code.`,
      );
      warnings.push(`  ${params.approveHint}`);
    }
  };

  const telegramConfigured = Boolean(cfg.telegram);
  const { token: telegramToken } = resolveTelegramToken(cfg);
  if (telegramConfigured && telegramToken.trim()) {
    const dmPolicy = cfg.telegram?.dmPolicy ?? "pairing";
    const configAllowFrom = (cfg.telegram?.allowFrom ?? []).map((v) =>
      String(v).trim(),
    );
    const hasWildcard = configAllowFrom.includes("*");
    const storeAllowFrom = await readTelegramAllowFromStore().catch(() => []);
    const allowCount = Array.from(
      new Set([
        ...configAllowFrom
          .filter((v) => v !== "*")
          .map((v) => v.replace(/^(telegram|tg):/i, ""))
          .filter(Boolean),
        ...storeAllowFrom.filter((v) => v !== "*"),
      ]),
    ).length;

    if (dmPolicy === "open") {
      warnings.push(
        `- Telegram DMs: OPEN (telegram.dmPolicy="open"). Anyone who can find the bot can DM it.`,
      );
      if (!hasWildcard) {
        warnings.push(
          `- Telegram DMs: config invalid — dmPolicy "open" requires telegram.allowFrom to include "*".`,
        );
      }
    } else if (dmPolicy === "disabled") {
      warnings.push(`- Telegram DMs: disabled (telegram.dmPolicy="disabled").`);
    } else if (allowCount === 0) {
      warnings.push(
        `- Telegram DMs: locked (telegram.dmPolicy="${dmPolicy}") with no allowlist; unknown senders will be blocked / get a pairing code.`,
      );
      warnings.push(
        `  Approve via: clawdbot telegram pairing list / clawdbot telegram pairing approve <code>`,
      );
    }

    const groupPolicy = cfg.telegram?.groupPolicy ?? "open";
    const groupAllowlistConfigured =
      cfg.telegram?.groups && Object.keys(cfg.telegram.groups).length > 0;
    if (groupPolicy === "open" && !groupAllowlistConfigured) {
      warnings.push(
        `- Telegram groups: open (groupPolicy="open") with no telegram.groups allowlist; mention-gating applies but any group can add + ping.`,
      );
    }
  }

  if (cfg.discord?.enabled !== false) {
    await warnDmPolicy({
      label: "Discord",
      provider: "discord",
      dmPolicy: cfg.discord?.dm?.policy ?? "pairing",
      allowFrom: cfg.discord?.dm?.allowFrom ?? [],
      allowFromPath: "discord.dm.",
      approveHint:
        "Approve via: clawdbot pairing list --provider discord / clawdbot pairing approve --provider discord <code>",
      normalizeEntry: (raw) =>
        raw.replace(/^(discord|user):/i, "").replace(/^<@!?(\d+)>$/, "$1"),
    });
  }

  if (cfg.slack?.enabled !== false) {
    await warnDmPolicy({
      label: "Slack",
      provider: "slack",
      dmPolicy: cfg.slack?.dm?.policy ?? "pairing",
      allowFrom: cfg.slack?.dm?.allowFrom ?? [],
      allowFromPath: "slack.dm.",
      approveHint:
        "Approve via: clawdbot pairing list --provider slack / clawdbot pairing approve --provider slack <code>",
      normalizeEntry: (raw) => raw.replace(/^(slack|user):/i, ""),
    });
  }

  if (cfg.signal?.enabled !== false) {
    await warnDmPolicy({
      label: "Signal",
      provider: "signal",
      dmPolicy: cfg.signal?.dmPolicy ?? "pairing",
      allowFrom: cfg.signal?.allowFrom ?? [],
      allowFromPath: "signal.",
      approveHint:
        "Approve via: clawdbot pairing list --provider signal / clawdbot pairing approve --provider signal <code>",
      normalizeEntry: (raw) =>
        normalizeE164(raw.replace(/^signal:/i, "").trim()),
    });
  }

  if (cfg.imessage?.enabled !== false) {
    await warnDmPolicy({
      label: "iMessage",
      provider: "imessage",
      dmPolicy: cfg.imessage?.dmPolicy ?? "pairing",
      allowFrom: cfg.imessage?.allowFrom ?? [],
      allowFromPath: "imessage.",
      approveHint:
        "Approve via: clawdbot pairing list --provider imessage / clawdbot pairing approve --provider imessage <code>",
    });
  }

  if (cfg.whatsapp) {
    await warnDmPolicy({
      label: "WhatsApp",
      provider: "whatsapp",
      dmPolicy: cfg.whatsapp?.dmPolicy ?? "pairing",
      allowFrom: cfg.whatsapp?.allowFrom ?? [],
      allowFromPath: "whatsapp.",
      approveHint:
        "Approve via: clawdbot pairing list --provider whatsapp / clawdbot pairing approve --provider whatsapp <code>",
      normalizeEntry: (raw) => normalizeE164(raw),
    });
  }

  if (warnings.length > 0) {
    note(warnings.join("\n"), "Security");
  }
}


--- apps/macos/Sources/Clawdbot/Resources/DeviceModels/LICENSE.apple-device-identifiers.txt ---
MIT License

Copyright (c) 2021 Kyle Seongwoo Jun

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

--- AGENTS.md ---
# Repository Guidelines

## Project Structure & Module Organization
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
- Tests: colocated `*.test.ts`.
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.

## Docs Linking (Mintlify)
- Docs are hosted on Mintlify (docs.clawd.bot).
- Internal doc links in `docs/**/*.md`: root-relative, no `.md`/`.mdx` (example: `[Config](/configuration)`).
- Section cross-references: use anchors on root-relative paths (example: `[Hooks](/configuration#hooks)`).
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.

## Build, Test, and Development Commands
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
- Install deps: `pnpm install`
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
- Node remains supported for running built output (`dist/*`) and production installs.
- Type-check/build: `pnpm build` (tsc)
- Lint/format: `pnpm lint` (biome check), `pnpm format` (biome format)
- Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage`

## Coding Style & Naming Conventions
- Language: TypeScript (ESM). Prefer strict typing; avoid `any`.
- Formatting/linting via Biome; run `pnpm lint` before commits.
- Add brief code comments for tricky or non-obvious logic.
- Keep files concise; extract helpers instead of “V2” copies. Use existing patterns for CLI options and dependency injection via `createDefaultDeps`.
- Aim to keep files under ~700 LOC; guideline only (not a hard guardrail). Split/refactor when it improves clarity or testability.

## Testing Guidelines
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
- Mobile: before using a simulator, check for connected real devices (iOS + Android) and prefer them when available.

## Commit & Pull Request Guidelines
- Create commits with `scripts/committer "<msg>" <file...>`; avoid manual `git add`/`git commit` so staging stays scoped.
- Follow concise, action-oriented commit messages (e.g., `CLI: add verbose flag to send`).
- Group related changes; avoid bundling unrelated refactors.
- PRs should summarize scope, note testing performed, and mention any user-facing changes or new flags.
- PR review flow: when given a PR link, review via `gh pr view`/`gh pr diff` and do **not** change branches.
- PR merge flow: create a temp branch from `main`, merge the PR branch into it (prefer squash unless commit history is important; use rebase/merge when it is). Always try to merge the PR unless it’s truly difficult, then use another approach. If we squash, add the PR author as a co-contributor. Apply fixes, add changelog entry (include PR # + thanks), run full gate before the final commit, commit, merge back to `main`, delete the temp branch, and end on `main`.
- When working on a PR: add a changelog entry with the PR number and thank the contributor.
- When working on an issue: reference the issue in the changelog entry.
- When merging a PR: leave a PR comment that explains exactly what we did and include the SHA hashes.
- When merging a PR from a new contributor: add their avatar to the README “Thanks to all clawtributors” thumbnail list.

### PR Workflow (Review vs Land)
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
- **Landing mode:** create an integration branch from `main`, bring in PR commits (**prefer rebase** for linear history; **merge allowed** when complexity/conflicts make it safer), apply fixes, add changelog (+ thanks + PR #), run full gate **locally before committing** (`pnpm lint && pnpm build && pnpm test`), commit, merge back to `main`, then `git switch main` (never stay on a topic branch after landing). Important: contributor needs to be in git graph after this!

## Security & Configuration Tips
- Web provider stores creds at `~/.clawdbot/credentials/`; rerun `clawdbot login` if logged out.
- Pi sessions live under `~/.clawdbot/sessions/` by default; the base directory is not configurable.
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.

## Troubleshooting
- Rebrand/migration issues (Clawdis → Clawdbot) or legacy config/service warnings: run `clawdbot doctor` (see `docs/gateway/doctor.md`).

## Agent-Specific Notes
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
- macOS logs: use `./scripts/clawlog.sh` (aka `vtlog`) to query unified logs for the Clawdbot subsystem; it supports follow/tail/category filters and expects passwordless sudo for `/usr/bin/log`.
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; don’t introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
- Release signing/notary keys are managed outside the repo; follow internal release docs.
- Notary auth env vars (`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_API_KEY_P8`) are expected in your environment (per internal release docs).
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
- **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks.
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested.
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
- Voice wake forwarding tips:
  - Command template should stay `clawdbot-mac agent --message "${text}" --thinking low`; `VoiceWakeForwarder` already shell-escapes `${text}`. Don’t add extra quotes.
  - launchd PATH is minimal; ensure the app’s launch agent PATH includes standard system paths plus your pnpm bin (typically `$HOME/Library/pnpm`) so `pnpm`/`clawdbot` binaries resolve when invoked via `clawdbot-mac`.
  - For manual `clawdbot send` messages that include `!`, use the heredoc pattern noted below to avoid the Bash tool’s escaping.

## Exclamation Mark Escaping Workaround
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot send` with messages containing exclamation marks, use heredoc syntax:

```bash
# WRONG - will send "Hello\\!" with backslash
clawdbot send --to "+1234" --message 'Hello!'

# CORRECT - use heredoc to avoid escaping
clawdbot send --to "+1234" --message "$(cat <<'EOF'
Hello!
EOF
)"
```

This is a Claude Code quirk, not a clawdbot bug.


## Links discovered
- [Config](https://github.com/clawdbot/clawdbot/blob/main/configuration.md)
- [Hooks](https://github.com/clawdbot/clawdbot/blob/main/configuration#hooks.md)

--- CLAUDE.md ---
AGENTS.md

--- README.md ---
# 🦞 CLAWDBOT — Personal AI Assistant

<p align="center">
  <img src="https://raw.githubusercontent.com/clawdbot/clawdbot/main/docs/whatsapp-clawd.jpg" alt="CLAWDBOT" width="400">
</p>

<p align="center">
  <strong>EXFOLIATE! EXFOLIATE!</strong>
</p>

<p align="center">
  <a href="https://github.com/clawdbot/clawdbot/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/clawdbot/clawdbot/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
  <a href="https://github.com/clawdbot/clawdbot/releases"><img src="https://img.shields.io/github/v/release/clawdbot/clawdbot?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
  <a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
  <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
</p>

**Clawdbot** is a *personal AI assistant* you run on your own devices.
It answers you on the providers you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.

If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.

[Website](https://clawdbot.com) · [Docs](https://docs.clawd.bot) · [Getting Started](https://docs.clawd.bot/getting-started) · [Updating](https://docs.clawd.bot/updating) · [Showcase](https://docs.clawd.bot/showcase) · [FAQ](https://docs.clawd.bot/faq) · [Wizard](https://docs.clawd.bot/wizard) · [Nix](https://github.com/clawdbot/nix-clawdbot) · [Docker](https://docs.clawd.bot/docker) · [Discord](https://discord.gg/clawd)

Preferred setup: run the onboarding wizard (`clawdbot onboard`). It walks through gateway, workspace, providers, and skills. The CLI wizard is the recommended path and works on **macOS, Linux, and Windows (via WSL2; strongly recommended)**.
Works with npm, pnpm, or bun.
New install? Start here: [Getting started](https://docs.clawd.bot/getting-started)

**Subscriptions (OAuth):**
- **[Anthropic](https://www.anthropic.com/)** (Claude Pro/Max)
- **[OpenAI](https://openai.com/)** (ChatGPT/Codex)

Model note: while any model is supported, I strongly recommend **Anthropic Pro/Max (100/200) + Opus 4.5** for long‑context strength and better prompt‑injection resistance. See [Onboarding](https://docs.clawd.bot/onboarding).

## Models (selection + auth)

- Models config + CLI: [Models](https://docs.clawd.bot/models)
- Auth profile rotation (OAuth vs API keys) + fallbacks: [Model failover](https://docs.clawd.bot/model-failover)

## Recommended setup (from source)

Do **not** download prebuilt binaries. Run from source.

Prefer `pnpm` for builds from source. Bun is optional for running TypeScript directly.

```bash
# Clone this repo
git clone https://github.com/clawdbot/clawdbot.git
cd clawdbot

pnpm install
pnpm ui:install
pnpm ui:build
pnpm build
pnpm clawdbot onboard
```

Note: `pnpm clawdbot ...` runs TypeScript directly (via `tsx`). `pnpm build` produces `dist/` for running via Node / the packaged `clawdbot` binary.

## Quick start (TL;DR)

Runtime: **Node ≥22**.

Full beginner guide (auth, pairing, providers): [Getting started](https://docs.clawd.bot/getting-started)

```bash
pnpm clawdbot onboard

pnpm clawdbot gateway --port 18789 --verbose

# Dev loop (auto-reload on TS changes)
pnpm gateway:watch

# Send a message
pnpm clawdbot send --to +1234567890 --message "Hello from Clawdbot"

# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord)
pnpm clawdbot agent --message "Ship checklist" --thinking high
```

Upgrading? [Updating guide](https://docs.clawd.bot/updating) (and run `clawdbot doctor`).

If you run from source, prefer `pnpm clawdbot …` (not global `clawdbot`).

## Security defaults (DM access)

Clawdbot connects to real messaging surfaces. Treat inbound DMs as **untrusted input**.

Full security guide: [Security](https://docs.clawd.bot/security)

Default behavior on Telegram/WhatsApp/Signal/iMessage/Discord/Slack:
- **DM pairing** (`dmPolicy="pairing"` / `discord.dm.policy="pairing"` / `slack.dm.policy="pairing"`): unknown senders receive a short pairing code and the bot does not process their message.
- Approve with: `clawdbot pairing approve --provider <provider> <code>` (then the sender is added to a local allowlist store).
- Public inbound DMs require an explicit opt-in: set `dmPolicy="open"` and include `"*"` in the provider allowlist (`allowFrom` / `discord.dm.allowFrom` / `slack.dm.allowFrom`).

Run `clawdbot doctor` to surface risky/misconfigured DM policies.

## Highlights

- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, providers, tools, and events.
- **[Multi-provider inbox](https://docs.clawd.bot/surface)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, WebChat, macOS, iOS/Android.
- **[Multi-agent routing](https://docs.clawd.bot/configuration)** — route inbound providers/accounts/peers to isolated agents (workspaces + per-agent sessions).
- **[Voice Wake](https://docs.clawd.bot/voicewake) + [Talk Mode](https://docs.clawd.bot/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
- **[Live Canvas](https://docs.clawd.bot/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui).
- **[First-class tools](https://docs.clawd.bot/tools)** — browser, canvas, nodes, cron, sessions, and Discord/Slack actions.
- **[Companion apps](https://docs.clawd.bot/macos)** — macOS menu bar app + iOS/Android [nodes](https://docs.clawd.bot/nodes).
- **[Onboarding](https://docs.clawd.bot/wizard) + [skills](https://docs.clawd.bot/skills)** — wizard-driven setup with bundled/managed/workspace skills.

## Everything we built so far

### Core platform
- [Gateway WS control plane](https://docs.clawd.bot/gateway) with sessions, presence, config, cron, webhooks, [Control UI](https://docs.clawd.bot/web), and [Canvas host](https://docs.clawd.bot/mac/canvas#canvas-a2ui).
- [CLI surface](https://docs.clawd.bot/agent-send): gateway, agent, send, [wizard](https://docs.clawd.bot/wizard), and [doctor](https://docs.clawd.bot/doctor).
- [Pi agent runtime](https://docs.clawd.bot/agent) in RPC mode with tool streaming and block streaming.
- [Session model](https://docs.clawd.bot/session): `main` for direct chats, group isolation, activation modes, queue modes, reply-back. Group rules: [Groups](https://docs.clawd.bot/groups).
- [Media pipeline](https://docs.clawd.bot/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/audio).

### Providers
- [Providers](https://docs.clawd.bot/surface): [WhatsApp](https://docs.clawd.bot/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/telegram) (grammY), [Slack](https://docs.clawd.bot/slack) (Bolt), [Discord](https://docs.clawd.bot/discord) (discord.js), [Signal](https://docs.clawd.bot/signal) (signal-cli), [iMessage](https://docs.clawd.bot/imessage) (imsg), [WebChat](https://docs.clawd.bot/webchat).
- [Group routing](https://docs.clawd.bot/group-messages): mention gating, reply tags, per-provider chunking and routing. Provider rules: [Providers](https://docs.clawd.bot/surface).

### Apps + nodes
- [macOS app](https://docs.clawd.bot/macos): menu bar control plane, [Voice Wake](https://docs.clawd.bot/voicewake)/PTT, [Talk Mode](https://docs.clawd.bot/talk) overlay, [WebChat](https://docs.clawd.bot/webchat), debug tools, [remote gateway](https://docs.clawd.bot/remote) control.
- [iOS node](https://docs.clawd.bot/ios): [Canvas](https://docs.clawd.bot/mac/canvas), [Voice Wake](https://docs.clawd.bot/voicewake), [Talk Mode](https://docs.clawd.bot/talk), camera, screen recording, Bonjour pairing.
- [Android node](https://docs.clawd.bot/android): [Canvas](https://docs.clawd.bot/mac/canvas), [Talk Mode](https://docs.clawd.bot/talk), camera, screen recording, optional SMS.
- [macOS node mode](https://docs.clawd.bot/nodes): system.run/notify + canvas/camera exposure.

### Tools + automation
- [Browser control](https://docs.clawd.bot/browser): dedicated clawd Chrome/Chromium, snapshots, actions, uploads, profiles.
- [Canvas](https://docs.clawd.bot/mac/canvas): [A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui) push/reset, eval, snapshot.
- [Nodes](https://docs.clawd.bot/nodes): camera snap/clip, screen record, [location.get](https://docs.clawd.bot/location-command), notifications.
- [Cron + wakeups](https://docs.clawd.bot/cron); [webhooks](https://docs.clawd.bot/webhook); [Gmail Pub/Sub](https://docs.clawd.bot/gmail-pubsub).
- [Skills platform](https://docs.clawd.bot/skills): bundled, managed, and workspace skills with install gating + UI.

### Ops + packaging
- [Control UI](https://docs.clawd.bot/web) + [WebChat](https://docs.clawd.bot/webchat) served directly from the Gateway.
- [Tailscale Serve/Funnel](https://docs.clawd.bot/tailscale) or [SSH tunnels](https://docs.clawd.bot/remote) with token/password auth.
- [Nix mode](https://docs.clawd.bot/nix) for declarative config; [Docker](https://docs.clawd.bot/docker)-based installs.
- [Doctor](https://docs.clawd.bot/doctor) migrations, [logging](https://docs.clawd.bot/logging).

## How it works (short)

```
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / WebChat
               │
               ▼
┌───────────────────────────────┐
│            Gateway            │  ws://127.0.0.1:18789
│       (control plane)         │  bridge: tcp://0.0.0.0:18790
└──────────────┬────────────────┘
               │
               ├─ Pi agent (RPC)
               ├─ CLI (clawdbot …)
               ├─ WebChat UI
               ├─ macOS app
               └─ iOS/Android nodes
```

## Key subsystems

- **[Gateway WebSocket network](https://docs.clawd.bot/architecture)** — single WS control plane for clients, tools, and events (plus ops: [Gateway runbook](https://docs.clawd.bot/gateway)).
- **[Tailscale exposure](https://docs.clawd.bot/tailscale)** — Serve/Funnel for the Gateway dashboard + WS (remote access: [Remote](https://docs.clawd.bot/remote)).
- **[Browser control](https://docs.clawd.bot/browser)** — clawd‑managed Chrome/Chromium with CDP control.
- **[Canvas + A2UI](https://docs.clawd.bot/mac/canvas)** — agent‑driven visual workspace (A2UI host: [Canvas/A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui)).
- **[Voice Wake](https://docs.clawd.bot/voicewake) + [Talk Mode](https://docs.clawd.bot/talk)** — always‑on speech and continuous conversation.
- **[Nodes](https://docs.clawd.bot/nodes)** — Canvas, camera snap/clip, screen record, `location.get`, notifications, plus macOS‑only `system.run`/`system.notify`.

## Tailscale access (Gateway dashboard)

Clawdbot can auto-configure Tailscale **Serve** (tailnet-only) or **Funnel** (public) while the Gateway stays bound to loopback. Configure `gateway.tailscale.mode`:

- `off`: no Tailscale automation (default).
- `serve`: tailnet-only HTTPS via `tailscale serve` (uses Tailscale identity headers by default).
- `funnel`: public HTTPS via `tailscale funnel` (requires shared password auth).

Notes:
- `gateway.bind` must stay `loopback` when Serve/Funnel is enabled (Clawdbot enforces this).
- Serve can be forced to require a password by setting `gateway.auth.mode: "password"` or `gateway.auth.allowTailscale: false`.
- Funnel refuses to start unless `gateway.auth.mode: "password"` is set.
- Optional: `gateway.tailscale.resetOnExit` to undo Serve/Funnel on shutdown.

Details: [Tailscale guide](https://docs.clawd.bot/tailscale) · [Web surfaces](https://docs.clawd.bot/web)

## Remote Gateway (Linux is great)

It’s perfectly fine to run the Gateway on a small Linux instance. Clients (macOS app, CLI, WebChat) can connect over **Tailscale Serve/Funnel** or **SSH tunnels**, and you can still pair device nodes (macOS/iOS/Android) to execute device‑local actions when needed.

- **Gateway host** runs the bash tool and provider connections by default.
- **Device nodes** run device‑local actions (`system.run`, camera, screen recording, notifications) via `node.invoke`.
In short: bash runs where the Gateway lives; device actions run where the device lives.

Details: [Remote access](https://docs.clawd.bot/remote) · [Nodes](https://docs.clawd.bot/nodes) · [Security](https://docs.clawd.bot/security)

## macOS permissions via the Gateway protocol

The macOS app can run in **node mode** and advertises its capabilities + permission map over the Gateway WebSocket (`node.list` / `node.describe`). Clients can then execute local actions via `node.invoke`:

- `system.run` runs a local command and returns stdout/stderr/exit code; set `needsScreenRecording: true` to require screen-recording permission (otherwise you’ll get `PERMISSION_MISSING`).
- `system.notify` posts a user notification and fails if notifications are denied.
- `canvas.*`, `camera.*`, `screen.record`, and `location.get` are also routed via `node.invoke` and follow TCC permission status.

Elevated bash (host permissions) is separate from macOS TCC:

- Use `/elevated on|off` to toggle per‑session elevated access when enabled + allowlisted.
- Gateway persists the per‑session toggle via `sessions.patch` (WS method) alongside `thinkingLevel`, `verboseLevel`, `model`, `sendPolicy`, and `groupActivation`.

Details: [Nodes](https://docs.clawd.bot/nodes) · [macOS app](https://docs.clawd.bot/macos) · [Gateway protocol](https://docs.clawd.bot/architecture)

## Agent to Agent (sessions_* tools)

- Use these to coordinate work across sessions without jumping between chat surfaces.
- `sessions_list` — discover active sessions (agents) and their metadata.
- `sessions_history` — fetch transcript logs for a session.
- `sessions_send` — message another session; optional reply‑back ping‑pong + announce step (`REPLY_SKIP`, `ANNOUNCE_SKIP`).

Details: [Session tools](https://docs.clawd.bot/session-tool)

## Skills registry (ClawdHub)

ClawdHub is a minimal skill registry. With ClawdHub enabled, the agent can search for skills automatically and pull in new ones as needed.

[ClawdHub](https://ClawdHub.com)

## Chat commands

Send these in WhatsApp/Telegram/Slack/WebChat (group commands are owner-only):

- `/status` — health + session info (group shows activation mode)
- `/new` or `/reset` — reset the session
- `/compact` — compact session context (summary)
- `/think <level>` — off|minimal|low|medium|high
- `/verbose on|off`
- `/restart` — restart the gateway (owner-only in groups)
- `/activation mention|always` — group activation toggle (groups only)

## macOS app (optional)

The Gateway alone delivers a great experience. All apps are optional and add extra features.

If you plan to build/run companion apps, initialize submodules first:

```bash
git submodule update --init --recursive
./scripts/restart-mac.sh
```

### macOS (Clawdbot.app) (optional)

- Menu bar control for the Gateway and health.
- Voice Wake + push-to-talk overlay.
- WebChat + debug tools.
- Remote gateway control over SSH.

Note: signed builds required for macOS permissions to stick across rebuilds (see `docs/mac/permissions.md`).

### iOS node (optional)

- Pairs as a node via the Bridge.
- Voice trigger forwarding + Canvas surface.
- Controlled via `clawdbot nodes …`.

Runbook: [iOS connect](https://docs.clawd.bot/ios).

### Android node (optional)

- Pairs via the same Bridge + pairing flow as iOS.
- Exposes Canvas, Camera, and Screen capture commands.
- Runbook: [Android connect](https://docs.clawd.bot/android).

## Agent workspace + skills

- Workspace root: `~/clawd` (configurable via `agent.workspace`).
- Injected prompt files: `AGENTS.md`, `SOUL.md`, `TOOLS.md`.
- Skills: `~/clawd/skills/<skill>/SKILL.md`.

## Configuration

Minimal `~/.clawdbot/clawdbot.json` (model + defaults):

```json5
{
  agent: {
    model: "anthropic/claude-opus-4-5"
  }
}
```

[Full configuration reference (all keys + examples).](https://docs.clawd.bot/configuration)

## Security model (important)

- **Default:** tools run on the host for the **main** session, so the agent has full access when it’s just you.
- **Group/channel safety:** set `agent.sandbox.mode: "non-main"` to run **non‑main sessions** (groups/channels) inside per‑session Docker sandboxes; bash then runs in Docker for those sessions.
- **Sandbox defaults:** allowlist `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`; denylist `browser`, `canvas`, `nodes`, `cron`, `discord`, `gateway`.

Details: [Security guide](https://docs.clawd.bot/security) · [Docker + sandboxing](https://docs.clawd.bot/docker) · [Sandbox config](https://docs.clawd.bot/configuration)

### [WhatsApp](https://docs.clawd.bot/whatsapp)

- Link the device: `pnpm clawdbot login` (stores creds in `~/.clawdbot/credentials`).
- Allowlist who can talk to the assistant via `whatsapp.allowFrom`.
- If `whatsapp.groups` is set, it becomes a group allowlist; include `"*"` to allow all.

### [Telegram](https://docs.clawd.bot/telegram)

- Set `TELEGRAM_BOT_TOKEN` or `telegram.botToken` (env wins).
- Optional: set `telegram.groups` (with `telegram.groups."*".requireMention`); when set, it is a group allowlist (include `"*"` to allow all). Also `telegram.allowFrom` or `telegram.webhookUrl` as needed.

```json5
{
  telegram: {
    botToken: "123456:ABCDEF"
  }
}
```

### [Slack](https://docs.clawd.bot/slack)

- Set `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` (or `slack.botToken` + `slack.appToken`).

### [Discord](https://docs.clawd.bot/discord)

- Set `DISCORD_BOT_TOKEN` or `discord.token` (env wins).
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `discord.dm.allowFrom`, `discord.guilds`, or `discord.mediaMaxMb` as needed.

```json5
{
  discord: {
    token: "1234abcd"
  }
}
```

### [Signal](https://docs.clawd.bot/signal)

- Requires `signal-cli` and a `signal` config section.

### [iMessage](https://docs.clawd.bot/imessage)

- macOS only; Messages must be signed in.
- If `imessage.groups` is set, it becomes a group allowlist; include `"*"` to allow all.

### [WebChat](https://docs.clawd.bot/webchat)

- Uses the Gateway WebSocket; no separate WebChat port/config.

Browser control (optional):

```json5
{
  browser: {
    enabled: true,
    controlUrl: "http://127.0.0.1:18791",
    color: "#FF4500"
  }
}
```

## Docs

Use these when you’re past the onboarding flow and want the deeper reference.
- [Start with the docs index for navigation and “what’s where.”](https://docs.clawd.bot)
- [Read the architecture overview for the gateway + protocol model.](https://docs.clawd.bot/architecture)
- [Use the full configuration reference when you need every key and example.](https://docs.clawd.bot/configuration)
- [Run the Gateway by the book with the operational runbook.](https://docs.clawd.bot/gateway)
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawd.bot/web)
- [Understand remote access over SSH tunnels or tailnets.](https://docs.clawd.bot/remote)
- [Follow the onboarding wizard flow for a guided setup.](https://docs.clawd.bot/wizard)
- [Wire external triggers via the webhook surface.](https://docs.clawd.bot/webhook)
- [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/gmail-pubsub)
- [Learn the macOS menu bar companion details.](https://docs.clawd.bot/mac/menu-bar)
- [Platform guides: Windows (WSL2)](https://docs.clawd.bot/windows), [Linux](https://docs.clawd.bot/linux), [macOS](https://docs.clawd.bot/macos), [iOS](https://docs.clawd.bot/ios), [Android](https://docs.clawd.bot/android)
- [Debug common failures with the troubleshooting guide.](https://docs.clawd.bot/troubleshooting)
- [Review security guidance before exposing anything.](https://docs.clawd.bot/security)

## Advanced docs (discovery + control)

- [Discovery + transports](https://docs.clawd.bot/discovery)
- [Bonjour/mDNS](https://docs.clawd.bot/bonjour)
- [Gateway pairing](https://docs.clawd.bot/gateway/pairing)
- [Remote gateway README](https://docs.clawd.bot/remote-gateway-readme)
- [Control UI](https://docs.clawd.bot/control-ui)
- [Dashboard](https://docs.clawd.bot/dashboard)

## Operations & troubleshooting

- [Health checks](https://docs.clawd.bot/health)
- [Gateway lock](https://docs.clawd.bot/gateway-lock)
- [Background process](https://docs.clawd.bot/background-process)
- [Browser troubleshooting (Linux)](https://docs.clawd.bot/browser-linux-troubleshooting)
- [Logging](https://docs.clawd.bot/logging)

## Deep dives

- [Agent loop](https://docs.clawd.bot/agent-loop)
- [Presence](https://docs.clawd.bot/presence)
- [TypeBox schemas](https://docs.clawd.bot/typebox)
- [RPC adapters](https://docs.clawd.bot/rpc)
- [Queue](https://docs.clawd.bot/queue)

## Workspace & skills

- [Skills config](https://docs.clawd.bot/skills-config)
- [Default AGENTS](https://docs.clawd.bot/AGENTS.default)
- [Templates: AGENTS](https://docs.clawd.bot/templates/AGENTS)
- [Templates: BOOTSTRAP](https://docs.clawd.bot/templates/BOOTSTRAP)
- [Templates: IDENTITY](https://docs.clawd.bot/templates/IDENTITY)
- [Templates: SOUL](https://docs.clawd.bot/templates/SOUL)
- [Templates: TOOLS](https://docs.clawd.bot/templates/TOOLS)
- [Templates: USER](https://docs.clawd.bot/templates/USER)

## Platform internals

- [macOS dev setup](https://docs.clawd.bot/mac/dev-setup)
- [macOS menu bar](https://docs.clawd.bot/mac/menu-bar)
- [macOS voice wake](https://docs.clawd.bot/mac/voicewake)
- [iOS node](https://docs.clawd.bot/ios)
- [Android node](https://docs.clawd.bot/android)
- [Windows (WSL2)](https://docs.clawd.bot/windows)
- [Linux app](https://docs.clawd.bot/linux)

## Email hooks (Gmail)

[Gmail Pub/Sub wiring (gcloud + gogcli), hook tokens, and auto-watch behavior are documented here.](https://docs.clawd.bot/gmail-pubsub)

Gateway auto-starts the watcher when `hooks.enabled=true` and `hooks.gmail.account` is set; `clawdbot hooks gmail run` is the manual daemon wrapper if you don’t want auto-start.

```bash
clawdbot hooks gmail setup --account you@gmail.com
clawdbot hooks gmail run
```

## Clawd

Clawdbot was built for **Clawd**, a space lobster AI assistant. 🦞  
by Peter Steinberger and the community.

- [Clawd](https://clawd.me)
- [SOUL](https://soul.md)
- [Peter](https://steipete.me)

## Community

See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines, maintainers, and how to submit PRs.  
AI/vibe-coded PRs welcome! 🤖

Thanks to all clawtributors:

<p align="left">
  <a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a>
  <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="Nachx639" title="Nachx639"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="mbelinky" title="mbelinky"/></a>
  <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a>
  <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="omniwired" title="omniwired"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="AbhisekBasu1" title="AbhisekBasu1"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a>
  <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="vsabavat" title="vsabavat"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a>
  <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="djangonavarro220" title="djangonavarro220"/></a>
  <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="Iamadig" title="Iamadig"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="ManuelHettich" title="ManuelHettich"/></a>
  <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a>
</p>


## Links discovered
- [Website](https://clawdbot.com)
- [Docs](https://docs.clawd.bot)
- [Getting Started](https://docs.clawd.bot/getting-started)
- [Updating](https://docs.clawd.bot/updating)
- [Showcase](https://docs.clawd.bot/showcase)
- [FAQ](https://docs.clawd.bot/faq)
- [Wizard](https://docs.clawd.bot/wizard)
- [Nix](https://github.com/clawdbot/nix-clawdbot)
- [Docker](https://docs.clawd.bot/docker)
- [Discord](https://discord.gg/clawd)
- [Getting started](https://docs.clawd.bot/getting-started)
- [Anthropic](https://www.anthropic.com/)
- [OpenAI](https://openai.com/)
- [Onboarding](https://docs.clawd.bot/onboarding)
- [Models](https://docs.clawd.bot/models)
- [Model failover](https://docs.clawd.bot/model-failover)
- [Updating guide](https://docs.clawd.bot/updating)
- [Security](https://docs.clawd.bot/security)
- [Local-first Gateway](https://docs.clawd.bot/gateway)
- [Multi-provider inbox](https://docs.clawd.bot/surface)
- [Multi-agent routing](https://docs.clawd.bot/configuration)
- [Voice Wake](https://docs.clawd.bot/voicewake)
- [Talk Mode](https://docs.clawd.bot/talk)
- [Live Canvas](https://docs.clawd.bot/mac/canvas)
- [A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui)
- [First-class tools](https://docs.clawd.bot/tools)
- [Companion apps](https://docs.clawd.bot/macos)
- [nodes](https://docs.clawd.bot/nodes)
- [Onboarding](https://docs.clawd.bot/wizard)
- [skills](https://docs.clawd.bot/skills)
- [Gateway WS control plane](https://docs.clawd.bot/gateway)
- [Control UI](https://docs.clawd.bot/web)
- [Canvas host](https://docs.clawd.bot/mac/canvas#canvas-a2ui)
- [CLI surface](https://docs.clawd.bot/agent-send)
- [wizard](https://docs.clawd.bot/wizard)
- [doctor](https://docs.clawd.bot/doctor)
- [Pi agent runtime](https://docs.clawd.bot/agent)
- [Session model](https://docs.clawd.bot/session)
- [Groups](https://docs.clawd.bot/groups)
- [Media pipeline](https://docs.clawd.bot/images)
- [Audio](https://docs.clawd.bot/audio)
- [Providers](https://docs.clawd.bot/surface)
- [WhatsApp](https://docs.clawd.bot/whatsapp)
- [Telegram](https://docs.clawd.bot/telegram)
- [Slack](https://docs.clawd.bot/slack)
- [Discord](https://docs.clawd.bot/discord)
- [Signal](https://docs.clawd.bot/signal)
- [iMessage](https://docs.clawd.bot/imessage)
- [WebChat](https://docs.clawd.bot/webchat)
- [Group routing](https://docs.clawd.bot/group-messages)
- [macOS app](https://docs.clawd.bot/macos)
- [remote gateway](https://docs.clawd.bot/remote)
- [iOS node](https://docs.clawd.bot/ios)
- [Canvas](https://docs.clawd.bot/mac/canvas)
- [Android node](https://docs.clawd.bot/android)
- [macOS node mode](https://docs.clawd.bot/nodes)
- [Browser control](https://docs.clawd.bot/browser)
- [Nodes](https://docs.clawd.bot/nodes)
- [location.get](https://docs.clawd.bot/location-command)
- [Cron + wakeups](https://docs.clawd.bot/cron)
- [webhooks](https://docs.clawd.bot/webhook)
- [Gmail Pub/Sub](https://docs.clawd.bot/gmail-pubsub)
- [Skills platform](https://docs.clawd.bot/skills)
- [Tailscale Serve/Funnel](https://docs.clawd.bot/tailscale)
- [SSH tunnels](https://docs.clawd.bot/remote)
- [Nix mode](https://docs.clawd.bot/nix)
- [Doctor](https://docs.clawd.bot/doctor)
- [logging](https://docs.clawd.bot/logging)
- [Gateway WebSocket network](https://docs.clawd.bot/architecture)
- [Gateway runbook](https://docs.clawd.bot/gateway)
- [Tailscale exposure](https://docs.clawd.bot/tailscale)
- [Remote](https://docs.clawd.bot/remote)
- [Canvas + A2UI](https://docs.clawd.bot/mac/canvas)
- [Canvas/A2UI](https://docs.clawd.bot/mac/canvas#canvas-a2ui)
- [Tailscale guide](https://docs.clawd.bot/tailscale)
- [Web surfaces](https://docs.clawd.bot/web)
- [Remote access](https://docs.clawd.bot/remote)
- [Gateway protocol](https://docs.clawd.bot/architecture)
- [Session tools](https://docs.clawd.bot/session-tool)
- [ClawdHub](https://ClawdHub.com)
- [iOS connect](https://docs.clawd.bot/ios)
- [Android connect](https://docs.clawd.bot/android)
- [Full configuration reference (all keys + examples).](https://docs.clawd.bot/configuration)
- [Security guide](https://docs.clawd.bot/security)
- [Docker + sandboxing](https://docs.clawd.bot/docker)
- [Sandbox config](https://docs.clawd.bot/configuration)
- [Start with the docs index for navigation and “what’s where.”](https://docs.clawd.bot)
- [Read the architecture overview for the gateway + protocol model.](https://docs.clawd.bot/architecture)
- [Use the full configuration reference when you need every key and example.](https://docs.clawd.bot/configuration)
- [Run the Gateway by the book with the operational runbook.](https://docs.clawd.bot/gateway)
- [Learn how the Control UI/Web surfaces work and how to expose them safely.](https://docs.clawd.bot/web)
- [Understand remote access over SSH tunnels or tailnets.](https://docs.clawd.bot/remote)
- [Follow the onboarding wizard flow for a guided setup.](https://docs.clawd.bot/wizard)
- [Wire external triggers via the webhook surface.](https://docs.clawd.bot/webhook)
- [Set up Gmail Pub/Sub triggers.](https://docs.clawd.bot/gmail-pubsub)
- [Learn the macOS menu bar companion details.](https://docs.clawd.bot/mac/menu-bar)
- [Platform guides: Windows (WSL2)](https://docs.clawd.bot/windows)
- [Linux](https://docs.clawd.bot/linux)
- [macOS](https://docs.clawd.bot/macos)
- [iOS](https://docs.clawd.bot/ios)

--- Swabble/README.md ---
# 🎙️ swabble — Speech.framework wake-word hook daemon (macOS 26)

swabble is a Swift 6.2 wake-word hook daemon. The CLI targets macOS 26 (SpeechAnalyzer + SpeechTranscriber). The shared `SwabbleKit` target is multi-platform and exposes wake-word gating utilities for iOS/macOS apps.

- **Local-only**: Speech.framework on-device models; zero network usage.
- **Wake word**: Default `clawd` (aliases `claude`), optional `--no-wake` bypass.
- **SwabbleKit**: Shared wake gate utilities (gap-based gating when you provide speech segments).
- **Hooks**: Run any command with prefix/env, cooldown, min_chars, timeout.
- **Services**: launchd helper stubs for start/stop/install.
- **File transcribe**: TXT or SRT with time ranges (using AttributedString splits).

## Quick start
```bash
# Install deps
brew install swiftformat swiftlint

# Build
swift build

# Write default config (~/.config/swabble/config.json)
swift run swabble setup

# Run foreground daemon
swift run swabble serve

# Test your hook
swift run swabble test-hook "hello world"

# Transcribe a file to SRT
swift run swabble transcribe /path/to/audio.m4a --format srt --output out.srt
```

## Use as a library
Add swabble as a SwiftPM dependency and import the `Swabble` or `SwabbleKit` product:

```swift
// Package.swift
dependencies: [
    .package(url: "https://github.com/steipete/swabble.git", branch: "main"),
],
targets: [
    .target(name: "MyApp", dependencies: [
        .product(name: "Swabble", package: "swabble"),     // Speech pipeline (macOS 26+ / iOS 26+)
        .product(name: "SwabbleKit", package: "swabble"),  // Wake-word gate utilities (iOS 17+ / macOS 15+)
    ]),
]
```

## CLI
- `serve` — foreground loop (mic → wake → hook)
- `transcribe <file>` — offline transcription (txt|srt)
- `test-hook "text"` — invoke configured hook
- `mic list|set <index>` — enumerate/select input device
- `setup` — write default config JSON
- `doctor` — check Speech auth & device availability
- `health` — prints `ok`
- `tail-log` — last 10 transcripts
- `status` — show wake state + recent transcripts
- `service install|uninstall|status` — user launchd plist (stub: prints launchctl commands)
- `start|stop|restart` — placeholders until full launchd wiring

All commands accept Commander runtime flags (`-v/--verbose`, `--json-output`, `--log-level`), plus `--config` where applicable.

## Config
`~/.config/swabble/config.json` (auto-created by `setup`):
```json
{
  "audio": {"deviceName": "", "deviceIndex": -1, "sampleRate": 16000, "channels": 1},
  "wake": {"enabled": true, "word": "clawd", "aliases": ["claude"]},
  "hook": {
    "command": "",
    "args": [],
    "prefix": "Voice swabble from ${hostname}: ",
    "cooldownSeconds": 1,
    "minCharacters": 24,
    "timeoutSeconds": 5,
    "env": {}
  },
  "logging": {"level": "info", "format": "text"},
  "transcripts": {"enabled": true, "maxEntries": 50},
  "speech": {"localeIdentifier": "en_US", "etiquetteReplacements": false}
}
```

- Config path override: `--config /path/to/config.json` on relevant commands.
- Transcripts persist to `~/Library/Application Support/swabble/transcripts.log`.

## Hook protocol
When a wake-gated transcript passes min_chars & cooldown, swabble runs:
```
<command> <args...> "<prefix><text>"
```
Environment variables:
- `SWABBLE_TEXT` — stripped transcript (wake word removed)
- `SWABBLE_PREFIX` — rendered prefix (hostname substituted)
- plus any `hook.env` key/values

## Speech pipeline
- `AVAudioEngine` tap → `BufferConverter` → `AnalyzerInput` → `SpeechAnalyzer` with a `SpeechTranscriber` module.
- Requests volatile + final results; the CLI uses text-only wake gating today.
- Authorization requested at first start; requires macOS 26 + new Speech.framework APIs.

## Development
- Format: `./scripts/format.sh` (uses ../peekaboo/.swiftformat if present)
- Lint: `./scripts/lint.sh` (uses ../peekaboo/.swiftlint.yml if present)
- Tests: `swift test` (uses swift-testing package)

## Roadmap
- launchd control (load/bootout, PID + status socket)
- JSON logging + PII redaction toggle
- Stronger wake-word detection and control socket status/health


--- showcase.md ---
# Showcase: what your personal assistant can do

Highlights from #showcase (Jan 2–5, 2026). Curated for “wow” factor + concrete links.

## Clawdhub projects (formerly Clawdis)
- **xuezh** — Chinese learning engine + Clawdbot skill for pronunciation feedback and study flows. <span class="showcase-link"><a href="https://github.com/joshp123/xuezh">github.com/joshp123/xuezh</a><span class="showcase-preview"><img src="/assets/showcase/xuezh-pronunciation.jpeg" alt="xuezh pronunciation feedback in Clawdbot" loading="lazy" decoding="async" /></span></span>
- **gohome** — Nix-native home automation with Clawdbot as the interface, plus Grafana dashboards. <span class="showcase-link"><a href="https://github.com/joshp123/gohome">github.com/joshp123/gohome</a><span class="showcase-preview"><img src="/assets/showcase/gohome-grafana.png" alt="GoHome Grafana dashboard" loading="lazy" decoding="async" /></span></span>
- **Roborock skill for GoHome** — Vacuum control plugin with gRPC actions + metrics. <span class="showcase-link"><a href="https://github.com/joshp123/gohome/tree/main/plugins/roborock">github.com/joshp123/gohome/tree/main/plugins/roborock</a><span class="showcase-preview"><img src="/assets/showcase/roborock-screenshot.jpg" alt="GoHome Roborock status screenshot" loading="lazy" decoding="async" /></span></span>
- **padel-cli** — Playtomic availability + booking CLI with a Clawdbot plugin output. <span class="showcase-link"><a href="https://github.com/joshp123/padel-cli">github.com/joshp123/padel-cli</a><span class="showcase-preview"><img src="/assets/showcase/padel-screenshot.jpg" alt="padel-cli availability screenshot" loading="lazy" decoding="async" /></span></span>

## Automation & real-world outcomes
- **Grocery autopilot (Picnic)** — Skill built around an unofficial Picnic API client. Pulls order history, infers preferred brands, maps recipes to cart, completes order in minutes. https://github.com/timkrase/clawdis-picnic-skill
- **Grocery autopilot (Picnic, alt)** — Another Picnic-based skill built via the `picnic-api` package. https://github.com/MRVDH/picnic-api
- **German rail planning** — Go CLI for Deutsche Bahn; skill picks best connections given time windows and preferences. https://github.com/timkrase/dbrest-cli + https://github.com/timkrase/clawdis-skills/tree/main/db-bahn (link check pending)
- **Accounting intake** — Collect PDFs from email, prep for tax consultant (monthly accounting batch). (No link shared.)

## Knowledge & memory systems
- **WhatsApp memory vault** — Ingests full exports, transcribes 1k+ voice notes, cross‑checks with git logs, outputs linked MD reports + ongoing indexing. (No link shared.)
- **Karakeep semantic search** — Sidecar adds vector search to Karakeep bookmarks (Qdrant + OpenAI/Ollama), includes Clawdis skill. https://github.com/jamesbrooksco/karakeep-semantic-search
- **Inside‑Out‑2 style memory** — Separate memory manager app turns session files into memories → beliefs → self model. (No link shared.)

## Voice, docs, and assistants on the phone
- **Clawdia phone bridge** — Vapi voice assistant ↔ Clawdis HTTP bridge; near‑real‑time phone calls. https://github.com/alejandroOPI/clawdia-bridge
- **Google Docs edit skill** — Rich‑text editing skill built fast with Claude Code. (No link shared.)
- **OpenRouter transcription skill** — Multi‑lingual audio transcription via OpenRouter (Gemini etc). ClawdHub: https://clawdhub.com/obviyus/openrouter-transcribe (user/slug link)

## Infrastructure & deployment
- **Home Assistant OS gateway add‑on** — Clawdbot gateway running on HA OS (Raspberry Pi), with SSH tunnel support + persistent state in /config. https://github.com/ngutman/clawdbot-ha-addon
- **Home Assistant skill** — Control/automate HA via ClawdHub. https://clawdhub.com/skills/homeassistant
- **Nix packaging** — Batteries‑included nixified clawdis config. https://github.com/joshp123/nix-clawdis
- **CalDAV skill** — khal/vdirsyncer based calendar skill. ClawdHub: caldav-calendar → https://clawdhub.com/skills/caldav-calendar

## Home + hardware
- **Roborock integration** — Plugin for robot vacuum control. https://github.com/joshp123/gohome/tree/main/plugins/roborock

## Community builds (non‑Clawdis but made with/around it)
- **StarSwap marketplace** — Full astronomy gear marketplace. https://star-swap.com/

---


## Links discovered
- [github.com/joshp123/xuezh](https://github.com/joshp123/xuezh)
- [github.com/joshp123/gohome](https://github.com/joshp123/gohome)
- [github.com/joshp123/gohome/tree/main/plugins/roborock](https://github.com/joshp123/gohome/tree/main/plugins/roborock)
- [github.com/joshp123/padel-cli](https://github.com/joshp123/padel-cli)

--- vitest.config.ts ---
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    include: ["src/**/*.test.ts", "test/format-error.test.ts"],
    setupFiles: ["test/setup.ts"],
    exclude: [
      "dist/**",
      "apps/macos/**",
      "apps/macos/.build/**",
      "**/vendor/**",
      "dist/Clawdbot.app/**",
      "**/*.live.test.ts",
    ],
    coverage: {
      provider: "v8",
      reporter: ["text", "lcov"],
      thresholds: {
        lines: 70,
        functions: 70,
        branches: 55,
        statements: 70,
      },
      include: ["src/**/*.ts"],
      exclude: [
        "src/**/*.test.ts",
        // Entrypoints and wiring (covered by CI smoke + manual/e2e flows).
        "src/entry.ts",
        "src/index.ts",
        "src/runtime.ts",
        "src/cli/**",
        "src/commands/**",
        "src/daemon/**",
        "src/hooks/**",
        "src/macos/**",

        // Some agent integrations are intentionally validated via manual/e2e runs.
        "src/agents/model-scan.ts",
        "src/agents/pi-embedded-runner.ts",
        "src/agents/sandbox-paths.ts",
        "src/agents/sandbox.ts",
        "src/agents/skills-install.ts",
        "src/agents/pi-tool-definition-adapter.ts",
        "src/agents/tools/discord-actions*.ts",
        "src/agents/tools/slack-actions.ts",

        // Gateway server integration surfaces are intentionally validated via manual/e2e runs.
        "src/gateway/control-ui.ts",
        "src/gateway/server-bridge.ts",
        "src/gateway/server-providers.ts",
        "src/gateway/server-methods/config.ts",
        "src/gateway/server-methods/send.ts",
        "src/gateway/server-methods/skills.ts",
        "src/gateway/server-methods/talk.ts",
        "src/gateway/server-methods/web.ts",
        "src/gateway/server-methods/wizard.ts",

        // Process bridges are hard to unit-test in isolation.
        "src/gateway/call.ts",
        "src/process/tau-rpc.ts",
        "src/process/exec.ts",
        // Interactive UIs/flows are intentionally validated via manual/e2e runs.
        "src/tui/**",
        "src/wizard/**",
        // Provider surfaces are largely integration-tested (or manually validated).
        "src/discord/**",
        "src/imessage/**",
        "src/signal/**",
        "src/slack/**",
        "src/browser/**",
        "src/providers/web/**",
        "src/telegram/index.ts",
        "src/telegram/proxy.ts",
        "src/telegram/webhook-set.ts",
        "src/telegram/**",
        "src/webchat/**",
        "src/gateway/server.ts",
        "src/gateway/client.ts",
        "src/gateway/protocol/**",
        "src/infra/tailscale.ts",
      ],
    },
  },
});


--- vitest.e2e.config.ts ---
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    include: ["test/**/*.e2e.test.ts"],
    setupFiles: ["test/setup.ts"],
    exclude: [
      "dist/**",
      "apps/macos/**",
      "apps/macos/.build/**",
      "**/vendor/**",
      "dist/Clawdbot.app/**",
    ],
  },
});


--- vitest.live.config.ts ---
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    include: ["src/**/*.live.test.ts"],
    setupFiles: ["test/setup.ts"],
    exclude: [
      "dist/**",
      "apps/macos/**",
      "apps/macos/.build/**",
      "**/vendor/**",
      "dist/Clawdbot.app/**",
    ],
  },
});


--- .github/ISSUE_TEMPLATE/bug_report.md ---
---
name: Bug report
about: Report a problem or unexpected behavior in Clawdbot.
title: "[Bug]: "
labels: bug
---

## Summary
What went wrong?

## Steps to reproduce
1.
2.
3.

## Expected behavior
What did you expect to happen?

## Actual behavior
What actually happened?

## Environment
- Clawdbot version:
- OS:
- Install method (pnpm/npx/docker/etc):

## Logs or screenshots
Paste relevant logs or add screenshots (redact secrets).


--- .github/ISSUE_TEMPLATE/feature_request.md ---
---
name: Feature request
about: Suggest an idea or improvement for Clawdbot.
title: "[Feature]: "
labels: enhancement
---

## Summary
Describe the problem you are trying to solve or the opportunity you see.

## Proposed solution
What would you like Clawdbot to do?

## Alternatives considered
Any other approaches you have considered?

## Additional context
Links, screenshots, or related issues.


--- apps/android/README.md ---
## Clawdbot Node (Android) (internal)

Modern Android node app: connects to the **Gateway-owned bridge** (`_clawdbot-bridge._tcp`) over TCP and exposes **Canvas + Chat + Camera**.

Notes:
- The node keeps the connection alive via a **foreground service** (persistent notification with a Disconnect action).
- Chat always uses the shared session key **`main`** (same session across iOS/macOS/WebChat/Android).
- Supports modern Android only (`minSdk 31`, Kotlin + Jetpack Compose).

## Open in Android Studio
- Open the folder `apps/android`.

## Build / Run

```bash
cd apps/android
./gradlew :app:assembleDebug
./gradlew :app:installDebug
./gradlew :app:testDebugUnitTest
```

`gradlew` auto-detects the Android SDK at `~/Library/Android/sdk` (macOS default) if `ANDROID_SDK_ROOT` / `ANDROID_HOME` are unset.

## Connect / Pair

1) Start the gateway (on your “master” machine):
```bash
pnpm clawdbot gateway --port 18789 --verbose
```

2) In the Android app:
- Open **Settings**
- Either select a discovered bridge under **Discovered Bridges**, or use **Advanced → Manual Bridge** (host + port).

3) Approve pairing (on the gateway machine):
```bash
clawdbot nodes pending
clawdbot nodes approve <requestId>
```

More details: `docs/android/connect.md`.

## Permissions

- Discovery:
  - Android 13+ (`API 33+`): `NEARBY_WIFI_DEVICES`
  - Android 12 and below: `ACCESS_FINE_LOCATION` (required for NSD scanning)
- Foreground service notification (Android 13+): `POST_NOTIFICATIONS`
- Camera:
  - `CAMERA` for `camera.snap` and `camera.clip`
  - `RECORD_AUDIO` for `camera.clip` when `includeAudio=true`


--- apps/ios/README.md ---
# Clawdbot (iOS)

Internal-only SwiftUI app scaffold.

## Lint/format (required)
```bash
brew install swiftformat swiftlint
```

## Generate the Xcode project
```bash
cd apps/ios
xcodegen generate
open Clawdbot.xcodeproj
```

## Shared packages
- `../shared/ClawdbotKit` — shared types/constants used by iOS (and later macOS bridge + gateway routing).

## fastlane
```bash
brew install fastlane

cd apps/ios
fastlane lanes
```

See `apps/ios/fastlane/SETUP.md` for App Store Connect auth + upload lanes.


--- apps/ios/fastlane/SETUP.md ---
# fastlane setup (Clawdbot iOS)

Install:

```bash
brew install fastlane
```

Create an App Store Connect API key:

- App Store Connect → Users and Access → Keys → App Store Connect API → Generate API Key
- Download the `.p8`, note the **Issuer ID** and **Key ID**

Create `apps/ios/fastlane/.env` (gitignored):

```bash
ASC_KEY_ID=YOUR_KEY_ID
ASC_ISSUER_ID=YOUR_ISSUER_ID
ASC_KEY_PATH=/absolute/path/to/AuthKey_XXXXXXXXXX.p8

# Code signing (Apple Team ID / App ID Prefix)
IOS_DEVELOPMENT_TEAM=YOUR_TEAM_ID
```

Tip: run `scripts/ios-team-id.sh` from the repo root to print a Team ID to paste into `.env`. Fastlane falls back to this helper if `IOS_DEVELOPMENT_TEAM` is missing.

Run:

```bash
cd apps/ios
fastlane beta
```


--- apps/shared/ClawdbotKit/Tools/CanvasA2UI/bootstrap.js ---
import { html, css, LitElement, unsafeCSS } from "lit";
import { repeat } from "lit/directives/repeat.js";
import { ContextProvider } from "@lit/context";

import { v0_8 } from "@a2ui/lit";
import "@a2ui/lit/ui";
import { themeContext } from "@clawdbot/a2ui-theme-context";

const modalStyles = css`
  dialog {
    position: fixed;
    inset: 0;
    width: 100%;
    height: 100%;
    margin: 0;
    padding: 24px;
    border: none;
    background: rgba(5, 8, 16, 0.65);
    backdrop-filter: blur(6px);
    display: grid;
    place-items: center;
  }

  dialog::backdrop {
    background: rgba(5, 8, 16, 0.65);
    backdrop-filter: blur(6px);
  }
`;

const modalElement = customElements.get("a2ui-modal");
if (modalElement && Array.isArray(modalElement.styles)) {
  modalElement.styles = [...modalElement.styles, modalStyles];
}

const empty = Object.freeze({});
const emptyClasses = () => ({});
const textHintStyles = () => ({ h1: {}, h2: {}, h3: {}, h4: {}, h5: {}, body: {}, caption: {} });

const isAndroid = /Android/i.test(globalThis.navigator?.userAgent ?? "");
const cardShadow = isAndroid ? "0 2px 10px rgba(0,0,0,.18)" : "0 10px 30px rgba(0,0,0,.35)";
const buttonShadow = isAndroid ? "0 2px 10px rgba(6, 182, 212, 0.14)" : "0 10px 25px rgba(6, 182, 212, 0.18)";
const statusShadow = isAndroid ? "0 2px 10px rgba(0, 0, 0, 0.18)" : "0 10px 24px rgba(0, 0, 0, 0.25)";
const statusBlur = isAndroid ? "10px" : "14px";

const clawdbotTheme = {
  components: {
    AudioPlayer: emptyClasses(),
    Button: emptyClasses(),
    Card: emptyClasses(),
    Column: emptyClasses(),
    CheckBox: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
    DateTimeInput: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
    Divider: emptyClasses(),
    Image: {
      all: emptyClasses(),
      icon: emptyClasses(),
      avatar: emptyClasses(),
      smallFeature: emptyClasses(),
      mediumFeature: emptyClasses(),
      largeFeature: emptyClasses(),
      header: emptyClasses(),
    },
    Icon: emptyClasses(),
    List: emptyClasses(),
    Modal: { backdrop: emptyClasses(), element: emptyClasses() },
    MultipleChoice: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
    Row: emptyClasses(),
    Slider: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
    Tabs: { container: emptyClasses(), element: emptyClasses(), controls: { all: emptyClasses(), selected: emptyClasses() } },
    Text: {
      all: emptyClasses(),
      h1: emptyClasses(),
      h2: emptyClasses(),
      h3: emptyClasses(),
      h4: emptyClasses(),
      h5: emptyClasses(),
      caption: emptyClasses(),
      body: emptyClasses(),
    },
    TextField: { container: emptyClasses(), element: emptyClasses(), label: emptyClasses() },
    Video: emptyClasses(),
  },
  elements: {
    a: emptyClasses(),
    audio: emptyClasses(),
    body: emptyClasses(),
    button: emptyClasses(),
    h1: emptyClasses(),
    h2: emptyClasses(),
    h3: emptyClasses(),
    h4: emptyClasses(),
    h5: emptyClasses(),
    iframe: emptyClasses(),
    input: emptyClasses(),
    p: emptyClasses(),
    pre: emptyClasses(),
    textarea: emptyClasses(),
    video: emptyClasses(),
  },
  markdown: {
    p: [],
    h1: [],
    h2: [],
    h3: [],
    h4: [],
    h5: [],
    ul: [],
    ol: [],
    li: [],
    a: [],
    strong: [],
    em: [],
  },
  additionalStyles: {
    Card: {
      background: "linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.03))",
      border: "1px solid rgba(255,255,255,.09)",
      borderRadius: "14px",
      padding: "14px",
      boxShadow: cardShadow,
    },
    Modal: {
      background: "rgba(12, 16, 24, 0.92)",
      border: "1px solid rgba(255,255,255,.12)",
      borderRadius: "16px",
      padding: "16px",
      boxShadow: "0 30px 80px rgba(0,0,0,.6)",
      width: "min(520px, calc(100vw - 48px))",
    },
    Column: { gap: "10px" },
    Row: { gap: "10px", alignItems: "center" },
    Divider: { opacity: "0.25" },
    Button: {
      background: "linear-gradient(135deg, #22c55e 0%, #06b6d4 100%)",
      border: "0",
      borderRadius: "12px",
      padding: "10px 14px",
      color: "#071016",
      fontWeight: "650",
      cursor: "pointer",
      boxShadow: buttonShadow,
    },
    Text: {
      ...textHintStyles(),
      h1: { fontSize: "20px", fontWeight: "750", margin: "0 0 6px 0" },
      h2: { fontSize: "16px", fontWeight: "700", margin: "0 0 6px 0" },
      body: { fontSize: "13px", lineHeight: "1.4" },
      caption: { opacity: "0.8" },
    },
    TextField: { display: "grid", gap: "6px" },
    Image: { borderRadius: "12px" },
  },
};

class ClawdbotA2UIHost extends LitElement {
  static properties = {
    surfaces: { state: true },
    pendingAction: { state: true },
    toast: { state: true },
  };

  #processor = v0_8.Data.createSignalA2uiMessageProcessor();
  #themeProvider = new ContextProvider(this, {
    context: themeContext,
    initialValue: clawdbotTheme,
  });

  surfaces = [];
  pendingAction = null;
  toast = null;
  #statusListener = null;

  static styles = css`
    :host {
      display: block;
      height: 100%;
      position: relative;
      box-sizing: border-box;
      padding:
        var(--clawdbot-a2ui-inset-top, 0px)
        var(--clawdbot-a2ui-inset-right, 0px)
        var(--clawdbot-a2ui-inset-bottom, 0px)
        var(--clawdbot-a2ui-inset-left, 0px);
    }

    #surfaces {
      display: grid;
      grid-template-columns: 1fr;
      gap: 12px;
      height: 100%;
      overflow: auto;
      padding-bottom: var(--clawdbot-a2ui-scroll-pad-bottom, 0px);
    }

    .status {
      position: absolute;
      left: 50%;
      transform: translateX(-50%);
      top: var(--clawdbot-a2ui-status-top, 12px);
      display: inline-flex;
      align-items: center;
      gap: 8px;
      padding: 8px 10px;
      border-radius: 12px;
      background: rgba(0, 0, 0, 0.45);
      border: 1px solid rgba(255, 255, 255, 0.18);
      color: rgba(255, 255, 255, 0.92);
      font: 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif;
      pointer-events: none;
      backdrop-filter: blur(${unsafeCSS(statusBlur)});
      -webkit-backdrop-filter: blur(${unsafeCSS(statusBlur)});
      box-shadow: ${unsafeCSS(statusShadow)};
      z-index: 5;
    }

    .toast {
      position: absolute;
      left: 50%;
      transform: translateX(-50%);
      bottom: var(--clawdbot-a2ui-toast-bottom, 12px);
      display: inline-flex;
      align-items: center;
      gap: 8px;
      padding: 8px 10px;
      border-radius: 12px;
      background: rgba(0, 0, 0, 0.45);
      border: 1px solid rgba(255, 255, 255, 0.18);
      color: rgba(255, 255, 255, 0.92);
      font: 13px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif;
      pointer-events: none;
      backdrop-filter: blur(${unsafeCSS(statusBlur)});
      -webkit-backdrop-filter: blur(${unsafeCSS(statusBlur)});
      box-shadow: ${unsafeCSS(statusShadow)};
      z-index: 5;
    }

    .toast.error {
      border-color: rgba(255, 109, 109, 0.35);
      color: rgba(255, 223, 223, 0.98);
    }

    .empty {
      position: absolute;
      left: 50%;
      transform: translateX(-50%);
      top: var(--clawdbot-a2ui-empty-top, var(--clawdbot-a2ui-status-top, 12px));
      text-align: center;
      opacity: 0.8;
      padding: 10px 12px;
      pointer-events: none;
    }

    .empty-title {
      font-weight: 700;
      margin-bottom: 6px;
    }

    .spinner {
      width: 12px;
      height: 12px;
      border-radius: 999px;
      border: 2px solid rgba(255, 255, 255, 0.25);
      border-top-color: rgba(255, 255, 255, 0.92);
      animation: spin 0.75s linear infinite;
    }

    @keyframes spin {
      from {
        transform: rotate(0deg);
      }
      to {
        transform: rotate(360deg);
      }
    }
  `;

  connectedCallback() {
    super.connectedCallback();
    globalThis.clawdbotA2UI = {
      applyMessages: (messages) => this.applyMessages(messages),
      reset: () => this.reset(),
      getSurfaces: () => Array.from(this.#processor.getSurfaces().keys()),
    };
    this.addEventListener("a2uiaction", (evt) => this.#handleA2UIAction(evt));
    this.#statusListener = (evt) => this.#handleActionStatus(evt);
    globalThis.addEventListener("clawdbot:a2ui-action-status", this.#statusListener);
    this.#syncSurfaces();
  }

  disconnectedCallback() {
    super.disconnectedCallback();
    if (this.#statusListener) {
      globalThis.removeEventListener("clawdbot:a2ui-action-status", this.#statusListener);
      this.#statusListener = null;
    }
  }

  #makeActionId() {
    return globalThis.crypto?.randomUUID?.() ?? `a2ui_${Date.now()}_${Math.random().toString(16).slice(2)}`;
  }

  #setToast(text, kind = "ok", timeoutMs = 1400) {
    const toast = { text, kind, expiresAt: Date.now() + timeoutMs };
    this.toast = toast;
    this.requestUpdate();
    setTimeout(() => {
      if (this.toast === toast) {
        this.toast = null;
        this.requestUpdate();
      }
    }, timeoutMs + 30);
  }

  #handleActionStatus(evt) {
    const detail = evt?.detail ?? null;
    if (!detail || typeof detail.id !== "string") return;
    if (!this.pendingAction || this.pendingAction.id !== detail.id) return;

    if (detail.ok) {
      this.pendingAction = { ...this.pendingAction, phase: "sent", sentAt: Date.now() };
    } else {
      const msg = typeof detail.error === "string" && detail.error ? detail.error : "send failed";
      this.pendingAction = { ...this.pendingAction, phase: "error", error: msg };
      this.#setToast(`Failed: ${msg}`, "error", 4500);
    }
    this.requestUpdate();
  }

  #handleA2UIAction(evt) {
    const payload = evt?.detail ?? evt?.payload ?? null;
    if (!payload || payload.eventType !== "a2ui.action") {
      return;
    }

    const action = payload.action;
    const name = action?.name;
    if (!name) {
      return;
    }

    const sourceComponentId = payload.sourceComponentId ?? "";
    const surfaces = this.#processor.getSurfaces();

    let surfaceId = null;
    let sourceNode = null;
    for (const [sid, surface] of surfaces.entries()) {
      const node = surface?.components?.get?.(sourceComponentId) ?? null;
      if (node) {
        surfaceId = sid;
        sourceNode = node;
        break;
      }
    }

    const context = {};
    const ctxItems = Array.isArray(action?.context) ? action.context : [];
    for (const item of ctxItems) {
      const key = item?.key;
      const value = item?.value ?? null;
      if (!key || !value) continue;

      if (typeof value.path === "string") {
        const resolved = sourceNode
          ? this.#processor.getData(sourceNode, value.path, surfaceId ?? undefined)
          : null;
        context[key] = resolved;
        continue;
      }
      if (Object.prototype.hasOwnProperty.call(value, "literalString")) {
        context[key] = value.literalString ?? "";
        continue;
      }
      if (Object.prototype.hasOwnProperty.call(value, "literalNumber")) {
        context[key] = value.literalNumber ?? 0;
        continue;
      }
      if (Object.prototype.hasOwnProperty.call(value, "literalBoolean")) {
        context[key] = value.literalBoolean ?? false;
        continue;
      }
    }

    const actionId = this.#makeActionId();
    this.pendingAction = { id: actionId, name, phase: "sending", startedAt: Date.now() };
    this.requestUpdate();

    const userAction = {
      id: actionId,
      name,
      surfaceId: surfaceId ?? "main",
      sourceComponentId,
      timestamp: new Date().toISOString(),
      ...(Object.keys(context).length ? { context } : {}),
    };

    globalThis.__clawdbotLastA2UIAction = userAction;

    const handler =
      globalThis.webkit?.messageHandlers?.clawdbotCanvasA2UIAction ??
      globalThis.clawdbotCanvasA2UIAction;
    if (handler?.postMessage) {
      try {
        // WebKit message handlers support structured objects; Android's JS interface expects strings.
        if (handler === globalThis.clawdbotCanvasA2UIAction) {
          handler.postMessage(JSON.stringify({ userAction }));
        } else {
          handler.postMessage({ userAction });
        }
      } catch (e) {
        const msg = String(e?.message ?? e);
        this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: msg };
        this.#setToast(`Failed: ${msg}`, "error", 4500);
      }
    } else {
      this.pendingAction = { id: actionId, name, phase: "error", startedAt: Date.now(), error: "missing native bridge" };
      this.#setToast("Failed: missing native bridge", "error", 4500);
    }
  }

  applyMessages(messages) {
    if (!Array.isArray(messages)) {
      throw new Error("A2UI: expected messages array");
    }
    this.#processor.processMessages(messages);
    this.#syncSurfaces();
    if (this.pendingAction?.phase === "sent") {
      this.#setToast(`Updated: ${this.pendingAction.name}`, "ok", 1100);
      this.pendingAction = null;
    }
    this.requestUpdate();
    return { ok: true, surfaces: this.surfaces.map(([id]) => id) };
  }

  reset() {
    this.#processor.clearSurfaces();
    this.#syncSurfaces();
    this.pendingAction = null;
    this.requestUpdate();
    return { ok: true };
  }

  #syncSurfaces() {
    this.surfaces = Array.from(this.#processor.getSurfaces().entries());
  }

  render() {
    if (this.surfaces.length === 0) {
      return html`<div class="empty">
        <div class="empty-title">Canvas (A2UI)</div>
        <div>Waiting for A2UI messages…</div>
      </div>`;
    }

    const statusText =
      this.pendingAction?.phase === "sent"
        ? `Working: ${this.pendingAction.name}`
        : this.pendingAction?.phase === "sending"
          ? `Sending: ${this.pendingAction.name}`
          : this.pendingAction?.phase === "error"
            ? `Failed: ${this.pendingAction.name}`
            : "";

    return html`
      ${this.pendingAction && this.pendingAction.phase !== "error"
        ? html`<div class="status"><div class="spinner"></div><div>${statusText}</div></div>`
        : ""}
      ${this.toast
        ? html`<div class="toast ${this.toast.kind === "error" ? "error" : ""}">${this.toast.text}</div>`
        : ""}
      <section id="surfaces">
      ${repeat(
        this.surfaces,
        ([surfaceId]) => surfaceId,
        ([surfaceId, surface]) => html`<a2ui-surface
          .surfaceId=${surfaceId}
          .surface=${surface}
          .processor=${this.#processor}
        ></a2ui-surface>`
      )}
    </section>`;
  }
}

customElements.define("clawdbot-a2ui-host", ClawdbotA2UIHost);


--- apps/macos/Sources/Clawdbot/Resources/DeviceModels/NOTICE.md ---
# Apple device identifier mappings

This directory includes model identifier → human-readable name mappings derived from the open-source project:

- `kyle-seongwoo-jun/apple-device-identifiers`
  - iOS mapping pinned to commit `8e7388b29da046183f5d976eb74dbb2f2acda955`
  - macOS mapping pinned to commit `98ca75324f7a88c1649eb5edfc266ef47b7b8193`

See `LICENSE.apple-device-identifiers.txt` for license terms.


--- apps/shared/ClawdbotKit/Sources/ClawdbotKit/Resources/CanvasScaffold/scaffold.html ---
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
    <title>Canvas</title>
    <script>
      (() => {
        try {
          const params = new URLSearchParams(window.location.search);
          const platform = (params.get('platform') || '').trim().toLowerCase();
          if (platform) {
            document.documentElement.dataset.platform = platform;
            return;
          }
          if (/android/i.test(navigator.userAgent || '')) {
            document.documentElement.dataset.platform = 'android';
          }
        } catch (_) {}
      })();
    </script>
    <style>
      :root { color-scheme: dark; }
      @media (prefers-reduced-motion: reduce) {
        body::before, body::after { animation: none !important; }
      }
      html,body { height:100%; margin:0; }
      body {
        background:
          radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.18), rgba(0,0,0,0) 55%),
          radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.14), rgba(0,0,0,0) 60%),
          radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.10), rgba(0,0,0,0) 60%),
          #000;
        overflow: hidden;
      }
      :root[data-platform="android"] body {
        background:
          radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.62), rgba(0,0,0,0) 55%),
          radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.52), rgba(0,0,0,0) 60%),
          radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.48), rgba(0,0,0,0) 60%),
          #0b1328;
      }
      body::before {
        content:"";
        position: fixed;
        inset: -20%;
        background:
          repeating-linear-gradient(0deg, rgba(255,255,255,0.03) 0, rgba(255,255,255,0.03) 1px,
                                 transparent 1px, transparent 48px),
          repeating-linear-gradient(90deg, rgba(255,255,255,0.03) 0, rgba(255,255,255,0.03) 1px,
                                 transparent 1px, transparent 48px);
        transform: translate3d(0,0,0) rotate(-7deg);
        will-change: transform, opacity;
        -webkit-backface-visibility: hidden;
        backface-visibility: hidden;
        opacity: 0.45;
        pointer-events: none;
        animation: clawdbot-grid-drift 140s ease-in-out infinite alternate;
      }
      :root[data-platform="android"] body::before { opacity: 0.80; }
      body::after {
        content:"";
        position: fixed;
        inset: -35%;
        background:
          radial-gradient(900px 700px at 30% 30%, rgba(42,113,255,0.16), rgba(0,0,0,0) 60%),
          radial-gradient(800px 650px at 70% 35%, rgba(255,0,138,0.12), rgba(0,0,0,0) 62%),
          radial-gradient(900px 800px at 55% 75%, rgba(0,209,255,0.10), rgba(0,0,0,0) 62%);
        filter: blur(28px);
        opacity: 0.52;
        will-change: transform, opacity;
        -webkit-backface-visibility: hidden;
        backface-visibility: hidden;
        transform: translate3d(0,0,0);
        pointer-events: none;
        animation: clawdbot-glow-drift 110s ease-in-out infinite alternate;
      }
      :root[data-platform="android"] body::after { opacity: 0.85; }
      @supports (mix-blend-mode: screen) {
        body::after { mix-blend-mode: screen; }
      }
      @supports not (mix-blend-mode: screen) {
        body::after { opacity: 0.70; }
      }
      @keyframes clawdbot-grid-drift {
        0%   { transform: translate3d(-12px, 8px, 0) rotate(-7deg); opacity: 0.40; }
        50%  { transform: translate3d( 10px,-7px, 0) rotate(-6.6deg); opacity: 0.56; }
        100% { transform: translate3d(-8px,  6px, 0) rotate(-7.2deg); opacity: 0.42; }
      }
      @keyframes clawdbot-glow-drift {
        0%   { transform: translate3d(-18px, 12px, 0) scale(1.02); opacity: 0.40; }
        50%  { transform: translate3d( 14px,-10px, 0) scale(1.05); opacity: 0.52; }
        100% { transform: translate3d(-10px,  8px, 0) scale(1.03); opacity: 0.43; }
      }
      canvas {
        position: fixed;
        inset: 0;
        display:block;
        width:100vw;
        height:100vh;
        touch-action: none;
        z-index: 1;
      }
      :root[data-platform="android"] #clawdbot-canvas {
        background:
          radial-gradient(1100px 800px at 20% 15%, rgba(42, 113, 255, 0.78), rgba(0,0,0,0) 58%),
          radial-gradient(900px 650px at 82% 28%, rgba(255, 0, 138, 0.66), rgba(0,0,0,0) 62%),
          radial-gradient(1000px 900px at 60% 88%, rgba(0, 209, 255, 0.58), rgba(0,0,0,0) 62%),
          #141c33;
      }
      #clawdbot-status {
        position: fixed;
        inset: 0;
        display: none;
        align-items: center;
        justify-content: flex-start;
        flex-direction: column;
        padding-top: calc(20px + env(safe-area-inset-top, 0px));
        pointer-events: none;
        z-index: 3;
      }
      #clawdbot-status .card {
        text-align: center;
        padding: 16px 18px;
        border-radius: 14px;
        background: rgba(18, 18, 22, 0.42);
        border: 1px solid rgba(255,255,255,0.08);
        box-shadow: 0 18px 60px rgba(0,0,0,0.55);
        -webkit-backdrop-filter: blur(14px);
        backdrop-filter: blur(14px);
      }
      #clawdbot-status .title {
        font: 600 20px -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", system-ui, sans-serif;
        letter-spacing: 0.2px;
        color: rgba(255,255,255,0.92);
        text-shadow: 0 0 22px rgba(42, 113, 255, 0.35);
      }
      #clawdbot-status .subtitle {
        margin-top: 6px;
        font: 500 12px -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
        color: rgba(255,255,255,0.58);
      }
    </style>
  </head>
  <body>
    <canvas id="clawdbot-canvas"></canvas>
    <div id="clawdbot-status">
      <div class="card">
        <div class="title" id="clawdbot-status-title">Ready</div>
        <div class="subtitle" id="clawdbot-status-subtitle">Waiting for agent</div>
      </div>
    </div>
    <script>
      (() => {
        const canvas = document.getElementById('clawdbot-canvas');
        const ctx = canvas.getContext('2d');
        const statusEl = document.getElementById('clawdbot-status');
        const titleEl = document.getElementById('clawdbot-status-title');
        const subtitleEl = document.getElementById('clawdbot-status-subtitle');
        const debugStatusEnabledByQuery = (() => {
          try {
            const params = new URLSearchParams(window.location.search);
            const raw = params.get('debugStatus') ?? params.get('debug');
            if (!raw) return false;
            const normalized = String(raw).trim().toLowerCase();
            return normalized === '1' || normalized === 'true' || normalized === 'yes';
          } catch (_) {
            return false;
          }
        })();
        let debugStatusEnabled = debugStatusEnabledByQuery;

        function resize() {
          const dpr = window.devicePixelRatio || 1;
          const w = Math.max(1, Math.floor(window.innerWidth * dpr));
          const h = Math.max(1, Math.floor(window.innerHeight * dpr));
          canvas.width = w;
          canvas.height = h;
          ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        }

        window.addEventListener('resize', resize);
        resize();

        const setDebugStatusEnabled = (enabled) => {
          debugStatusEnabled = !!enabled;
          if (!statusEl) return;
          if (!debugStatusEnabled) {
            statusEl.style.display = 'none';
          }
        };

        if (statusEl && !debugStatusEnabled) {
          statusEl.style.display = 'none';
        }

        window.__clawdbot = {
          canvas,
          ctx,
          setDebugStatusEnabled,
          setStatus: (title, subtitle) => {
            if (!statusEl || !debugStatusEnabled) return;
            if (!title && !subtitle) {
              statusEl.style.display = 'none';
              return;
            }
            statusEl.style.display = 'flex';
            if (titleEl && typeof title === 'string') titleEl.textContent = title;
            if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
            if (!debugStatusEnabled) {
              clearTimeout(window.__statusTimeout);
              window.__statusTimeout = setTimeout(() => {
                statusEl.style.display = 'none';
              }, 3000);
            } else {
              clearTimeout(window.__statusTimeout);
            }
          }
        };
      })();

    </script>
  </body>
</html>


--- scripts/bench-model.ts ---
import { completeSimple, getModel, type Model } from "@mariozechner/pi-ai";

type Usage = {
  input?: number;
  output?: number;
  cacheRead?: number;
  cacheWrite?: number;
  totalTokens?: number;
};

type RunResult = {
  durationMs: number;
  usage?: Usage;
};

const DEFAULT_PROMPT =
  "Reply with a single word: ok. No punctuation or extra text.";
const DEFAULT_RUNS = 10;

function parseArg(flag: string): string | undefined {
  const idx = process.argv.indexOf(flag);
  if (idx === -1) return undefined;
  return process.argv[idx + 1];
}

function parseRuns(raw: string | undefined): number {
  if (!raw) return DEFAULT_RUNS;
  const parsed = Number(raw);
  if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_RUNS;
  return Math.floor(parsed);
}

function median(values: number[]): number {
  if (values.length === 0) return 0;
  const sorted = [...values].sort((a, b) => a - b);
  const mid = Math.floor(sorted.length / 2);
  if (sorted.length % 2 === 0) {
    return Math.round((sorted[mid - 1] + sorted[mid]) / 2);
  }
  return sorted[mid];
}

async function runModel(opts: {
  label: string;
  model: Model<any>;
  apiKey: string;
  runs: number;
  prompt: string;
}): Promise<RunResult[]> {
  const results: RunResult[] = [];
  for (let i = 0; i < opts.runs; i += 1) {
    const started = Date.now();
    const res = await completeSimple(
      opts.model,
      {
        messages: [
          {
            role: "user",
            content: opts.prompt,
            timestamp: Date.now(),
          },
        ],
      },
      { apiKey: opts.apiKey, maxTokens: 64 },
    );
    const durationMs = Date.now() - started;
    results.push({ durationMs, usage: res.usage });
    console.log(
      `${opts.label} run ${i + 1}/${opts.runs}: ${durationMs}ms`,
    );
  }
  return results;
}

async function main(): Promise<void> {
  const runs = parseRuns(parseArg("--runs"));
  const prompt = parseArg("--prompt") ?? DEFAULT_PROMPT;

  const anthropicKey = process.env.ANTHROPIC_API_KEY?.trim();
  const minimaxKey = process.env.MINIMAX_API_KEY?.trim();
  if (!anthropicKey) {
    throw new Error("Missing ANTHROPIC_API_KEY in environment.");
  }
  if (!minimaxKey) {
    throw new Error("Missing MINIMAX_API_KEY in environment.");
  }

  const minimaxBaseUrl =
    process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/v1";
  const minimaxModelId =
    process.env.MINIMAX_MODEL?.trim() || "minimax-m2.1";

  const minimaxModel: Model<"openai-completions"> = {
    id: minimaxModelId,
    name: `MiniMax ${minimaxModelId}`,
    api: "openai-completions",
    provider: "minimax",
    baseUrl: minimaxBaseUrl,
    reasoning: false,
    input: ["text"],
    cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
    contextWindow: 200000,
    maxTokens: 8192,
  };
  const opusModel = getModel("anthropic", "claude-opus-4-5");

  console.log(`Prompt: ${prompt}`);
  console.log(`Runs: ${runs}`);
  console.log("");

  const minimaxResults = await runModel({
    label: "minimax",
    model: minimaxModel,
    apiKey: minimaxKey,
    runs,
    prompt,
  });
  const opusResults = await runModel({
    label: "opus",
    model: opusModel,
    apiKey: anthropicKey,
    runs,
    prompt,
  });

  const summarize = (label: string, results: RunResult[]) => {
    const durations = results.map((r) => r.durationMs);
    const med = median(durations);
    const min = Math.min(...durations);
    const max = Math.max(...durations);
    return { label, med, min, max };
  };

  const summary = [summarize("minimax", minimaxResults), summarize("opus", opusResults)];
  console.log("");
  console.log("Summary (ms):");
  for (const row of summary) {
    console.log(
      `${row.label.padEnd(7)} median=${row.med} min=${row.min} max=${row.max}`,
    );
  }
}

await main();


--- scripts/canvas-a2ui-copy.ts ---
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";

const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const srcDir = path.join(repoRoot, "src", "canvas-host", "a2ui");
const outDir = path.join(repoRoot, "dist", "canvas-host", "a2ui");

async function main() {
  await fs.stat(path.join(srcDir, "index.html"));
  await fs.stat(path.join(srcDir, "a2ui.bundle.js"));
  await fs.mkdir(path.dirname(outDir), { recursive: true });
  await fs.cp(srcDir, outDir, { recursive: true });
}

main().catch((err) => {
  console.error(String(err));
  process.exit(1);
});


--- scripts/protocol-gen.ts ---
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { ProtocolSchemas } from "../src/gateway/protocol/schema.js";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..");

async function writeJsonSchema() {
  const definitions: Record<string, unknown> = {};
  for (const [name, schema] of Object.entries(ProtocolSchemas)) {
    definitions[name] = schema;
  }

  const rootSchema = {
    $schema: "http://json-schema.org/draft-07/schema#",
    $id: "https://clawdbot.dev/protocol.schema.json",
    title: "Clawdbot Gateway Protocol",
    description: "Handshake, request/response, and event frames for the Gateway WebSocket.",
    oneOf: [
      { $ref: "#/definitions/RequestFrame" },
      { $ref: "#/definitions/ResponseFrame" },
      { $ref: "#/definitions/EventFrame" },
    ],
    discriminator: {
      propertyName: "type",
      mapping: {
        req: "#/definitions/RequestFrame",
        res: "#/definitions/ResponseFrame",
        event: "#/definitions/EventFrame",
      },
    },
    definitions,
  };

  const distDir = path.join(repoRoot, "dist");
  await fs.mkdir(distDir, { recursive: true });
  const jsonSchemaPath = path.join(distDir, "protocol.schema.json");
  await fs.writeFile(jsonSchemaPath, JSON.stringify(rootSchema, null, 2));
  console.log(`wrote ${jsonSchemaPath}`);
  return { jsonSchemaPath, schemaString: JSON.stringify(rootSchema) };
}

async function main() {
  await writeJsonSchema();
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});


--- scripts/protocol-gen-swift.ts ---
import { promises as fs } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
  ErrorCodes,
  PROTOCOL_VERSION,
  ProtocolSchemas,
} from "../src/gateway/protocol/schema.js";

type JsonSchema = {
  type?: string | string[];
  properties?: Record<string, JsonSchema>;
  required?: string[];
  items?: JsonSchema;
  enum?: string[];
  patternProperties?: Record<string, JsonSchema>;
};

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(__dirname, "..");
const outPath = path.join(
  repoRoot,
  "apps",
  "macos",
  "Sources",
  "ClawdbotProtocol",
  "GatewayModels.swift",
);

const header = `// Generated by scripts/protocol-gen-swift.ts — do not edit by hand\nimport Foundation\n\npublic let GATEWAY_PROTOCOL_VERSION = ${PROTOCOL_VERSION}\n\npublic enum ErrorCode: String, Codable, Sendable {\n${Object.values(ErrorCodes)
  .map((c) => `    case ${camelCase(c)} = "${c}"`)
  .join("\n")}\n}\n`;

const reserved = new Set([
  "associatedtype",
  "class",
  "deinit",
  "enum",
  "extension",
  "fileprivate",
  "func",
  "import",
  "init",
  "inout",
  "internal",
  "let",
  "open",
  "operator",
  "private",
  "precedencegroup",
  "protocol",
  "public",
  "rethrows",
  "static",
  "struct",
  "subscript",
  "typealias",
  "var",
]);

function camelCase(input: string) {
  return input
    .replace(/[^a-zA-Z0-9]+/g, " ")
    .trim()
    .toLowerCase()
    .split(/\s+/)
    .map((p, i) => (i === 0 ? p : p[0].toUpperCase() + p.slice(1)))
    .join("");
}

function safeName(name: string) {
  const cc = camelCase(name.replace(/-/g, "_"));
  if (reserved.has(cc)) return `_${cc}`;
  return cc;
}

// filled later once schemas are loaded
const schemaNameByObject = new Map<object, string>();

function swiftType(schema: JsonSchema, required: boolean): string {
  const t = schema.type;
  const isOptional = !required;
  let base: string;
  const named = schemaNameByObject.get(schema as object);
  if (named) {
    base = named;
  } else if (t === "string") base = "String";
  else if (t === "integer") base = "Int";
  else if (t === "number") base = "Double";
  else if (t === "boolean") base = "Bool";
  else if (t === "array") {
    base = `[${swiftType(schema.items ?? { type: "Any" }, true)}]`;
  } else if (schema.enum) {
    base = "String";
  } else if (schema.patternProperties) {
    base = "[String: AnyCodable]";
  } else if (t === "object") {
    base = "[String: AnyCodable]";
  } else {
    base = "AnyCodable";
  }
  return isOptional ? `${base}?` : base;
}

function emitStruct(name: string, schema: JsonSchema): string {
  const props = schema.properties ?? {};
  const required = new Set(schema.required ?? []);
  const lines: string[] = [];
  lines.push(`public struct ${name}: Codable, Sendable {`);
  if (Object.keys(props).length === 0) {
    lines.push("}\n");
    return lines.join("\n");
  }
  const codingKeys: string[] = [];
  for (const [key, propSchema] of Object.entries(props)) {
    const propName = safeName(key);
    const propType = swiftType(propSchema, required.has(key));
    lines.push(`    public let ${propName}: ${propType}`);
    if (propName !== key) {
      codingKeys.push(`        case ${propName} = "${key}"`);
    } else {
      codingKeys.push(`        case ${propName}`);
    }
  }
  lines.push("\n    public init(\n" +
    Object.entries(props)
      .map(([key, prop]) => {
        const propName = safeName(key);
        const req = required.has(key);
        return `        ${propName}: ${swiftType(prop, true)}${req ? "" : "?"}`;
      })
      .join(",\n") +
    "\n    ) {\n" +
    Object.entries(props)
      .map(([key]) => {
        const propName = safeName(key);
        return `        self.${propName} = ${propName}`;
      })
      .join("\n") +
    "\n    }\n" +
    "    private enum CodingKeys: String, CodingKey {\n" +
    codingKeys.join("\n") +
    "\n    }\n}");
  lines.push("");
  return lines.join("\n");
}

function emitGatewayFrame(): string {
  const cases = ["req", "res", "event"];
  const associated: Record<string, string> = {
    req: "RequestFrame",
    res: "ResponseFrame",
    event: "EventFrame",
  };
  const caseLines = cases.map((c) => `    case ${safeName(c)}(${associated[c]})`);
  const initLines = `
    private enum CodingKeys: String, CodingKey {
        case type
    }

    public init(from decoder: Decoder) throws {
        let typeContainer = try decoder.container(keyedBy: CodingKeys.self)
        let type = try typeContainer.decode(String.self, forKey: .type)
        switch type {
        case "req":
            self = .req(try RequestFrame(from: decoder))
        case "res":
            self = .res(try ResponseFrame(from: decoder))
        case "event":
            self = .event(try EventFrame(from: decoder))
        default:
            let container = try decoder.singleValueContainer()
            let raw = try container.decode([String: AnyCodable].self)
            self = .unknown(type: type, raw: raw)
        }
    }

    public func encode(to encoder: Encoder) throws {
        switch self {
        case .req(let v): try v.encode(to: encoder)
        case .res(let v): try v.encode(to: encoder)
        case .event(let v): try v.encode(to: encoder)
        case .unknown(_, let raw):
            var container = encoder.singleValueContainer()
            try container.encode(raw)
        }
    }
`;

  return [
    "public enum GatewayFrame: Codable, Sendable {",
    ...caseLines,
    "    case unknown(type: String, raw: [String: AnyCodable])",
    initLines,
    "}",
    "",
  ].join("\n");
}

async function generate() {
  const definitions = Object.entries(ProtocolSchemas) as Array<
    [string, JsonSchema]
  >;

  for (const [name, schema] of definitions) {
    schemaNameByObject.set(schema as object, name);
  }

  const parts: string[] = [];
  parts.push(header);

  // Value structs
  for (const [name, schema] of definitions) {
    if (name === "GatewayFrame") continue;
    if (schema.type === "object") {
      parts.push(emitStruct(name, schema));
    }
  }

  // Frame enum must come after payload structs
  parts.push(emitGatewayFrame());

  const content = parts.join("\n");
  await fs.mkdir(path.dirname(outPath), { recursive: true });
  await fs.writeFile(outPath, content);
  console.log(`wrote ${outPath}`);
}

generate().catch((err) => {
  console.error(err);
  process.exit(1);
});


--- scripts/test-force.ts ---
#!/usr/bin/env bun
import os from "node:os";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { forceFreePort, type PortProcess } from "../src/cli/ports.js";

const DEFAULT_PORT = 18789;

function killGatewayListeners(port: number): PortProcess[] {
  try {
    const killed = forceFreePort(port);
    if (killed.length > 0) {
      console.log(
        `freed port ${port}; terminated: ${killed
          .map((p) => `${p.command} (pid ${p.pid})`)
          .join(", ")}`,
      );
    } else {
      console.log(`port ${port} already free`);
    }
    return killed;
  } catch (err) {
    console.error(`failed to free port ${port}: ${String(err)}`);
    return [];
  }
}

function runTests() {
  const isolatedLock =
    process.env.CLAWDBOT_GATEWAY_LOCK ??
    path.join(os.tmpdir(), `clawdbot-gateway.lock.test.${Date.now()}`);
  const result = spawnSync("pnpm", ["vitest", "run"], {
    stdio: "inherit",
    env: {
      ...process.env,
      CLAWDBOT_GATEWAY_LOCK: isolatedLock,
    },
  });
  if (result.error) {
    console.error(`pnpm test failed to start: ${String(result.error)}`);
    process.exit(1);
  }
  process.exit(result.status ?? 1);
}

function main() {
  const port = Number.parseInt(
    process.env.CLAWDBOT_GATEWAY_PORT ?? `${DEFAULT_PORT}`,
    10,
  );

  console.log(`🧹 test:force - clearing gateway on port ${port}`);
  const killed = killGatewayListeners(port);
  if (killed.length === 0) {
    console.log("no listeners to kill");
  }

  console.log("running pnpm test…");
  runTests();
}

main();


--- scripts/ui.js ---
#!/usr/bin/env node
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { fileURLToPath } from "node:url";

const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "..");
const uiDir = path.join(repoRoot, "ui");

function usage() {
  // keep this tiny; it's invoked from npm scripts too
  process.stderr.write(
    "Usage: node scripts/ui.js <install|dev|build|test> [...args]\n",
  );
}

function which(cmd) {
  try {
    const key = process.platform === "win32" ? "Path" : "PATH";
    const paths = (process.env[key] ?? process.env.PATH ?? "")
      .split(path.delimiter)
      .filter(Boolean);
    const extensions =
      process.platform === "win32"
        ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM")
            .split(";")
            .filter(Boolean)
        : [""];
    for (const entry of paths) {
      for (const ext of extensions) {
        const candidate = path.join(entry, process.platform === "win32" ? `${cmd}${ext}` : cmd);
        try {
          if (fs.existsSync(candidate)) return candidate;
        } catch {
          // ignore
        }
      }
    }
  } catch {
    // ignore
  }
  return null;
}

function resolveRunner() {
  const bun = which("bun");
  if (bun) return { cmd: bun, kind: "bun" };
  const pnpm = which("pnpm");
  if (pnpm) return { cmd: pnpm, kind: "pnpm" };
  return null;
}

function run(cmd, args) {
  const child = spawn(cmd, args, {
    cwd: uiDir,
    stdio: "inherit",
    env: process.env,
  });
  child.on("exit", (code, signal) => {
    if (signal) process.exit(1);
    process.exit(code ?? 1);
  });
}

function runSync(cmd, args) {
  const result = spawnSync(cmd, args, {
    cwd: uiDir,
    stdio: "inherit",
    env: process.env,
  });
  if (result.signal) process.exit(1);
  if ((result.status ?? 1) !== 0) process.exit(result.status ?? 1);
}

function depsInstalled() {
  try {
    const require = createRequire(path.join(uiDir, "package.json"));
    require.resolve("vite");
    require.resolve("dompurify");
    return true;
  } catch {
    return false;
  }
}

const [, , action, ...rest] = process.argv;
if (!action) {
  usage();
  process.exit(2);
}

const runner = resolveRunner();
if (!runner) {
  process.stderr.write(
    "Missing UI runner: install bun or pnpm, then retry.\n",
  );
  process.exit(1);
}

const script =
  action === "install"
    ? null
    : action === "dev"
      ? "dev"
      : action === "build"
        ? "build"
        : action === "test"
          ? "test"
          : null;

if (action !== "install" && !script) {
  usage();
  process.exit(2);
}

if (runner.kind === "bun") {
  if (action === "install") run(runner.cmd, ["install", ...rest]);
  else {
    if (!depsInstalled()) runSync(runner.cmd, ["install"]);
    run(runner.cmd, ["run", script, ...rest]);
  }
} else {
  if (action === "install") run(runner.cmd, ["install", ...rest]);
  else {
    if (!depsInstalled()) runSync(runner.cmd, ["install"]);
    run(runner.cmd, ["run", script, ...rest]);
  }
}


--- skills/local-places/SERVER_README.md ---
# Local Places

This repo is a fusion of two pieces:

- A FastAPI server that exposes endpoints for searching and resolving places via the Google Maps Places API.
- A companion agent skill that explains how to use the API and can call it to find places efficiently.

Together, the skill and server let an agent turn natural-language place queries into structured results quickly.

## Run locally

```bash
# copy skill definition into the relevant folder (where the agent looks for it)
# then run the server

uv venv
uv pip install -e ".[dev]"
uv run --env-file .env uvicorn local_places.main:app --host 0.0.0.0 --reload
```

Open the API docs at http://127.0.0.1:8000/docs.

## Places API

Set the Google Places API key before running:

```bash
export GOOGLE_PLACES_API_KEY="your-key"
```

Endpoints:

- `POST /places/search` (free-text query + filters)
- `GET /places/{place_id}` (place details)
- `POST /locations/resolve` (resolve a user-provided location string)

Example search request:

```json
{
  "query": "italian restaurant",
  "filters": {
    "types": ["restaurant"],
    "open_now": true,
    "min_rating": 4.0,
    "price_levels": [1, 2]
  },
  "limit": 10
}
```

Notes:

- `filters.types` supports a single type (mapped to Google `includedType`).

Example search request (curl):

```bash
curl -X POST http://127.0.0.1:8000/places/search \
  -H "Content-Type: application/json" \
  -d '{
    "query": "italian restaurant",
    "location_bias": {
      "lat": 40.8065,
      "lng": -73.9719,
      "radius_m": 3000
    },
    "filters": {
      "types": ["restaurant"],
      "open_now": true,
      "min_rating": 4.0,
      "price_levels": [1, 2, 3]
    },
    "limit": 10
  }'
```

Example resolve request (curl):

```bash
curl -X POST http://127.0.0.1:8000/locations/resolve \
  -H "Content-Type: application/json" \
  -d '{
    "location_text": "Riverside Park, New York",
    "limit": 5
  }'
```

## Test

```bash
uv run pytest
```

## OpenAPI

Generate the OpenAPI schema:

```bash
uv run python scripts/generate_openapi.py
```


--- skills/1password/SKILL.md ---
---
name: 1password
description: Set up and use 1Password CLI (op). Use when installing the CLI, enabling desktop app integration, signing in (single or multi-account), or reading/injecting/running secrets via op.
homepage: https://developer.1password.com/docs/cli/get-started/
metadata: {"clawdbot":{"emoji":"🔐","requires":{"bins":["op"]},"install":[{"id":"brew","kind":"brew","formula":"1password-cli","bins":["op"],"label":"Install 1Password CLI (brew)"}]}}
---

# 1Password CLI

Follow the official CLI get-started steps. Don't guess install commands.

## References

- `references/get-started.md` (install + app integration + sign-in flow)
- `references/cli-examples.md` (real `op` examples)

## Workflow

1. Check OS + shell.
2. Verify CLI present: `op --version`.
3. Confirm desktop app integration is enabled (per get-started) and the app is unlocked.
4. REQUIRED: create a fresh tmux session for all `op` commands (no direct `op` calls outside tmux).
5. Sign in / authorize inside tmux: `op signin` (expect app prompt).
6. Verify access inside tmux: `op whoami` (must succeed before any secret read).
7. If multiple accounts: use `--account` or `OP_ACCOUNT`.

## REQUIRED tmux session (T-Max)

The shell tool uses a fresh TTY per command. To avoid re-prompts and failures, always run `op` inside a dedicated tmux session with a fresh socket/session name.

Example (see `tmux` skill for socket conventions, do not reuse old session names):

```bash
SOCKET_DIR="${CLAWDBOT_TMUX_SOCKET_DIR:-${TMPDIR:-/tmp}/clawdbot-tmux-sockets}"
mkdir -p "$SOCKET_DIR"
SOCKET="$SOCKET_DIR/clawdbot-op.sock"
SESSION="op-auth-$(date +%Y%m%d-%H%M%S)"

tmux -S "$SOCKET" new -d -s "$SESSION" -n shell
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op signin --account my.1password.com" Enter
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op whoami" Enter
tmux -S "$SOCKET" send-keys -t "$SESSION":0.0 -- "op vault list" Enter
tmux -S "$SOCKET" capture-pane -p -J -t "$SESSION":0.0 -S -200
tmux -S "$SOCKET" kill-session -t "$SESSION"
```

## Guardrails

- Never paste secrets into logs, chat, or code.
- Prefer `op run` / `op inject` over writing secrets to disk.
- If sign-in without app integration is needed, use `op account add`.
- If a command returns "account is not signed in", re-run `op signin` inside tmux and authorize in the app.
- Do not run `op` outside tmux; stop and ask if tmux is unavailable.


--- skills/apple-notes/SKILL.md ---
---
name: apple-notes
description: Manage Apple Notes via the `memo` CLI on macOS (create, view, edit, delete, search, move, and export notes). Use when a user asks Clawdbot to add a note, list notes, search notes, or manage note folders.
homepage: https://github.com/antoniorodr/memo
metadata: {"clawdbot":{"emoji":"📝","os":["darwin"],"requires":{"bins":["memo"]},"install":[{"id":"brew","kind":"brew","formula":"antoniorodr/memo/memo","bins":["memo"],"label":"Install memo via Homebrew"}]}}
---

# Apple Notes CLI

Use `memo notes` to manage Apple Notes directly from the terminal. Create, view, edit, delete, search, move notes between folders, and export to HTML/Markdown.

Setup
- Install (Homebrew): `brew tap antoniorodr/memo && brew install antoniorodr/memo/memo`
- Manual (pip): `pip install .` (after cloning the repo)
- macOS-only; if prompted, grant Automation access to Notes.app.

View Notes
- List all notes: `memo notes`
- Filter by folder: `memo notes -f "Folder Name"`
- Search notes (fuzzy): `memo notes -s "query"`

Create Notes
- Add a new note: `memo notes -a`
  - Opens an interactive editor to compose the note.
- Quick add with title: `memo notes -a "Note Title"`

Edit Notes
- Edit existing note: `memo notes -e`
  - Interactive selection of note to edit.

Delete Notes
- Delete a note: `memo notes -d`
  - Interactive selection of note to delete.

Move Notes
- Move note to folder: `memo notes -m`
  - Interactive selection of note and destination folder.

Export Notes
- Export to HTML/Markdown: `memo notes -ex`
  - Exports selected note; uses Mistune for markdown processing.

Limitations
- Cannot edit notes containing images or attachments.
- Interactive prompts may require terminal access.

Notes
- macOS-only.
- Requires Apple Notes.app to be accessible.
- For automation, grant permissions in System Settings > Privacy & Security > Automation.


--- skills/apple-reminders/SKILL.md ---
---
name: apple-reminders
description: Manage Apple Reminders via the `remindctl` CLI on macOS (list, add, edit, complete, delete). Supports lists, date filters, and JSON/plain output.
homepage: https://github.com/steipete/remindctl
metadata: {"clawdbot":{"emoji":"⏰","os":["darwin"],"requires":{"bins":["remindctl"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/remindctl","bins":["remindctl"],"label":"Install remindctl via Homebrew"}]}}
---

# Apple Reminders CLI (remindctl)

Use `remindctl` to manage Apple Reminders directly from the terminal. It supports list filtering, date-based views, and scripting output.

Setup
- Install (Homebrew): `brew install steipete/tap/remindctl`
- From source: `pnpm install && pnpm build` (binary at `./bin/remindctl`)
- macOS-only; grant Reminders permission when prompted.

Permissions
- Check status: `remindctl status`
- Request access: `remindctl authorize`

View Reminders
- Default (today): `remindctl`
- Today: `remindctl today`
- Tomorrow: `remindctl tomorrow`
- Week: `remindctl week`
- Overdue: `remindctl overdue`
- Upcoming: `remindctl upcoming`
- Completed: `remindctl completed`
- All: `remindctl all`
- Specific date: `remindctl 2026-01-04`

Manage Lists
- List all lists: `remindctl list`
- Show list: `remindctl list Work`
- Create list: `remindctl list Projects --create`
- Rename list: `remindctl list Work --rename Office`
- Delete list: `remindctl list Work --delete`

Create Reminders
- Quick add: `remindctl add "Buy milk"`
- With list + due: `remindctl add --title "Call mom" --list Personal --due tomorrow`

Edit Reminders
- Edit title/due: `remindctl edit 1 --title "New title" --due 2026-01-04`

Complete Reminders
- Complete by id: `remindctl complete 1 2 3`

Delete Reminders
- Delete by id: `remindctl delete 4A83 --force`

Output Formats
- JSON (scripting): `remindctl today --json`
- Plain TSV: `remindctl today --plain`
- Counts only: `remindctl today --quiet`

Date Formats
Accepted by `--due` and date filters:
- `today`, `tomorrow`, `yesterday`
- `YYYY-MM-DD`
- `YYYY-MM-DD HH:mm`
- ISO 8601 (`2026-01-04T12:34:56Z`)

Notes
- macOS-only.
- If access is denied, enable Terminal/remindctl in System Settings → Privacy & Security → Reminders.
- If running over SSH, grant access on the Mac that runs the command.


--- skills/bear-notes/SKILL.md ---
---
name: bear-notes
description: Create, search, and manage Bear notes via grizzly CLI.
homepage: https://bear.app
metadata: {"clawdbot":{"emoji":"🐻","os":["darwin"],"requires":{"bins":["grizzly"]},"install":[{"id":"go","kind":"go","module":"github.com/tylerwince/grizzly/cmd/grizzly@latest","bins":["grizzly"],"label":"Install grizzly (go)"}]}}
---

# Bear Notes

Use `grizzly` to create, read, and manage notes in Bear on macOS.

Requirements
- Bear app installed and running
- For some operations (add-text, tags, open-note --selected), a Bear app token (stored in `~/.config/grizzly/token`)

## Getting a Bear Token

For operations that require a token (add-text, tags, open-note --selected), you need an authentication token:
1. Open Bear → Help → API Token → Copy Token
2. Save it: `echo "YOUR_TOKEN" > ~/.config/grizzly/token`

## Common Commands

Create a note
```bash
echo "Note content here" | grizzly create --title "My Note" --tag work
grizzly create --title "Quick Note" --tag inbox < /dev/null
```

Open/read a note by ID
```bash
grizzly open-note --id "NOTE_ID" --enable-callback --json
```

Append text to a note
```bash
echo "Additional content" | grizzly add-text --id "NOTE_ID" --mode append --token-file ~/.config/grizzly/token
```

List all tags
```bash
grizzly tags --enable-callback --json --token-file ~/.config/grizzly/token
```

Search notes (via open-tag)
```bash
grizzly open-tag --name "work" --enable-callback --json
```

## Options

Common flags:
- `--dry-run` — Preview the URL without executing
- `--print-url` — Show the x-callback-url
- `--enable-callback` — Wait for Bear's response (needed for reading data)
- `--json` — Output as JSON (when using callbacks)
- `--token-file PATH` — Path to Bear API token file

## Configuration

Grizzly reads config from (in priority order):
1. CLI flags
2. Environment variables (`GRIZZLY_TOKEN_FILE`, `GRIZZLY_CALLBACK_URL`, `GRIZZLY_TIMEOUT`)
3. `.grizzly.toml` in current directory
4. `~/.config/grizzly/config.toml`

Example `~/.config/grizzly/config.toml`:
```toml
token_file = "~/.config/grizzly/token"
callback_url = "http://127.0.0.1:42123/success"
timeout = "5s"
```

## Notes

- Bear must be running for commands to work
- Note IDs are Bear's internal identifiers (visible in note info or via callbacks)
- Use `--enable-callback` when you need to read data back from Bear
- Some operations require a valid token (add-text, tags, open-note --selected)


--- skills/bird/SKILL.md ---
---
name: bird
description: X/Twitter CLI for reading, searching, and posting via cookies or Sweetistics.
homepage: https://bird.fast
metadata: {"clawdbot":{"emoji":"🐦","requires":{"bins":["bird"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/bird","bins":["bird"],"label":"Install bird (brew)"}]}}
---

# bird

Use `bird` to read/search X and post tweets/replies.

Quick start
- `bird whoami`
- `bird read <url-or-id>`
- `bird thread <url-or-id>`
- `bird search "query" -n 5`

Posting (confirm with user first)
- `bird tweet "text"`
- `bird reply <id-or-url> "text"`

Auth sources
- Browser cookies (default: Firefox/Chrome)
- Sweetistics API: set `SWEETISTICS_API_KEY` or use `--engine sweetistics`
- Check sources: `bird check`


--- skills/blogwatcher/SKILL.md ---
---
name: blogwatcher
description: Monitor blogs and RSS/Atom feeds for updates using the blogwatcher CLI.
homepage: https://github.com/Hyaxia/blogwatcher
metadata: {"clawdbot":{"emoji":"📰","requires":{"bins":["blogwatcher"]},"install":[{"id":"go","kind":"go","module":"github.com/Hyaxia/blogwatcher/cmd/blogwatcher@latest","bins":["blogwatcher"],"label":"Install blogwatcher (go)"}]}}
---

# blogwatcher

Track blog and RSS/Atom feed updates with the `blogwatcher` CLI.

Install
- Go: `go install github.com/Hyaxia/blogwatcher/cmd/blogwatcher@latest`

Quick start
- `blogwatcher --help`

Common commands
- Add a blog: `blogwatcher add "My Blog" https://example.com`
- List blogs: `blogwatcher blogs`
- Scan for updates: `blogwatcher scan`
- List articles: `blogwatcher articles`
- Mark an article read: `blogwatcher read 1`
- Mark all articles read: `blogwatcher read-all`
- Remove a blog: `blogwatcher remove "My Blog"`

Example output
```
$ blogwatcher blogs
Tracked blogs (1):

  xkcd
    URL: https://xkcd.com
```
```
$ blogwatcher scan
Scanning 1 blog(s)...

  xkcd
    Source: RSS | Found: 4 | New: 4

Found 4 new article(s) total!
```

Notes
- Use `blogwatcher <command> --help` to discover flags and options.


--- skills/blucli/SKILL.md ---
---
name: blucli
description: BluOS CLI (blu) for discovery, playback, grouping, and volume.
homepage: https://blucli.sh
metadata: {"clawdbot":{"emoji":"🫐","requires":{"bins":["blu"]},"install":[{"id":"go","kind":"go","module":"github.com/steipete/blucli/cmd/blu@latest","bins":["blu"],"label":"Install blucli (go)"}]}}
---

# blucli (blu)

Use `blu` to control Bluesound/NAD players.

Quick start
- `blu devices` (pick target)
- `blu --device <id> status`
- `blu play|pause|stop`
- `blu volume set 15`

Target selection (in priority order)
- `--device <id|name|alias>`
- `BLU_DEVICE`
- config default (if set)

Common tasks
- Grouping: `blu group status|add|remove`
- TuneIn search/play: `blu tunein search "query"`, `blu tunein play "query"`

Prefer `--json` for scripts. Confirm the target device before changing playback.


--- skills/brave-search/SKILL.md ---
---
name: brave-search
description: Web search and content extraction via Brave Search API.
homepage: https://brave.com/search/api
metadata: {"clawdbot":{"emoji":"🦁","requires":{"bins":["node"],"env":["BRAVE_API_KEY"]},"primaryEnv":"BRAVE_API_KEY"}}
---

# Brave Search

Headless web search (and lightweight content extraction) using Brave Search API. No browser required.

## Search

```bash
node {baseDir}/scripts/search.mjs "query"
node {baseDir}/scripts/search.mjs "query" -n 10
node {baseDir}/scripts/search.mjs "query" --content
node {baseDir}/scripts/search.mjs "query" -n 3 --content
```

## Extract a page

```bash
node {baseDir}/scripts/content.mjs "https://example.com/article"
```

Notes:
- Needs `BRAVE_API_KEY`.
- Content extraction is best-effort (good for articles; not for app-like sites).
- If a site is blocked or too JS-heavy, prefer the `summarize` skill (it can use a Firecrawl fallback).


--- skills/camsnap/SKILL.md ---
---
name: camsnap
description: Capture frames or clips from RTSP/ONVIF cameras.
homepage: https://camsnap.ai
metadata: {"clawdbot":{"emoji":"📸","requires":{"bins":["camsnap"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/camsnap","bins":["camsnap"],"label":"Install camsnap (brew)"}]}}
---

# camsnap

Use `camsnap` to grab snapshots, clips, or motion events from configured cameras.

Setup
- Config file: `~/.config/camsnap/config.yaml`
- Add camera: `camsnap add --name kitchen --host 192.168.0.10 --user user --pass pass`

Common commands
- Discover: `camsnap discover --info`
- Snapshot: `camsnap snap kitchen --out shot.jpg`
- Clip: `camsnap clip kitchen --dur 5s --out clip.mp4`
- Motion watch: `camsnap watch kitchen --threshold 0.2 --action '...'`
- Doctor: `camsnap doctor --probe`

Notes
- Requires `ffmpeg` on PATH.
- Prefer a short test capture before longer clips.


--- src/canvas-host/a2ui/index.html ---
<!doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Clawdbot Canvas</title>
    <script>
      (() => {
        try {
          const params = new URLSearchParams(window.location.search);
          const platform = (params.get('platform') || '').trim().toLowerCase();
          if (platform) {
            document.documentElement.dataset.platform = platform;
            return;
          }
          if (/android/i.test(navigator.userAgent || '')) {
            document.documentElement.dataset.platform = 'android';
          }
        } catch (_) {}
      })();
    </script>
    <style>
      :root { color-scheme: dark; }
      @media (prefers-reduced-motion: reduce) {
        body::before, body::after { animation: none !important; }
      }
      html, body { height: 100%; margin: 0; }
      body {
        font: 14px system-ui, -apple-system, BlinkMacSystemFont, "Roboto", sans-serif;
        background:
          radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.18), rgba(0,0,0,0) 55%),
          radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.14), rgba(0,0,0,0) 60%),
          radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.10), rgba(0,0,0,0) 60%),
          #000;
        color: #e5e7eb;
        overflow: hidden;
      }
      :root[data-platform="android"] body {
        background:
          radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.62), rgba(0,0,0,0) 55%),
          radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.52), rgba(0,0,0,0) 60%),
          radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.48), rgba(0,0,0,0) 60%),
          #0b1328;
      }
      body::before {
        content:"";
        position: fixed;
        inset: -20%;
        background:
          repeating-linear-gradient(0deg, rgba(255,255,255,0.03) 0, rgba(255,255,255,0.03) 1px,
                                 transparent 1px, transparent 48px),
          repeating-linear-gradient(90deg, rgba(255,255,255,0.03) 0, rgba(255,255,255,0.03) 1px,
                                 transparent 1px, transparent 48px);
        transform: translate3d(0,0,0) rotate(-7deg);
        will-change: transform, opacity;
        -webkit-backface-visibility: hidden;
        backface-visibility: hidden;
        opacity: 0.45;
        pointer-events: none;
        animation: clawdbot-grid-drift 140s ease-in-out infinite alternate;
      }
      :root[data-platform="android"] body::before { opacity: 0.80; }
      body::after {
        content:"";
        position: fixed;
        inset: -35%;
        background:
          radial-gradient(900px 700px at 30% 30%, rgba(42,113,255,0.16), rgba(0,0,0,0) 60%),
          radial-gradient(800px 650px at 70% 35%, rgba(255,0,138,0.12), rgba(0,0,0,0) 62%),
          radial-gradient(900px 800px at 55% 75%, rgba(0,209,255,0.10), rgba(0,0,0,0) 62%);
        filter: blur(28px);
        opacity: 0.52;
        will-change: transform, opacity;
        -webkit-backface-visibility: hidden;
        backface-visibility: hidden;
        transform: translate3d(0,0,0);
        pointer-events: none;
        animation: clawdbot-glow-drift 110s ease-in-out infinite alternate;
      }
      :root[data-platform="android"] body::after { opacity: 0.85; }
      @supports (mix-blend-mode: screen) {
        body::after { mix-blend-mode: screen; }
      }
      @supports not (mix-blend-mode: screen) {
        body::after { opacity: 0.70; }
      }
      @keyframes clawdbot-grid-drift {
        0%   { transform: translate3d(-12px, 8px, 0) rotate(-7deg); opacity: 0.40; }
        50%  { transform: translate3d( 10px,-7px, 0) rotate(-6.6deg); opacity: 0.56; }
        100% { transform: translate3d(-8px,  6px, 0) rotate(-7.2deg); opacity: 0.42; }
      }
      @keyframes clawdbot-glow-drift {
        0%   { transform: translate3d(-18px, 12px, 0) scale(1.02); opacity: 0.40; }
        50%  { transform: translate3d( 14px,-10px, 0) scale(1.05); opacity: 0.52; }
        100% { transform: translate3d(-10px,  8px, 0) scale(1.03); opacity: 0.43; }
      }
      canvas {
        position: fixed;
        inset: 0;
        display: block;
        width: 100vw;
        height: 100vh;
        touch-action: none;
        z-index: 1;
      }
      :root[data-platform="android"] #clawdbot-canvas {
        background:
          radial-gradient(1100px 800px at 20% 15%, rgba(42, 113, 255, 0.78), rgba(0,0,0,0) 58%),
          radial-gradient(900px 650px at 82% 28%, rgba(255, 0, 138, 0.66), rgba(0,0,0,0) 62%),
          radial-gradient(1000px 900px at 60% 88%, rgba(0, 209, 255, 0.58), rgba(0,0,0,0) 62%),
          #141c33;
      }
      #clawdbot-status {
        position: fixed;
        inset: 0;
        display: none;
        align-items: center;
        justify-content: center;
        flex-direction: column;
        padding: 24px;
        box-sizing: border-box;
        pointer-events: none;
        z-index: 3;
      }
      #clawdbot-status .card {
        width: min(560px, 88vw);
        text-align: left;
        padding: 14px 16px 12px;
        border-radius: 16px;
        background:
          linear-gradient(140deg, rgba(23, 24, 35, 0.78), rgba(18, 19, 28, 0.55));
        border: 1px solid rgba(255,255,255,0.12);
        box-shadow: 0 16px 46px rgba(0,0,0,0.52), inset 0 1px 0 rgba(255,255,255,0.06);
        -webkit-backdrop-filter: blur(18px) saturate(140%);
        backdrop-filter: blur(18px) saturate(140%);
      }
      #clawdbot-status .title {
        font: 600 12px/1.2 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
        letter-spacing: 0.45px;
        text-transform: uppercase;
        color: rgba(255,255,255,0.7);
      }
      #clawdbot-status .subtitle {
        margin-top: 8px;
        font: 500 13px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif;
        color: rgba(255,255,255,0.9);
        white-space: pre-wrap;
        overflow-wrap: anywhere;
      }
      clawdbot-a2ui-host {
        display: block;
        height: 100%;
        position: fixed;
        inset: 0;
        z-index: 4;
        --clawdbot-a2ui-inset-top: 28px;
        --clawdbot-a2ui-inset-right: 0px;
        --clawdbot-a2ui-inset-bottom: 0px;
        --clawdbot-a2ui-inset-left: 0px;
        --clawdbot-a2ui-scroll-pad-bottom: 0px;
        --clawdbot-a2ui-status-top: calc(50% - 18px);
        --clawdbot-a2ui-empty-top: 18px;
      }
    </style>
  </head>
  <body>
    <canvas id="clawdbot-canvas"></canvas>
    <div id="clawdbot-status">
      <div class="card">
        <div class="title" id="clawdbot-status-title">Ready</div>
        <div class="subtitle" id="clawdbot-status-subtitle">Waiting for agent</div>
      </div>
    </div>
    <clawdbot-a2ui-host></clawdbot-a2ui-host>
    <script src="a2ui.bundle.js"></script>
    <script>
      (() => {
        const canvas = document.getElementById('clawdbot-canvas');
        const ctx = canvas.getContext('2d');
        const statusEl = document.getElementById('clawdbot-status');
        const titleEl = document.getElementById('clawdbot-status-title');
        const subtitleEl = document.getElementById('clawdbot-status-subtitle');
        const debugStatusEnabledByQuery = (() => {
          try {
            const params = new URLSearchParams(window.location.search);
            const raw = params.get('debugStatus') ?? params.get('debug');
            if (!raw) return false;
            const normalized = String(raw).trim().toLowerCase();
            return normalized === '1' || normalized === 'true' || normalized === 'yes';
          } catch (_) {
            return false;
          }
        })();
        let debugStatusEnabled = debugStatusEnabledByQuery;

        function resize() {
          const dpr = window.devicePixelRatio || 1;
          const w = Math.max(1, Math.floor(window.innerWidth * dpr));
          const h = Math.max(1, Math.floor(window.innerHeight * dpr));
          canvas.width = w;
          canvas.height = h;
          ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        }

        window.addEventListener('resize', resize);
        resize();

        const setDebugStatusEnabled = (enabled) => {
          debugStatusEnabled = !!enabled;
          if (!statusEl) return;
          if (!debugStatusEnabled) {
            statusEl.style.display = 'none';
          }
        };

        if (statusEl && !debugStatusEnabled) {
          statusEl.style.display = 'none';
        }

        window.__clawdbot = {
          canvas,
          ctx,
          setDebugStatusEnabled,
          setStatus: (title, subtitle) => {
            if (!statusEl || !debugStatusEnabled) return;
            if (!title && !subtitle) {
              statusEl.style.display = 'none';
              return;
            }
            statusEl.style.display = 'flex';
            if (titleEl && typeof title === 'string') titleEl.textContent = title;
            if (subtitleEl && typeof subtitle === 'string') subtitleEl.textContent = subtitle;
            if (!debugStatusEnabled) {
              clearTimeout(window.__statusTimeout);
              window.__statusTimeout = setTimeout(() => {
                statusEl.style.display = 'none';
              }, 3000);
            } else {
              clearTimeout(window.__statusTimeout);
            }
          }
        };
      })();
    </script>
  </body>
</html>


--- src/entry.ts ---
#!/usr/bin/env node
import process from "node:process";

import { applyCliProfileEnv, parseCliProfileArgs } from "./cli/profile.js";

const parsed = parseCliProfileArgs(process.argv);
if (!parsed.ok) {
  // Keep it simple; Commander will handle rich help/errors after we strip flags.
  console.error(`[clawdbot] ${parsed.error}`);
  process.exit(2);
}

if (parsed.profile) {
  applyCliProfileEnv({ profile: parsed.profile });
  // Keep Commander and ad-hoc argv checks consistent.
  process.argv = parsed.argv;
}

const { runCli } = await import("./cli/run-main.js");
await runCli(process.argv);


--- src/globals.ts ---
import chalk from "chalk";
import { getLogger, isFileLogLevelEnabled } from "./logging.js";

let globalVerbose = false;
let globalYes = false;

export function setVerbose(v: boolean) {
  globalVerbose = v;
}

export function isVerbose() {
  return globalVerbose;
}

export function shouldLogVerbose() {
  return globalVerbose || isFileLogLevelEnabled("debug");
}

export function logVerbose(message: string) {
  if (!shouldLogVerbose()) return;
  try {
    getLogger().debug({ message }, "verbose");
  } catch {
    // ignore logger failures to avoid breaking verbose printing
  }
  if (!globalVerbose) return;
  console.log(chalk.gray(message));
}

export function logVerboseConsole(message: string) {
  if (!globalVerbose) return;
  console.log(chalk.gray(message));
}

export function setYes(v: boolean) {
  globalYes = v;
}

export function isYes() {
  return globalYes;
}

export const success = chalk.green;
export const warn = chalk.yellow;
export const info = chalk.cyan;
export const danger = chalk.red;


--- src/globals.test.ts ---
import { afterEach, describe, expect, it, vi } from "vitest";
import { isVerbose, isYes, logVerbose, setVerbose, setYes } from "./globals.js";

describe("globals", () => {
  afterEach(() => {
    setVerbose(false);
    setYes(false);
    vi.restoreAllMocks();
  });

  it("toggles verbose flag and logs when enabled", () => {
    const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
    setVerbose(false);
    logVerbose("hidden");
    expect(logSpy).not.toHaveBeenCalled();

    setVerbose(true);
    logVerbose("shown");
    expect(isVerbose()).toBe(true);
    expect(logSpy).toHaveBeenCalledWith(expect.stringContaining("shown"));
  });

  it("stores yes flag", () => {
    setYes(true);
    expect(isYes()).toBe(true);
    setYes(false);
    expect(isYes()).toBe(false);
  });
});


--- src/index.ts ---
#!/usr/bin/env node
import process from "node:process";
import { fileURLToPath } from "node:url";

import { getReplyFromConfig } from "./auto-reply/reply.js";
import { applyTemplate } from "./auto-reply/templating.js";
import { createDefaultDeps } from "./cli/deps.js";
import { promptYesNo } from "./cli/prompt.js";
import { waitForever } from "./cli/wait.js";
import { loadConfig } from "./config/config.js";
import {
  deriveSessionKey,
  loadSessionStore,
  resolveSessionKey,
  resolveStorePath,
  saveSessionStore,
} from "./config/sessions.js";
import { ensureBinary } from "./infra/binaries.js";
import { loadDotEnv } from "./infra/dotenv.js";
import { normalizeEnv } from "./infra/env.js";
import { isMainModule } from "./infra/is-main.js";
import { ensureClawdbotCliOnPath } from "./infra/path-env.js";
import {
  describePortOwner,
  ensurePortAvailable,
  handlePortError,
  PortInUseError,
} from "./infra/ports.js";
import { assertSupportedRuntime } from "./infra/runtime-guard.js";
import { installUnhandledRejectionHandler } from "./infra/unhandled-rejections.js";
import { enableConsoleCapture } from "./logging.js";
import { runCommandWithTimeout, runExec } from "./process/exec.js";
import { monitorWebProvider } from "./provider-web.js";
import { assertProvider, normalizeE164, toWhatsappJid } from "./utils.js";

loadDotEnv({ quiet: true });
normalizeEnv();
ensureClawdbotCliOnPath();

// Capture all console output into structured logs while keeping stdout/stderr behavior.
enableConsoleCapture();

// Enforce the minimum supported runtime before doing any work.
assertSupportedRuntime();

import { buildProgram } from "./cli/program.js";

const program = buildProgram();

export {
  assertProvider,
  applyTemplate,
  createDefaultDeps,
  deriveSessionKey,
  describePortOwner,
  ensureBinary,
  ensurePortAvailable,
  getReplyFromConfig,
  handlePortError,
  loadConfig,
  loadSessionStore,
  monitorWebProvider,
  normalizeE164,
  PortInUseError,
  promptYesNo,
  resolveSessionKey,
  resolveStorePath,
  runCommandWithTimeout,
  runExec,
  saveSessionStore,
  toWhatsappJid,
  waitForever,
};

const isMain = isMainModule({
  currentFile: fileURLToPath(import.meta.url),
});

if (isMain) {
  // Global error handlers to prevent silent crashes from unhandled rejections/exceptions.
  // These log the error and exit gracefully instead of crashing without trace.
  installUnhandledRejectionHandler();

  process.on("uncaughtException", (error) => {
    console.error(
      "[clawdbot] Uncaught exception:",
      error.stack ?? error.message,
    );
    process.exit(1);
  });

  void program.parseAsync(process.argv).catch((err) => {
    console.error(
      "[clawdbot] CLI failed:",
      err instanceof Error ? (err.stack ?? err.message) : err,
    );
    process.exit(1);
  });
}


--- src/index.test.ts ---
import { describe, expect, it } from "vitest";
import { assertProvider, normalizeE164, toWhatsappJid } from "./index.js";

describe("normalizeE164", () => {
  it("strips whatsapp prefix and whitespace", () => {
    expect(normalizeE164("whatsapp:+1 555 555 0123")).toBe("+15555550123");
  });

  it("adds plus when missing", () => {
    expect(normalizeE164("1555123")).toBe("+1555123");
  });
});

describe("toWhatsappJid", () => {
  it("converts E164 to jid", () => {
    expect(toWhatsappJid("+1 555 555 0123")).toBe("15555550123@s.whatsapp.net");
  });

  it("keeps group JIDs intact", () => {
    expect(toWhatsappJid("123456789-987654321@g.us")).toBe(
      "123456789-987654321@g.us",
    );
  });
});

describe("assertProvider", () => {
  it("accepts valid providers", () => {
    expect(() => assertProvider("web")).not.toThrow();
  });

  it("throws on invalid provider", () => {
    expect(() => assertProvider("invalid" as string)).toThrow();
  });
});


--- src/logger.ts ---
import { danger, info, logVerboseConsole, success, warn } from "./globals.js";
import { createSubsystemLogger, getLogger } from "./logging.js";
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";

const subsystemPrefixRe = /^([a-z][a-z0-9-]{1,20}):\s+(.*)$/i;

function splitSubsystem(message: string) {
  const match = message.match(subsystemPrefixRe);
  if (!match) return null;
  const [, subsystem, rest] = match;
  return { subsystem, rest };
}

export function logInfo(message: string, runtime: RuntimeEnv = defaultRuntime) {
  const parsed = runtime === defaultRuntime ? splitSubsystem(message) : null;
  if (parsed) {
    createSubsystemLogger(parsed.subsystem).info(parsed.rest);
    return;
  }
  runtime.log(info(message));
  getLogger().info(message);
}

export function logWarn(message: string, runtime: RuntimeEnv = defaultRuntime) {
  const parsed = runtime === defaultRuntime ? splitSubsystem(message) : null;
  if (parsed) {
    createSubsystemLogger(parsed.subsystem).warn(parsed.rest);
    return;
  }
  runtime.log(warn(message));
  getLogger().warn(message);
}

export function logSuccess(
  message: string,
  runtime: RuntimeEnv = defaultRuntime,
) {
  const parsed = runtime === defaultRuntime ? splitSubsystem(message) : null;
  if (parsed) {
    createSubsystemLogger(parsed.subsystem).info(parsed.rest);
    return;
  }
  runtime.log(success(message));
  getLogger().info(message);
}

export function logError(
  message: string,
  runtime: RuntimeEnv = defaultRuntime,
) {
  const parsed = runtime === defaultRuntime ? splitSubsystem(message) : null;
  if (parsed) {
    createSubsystemLogger(parsed.subsystem).error(parsed.rest);
    return;
  }
  runtime.error(danger(message));
  getLogger().error(message);
}

export function logDebug(message: string) {
  // Always emit to file logger (level-filtered); console only when verbose.
  getLogger().debug(message);
  logVerboseConsole(message);
}


--- src/logger.test.ts ---
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

import { afterEach, describe, expect, it, vi } from "vitest";

import { setVerbose } from "./globals.js";
import { logDebug, logError, logInfo, logSuccess, logWarn } from "./logger.js";
import { DEFAULT_LOG_DIR, resetLogger, setLoggerOverride } from "./logging.js";
import type { RuntimeEnv } from "./runtime.js";

describe("logger helpers", () => {
  afterEach(() => {
    resetLogger();
    setLoggerOverride(null);
    setVerbose(false);
  });

  it("formats messages through runtime log/error", () => {
    const log = vi.fn();
    const error = vi.fn();
    const runtime: RuntimeEnv = { log, error, exit: vi.fn() };

    logInfo("info", runtime);
    logWarn("warn", runtime);
    logSuccess("ok", runtime);
    logError("bad", runtime);

    expect(log).toHaveBeenCalledTimes(3);
    expect(error).toHaveBeenCalledTimes(1);
  });

  it("only logs debug when verbose is enabled", () => {
    const logVerbose = vi.spyOn(console, "log");
    setVerbose(false);
    logDebug("quiet");
    expect(logVerbose).not.toHaveBeenCalled();

    setVerbose(true);
    logVerbose.mockClear();
    logDebug("loud");
    expect(logVerbose).toHaveBeenCalled();
    logVerbose.mockRestore();
  });

  it("writes to configured log file at configured level", () => {
    const logPath = pathForTest();
    cleanup(logPath);
    setLoggerOverride({ level: "info", file: logPath });
    fs.writeFileSync(logPath, "");
    logInfo("hello");
    logDebug("debug-only"); // may be filtered depending on level mapping
    const content = fs.readFileSync(logPath, "utf-8");
    expect(content.length).toBeGreaterThan(0);
    cleanup(logPath);
  });

  it("filters messages below configured level", () => {
    const logPath = pathForTest();
    cleanup(logPath);
    setLoggerOverride({ level: "warn", file: logPath });
    logInfo("info-only");
    logWarn("warn-only");
    const content = fs.readFileSync(logPath, "utf-8");
    expect(content).toContain("warn-only");
    cleanup(logPath);
  });

  it("uses daily rolling default log file and prunes old ones", () => {
    resetLogger();
    setLoggerOverride({}); // force defaults regardless of user config
    const today = new Date().toISOString().slice(0, 10);
    const todayPath = path.join(DEFAULT_LOG_DIR, `clawdbot-${today}.log`);

    // create an old file to be pruned
    const oldPath = path.join(DEFAULT_LOG_DIR, "clawdbot-2000-01-01.log");
    fs.mkdirSync(DEFAULT_LOG_DIR, { recursive: true });
    fs.writeFileSync(oldPath, "old");
    fs.utimesSync(oldPath, new Date(0), new Date(0));
    cleanup(todayPath);

    logInfo("roll-me");

    expect(fs.existsSync(todayPath)).toBe(true);
    expect(fs.readFileSync(todayPath, "utf-8")).toContain("roll-me");
    expect(fs.existsSync(oldPath)).toBe(false);

    cleanup(todayPath);
  });
});

function pathForTest() {
  const file = path.join(
    os.tmpdir(),
    `clawdbot-log-${crypto.randomUUID()}.log`,
  );
  fs.mkdirSync(path.dirname(file), { recursive: true });
  return file;
}

function cleanup(file: string) {
  try {
    fs.rmSync(file, { force: true });
  } catch {
    // ignore
  }
}


--- src/logging.ts ---
import fs from "node:fs";
import path from "node:path";
import util from "node:util";

import { Chalk } from "chalk";
import { Logger as TsLogger } from "tslog";
import { type ClawdbotConfig, loadConfig } from "./config/config.js";
import { isVerbose } from "./globals.js";
import { defaultRuntime, type RuntimeEnv } from "./runtime.js";

// Pin to /tmp so mac Debug UI and docs match; os.tmpdir() can be a per-user
// randomized path on macOS which made the “Open log” button a no-op.
export const DEFAULT_LOG_DIR = "/tmp/clawdbot";
export const DEFAULT_LOG_FILE = path.join(DEFAULT_LOG_DIR, "clawdbot.log"); // legacy single-file path

const LOG_PREFIX = "clawdbot";
const LOG_SUFFIX = ".log";
const MAX_LOG_AGE_MS = 24 * 60 * 60 * 1000; // 24h

const ALLOWED_LEVELS = [
  "silent",
  "fatal",
  "error",
  "warn",
  "info",
  "debug",
  "trace",
] as const;

type Level = (typeof ALLOWED_LEVELS)[number];

export type LoggerSettings = {
  level?: Level;
  file?: string;
  consoleLevel?: Level;
  consoleStyle?: ConsoleStyle;
};

type LogObj = { date?: Date } & Record<string, unknown>;

type ResolvedSettings = {
  level: Level;
  file: string;
};
export type LoggerResolvedSettings = ResolvedSettings;

export type ConsoleStyle = "pretty" | "compact" | "json";
type ConsoleSettings = {
  level: Level;
  style: ConsoleStyle;
};
export type ConsoleLoggerSettings = ConsoleSettings;

let cachedLogger: TsLogger<LogObj> | null = null;
let cachedSettings: ResolvedSettings | null = null;
let cachedConsoleSettings: ConsoleSettings | null = null;
let overrideSettings: LoggerSettings | null = null;
let consolePatched = false;
let forceConsoleToStderr = false;
let rawConsole: {
  log: typeof console.log;
  info: typeof console.info;
  warn: typeof console.warn;
  error: typeof console.error;
} | null = null;

function normalizeLevel(level?: string): Level {
  const candidate = level ?? "info";
  return ALLOWED_LEVELS.includes(candidate as Level)
    ? (candidate as Level)
    : "info";
}

function resolveSettings(): ResolvedSettings {
  const cfg: ClawdbotConfig["logging"] | undefined =
    overrideSettings ?? loadConfig().logging;
  const level = normalizeLevel(cfg?.level);
  const file = cfg?.file ?? defaultRollingPathForToday();
  return { level, file };
}

function resolveConsoleSettings(): ConsoleSettings {
  const cfg: ClawdbotConfig["logging"] | undefined =
    overrideSettings ?? loadConfig().logging;
  const level = normalizeConsoleLevel(cfg?.consoleLevel);
  const style = normalizeConsoleStyle(cfg?.consoleStyle);
  return { level, style };
}

function settingsChanged(a: ResolvedSettings | null, b: ResolvedSettings) {
  if (!a) return true;
  return a.level !== b.level || a.file !== b.file;
}

function consoleSettingsChanged(a: ConsoleSettings | null, b: ConsoleSettings) {
  if (!a) return true;
  return a.level !== b.level || a.style !== b.style;
}

function levelToMinLevel(level: Level): number {
  // tslog level ordering: fatal=0, error=1, warn=2, info=3, debug=4, trace=5
  const map: Record<Level, number> = {
    fatal: 0,
    error: 1,
    warn: 2,
    info: 3,
    debug: 4,
    trace: 5,
    silent: Number.POSITIVE_INFINITY,
  };
  return map[level];
}

export function isFileLogLevelEnabled(level: LogLevel): boolean {
  const settings = cachedSettings ?? resolveSettings();
  if (!cachedSettings) cachedSettings = settings;
  return levelToMinLevel(level) <= levelToMinLevel(settings.level);
}

function normalizeConsoleLevel(level?: string): Level {
  if (isVerbose()) return "debug";
  const candidate = level ?? "info";
  return ALLOWED_LEVELS.includes(candidate as Level)
    ? (candidate as Level)
    : "info";
}

function normalizeConsoleStyle(style?: string): ConsoleStyle {
  if (style === "compact" || style === "json" || style === "pretty") {
    return style;
  }
  if (!process.stdout.isTTY) return "compact";
  return "pretty";
}

function buildLogger(settings: ResolvedSettings): TsLogger<LogObj> {
  fs.mkdirSync(path.dirname(settings.file), { recursive: true });
  // Clean up stale rolling logs when using a dated log filename.
  if (isRollingPath(settings.file)) {
    pruneOldRollingLogs(path.dirname(settings.file));
  }
  const logger = new TsLogger<LogObj>({
    name: "clawdbot",
    minLevel: levelToMinLevel(settings.level),
    type: "hidden", // no ansi formatting
  });

  logger.attachTransport((logObj: LogObj) => {
    try {
      const time = logObj.date?.toISOString?.() ?? new Date().toISOString();
      const line = JSON.stringify({ ...logObj, time });
      fs.appendFileSync(settings.file, `${line}\n`, { encoding: "utf8" });
    } catch {
      // never block on logging failures
    }
  });

  return logger;
}

export function getLogger(): TsLogger<LogObj> {
  const settings = resolveSettings();
  if (!cachedLogger || settingsChanged(cachedSettings, settings)) {
    cachedLogger = buildLogger(settings);
    cachedSettings = settings;
  }
  return cachedLogger;
}

export function getConsoleSettings(): ConsoleLoggerSettings {
  const settings = resolveConsoleSettings();
  if (
    !cachedConsoleSettings ||
    consoleSettingsChanged(cachedConsoleSettings, settings)
  ) {
    cachedConsoleSettings = settings;
  }
  return cachedConsoleSettings;
}

export function getChildLogger(
  bindings?: Record<string, unknown>,
  opts?: { level?: Level },
): TsLogger<LogObj> {
  const base = getLogger();
  const minLevel = opts?.level ? levelToMinLevel(opts.level) : undefined;
  const name = bindings ? JSON.stringify(bindings) : undefined;
  return base.getSubLogger({
    name,
    minLevel,
    prefix: bindings ? [name ?? ""] : [],
  });
}

export type LogLevel = Level;

// Baileys expects a pino-like logger shape. Provide a lightweight adapter.
export function toPinoLikeLogger(
  logger: TsLogger<LogObj>,
  level: Level,
): PinoLikeLogger {
  const buildChild = (bindings?: Record<string, unknown>) =>
    toPinoLikeLogger(
      logger.getSubLogger({
        name: bindings ? JSON.stringify(bindings) : undefined,
      }),
      level,
    );

  return {
    level,
    child: buildChild,
    trace: (...args: unknown[]) => logger.trace(...args),
    debug: (...args: unknown[]) => logger.debug(...args),
    info: (...args: unknown[]) => logger.info(...args),
    warn: (...args: unknown[]) => logger.warn(...args),
    error: (...args: unknown[]) => logger.error(...args),
    fatal: (...args: unknown[]) => logger.fatal(...args),
  };
}

export type PinoLikeLogger = {
  level: string;
  child: (bindings?: Record<string, unknown>) => PinoLikeLogger;
  trace: (...args: unknown[]) => void;
  debug: (...args: unknown[]) => void;
  info: (...args: unknown[]) => void;
  warn: (...args: unknown[]) => void;
  error: (...args: unknown[]) => void;
  fatal: (...args: unknown[]) => void;
};

export function getResolvedLoggerSettings(): LoggerResolvedSettings {
  return resolveSettings();
}

export function getResolvedConsoleSettings(): ConsoleLoggerSettings {
  return getConsoleSettings();
}

// Test helpers
export function setLoggerOverride(settings: LoggerSettings | null) {
  overrideSettings = settings;
  cachedLogger = null;
  cachedSettings = null;
}

export function resetLogger() {
  cachedLogger = null;
  cachedSettings = null;
  cachedConsoleSettings = null;
  overrideSettings = null;
}

// Route all console output (including tslog console writes) to stderr.
// This keeps stdout clean for RPC/JSON modes.
export function routeLogsToStderr(): void {
  forceConsoleToStderr = true;
}

const SUPPRESSED_CONSOLE_PREFIXES = [
  "Closing session:",
  "Opening session:",
  "Removing old closed session:",
  "Session already closed",
  "Session already open",
] as const;

function shouldSuppressConsoleMessage(message: string): boolean {
  if (isVerbose()) return false;
  return SUPPRESSED_CONSOLE_PREFIXES.some((prefix) =>
    message.startsWith(prefix),
  );
}

function isEpipeError(err: unknown): boolean {
  return Boolean((err as { code?: string })?.code === "EPIPE");
}

/**
 * Route console.* calls through pino while still emitting to stdout/stderr.
 * This keeps user-facing output unchanged but guarantees every console call is captured in log files.
 */
export function enableConsoleCapture(): void {
  if (consolePatched) return;
  consolePatched = true;

  const logger = getLogger();

  const original = {
    log: console.log,
    info: console.info,
    warn: console.warn,
    error: console.error,
    debug: console.debug,
    trace: console.trace,
  };
  rawConsole = {
    log: original.log,
    info: original.info,
    warn: original.warn,
    error: original.error,
  };

  const forward =
    (level: Level, orig: (...args: unknown[]) => void) =>
    (...args: unknown[]) => {
      const formatted = util.format(...args);
      if (shouldSuppressConsoleMessage(formatted)) return;
      try {
        // Map console levels to pino
        if (level === "trace") {
          logger.trace(formatted);
        } else if (level === "debug") {
          logger.debug(formatted);
        } else if (level === "info") {
          logger.info(formatted);
        } else if (level === "warn") {
          logger.warn(formatted);
        } else if (level === "error" || level === "fatal") {
          logger.error(formatted);
        } else {
          logger.info(formatted);
        }
      } catch {
        // never block console output on logging failures
      }
      if (forceConsoleToStderr) {
        const target =
          level === "error" || level === "fatal" || level === "warn"
            ? process.stderr
            : process.stderr; // in RPC/JSON mode, keep stdout clean
        try {
          target.write(`${formatted}\n`);
        } catch (err) {
          if (isEpipeError(err)) return;
          throw err;
        }
      } else {
        try {
          orig.apply(console, args as []);
        } catch (err) {
          if (isEpipeError(err)) return;
          throw err;
        }
      }
    };

  console.log = forward("info", original.log);
  console.info = forward("info", original.info);
  console.warn = forward("warn", original.warn);
  console.error = forward("error", original.error);
  console.debug = forward("debug", original.debug);
  console.trace = forward("trace", original.trace);
}

type SubsystemLogger = {
  subsystem: string;
  trace: (message: string, meta?: Record<string, unknown>) => void;
  debug: (message: string, meta?: Record<string, unknown>) => void;
  info: (message: string, meta?: Record<string, unknown>) => void;
  warn: (message: string, meta?: Record<string, unknown>) => void;
  error: (message: string, meta?: Record<string, unknown>) => void;
  fatal: (message: string, meta?: Record<string, unknown>) => void;
  raw: (message: string) => void;
  child: (name: string) => SubsystemLogger;
};

function shouldLogToConsole(level: Level, settings: ConsoleSettings): boolean {
  const current = levelToMinLevel(level);
  const min = levelToMinLevel(settings.level);
  return current <= min;
}

type ChalkInstance = InstanceType<typeof Chalk>;

function isRichConsoleEnv(): boolean {
  const term = (process.env.TERM ?? "").toLowerCase();
  if (process.env.COLORTERM || process.env.TERM_PROGRAM) return true;
  return term.length > 0 && term !== "dumb";
}

function getColorForConsole(): ChalkInstance {
  if (process.env.NO_COLOR) return new Chalk({ level: 0 });
  const hasTty = Boolean(process.stdout.isTTY || process.stderr.isTTY);
  return hasTty || isRichConsoleEnv()
    ? new Chalk({ level: 1 })
    : new Chalk({ level: 0 });
}

const SUBSYSTEM_COLORS = [
  "cyan",
  "green",
  "yellow",
  "blue",
  "magenta",
  "red",
] as const;
const SUBSYSTEM_COLOR_OVERRIDES: Record<
  string,
  (typeof SUBSYSTEM_COLORS)[number]
> = {
  "gmail-watcher": "blue",
};
const SUBSYSTEM_PREFIXES_TO_DROP = ["gateway", "providers"] as const;
const SUBSYSTEM_MAX_SEGMENTS = 2;

function pickSubsystemColor(
  color: ChalkInstance,
  subsystem: string,
): ChalkInstance {
  const override = SUBSYSTEM_COLOR_OVERRIDES[subsystem];
  if (override) return color[override];
  let hash = 0;
  for (let i = 0; i < subsystem.length; i += 1) {
    hash = (hash * 31 + subsystem.charCodeAt(i)) | 0;
  }
  const idx = Math.abs(hash) % SUBSYSTEM_COLORS.length;
  const name = SUBSYSTEM_COLORS[idx];
  return color[name];
}

function formatSubsystemForConsole(subsystem: string): string {
  const parts = subsystem.split("/").filter(Boolean);
  const original = parts.join("/") || subsystem;
  while (
    parts.length > 0 &&
    SUBSYSTEM_PREFIXES_TO_DROP.includes(
      parts[0] as (typeof SUBSYSTEM_PREFIXES_TO_DROP)[number],
    )
  ) {
    parts.shift();
  }
  if (parts.length === 0) return original;
  if (parts[0] === "whatsapp" || parts[0] === "telegram") {
    return parts[0];
  }
  if (parts.length > SUBSYSTEM_MAX_SEGMENTS) {
    return parts.slice(-SUBSYSTEM_MAX_SEGMENTS).join("/");
  }
  return parts.join("/");
}

function formatConsoleLine(opts: {
  level: Level;
  subsystem: string;
  message: string;
  style: ConsoleStyle;
  meta?: Record<string, unknown>;
}): string {
  const displaySubsystem =
    opts.style === "json"
      ? opts.subsystem
      : formatSubsystemForConsole(opts.subsystem);
  if (opts.style === "json") {
    return JSON.stringify({
      time: new Date().toISOString(),
      level: opts.level,
      subsystem: displaySubsystem,
      message: opts.message,
      ...opts.meta,
    });
  }
  const color = getColorForConsole();
  const prefix = `[${displaySubsystem}]`;
  const prefixColor = pickSubsystemColor(color, displaySubsystem);
  const levelColor =
    opts.level === "error" || opts.level === "fatal"
      ? color.red
      : opts.level === "warn"
        ? color.yellow
        : opts.level === "debug" || opts.level === "trace"
          ? color.gray
          : color.cyan;
  const time =
    opts.style === "pretty"
      ? color.gray(new Date().toISOString().slice(11, 19))
      : "";
  const prefixToken = prefixColor(prefix);
  const head = [time, prefixToken].filter(Boolean).join(" ");
  return `${head} ${levelColor(opts.message)}`;
}

function writeConsoleLine(level: Level, line: string) {
  const sink = rawConsole ?? console;
  if (forceConsoleToStderr || level === "error" || level === "fatal") {
    (sink.error ?? console.error)(line);
  } else if (level === "warn") {
    (sink.warn ?? console.warn)(line);
  } else {
    (sink.log ?? console.log)(line);
  }
}

function logToFile(
  fileLogger: TsLogger<LogObj>,
  level: Level,
  message: string,
  meta?: Record<string, unknown>,
) {
  if (level === "silent") return;
  const safeLevel = level as Exclude<Level, "silent">;
  const method = (fileLogger as unknown as Record<string, unknown>)[
    safeLevel
  ] as unknown as ((...args: unknown[]) => void) | undefined;
  if (typeof method !== "function") return;
  if (meta && Object.keys(meta).length > 0) {
    method.call(fileLogger, meta, message);
  } else {
    method.call(fileLogger, message);
  }
}

export function createSubsystemLogger(subsystem: string): SubsystemLogger {
  let fileLogger: TsLogger<LogObj> | null = null;
  const getFileLogger = () => {
    if (!fileLogger) fileLogger = getChildLogger({ subsystem });
    return fileLogger;
  };
  const emit = (
    level: Level,
    message: string,
    meta?: Record<string, unknown>,
  ) => {
    const consoleSettings = getConsoleSettings();
    let consoleMessageOverride: string | undefined;
    let fileMeta = meta;
    if (meta && Object.keys(meta).length > 0) {
      const { consoleMessage, ...rest } = meta as Record<string, unknown> & {
        consoleMessage?: unknown;
      };
      if (typeof consoleMessage === "string") {
        consoleMessageOverride = consoleMessage;
      }
      fileMeta = Object.keys(rest).length > 0 ? rest : undefined;
    }
    logToFile(getFileLogger(), level, message, fileMeta);
    if (!shouldLogToConsole(level, consoleSettings)) return;
    const line = formatConsoleLine({
      level,
      subsystem,
      message:
        consoleSettings.style === "json"
          ? message
          : (consoleMessageOverride ?? message),
      style: consoleSettings.style,
      meta: fileMeta,
    });
    writeConsoleLine(level, line);
  };

  const logger: SubsystemLogger = {
    subsystem,
    trace: (message, meta) => emit("trace", message, meta),
    debug: (message, meta) => emit("debug", message, meta),
    info: (message, meta) => emit("info", message, meta),
    warn: (message, meta) => emit("warn", message, meta),
    error: (message, meta) => emit("error", message, meta),
    fatal: (message, meta) => emit("fatal", message, meta),
    raw: (message) => {
      logToFile(getFileLogger(), "info", message, { raw: true });
      writeConsoleLine("info", message);
    },
    child: (name) => createSubsystemLogger(`${subsystem}/${name}`),
  };
  return logger;
}

export function runtimeForLogger(
  logger: SubsystemLogger,
  exit: RuntimeEnv["exit"] = defaultRuntime.exit,
): RuntimeEnv {
  return {
    log: (message: string) => logger.info(message),
    error: (message: string) => logger.error(message),
    exit,
  };
}

export function createSubsystemRuntime(
  subsystem: string,
  exit: RuntimeEnv["exit"] = defaultRuntime.exit,
): RuntimeEnv {
  return runtimeForLogger(createSubsystemLogger(subsystem), exit);
}

function defaultRollingPathForToday(): string {
  const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
  return path.join(DEFAULT_LOG_DIR, `${LOG_PREFIX}-${today}${LOG_SUFFIX}`);
}

function isRollingPath(file: string): boolean {
  const base = path.basename(file);
  return (
    base.startsWith(`${LOG_PREFIX}-`) &&
    base.endsWith(LOG_SUFFIX) &&
    base.length === `${LOG_PREFIX}-YYYY-MM-DD${LOG_SUFFIX}`.length
  );
}

function pruneOldRollingLogs(dir: string): void {
  try {
    const entries = fs.readdirSync(dir, { withFileTypes: true });
    const cutoff = Date.now() - MAX_LOG_AGE_MS;
    for (const entry of entries) {
      if (!entry.isFile()) continue;
      if (
        !entry.name.startsWith(`${LOG_PREFIX}-`) ||
        !entry.name.endsWith(LOG_SUFFIX)
      )
        continue;
      const fullPath = path.join(dir, entry.name);
      try {
        const stat = fs.statSync(fullPath);
        if (stat.mtimeMs < cutoff) {
          fs.rmSync(fullPath, { force: true });
        }
      } catch {
        // ignore errors during pruning
      }
    }
  } catch {
    // ignore missing dir or read errors
  }
}


--- src/polls.ts ---
export type PollInput = {
  question: string;
  options: string[];
  maxSelections?: number;
  durationHours?: number;
};

export type NormalizedPollInput = {
  question: string;
  options: string[];
  maxSelections: number;
  durationHours?: number;
};

type NormalizePollOptions = {
  maxOptions?: number;
};

export function normalizePollInput(
  input: PollInput,
  options: NormalizePollOptions = {},
): NormalizedPollInput {
  const question = input.question.trim();
  if (!question) {
    throw new Error("Poll question is required");
  }
  const pollOptions = (input.options ?? []).map((option) => option.trim());
  const cleaned = pollOptions.filter(Boolean);
  if (cleaned.length < 2) {
    throw new Error("Poll requires at least 2 options");
  }
  if (options.maxOptions !== undefined && cleaned.length > options.maxOptions) {
    throw new Error(`Poll supports at most ${options.maxOptions} options`);
  }
  const maxSelectionsRaw = input.maxSelections;
  const maxSelections =
    typeof maxSelectionsRaw === "number" && Number.isFinite(maxSelectionsRaw)
      ? Math.floor(maxSelectionsRaw)
      : 1;
  if (maxSelections < 1) {
    throw new Error("maxSelections must be at least 1");
  }
  if (maxSelections > cleaned.length) {
    throw new Error("maxSelections cannot exceed option count");
  }
  const durationRaw = input.durationHours;
  const durationHours =
    typeof durationRaw === "number" && Number.isFinite(durationRaw)
      ? Math.floor(durationRaw)
      : undefined;
  if (durationHours !== undefined && durationHours < 1) {
    throw new Error("durationHours must be at least 1");
  }
  return {
    question,
    options: cleaned,
    maxSelections,
    durationHours,
  };
}

export function normalizePollDurationHours(
  value: number | undefined,
  options: { defaultHours: number; maxHours: number },
): number {
  const base =
    typeof value === "number" && Number.isFinite(value)
      ? Math.floor(value)
      : options.defaultHours;
  return Math.min(Math.max(base, 1), options.maxHours);
}


--- test/auto-reply.retry.test.ts ---
import { describe, expect, it, vi } from "vitest";

vi.mock("../src/web/media.js", () => ({
  loadWebMedia: vi.fn(async () => ({
    buffer: Buffer.from("img"),
    contentType: "image/jpeg",
    kind: "image",
    fileName: "img.jpg",
  })),
}));

import { defaultRuntime } from "../src/runtime.js";
import { deliverWebReply } from "../src/web/auto-reply.js";
import type { WebInboundMessage } from "../src/web/inbound.js";

const noopLogger = {
  info: vi.fn(),
  warn: vi.fn(),
};

function makeMsg(): WebInboundMessage {
  const reply = vi.fn<
    Parameters<WebInboundMessage["reply"]>,
    ReturnType<WebInboundMessage["reply"]>
  >();
  const sendMedia = vi.fn<
    Parameters<WebInboundMessage["sendMedia"]>,
    ReturnType<WebInboundMessage["sendMedia"]>
  >();
  const sendComposing = vi.fn<
    Parameters<WebInboundMessage["sendComposing"]>,
    ReturnType<WebInboundMessage["sendComposing"]>
  >();
  return {
    from: "+10000000000",
    conversationId: "+10000000000",
    to: "+20000000000",
    id: "abc",
    body: "hello",
    chatType: "direct",
    chatId: "chat-1",
    sendComposing,
    reply,
    sendMedia,
  };
}

describe("deliverWebReply retry", () => {
  it("retries text send on transient failure", async () => {
    const msg = makeMsg();
    msg.reply.mockRejectedValueOnce(new Error("connection closed"));
    msg.reply.mockResolvedValueOnce(undefined);

    await expect(
      deliverWebReply({
        replyResult: { text: "hi" },
        msg,
        maxMediaBytes: 5_000_000,
        replyLogger: noopLogger,
        runtime: defaultRuntime,
        skipLog: true,
      }),
    ).resolves.toBeUndefined();

    expect(msg.reply).toHaveBeenCalledTimes(2);
  });

  it("retries media send on transient failure", async () => {
    const msg = makeMsg();
    msg.sendMedia.mockRejectedValueOnce(new Error("socket reset"));
    msg.sendMedia.mockResolvedValueOnce(undefined);

    await expect(
      deliverWebReply({
        replyResult: {
          text: "caption",
          mediaUrl: "http://example.com/img.jpg",
        },
        msg,
        maxMediaBytes: 5_000_000,
        replyLogger: noopLogger,
        runtime: defaultRuntime,
        skipLog: true,
      }),
    ).resolves.toBeUndefined();

    expect(msg.sendMedia).toHaveBeenCalledTimes(2);
  });
});


--- test/gateway.multi.e2e.test.ts ---
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import { request as httpRequest } from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { afterAll, describe, expect, it } from "vitest";
import {
  approveNodePairing,
  listNodePairing,
} from "../src/infra/node-pairing.js";

type GatewayInstance = {
  name: string;
  port: number;
  bridgePort: number;
  hookToken: string;
  homeDir: string;
  configPath: string;
  child: ChildProcessWithoutNullStreams;
  stdout: string[];
  stderr: string[];
};

type NodeListPayload = {
  nodes?: Array<{ nodeId?: string; connected?: boolean; paired?: boolean }>;
};

type HealthPayload = { ok?: boolean };

type PairingList = {
  pending: Array<{ requestId: string; nodeId: string }>;
};

const GATEWAY_START_TIMEOUT_MS = 45_000;
const E2E_TIMEOUT_MS = 120_000;

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const getFreePort = async () => {
  const srv = net.createServer();
  await new Promise<void>((resolve) => srv.listen(0, "127.0.0.1", resolve));
  const addr = srv.address();
  if (!addr || typeof addr === "string") {
    srv.close();
    throw new Error("failed to bind ephemeral port");
  }
  await new Promise<void>((resolve) => srv.close(() => resolve()));
  return addr.port;
};

const waitForPortOpen = async (
  proc: ChildProcessWithoutNullStreams,
  chunksOut: string[],
  chunksErr: string[],
  port: number,
  timeoutMs: number,
) => {
  const startedAt = Date.now();
  while (Date.now() - startedAt < timeoutMs) {
    if (proc.exitCode !== null) {
      const stdout = chunksOut.join("");
      const stderr = chunksErr.join("");
      throw new Error(
        `gateway exited before listening (code=${String(proc.exitCode)} signal=${String(proc.signalCode)})\n` +
          `--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
      );
    }

    try {
      await new Promise<void>((resolve, reject) => {
        const socket = net.connect({ host: "127.0.0.1", port });
        socket.once("connect", () => {
          socket.destroy();
          resolve();
        });
        socket.once("error", (err) => {
          socket.destroy();
          reject(err);
        });
      });
      return;
    } catch {
      // keep polling
    }

    await sleep(25);
  }
  const stdout = chunksOut.join("");
  const stderr = chunksErr.join("");
  throw new Error(
    `timeout waiting for gateway to listen on port ${port}\n` +
      `--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
  );
};

const spawnGatewayInstance = async (name: string): Promise<GatewayInstance> => {
  const port = await getFreePort();
  const bridgePort = await getFreePort();
  const hookToken = `token-${name}-${randomUUID()}`;
  const homeDir = await fs.mkdtemp(
    path.join(os.tmpdir(), `clawdbot-e2e-${name}-`),
  );
  const configDir = path.join(homeDir, ".clawdbot");
  await fs.mkdir(configDir, { recursive: true });
  const configPath = path.join(configDir, "clawdbot.json");
  const config = {
    gateway: { port },
    hooks: { enabled: true, token: hookToken, path: "/hooks" },
    bridge: { bind: "loopback", port: bridgePort },
  };
  await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8");

  const stdout: string[] = [];
  const stderr: string[] = [];
  let child: ChildProcessWithoutNullStreams | null = null;

  try {
    child = spawn(
      "bun",
      [
        "src/index.ts",
        "gateway",
        "--port",
        String(port),
        "--bind",
        "loopback",
        "--allow-unconfigured",
      ],
      {
        cwd: process.cwd(),
        env: {
          ...process.env,
          HOME: homeDir,
          CLAWDBOT_CONFIG_PATH: configPath,
          CLAWDBOT_STATE_DIR: path.join(homeDir, ".clawdbot", "state"),
          CLAWDBOT_GATEWAY_TOKEN: "",
          CLAWDBOT_GATEWAY_PASSWORD: "",
          CLAWDBOT_SKIP_PROVIDERS: "1",
          CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1",
          CLAWDBOT_SKIP_CANVAS_HOST: "1",
          CLAWDBOT_ENABLE_BRIDGE_IN_TESTS: "1",
          CLAWDBOT_BRIDGE_HOST: "127.0.0.1",
          CLAWDBOT_BRIDGE_PORT: String(bridgePort),
        },
        stdio: ["ignore", "pipe", "pipe"],
      },
    );

    child.stdout?.setEncoding("utf8");
    child.stderr?.setEncoding("utf8");
    child.stdout?.on("data", (d) => stdout.push(String(d)));
    child.stderr?.on("data", (d) => stderr.push(String(d)));

    await waitForPortOpen(
      child,
      stdout,
      stderr,
      port,
      GATEWAY_START_TIMEOUT_MS,
    );

    return {
      name,
      port,
      bridgePort,
      hookToken,
      homeDir,
      configPath,
      child,
      stdout,
      stderr,
    };
  } catch (err) {
    if (child && child.exitCode === null && !child.killed) {
      try {
        child.kill("SIGKILL");
      } catch {
        // ignore
      }
    }
    await fs.rm(homeDir, { recursive: true, force: true });
    throw err;
  }
};

const stopGatewayInstance = async (inst: GatewayInstance) => {
  if (inst.child.exitCode === null && !inst.child.killed) {
    try {
      inst.child.kill("SIGTERM");
    } catch {
      // ignore
    }
  }
  const exited = await Promise.race([
    new Promise<boolean>((resolve) => {
      if (inst.child.exitCode !== null) return resolve(true);
      inst.child.once("exit", () => resolve(true));
    }),
    sleep(5_000).then(() => false),
  ]);
  if (!exited && inst.child.exitCode === null && !inst.child.killed) {
    try {
      inst.child.kill("SIGKILL");
    } catch {
      // ignore
    }
  }
  await fs.rm(inst.homeDir, { recursive: true, force: true });
};

const runCliJson = async (
  args: string[],
  env: NodeJS.ProcessEnv,
): Promise<unknown> => {
  const stdout: string[] = [];
  const stderr: string[] = [];
  const child = spawn("bun", ["src/index.ts", ...args], {
    cwd: process.cwd(),
    env: { ...process.env, ...env },
    stdio: ["ignore", "pipe", "pipe"],
  });
  child.stdout?.setEncoding("utf8");
  child.stderr?.setEncoding("utf8");
  child.stdout?.on("data", (d) => stdout.push(String(d)));
  child.stderr?.on("data", (d) => stderr.push(String(d)));
  const result = await new Promise<{
    code: number | null;
    signal: string | null;
  }>((resolve) =>
    child.once("exit", (code, signal) => resolve({ code, signal })),
  );
  const out = stdout.join("").trim();
  if (result.code !== 0) {
    throw new Error(
      `cli failed (code=${String(result.code)} signal=${String(result.signal)})\n` +
        `--- stdout ---\n${out}\n--- stderr ---\n${stderr.join("")}`,
    );
  }
  try {
    return out ? (JSON.parse(out) as unknown) : null;
  } catch (err) {
    throw new Error(
      `cli returned non-json output: ${String(err)}\n` +
        `--- stdout ---\n${out}\n--- stderr ---\n${stderr.join("")}`,
    );
  }
};

const postJson = async (url: string, body: unknown) => {
  const payload = JSON.stringify(body);
  const parsed = new URL(url);
  return await new Promise<{ status: number; json: unknown }>(
    (resolve, reject) => {
      const req = httpRequest(
        {
          method: "POST",
          hostname: parsed.hostname,
          port: Number(parsed.port),
          path: `${parsed.pathname}${parsed.search}`,
          headers: {
            "Content-Type": "application/json",
            "Content-Length": Buffer.byteLength(payload),
          },
        },
        (res) => {
          let data = "";
          res.setEncoding("utf8");
          res.on("data", (chunk) => {
            data += chunk;
          });
          res.on("end", () => {
            let json: unknown = null;
            if (data.trim()) {
              try {
                json = JSON.parse(data);
              } catch {
                json = data;
              }
            }
            resolve({ status: res.statusCode ?? 0, json });
          });
        },
      );
      req.on("error", reject);
      req.write(payload);
      req.end();
    },
  );
};

const createLineReader = (socket: net.Socket) => {
  let buffer = "";
  const pending: Array<(line: string) => void> = [];

  const flush = () => {
    while (pending.length > 0) {
      const idx = buffer.indexOf("\n");
      if (idx === -1) return;
      const line = buffer.slice(0, idx);
      buffer = buffer.slice(idx + 1);
      const resolve = pending.shift();
      resolve?.(line);
    }
  };

  socket.on("data", (chunk) => {
    buffer += chunk.toString("utf8");
    flush();
  });

  const readLine = async () => {
    flush();
    const idx = buffer.indexOf("\n");
    if (idx !== -1) {
      const line = buffer.slice(0, idx);
      buffer = buffer.slice(idx + 1);
      return line;
    }
    return await new Promise<string>((resolve) => pending.push(resolve));
  };

  return readLine;
};

const sendLine = (socket: net.Socket, obj: unknown) => {
  socket.write(`${JSON.stringify(obj)}\n`);
};

const readLineWithTimeout = async (
  readLine: () => Promise<string>,
  label: string,
  timeoutMs = 10_000,
) => {
  const timer = sleep(timeoutMs).then(() => {
    throw new Error(`timeout waiting for ${label}`);
  });
  return await Promise.race([readLine(), timer]);
};

const waitForPairRequest = async (
  baseDir: string,
  nodeId: string,
  timeoutMs = 10_000,
) => {
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    const list = (await listNodePairing(baseDir)) as PairingList;
    const match = list.pending.find((p) => p.nodeId === nodeId);
    if (match?.requestId) return match.requestId;
    await sleep(50);
  }
  throw new Error(`timeout waiting for pairing request for ${nodeId}`);
};

const pairNode = async (inst: GatewayInstance, nodeId: string) => {
  const socket = net.connect({ host: "127.0.0.1", port: inst.bridgePort });
  await new Promise<void>((resolve, reject) => {
    socket.once("connect", resolve);
    socket.once("error", reject);
  });

  const readLine = createLineReader(socket);
  sendLine(socket, {
    type: "pair-request",
    nodeId,
    platform: "ios",
    version: "1.0.0",
  });

  const baseDir = path.join(inst.homeDir, ".clawdbot");
  const requestId = await waitForPairRequest(baseDir, nodeId);
  const approved = await approveNodePairing(requestId, baseDir);
  expect(approved).toBeTruthy();

  const pairLine = JSON.parse(
    await readLineWithTimeout(readLine, `pair-ok (${nodeId})`),
  ) as { type?: string; token?: string };
  expect(pairLine.type).toBe("pair-ok");
  expect(pairLine.token).toBeTruthy();

  const helloLine = JSON.parse(
    await readLineWithTimeout(readLine, `hello-ok (${nodeId})`),
  ) as { type?: string };
  expect(helloLine.type).toBe("hello-ok");

  return socket;
};

describe("gateway multi-instance e2e", () => {
  const instances: GatewayInstance[] = [];

  afterAll(async () => {
    for (const inst of instances) {
      await stopGatewayInstance(inst);
    }
  });

  it(
    "spins up two gateways and exercises WS + HTTP + node pairing",
    { timeout: E2E_TIMEOUT_MS },
    async () => {
      const gwA = await spawnGatewayInstance("a");
      instances.push(gwA);
      const gwB = await spawnGatewayInstance("b");
      instances.push(gwB);

      const [healthA, healthB] = (await Promise.all([
        runCliJson(["health", "--json", "--timeout", "10000"], {
          CLAWDBOT_GATEWAY_PORT: String(gwA.port),
          CLAWDBOT_GATEWAY_TOKEN: "",
          CLAWDBOT_GATEWAY_PASSWORD: "",
        }),
        runCliJson(["health", "--json", "--timeout", "10000"], {
          CLAWDBOT_GATEWAY_PORT: String(gwB.port),
          CLAWDBOT_GATEWAY_TOKEN: "",
          CLAWDBOT_GATEWAY_PASSWORD: "",
        }),
      ])) as [HealthPayload, HealthPayload];
      expect(healthA.ok).toBe(true);
      expect(healthB.ok).toBe(true);

      const [hookResA, hookResB] = await Promise.all([
        postJson(
          `http://127.0.0.1:${gwA.port}/hooks/wake?token=${gwA.hookToken}`,
          { text: "wake a", mode: "now" },
        ),
        postJson(
          `http://127.0.0.1:${gwB.port}/hooks/wake?token=${gwB.hookToken}`,
          { text: "wake b", mode: "now" },
        ),
      ]);
      expect(hookResA.status).toBe(200);
      expect((hookResA.json as { ok?: boolean } | undefined)?.ok).toBe(true);
      expect(hookResB.status).toBe(200);
      expect((hookResB.json as { ok?: boolean } | undefined)?.ok).toBe(true);

      const nodeASocket = await pairNode(gwA, "node-a");
      const nodeBSocket = await pairNode(gwB, "node-b");

      const [nodeListA, nodeListB] = (await Promise.all([
        runCliJson(
          ["nodes", "status", "--json", "--url", `ws://127.0.0.1:${gwA.port}`],
          {
            CLAWDBOT_GATEWAY_TOKEN: "",
            CLAWDBOT_GATEWAY_PASSWORD: "",
          },
        ),
        runCliJson(
          ["nodes", "status", "--json", "--url", `ws://127.0.0.1:${gwB.port}`],
          {
            CLAWDBOT_GATEWAY_TOKEN: "",
            CLAWDBOT_GATEWAY_PASSWORD: "",
          },
        ),
      ])) as [NodeListPayload, NodeListPayload];
      expect(
        nodeListA.nodes?.some(
          (n) =>
            n.nodeId === "node-a" && n.connected === true && n.paired === true,
        ),
      ).toBe(true);
      expect(
        nodeListB.nodes?.some(
          (n) =>
            n.nodeId === "node-b" && n.connected === true && n.paired === true,
        ),
      ).toBe(true);

      nodeASocket.destroy();
      nodeBSocket.destroy();
    },
  );
});


--- test/setup.ts ---
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
const originalXdgDataHome = process.env.XDG_DATA_HOME;
const originalXdgStateHome = process.env.XDG_STATE_HOME;
const originalXdgCacheHome = process.env.XDG_CACHE_HOME;
const originalTestHome = process.env.CLAWDBOT_TEST_HOME;

const tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-test-home-"));
process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome;
process.env.CLAWDBOT_TEST_HOME = tempHome;
process.env.XDG_CONFIG_HOME = path.join(tempHome, ".config");
process.env.XDG_DATA_HOME = path.join(tempHome, ".local", "share");
process.env.XDG_STATE_HOME = path.join(tempHome, ".local", "state");
process.env.XDG_CACHE_HOME = path.join(tempHome, ".cache");

const restoreEnv = (key: string, value: string | undefined) => {
  if (value === undefined) delete process.env[key];
  else process.env[key] = value;
};

process.on("exit", () => {
  restoreEnv("HOME", originalHome);
  restoreEnv("USERPROFILE", originalUserProfile);
  restoreEnv("XDG_CONFIG_HOME", originalXdgConfigHome);
  restoreEnv("XDG_DATA_HOME", originalXdgDataHome);
  restoreEnv("XDG_STATE_HOME", originalXdgStateHome);
  restoreEnv("XDG_CACHE_HOME", originalXdgCacheHome);
  restoreEnv("CLAWDBOT_TEST_HOME", originalTestHome);
  try {
    fs.rmSync(tempHome, { recursive: true, force: true });
  } catch {
    // ignore cleanup errors
  }
});


--- test/mocks/baileys.ts ---
import { EventEmitter } from "node:events";

import { vi } from "vitest";

export type MockBaileysSocket = {
  ev: EventEmitter;
  ws: { close: ReturnType<typeof vi.fn> };
  sendPresenceUpdate: ReturnType<typeof vi.fn>;
  sendMessage: ReturnType<typeof vi.fn>;
  readMessages: ReturnType<typeof vi.fn>;
  user?: { id?: string };
};

export type MockBaileysModule = {
  DisconnectReason: { loggedOut: number };
  fetchLatestBaileysVersion: ReturnType<typeof vi.fn>;
  makeCacheableSignalKeyStore: ReturnType<typeof vi.fn>;
  makeWASocket: ReturnType<typeof vi.fn>;
  useMultiFileAuthState: ReturnType<typeof vi.fn>;
  jidToE164?: (jid: string) => string | null;
  proto?: unknown;
  downloadMediaMessage?: ReturnType<typeof vi.fn>;
};

export function createMockBaileys(): {
  mod: MockBaileysModule;
  lastSocket: () => MockBaileysSocket;
} {
  const sockets: MockBaileysSocket[] = [];
  const makeWASocket = vi.fn((_opts: unknown) => {
    const ev = new EventEmitter();
    const sock: MockBaileysSocket = {
      ev,
      ws: { close: vi.fn() },
      sendPresenceUpdate: vi.fn().mockResolvedValue(undefined),
      sendMessage: vi.fn().mockResolvedValue({ key: { id: "msg123" } }),
      readMessages: vi.fn().mockResolvedValue(undefined),
      user: { id: "123@s.whatsapp.net" },
    };
    setImmediate(() => ev.emit("connection.update", { connection: "open" }));
    sockets.push(sock);
    return sock;
  });

  const mod: MockBaileysModule = {
    DisconnectReason: { loggedOut: 401 },
    fetchLatestBaileysVersion: vi
      .fn()
      .mockResolvedValue({ version: [1, 2, 3] }),
    makeCacheableSignalKeyStore: vi.fn((keys: unknown) => keys),
    makeWASocket,
    useMultiFileAuthState: vi.fn(async () => ({
      state: { creds: {}, keys: {} },
      saveCreds: vi.fn(),
    })),
    jidToE164: (jid: string) => jid.replace(/@.*$/, "").replace(/^/, "+"),
    downloadMediaMessage: vi.fn().mockResolvedValue(Buffer.from("img")),
  };

  return {
    mod,
    lastSocket: () => {
      const last = sockets.at(-1);
      if (!last) {
        throw new Error("No Baileys sockets created");
      }
      return last;
    },
  };
}


--- ui/index.html ---
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Clawdbot Control</title>
    <meta name="color-scheme" content="dark light" />
    <link rel="icon" href="/favicon.ico" sizes="any" />
  </head>
  <body>
    <clawdbot-app></clawdbot-app>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>



--- ui/vite.config.ts ---
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vite";

const here = path.dirname(fileURLToPath(import.meta.url));

function normalizeBase(input: string): string {
  const trimmed = input.trim();
  if (!trimmed) return "/";
  if (trimmed === "./") return "./";
  if (trimmed.endsWith("/")) return trimmed;
  return `${trimmed}/`;
}

export default defineConfig(({ command }) => {
  const envBase = process.env.CLAWDBOT_CONTROL_UI_BASE_PATH?.trim();
  const base = envBase ? normalizeBase(envBase) : "/";
  return {
    base,
    publicDir: path.resolve(here, "public"),
    optimizeDeps: {
      include: ["lit/directives/repeat.js"],
    },
    build: {
      outDir: path.resolve(here, "../dist/control-ui"),
      emptyOutDir: true,
      sourcemap: true,
    },
    server: {
      host: true,
      port: 5173,
      strictPort: true,
    },
  };
});


--- ui/vitest.config.ts ---
import { playwright } from "@vitest/browser-playwright";
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    include: ["src/**/*.test.ts"],
    browser: {
      enabled: true,
      provider: playwright(),
      instances: [{ browser: "chromium", name: "chromium" }],
      headless: true,
      ui: false,
    },
  },
});


--- ui/src/main.ts ---
import "./styles.css";
import "./ui/app.ts";



--- ui/src/ui/app.ts ---
import { LitElement, html, nothing } from "lit";
import { customElement, state } from "lit/decorators.js";

import { GatewayBrowserClient, type GatewayEventFrame, type GatewayHelloOk } from "./gateway";
import { loadSettings, saveSettings, type UiSettings } from "./storage";
import { renderApp } from "./app-render";
import {
  inferBasePathFromPathname,
  normalizeBasePath,
  normalizePath,
  pathForTab,
  tabFromPath,
  type Tab,
} from "./navigation";
import {
  resolveTheme,
  type ResolvedTheme,
  type ThemeMode,
} from "./theme";
import {
  startThemeTransition,
  type ThemeTransitionContext,
} from "./theme-transition";
import type {
  ConfigSnapshot,
  ConfigUiHints,
  CronJob,
  CronRunLogEntry,
  CronStatus,
  HealthSnapshot,
  PresenceEntry,
  ProvidersStatusSnapshot,
  SessionsListResult,
  SkillStatusReport,
  StatusSummary,
} from "./types";
import {
  defaultDiscordActions,
  defaultSlackActions,
  type CronFormState,
  type DiscordForm,
  type IMessageForm,
  type SlackForm,
  type SignalForm,
  type TelegramForm,
} from "./ui-types";
import {
  loadChatHistory,
  sendChat,
  handleChatEvent,
  type ChatEventPayload,
} from "./controllers/chat";
import { loadNodes } from "./controllers/nodes";
import {
  loadConfig,
  loadConfigSchema,
  updateConfigFormValue,
} from "./controllers/config";
import {
  loadProviders,
  logoutWhatsApp,
  saveDiscordConfig,
  saveIMessageConfig,
  saveSlackConfig,
  saveSignalConfig,
  saveTelegramConfig,
  startWhatsAppLogin,
  waitWhatsAppLogin,
} from "./controllers/connections";
import { loadPresence } from "./controllers/presence";
import { loadSessions } from "./controllers/sessions";
import {
  loadCronJobs,
  loadCronStatus,
} from "./controllers/cron";
import {
  loadSkills,
} from "./controllers/skills";
import { loadDebug } from "./controllers/debug";

type EventLogEntry = {
  ts: number;
  event: string;
  payload?: unknown;
};

const TOOL_STREAM_LIMIT = 50;

type AgentEventPayload = {
  runId: string;
  seq: number;
  stream: string;
  ts: number;
  sessionKey?: string;
  data: Record<string, unknown>;
};

type ToolStreamEntry = {
  toolCallId: string;
  runId: string;
  sessionKey?: string;
  name: string;
  args?: unknown;
  output?: string;
  startedAt: number;
  updatedAt: number;
  message: Record<string, unknown>;
};

function extractToolOutputText(value: unknown): string | null {
  if (!value || typeof value !== "object") return null;
  const record = value as Record<string, unknown>;
  if (typeof record.text === "string") return record.text;
  const content = record.content;
  if (!Array.isArray(content)) return null;
  const parts = content
    .map((item) => {
      if (!item || typeof item !== "object") return null;
      const entry = item as Record<string, unknown>;
      if (entry.type === "text" && typeof entry.text === "string") return entry.text;
      return null;
    })
    .filter((part): part is string => Boolean(part));
  if (parts.length === 0) return null;
  return parts.join("\n");
}

function formatToolOutput(value: unknown): string | null {
  if (value === null || value === undefined) return null;
  if (typeof value === "string") return value;
  if (typeof value === "number" || typeof value === "boolean") {
    return String(value);
  }
  const contentText = extractToolOutputText(value);
  if (contentText) return contentText;
  try {
    return JSON.stringify(value, null, 2);
  } catch {
    return String(value);
  }
}

declare global {
  interface Window {
    __CLAWDBOT_CONTROL_UI_BASE_PATH__?: string;
  }
}

const DEFAULT_CRON_FORM: CronFormState = {
  name: "",
  description: "",
  enabled: true,
  scheduleKind: "every",
  scheduleAt: "",
  everyAmount: "30",
  everyUnit: "minutes",
  cronExpr: "0 7 * * *",
  cronTz: "",
  sessionTarget: "main",
  wakeMode: "next-heartbeat",
  payloadKind: "systemEvent",
  payloadText: "",
  deliver: false,
  provider: "last",
  to: "",
  timeoutSeconds: "",
  postToMainPrefix: "",
};

@customElement("clawdbot-app")
export class ClawdbotApp extends LitElement {
  @state() settings: UiSettings = loadSettings();
  @state() password = "";
  @state() tab: Tab = "chat";
  @state() connected = false;
  @state() theme: ThemeMode = this.settings.theme ?? "system";
  @state() themeResolved: ResolvedTheme = "dark";
  @state() hello: GatewayHelloOk | null = null;
  @state() lastError: string | null = null;
  @state() eventLog: EventLogEntry[] = [];
  private eventLogBuffer: EventLogEntry[] = [];

  @state() sessionKey = this.settings.sessionKey;
  @state() chatLoading = false;
  @state() chatSending = false;
  @state() chatMessage = "";
  @state() chatMessages: unknown[] = [];
  @state() chatToolMessages: unknown[] = [];
  @state() chatStream: string | null = null;
  @state() chatStreamStartedAt: number | null = null;
  @state() chatRunId: string | null = null;
  @state() chatThinkingLevel: string | null = null;

  @state() nodesLoading = false;
  @state() nodes: Array<Record<string, unknown>> = [];

  @state() configLoading = false;
  @state() configRaw = "{\n}\n";
  @state() configValid: boolean | null = null;
  @state() configIssues: unknown[] = [];
  @state() configSaving = false;
  @state() configApplying = false;
  @state() updateRunning = false;
  @state() applySessionKey = this.settings.lastActiveSessionKey;
  @state() configSnapshot: ConfigSnapshot | null = null;
  @state() configSchema: unknown | null = null;
  @state() configSchemaVersion: string | null = null;
  @state() configSchemaLoading = false;
  @state() configUiHints: ConfigUiHints = {};
  @state() configForm: Record<string, unknown> | null = null;
  @state() configFormDirty = false;
  @state() configFormMode: "form" | "raw" = "form";

  @state() providersLoading = false;
  @state() providersSnapshot: ProvidersStatusSnapshot | null = null;
  @state() providersError: string | null = null;
  @state() providersLastSuccess: number | null = null;
  @state() whatsappLoginMessage: string | null = null;
  @state() whatsappLoginQrDataUrl: string | null = null;
  @state() whatsappLoginConnected: boolean | null = null;
  @state() whatsappBusy = false;
  @state() telegramForm: TelegramForm = {
    token: "",
    requireMention: true,
    allowFrom: "",
    proxy: "",
    webhookUrl: "",
    webhookSecret: "",
    webhookPath: "",
  };
  @state() telegramSaving = false;
  @state() telegramTokenLocked = false;
  @state() telegramConfigStatus: string | null = null;
  @state() discordForm: DiscordForm = {
    enabled: true,
    token: "",
    dmEnabled: true,
    allowFrom: "",
    groupEnabled: false,
    groupChannels: "",
    mediaMaxMb: "",
    historyLimit: "",
    textChunkLimit: "",
    guilds: [],
    actions: { ...defaultDiscordActions },
    slashEnabled: false,
    slashName: "",
    slashSessionPrefix: "",
    slashEphemeral: true,
  };
  @state() discordSaving = false;
  @state() discordTokenLocked = false;
  @state() discordConfigStatus: string | null = null;
  @state() slackForm: SlackForm = {
    enabled: true,
    botToken: "",
    appToken: "",
    dmEnabled: true,
    allowFrom: "",
    groupEnabled: false,
    groupChannels: "",
    mediaMaxMb: "",
    textChunkLimit: "",
    reactionNotifications: "own",
    reactionAllowlist: "",
    slashEnabled: false,
    slashName: "",
    slashSessionPrefix: "",
    slashEphemeral: true,
    actions: { ...defaultSlackActions },
    channels: [],
  };
  @state() slackSaving = false;
  @state() slackTokenLocked = false;
  @state() slackAppTokenLocked = false;
  @state() slackConfigStatus: string | null = null;
  @state() signalForm: SignalForm = {
    enabled: true,
    account: "",
    httpUrl: "",
    httpHost: "",
    httpPort: "",
    cliPath: "",
    autoStart: true,
    receiveMode: "",
    ignoreAttachments: false,
    ignoreStories: false,
    sendReadReceipts: false,
    allowFrom: "",
    mediaMaxMb: "",
  };
  @state() signalSaving = false;
  @state() signalConfigStatus: string | null = null;
  @state() imessageForm: IMessageForm = {
    enabled: true,
    cliPath: "",
    dbPath: "",
    service: "auto",
    region: "",
    allowFrom: "",
    includeAttachments: false,
    mediaMaxMb: "",
  };
  @state() imessageSaving = false;
  @state() imessageConfigStatus: string | null = null;

  @state() presenceLoading = false;
  @state() presenceEntries: PresenceEntry[] = [];
  @state() presenceError: string | null = null;
  @state() presenceStatus: string | null = null;

  @state() sessionsLoading = false;
  @state() sessionsResult: SessionsListResult | null = null;
  @state() sessionsError: string | null = null;
  @state() sessionsFilterActive = "";
  @state() sessionsFilterLimit = "120";
  @state() sessionsIncludeGlobal = true;
  @state() sessionsIncludeUnknown = false;

  @state() cronLoading = false;
  @state() cronJobs: CronJob[] = [];
  @state() cronStatus: CronStatus | null = null;
  @state() cronError: string | null = null;
  @state() cronForm: CronFormState = { ...DEFAULT_CRON_FORM };
  @state() cronRunsJobId: string | null = null;
  @state() cronRuns: CronRunLogEntry[] = [];
  @state() cronBusy = false;

  @state() skillsLoading = false;
  @state() skillsReport: SkillStatusReport | null = null;
  @state() skillsError: string | null = null;
  @state() skillsFilter = "";
  @state() skillEdits: Record<string, string> = {};
  @state() skillsBusyKey: string | null = null;

  @state() debugLoading = false;
  @state() debugStatus: StatusSummary | null = null;
  @state() debugHealth: HealthSnapshot | null = null;
  @state() debugModels: unknown[] = [];
  @state() debugHeartbeat: unknown | null = null;
  @state() debugCallMethod = "";
  @state() debugCallParams = "{}";
  @state() debugCallResult: string | null = null;
  @state() debugCallError: string | null = null;

  client: GatewayBrowserClient | null = null;
  private chatScrollFrame: number | null = null;
  private chatScrollTimeout: number | null = null;
  private chatHasAutoScrolled = false;
  private nodesPollInterval: number | null = null;
  private toolStreamById = new Map<string, ToolStreamEntry>();
  private toolStreamOrder: string[] = [];
  basePath = "";
  private popStateHandler = () => this.onPopState();
  private themeMedia: MediaQueryList | null = null;
  private themeMediaHandler: ((event: MediaQueryListEvent) => void) | null = null;
  private topbarObserver: ResizeObserver | null = null;

  createRenderRoot() {
    return this;
  }

  connectedCallback() {
    super.connectedCallback();
    this.basePath = this.inferBasePath();
    this.syncTabWithLocation(true);
    this.syncThemeWithSettings();
    this.attachThemeListener();
    window.addEventListener("popstate", this.popStateHandler);
    this.applySettingsFromUrl();
    this.connect();
    this.startNodesPolling();
  }

  protected firstUpdated() {
    this.observeTopbar();
  }

  disconnectedCallback() {
    window.removeEventListener("popstate", this.popStateHandler);
    this.stopNodesPolling();
    this.detachThemeListener();
    this.topbarObserver?.disconnect();
    this.topbarObserver = null;
    super.disconnectedCallback();
  }

  protected updated(changed: Map<PropertyKey, unknown>) {
    if (
      this.tab === "chat" &&
      (changed.has("chatMessages") ||
        changed.has("chatToolMessages") ||
        changed.has("chatStream") ||
        changed.has("chatLoading") ||
        changed.has("tab"))
    ) {
      const forcedByTab = changed.has("tab");
      const forcedByLoad =
        changed.has("chatLoading") &&
        changed.get("chatLoading") === true &&
        this.chatLoading === false;
      this.scheduleChatScroll(forcedByTab || forcedByLoad || !this.chatHasAutoScrolled);
    }
  }

  connect() {
    this.lastError = null;
    this.hello = null;
    this.connected = false;

    this.client?.stop();
    this.client = new GatewayBrowserClient({
      url: this.settings.gatewayUrl,
      token: this.settings.token.trim() ? this.settings.token : undefined,
      password: this.password.trim() ? this.password : undefined,
      clientName: "clawdbot-control-ui",
      mode: "webchat",
      onHello: (hello) => {
        this.connected = true;
        this.hello = hello;
        this.applySnapshot(hello);
        void loadNodes(this, { quiet: true });
        void this.refreshActiveTab();
      },
      onClose: ({ code, reason }) => {
        this.connected = false;
        this.lastError = `disconnected (${code}): ${reason || "no reason"}`;
      },
      onEvent: (evt) => this.onEvent(evt),
      onGap: ({ expected, received }) => {
        this.lastError = `event gap detected (expected seq ${expected}, got ${received}); refresh recommended`;
      },
    });
    this.client.start();
  }

  private scheduleChatScroll(force = false) {
    if (this.chatScrollFrame) cancelAnimationFrame(this.chatScrollFrame);
    if (this.chatScrollTimeout != null) {
      clearTimeout(this.chatScrollTimeout);
      this.chatScrollTimeout = null;
    }
    const pickScrollTarget = () => {
      const container = this.querySelector(".chat-thread") as HTMLElement | null;
      if (container) {
        const overflowY = getComputedStyle(container).overflowY;
        const canScroll =
          overflowY === "auto" ||
          overflowY === "scroll" ||
          container.scrollHeight - container.clientHeight > 1;
        if (canScroll) return container;
      }
      return (document.scrollingElement ?? document.documentElement) as HTMLElement | null;
    };
    // Wait for Lit render to complete, then scroll
    void this.updateComplete.then(() => {
      this.chatScrollFrame = requestAnimationFrame(() => {
        this.chatScrollFrame = null;
        const target = pickScrollTarget();
        if (!target) return;
        const distanceFromBottom =
          target.scrollHeight - target.scrollTop - target.clientHeight;
        const shouldStick = force || distanceFromBottom < 200;
        if (!shouldStick) return;
        if (force) this.chatHasAutoScrolled = true;
        target.scrollTop = target.scrollHeight;
        const retryDelay = force ? 150 : 120;
        this.chatScrollTimeout = window.setTimeout(() => {
          this.chatScrollTimeout = null;
          const latest = pickScrollTarget();
          if (!latest) return;
          const latestDistanceFromBottom =
            latest.scrollHeight - latest.scrollTop - latest.clientHeight;
          if (!force && latestDistanceFromBottom >= 250) return;
          latest.scrollTop = latest.scrollHeight;
        }, retryDelay);
      });
    });
  }

  private observeTopbar() {
    if (typeof ResizeObserver === "undefined") return;
    const topbar = this.querySelector(".topbar");
    if (!topbar) return;
    const update = () => {
      const { height } = topbar.getBoundingClientRect();
      this.style.setProperty("--topbar-height", `${height}px`);
    };
    update();
    this.topbarObserver = new ResizeObserver(() => update());
    this.topbarObserver.observe(topbar);
  }

  private startNodesPolling() {
    if (this.nodesPollInterval != null) return;
    this.nodesPollInterval = window.setInterval(
      () => void loadNodes(this, { quiet: true }),
      5000,
    );
  }

  private stopNodesPolling() {
    if (this.nodesPollInterval == null) return;
    clearInterval(this.nodesPollInterval);
    this.nodesPollInterval = null;
  }

  resetToolStream() {
    this.toolStreamById.clear();
    this.toolStreamOrder = [];
    this.chatToolMessages = [];
  }

  resetChatScroll() {
    this.chatHasAutoScrolled = false;
  }

  private trimToolStream() {
    if (this.toolStreamOrder.length <= TOOL_STREAM_LIMIT) return;
    const overflow = this.toolStreamOrder.length - TOOL_STREAM_LIMIT;
    const removed = this.toolStreamOrder.splice(0, overflow);
    for (const id of removed) this.toolStreamById.delete(id);
  }

  private syncToolStreamMessages() {
    this.chatToolMessages = this.toolStreamOrder
      .map((id) => this.toolStreamById.get(id)?.message)
      .filter((msg): msg is Record<string, unknown> => Boolean(msg));
  }

  private buildToolStreamMessage(entry: ToolStreamEntry): Record<string, unknown> {
    const content: Array<Record<string, unknown>> = [];
    content.push({
      type: "toolcall",
      name: entry.name,
      arguments: entry.args ?? {},
    });
    if (entry.output) {
      content.push({
        type: "toolresult",
        name: entry.name,
        text: entry.output,
      });
    }
    return {
      role: "assistant",
      toolCallId: entry.toolCallId,
      runId: entry.runId,
      content,
      timestamp: entry.startedAt,
    };
  }

  private handleAgentEvent(payload?: AgentEventPayload) {
    if (!payload || payload.stream !== "tool") return;
    const sessionKey =
      typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
    if (sessionKey && sessionKey !== this.sessionKey) return;
    // Fallback: only accept session-less events for the active run.
    if (!sessionKey && this.chatRunId && payload.runId !== this.chatRunId) return;
    if (this.chatRunId && payload.runId !== this.chatRunId) return;
    if (!this.chatRunId) return;

    const data = payload.data ?? {};
    const toolCallId =
      typeof data.toolCallId === "string" ? data.toolCallId : "";
    if (!toolCallId) return;
    const name = typeof data.name === "string" ? data.name : "tool";
    const phase = typeof data.phase === "string" ? data.phase : "";
    const args = phase === "start" ? data.args : undefined;
    const output =
      phase === "update"
        ? formatToolOutput(data.partialResult)
        : phase === "result"
          ? formatToolOutput(data.result)
          : undefined;

    const now = Date.now();
    let entry = this.toolStreamById.get(toolCallId);
    if (!entry) {
      entry = {
        toolCallId,
        runId: payload.runId,
        sessionKey,
        name,
        args,
        output,
        startedAt: typeof payload.ts === "number" ? payload.ts : now,
        updatedAt: now,
        message: {},
      };
      this.toolStreamById.set(toolCallId, entry);
      this.toolStreamOrder.push(toolCallId);
    } else {
      entry.name = name;
      if (args !== undefined) entry.args = args;
      if (output !== undefined) entry.output = output;
      entry.updatedAt = now;
    }

    entry.message = this.buildToolStreamMessage(entry);
    this.trimToolStream();
    this.syncToolStreamMessages();
  }

  private onEvent(evt: GatewayEventFrame) {
    this.eventLogBuffer = [
      { ts: Date.now(), event: evt.event, payload: evt.payload },
      ...this.eventLogBuffer,
    ].slice(0, 250);
    if (this.tab === "debug") {
      this.eventLog = this.eventLogBuffer;
    }

    if (evt.event === "agent") {
      this.handleAgentEvent(evt.payload as AgentEventPayload | undefined);
      return;
    }

    if (evt.event === "chat") {
      const payload = evt.payload as ChatEventPayload | undefined;
      if (payload?.sessionKey) {
        this.setLastActiveSessionKey(payload.sessionKey);
      }
      const state = handleChatEvent(this, payload);
      if (state === "final" || state === "error" || state === "aborted") {
        this.resetToolStream();
      }
      if (state === "final") void loadChatHistory(this);
      return;
    }

    if (evt.event === "presence") {
      const payload = evt.payload as { presence?: PresenceEntry[] } | undefined;
      if (payload?.presence && Array.isArray(payload.presence)) {
        this.presenceEntries = payload.presence;
        this.presenceError = null;
        this.presenceStatus = null;
      }
      return;
    }

    if (evt.event === "cron" && this.tab === "cron") {
      void this.loadCron();
    }
  }

  private applySnapshot(hello: GatewayHelloOk) {
    const snapshot = hello.snapshot as
      | { presence?: PresenceEntry[]; health?: HealthSnapshot }
      | undefined;
    if (snapshot?.presence && Array.isArray(snapshot.presence)) {
      this.presenceEntries = snapshot.presence;
    }
    if (snapshot?.health) {
      this.debugHealth = snapshot.health;
    }
  }

  applySettings(next: UiSettings) {
    const normalized = {
      ...next,
      lastActiveSessionKey:
        next.lastActiveSessionKey?.trim() || next.sessionKey.trim() || "main",
    };
    this.settings = normalized;
    saveSettings(normalized);
    if (next.theme !== this.theme) {
      this.theme = next.theme;
      this.applyResolvedTheme(resolveTheme(next.theme));
    }
    this.applySessionKey = this.settings.lastActiveSessionKey;
  }

  private setLastActiveSessionKey(next: string) {
    const trimmed = next.trim();
    if (!trimmed) return;
    if (this.settings.lastActiveSessionKey === trimmed) return;
    this.applySettings({ ...this.settings, lastActiveSessionKey: trimmed });
  }

  private applySettingsFromUrl() {
    if (!window.location.search) return;
    const params = new URLSearchParams(window.location.search);
    const tokenRaw = params.get("token");
    const passwordRaw = params.get("password");
    let changed = false;

    if (tokenRaw != null) {
      const token = tokenRaw.trim();
      if (token && !this.settings.token) {
        this.applySettings({ ...this.settings, token });
        changed = true;
      }
      params.delete("token");
    }

    if (passwordRaw != null) {
      const password = passwordRaw.trim();
      if (password) {
        this.password = password;
        changed = true;
      }
      params.delete("password");
    }

    if (!changed && tokenRaw == null && passwordRaw == null) return;
    const url = new URL(window.location.href);
    url.search = params.toString();
    window.history.replaceState({}, "", url.toString());
  }

  setTab(next: Tab) {
    if (this.tab !== next) this.tab = next;
    if (next === "chat") this.chatHasAutoScrolled = false;
    void this.refreshActiveTab();
    this.syncUrlWithTab(next, false);
  }

  setTheme(next: ThemeMode, context?: ThemeTransitionContext) {
    const applyTheme = () => {
      this.theme = next;
      this.applySettings({ ...this.settings, theme: next });
      this.applyResolvedTheme(resolveTheme(next));
    };
    startThemeTransition({
      nextTheme: next,
      applyTheme,
      context,
      currentTheme: this.theme,
    });
  }

  private async refreshActiveTab() {
    if (this.tab === "overview") await this.loadOverview();
    if (this.tab === "connections") await this.loadConnections();
    if (this.tab === "instances") await loadPresence(this);
    if (this.tab === "sessions") await loadSessions(this);
    if (this.tab === "cron") await this.loadCron();
    if (this.tab === "skills") await loadSkills(this);
    if (this.tab === "nodes") await loadNodes(this);
    if (this.tab === "chat") {
      await Promise.all([loadChatHistory(this), loadSessions(this)]);
      this.scheduleChatScroll(!this.chatHasAutoScrolled);
    }
    if (this.tab === "config") {
      await loadConfigSchema(this);
      await loadConfig(this);
    }
    if (this.tab === "debug") {
      await loadDebug(this);
      this.eventLog = this.eventLogBuffer;
    }
  }

  private inferBasePath() {
    if (typeof window === "undefined") return "";
    const configured = window.__CLAWDBOT_CONTROL_UI_BASE_PATH__;
    if (typeof configured === "string" && configured.trim()) {
      return normalizeBasePath(configured);
    }
    return inferBasePathFromPathname(window.location.pathname);
  }

  private syncThemeWithSettings() {
    this.theme = this.settings.theme ?? "system";
    this.applyResolvedTheme(resolveTheme(this.theme));
  }

  private applyResolvedTheme(resolved: ResolvedTheme) {
    this.themeResolved = resolved;
    if (typeof document === "undefined") return;
    const root = document.documentElement;
    root.dataset.theme = resolved;
    root.style.colorScheme = resolved;
  }

  private attachThemeListener() {
    if (typeof window === "undefined" || typeof window.matchMedia !== "function")
      return;
    this.themeMedia = window.matchMedia("(prefers-color-scheme: dark)");
    this.themeMediaHandler = (event) => {
      if (this.theme !== "system") return;
      this.applyResolvedTheme(event.matches ? "dark" : "light");
    };
    if (typeof this.themeMedia.addEventListener === "function") {
      this.themeMedia.addEventListener("change", this.themeMediaHandler);
      return;
    }
    const legacy = this.themeMedia as MediaQueryList & {
      addListener: (cb: (event: MediaQueryListEvent) => void) => void;
    };
    legacy.addListener(this.themeMediaHandler);
  }

  private detachThemeListener() {
    if (!this.themeMedia || !this.themeMediaHandler) return;
    if (typeof this.themeMedia.removeEventListener === "function") {
      this.themeMedia.removeEventListener("change", this.themeMediaHandler);
      return;
    }
    const legacy = this.themeMedia as MediaQueryList & {
      removeListener: (cb: (event: MediaQueryListEvent) => void) => void;
    };
    legacy.removeListener(this.themeMediaHandler);
    this.themeMedia = null;
    this.themeMediaHandler = null;
  }

  private syncTabWithLocation(replace: boolean) {
    if (typeof window === "undefined") return;
    const resolved = tabFromPath(window.location.pathname, this.basePath) ?? "chat";
    this.setTabFromRoute(resolved);
    this.syncUrlWithTab(resolved, replace);
  }

  private onPopState() {
    if (typeof window === "undefined") return;
    const resolved = tabFromPath(window.location.pathname, this.basePath);
    if (!resolved) return;
    this.setTabFromRoute(resolved);
  }

  private setTabFromRoute(next: Tab) {
    if (this.tab !== next) this.tab = next;
    if (next === "chat") this.chatHasAutoScrolled = false;
    if (this.connected) void this.refreshActiveTab();
  }

  private syncUrlWithTab(tab: Tab, replace: boolean) {
    if (typeof window === "undefined") return;
    const targetPath = normalizePath(pathForTab(tab, this.basePath));
    const currentPath = normalizePath(window.location.pathname);
    if (currentPath === targetPath) return;
    const url = new URL(window.location.href);
    url.pathname = targetPath;
    if (replace) {
      window.history.replaceState({}, "", url.toString());
    } else {
      window.history.pushState({}, "", url.toString());
    }
  }

  async loadOverview() {
    await Promise.all([
      loadProviders(this, false),
      loadPresence(this),
      loadSessions(this),
      loadCronStatus(this),
      loadDebug(this),
    ]);
  }

  private async loadConnections() {
    await Promise.all([loadProviders(this, true), loadConfig(this)]);
  }

  async loadCron() {
    await Promise.all([loadCronStatus(this), loadCronJobs(this)]);
  }
  async handleSendChat() {
    if (!this.connected) return;
    this.resetToolStream();
    const ok = await sendChat(this);
    if (ok) {
      this.setLastActiveSessionKey(this.sessionKey);
    }
    if (ok && this.chatRunId) {
      // chat.send returned (run finished), but we missed the chat final event.
      this.chatRunId = null;
      this.chatStream = null;
      this.chatStreamStartedAt = null;
      this.resetToolStream();
      void loadChatHistory(this);
    }
    this.scheduleChatScroll();
  }

  async handleWhatsAppStart(force: boolean) {
    await startWhatsAppLogin(this, force);
    await loadProviders(this, true);
  }

  async handleWhatsAppWait() {
    await waitWhatsAppLogin(this);
    await loadProviders(this, true);
  }

  async handleWhatsAppLogout() {
    await logoutWhatsApp(this);
    await loadProviders(this, true);
  }

  async handleTelegramSave() {
    await saveTelegramConfig(this);
    await loadConfig(this);
    await loadProviders(this, true);
  }

  async handleDiscordSave() {
    await saveDiscordConfig(this);
    await loadConfig(this);
    await loadProviders(this, true);
  }

  async handleSlackSave() {
    await saveSlackConfig(this);
    await loadConfig(this);
    await loadProviders(this, true);
  }

  async handleSignalSave() {
    await saveSignalConfig(this);
    await loadConfig(this);
    await loadProviders(this, true);
  }

  async handleIMessageSave() {
    await saveIMessageConfig(this);
    await loadConfig(this);
    await loadProviders(this, true);
  }

  render() {
    return renderApp(this);
  }
}


--- ui/src/ui/app-render.ts ---
import { html, nothing } from "lit";

import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway";
import {
  TAB_GROUPS,
  pathForTab,
  subtitleForTab,
  titleForTab,
  type Tab,
} from "./navigation";
import type { UiSettings } from "./storage";
import type { ThemeMode } from "./theme";
import type { ThemeTransitionContext } from "./theme-transition";
import type {
  ConfigSnapshot,
  CronJob,
  CronRunLogEntry,
  CronStatus,
  HealthSnapshot,
  PresenceEntry,
  ProvidersStatusSnapshot,
  SessionsListResult,
  SkillStatusReport,
  StatusSummary,
} from "./types";
import type {
  CronFormState,
  DiscordForm,
  IMessageForm,
  SlackForm,
  SignalForm,
  TelegramForm,
} from "./ui-types";
import { renderChat } from "./views/chat";
import { renderConfig } from "./views/config";
import { renderConnections } from "./views/connections";
import { renderCron } from "./views/cron";
import { renderDebug } from "./views/debug";
import { renderInstances } from "./views/instances";
import { renderNodes } from "./views/nodes";
import { renderOverview } from "./views/overview";
import { renderSessions } from "./views/sessions";
import { renderSkills } from "./views/skills";
import {
  loadProviders,
  updateDiscordForm,
  updateIMessageForm,
  updateSlackForm,
  updateSignalForm,
  updateTelegramForm,
} from "./controllers/connections";
import { loadPresence } from "./controllers/presence";
import { loadSessions, patchSession } from "./controllers/sessions";
import {
  installSkill,
  loadSkills,
  saveSkillApiKey,
  updateSkillEdit,
  updateSkillEnabled,
} from "./controllers/skills";
import { loadNodes } from "./controllers/nodes";
import { loadChatHistory } from "./controllers/chat";
import {
  applyConfig,
  loadConfig,
  runUpdate,
  saveConfig,
  updateConfigFormValue,
} from "./controllers/config";
import { loadCronRuns, toggleCronJob, runCronJob, removeCronJob, addCronJob } from "./controllers/cron";
import { loadDebug, callDebugMethod } from "./controllers/debug";

export type EventLogEntry = {
  ts: number;
  event: string;
  payload?: unknown;
};

export type AppViewState = {
  settings: UiSettings;
  password: string;
  tab: Tab;
  basePath: string;
  connected: boolean;
  theme: ThemeMode;
  themeResolved: "light" | "dark";
  hello: GatewayHelloOk | null;
  lastError: string | null;
  eventLog: EventLogEntry[];
  sessionKey: string;
  chatLoading: boolean;
  chatSending: boolean;
  chatMessage: string;
  chatMessages: unknown[];
  chatToolMessages: unknown[];
  chatStream: string | null;
  chatRunId: string | null;
  chatThinkingLevel: string | null;
  nodesLoading: boolean;
  nodes: Array<Record<string, unknown>>;
  configLoading: boolean;
  configRaw: string;
  configValid: boolean | null;
  configIssues: unknown[];
  configSaving: boolean;
  configApplying: boolean;
  updateRunning: boolean;
  configSnapshot: ConfigSnapshot | null;
  configSchema: unknown | null;
  configSchemaLoading: boolean;
  configUiHints: Record<string, unknown>;
  configForm: Record<string, unknown> | null;
  configFormMode: "form" | "raw";
  providersLoading: boolean;
  providersSnapshot: ProvidersStatusSnapshot | null;
  providersError: string | null;
  providersLastSuccess: number | null;
  whatsappLoginMessage: string | null;
  whatsappLoginQrDataUrl: string | null;
  whatsappLoginConnected: boolean | null;
  whatsappBusy: boolean;
  telegramForm: TelegramForm;
  telegramSaving: boolean;
  telegramTokenLocked: boolean;
  telegramConfigStatus: string | null;
  discordForm: DiscordForm;
  discordSaving: boolean;
  discordTokenLocked: boolean;
  discordConfigStatus: string | null;
  slackForm: SlackForm;
  slackSaving: boolean;
  slackTokenLocked: boolean;
  slackAppTokenLocked: boolean;
  slackConfigStatus: string | null;
  signalForm: SignalForm;
  signalSaving: boolean;
  signalConfigStatus: string | null;
  imessageForm: IMessageForm;
  imessageSaving: boolean;
  imessageConfigStatus: string | null;
  presenceLoading: boolean;
  presenceEntries: PresenceEntry[];
  presenceError: string | null;
  presenceStatus: string | null;
  sessionsLoading: boolean;
  sessionsResult: SessionsListResult | null;
  sessionsError: string | null;
  sessionsFilterActive: string;
  sessionsFilterLimit: string;
  sessionsIncludeGlobal: boolean;
  sessionsIncludeUnknown: boolean;
  cronLoading: boolean;
  cronJobs: CronJob[];
  cronStatus: CronStatus | null;
  cronError: string | null;
  cronForm: CronFormState;
  cronRunsJobId: string | null;
  cronRuns: CronRunLogEntry[];
  cronBusy: boolean;
  skillsLoading: boolean;
  skillsReport: SkillStatusReport | null;
  skillsError: string | null;
  skillsFilter: string;
  skillEdits: Record<string, string>;
  skillsBusyKey: string | null;
  debugLoading: boolean;
  debugStatus: StatusSummary | null;
  debugHealth: HealthSnapshot | null;
  debugModels: unknown[];
  debugHeartbeat: unknown | null;
  debugCallMethod: string;
  debugCallParams: string;
  debugCallResult: string | null;
  debugCallError: string | null;
  client: GatewayBrowserClient | null;
  connect: () => void;
  setTab: (tab: Tab) => void;
  setTheme: (theme: ThemeMode, context?: ThemeTransitionContext) => void;
  applySettings: (next: UiSettings) => void;
  loadOverview: () => Promise<void>;
  loadCron: () => Promise<void>;
  handleWhatsAppStart: (force: boolean) => Promise<void>;
  handleWhatsAppWait: () => Promise<void>;
  handleWhatsAppLogout: () => Promise<void>;
  handleTelegramSave: () => Promise<void>;
  handleSendChat: () => Promise<void>;
  resetToolStream: () => void;
};

export function renderApp(state: AppViewState) {
  const presenceCount = state.presenceEntries.length;
  const sessionsCount = state.sessionsResult?.count ?? null;
  const cronNext = state.cronStatus?.nextWakeAtMs ?? null;
  const chatDisabledReason = state.connected ? null : "Disconnected from gateway.";
  const isChat = state.tab === "chat";
  const chatFocus = isChat && state.settings.chatFocusMode;

  return html`
    <div class="shell ${isChat ? "shell--chat" : ""} ${chatFocus ? "shell--chat-focus" : ""}">
      <header class="topbar">
        <div class="brand">
          <div class="brand-title">Clawdbot Control</div>
          <div class="brand-sub">Gateway dashboard</div>
        </div>
        <div class="topbar-status">
          <div class="pill">
            <span class="statusDot ${state.connected ? "ok" : ""}"></span>
            <span>Health</span>
            <span class="mono">${state.connected ? "OK" : "Offline"}</span>
          </div>
          ${isChat
            ? renderChatFocusToggle(
                state.settings.chatFocusMode,
                () =>
                  state.applySettings({
                    ...state.settings,
                    chatFocusMode: !state.settings.chatFocusMode,
                  }),
              )
            : nothing}
          ${renderThemeToggle(state)}
        </div>
      </header>
      <aside class="nav">
        ${TAB_GROUPS.map(
          (group) => html`
            <div class="nav-group">
              <div class="nav-label">${group.label}</div>
              ${group.tabs.map((tab) => renderTab(state, tab))}
            </div>
          `,
        )}
      </aside>
      <main class="content ${isChat ? "content--chat" : ""}">
        <section class="content-header">
          <div>
            <div class="page-title">${titleForTab(state.tab)}</div>
            <div class="page-sub">${subtitleForTab(state.tab)}</div>
          </div>
          <div class="page-meta">
            ${state.lastError
              ? html`<div class="pill danger">${state.lastError}</div>`
              : nothing}
          </div>
        </section>

        ${state.tab === "overview"
          ? renderOverview({
              connected: state.connected,
              hello: state.hello,
              settings: state.settings,
              password: state.password,
              lastError: state.lastError,
              presenceCount,
              sessionsCount,
              cronEnabled: state.cronStatus?.enabled ?? null,
              cronNext,
              lastProvidersRefresh: state.providersLastSuccess,
              onSettingsChange: (next) => state.applySettings(next),
              onPasswordChange: (next) => (state.password = next),
              onSessionKeyChange: (next) => {
                state.sessionKey = next;
                state.chatMessage = "";
                state.resetToolStream();
                state.applySettings({
                  ...state.settings,
                  sessionKey: next,
                  lastActiveSessionKey: next,
                });
              },
              onConnect: () => state.connect(),
              onRefresh: () => state.loadOverview(),
            })
          : nothing}

        ${state.tab === "connections"
          ? renderConnections({
              connected: state.connected,
              loading: state.providersLoading,
              snapshot: state.providersSnapshot,
              lastError: state.providersError,
              lastSuccessAt: state.providersLastSuccess,
              whatsappMessage: state.whatsappLoginMessage,
              whatsappQrDataUrl: state.whatsappLoginQrDataUrl,
              whatsappConnected: state.whatsappLoginConnected,
              whatsappBusy: state.whatsappBusy,
              telegramForm: state.telegramForm,
              telegramTokenLocked: state.telegramTokenLocked,
              telegramSaving: state.telegramSaving,
              telegramStatus: state.telegramConfigStatus,
              discordForm: state.discordForm,
              discordTokenLocked: state.discordTokenLocked,
              discordSaving: state.discordSaving,
              discordStatus: state.discordConfigStatus,
              slackForm: state.slackForm,
              slackTokenLocked: state.slackTokenLocked,
              slackAppTokenLocked: state.slackAppTokenLocked,
              slackSaving: state.slackSaving,
              slackStatus: state.slackConfigStatus,
              signalForm: state.signalForm,
              signalSaving: state.signalSaving,
              signalStatus: state.signalConfigStatus,
              imessageForm: state.imessageForm,
              imessageSaving: state.imessageSaving,
              imessageStatus: state.imessageConfigStatus,
              onRefresh: (probe) => loadProviders(state, probe),
              onWhatsAppStart: (force) => state.handleWhatsAppStart(force),
              onWhatsAppWait: () => state.handleWhatsAppWait(),
              onWhatsAppLogout: () => state.handleWhatsAppLogout(),
              onTelegramChange: (patch) => updateTelegramForm(state, patch),
              onTelegramSave: () => state.handleTelegramSave(),
              onDiscordChange: (patch) => updateDiscordForm(state, patch),
              onDiscordSave: () => state.handleDiscordSave(),
              onSlackChange: (patch) => updateSlackForm(state, patch),
              onSlackSave: () => state.handleSlackSave(),
              onSignalChange: (patch) => updateSignalForm(state, patch),
              onSignalSave: () => state.handleSignalSave(),
              onIMessageChange: (patch) => updateIMessageForm(state, patch),
              onIMessageSave: () => state.handleIMessageSave(),
            })
          : nothing}

        ${state.tab === "instances"
          ? renderInstances({
              loading: state.presenceLoading,
              entries: state.presenceEntries,
              lastError: state.presenceError,
              statusMessage: state.presenceStatus,
              onRefresh: () => loadPresence(state),
            })
          : nothing}

        ${state.tab === "sessions"
          ? renderSessions({
              loading: state.sessionsLoading,
              result: state.sessionsResult,
              error: state.sessionsError,
              activeMinutes: state.sessionsFilterActive,
              limit: state.sessionsFilterLimit,
              includeGlobal: state.sessionsIncludeGlobal,
              includeUnknown: state.sessionsIncludeUnknown,
              onFiltersChange: (next) => {
                state.sessionsFilterActive = next.activeMinutes;
                state.sessionsFilterLimit = next.limit;
                state.sessionsIncludeGlobal = next.includeGlobal;
                state.sessionsIncludeUnknown = next.includeUnknown;
              },
              onRefresh: () => loadSessions(state),
              onPatch: (key, patch) => patchSession(state, key, patch),
            })
          : nothing}

        ${state.tab === "cron"
          ? renderCron({
              loading: state.cronLoading,
              status: state.cronStatus,
              jobs: state.cronJobs,
              error: state.cronError,
              busy: state.cronBusy,
              form: state.cronForm,
              runsJobId: state.cronRunsJobId,
              runs: state.cronRuns,
              onFormChange: (patch) => (state.cronForm = { ...state.cronForm, ...patch }),
              onRefresh: () => state.loadCron(),
              onAdd: () => addCronJob(state),
              onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
              onRun: (job) => runCronJob(state, job),
              onRemove: (job) => removeCronJob(state, job),
              onLoadRuns: (jobId) => loadCronRuns(state, jobId),
            })
          : nothing}

        ${state.tab === "skills"
          ? renderSkills({
              loading: state.skillsLoading,
              report: state.skillsReport,
              error: state.skillsError,
              filter: state.skillsFilter,
              edits: state.skillEdits,
              busyKey: state.skillsBusyKey,
              onFilterChange: (next) => (state.skillsFilter = next),
              onRefresh: () => loadSkills(state),
              onToggle: (key, enabled) => updateSkillEnabled(state, key, enabled),
              onEdit: (key, value) => updateSkillEdit(state, key, value),
              onSaveKey: (key) => saveSkillApiKey(state, key),
              onInstall: (name, installId) => installSkill(state, name, installId),
            })
          : nothing}

        ${state.tab === "nodes"
          ? renderNodes({
              loading: state.nodesLoading,
              nodes: state.nodes,
              onRefresh: () => loadNodes(state),
            })
          : nothing}

        ${state.tab === "chat"
          ? renderChat({
              sessionKey: state.sessionKey,
              onSessionKeyChange: (next) => {
                state.sessionKey = next;
                state.chatMessage = "";
                state.chatStream = null;
                state.chatStreamStartedAt = null;
                state.chatRunId = null;
                state.resetToolStream();
                state.resetChatScroll();
                state.applySettings({
                  ...state.settings,
                  sessionKey: next,
                  lastActiveSessionKey: next,
                });
                void loadChatHistory(state);
              },
              thinkingLevel: state.chatThinkingLevel,
              loading: state.chatLoading,
              sending: state.chatSending,
              messages: state.chatMessages,
              toolMessages: state.chatToolMessages,
              stream: state.chatStream,
              streamStartedAt: state.chatStreamStartedAt,
              draft: state.chatMessage,
              connected: state.connected,
              canSend: state.connected,
              disabledReason: chatDisabledReason,
              error: state.lastError,
              sessions: state.sessionsResult,
              onRefresh: () => {
                state.resetToolStream();
                return loadChatHistory(state);
              },
              onDraftChange: (next) => (state.chatMessage = next),
              onSend: () => state.handleSendChat(),
            })
          : nothing}

        ${state.tab === "config"
          ? renderConfig({
              raw: state.configRaw,
              valid: state.configValid,
              issues: state.configIssues,
              loading: state.configLoading,
              saving: state.configSaving,
              applying: state.configApplying,
              updating: state.updateRunning,
              connected: state.connected,
              schema: state.configSchema,
              schemaLoading: state.configSchemaLoading,
              uiHints: state.configUiHints,
              formMode: state.configFormMode,
              formValue: state.configForm,
              onRawChange: (next) => (state.configRaw = next),
              onFormModeChange: (mode) => (state.configFormMode = mode),
              onFormPatch: (path, value) => updateConfigFormValue(state, path, value),
              onReload: () => loadConfig(state),
              onSave: () => saveConfig(state),
              onApply: () => applyConfig(state),
              onUpdate: () => runUpdate(state),
            })
          : nothing}

        ${state.tab === "debug"
          ? renderDebug({
              loading: state.debugLoading,
              status: state.debugStatus,
              health: state.debugHealth,
              models: state.debugModels,
              heartbeat: state.debugHeartbeat,
              eventLog: state.eventLog,
              callMethod: state.debugCallMethod,
              callParams: state.debugCallParams,
              callResult: state.debugCallResult,
              callError: state.debugCallError,
              onCallMethodChange: (next) => (state.debugCallMethod = next),
              onCallParamsChange: (next) => (state.debugCallParams = next),
              onRefresh: () => loadDebug(state),
              onCall: () => callDebugMethod(state),
            })
          : nothing}
      </main>
    </div>
  `;
}

function renderTab(state: AppViewState, tab: Tab) {
  const href = pathForTab(tab, state.basePath);
  return html`
    <a
      href=${href}
      class="nav-item ${state.tab === tab ? "active" : ""}"
      @click=${(event: MouseEvent) => {
        if (
          event.defaultPrevented ||
          event.button !== 0 ||
          event.metaKey ||
          event.ctrlKey ||
          event.shiftKey ||
          event.altKey
        ) {
          return;
        }
        event.preventDefault();
        state.setTab(tab);
      }}
    >
      <span>${titleForTab(tab)}</span>
    </a>
  `;
}

const THEME_ORDER: ThemeMode[] = ["system", "light", "dark"];

function renderThemeToggle(state: AppViewState) {
  const index = Math.max(0, THEME_ORDER.indexOf(state.theme));
  const applyTheme = (next: ThemeMode) => (event: MouseEvent) => {
    const element = event.currentTarget as HTMLElement;
    const context: ThemeTransitionContext = { element };
    if (event.clientX || event.clientY) {
      context.pointerClientX = event.clientX;
      context.pointerClientY = event.clientY;
    }
    state.setTheme(next, context);
  };

  return html`
    <div class="theme-toggle" style="--theme-index: ${index};">
      <div class="theme-toggle__track" role="group" aria-label="Theme">
        <span class="theme-toggle__indicator"></span>
        <button
          class="theme-toggle__button ${state.theme === "system" ? "active" : ""}"
          @click=${applyTheme("system")}
          aria-pressed=${state.theme === "system"}
          aria-label="System theme"
          title="System"
        >
          ${renderMonitorIcon()}
        </button>
        <button
          class="theme-toggle__button ${state.theme === "light" ? "active" : ""}"
          @click=${applyTheme("light")}
          aria-pressed=${state.theme === "light"}
          aria-label="Light theme"
          title="Light"
        >
          ${renderSunIcon()}
        </button>
        <button
          class="theme-toggle__button ${state.theme === "dark" ? "active" : ""}"
          @click=${applyTheme("dark")}
          aria-pressed=${state.theme === "dark"}
          aria-label="Dark theme"
          title="Dark"
        >
          ${renderMoonIcon()}
        </button>
      </div>
    </div>
  `;
}

function renderChatFocusToggle(focusMode: boolean, onToggle: () => void) {
  return html`
    <button
      class="btn ${focusMode ? "active" : ""}"
      @click=${onToggle}
      aria-pressed=${focusMode}
      title="Toggle focus mode (hide sidebar + page header)"
    >
      Focus
    </button>
  `;
}

function renderSunIcon() {
  return html`
    <svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
      <circle cx="12" cy="12" r="4"></circle>
      <path d="M12 2v2"></path>
      <path d="M12 20v2"></path>
      <path d="m4.93 4.93 1.41 1.41"></path>
      <path d="m17.66 17.66 1.41 1.41"></path>
      <path d="M2 12h2"></path>
      <path d="M20 12h2"></path>
      <path d="m6.34 17.66-1.41 1.41"></path>
      <path d="m19.07 4.93-1.41 1.41"></path>
    </svg>
  `;
}

function renderMoonIcon() {
  return html`
    <svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
      <path
        d="M20.985 12.486a9 9 0 1 1-9.473-9.472c.405-.022.617.46.402.803a6 6 0 0 0 8.268 8.268c.344-.215.825-.004.803.401"
      ></path>
    </svg>
  `;
}

function renderMonitorIcon() {
  return html`
    <svg class="theme-icon" viewBox="0 0 24 24" aria-hidden="true">
      <rect width="20" height="14" x="2" y="3" rx="2"></rect>
      <line x1="8" x2="16" y1="21" y2="21"></line>
      <line x1="12" x2="12" y1="17" y2="21"></line>
    </svg>
  `;
}


--- ui/src/ui/chat-markdown.browser.test.ts ---
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import { ClawdbotApp } from "./app";

const originalConnect = ClawdbotApp.prototype.connect;

function mountApp(pathname: string) {
  window.history.replaceState({}, "", pathname);
  const app = document.createElement("clawdbot-app") as ClawdbotApp;
  document.body.append(app);
  return app;
}

beforeEach(() => {
  ClawdbotApp.prototype.connect = () => {
    // no-op: avoid real gateway WS connections in browser tests
  };
  window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined;
  document.body.innerHTML = "";
});

afterEach(() => {
  ClawdbotApp.prototype.connect = originalConnect;
  window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined;
  document.body.innerHTML = "";
});

describe("chat markdown rendering", () => {
  it("renders markdown inside tool result cards", async () => {
    const app = mountApp("/chat");
    await app.updateComplete;

    app.chatMessages = [
      {
        role: "assistant",
        content: [
          { type: "toolcall", name: "noop", arguments: {} },
          { type: "toolresult", name: "noop", text: "Hello **world**" },
        ],
        timestamp: Date.now(),
      },
    ];

    await app.updateComplete;

    const strong = app.querySelector(".chat-tool-card__output strong");
    expect(strong?.textContent).toBe("world");
  });
});



--- ui/src/ui/config-form.browser.test.ts ---
import { render } from "lit";
import { describe, expect, it, vi } from "vitest";

import { analyzeConfigSchema, renderConfigForm } from "./views/config-form";

const rootSchema = {
  type: "object",
  properties: {
    gateway: {
      type: "object",
      properties: {
        auth: {
          type: "object",
          properties: {
            token: { type: "string" },
          },
        },
      },
    },
    allowFrom: {
      type: "array",
      items: { type: "string" },
    },
    mode: {
      type: "string",
      enum: ["off", "token"],
    },
    enabled: {
      type: "boolean",
    },
    bind: {
      anyOf: [
        { const: "auto" },
        { const: "lan" },
        { const: "tailnet" },
        { const: "loopback" },
      ],
    },
  },
};

describe("config form renderer", () => {
  it("renders inputs and patches values", () => {
    const onPatch = vi.fn();
    const container = document.createElement("div");
    const analysis = analyzeConfigSchema(rootSchema);
    render(
      renderConfigForm({
        schema: analysis.schema,
        uiHints: {
          "gateway.auth.token": { label: "Gateway Token", sensitive: true },
        },
        unsupportedPaths: analysis.unsupportedPaths,
        value: {},
        onPatch,
      }),
      container,
    );

    const tokenInput = container.querySelector(
      "input[type='password']",
    ) as HTMLInputElement | null;
    expect(tokenInput).not.toBeNull();
    if (!tokenInput) return;
    tokenInput.value = "abc123";
    tokenInput.dispatchEvent(new Event("input", { bubbles: true }));
    expect(onPatch).toHaveBeenCalledWith(
      ["gateway", "auth", "token"],
      "abc123",
    );

    const select = container.querySelector("select") as HTMLSelectElement | null;
    const selects = Array.from(container.querySelectorAll("select"));
    const modeSelect = selects.find((el) =>
      Array.from(el.options).some((opt) => opt.textContent?.trim() === "token"),
    ) as HTMLSelectElement | undefined;
    expect(modeSelect).not.toBeUndefined();
    if (!modeSelect) return;
    const tokenOption = Array.from(modeSelect.options).find(
      (opt) => opt.textContent?.trim() === "token",
    );
    expect(tokenOption).not.toBeUndefined();
    if (!tokenOption) return;
    modeSelect.value = tokenOption.value;
    modeSelect.dispatchEvent(new Event("change", { bubbles: true }));
    expect(onPatch).toHaveBeenCalledWith(["mode"], "token");

    const checkbox = container.querySelector(
      "input[type='checkbox']",
    ) as HTMLInputElement | null;
    expect(checkbox).not.toBeNull();
    if (!checkbox) return;
    checkbox.checked = true;
    checkbox.dispatchEvent(new Event("change", { bubbles: true }));
    expect(onPatch).toHaveBeenCalledWith(["enabled"], true);
  });

  it("adds and removes array entries", () => {
    const onPatch = vi.fn();
    const container = document.createElement("div");
    const analysis = analyzeConfigSchema(rootSchema);
    render(
      renderConfigForm({
        schema: analysis.schema,
        uiHints: {},
        unsupportedPaths: analysis.unsupportedPaths,
        value: { allowFrom: ["+1"] },
        onPatch,
      }),
      container,
    );

    const addButton = Array.from(container.querySelectorAll("button")).find(
      (btn) => btn.textContent?.trim() === "Add",
    );
    expect(addButton).not.toBeUndefined();
    addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
    expect(onPatch).toHaveBeenCalledWith(["allowFrom"], ["+1", ""]);

    const removeButton = Array.from(container.querySelectorAll("button")).find(
      (btn) => btn.textContent?.trim() === "Remove",
    );
    expect(removeButton).not.toBeUndefined();
    removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
    expect(onPatch).toHaveBeenCalledWith(["allowFrom"], []);
  });

  it("renders union literals as select options", () => {
    const onPatch = vi.fn();
    const container = document.createElement("div");
    const analysis = analyzeConfigSchema(rootSchema);
    render(
      renderConfigForm({
        schema: analysis.schema,
        uiHints: {},
        unsupportedPaths: analysis.unsupportedPaths,
        value: { bind: "auto" },
        onPatch,
      }),
      container,
    );

    const selects = Array.from(container.querySelectorAll("select"));
    const bindSelect = selects.find((el) =>
      Array.from(el.options).some((opt) => opt.textContent?.trim() === "tailnet"),
    ) as HTMLSelectElement | undefined;
    expect(bindSelect).not.toBeUndefined();
    if (!bindSelect) return;
    const tailnetOption = Array.from(bindSelect.options).find(
      (opt) => opt.textContent?.trim() === "tailnet",
    );
    expect(tailnetOption).not.toBeUndefined();
    if (!tailnetOption) return;
    bindSelect.value = tailnetOption.value;
    bindSelect.dispatchEvent(new Event("change", { bubbles: true }));
    expect(onPatch).toHaveBeenCalledWith(["bind"], "tailnet");
  });

  it("renders map fields from additionalProperties", () => {
    const onPatch = vi.fn();
    const container = document.createElement("div");
    const schema = {
      type: "object",
      properties: {
        slack: {
          type: "object",
          additionalProperties: {
            type: "string",
          },
        },
      },
    };
    const analysis = analyzeConfigSchema(schema);
    render(
      renderConfigForm({
        schema: analysis.schema,
        uiHints: {},
        unsupportedPaths: analysis.unsupportedPaths,
        value: { slack: { channelA: "ok" } },
        onPatch,
      }),
      container,
    );

    const removeButton = Array.from(container.querySelectorAll("button")).find(
      (btn) => btn.textContent?.trim() === "Remove",
    );
    expect(removeButton).not.toBeUndefined();
    removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
    expect(onPatch).toHaveBeenCalledWith(["slack"], {});
  });

  it("flags unsupported unions", () => {
    const schema = {
      type: "object",
      properties: {
        mixed: {
          anyOf: [{ type: "string" }, { type: "object", properties: {} }],
        },
      },
    };
    const analysis = analyzeConfigSchema(schema);
    expect(analysis.unsupportedPaths).toContain("mixed");
  });

  it("supports nullable types", () => {
    const schema = {
      type: "object",
      properties: {
        note: { type: ["string", "null"] },
      },
    };
    const analysis = analyzeConfigSchema(schema);
    expect(analysis.unsupportedPaths).not.toContain("note");
  });

  it("flags additionalProperties true", () => {
    const schema = {
      type: "object",
      properties: {
        extra: {
          type: "object",
          additionalProperties: true,
        },
      },
    };
    const analysis = analyzeConfigSchema(schema);
    expect(analysis.unsupportedPaths).toContain("extra");
  });
});


--- ui/src/ui/focus-mode.browser.test.ts ---
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import { ClawdbotApp } from "./app";

const originalConnect = ClawdbotApp.prototype.connect;

function mountApp(pathname: string) {
  window.history.replaceState({}, "", pathname);
  const app = document.createElement("clawdbot-app") as ClawdbotApp;
  document.body.append(app);
  return app;
}

beforeEach(() => {
  ClawdbotApp.prototype.connect = () => {
    // no-op: avoid real gateway WS connections in browser tests
  };
  window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined;
  localStorage.clear();
  document.body.innerHTML = "";
});

afterEach(() => {
  ClawdbotApp.prototype.connect = originalConnect;
  window.__CLAWDBOT_CONTROL_UI_BASE_PATH__ = undefined;
  localStorage.clear();
  document.body.innerHTML = "";
});

describe("chat focus mode", () => {
  it("collapses header + sidebar on chat tab only", async () => {
    const app = mountApp("/chat");
    await app.updateComplete;

    const shell = app.querySelector(".shell");
    expect(shell).not.toBeNull();
    expect(shell?.classList.contains("shell--chat-focus")).toBe(false);

    const toggle = app.querySelector<HTMLButtonElement>(
      'button[title^="Toggle focus mode"]',
    );
    expect(toggle).not.toBeNull();
    toggle?.click();

    await app.updateComplete;
    expect(shell?.classList.contains("shell--chat-focus")).toBe(true);

    const link = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/connections"]');
    expect(link).not.toBeNull();
    link?.dispatchEvent(
      new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }),
    );

    await app.updateComplete;
    expect(app.tab).toBe("connections");
    expect(shell?.classList.contains("shell--chat-focus")).toBe(false);

    const chatLink = app.querySelector<HTMLAnchorElement>('a.nav-item[href="/chat"]');
    chatLink?.dispatchEvent(
      new MouseEvent("click", { bubbles: true, cancelable: true, button: 0 }),
    );

    await app.updateComplete;
    expect(app.tab).toBe("chat");
    expect(shell?.classList.contains("shell--chat-focus")).toBe(true);
  });
});



--- ui/src/ui/format.ts ---
export function formatMs(ms?: number | null): string {
  if (!ms && ms !== 0) return "n/a";
  return new Date(ms).toLocaleString();
}

export function formatAgo(ms?: number | null): string {
  if (!ms && ms !== 0) return "n/a";
  const diff = Date.now() - ms;
  if (diff < 0) return "just now";
  const sec = Math.round(diff / 1000);
  if (sec < 60) return `${sec}s ago`;
  const min = Math.round(sec / 60);
  if (min < 60) return `${min}m ago`;
  const hr = Math.round(min / 60);
  if (hr < 48) return `${hr}h ago`;
  const day = Math.round(hr / 24);
  return `${day}d ago`;
}

export function formatDurationMs(ms?: number | null): string {
  if (!ms && ms !== 0) return "n/a";
  if (ms < 1000) return `${ms}ms`;
  const sec = Math.round(ms / 1000);
  if (sec < 60) return `${sec}s`;
  const min = Math.round(sec / 60);
  if (min < 60) return `${min}m`;
  const hr = Math.round(min / 60);
  if (hr < 48) return `${hr}h`;
  const day = Math.round(hr / 24);
  return `${day}d`;
}

export function formatList(values?: Array<string | null | undefined>): string {
  if (!values || values.length === 0) return "none";
  return values.filter((v): v is string => Boolean(v && v.trim())).join(", ");
}

export function clampText(value: string, max = 120): string {
  if (value.length <= max) return value;
  return `${value.slice(0, Math.max(0, max - 1))}…`;
}

export function toNumber(value: string, fallback: number): number {
  const n = Number(value);
  return Number.isFinite(n) ? n : fallback;
}

export function parseList(input: string): string[] {
  return input
    .split(/[,\n]/)
    .map((v) => v.trim())
    .filter((v) => v.length > 0);
}



--- vendor/a2ui/README.md ---
# A2UI: Agent-to-User Interface

A2UI is an open-source project, complete with a format
optimized for representing updateable agent-generated
UIs and an initial set of renderers, that allows agents
to generate or populate rich user interfaces.

<img src="docs/assets/a2ui_gallery_examples.png" alt="Gallery of A2UI components" height="400">

*A gallery of A2UI rendered cards, showing a variety of UI compositions that A2UI can achieve.*

## ⚠️ Status: Early Stage Public Preview

> **Note:** A2UI is currently in **v0.8 (Public Preview)**. The specification and
implementations are functional but are still evolving. We are opening the project to
foster collaboration, gather feedback, and solicit contributions (e.g., on client renderers).
Expect changes.

## Summary

Generative AI excels at creating text and code, but agents can struggle to
present rich, interactive interfaces to users, especially when those agents
are remote or running across trust boundaries.

**A2UI** is an open standard and set of libraries that allows agents to
"speak UI." Agents send a declarative JSON format describing the *intent* of
the UI. The client application then renders this using its own native
component library (Flutter, Angular, Lit, etc.).

This approach ensures that agent-generated UIs are
**safe like data, but expressive like code**.

## High-Level Philosophy

A2UI was designed to address the specific challenges of interoperable,
cross-platform, generative or template-based UI responses from agents.

The project's core philosophies:

* **Security first**: Running arbitrary code generated by an LLM may present a
security risk. A2UI is a declarative data format, not executable
code. Your client application maintains a "catalog" of trusted, pre-approved
UI components (e.g., Card, Button, TextField), and the agent can only request
to render components from that catalog.
* **LLM-friendly and incrementally updateable**: The UI is represented as a flat
list of components with ID references which is easy for LLMs to generate
incrementally, allowing for progressive rendering and a responsive user
experience. An agent can efficiently make incremental changes to the UI based
on new user requests as the conversation progresses.
* **Framework-agnostic and portable**: A2UI separates the UI structure from
the UI implementation. The agent sends a description of the component tree
and its associated data model. Your client application is responsible for
mapping these abstract descriptions to its native widgets—be it web components,
Flutter widgets, React components, SwiftUI views or something else entirely.
The same A2UI JSON payload from an agent can be rendered on multiple different
clients built on top of different frameworks.
* **Flexibility**: A2UI also features an open registry pattern that allows
developers to map server-side types to custom client implementations, from
native mobile widgets to React components. By registering a "Smart Wrapper,"
you can connect any existing UI component—including secure iframe containers
for legacy content—to A2UI's data binding and event system.  Crucially, this
places security firmly in the developer's hands, enabling them to enforce
strict sandboxing policies and "trust ladders" directly within their custom
component logic rather than relying solely on the core system.

## Use Cases

Some of the use cases include:

* **Dynamic Data Collection:** An agent generates a bespoke form (date pickers,
sliders, inputs) based on the specific context of a conversation (e.g.,
booking a specialized reservation).
* **Remote Sub-Agents:** An orchestrator agent delegates a task to a
remote specialized agent (e.g., a travel booking agent) which returns a
UI payload to be rendered inside the main chat window.
* **Adaptive Workflows:** Enterprise agents that generate approval
dashboards or data visualizations on the fly based on the user's query.

## Architecture

The A2UI flow disconnects the generation of UI from the execution of UI:

1. **Generation:** An Agent (using Gemini or another LLM) generates or uses
a pre-generated `A2UI Response`, a JSON payload describing the composition
of UI components and their properties.
2. **Transport:** This message is sent to the client application
(via A2A, AG UI, etc.).
3. **Resolution:** The Client's **A2UI Renderer** parses the JSON.
4. **Rendering:** The Renderer maps the abstract components
(e.g., `type: 'text-field'`) to the concrete implementation in the client's codebase.

## Dependencies

A2UI is designed to be a lightweight format, but it fits into a larger ecosystem:

* **Transports:** Compatible with **A2A Protocol** and **AG UI**.
* **LLMs:** Can be generated by any model capable of generating JSON output.
* **Host Frameworks:** Requires a host application built in a supported framework
(currently: Web or Flutter).

## Getting Started

The best way to understand A2UI is to run the samples.

### Prerequisites

* Node.js (for web clients)
* Python (for agent samples)
* A valid [Gemini API Key](https://aistudio.google.com/) is required for the samples.

### Running the Restaurant Finder Demo

1. **Clone the repository:**

    ```bash
    git clone https://github.com/google/A2UI.git
    cd A2UI
    ```

2. **Set your API Key:**

    ```bash
    export GEMINI_API_KEY="your_gemini_api_key"
    ```

3. **Run the Agent (Backend):**

    ```bash
    cd samples/agent/adk/restaurant_finder
    uv run .
    ```

4. **Run the Client (Frontend):**
    Open a new terminal window:

    ```bash
    cd samples/client/lit/shell
    npm install
    npm run dev
    ```

For Flutter developers, check out the [GenUI SDK](https://github.com/flutter/genui),
which uses A2UI under the hood.

CopilotKit has a public [A2UI Widget Builder](https://go.copilotkit.ai/A2UI-widget-builder)
to try out as well.

## Roadmap

We hope to work with the community on the following:

* **Spec Stabilization:** Moving towards a v1.0 specification.
* **More Renderers:** Adding official support for React, Jetpack Compose, iOS (SwiftUI), and more.
* **Additional Transports:** Support for REST and more.
* **Additional Agent Frameworks:** Genkit, LangGraph, and more.

## Contribute

A2UI is an **Apache 2.0** licensed project. We believe the future of UI is agentic,
and we want to work with you to help build it.

See [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to get started.


## Links discovered
- [Gemini API Key](https://aistudio.google.com/)
- [GenUI SDK](https://github.com/flutter/genui)
- [A2UI Widget Builder](https://go.copilotkit.ai/A2UI-widget-builder)
- [CONTRIBUTING.md](https://github.com/clawdbot/clawdbot/blob/main/vendor/a2ui/CONTRIBUTING.md)

--- vendor/a2ui/renderers/angular/README.md ---
Angular implementation of A2UI.

Important: The sample code provided is for demonstration purposes and illustrates the mechanics of A2UI and the Agent-to-Agent (A2A) protocol. When building production applications, it is critical to treat any agent operating outside of your direct control as a potentially untrusted entity.

All operational data received from an external agent—including its AgentCard, messages, artifacts, and task statuses—should be handled as untrusted input. For example, a malicious agent could provide crafted data in its fields (e.g., name, skills.description) that, if used without sanitization to construct prompts for a Large Language Model (LLM), could expose your application to prompt injection attacks.

Similarly, any UI definition or data stream received must be treated as untrusted. Malicious agents could attempt to spoof legitimate interfaces to deceive users (phishing), inject malicious scripts via property values (XSS), or generate excessive layout complexity to degrade client performance (DoS). If your application supports optional embedded content (such as iframes or web views), additional care must be taken to prevent exposure to malicious external sites.

Developer Responsibility: Failure to properly validate data and strictly sandbox rendered content can introduce severe vulnerabilities. Developers are responsible for implementing appropriate security measures—such as input sanitization, Content Security Policies (CSP), strict isolation for optional embedded content, and secure credential handling—to protect their systems and users.

--- vendor/a2ui/renderers/lit/README.md ---
Lit implementation of A2UI.

Important: The sample code provided is for demonstration purposes and illustrates the mechanics of A2UI and the Agent-to-Agent (A2A) protocol. When building production applications, it is critical to treat any agent operating outside of your direct control as a potentially untrusted entity.

All operational data received from an external agent—including its AgentCard, messages, artifacts, and task statuses—should be handled as untrusted input. For example, a malicious agent could provide crafted data in its fields (e.g., name, skills.description) that, if used without sanitization to construct prompts for a Large Language Model (LLM), could expose your application to prompt injection attacks.

Similarly, any UI definition or data stream received must be treated as untrusted. Malicious agents could attempt to spoof legitimate interfaces to deceive users (phishing), inject malicious scripts via property values (XSS), or generate excessive layout complexity to degrade client performance (DoS). If your application supports optional embedded content (such as iframes or web views), additional care must be taken to prevent exposure to malicious external sites.

Developer Responsibility: Failure to properly validate data and strictly sandbox rendered content can introduce severe vulnerabilities. Developers are responsible for implementing appropriate security measures—such as input sanitization, Content Security Policies (CSP), strict isolation for optional embedded content, and secure credential handling—to protect their systems and users.

--- vendor/a2ui/specification/0.8/eval/README.md ---
# Genkit Eval Framework for UI generation

This is for evaluating A2UI (v0.8) against various LLMs.

## Setup

To use the models, you need to set the following environment variables with your API keys:

- `GEMINI_API_KEY`
- `OPENAI_API_KEY`
- `ANTHROPIC_API_KEY`

You can set these in a `.env` file in the root of the project, or in your shell's configuration file (e.g., `.bashrc`, `.zshrc`).

You also need to install dependencies before running:

```bash
pnpm install
```

## Running all evals (warning: can use *lots* of model quota)

To run the flow, use the following command:

```bash
pnpm run evalAll
```

## Running a Single Test

You can run the script for a single model and data point by using the `--model` and `--prompt` command-line flags. This is useful for quick tests and debugging.

### Syntax

```bash
pnpm run eval -- --model='<model_name>' --prompt=<prompt_name>
```

### Example

To run the test with the `gpt-5-mini (reasoning: minimal)` model and the `generateDogUIs` prompt, use the following command:

```bash
pnpm run eval -- --model='gpt-5-mini (reasoning: minimal)' --prompt=generateDogUIs
```

## Controlling Output

By default, the script only prints the summary table and any errors that occur during generation. To see the full JSON output for each successful generation, use the `--verbose` flag.

To keep the input and output for each run in separate files, specify the `--keep=<output_dir>` flag, which will create a directory hierarchy with the input and output for each LLM call in separate files.

### Example

```bash
pnpm run evalAll -- --verbose
```

```bash
pnpm run evalAll -- --keep=output
```


--- vendor/a2ui/specification/0.8/json/README.md ---
# A2UI JSON Schema Files

This directory contains the formal JSON Schema definitions for the A2UI protocol.

## Schema Descriptions

-   `server_to_client.json`: This is the core, catalog-agnostic schema for messages sent from the server to the client. It defines the four main message types (`beginRendering`, `surfaceUpdate`, `dataModelUpdate`, `deleteSurface`) and their structure. In this schema, the `component` object within a `surfaceUpdate` message is generic (`"additionalProperties": true`), allowing any component definitions to be passed.

-   `client_to_server.json`: This schema defines the structure for event messages sent from the client to the server. This includes user-initiated actions (`userAction`), error reporting (`error`), and the crucial `clientUiCapabilities` message, which allows a client to inform the server about the component catalog it supports.

-   `catalog_description_schema.json`: This is a meta-schema that defines the structure of an A2UI component catalog. A catalog consists of a `components` object and a `styles` object, where each key is a component/style name and the value is a JSON schema defining its properties. This allows for the creation of custom component sets.

-   `standard_catalog_definition.json`: This file is a concrete implementation of a catalog, conforming to the `catalog_description_schema.json`. It defines the standard set of components (e.g., `Text`, `Image`, `Row`, `Card`) and styles that are part of the baseline A2UI specification.

-   `server_to_client_with_standard_catalog.json`: This is a resolved, LLM-friendly version of the server-to-client schema. It is generated by combining `server_to_client.json` with the `standard_catalog_definition.json`. In this version, the generic `component` object is replaced with a strict `oneOf` definition that includes every component from the standard catalog. This provides a complete, strictly-typed schema that is ideal for LLMs to use for generating valid A2UI messages without ambiguity.


--- vendor/a2ui/specification/0.9/eval/README.md ---
# Genkit Eval Framework for UI generation

This is for evaluating A2UI (v0.9) against various LLMs.

This version embeds the JSON schemas directly into the prompt and instructs the LLM to output a JSON object within a markdown code block. The framework then extracts and validates this JSON.

## Setup

To use the models, you need to set the following environment variables with your API keys:

- `GEMINI_API_KEY`
- `OPENAI_API_KEY`
- `ANTHROPIC_API_KEY`

You can set these in a `.env` file in the root of the project, or in your shell's configuration file (e.g., `.bashrc`, `.zshrc`).

You also need to install dependencies before running:

```bash
pnpm install
```

## Running all evals (warning: can use _lots_ of model quota)

To run the flow, use the following command:

```bash
pnpm run evalAll
```

## Running a Single Test

You can run the script for a single model and data point by using the `--model` and `--prompt` command-line flags. This is useful for quick tests and debugging.

### Syntax

```bash
pnpm run eval --model=<model_name> --prompt=<prompt_name>
```

### Example

To run the test with the `gemini-2.5-flash-lite` model and the `loginForm` prompt, use the following command:

```bash
pnpm run eval --model=gemini-2.5-flash-lite --prompt=loginForm
```

## Controlling Output

By default, the script prints a progress bar and the final summary table to the console. Detailed logs are written to `output.log` in the results directory.

### Command-Line Options

- `--log-level=<level>`: Sets the console logging level (default: `info`). Options: `error`, `warn`, `info`, `http`, `verbose`, `debug`, `silly`.
  - Note: The file log (`output.log` in the results directory) always captures `debug` level logs regardless of this setting.
- `--results=<output_dir>`: (Default: `results/output-<model>` or `results/output-combined` if multiple models are specified) Preserves output files. To specify a custom directory, use `--results=my_results`.
- `--clean-results`: If set, cleans the results directory before running tests.
- `--runs-per-prompt=<number>`: Number of times to run each prompt (default: 1).
- `--model=<model_name>`: (Default: all models) Run only the specified model(s). Can be specified multiple times.
- `--prompt=<prompt_name>`: (Default: all prompts) Run only the specified prompt.

### Examples

Run with debug output in console:
```bash
pnpm run eval -- --log-level=debug
```

Run 5 times per prompt and clean previous results:
```bash
pnpm run eval -- --runs-per-prompt=5 --clean-results
```

## Rate Limiting

The framework includes a two-tiered rate limiting system:
1. **Proactive Limiting**: Locally tracks token and request usage to stay within configured limits (defined in `src/models.ts`).
2. **Reactive Circuit Breaker**: Automatically pauses requests to a model if a `RESOURCE_EXHAUSTED` (429) error is received, resuming only after the requested retry duration.


--- vendor/a2ui/.gemini/GEMINI.md ---
# A2UI Gemini Agent Guide

This document serves as a guide for using the Gemini agent within the A2UI repository. It outlines the repository's structure, explains the core concepts of the A2UI protocol, and provides instructions for running the various demos and keeping this guide up-to-date.

## Repository Structure

The A2UI repository is organized into several key directories:

-   `specification/0.8/docs/`: Contains the primary human-readable documentation for the A2UI protocol.
    -   `a2ui_protocol.md`: The foundational specification document. This is the best place to start to understand the protocol's fundamental goals.
-   `specification/0.8/json/`: Contains the formal JSON schema definitions for the protocol.
    -   `server_to_client.json`: Defines the schema for messages sent from the server to the client.
    -   `client_to_server.json`: Defines the schema for event messages sent from the client to the server.
-   `a2a_agents/python/`: Contains Python code relating to server-side integration of A2UI
    -   `a2ui_extension/`: Python implementation of the A2UI A2A extension.
    -   `adk/samples/`: Contains demo applications that showcase the A2UI protocol in action using the ADK framework.
-   `web/`: Contains the web-based client implementations (using Lit and Vite) for the samples, including a shared library (`renderers/lit`).
-   `angular/`: Contains an alternative web-based client implementation using Angular.
-   `eval/`: Contains a Genkit-based framework for evaluating LLM performance in generating A2UI responses.

## A2UI Specification Overview

The A2UI protocol is a JSONL-based, streaming UI protocol designed to be easily generated by Large Language Models (LLMs). It enables a server to stream a platform-agnostic, abstract UI definition to a client, which then renders it progressively using a native widget set.

### Core Concepts

The core concepts of the A2UI protocol are detailed in the main specification document. Rather than duplicating the content here, you should refer to the authoritative source:

-   **A2UI Protocol Specification**: `@docs/a2ui_protocol.md`

This document covers the design philosophy, architecture, data flow, and core concepts of the protocol.

### Schemas

The formal, machine-readable definitions of the protocol are maintained as JSON schemas:

-   **Server-to-Client Schema**: `@specification/0.8/json/server_to_client.json`
-   **Server-to-Client Schema, with standard catalog**: `@specification/0.8/json/server_to_client_with_standard_catalog.json`
-   **Client-to-Server Schema**: `@specification/0.8/json/client_to_server.json`

## Running the Demos

There are three demos available in the `a2a_samples/` directory. Each demo has a corresponding web client in the `web/` and `angular/` directories. To run a demo, you will need to start both the server and the client.

### Running a Demo Server

To run a demo server, navigate to the demo's directory and run the `__main__.py` script. For example, to run the contact lookup demo:

```bash
cd a2a_samples/a2ui_contact_lookup
python -m __main__
```

### Running a Demo Client (Lit)

To run a demo client, navigate to the corresponding client directory in `web/` and start the development server. For example, to run the contact lookup client:

```bash
cd web/contact
npm install
npm run dev
```

### Running a Demo Client (Angular)

To run a demo client, navigate to the `angular/` directory and start the development server with the project name. For example, to run the contact lookup client:

```bash
cd angular
npm install
npm start -- contact
```

## Renderers

There are three renderers available for A2UI:

-   **Web (Lit)**: Located in `renderers/lit`, this is the primary web renderer used by the demos in `web/`.
-   **Angular**: Located in `angular/projects/lib`, this is an alternative web renderer for Angular applications.
-   **Flutter**: The Flutter renderer is in a separate repository: [https://github.com/flutter/genui](https://github.com/flutter/genui)

## Keeping This Guide Updated

This document is intended to be a living guide for the repository. As the repository evolves, it's important to keep this file up-to-date. When making changes to the repository, please consider the following:

-   **New Demos or Clients**: If you add a new demo or client, add it to the "Running the Demos" section.
-   **Specification Changes**: If you make significant changes to the A2UI protocol, ensure that the "A2UI Specification Overview" section is updated to reflect the changes, and that any linked documents are also updated.
-   **Repository Structure Changes**: If you change the directory structure of the repository, update the "Repository Structure" section.

To get this file back in sync, you can run the following commands:

1. List all the files in the entire repo with `git ls-tree main --name-only -r`
2. Read the ~50 most important files in the list, potentially in batches.
3. Update this file.


## Links discovered
- [https://github.com/flutter/genui](https://github.com/flutter/genui)

--- vendor/a2ui/specification/0.8/eval/GEMINI.md ---
# A2UI Protocol Message Validation Logic

This document outlines the validation rules implemented in the `validateSchema` function. The purpose of this validator is to check for constraints that are not easily expressed in the JSON schema itself, such as conditional requirements and reference integrity.

An A2UI message is a JSON object that can have a `surfaceId` and one of the following properties, defining the message type: `beginRendering`, `surfaceUpdate`, `dataModelUpdate`, or `deleteSurface`.

## Common Properties

- **`surfaceId`**: An optional string that identifies the UI surface the message applies to.

## `BeginRendering` Message Rules

- **Required**: Must have a `root` property, which is the ID of the root component to render.

## `SurfaceUpdate` Message Rules

### 1. Component ID Integrity

- **Uniqueness**: All component `id`s within the `components` array must be unique.
- **Reference Validity**: Any property that references a component ID (e.g., `child`, `children`, `entryPointChild`, `contentChild`) must point to an ID that actually exists in the `components` array.

### 2. Component-Specific Property Rules

For each component in the `components` array, the following rules apply:

- **General**:
  - A component must have an `id` and a `componentProperties` object.
  - The `componentProperties` object must contain exactly one key, which defines the component's type (e.g., "Heading", "Text").

- **Heading**:
  - **Required**: Must have a `text` property.
- **Text**:
  - **Required**: Must have a `text` property.
- **Image**:
  - **Required**: Must have a `url` property.
- **Video**:
  - **Required**: Must have a `url` property.
- **AudioPlayer**:
  - **Required**: Must have a `url` property.
- **TextField**:
  - **Required**: Must have a `label` property.
- **DateTimeInput**:
  - **Required**: Must have a `value` property.
- **MultipleChoice**:
  - **Required**: Must have a `selections` property.
- **Slider**:
  - **Required**: Must have a `value` property.
- **Container Components** (`Row`, `Column`, `List`):
  - **Required**: Must have a `children` property.
  - The `children` object must contain _either_ `explicitList` _or_ `template`, but not both.
- **Card**:
  - **Required**: Must have a `child` property.
- **Tabs**:
  - **Required**: Must have a `tabItems` property, which must be an array.
  - Each item in `tabItems` must have a `title` and a `child`.
- **Modal**:
  - **Required**: Must have both `entryPointChild` and `contentChild` properties.
- **Button**:
  - **Required**: Must have `label` and `action` properties.
- **CheckBox**:
  - **Required**: Must have `label` and `value` properties.
- **Divider**:
  - No required properties.

## `DataModelUpdate` Message Rules

- **Required**: A `DataModelUpdate` message must have a `contents` property.
- The `path` property is optional.
- If `path` is not present, the `contents` object will replace the entire data model.
- If `path` is present, the `contents` will be set at that location in the data model.
- No other properties besides `path` and `contents` are allowed.

## `DeleteSurface` Message Rules

- **Required**: Must have a `delete` property set to `true`.
- No other properties are allowed.


--- vendor/a2ui/specification/0.8/eval/genkit.conf.js ---
/*
 Copyright 2025 Google LLC

 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at

      https://www.apache.org/licenses/LICENSE-2.0

 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */

import { googleAI } from "@genkit-ai/google-genai";
import { configure } from "genkit";

export default configure({
  plugins: [googleAI()],
  logLevel: "debug",
  enableTracingAndMetrics: true,
});


--- vendor/a2ui/specification/0.9/eval/genkit.conf.js ---
/*
 Copyright 2025 Google LLC

 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
 You may obtain a copy of the License at

      https://www.apache.org/licenses/LICENSE-2.0

 Unless required by applicable law or agreed to in writing, software
 distributed under the License is distributed on an "AS IS" BASIS,
 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */

import { googleAI } from "@genkit-ai/google-genai";
import { configure } from "genkit";

export default configure({
  plugins: [googleAI()],
  logLevel: "debug",
  enableTracingAndMetrics: true,
});
