---
summary: "All configuration options for ~/.clawdbot/clawdbot.json with examples"
read_when:
  - Adding or modifying config fields
---
# Configuration 🔧

CLAWDBOT reads an optional **JSON5** config from `~/.clawdbot/clawdbot.json` (comments + trailing commas allowed).

If the file is missing, CLAWDBOT uses safe-ish defaults (embedded Pi agent + per-sender sessions + workspace `~/clawd`). You usually only need a config to:
- restrict who can trigger the bot (`whatsapp.allowFrom`, `telegram.allowFrom`, etc.)
- control group allowlists + mention behavior (`whatsapp.groups`, `telegram.groups`, `discord.guilds`, `routing.groupChat`)
- customize message prefixes (`messages`)
- set the agent's workspace (`agent.workspace`)
- tune the embedded agent (`agent`) and session behavior (`session`)
- set the agent's identity (`identity`)

## Schema + UI hints

The Gateway exposes a JSON Schema representation of the config via `config.schema` for UI editors.
The Control UI renders a form from this schema, with a **Raw JSON** editor as an escape hatch.

Hints (labels, grouping, sensitive fields) ship alongside the schema so clients can render
better forms without hard-coding config knowledge.

## Minimal config (recommended starting point)

```json5
{
  agent: { workspace: "~/clawd" },
  whatsapp: { allowFrom: ["+15555550123"] }
}
```

Build the default image once with:
```bash
scripts/sandbox-setup.sh
```

## Self-chat mode (recommended for group control)

To prevent the bot from responding to WhatsApp @-mentions in groups (only respond to specific text triggers):

```json5
{
  agent: { workspace: "~/clawd" },
  whatsapp: {
    // Allowlist is DMs only; including your own number enables self-chat mode.
    allowFrom: ["+15555550123"],
    groups: { "*": { requireMention: true } }
  },
  routing: {
    groupChat: {
      mentionPatterns: ["@clawd", "reisponde"]
    }
  }
}
```

## Common options

### Env vars + `.env`

CLAWDBOT reads env vars from the parent process (shell, launchd/systemd, CI, etc.).

Additionally, it loads:
- `.env` from the current working directory (if present)
- a global fallback `.env` from `~/.clawdbot/.env` (aka `$CLAWDBOT_STATE_DIR/.env`)

Neither `.env` file overrides existing env vars.

### `env.shellEnv` (optional)

Opt-in convenience: if enabled and none of the expected keys are set yet, CLAWDBOT runs your login shell and imports only the missing expected keys (never overrides).
This effectively sources your shell profile.

```json5
{
  env: {
    shellEnv: {
      enabled: true,
      timeoutMs: 15000
    }
  }
}
```

Env var equivalent:
- `CLAWDBOT_LOAD_SHELL_ENV=1`
- `CLAWDBOT_SHELL_ENV_TIMEOUT_MS=15000`

### Auth storage (OAuth + API keys)

Clawdbot stores **per-agent** auth profiles (OAuth + API keys) in:
- `<agentDir>/auth-profiles.json` (default: `~/.clawdbot/agents/<agentId>/agent/auth-profiles.json`)

Legacy OAuth imports:
- `~/.clawdbot/credentials/oauth.json` (or `$CLAWDBOT_STATE_DIR/credentials/oauth.json`)

The embedded Pi agent maintains a runtime cache at:
- `<agentDir>/auth.json` (managed automatically; don’t edit manually)

Legacy agent dir (pre multi-agent):
- `~/.clawdbot/agent/*` (migrated by `clawdbot doctor` into `~/.clawdbot/agents/<defaultAgentId>/agent/*`)

Overrides:
- OAuth dir (legacy import only): `CLAWDBOT_OAUTH_DIR`
- Agent dir (default agent root override): `CLAWDBOT_AGENT_DIR` (preferred), `PI_CODING_AGENT_DIR` (legacy)

On first use, Clawdbot imports `oauth.json` entries into `auth-profiles.json`.

### `auth`

Optional metadata for auth profiles. This does **not** store secrets; it maps
profile IDs to a provider + mode (and optional email) and defines the provider
rotation order used for failover.

```json5
{
  auth: {
    profiles: {
      "anthropic:me@example.com": { provider: "anthropic", mode: "oauth", email: "me@example.com" },
      "anthropic:work": { provider: "anthropic", mode: "api_key" }
    },
    order: {
      anthropic: ["anthropic:me@example.com", "anthropic:work"]
    }
  }
}
```

### `identity`

Optional agent identity used for defaults and UX. This is written by the macOS onboarding assistant.

If set, CLAWDBOT derives defaults (only when you haven’t set them explicitly):
- `messages.ackReaction` from `identity.emoji` (falls back to 👀)
- `routing.groupChat.mentionPatterns` from `identity.name` (so “@Samantha” works in groups across Telegram/Slack/Discord/iMessage/WhatsApp)

```json5
{
  identity: { name: "Samantha", theme: "helpful sloth", emoji: "🦥" }
}
```

### `wizard`

Metadata written by CLI wizards (`onboard`, `configure`, `doctor`, `update`).

```json5
{
  wizard: {
    lastRunAt: "2026-01-01T00:00:00.000Z",
    lastRunVersion: "2026.1.4",
    lastRunCommit: "abc1234",
    lastRunCommand: "configure",
    lastRunMode: "local"
  }
}
```

### `logging`

- Default log file: `/tmp/clawdbot/clawdbot-YYYY-MM-DD.log`
- If you want a stable path, set `logging.file` to `/tmp/clawdbot/clawdbot.log`.
- Console output can be tuned separately via:
  - `logging.consoleLevel` (defaults to `info`, bumps to `debug` when `--verbose`)
  - `logging.consoleStyle` (`pretty` | `compact` | `json`)
- Tool summaries can be redacted to avoid leaking secrets:
  - `logging.redactSensitive` (`off` | `tools`, default: `tools`)
  - `logging.redactPatterns` (array of regex strings; overrides defaults)

```json5
{
  logging: {
    level: "info",
    file: "/tmp/clawdbot/clawdbot.log",
    consoleLevel: "info",
    consoleStyle: "pretty",
    redactSensitive: "tools",
    redactPatterns: [
      // Example: override defaults with your own rules.
      "\\bTOKEN\\b\\s*[=:]\\s*([\"']?)([^\\s\"']+)\\1",
      "/\\bsk-[A-Za-z0-9_-]{8,}\\b/gi"
    ]
  }
}
```

### `whatsapp.dmPolicy`

Controls how WhatsApp direct chats (DMs) are handled:
- `"pairing"` (default): unknown senders get a pairing code; owner must approve
- `"allowlist"`: only allow senders in `whatsapp.allowFrom` (or paired allow store)
- `"open"`: allow all inbound DMs (**requires** `whatsapp.allowFrom` to include `"*"`)
- `"disabled"`: ignore all inbound DMs

Pairing codes expire after 1 hour; the bot only sends a pairing code when a new request is created.

Pairing approvals:
- `clawdbot pairing list --provider whatsapp`
- `clawdbot pairing approve --provider whatsapp <code>`

### `whatsapp.allowFrom`

Allowlist of E.164 phone numbers that may trigger WhatsApp auto-replies (**DMs only**).
If empty and `whatsapp.dmPolicy="pairing"`, unknown senders will receive a pairing code.
For groups, use `whatsapp.groupPolicy` + `whatsapp.groupAllowFrom`.

```json5
{
  whatsapp: {
    dmPolicy: "pairing", // pairing | allowlist | open | disabled
    allowFrom: ["+15555550123", "+447700900123"],
    textChunkLimit: 4000 // optional outbound chunk size (chars)
  }
}
```

### `whatsapp.accounts` (multi-account)

Run multiple WhatsApp accounts in one gateway:

```json5
{
  whatsapp: {
    accounts: {
      default: {}, // optional; keeps the default id stable
      personal: {},
      biz: {
        // Optional override. Default: ~/.clawdbot/credentials/whatsapp/biz
        // authDir: "~/.clawdbot/credentials/whatsapp/biz",
      }
    }
  }
}
```

Notes:
- Outbound commands default to account `default` if present; otherwise the first configured account id (sorted).
- The legacy single-account Baileys auth dir is migrated by `clawdbot doctor` into `whatsapp/default`.

### `routing.groupChat`

Group messages default to **require mention** (either metadata mention or regex patterns). Applies to WhatsApp, Telegram, Discord, and iMessage group chats.

**Mention types:**
- **Metadata mentions**: Native platform @-mentions (e.g., WhatsApp tap-to-mention). Ignored in WhatsApp self-chat mode (see `whatsapp.allowFrom`).
- **Text patterns**: Regex patterns defined in `mentionPatterns`. Always checked regardless of self-chat mode.
- Mention gating is enforced only when mention detection is possible (native mentions or at least one `mentionPattern`).

```json5
{
  routing: {
    groupChat: {
      mentionPatterns: ["@clawd", "clawdbot", "clawd"],
      historyLimit: 50
    }
  }
}
```

Mention gating defaults live per provider (`whatsapp.groups`, `telegram.groups`, `imessage.groups`, `discord.guilds`). When `*.groups` is set, it also acts as a group allowlist; include `"*"` to allow all groups.

To respond **only** to specific text triggers (ignoring native @-mentions):
```json5
{
  whatsapp: {
    // Include your own number to enable self-chat mode (ignore native @-mentions).
    allowFrom: ["+15555550123"],
    groups: { "*": { requireMention: true } }
  },
  routing: {
    groupChat: {
      // Only these text patterns will trigger responses
      mentionPatterns: ["reisponde", "@clawd"]
    }
  }
}
```

### Group policy (per provider)

Use `*.groupPolicy` to control whether group/room messages are accepted at all:

```json5
{
  whatsapp: {
    groupPolicy: "allowlist",
    groupAllowFrom: ["+15551234567"]
  },
  telegram: {
    groupPolicy: "allowlist",
    groupAllowFrom: ["tg:123456789", "@alice"]
  },
  signal: {
    groupPolicy: "allowlist",
    groupAllowFrom: ["+15551234567"]
  },
  imessage: {
    groupPolicy: "allowlist",
    groupAllowFrom: ["chat_id:123"]
  },
  discord: {
    groupPolicy: "allowlist",
    guilds: {
      "GUILD_ID": {
        channels: { help: { allow: true } }
      }
    }
  },
  slack: {
    groupPolicy: "allowlist",
    channels: { "#general": { allow: true } }
  }
}
```

Notes:
- `"open"` (default): groups bypass allowlists; mention-gating still applies.
- `"disabled"`: block all group/room messages.
- `"allowlist"`: only allow groups/rooms that match the configured allowlist.
- WhatsApp/Telegram/Signal/iMessage use `groupAllowFrom` (fallback: explicit `allowFrom`).
- Discord/Slack use channel allowlists (`discord.guilds.*.channels`, `slack.channels`).
- Group DMs (Discord/Slack) are still controlled by `dm.groupEnabled` + `dm.groupChannels`.

### Multi-agent routing (`routing.agents` + `routing.bindings`)

Run multiple isolated agents (separate workspace, `agentDir`, sessions) inside one Gateway. Inbound messages are routed to an agent via bindings.

- `routing.defaultAgentId`: fallback when no binding matches (default: `main`).
- `routing.agents.<agentId>`: per-agent overrides.
  - `name`: display name for the agent.
  - `workspace`: default `~/clawd-<agentId>` (for `main`, falls back to legacy `agent.workspace`).
  - `agentDir`: default `~/.clawdbot/agents/<agentId>/agent`.
  - `model`: per-agent default model (provider/model), overrides `agent.model` for that agent.
  - `sandbox`: per-agent sandbox config (overrides `agent.sandbox`).
    - `mode`: `"off"` | `"non-main"` | `"all"`
    - `scope`: `"session"` | `"agent"` | `"shared"`
    - `workspaceRoot`: custom sandbox workspace root
  - `tools`: per-agent tool restrictions (applied before sandbox tool policy).
    - `allow`: array of allowed tool names
    - `deny`: array of denied tool names (deny wins)
- `routing.bindings[]`: routes inbound messages to an `agentId`.
  - `match.provider` (required)
  - `match.accountId` (optional; `*` = any account; omitted = default account)
  - `match.peer` (optional; `{ kind: dm|group|channel, id }`)
  - `match.guildId` / `match.teamId` (optional; provider-specific)

Deterministic match order:
1) `match.peer`
2) `match.guildId`
3) `match.teamId`
4) `match.accountId` (exact, no peer/guild/team)
5) `match.accountId: "*"` (provider-wide, no peer/guild/team)
6) `routing.defaultAgentId`

Within each match tier, the first matching entry in `routing.bindings` wins.

Example: two WhatsApp accounts → two agents:

```json5
{
  routing: {
    defaultAgentId: "home",
    agents: {
      home: { workspace: "~/clawd-home" },
      work: { workspace: "~/clawd-work" },
    },
    bindings: [
      { agentId: "home", match: { provider: "whatsapp", accountId: "personal" } },
      { agentId: "work", match: { provider: "whatsapp", accountId: "biz" } },
    ],
  },
  whatsapp: {
    accounts: {
      personal: {},
      biz: {},
    }
  }
}
```

### `routing.agentToAgent` (optional)

Agent-to-agent messaging is opt-in:

```json5
{
  routing: {
    agentToAgent: {
      enabled: false,
      allow: ["home", "work"]
    }
  }
}
```

### `routing.queue`

Controls how inbound messages behave when an agent run is already active.

```json5
{
  routing: {
    queue: {
      mode: "collect", // steer | followup | collect | steer-backlog (steer+backlog ok) | interrupt (queue=steer legacy)
      debounceMs: 1000,
      cap: 20,
      drop: "summarize", // old | new | summarize
      byProvider: {
        whatsapp: "collect",
        telegram: "collect",
        discord: "collect",
        imessage: "collect",
        webchat: "collect"
      }
    }
  }
}
```

### `commands` (chat command handling)

Controls how chat commands are enabled across connectors.

```json5
{
  commands: {
    native: false,          // register native commands when supported
    text: true,             // parse slash commands in chat messages
    useAccessGroups: true   // enforce access-group allowlists/policies for commands
  }
}
```

Notes:
- Text commands must be sent as a **standalone** message and use the leading `/` (no plain-text aliases).
- `commands.text: false` disables parsing chat messages for commands.
- `commands.native: true` registers native commands on supported connectors (Discord/Slack/Telegram). Platforms without native commands still rely on text commands.
- `commands.native: false` skips native registration; Discord/Telegram clear previously registered commands on startup. Slack commands are managed in the Slack app.
- `commands.useAccessGroups: false` allows commands to bypass access-group allowlists/policies.

### `web` (WhatsApp web provider)

WhatsApp runs through the gateway’s web provider. It starts automatically when a linked session exists.
Set `web.enabled: false` to keep it off by default.

```json5
{
  web: {
    enabled: true,
    heartbeatSeconds: 60,
    reconnect: {
      initialMs: 2000,
      maxMs: 120000,
      factor: 1.4,
      jitter: 0.2,
      maxAttempts: 0
    }
  }
}
```

### `telegram` (bot transport)

Clawdbot starts Telegram only when a `telegram` config section exists. The bot token is resolved from `TELEGRAM_BOT_TOKEN` or `telegram.botToken`.
Set `telegram.enabled: false` to disable automatic startup.

```json5
{
  telegram: {
    enabled: true,
    botToken: "your-bot-token",
    dmPolicy: "pairing",                 // pairing | allowlist | open | disabled
    allowFrom: ["tg:123456789"],         // optional; "open" requires ["*"]
    groups: {
      "*": { requireMention: true },
      "-1001234567890": {
        allowFrom: ["@admin"],
        systemPrompt: "Keep answers brief.",
        topics: {
          "99": {
            requireMention: false,
            skills: ["search"],
            systemPrompt: "Stay on topic."
          }
        }
      }
    },
    streamMode: "partial",               // off | partial | block (draft streaming)
    actions: { reactions: true },        // tool action gates (false disables)
    mediaMaxMb: 5,
    proxy: "socks5://localhost:9050",
    webhookUrl: "https://example.com/telegram-webhook",
    webhookSecret: "secret",
    webhookPath: "/telegram-webhook"
  }
}
```

Draft streaming notes:
- Uses Telegram `sendMessageDraft` (draft bubble, not a real message).
- Requires **private chat topics** (message_thread_id in DMs; bot has topics enabled).
- `/reasoning stream` streams reasoning into the draft, then sends the final answer.

### `discord` (bot transport)

Configure the Discord bot by setting the bot token and optional gating:

```json5
{
  discord: {
    enabled: true,
    token: "your-bot-token",
    mediaMaxMb: 8,                          // clamp inbound media size
    actions: {                              // tool action gates (false disables)
      reactions: true,
      stickers: true,
      polls: true,
      permissions: true,
      messages: true,
      threads: true,
      pins: true,
      search: true,
      memberInfo: true,
      roleInfo: true,
      roles: false,
      channelInfo: true,
      voiceStatus: true,
      events: true,
      moderation: false
    },
    replyToMode: "off",                     // off | first | all
    dm: {
      enabled: true,                        // disable all DMs when false
      policy: "pairing",                    // pairing | allowlist | open | disabled
      allowFrom: ["1234567890", "steipete"], // optional DM allowlist ("open" requires ["*"])
      groupEnabled: false,                 // enable group DMs
      groupChannels: ["clawd-dm"]          // optional group DM allowlist
    },
    guilds: {
      "123456789012345678": {               // guild id (preferred) or slug
        slug: "friends-of-clawd",
        requireMention: false,              // per-guild default
        reactionNotifications: "own",       // off | own | all | allowlist
        users: ["987654321098765432"],      // optional per-guild user allowlist
        channels: {
          general: { allow: true },
          help: {
            allow: true,
            requireMention: true,
            users: ["987654321098765432"],
            skills: ["docs"],
            systemPrompt: "Short answers only."
          }
        }
      }
    },
    historyLimit: 20                        // include last N guild messages as context
  }
}
```

Clawdbot starts Discord only when a `discord` config section exists. The token is resolved from `DISCORD_BOT_TOKEN` or `discord.token` (unless `discord.enabled` is `false`). Use `user:<id>` (DM) or `channel:<id>` (guild channel) when specifying delivery targets for cron/CLI commands.
Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged channel name (no leading `#`). Prefer guild ids as keys to avoid rename ambiguity.
Reaction notification modes:
- `off`: no reaction events.
- `own`: reactions on the bot's own messages (default).
- `all`: all reactions on all messages.
- `allowlist`: reactions from `guilds.<id>.users` on all messages (empty list disables).

### `slack` (socket mode)

Slack runs in Socket Mode and requires both a bot token and app token:

```json5
{
  slack: {
    enabled: true,
    botToken: "xoxb-...",
    appToken: "xapp-...",
    dm: {
      enabled: true,
      policy: "pairing", // pairing | allowlist | open | disabled
      allowFrom: ["U123", "U456", "*"], // optional; "open" requires ["*"]
      groupEnabled: false,
      groupChannels: ["G123"]
    },
    channels: {
      C123: { allow: true, requireMention: true },
      "#general": {
        allow: true,
        requireMention: true,
        users: ["U123"],
        skills: ["docs"],
        systemPrompt: "Short answers only."
      }
    },
    reactionNotifications: "own", // off | own | all | allowlist
    reactionAllowlist: ["U123"],
    actions: {
      reactions: true,
      messages: true,
      pins: true,
      memberInfo: true,
      emojiList: true
    },
    slashCommand: {
      enabled: true,
      name: "clawd",
      sessionPrefix: "slack:slash",
      ephemeral: true
    },
    textChunkLimit: 4000,
    mediaMaxMb: 20
  }
}
```

Clawdbot starts Slack when the provider is enabled and both tokens are set (via config or `SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN`). Use `user:<id>` (DM) or `channel:<id>` when specifying delivery targets for cron/CLI commands.

Reaction notification modes:
- `off`: no reaction events.
- `own`: reactions on the bot's own messages (default).
- `all`: all reactions on all messages.
- `allowlist`: reactions from `slack.reactionAllowlist` on all messages (empty list disables).

Slack action groups (gate `slack` tool actions):
| Action group | Default | Notes |
| --- | --- | --- |
| reactions | enabled | React + list reactions |
| messages | enabled | Read/send/edit/delete |
| pins | enabled | Pin/unpin/list |
| memberInfo | enabled | Member info |
| emojiList | enabled | Custom emoji list |
### `imessage` (imsg CLI)

Clawdbot spawns `imsg rpc` (JSON-RPC over stdio). No daemon or port required.

```json5
{
  imessage: {
    enabled: true,
    cliPath: "imsg",
    dbPath: "~/Library/Messages/chat.db",
    dmPolicy: "pairing", // pairing | allowlist | open | disabled
    allowFrom: ["+15555550123", "user@example.com", "chat_id:123"],
    includeAttachments: false,
    mediaMaxMb: 16,
    service: "auto",
    region: "US"
  }
}
```

Notes:
- Requires Full Disk Access to the Messages DB.
- The first send will prompt for Messages automation permission.
- Prefer `chat_id:<id>` targets. Use `imsg chats --limit 20` to list chats.

### `agent.workspace`

Sets the **single global workspace directory** used by the agent for file operations.

Default: `~/clawd`.

```json5
{
  agent: { workspace: "~/clawd" }
}
```

If `agent.sandbox` is enabled, non-main sessions can override this with their
own per-scope workspaces under `agent.sandbox.workspaceRoot`.

### `agent.skipBootstrap`

Disables automatic creation of the workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, and `BOOTSTRAP.md`).

Use this for pre-seeded deployments where your workspace files come from a repo.

```json5
{
  agent: { skipBootstrap: true }
}
```

### `agent.userTimezone`

Sets the user’s timezone for **system prompt context** (not for timestamps in
message envelopes). If unset, Clawdbot uses the host timezone at runtime.

```json5
{
  agent: { userTimezone: "America/Chicago" }
}
```

### `messages`

Controls inbound/outbound prefixes and optional ack reactions.

```json5
{
  messages: {
    messagePrefix: "[clawdbot]",
    responsePrefix: "🦞",
    ackReaction: "👀",
    ackReactionScope: "group-mentions"
  }
}
```

`responsePrefix` is applied to **all outbound replies** (tool summaries, block
streaming, final replies) across providers unless already present.

`ackReaction` sends a best-effort emoji reaction to acknowledge inbound messages
on providers that support reactions (Slack/Discord/Telegram). Defaults to the
configured `identity.emoji` when set, otherwise `"👀"`. Set it to `""` to disable.

`ackReactionScope` controls when reactions fire:
- `group-mentions` (default): only when a group/room requires mentions **and** the bot was mentioned
- `group-all`: all group/room messages
- `direct`: direct messages only
- `all`: all messages

### `talk`

Defaults for Talk mode (macOS/iOS/Android). Voice IDs fall back to `ELEVENLABS_VOICE_ID` or `SAG_VOICE_ID` when unset.
`apiKey` falls back to `ELEVENLABS_API_KEY` (or the gateway’s shell profile) when unset.
`voiceAliases` lets Talk directives use friendly names (e.g. `"voice":"Clawd"`).

```json5
{
  talk: {
    voiceId: "elevenlabs_voice_id",
    voiceAliases: {
      Clawd: "EXAVITQu4vr4xnSDxMaL",
      Roger: "CwhRBWXzGAHq8TQ4Fs17"
    },
    modelId: "eleven_v3",
    outputFormat: "mp3_44100_128",
    apiKey: "elevenlabs_api_key",
    interruptOnSpeech: true
  }
}
```

### `agent`

Controls the embedded agent runtime (model/thinking/verbose/timeouts).
`agent.models` defines the configured model catalog (and acts as the allowlist for `/model`).
`agent.model.primary` sets the default model; `agent.model.fallbacks` are global failovers.
`agent.imageModel` is optional and is **only used if the primary model lacks image input**.

Clawdbot also ships a few built-in alias shorthands. Defaults only apply when the model
is already present in `agent.models`:

- `opus` -> `anthropic/claude-opus-4-5`
- `sonnet` -> `anthropic/claude-sonnet-4-5`
- `gpt` -> `openai/gpt-5.2`
- `gpt-mini` -> `openai/gpt-5-mini`
- `gemini` -> `google/gemini-3-pro-preview`
- `gemini-flash` -> `google/gemini-3-flash-preview`

If you configure the same alias name (case-insensitive) yourself, your value wins (defaults never override).

```json5
{
  agent: {
    models: {
      "anthropic/claude-opus-4-5": { alias: "Opus" },
      "anthropic/claude-sonnet-4-1": { alias: "Sonnet" },
      "openrouter/deepseek/deepseek-r1:free": {}
    },
    model: {
      primary: "anthropic/claude-opus-4-5",
      fallbacks: [
        "openrouter/deepseek/deepseek-r1:free",
        "openrouter/meta-llama/llama-3.3-70b-instruct:free"
      ]
    },
    imageModel: {
      primary: "openrouter/qwen/qwen-2.5-vl-72b-instruct:free",
      fallbacks: [
        "openrouter/google/gemini-2.0-flash-vision:free"
      ]
    },
    thinkingDefault: "low",
    verboseDefault: "off",
    elevatedDefault: "on",
    timeoutSeconds: 600,
    mediaMaxMb: 5,
    heartbeat: {
      every: "30m",
      target: "last"
    },
    maxConcurrent: 3,
    subagents: {
      maxConcurrent: 1,
      archiveAfterMinutes: 60
    },
    bash: {
      backgroundMs: 10000,
      timeoutSec: 1800,
      cleanupMs: 1800000
    },
    contextTokens: 200000
  }
}
```

Block streaming:
- `agent.blockStreamingDefault`: `"on"`/`"off"` (default on).
- `agent.blockStreamingBreak`: `"text_end"` or `"message_end"` (default: text_end).
- `agent.blockStreamingChunk`: soft chunking for streamed blocks. Defaults to
  800–1200 chars, prefers paragraph breaks (`\n\n`), then newlines, then sentences.
  Example:
  ```json5
  {
    agent: {
      blockStreamingChunk: { minChars: 800, maxChars: 1200 }
    }
  }
  ```

`agent.model.primary` should be set as `provider/model` (e.g. `anthropic/claude-opus-4-5`).
Aliases come from `agent.models.*.alias` (e.g. `Opus`).
If you omit the provider, CLAWDBOT currently assumes `anthropic` as a temporary
deprecation fallback.
Z.AI models are available as `zai/<model>` (e.g. `zai/glm-4.7`) and require
`ZAI_API_KEY` (or legacy `Z_AI_API_KEY`) in the environment.

`agent.heartbeat` configures periodic heartbeat runs:
- `every`: duration string (`ms`, `s`, `m`, `h`); default unit minutes. Default:
  `30m`. Set `0m` to disable.
- `model`: optional override model for heartbeat runs (`provider/model`).
- `target`: optional delivery provider (`last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `none`). Default: `last`.
- `to`: optional recipient override (provider-specific id, e.g. E.164 for WhatsApp, chat id for Telegram).
- `prompt`: optional override for the heartbeat body (default: `Read HEARTBEAT.md if exists. Consider outstanding tasks. Checkup sometimes on your human during (user local) day time.`). Overrides are sent verbatim; include a `Read HEARTBEAT.md if exists` line if you still want the file read.
- `ackMaxChars`: max chars allowed after `HEARTBEAT_OK` before delivery (default: 30).

Heartbeats run full agent turns. Shorter intervals burn more tokens; be mindful
of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.

`agent.bash` configures background bash defaults:
- `backgroundMs`: time before auto-background (ms, default 10000)
- `timeoutSec`: auto-kill after this runtime (seconds, default 1800)
- `cleanupMs`: how long to keep finished sessions in memory (ms, default 1800000)

`agent.subagents` configures sub-agent defaults:
- `maxConcurrent`: max concurrent sub-agent runs (default 1)
- `archiveAfterMinutes`: auto-archive sub-agent sessions after N minutes (default 60; set `0` to disable)
- `tools.allow` / `tools.deny`: per-subagent tool allow/deny policy (deny wins)

`agent.tools` configures a global tool allow/deny policy (deny wins).
This is applied even when the Docker sandbox is **off**.

Example (disable browser/canvas everywhere):
```json5
{
  agent: {
    tools: {
      deny: ["browser", "canvas"]
    }
  }
}
```

`agent.elevated` controls elevated (host) bash access:
- `enabled`: allow elevated mode (default true)
- `allowFrom`: per-provider allowlists (empty = disabled)
  - `whatsapp`: E.164 numbers
  - `telegram`: chat ids or usernames
  - `discord`: user ids or usernames (falls back to `discord.dm.allowFrom` if omitted)
  - `signal`: E.164 numbers
  - `imessage`: handles/chat ids
  - `webchat`: session ids or usernames

Example:
```json5
{
  agent: {
    elevated: {
      enabled: true,
      allowFrom: {
        whatsapp: ["+15555550123"],
        discord: ["steipete", "1234567890123"]
      }
    }
  }
}
```

`agent.maxConcurrent` sets the maximum number of embedded agent runs that can
execute in parallel across sessions. Each session is still serialized (one run
per session key at a time). Default: 1.

### `agent.sandbox`

Optional **Docker sandboxing** for the embedded agent. Intended for non-main
sessions so they cannot access your host system.

Defaults (if enabled):
- scope: `"agent"` (one container + workspace per agent)
- Debian bookworm-slim based image
- agent workspace access: `workspaceAccess: "none"` (default)
  - `"none"`: use a per-scope sandbox workspace under `~/.clawdbot/sandboxes`
  - `"ro"`: keep the sandbox workspace at `/workspace`, and mount the agent workspace read-only at `/agent` (disables `write`/`edit`)
  - `"rw"`: mount the agent workspace read/write at `/workspace`
- auto-prune: idle > 24h OR age > 7d
- tools: allow only `bash`, `process`, `read`, `write`, `edit`, `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn` (deny wins)
- optional sandboxed browser (Chromium + CDP, noVNC observer)
- hardening knobs: `network`, `user`, `pidsLimit`, `memory`, `cpus`, `ulimits`, `seccompProfile`, `apparmorProfile`

Warning: `scope: "shared"` means a shared container and shared workspace. No
cross-session isolation. Use `scope: "session"` for per-session isolation.

Legacy: `perSession` is still supported (`true` → `scope: "session"`,
`false` → `scope: "shared"`).

```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",
        containerPrefix: "clawdbot-sbx-",
        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"]
      },
      browser: {
        enabled: false,
        image: "clawdbot-sandbox-browser:bookworm-slim",
        containerPrefix: "clawdbot-sbx-browser-",
        cdpPort: 9222,
        vncPort: 5900,
        noVncPort: 6080,
        headless: false,
        enableNoVnc: true
      },
      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
      }
    }
  }
}
```

Build the default sandbox image once with:
```bash
scripts/sandbox-setup.sh
```

Note: sandbox containers default to `network: "none"`; set `agent.sandbox.docker.network`
to `"bridge"` (or your custom network) if the agent needs outbound access.

Note: inbound attachments are staged into the active workspace at `media/inbound/*`. With `workspaceAccess: "rw"`, that means files are written into the agent workspace.

Build the optional browser image with:
```bash
scripts/sandbox-browser-setup.sh
```

When `agent.sandbox.browser.enabled=true`, the browser tool uses a sandboxed
Chromium instance (CDP). If noVNC is enabled (default when headless=false),
the noVNC URL is injected into the system prompt so the agent can reference it.
This does not require `browser.enabled` in the main config; the sandbox control
URL is injected per session.

### `models` (custom providers + base URLs)

Clawdbot uses the **pi-coding-agent** model catalog. You can add custom providers
(LiteLLM, local OpenAI-compatible servers, Anthropic proxies, etc.) by writing
`~/.clawdbot/agents/<agentId>/agent/models.json` or by defining the same schema inside your
Clawdbot config under `models.providers`.

When `models.providers` is present, Clawdbot writes/merges a `models.json` into
`~/.clawdbot/agents/<agentId>/agent/` on startup:
- default behavior: **merge** (keeps existing providers, overrides on name)
- set `models.mode: "replace"` to overwrite the file contents

Select the model via `agent.model.primary` (provider/model).

```json5
{
  agent: {
    model: { primary: "custom-proxy/llama-3.1-8b" },
    models: {
      "custom-proxy/llama-3.1-8b": {}
    }
  },
  models: {
    mode: "merge",
    providers: {
      "custom-proxy": {
        baseUrl: "http://localhost:4000/v1",
        apiKey: "LITELLM_KEY",
        api: "openai-completions",
        models: [
          {
            id: "llama-3.1-8b",
            name: "Llama 3.1 8B",
            reasoning: false,
            input: ["text"],
            cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
            contextWindow: 128000,
            maxTokens: 32000
          }
        ]
      }
    }
  }
}
```

### Z.AI (GLM-4.7) — provider alias support

Z.AI models are available via the built-in `zai` provider. Set `ZAI_API_KEY`
in your environment and reference the model by provider/model.

```json5
{
  agent: {
    model: "zai/glm-4.7",
    allowedModels: ["zai/glm-4.7"]
  }
}
```

Notes:
- `z.ai/*` and `z-ai/*` are accepted aliases and normalize to `zai/*`.
- If `ZAI_API_KEY` is missing, requests to `zai/*` will fail with an auth error at runtime.
- Example error: `No API key found for provider "zai".`
- Z.AI’s general API endpoint is `https://api.z.ai/api/paas/v4`. The GLM Coding
  Plan uses the dedicated Coding endpoint `https://api.z.ai/api/coding/paas/v4`.
  The built-in `zai` provider uses the Coding endpoint. If you need the general
  endpoint, define a custom provider in `models.providers` with the base URL
  override (see the custom providers section above).
- Use a fake placeholder in docs/configs; never commit real API keys.

### Local models (LM Studio) — recommended setup

Best current local setup (what we’re running): **MiniMax M2.1** on a beefy Mac Studio
via **LM Studio** using the **Responses API**.

```json5
{
  agent: {
    model: { primary: "lmstudio/minimax-m2.1-gs32" },
    models: {
      "anthropic/claude-opus-4-5": { alias: "Opus" },
      "lmstudio/minimax-m2.1-gs32": { alias: "Minimax" }
    }
  },
  models: {
    mode: "merge",
    providers: {
      lmstudio: {
        baseUrl: "http://127.0.0.1:1234/v1",
        apiKey: "lmstudio",
        api: "openai-responses",
        models: [
          {
            id: "minimax-m2.1-gs32",
            name: "MiniMax M2.1 GS32",
            reasoning: false,
            input: ["text"],
            cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
            contextWindow: 196608,
            maxTokens: 8192
          }
        ]
      }
    }
  }
}
```

Notes:
- LM Studio must have the model loaded and the local server enabled (default URL above).
- Responses API enables clean reasoning/output separation; WhatsApp sees only final text.
- Adjust `contextWindow`/`maxTokens` if your LM Studio context length differs.

Notes:
- Supported APIs: `openai-completions`, `openai-responses`, `anthropic-messages`,
  `google-generative-ai`
- Use `authHeader: true` + `headers` for custom auth needs.
- Override the agent config root with `CLAWDBOT_AGENT_DIR` (or `PI_CODING_AGENT_DIR`)
  if you want `models.json` stored elsewhere (default: `~/.clawdbot/agents/main/agent`).

### `session`

Controls session scoping, idle expiry, reset triggers, and where the session store is written.

```json5
{
  session: {
    scope: "per-sender",
    idleMinutes: 60,
    resetTriggers: ["/new", "/reset"],
    // Default is already per-agent under ~/.clawdbot/agents/<agentId>/sessions/sessions.json
    // You can override with {agentId} templating:
    store: "~/.clawdbot/agents/{agentId}/sessions/sessions.json",
    // Direct chats collapse to agent:<agentId>:<mainKey> (default: "main").
    mainKey: "main",
    agentToAgent: {
      // Max ping-pong reply turns between requester/target (0–5).
      maxPingPongTurns: 5
    },
    sendPolicy: {
      rules: [
        { action: "deny", match: { provider: "discord", chatType: "group" } }
      ],
      default: "allow"
    }
  }
}
```

Fields:
- `mainKey`: direct-chat bucket key (default: `"main"`). Useful when you want to “rename” the primary DM thread without changing `agentId`.
- `agentToAgent.maxPingPongTurns`: max reply-back turns between requester/target (0–5, default 5).
- `sendPolicy.default`: `allow` or `deny` fallback when no rule matches.
- `sendPolicy.rules[]`: match by `provider`, `chatType` (`direct|group|room`), or `keyPrefix` (e.g. `cron:`). First deny wins; otherwise allow.

### `skills` (skills config)

Controls bundled allowlist, install preferences, extra skill folders, and per-skill
overrides. Applies to **bundled** skills and `~/.clawdbot/skills` (workspace skills
still win on name conflicts).

Fields:
- `allowBundled`: optional allowlist for **bundled** skills only. If set, only those
  bundled skills are eligible (managed/workspace skills unaffected).
- `load.extraDirs`: additional skill directories to scan (lowest precedence).
- `install.preferBrew`: prefer brew installers when available (default: true).
- `install.nodeManager`: node installer preference (`npm` | `pnpm` | `yarn`, default: npm).
- `entries.<skillKey>`: per-skill config overrides.

Per-skill fields:
- `enabled`: set `false` to disable a skill even if it’s bundled/installed.
- `env`: environment variables injected for the agent run (only if not already set).
- `apiKey`: optional convenience for skills that declare a primary env var (e.g. `nano-banana-pro` → `GEMINI_API_KEY`).

Example:

```json5
{
  skills: {
    allowBundled: ["brave-search", "gemini"],
    load: {
      extraDirs: [
        "~/Projects/agent-scripts/skills",
        "~/Projects/oss/some-skill-pack/skills"
      ]
    },
    install: {
      preferBrew: true,
      nodeManager: "npm"
    },
    entries: {
      "nano-banana-pro": {
        apiKey: "GEMINI_KEY_HERE",
        env: {
          GEMINI_API_KEY: "GEMINI_KEY_HERE"
        }
      },
      peekaboo: { enabled: true },
      sag: { enabled: false }
    }
  }
}
```

### `browser` (clawd-managed Chrome)

Clawdbot can start a **dedicated, isolated** Chrome/Chromium instance for clawd and expose a small loopback control server.
Profiles can point at a **remote** Chrome via `profiles.<name>.cdpUrl`. Remote
profiles are attach-only (start/stop/reset are disabled).

`browser.cdpUrl` remains for legacy single-profile configs and as the base
scheme/host for profiles that only set `cdpPort`.

Defaults:
- enabled: `true`
- control URL: `http://127.0.0.1:18791` (CDP uses `18792`)
- CDP URL: `http://127.0.0.1:18792` (control URL + 1, legacy single-profile)
- profile color: `#FF4500` (lobster-orange)
- Note: the control server is started by the running gateway (Clawdbot.app menubar, or `clawdbot gateway`).

```json5
{
  browser: {
    enabled: true,
    controlUrl: "http://127.0.0.1:18791",
    // cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override
    defaultProfile: "clawd",
    profiles: {
      clawd: { cdpPort: 18800, color: "#FF4500" },
      work: { cdpPort: 18801, color: "#0066CC" },
      remote: { cdpUrl: "http://10.0.0.42:9222", color: "#00AA00" }
    },
    color: "#FF4500",
    // Advanced:
    // headless: false,
    // noSandbox: false,
    // executablePath: "/usr/bin/chromium",
    // attachOnly: false, // set true when tunneling a remote CDP to localhost
  }
}
```

### `ui` (Appearance)

Optional accent color used by the native apps for UI chrome (e.g. Talk Mode bubble tint).

If unset, clients fall back to a muted light-blue.

```json5
{
  ui: {
    seamColor: "#FF4500" // hex (RRGGBB or #RRGGBB)
  }
}
```

### `gateway` (Gateway server mode + bind)

Use `gateway.mode` to explicitly declare whether this machine should run the Gateway.

Defaults:
- mode: **unset** (treated as “do not auto-start”)
- bind: `loopback`
- port: `18789` (single port for WS + HTTP)

```json5
{
  gateway: {
    mode: "local", // or "remote"
    port: 18789, // WS + HTTP multiplex
    bind: "loopback",
    // controlUi: { enabled: true, basePath: "/clawdbot" }
    // auth: { mode: "token", token: "your-token" } // token is for multi-machine CLI access
    // tailscale: { mode: "off" | "serve" | "funnel" }
  }
}
```

Control UI base path:
- `gateway.controlUi.basePath` sets the URL prefix where the Control UI is served.
- Examples: `"/ui"`, `"/clawdbot"`, `"/apps/clawdbot"`.
- Default: root (`/`) (unchanged).

Related docs:
- [Control UI](/web/control-ui)
- [Web overview](/web)
- [Tailscale](/gateway/tailscale)
- [Remote access](/gateway/remote)

Notes:
- `clawdbot gateway` refuses to start unless `gateway.mode` is set to `local` (or you pass the override flag).
- `gateway.port` controls the single multiplexed port used for WebSocket + HTTP (control UI, hooks, A2UI).
- Precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`.

Auth and Tailscale:
- `gateway.auth.mode` sets the handshake requirements (`token` or `password`).
- `gateway.auth.token` stores the shared token for token auth (used by the CLI on the same machine).
- When `gateway.auth.mode` is set, only that method is accepted (plus optional Tailscale headers).
- `gateway.auth.password` can be set here, or via `CLAWDBOT_GATEWAY_PASSWORD` (recommended).
- `gateway.auth.allowTailscale` controls whether Tailscale identity headers can satisfy auth.
- `gateway.tailscale.mode: "serve"` uses Tailscale Serve (tailnet only, loopback bind).
- `gateway.tailscale.mode: "funnel"` exposes the dashboard publicly; requires auth.
- `gateway.tailscale.resetOnExit` resets Serve/Funnel config on shutdown.

Remote client defaults (CLI):
- `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`.
- `gateway.remote.token` supplies the token for remote calls (leave unset for no auth).
- `gateway.remote.password` supplies the password for remote calls (leave unset for no auth).

macOS app behavior:
- Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
- If `gateway.mode` is unset but `gateway.remote.url` is set, the macOS app treats it as remote mode.
- When you change connection mode in the macOS app, it writes `gateway.mode` (and `gateway.remote.url` in remote mode) back to the config file.

```json5
{
  gateway: {
    mode: "remote",
    remote: {
      url: "ws://gateway.tailnet:18789",
      token: "your-token",
      password: "your-password"
    }
  }
}
```

### `gateway.reload` (Config hot reload)

The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically.

Modes:
- `hybrid` (default): hot-apply safe changes; restart the Gateway for critical changes.
- `hot`: only apply hot-safe changes; log when a restart is required.
- `restart`: restart the Gateway on any config change.
- `off`: disable hot reload.

```json5
{
  gateway: {
    reload: {
      mode: "hybrid",
      debounceMs: 300
    }
  }
}
```

#### Hot reload matrix (files + impact)

Files watched:
- `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`)

Hot-applied (no full gateway restart):
- `hooks` (webhook auth/path/mappings) + `hooks.gmail` (Gmail watcher restarted)
- `browser` (browser control server restart)
- `cron` (cron service restart + concurrency update)
- `agent.heartbeat` (heartbeat runner restart)
- `web` (WhatsApp web provider restart)
- `telegram`, `discord`, `signal`, `imessage` (provider restarts)
- `agent`, `models`, `routing`, `messages`, `session`, `whatsapp`, `logging`, `skills`, `ui`, `talk`, `identity`, `wizard` (dynamic reads)

Requires full Gateway restart:
- `gateway` (port/bind/auth/control UI/tailscale)
- `bridge`
- `discovery`
- `canvasHost`
- Any unknown/unsupported config path (defaults to restart for safety)

### Multi-instance isolation

To run multiple gateways on one host, isolate per-instance state + config and use unique ports:
- `CLAWDBOT_CONFIG_PATH` (per-instance config)
- `CLAWDBOT_STATE_DIR` (sessions/creds)
- `agent.workspace` (memories)
- `gateway.port` (unique per instance)

Convenience flags (CLI):
- `clawdbot --dev …` → uses `~/.clawdbot-dev` + shifts ports from base `19001`
- `clawdbot --profile <name> …` → uses `~/.clawdbot-<name>` (port via config/env/flags)

See [`docs/gateway.md`](/gateway) for the derived port mapping (gateway/bridge/browser/canvas).

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

### `hooks` (Gateway webhooks)

Enable a simple HTTP webhook endpoint on the Gateway HTTP server.

Defaults:
- enabled: `false`
- path: `/hooks`
- maxBodyBytes: `262144` (256 KB)

```json5
{
  hooks: {
    enabled: true,
    token: "shared-secret",
    path: "/hooks",
    presets: ["gmail"],
    transformsDir: "~/.clawdbot/hooks",
    mappings: [
      {
        match: { path: "gmail" },
        action: "agent",
        wakeMode: "now",
        name: "Gmail",
        sessionKey: "hook:gmail:{{messages[0].id}}",
        messageTemplate:
          "From: {{messages[0].from}}\nSubject: {{messages[0].subject}}\n{{messages[0].snippet}}",
      },
    ],
  }
}
```

Requests must include the hook token:
- `Authorization: Bearer <token>` **or**
- `x-clawdbot-token: <token>` **or**
- `?token=<token>`

Endpoints:
- `POST /hooks/wake` → `{ text, mode?: "now"|"next-heartbeat" }`
- `POST /hooks/agent` → `{ message, name?, sessionKey?, wakeMode?, deliver?, provider?, to?, thinking?, timeoutSeconds? }`
- `POST /hooks/<name>` → resolved via `hooks.mappings`

`/hooks/agent` always posts a summary into the main session (and can optionally trigger an immediate heartbeat via `wakeMode: "now"`).

Mapping notes:
- `match.path` matches the sub-path after `/hooks` (e.g. `/hooks/gmail` → `gmail`).
- `match.source` matches a payload field (e.g. `{ source: "gmail" }`) so you can use a generic `/hooks/ingest` path.
- Templates like `{{messages[0].subject}}` read from the payload.
- `transform` can point to a JS/TS module that returns a hook action.

Gmail helper config (used by `clawdbot hooks gmail setup` / `run`):

```json5
{
  hooks: {
    gmail: {
      account: "clawdbot@gmail.com",
      topic: "projects/<project-id>/topics/gog-gmail-watch",
      subscription: "gog-gmail-watch-push",
      pushToken: "shared-push-token",
      hookUrl: "http://127.0.0.1:18789/hooks/gmail",
      includeBody: true,
      maxBytes: 20000,
      renewEveryMinutes: 720,
      serve: { bind: "127.0.0.1", port: 8788, path: "/" },
      tailscale: { mode: "funnel", path: "/gmail-pubsub" },
    }
  }
}
```

Gateway auto-start:
- If `hooks.enabled=true` and `hooks.gmail.account` is set, the Gateway starts
  `gog gmail watch serve` on boot and auto-renews the watch.
- Set `CLAWDBOT_SKIP_GMAIL_WATCHER=1` to disable the auto-start (for manual runs).
- Avoid running a separate `gog gmail watch serve` alongside the Gateway; it will
  fail with `listen tcp 127.0.0.1:8788: bind: address already in use`.

Note: when `tailscale.mode` is on, Clawdbot defaults `serve.path` to `/` so
Tailscale can proxy `/gmail-pubsub` correctly (it strips the set-path prefix).

### `canvasHost` (LAN/tailnet Canvas file server + live reload)

The Gateway serves a directory of HTML/CSS/JS over HTTP so iOS/Android nodes can simply `canvas.navigate` to it.

Default root: `~/clawd/canvas`  
Default port: `18793` (chosen to avoid the clawd browser CDP port `18792`)  
The server listens on the **bridge bind host** (LAN or Tailnet) so nodes can reach it.

The server:
- serves files under `canvasHost.root`
- injects a tiny live-reload client into served HTML
- watches the directory and broadcasts reloads over a WebSocket endpoint at `/__clawdbot/ws`
- auto-creates a starter `index.html` when the directory is empty (so you see something immediately)
- also serves A2UI at `/__clawdbot__/a2ui/` and is advertised to nodes as `canvasHostUrl`
  (always used by nodes for Canvas/A2UI)

Disable live reload (and file watching) if the directory is large or you hit `EMFILE`:
- config: `canvasHost: { liveReload: false }`

```json5
{
  canvasHost: {
    root: "~/clawd/canvas",
    port: 18793,
    liveReload: true
  }
}
```

Changes to `canvasHost.*` require a gateway restart (config reload will restart).

Disable with:
- config: `canvasHost: { enabled: false }`
- env: `CLAWDBOT_SKIP_CANVAS_HOST=1`

### `bridge` (node bridge server)

The Gateway can expose a simple TCP bridge for nodes (iOS/Android), typically on port `18790`.

Defaults:
- enabled: `true`
- port: `18790`
- bind: `lan` (binds to `0.0.0.0`)

Bind modes:
- `lan`: `0.0.0.0` (reachable on any interface, including LAN/Wi‑Fi and Tailscale)
- `tailnet`: bind only to the machine’s Tailscale IP (recommended for Vienna ⇄ London)
- `loopback`: `127.0.0.1` (local only)
- `auto`: prefer tailnet IP if present, else `lan`

```json5
{
  bridge: {
    enabled: true,
    port: 18790,
    bind: "tailnet"
  }
}
```

### `discovery.wideArea` (Wide-Area Bonjour / unicast DNS‑SD)

When enabled, the Gateway writes a unicast DNS-SD zone for `_clawdbot-bridge._tcp` under `~/.clawdbot/dns/` using the standard discovery domain `clawdbot.internal.`

To make iOS/Android discover across networks (Vienna ⇄ London), pair this with:
- a DNS server on the gateway host serving `clawdbot.internal.` (CoreDNS is recommended)
- Tailscale **split DNS** so clients resolve `clawdbot.internal` via that server

One-time setup helper (gateway host):

```bash
clawdbot dns setup --apply
```

```json5
{
  discovery: { wideArea: { enabled: true } }
}
```

## Template variables

Template placeholders are expanded in `routing.transcribeAudio.command` (and any future templated command fields).

| Variable | Description |
|----------|-------------|
| `{{Body}}` | Full inbound message body |
| `{{BodyStripped}}` | Body with group mentions stripped (best default for agents) |
| `{{From}}` | Sender identifier (E.164 for WhatsApp; may differ per provider) |
| `{{To}}` | Destination identifier |
| `{{MessageSid}}` | Provider message id (when available) |
| `{{SessionId}}` | Current session UUID |
| `{{IsNewSession}}` | `"true"` when a new session was created |
| `{{MediaUrl}}` | Inbound media pseudo-URL (if present) |
| `{{MediaPath}}` | Local media path (if downloaded) |
| `{{MediaType}}` | Media type (image/audio/document/…) |
| `{{Transcript}}` | Audio transcript (when enabled) |
| `{{ChatType}}` | `"direct"` or `"group"` |
| `{{GroupSubject}}` | Group subject (best effort) |
| `{{GroupMembers}}` | Group members preview (best effort) |
| `{{SenderName}}` | Sender display name (best effort) |
| `{{SenderE164}}` | Sender phone number (best effort) |
| `{{Provider}}` | Provider hint (whatsapp|telegram|discord|imessage|webchat|…) |

## Cron (Gateway scheduler)

Cron is a Gateway-owned scheduler for wakeups and scheduled jobs. See [Cron jobs](/automation/cron-jobs) for the feature overview and CLI examples.

```json5
{
  cron: {
    enabled: true,
    maxConcurrentRuns: 2
  }
}
```

---

*Next: [Agent Runtime](/concepts/agent)* 🦞
