> ## Documentation Index
> Fetch the complete documentation index at: https://ancplua.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Configuration Reference

Complete field-by-field reference for \~/.datzi/datzi.json

# Configuration Reference

Every field available in `~/.datzi/datzi.json`. For a task-oriented overview,
see [Configuration](/datzi/gateway/configuration).

Config format is **JSON5** (comments + trailing commas allowed). All fields are optional — Datzi uses safe defaults when
omitted.

***

## Channels

Each channel starts automatically when its config section exists (unless `enabled: false`).

### DM and group access

All channels support DM policies and group policies:

| DM policy           | Behavior                                                        |
| ------------------- | --------------------------------------------------------------- |
| `pairing` (default) | Unknown senders get a one-time pairing code; owner must approve |
| `allowlist`         | Only senders in `allowFrom` (or paired allow store)             |
| `open`              | Allow all inbound DMs (requires `allowFrom: ["*"]`)             |
| `disabled`          | Ignore all inbound DMs                                          |

| Group policy          | Behavior                                               |
| --------------------- | ------------------------------------------------------ |
| `allowlist` (default) | Only groups matching the configured allowlist          |
| `open`                | Bypass group allowlists (mention-gating still applies) |
| `disabled`            | Block all group/room messages                          |

<Note>
  `channels.defaults.groupPolicy` sets the default when a provider's
  `groupPolicy` is unset. Pairing codes expire after 1 hour. Pending DM pairing
  requests are capped at **3 per channel**. Slack/Discord have a special
  fallback: if their provider section is missing entirely, runtime group policy
  can resolve to `open` (with a startup warning).
</Note>

### Channel model overrides

Use `channels.modelByChannel` to pin specific channel IDs to a model. Values accept `provider/model` or configured model
aliases. The channel mapping applies when a session does not already have a model override (for example, set via
`/model`).

```json5 theme={null}
{
  channels: {
    modelByChannel: {
      discord: {
        '123456789012345678': 'ollama/qwen3-coder:32b'
      },
      slack: {
        C1234567890: 'ollama/deepseek-r1:32b'
      },
      telegram: {
        '-1001234567890': 'ollama/qwen3-coder:14b',
        '-1001234567890:topic:99': 'ollama/qwen3-coder:14b'
      }
    }
  }
}
```

### WhatsApp

WhatsApp runs through the gateway's web channel (Baileys Web). It starts automatically when a linked session exists.

```json5 theme={null}
{
  channels: {
    whatsapp: {
      dmPolicy: 'pairing',
      // pairing | allowlist | open | disabled
      allowFrom: ['+15555550123', '+447700900123'],
      textChunkLimit: 4000,
      chunkMode: 'length',
      // length | newline
      mediaMaxMb: 50,
      sendReadReceipts: true,
      // blue ticks (false in self-chat mode)
      groups: {
        '*': {
          requireMention: true
        }
      },
      groupPolicy: 'allowlist',
      groupAllowFrom: ['+15551234567']
    }
  },
  web: {
    enabled: true,
    heartbeatSeconds: 60,
    reconnect: {
      initialMs: 2000,
      maxMs: 120000,
      factor: 1.4,
      jitter: 0.2,
      maxAttempts: 0
    }
  }
}
```

<Accordion title="Multi-account WhatsApp">
  ```json5 theme={null}
  {
    channels: {
      whatsapp: {
        accounts: {
          default: {},
          personal: {},
          biz: {
            // authDir: "~/.datzi/credentials/whatsapp/biz",
          },
        },
      },
    },
  }
  ```

  * Outbound commands default to account `default` if present; otherwise the first configured account id (sorted).
  * Legacy single-account Baileys auth dir is migrated by `datzi doctor` into `whatsapp/default`.
  * Per-account overrides: `channels.whatsapp.accounts.<id>.sendReadReceipts`, `channels.whatsapp.accounts.<id>.dmPolicy`,
    `channels.whatsapp.accounts.<id>.allowFrom`.
</Accordion>

### Telegram

```json5 theme={null}
{
  channels: {
    telegram: {
      enabled: true,
      botToken: 'your-bot-token',
      dmPolicy: 'pairing',
      allowFrom: ['tg:123456789'],
      groups: {
        '*': {
          requireMention: true
        },
        '-1001234567890': {
          allowFrom: ['@admin'],
          systemPrompt: 'Keep answers brief.',
          topics: {
            '99': {
              requireMention: false,
              skills: ['search'],
              systemPrompt: 'Stay on topic.'
            }
          }
        }
      },
      customCommands: [
        {
          command: 'backup',
          description: 'Git backup'
        },
        {
          command: 'generate',
          description: 'Create an image'
        }
      ],
      historyLimit: 50,
      replyToMode: 'first',
      // off | first | all
      linkPreview: true,
      streaming: 'partial',
      // off | partial | block | progress (default: off)
      actions: {
        reactions: true,
        sendMessage: true
      },
      reactionNotifications: 'own',
      // off | own | all
      mediaMaxMb: 5,
      retry: {
        attempts: 3,
        minDelayMs: 400,
        maxDelayMs: 30000,
        jitter: 0.1
      },
      network: {
        autoSelectFamily: false
      },
      proxy: 'socks5://localhost:9050',
      webhookUrl: 'https://example.com/telegram-webhook',
      webhookSecret: 'secret',
      webhookPath: '/telegram-webhook'
    }
  }
}
```

* Bot token: `channels.telegram.botToken` or `channels.telegram.tokenFile`, with `TELEGRAM_BOT_TOKEN` as fallback for
  the default account.
* `configWrites: false` blocks Telegram-initiated config writes (supergroup ID migrations, `/config set|unset`).
* Telegram stream previews use `sendMessage` + `editMessageText` (works in direct and group chats).
* Retry policy: see Retry policy.

### Discord

```json5 theme={null}
{
  channels: {
    discord: {
      enabled: true,
      token: 'your-bot-token',
      mediaMaxMb: 8,
      allowBots: false,
      actions: {
        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
      dmPolicy: 'pairing',
      allowFrom: ['1234567890', 'steipete'],
      dm: {
        enabled: true,
        groupEnabled: false,
        groupChannels: ['datzi-dm']
      },
      guilds: {
        '123456789012345678': {
          slug: 'friends-of-datzi',
          requireMention: false,
          reactionNotifications: 'own',
          users: ['987654321098765432'],
          channels: {
            general: {
              allow: true
            },
            help: {
              allow: true,
              requireMention: true,
              users: ['987654321098765432'],
              skills: ['docs'],
              systemPrompt: 'Short answers only.'
            }
          }
        }
      },
      historyLimit: 20,
      textChunkLimit: 2000,
      chunkMode: 'length',
      // length | newline
      streaming: 'off',
      // off | partial | block | progress (progress maps to partial on Discord)
      maxLinesPerMessage: 17,
      ui: {
        components: {
          accentColor: '#5865F2'
        }
      },
      threadBindings: {
        enabled: true,
        ttlHours: 24,
        spawnSubagentSessions: false
        // opt-in for sessions_spawn({ thread: true })
      },
      voice: {
        enabled: true,
        autoJoin: [
          {
            guildId: '123456789012345678',
            channelId: '234567890123456789'
          }
        ],
      },
      retry: {
        attempts: 3,
        minDelayMs: 500,
        maxDelayMs: 30000,
        jitter: 0.1
      }
    }
  }
}
```

* Token: `channels.discord.token`, with `DISCORD_BOT_TOKEN` as fallback for the default account.
* Use `user:<id>` (DM) or `channel:<id>` (guild channel) for delivery targets; bare numeric IDs are rejected.
* Guild slugs are lowercase with spaces replaced by `-`; channel keys use the slugged name (no `#`). Prefer guild IDs.
* Bot-authored messages are ignored by default. `allowBots: true` enables them (own messages still filtered).
* `maxLinesPerMessage` (default 17) splits tall messages even when under 2000 chars.
* `channels.discord.threadBindings` controls Discord thread-bound routing:
  * `enabled`: Discord override for thread-bound session features (`/focus`, `/unfocus`, `/agents`, `/session ttl`,
    and bound delivery/routing)
  * `ttlHours`: Discord override for auto-unfocus TTL (`0` disables)
  * `spawnSubagentSessions`: opt-in switch for `sessions_spawn({ thread: true })` auto thread creation/binding
* `channels.discord.ui.components.accentColor` sets the accent color for Discord components v2 containers.
* `channels.discord.voice` enables Discord voice channel conversations and optional auto-join + TTS overrides.
* `channels.discord.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values are
  auto-migrated.

**Reaction notification modes:** `off` (none), `own` (bot's messages, default), `all` (all messages), `allowlist` (from
`guilds.<id>.users` on all messages).

### Google Chat

```json5 theme={null}
{
  channels: {
    googlechat: {
      enabled: true,
      serviceAccountFile: '/path/to/service-account.json',
      audienceType: 'app-url',
      // app-url | project-number
      audience: 'https://gateway.example.com/googlechat',
      webhookPath: '/googlechat',
      botUser: 'users/1234567890',
      dm: {
        enabled: true,
        policy: 'pairing',
        allowFrom: ['users/1234567890']
      },
      groupPolicy: 'allowlist',
      groups: {
        'spaces/AAAA': {
          allow: true,
          requireMention: true
        }
      },
      actions: {
        reactions: true
      },
      typingIndicator: 'message',
      mediaMaxMb: 20
    }
  }
}
```

* Service account JSON: inline (`serviceAccount`) or file-based (`serviceAccountFile`).
* Env fallbacks: `GOOGLE_CHAT_SERVICE_ACCOUNT` or `GOOGLE_CHAT_SERVICE_ACCOUNT_FILE`.
* Use `spaces/<spaceId>` or `users/<userId|email>` for delivery targets.

### Slack

```json5 theme={null}
{
  channels: {
    slack: {
      enabled: true,
      botToken: 'xoxb-...',
      appToken: 'xapp-...',
      dmPolicy: 'pairing',
      allowFrom: ['U123', 'U456', '*'],
      dm: {
        enabled: true,
        groupEnabled: false,
        groupChannels: ['G123']
      },
      channels: {
        C123: {
          allow: true,
          requireMention: true,
          allowBots: false
        },
        '#general': {
          allow: true,
          requireMention: true,
          allowBots: false,
          users: ['U123'],
          skills: ['docs'],
          systemPrompt: 'Short answers only.'
        }
      },
      historyLimit: 50,
      allowBots: false,
      reactionNotifications: 'own',
      reactionAllowlist: ['U123'],
      replyToMode: 'off',
      // off | first | all
      thread: {
        historyScope: 'thread',
        // thread | channel
        inheritParent: false
      },
      actions: {
        reactions: true,
        messages: true,
        pins: true,
        memberInfo: true,
        emojiList: true
      },
      slashCommand: {
        enabled: true,
        name: 'datzi',
        sessionPrefix: 'slack:slash',
        ephemeral: true
      },
      textChunkLimit: 4000,
      chunkMode: 'length',
      streaming: 'partial',
      // off | partial | block | progress (preview mode)
      nativeStreaming: true,
      // use Slack native streaming API when streaming=partial
      mediaMaxMb: 20
    }
  }
}
```

* **Socket mode** requires both `botToken` and `appToken` (`SLACK_BOT_TOKEN` + `SLACK_APP_TOKEN` for default account env
  fallback).
* **HTTP mode** requires `botToken` plus `signingSecret` (at root or per-account).
* `configWrites: false` blocks Slack-initiated config writes.
* `channels.slack.streaming` is the canonical stream mode key. Legacy `streamMode` and boolean `streaming` values are
  auto-migrated.
* Use `user:<id>` (DM) or `channel:<id>` for delivery targets.

**Reaction notification modes:** `off`, `own` (default), `all`, `allowlist` (from `reactionAllowlist`).

**Thread session isolation:** `thread.historyScope` is per-thread (default) or shared across channel.
`thread.inheritParent` copies parent channel transcript to new threads.

| 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      |

### Mattermost

Mattermost ships as a plugin: `datzi plugins install @datzi/mattermost`.

```json5 theme={null}
{
  channels: {
    mattermost: {
      enabled: true,
      botToken: 'mm-token',
      baseUrl: 'https://chat.example.com',
      dmPolicy: 'pairing',
      chatmode: 'oncall',
      // oncall | onmessage | onchar
      oncharPrefixes: ['>', '!'],
      textChunkLimit: 4000,
      chunkMode: 'length'
    }
  }
}
```

Chat modes: `oncall` (respond on @-mention, default), `onmessage` (every message), `onchar` (messages starting with
trigger prefix).

### Signal

```json5 theme={null}
{
  channels: {
    signal: {
      reactionNotifications: 'own',
      // off | own | all | allowlist
      reactionAllowlist: [
        '+15551234567',
        'uuid:123e4567-e89b-12d3-a456-426614174000'
      ],
      historyLimit: 50
    }
  }
}
```

**Reaction notification modes:** `off`, `own` (default), `all`, `allowlist` (from `reactionAllowlist`).

### iMessage

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

```json5 theme={null}
{
  channels: {
    imessage: {
      enabled: true,
      cliPath: 'imsg',
      dbPath: '~/Library/Messages/chat.db',
      remoteHost: 'user@gateway-host',
      dmPolicy: 'pairing',
      allowFrom: ['+15555550123', 'user@example.com', 'chat_id:123'],
      historyLimit: 50,
      includeAttachments: false,
      attachmentRoots: ['/Users/*/Library/Messages/Attachments'],
      remoteAttachmentRoots: ['/Users/*/Library/Messages/Attachments'],
      mediaMaxMb: 16,
      service: 'auto',
      region: 'US'
    }
  }
}
```

* Requires Full Disk Access to the Messages DB.
* Prefer `chat_id:<id>` targets. Use `imsg chats --limit 20` to list chats.
* `cliPath` can point to an SSH wrapper; set `remoteHost` (`host` or `user@host`) for SCP attachment fetching.
* `attachmentRoots` and `remoteAttachmentRoots` restrict inbound attachment paths (default:
  `/Users/*/Library/Messages/Attachments`).
* SCP uses strict host-key checking, so ensure the relay host key already exists in `~/.ssh/known_hosts`.

<Accordion title="iMessage SSH wrapper example">
  `bash #!/usr/bin/env bash exec ssh -T gateway-host imsg "$@" `
</Accordion>

### Multi-account (all channels)

Run multiple accounts per channel (each with its own `accountId`):

```json5 theme={null}
{
  channels: {
    telegram: {
      accounts: {
        default: {
          name: 'Primary bot',
          botToken: '123456:ABC...'
        },
        alerts: {
          name: 'Alerts bot',
          botToken: '987654:XYZ...'
        }
      }
    }
  }
}
```

* `default` is used when `accountId` is omitted (CLI + routing).
* Env tokens only apply to the **default** account.
* Base channel settings apply to all accounts unless overridden per account.
* Use `bindings[].match.accountId` to route each account to a different agent.

### Group chat mention gating

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

**Mention types:**

* **Metadata mentions**: Native platform @-mentions. Ignored in WhatsApp self-chat mode.
* **Text patterns**: Regex patterns in `agents.list[].groupChat.mentionPatterns`. Always checked.
* Mention gating is enforced only when detection is possible (native mentions or at least one pattern).

```json5 theme={null}
{
  messages: {
    groupChat: {
      historyLimit: 50
    }
  },
  agents: {
    list: [
      {
        id: 'main',
        groupChat: {
          mentionPatterns: ['@datzi', 'datzi']
        }
      }
    ]
  }
}
```

`messages.groupChat.historyLimit` sets the global default. Channels can override with`channels.<channel>.historyLimit` (
or per-account). Set `0` to disable.

#### DM history limits

```json5 theme={null}
{
  channels: {
    telegram: {
      dmHistoryLimit: 30,
      dms: {
        '123456789': {
          historyLimit: 50
        }
      }
    }
  }
}
```

Resolution: per-DM override → provider default → no limit (all retained).

Supported: `telegram`, `whatsapp`, `discord`, `slack`, `signal`, `imessage`, `msteams`.

#### Self-chat mode

Include your own number in `allowFrom` to enable self-chat mode (ignores native @-mentions, only responds to text
patterns):

```json5 theme={null}
{
  channels: {
    whatsapp: {
      allowFrom: ['+15555550123'],
      groups: {
        '*': {
          requireMention: true
        }
      }
    }
  },
  agents: {
    list: [
      {
        id: 'main',
        groupChat: {
          mentionPatterns: ['reisponde', '@datzi']
        }
      }
    ]
  }
}
```

### Commands (chat command handling)

```json5 theme={null}
{
  commands: {
    native: 'auto',
    // register native commands when supported
    text: true,
    // parse /commands in chat messages
    bash: false,
    // allow ! (alias: /bash)
    bashForegroundMs: 2000,
    config: false,
    // allow /config
    debug: false,
    // allow /debug
    restart: false,
    // allow /restart + gateway restart tool
    allowFrom: {
      '*': ['user1'],
      discord: ['user:123']
    },
    useAccessGroups: true
  }
}
```

<Accordion title="Command details">
  * Text commands must be **standalone** messages with leading `/`.
  * `native: "auto"` turns on native commands for Discord/Telegram, leaves Slack off.
  * Override per channel: `channels.discord.commands.native` (bool or `"auto"`). `false` clears previously registered commands.
  * `channels.telegram.customCommands` adds extra Telegram bot menu entries.
  * `bash: true` enables `! <cmd>` for host shell. Requires `tools.elevated.enabled` and sender in `tools.elevated.allowFrom.<channel>`.
  * `config: true` enables `/config` (reads/writes `datzi.json`).
  * `channels.<provider>.configWrites` gates config mutations per channel (default: true).
  * `allowFrom` is per-provider. When set, it is the **only** authorization source (channel allowlists/pairing and `useAccessGroups` are ignored).
  * `useAccessGroups: false` allows commands to bypass access-group policies when `allowFrom` is not set.
</Accordion>

***

## Agent defaults

### `agents.defaults.workspace`

Default: `~/.datzi/workspace`.

```json5 theme={null}
{
  agents: {
    defaults: {
      workspace: '~/.datzi/workspace'
    }
  }
}
```

### `agents.defaults.repoRoot`

Optional repository root shown in the system prompt's Runtime line. If unset, Datzi auto-detects by walking upward from
the workspace.

```json5 theme={null}
{
  agents: {
    defaults: {
      repoRoot: '~/Projects/datzi'
    }
  }
}
```

### `agents.defaults.skipBootstrap`

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

```json5 theme={null}
{
  agents: {
    defaults: {
      skipBootstrap: true
    }
  }
}
```

### `agents.defaults.bootstrapMaxChars`

Max characters per workspace bootstrap file before truncation. Default: `20000`.

```json5 theme={null}
{
  agents: {
    defaults: {
      bootstrapMaxChars: 20000
    }
  }
}
```

### `agents.defaults.bootstrapTotalMaxChars`

Max total characters injected across all workspace bootstrap files. Default: `150000`.

```json5 theme={null}
{
  agents: {
    defaults: {
      bootstrapTotalMaxChars: 150000
    }
  }
}
```

### `agents.defaults.imageMaxDimensionPx`

Max pixel size for the longest image side in transcript/tool image blocks before provider calls.
Default: `1200`.

Lower values usually reduce vision-token usage and request payload size for screenshot-heavy runs.
Higher values preserve more visual detail.

```json5 theme={null}
{
  agents: {
    defaults: {
      imageMaxDimensionPx: 1200
    }
  }
}
```

### `agents.defaults.userTimezone`

Timezone for system prompt context (not message timestamps). Falls back to host timezone.

```json5 theme={null}
{
  agents: {
    defaults: {
      userTimezone: 'America/Chicago'
    }
  }
}
```

### `agents.defaults.timeFormat`

Time format in system prompt. Default: `auto` (OS preference).

```json5 theme={null}
{
  agents: {
    defaults: {
      timeFormat: 'auto'
    }
  }
  // auto | 12 | 24
}
```

### `agents.defaults.model`

```json5 theme={null}
{
  agents: {
    defaults: {
      models: {
        'ollama/qwen3-coder:32b': {
          alias: 'opus'
        },
        'minimax/MiniMax-M2.1': {
          alias: 'minimax'
        }
      },
      model: {
        primary: 'ollama/qwen3-coder:32b',
        fallbacks: ['minimax/MiniMax-M2.1']
      },
      imageModel: {
        primary: 'ollama/llava:latest',
        fallbacks: ['ollama/qwen3-coder:14b']
      },
      thinkingDefault: 'low',
      verboseDefault: 'off',
      elevatedDefault: 'on',
      timeoutSeconds: 600,
      mediaMaxMb: 5,
      contextTokens: 200000,
      maxConcurrent: 3
    }
  }
}
```

* `model.primary`: format `provider/model` (e.g. `ollama/qwen3-coder:32b`). If you omit the provider, Datzi assumes
  `anthropic` (deprecated).
* `models`: the configured model catalog and allowlist for `/model`. Each entry can include `alias` (shortcut) and
  `params` (provider-specific: `temperature`, `maxTokens`).
* `imageModel`: only used if the primary model lacks image input.
* `maxConcurrent`: max parallel agent runs across sessions (each session still serialized). Default: 1.

**Built-in alias shorthands** (only apply when the model is in `agents.defaults.models`):

| Alias      | Model                    |
| ---------- | ------------------------ |
| `ollama`   | `ollama/qwen3-coder:32b` |
| `deepseek` | `ollama/deepseek-r1:32b` |
| `coder14b` | `ollama/qwen3-coder:14b` |

Your configured aliases always win over defaults.

Z.AI GLM-4.x models automatically enable thinking mode unless you set `--thinking off` or define
`agents.defaults.models["zai/<model>"].params.thinking` yourself.
Z.AI models enable `tool_stream` by default for tool call streaming. Set
`agents.defaults.models["zai/<model>"].params.tool_stream` to `false` to disable it.

### `agents.defaults.cliBackends`

Optional CLI backends for text-only fallback runs (no tool calls). Useful as a backup when API providers fail.

```json5 theme={null}
{
  agents: {
    defaults: {
      cliBackends: {
        'claude-cli': {
          command: '/opt/homebrew/bin/claude'
        },
        'my-cli': {
          command: 'my-cli',
          args: ['--json'],
          output: 'json',
          modelArg: '--model',
          sessionArg: '--session',
          sessionMode: 'existing',
          systemPromptArg: '--system',
          systemPromptWhen: 'first',
          imageArg: '--image',
          imageMode: 'repeat'
        }
      }
    }
  }
}
```

* CLI backends are text-first; tools are always disabled.
* Sessions supported when `sessionArg` is set.
* Image pass-through supported when `imageArg` accepts file paths.

### `agents.defaults.heartbeat`

Periodic heartbeat runs.

```json5 theme={null}
{
  agents: {
    defaults: {
      heartbeat: {
        every: '30m',
        // 0m disables
        model: 'ollama/qwen3-coder:14b',
        includeReasoning: false,
        session: 'main',
        to: '+15555550123',
        target: 'last',
        // last | whatsapp | telegram | discord | ... | none
        prompt: 'Read HEARTBEAT.md if it exists...',
        ackMaxChars: 300,
        suppressToolErrorWarnings: false
      }
    }
  }
}
```

* `every`: duration string (ms/s/m/h). Default: `30m`.
* `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs.
* Per-agent: set `agents.list[].heartbeat`. When any agent defines `heartbeat`, **only those agents** run heartbeats.
* Heartbeats run full agent turns — shorter intervals burn more tokens.

### `agents.defaults.compaction`

```json5 theme={null}
{
  agents: {
    defaults: {
      compaction: {
        mode: 'safeguard',
        // default | safeguard
        reserveTokensFloor: 24000,
        memoryFlush: {
          enabled: true,
          softThresholdTokens: 6000,
          systemPrompt: 'Session nearing compaction. Store durable memories now.',
          prompt: 'Write any lasting notes to memory/YYYY-MM-DD.md; reply with NO_REPLY if nothing to store.'
        }
      }
    }
  }
}
```

* `mode`: `default` or `safeguard` (chunked summarization for long histories). See [Compaction](/datzi/concepts/compaction).
* `memoryFlush`: silent agentic turn before auto-compaction to store durable memories. Skipped when workspace is
  read-only.

### `agents.defaults.contextPruning`

Prunes **old tool results** from in-memory context before sending to the LLM. Does **not** modify session history on
disk.

```json5 theme={null}
{
  agents: {
    defaults: {
      contextPruning: {
        mode: 'cache-ttl',
        // off | cache-ttl
        ttl: '1h',
        // duration (ms/s/m/h), default unit: minutes
        keepLastAssistants: 3,
        softTrimRatio: 0.3,
        hardClearRatio: 0.5,
        minPrunableToolChars: 50000,
        softTrim: {
          maxChars: 4000,
          headChars: 1500,
          tailChars: 1500
        },
        hardClear: {
          enabled: true,
          placeholder: '[Old tool result content cleared]'
        },
        tools: {
          deny: ['browser', 'canvas']
        }
      }
    }
  }
}
```

<Accordion title="cache-ttl mode behavior">
  * `mode: "cache-ttl"` enables pruning passes.
  * `ttl` controls how often pruning can run again (after the last cache touch).
  * Pruning soft-trims oversized tool results first, then hard-clears older tool results if needed.

  **Soft-trim** keeps beginning + end and inserts `...` in the middle.

  **Hard-clear** replaces the entire tool result with the placeholder.

  Notes:

  * Image blocks are never trimmed/cleared.
  * Ratios are character-based (approximate), not exact token counts.
  * If fewer than `keepLastAssistants` assistant messages exist, pruning is skipped.
</Accordion>

See [Session Pruning](/datzi/concepts/session) for behavior details.

### Block streaming

```json5 theme={null}
{
  agents: {
    defaults: {
      blockStreamingDefault: 'off',
      // on | off
      blockStreamingBreak: 'text_end',
      // text_end | message_end
      blockStreamingChunk: {
        minChars: 800,
        maxChars: 1200
      },
      blockStreamingCoalesce: {
        idleMs: 1000
      },
      humanDelay: {
        mode: 'natural'
      }
      // off | natural | custom (use minMs/maxMs)
    }
  }
}
```

* Non-Telegram channels require explicit `*.blockStreaming: true` to enable block replies.
* Channel overrides: `channels.<channel>.blockStreamingCoalesce` (and per-account variants). Signal/Slack/Discord/Google
  Chat default `minChars: 1500`.
* `humanDelay`: randomized pause between block replies. `natural` = 800–2500ms. Per-agent override:
  `agents.list[].humanDelay`.

See [Streaming](/datzi/concepts/agent-loop) for behavior + chunking details.

### Typing indicators

```json5 theme={null}
{
  agents: {
    defaults: {
      typingMode: 'instant',
      // never | instant | thinking | message
      typingIntervalSeconds: 6
    }
  }
}
```

* Defaults: `instant` for direct chats/mentions, `message` for unmentioned group chats.
* Per-session overrides: `session.typingMode`, `session.typingIntervalSeconds`.

See Typing Indicators.

### `agents.defaults.sandbox`

Optional **Docker sandboxing** for the embedded agent. See [Sandboxing](/datzi/gateway/sandboxing) for the full guide.

```json5 theme={null}
{
  agents: {
    defaults: {
      sandbox: {
        mode: 'non-main',
        // off | non-main | all
        scope: 'agent',
        // session | agent | shared
        workspaceAccess: 'none',
        // none | ro | rw
        workspaceRoot: '~/.datzi/sandboxes',
        docker: {
          image: 'datzi-sandbox:bookworm-slim',
          containerPrefix: 'datzi-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: 'datzi-sandbox',
          dns: ['1.1.1.1', '8.8.8.8'],
          extraHosts: ['internal.service:10.0.0.5'],
          binds: ['/home/user/source:/source:rw']
        },
        browser: {
          enabled: false,
          image: 'datzi-sandbox-browser:bookworm-slim',
          network: 'datzi-sandbox-browser',
          cdpPort: 9222,
          cdpSourceRange: '172.21.0.1/32',
          vncPort: 5900,
          noVncPort: 6080,
          headless: false,
          enableNoVnc: true,
          allowHostControl: false,
          autoStart: true,
          autoStartTimeoutMs: 12000
        },
        prune: {
          idleHours: 24,
          maxAgeDays: 7
        }
      }
    }
  },
  tools: {
    sandbox: {
      tools: {
        allow: [
          'exec',
          'process',
          'read',
          'write',
          'edit',
          'apply_patch',
          'sessions_list',
          'sessions_history',
          'sessions_send',
          'sessions_spawn',
          'session_status'
        ],
        deny: ['browser', 'canvas', 'nodes', 'cron', 'discord', 'gateway']
      }
    }
  }
}
```

<Accordion title="Sandbox details">
  **Workspace access:**

  * `none`: per-scope sandbox workspace under `~/.datzi/sandboxes`
  * `ro`: sandbox workspace at `/workspace`, agent workspace mounted read-only at `/agent`
  * `rw`: agent workspace mounted read/write at `/workspace`

  **Scope:**

  * `session`: per-session container + workspace
  * `agent`: one container + workspace per agent (default)
  * `shared`: shared container and workspace (no cross-session isolation)

  **`setupCommand`** runs once after container creation (via `sh -lc`). Needs network egress, writable root, root user.

  **Containers default to `network: "none"`** — set to `"bridge"` if the agent needs outbound access.

  **Inbound attachments** are staged into `media/inbound/*` in the active workspace.

  **`docker.binds`** mounts additional host directories; global and per-agent binds are merged.

  **Sandboxed browser** (`sandbox.browser.enabled`): Chromium + CDP in a container. noVNC URL injected into system prompt.
  Does not require `browser.enabled` in main config.
  noVNC observer access uses VNC auth by default and Datzi emits a short-lived token URL (instead of exposing the password
  in the shared URL).

  * `allowHostControl: false` (default) blocks sandboxed sessions from targeting the host browser.
  * `network` defaults to `datzi-sandbox-browser` (dedicated bridge network). Set to `bridge` only when you explicitly
    want global bridge connectivity.
  * `cdpSourceRange` optionally restricts CDP ingress at the container edge to a CIDR range (for example `172.21.0.1/32`).
  * `sandbox.browser.binds` mounts additional host directories into the sandbox browser container only. When set (
    including `[]`), it replaces `docker.binds` for the browser container.
</Accordion>

Build images:

```bash theme={null}
scripts/sandbox-setup.sh           # main sandbox image
scripts/sandbox-browser-setup.sh   # optional browser image
```

### `agents.list` (per-agent overrides)

```json5 theme={null}
{
  agents: {
    list: [
      {
        id: 'main',
        default: true,
        name: 'Main Agent',
        workspace: '~/.datzi/workspace',
        agentDir: '~/.datzi/agents/main/agent',
        model: 'ollama/qwen3-coder:32b',
        // or { primary, fallbacks }
        identity: {
          name: 'Samantha',
          theme: 'helpful sloth',
          emoji: '🦥',
          avatar: 'avatars/samantha.png'
        },
        groupChat: {
          mentionPatterns: ['@datzi']
        },
        sandbox: {
          mode: 'off'
        },
        subagents: {
          allowAgents: ['*']
        },
        tools: {
          profile: 'coding',
          allow: ['browser'],
          deny: ['canvas'],
          elevated: {
            enabled: true
          }
        }
      }
    ]
  }
}
```

* `id`: stable agent id (required).
* `default`: when multiple are set, first wins (warning logged). If none set, first list entry is default.
* `model`: string form overrides `primary` only; object form `{ primary, fallbacks }` overrides both (`[]` disables
  global fallbacks). Cron jobs that only override `primary` still inherit default fallbacks unless you set
  `fallbacks: []`.
* `identity.avatar`: workspace-relative path, `http(s)` URL, or `data:` URI.
* `identity` derives defaults: `ackReaction` from `emoji`, `mentionPatterns` from `name`/`emoji`.
* `subagents.allowAgents`: allowlist of agent ids for `sessions_spawn` (`["*"]` = any; default: same agent only).

***

## Multi-agent routing

Run multiple isolated agents inside one Gateway. See [Multi-Agent](/datzi/concepts/multi-agent).

```json5 theme={null}
{
  agents: {
    list: [
      {
        id: 'home',
        default: true,
        workspace: '~/.datzi/workspace-home'
      },
      {
        id: 'work',
        workspace: '~/.datzi/workspace-work'
      }
    ]
  },
  bindings: [
    {
      agentId: 'home',
      match: {
        channel: 'whatsapp',
        accountId: 'personal'
      }
    },
    {
      agentId: 'work',
      match: {
        channel: 'whatsapp',
        accountId: 'biz'
      }
    }
  ]
}
```

### Binding match fields

* `match.channel` (required)
* `match.accountId` (optional; `*` = any account; omitted = default account)
* `match.peer` (optional; `{ kind: direct|group|channel, id }`)
* `match.guildId` / `match.teamId` (optional; channel-specific)

**Deterministic match order:**

1. `match.peer`
2. `match.guildId`
3. `match.teamId`
4. `match.accountId` (exact, no peer/guild/team)
5. `match.accountId: "*"` (channel-wide)
6. Default agent

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

### Per-agent access profiles

<Accordion title="Full access (no sandbox)">
  ```json5 theme={null}
  {
    agents: {
      list: [
        {
          id: "personal",
          workspace: "~/.datzi/workspace-personal",
          sandbox: { mode: "off" },
        },
      ],
    },
  }
  ```
</Accordion>

<Accordion title="Read-only tools + workspace">
  ```json5 theme={null}
  {
    agents: {
      list: [
        {
          id: "family",
          workspace: "~/.datzi/workspace-family",
          sandbox: { mode: "all", scope: "agent", workspaceAccess: "ro" },
          tools: {
            allow: [
              "read",
              "sessions_list",
              "sessions_history",
              "sessions_send",
              "sessions_spawn",
              "session_status",
            ],
            deny: ["write", "edit", "apply_patch", "exec", "process", "browser"],
          },
        },
      ],
    },
  }
  ```
</Accordion>

<Accordion title="No filesystem access (messaging only)">
  ```json5 theme={null}
  {
    agents: {
      list: [
        {
          id: "public",
          workspace: "~/.datzi/workspace-public",
          sandbox: { mode: "all", scope: "agent", workspaceAccess: "none" },
          tools: {
            allow: [
              "sessions_list",
              "sessions_history",
              "sessions_send",
              "sessions_spawn",
              "session_status",
              "whatsapp",
              "telegram",
              "slack",
              "discord",
              "gateway",
            ],
            deny: [
              "read",
              "write",
              "edit",
              "apply_patch",
              "exec",
              "process",
              "browser",
              "canvas",
              "nodes",
              "cron",
              "gateway",
              "image",
            ],
          },
        },
      ],
    },
  }
  ```
</Accordion>

See [Multi-Agent Sandbox & Tools](/datzi/tools/subagents) for precedence details.

***

## Session

```json5 theme={null}
{
  session: {
    scope: 'per-sender',
    dmScope: 'main',
    // main | per-peer | per-channel-peer | per-account-channel-peer
    identityLinks: {
      alice: ['telegram:123456789', 'discord:987654321012345678']
    },
    reset: {
      mode: 'daily',
      // daily | idle
      atHour: 4,
      idleMinutes: 60
    },
    resetByType: {
      thread: {
        mode: 'daily',
        atHour: 4
      },
      direct: {
        mode: 'idle',
        idleMinutes: 240
      },
      group: {
        mode: 'idle',
        idleMinutes: 120
      }
    },
    resetTriggers: ['/new', '/reset'],
    store: '~/.datzi/agents/{agentId}/sessions/sessions.json',
    maintenance: {
      mode: 'warn',
      // warn | enforce
      pruneAfter: '30d',
      maxEntries: 500,
      rotateBytes: '10mb'
    },
    threadBindings: {
      enabled: true,
      ttlHours: 24
      // default auto-unfocus TTL for thread-bound sessions (0 disables)
    },
    mainKey: 'main',
    // legacy (runtime always uses "main")
    agentToAgent: {
      maxPingPongTurns: 5
    },
    sendPolicy: {
      rules: [
        {
          action: 'deny',
          match: {
            channel: 'discord',
            chatType: 'group'
          }
        }
      ],
      default: 'allow'
    }
  }
}
```

<Accordion title="Session field details">
  * **`dmScope`**: how DMs are grouped. \* `main`: all DMs share the main
    session. \* `per-peer`: isolate by sender id across channels. \*
    `per-channel-peer`: isolate per channel + sender (recommended for multi-user
    inboxes). \* `per-account-channel-peer`: isolate per account + channel + sender
    (recommended for multi-account). \* **`identityLinks`**: map canonical ids to
    provider-prefixed peers for cross-channel session sharing. \* **`reset`**:
    primary reset policy. `daily` resets at `atHour` local time; `idle` resets
    after `idleMinutes`. When both configured, whichever expires first wins. \*
    **`resetByType`**: per-type overrides (`direct`, `group`, `thread`). Legacy
    `dm` accepted as alias for `direct`. \* **`mainKey`**: legacy field. Runtime
    now always uses `"main"` for the main direct-chat bucket. \* **`sendPolicy`**:
    match by `channel`, `chatType` (`direct|group|channel`, with legacy `dm`
    alias), `keyPrefix`, or `rawKeyPrefix`. First deny wins. \* **`maintenance`**:
    `warn` warns the active session on eviction; `enforce` applies pruning and
    rotation. \* **`threadBindings`**: global defaults for thread-bound session
    features. \* `enabled`: master default switch (providers can override; Discord
    uses `channels.discord.threadBindings.enabled`) \* `ttlHours`: default
    auto-unfocus TTL in hours (`0` disables; providers can override)
</Accordion>

***

## Messages

```json5 theme={null}
{
  messages: {
    responsePrefix: '🐟',
    // or "auto"
    ackReaction: '👀',
    ackReactionScope: 'group-mentions',
    // group-mentions | group-all | direct | all
    removeAckAfterReply: false,
    queue: {
      mode: 'collect',
      // steer | followup | collect | steer-backlog | steer+backlog | queue | interrupt
      debounceMs: 1000,
      cap: 20,
      drop: 'summarize',
      // old | new | summarize
      byChannel: {
        whatsapp: 'collect',
        telegram: 'collect'
      }
    },
    inbound: {
      debounceMs: 2000,
      // 0 disables
      byChannel: {
        whatsapp: 5000,
        slack: 1500
      }
    }
  }
}
```

### Response prefix

Per-channel/account overrides: `channels.<channel>.responsePrefix`, `channels.<channel>.accounts.<id>.responsePrefix`.

Resolution (most specific wins): account → channel → global. `""` disables and stops cascade. `"auto"` derives
`[{identity.name}]`.

**Template variables:**

| Variable          | Description            | Example                  |
| ----------------- | ---------------------- | ------------------------ |
| `{model}`         | Short model name       | `qwen3-coder:32b`        |
| `{modelFull}`     | Full model identifier  | `ollama/qwen3-coder:32b` |
| `{provider}`      | Provider name          | `anthropic`              |
| `{thinkingLevel}` | Current thinking level | `high`, `low`, `off`     |
| `{identity.name}` | Agent identity name    | (same as `"auto"`)       |

Variables are case-insensitive. `{think}` is an alias for `{thinkingLevel}`.

### Ack reaction

* Defaults to active agent's `identity.emoji`, otherwise `"👀"`. Set `""` to disable.
* Per-channel overrides: `channels.<channel>.ackReaction`, `channels.<channel>.accounts.<id>.ackReaction`.
* Resolution order: account → channel → `messages.ackReaction` → identity fallback.
* Scope: `group-mentions` (default), `group-all`, `direct`, `all`.
* `removeAckAfterReply`: removes ack after reply (Slack/Discord/Telegram/Google Chat only).

### Inbound debounce

Batches rapid text-only messages from the same sender into a single agent turn. Media/attachments flush immediately.
Control commands bypass debouncing.

### TTS (text-to-speech)

> Datzi runs on Ollama (qwen3-coder:14b). No external API keys required. TTS requires local alternatives or is disabled in local-only mode.

***

## Talk

> Datzi runs on Ollama (qwen3-coder:14b). No external API keys required. Talk mode TTS is not available in local-only mode.

***

## Tools

### Tool profiles

`tools.profile` sets a base allowlist before `tools.allow`/`tools.deny`:

| Profile     | Includes                                                                                  |
| ----------- | ----------------------------------------------------------------------------------------- |
| `minimal`   | `session_status` only                                                                     |
| `coding`    | `group:fs`, `group:runtime`, `group:sessions`, `group:memory`, `image`                    |
| `messaging` | `group:messaging`, `sessions_list`, `sessions_history`, `sessions_send`, `session_status` |
| `full`      | No restriction (same as unset)                                                            |

### Tool groups

| Group              | Tools                                                                                    |
| ------------------ | ---------------------------------------------------------------------------------------- |
| `group:runtime`    | `exec`, `process` (`bash` is accepted as an alias for `exec`)                            |
| `group:fs`         | `read`, `write`, `edit`, `apply_patch`                                                   |
| `group:sessions`   | `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status` |
| `group:memory`     | `memory_search`, `memory_get`                                                            |
| `group:web`        | `web_search`, `web_fetch`                                                                |
| `group:ui`         | `browser`, `canvas`                                                                      |
| `group:automation` | `cron`, `gateway`                                                                        |
| `group:messaging`  | `message`                                                                                |
| `group:nodes`      | `nodes`                                                                                  |
| `group:datzi`      | All built-in tools (excludes provider plugins)                                           |

### `tools.allow` / `tools.deny`

Global tool allow/deny policy (deny wins). Case-insensitive, supports `*` wildcards. Applied even when Docker sandbox is
off.

```json5 theme={null}
{
  tools: {
    deny: ['browser', 'canvas']
  }
}
```

### `tools.byProvider`

Further restrict tools for specific providers or models. Order: base profile → provider profile → allow/deny.

```json5 theme={null}
{
  tools: {
    profile: 'coding',
    byProvider: {
      'google-antigravity': {
        profile: 'minimal'
      },
      'ollama/deepseek-r1:32b': {
        allow: ['group:fs', 'sessions_list']
      }
    }
  }
}
```

### `tools.elevated`

Controls elevated (host) exec access:

```json5 theme={null}
{
  tools: {
    elevated: {
      enabled: true,
      allowFrom: {
        whatsapp: ['+15555550123'],
        discord: ['steipete', '1234567890123']
      }
    }
  }
}
```

* Per-agent override (`agents.list[].tools.elevated`) can only further restrict.
* `/elevated on|off|ask|full` stores state per session; inline directives apply to single message.
* Elevated `exec` runs on the host, bypasses sandboxing.

### `tools.exec`

```json5 theme={null}
{
  tools: {
    exec: {
      backgroundMs: 10000,
      timeoutSec: 1800,
      cleanupMs: 1800000,
      notifyOnExit: true,
      notifyOnExitEmptySuccess: false,
      applyPatch: {
        enabled: false,
        allowModels: ['ollama/deepseek-r1:32b']
      }
    }
  }
}
```

### `tools.loopDetection`

Tool-loop safety checks are **disabled by default**. Set `enabled: true` to activate detection.
Settings can be defined globally in `tools.loopDetection` and overridden per-agent at
`agents.list[].tools.loopDetection`.

```json5 theme={null}
{
  tools: {
    loopDetection: {
      enabled: true,
      historySize: 30,
      warningThreshold: 10,
      criticalThreshold: 20,
      globalCircuitBreakerThreshold: 30,
      detectors: {
        genericRepeat: true,
        knownPollNoProgress: true,
        pingPong: true
      }
    }
  }
}
```

* `historySize`: max tool-call history retained for loop analysis.
* `warningThreshold`: repeating no-progress pattern threshold for warnings.
* `criticalThreshold`: higher repeating threshold for blocking critical loops.
* `globalCircuitBreakerThreshold`: hard stop threshold for any no-progress run.
* `detectors.genericRepeat`: warn on repeated same-tool/same-args calls.
* `detectors.knownPollNoProgress`: warn/block on known poll tools (`process.poll`, `command_status`, etc.).
* `detectors.pingPong`: warn/block on alternating no-progress pair patterns.
* If `warningThreshold >= criticalThreshold` or `criticalThreshold >= globalCircuitBreakerThreshold`, validation fails.

### `tools.web`

```json5 theme={null}
{
  tools: {
    web: {
      search: {
        enabled: true,
        apiKey: 'brave_api_key',
        maxResults: 5,
        timeoutSeconds: 30,
        cacheTtlMinutes: 15
      },
      fetch: {
        enabled: true,
        maxChars: 50000,
        maxCharsCap: 50000,
        timeoutSeconds: 30,
        cacheTtlMinutes: 15,
        userAgent: 'custom-ua'
      }
    }
  }
}
```

### `tools.media`

Configures inbound media understanding (image/audio/video):

```json5 theme={null}
{
  tools: {
    media: {
      concurrency: 2,
      audio: {
        enabled: true,
        maxBytes: 20971520,
        scope: {
          default: 'deny',
          rules: [
            {
              action: 'allow',
              match: {
                chatType: 'direct'
              }
            }
          ]
        },
        models: [
          {
            type: 'cli',
            command: 'whisper',
            args: ['--model', 'base', '{{MediaPath}}']
          }
        ]
      },
      video: {
        enabled: true,
        maxBytes: 52428800,
        models: []
      }
    }
  }
}
```

<Accordion title="Media model entry fields">
  **Provider entry** (`type: "provider"` or omitted):

  * `provider`: API provider id (e.g. `ollama`)
  * `model`: model id override
  * `profile` / `preferredProfile`: auth profile selection

  **CLI entry** (`type: "cli"`):

  * `command`: executable to run
  * `args`: templated args (supports `{{MediaPath}}`, `{{Prompt}}`, `{{MaxChars}}`, etc.)

  **Common fields:**

  * `capabilities`: optional list (`image`, `audio`, `video`).
  * `prompt`, `maxChars`, `maxBytes`, `timeoutSeconds`, `language`: per-entry overrides.
  * Failures fall back to the next entry.

  Provider auth follows standard order: auth profiles → env vars → `models.providers.*.apiKey`.
</Accordion>

### `tools.agentToAgent`

```json5 theme={null}
{
  tools: {
    agentToAgent: {
      enabled: false,
      allow: ['home', 'work']
    }
  }
}
```

### `tools.sessions`

Controls which sessions can be targeted by the session tools (`sessions_list`, `sessions_history`, `sessions_send`).

Default: `tree` (current session + sessions spawned by it, such as subagents).

```json5 theme={null}
{
  tools: {
    sessions: {
      // "self" | "tree" | "agent" | "all"
      visibility: 'tree'
    }
  }
}
```

Notes:

* `self`: only the current session key.
* `tree`: current session + sessions spawned by the current session (subagents).
* `agent`: any session belonging to the current agent id (can include other users if you run per-sender sessions under
  the same agent id).
* `all`: any session. Cross-agent targeting still requires `tools.agentToAgent`.
* Sandbox clamp: when the current session is sandboxed and `agents.defaults.sandbox.sessionToolsVisibility="spawned"`,
  visibility is forced to `tree` even if `tools.sessions.visibility="all"`.

### `tools.subagents`

```json5 theme={null}
{
  agents: {
    defaults: {
      subagents: {
        model: 'minimax/MiniMax-M2.1',
        maxConcurrent: 1,
        archiveAfterMinutes: 60
      }
    }
  }
}
```

* `model`: default model for spawned sub-agents. If omitted, sub-agents inherit the caller's model.
* Per-subagent tool policy: `tools.subagents.tools.allow` / `tools.subagents.tools.deny`.

***

## Custom providers and base URLs

Datzi uses the pi-coding-agent model catalog. Add custom providers via `models.providers` in config or
`~/.datzi/agents/<agentId>/agent/models.json`.

```json5 theme={null}
{
  models: {
    mode: 'merge',
    // merge (default) | replace
    providers: {
      'custom-proxy': {
        baseUrl: 'http://localhost:4000/v1',
        apiKey: 'LITELLM_KEY',
        api: 'openai-completions',
        // openai-completions | openai-responses | anthropic-messages | google-generative-ai
        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
          }
        ]
      }
    }
  }
}
```

* Use `authHeader: true` + `headers` for custom auth needs.
* Override agent config root with `DATZI_AGENT_DIR` (or `PI_CODING_AGENT_DIR`).

### Provider examples

<Accordion title="Ollama (recommended)">
  ```json5 theme={null}
  {
    agents: {
      defaults: {
        model: {
          primary: "ollama/qwen3-coder:14b",
        },
        models: {
          "ollama/qwen3-coder:14b": { alias: "Qwen3 Coder" },
        },
      },
    },
  }
  ```

  > Datzi runs on Ollama (qwen3-coder:14b). No external API keys required.
</Accordion>

<Accordion title="OpenCode Zen">
  ```json5 theme={null}
  {
    agents: {
      defaults: {
        model: { primary: "ollama/qwen3-coder:32b" },
        models: { "ollama/qwen3-coder:32b": { alias: "Qwen3" } },
      },
    },
  }
  ```

  Shortcut: `datzi onboard --auth-choice opencode-zen`.
</Accordion>

<Accordion title="Z.AI (GLM-4.7)">
  ```json5 theme={null}
  {
    agents: {
      defaults: {
        model: { primary: "zai/glm-4.7" },
        models: { "zai/glm-4.7": {} },
      },
    },
  }
  ```

  `z.ai/*` and `z-ai/*` are accepted aliases. Shortcut: `datzi onboard --auth-choice zai-api-key`.

  * General endpoint: `https://api.z.ai/api/paas/v4`
  * Coding endpoint (default): `https://api.z.ai/api/coding/paas/v4`
  * For the general endpoint, define a custom provider with the base URL override.
</Accordion>

<Accordion title="Moonshot AI (Kimi)">
  ```json5 theme={null}
  {
    agents: {
      defaults: {
        model: { primary: "ollama/qwen3-coder:14b" },
        models: { "ollama/qwen3-coder:14b": { alias: "Qwen3 Coder" } },
      },
    },
  }
  ```

  > Datzi runs on Ollama (qwen3-coder:14b). No external API keys required.

  Shortcut: `datzi onboard --auth-choice moonshot-api-key-cn`.
</Accordion>

<Accordion title="Kimi Coding">
  ```json5 theme={null}
  {
    agents: {
      defaults: {
        model: { primary: "ollama/qwen3-coder:14b" },
        models: { "ollama/qwen3-coder:14b": { alias: "Qwen3 Coder" } },
      },
    },
  }
  ```

  > Datzi runs on Ollama (qwen3-coder:14b). No external API keys required.

  Shortcut: `datzi onboard --auth-choice kimi-code-api-key`.
</Accordion>

<Accordion title="Local models (Ollama)">
  ```json5 theme={null}
  {
    agents: {
      defaults: {
        model: { primary: "ollama/qwen3-coder:14b" },
        models: { "ollama/qwen3-coder:14b": { alias: "Qwen3 Coder" } },
      },
    },
  }
  ```

  > Datzi runs on Ollama (qwen3-coder:14b). No external API keys required.
</Accordion>

<Accordion title="MiniMax M2.1 (local via LM Studio)">
  ```json5 theme={null}
  {
    agents: {
      defaults: {
        model: { primary: "ollama/qwen3-coder:14b" },
        models: {
          "ollama/qwen3-coder:14b": { alias: "Qwen3 Coder" },
        },
      },
    },
  }
  ```

  > Datzi runs on Ollama (qwen3-coder:14b). No external API keys required.
</Accordion>

<Accordion title="Local models (LM Studio)">
  See [Local Models](/datzi/gateway/local-models). TL;DR: run MiniMax M2.1 via LM
  Studio Responses API on serious hardware; keep hosted models merged for
  fallback.
</Accordion>

***

## Skills

```json5 theme={null}
{
  skills: {
    allowBundled: ['gemini', 'peekaboo'],
    load: {
      extraDirs: ['~/Projects/agent-scripts/skills']
    },
    install: {
      preferBrew: true,
      nodeManager: 'npm'
      // npm | pnpm | yarn
    },
    entries: {
      peekaboo: {
        enabled: true
      }
    }
  }
}
```

* `allowBundled`: optional allowlist for bundled skills only (managed/workspace skills unaffected).
* `entries.<skillKey>.enabled: false` disables a skill even if bundled/installed.
* `entries.<skillKey>.apiKey`: convenience for skills declaring a primary env var.

***

## Plugins

```json5 theme={null}
{
  plugins: {
    enabled: true,
    allow: ['voice-call'],
    deny: [],
    load: {
      paths: ['~/Projects/oss/voice-call-extension']
    },
    entries: {
      'voice-call': {
        enabled: true,
        config: {
          provider: 'twilio'
        }
      }
    }
  }
}
```

* Loaded from `~/.datzi/extensions`, `<workspace>/.datzi/extensions`, plus `plugins.load.paths`.
* **Config changes require a gateway restart.**
* `allow`: optional allowlist (only listed plugins load). `deny` wins.

See [Plugins](/datzi/tools/skills).

***

## Browser

```json5 theme={null}
{
  browser: {
    enabled: true,
    evaluateEnabled: true,
    defaultProfile: 'chrome',
    profiles: {
      datzi: {
        cdpPort: 18800,
        color: '#FF4500'
      },
      work: {
        cdpPort: 18801,
        color: '#0066CC'
      },
      remote: {
        cdpUrl: 'http://10.0.0.42:9222',
        color: '#00AA00'
      }
    },
    color: '#FF4500'
    // headless: false,
    // noSandbox: false,
    // executablePath: "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
    // attachOnly: false,
  }
}
```

* `evaluateEnabled: false` disables `act:evaluate` and `wait --fn`.
* Remote profiles are attach-only (start/stop/reset disabled).
* Auto-detect order: default browser if Chromium-based → Chrome → Brave → Edge → Chromium → Chrome Canary.
* Control service: loopback only (port derived from `gateway.port`, default `18791`).

***

## UI

```json5 theme={null}
{
  ui: {
    seamColor: '#FF4500',
    assistant: {
      name: 'Datzi',
      avatar: 'CB'
      // emoji, short text, image URL, or data URI
    }
  }
}
```

* `seamColor`: accent color for native app UI chrome (Talk Mode bubble tint, etc.).
* `assistant`: Control UI identity override. Falls back to active agent identity.

***

## Gateway

```json5 theme={null}
{
  gateway: {
    mode: 'local',
    // local | remote
    port: 18789,
    bind: 'loopback',
    auth: {
      mode: 'token',
      // none | token | password | trusted-proxy
      token: 'your-token',
      // password: "your-password", // or DATZI_GATEWAY_PASSWORD
      // trustedProxy: { userHeader: "x-forwarded-user" }, // for mode=trusted-proxy; see /gateway/trusted-proxy-auth
      allowTailscale: true,
      rateLimit: {
        maxAttempts: 10,
        windowMs: 60000,
        lockoutMs: 300000,
        exemptLoopback: true
      }
    },
    tailscale: {
      mode: 'off',
      // off | serve | funnel
      resetOnExit: false
    },
    controlUi: {
      enabled: true,
      basePath: '/datzi'
      // root: "dist/control-ui",
      // allowInsecureAuth: false,
      // dangerouslyDisableDeviceAuth: false,
    },
    remote: {
      url: 'ws://gateway.tailnet:18789',
      transport: 'ssh',
      // ssh | direct
      token: 'your-token'
      // password: "your-password",
    },
    trustedProxies: ['10.0.0.1'],
    // Optional. Default false.
    allowRealIpFallback: false,
    tools: {
      // Additional /tools/invoke HTTP denies
      deny: ['browser'],
      // Remove tools from the default HTTP deny list
      allow: ['gateway']
    }
  }
}
```

<Accordion title="Gateway field details">
  * `mode`: `local` (run gateway) or `remote` (connect to remote gateway). Gateway refuses to start unless `local`.
  * `port`: single multiplexed port for WS + HTTP. Precedence: `--port` > `DATZI_GATEWAY_PORT` > `gateway.port` > `18789`.
  * `bind`: `auto`, `loopback` (default), `lan` (`0.0.0.0`), `tailnet` (Tailscale IP only), or `custom`.
  * **Auth**: required by default. Non-loopback binds require a shared token/password. Onboarding wizard generates a token by default.
  * `auth.mode: "none"`: explicit no-auth mode. Use only for trusted local loopback setups; this is intentionally not offered by onboarding prompts.
  * `auth.mode: "trusted-proxy"`: delegate auth to an identity-aware reverse proxy and trust identity headers from `gateway.trustedProxies` (see Trusted Proxy Auth).
  * `auth.allowTailscale`: when `true`, Tailscale Serve identity headers can satisfy Control UI/WebSocket auth (verified via `tailscale whois`); HTTP API endpoints still require token/password auth. This tokenless flow assumes the gateway host is trusted. Defaults to `true` when `tailscale.mode = "serve"`.
  * `auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`.
    * `auth.rateLimit.exemptLoopback` defaults to `true`; set `false` when you intentionally want localhost traffic rate-limited too (for test setups or strict proxy deployments).
  * `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth).
  * `remote.transport`: `ssh` (default) or `direct` (ws/wss). For `direct`, `remote.url` must be `ws://` or `wss://`.
  * `gateway.remote.token` is for remote CLI calls only; does not enable local gateway auth.
  * `trustedProxies`: reverse proxy IPs that terminate TLS. Only list proxies you control.
  * `allowRealIpFallback`: when `true`, the gateway accepts `X-Real-IP` if `X-Forwarded-For` is missing. Default `false` for fail-closed behavior.
  * `gateway.tools.deny`: extra tool names blocked for HTTP `POST /tools/invoke` (extends default deny list).
  * `gateway.tools.allow`: remove tool names from the default HTTP deny list.
</Accordion>

### OpenAI-compatible endpoints

* Chat Completions: disabled by default. Enable with `gateway.http.endpoints.chatCompletions.enabled: true`.
* Responses API: `gateway.http.endpoints.responses.enabled`.
* Responses URL-input hardening:
  * `gateway.http.endpoints.responses.maxUrlParts`
  * `gateway.http.endpoints.responses.files.urlAllowlist`
  * `gateway.http.endpoints.responses.images.urlAllowlist`

### Multi-instance isolation

Run multiple gateways on one host with unique ports and state dirs:

```bash theme={null}
DATZI_CONFIG_PATH=~/.datzi/a.json \
DATZI_STATE_DIR=~/.datzi-a \
datzi gateway --port 19001
```

Convenience flags: `--dev` (uses `~/.datzi-dev` + port `19001`), `--profile <name>` (uses `~/.datzi-<name>`).

See Multiple Gateways.

***

## Hooks

```json5 theme={null}
{
  hooks: {
    enabled: true,
    token: 'shared-secret',
    path: '/hooks',
    maxBodyBytes: 262144,
    defaultSessionKey: 'hook:ingress',
    allowRequestSessionKey: false,
    allowedSessionKeyPrefixes: ['hook:'],
    allowedAgentIds: ['hooks', 'main'],
    presets: ['gmail'],
    transformsDir: '~/.datzi/hooks/transforms',
    mappings: [
      {
        match: {
          path: 'gmail'
        },
        action: 'agent',
        agentId: 'hooks',
        wakeMode: 'now',
        name: 'Gmail',
        sessionKey: 'hook:gmail:{{messages[0].id}}',
        messageTemplate: 'From: {{messages[0].from}}\nSubject: {{messages[0].subject}}\n{{messages[0].snippet}}',
        deliver: true,
        channel: 'last',
        model: 'ollama/qwen3-coder:14b'
      }
    ]
  }
}
```

Auth: `Authorization: Bearer <token>` or `x-datzi-token: <token>`.

**Endpoints:**

* `POST /hooks/wake` → `{ text, mode?: "now"|"next-heartbeat" }`
* `POST /hooks/agent` →
  `{ message, name?, agentId?, sessionKey?, wakeMode?, deliver?, channel?, to?, model?, thinking?, timeoutSeconds? }`
  * `sessionKey` from request payload is accepted only when `hooks.allowRequestSessionKey=true` (default: `false`).
* `POST /hooks/<name>` → resolved via `hooks.mappings`

<Accordion title="Mapping details">
  - `match.path` matches sub-path after `/hooks` (e.g. `/hooks/gmail` → `gmail`).
  - `match.source` matches a payload field for generic paths.
  - Templates like `{{messages[0].subject}}` read from the payload.
  - `transform` can point to a JS/TS module returning a hook action.
    * `transform.module` must be a relative path and stays within `hooks.transformsDir` (absolute paths and traversal are rejected).
  - `agentId` routes to a specific agent; unknown IDs fall back to default.
  - `allowedAgentIds`: restricts explicit routing (`*` or omitted = allow all, `[]` = deny all).
  - `defaultSessionKey`: optional fixed session key for hook agent runs without explicit `sessionKey`.
  - `allowRequestSessionKey`: allow `/hooks/agent` callers to set `sessionKey` (default: `false`).
  - `allowedSessionKeyPrefixes`: optional prefix allowlist for explicit `sessionKey` values (request + mapping), e.g. `["hook:"]`.
  - `deliver: true` sends final reply to a channel; `channel` defaults to `last`.
  - `model` overrides LLM for this hook run (must be allowed if model catalog is set).
</Accordion>

### Gmail integration

```json5 theme={null}
{
  hooks: {
    gmail: {
      account: 'datzi@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'
      },
      model: 'ollama/qwen3-coder:14b',
      thinking: 'off'
    }
  }
}
```

* Gateway auto-starts `gog gmail watch serve` on boot when configured. Set `DATZI_SKIP_GMAIL_WATCHER=1` to disable.
* Don't run a separate `gog gmail watch serve` alongside the Gateway.

***

## Canvas host

```json5 theme={null}
{
  canvasHost: {
    root: '~/.datzi/workspace/canvas',
    liveReload: true
    // enabled: false, // or DATZI_SKIP_CANVAS_HOST=1
  }
}
```

* Serves agent-editable HTML/CSS/JS and A2UI over HTTP under the Gateway port:
  * `http://<gateway-host>:<gateway.port>/__datzi__/canvas/`
  * `http://<gateway-host>:<gateway.port>/__datzi__/a2ui/`
* Local-only: keep `gateway.bind: "loopback"` (default).
* Non-loopback binds: canvas routes require Gateway auth (token/password/trusted-proxy), same as other Gateway HTTP
  surfaces.
* Node WebViews typically don't send auth headers; after a node is paired and connected, the Gateway advertises
  node-scoped capability URLs for canvas/A2UI access.
* Capability URLs are bound to the active node WS session and expire quickly. IP-based fallback is not used.
* Injects live-reload client into served HTML.
* Auto-creates starter `index.html` when empty.
* Also serves A2UI at `/__datzi__/a2ui/`.
* Changes require a gateway restart.
* Disable live reload for large directories or `EMFILE` errors.

***

## Discovery

### mDNS (Bonjour)

```json5 theme={null}
{
  discovery: {
    mdns: {
      mode: 'minimal'
      // minimal | full | off
    }
  }
}
```

* `minimal` (default): omit `cliPath` + `sshPort` from TXT records.
* `full`: include `cliPath` + `sshPort`.
* Hostname defaults to `datzi`. Override with `DATZI_MDNS_HOSTNAME`.

### Wide-area (DNS-SD)

```json5 theme={null}
{
  discovery: {
    wideArea: {
      enabled: true
    }
  }
}
```

Writes a unicast DNS-SD zone under `~/.datzi/dns/`. For cross-network discovery, pair with a DNS server (CoreDNS
recommended) + Tailscale split DNS.

Setup: `datzi dns setup --apply`.

***

## Environment

### `env` (inline env vars)

```json5 theme={null}
{
  env: {
    vars: {},
    shellEnv: {
      enabled: true,
      timeoutMs: 15000
    }
  }
}
```

* Inline env vars are only applied if the process env is missing the key.
* `.env` files: CWD `.env` + `~/.datzi/.env` (neither overrides existing vars).
* `shellEnv`: imports missing expected keys from your login shell profile.
* See [Environment](/datzi/help/environment) for full precedence.

### Env var substitution

Reference env vars in any config string with `${VAR_NAME}`:

```json5 theme={null}
{
  gateway: {
    auth: {
      token: '${DATZI_GATEWAY_TOKEN}'
    }
  }
}
```

* Only uppercase names matched: `[A-Z_][A-Z0-9_]*`.
* Missing/empty vars throw an error at config load.
* Escape with `$${VAR}` for a literal `${VAR}`.
* Works with `$include`.

***

## Auth storage

> Datzi runs on Ollama (qwen3-coder:14b). No external API keys required. Auth profiles are not needed for local Ollama.

***

## Logging

```json5 theme={null}
{
  logging: {
    level: 'info',
    file: '/tmp/datzi/datzi.log',
    consoleLevel: 'info',
    consoleStyle: 'pretty',
    // pretty | compact | json
    redactSensitive: 'tools',
    // off | tools
    redactPatterns: ['\\bTOKEN\\b\\s*[=:]\\s*(["\']?)([^\\s"\']+)\\1']
  }
}
```

* Default log file: `/tmp/datzi/datzi-YYYY-MM-DD.log`.
* Set `logging.file` for a stable path.
* `consoleLevel` bumps to `debug` when `--verbose`.

***

## Wizard

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

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

***

## Identity

```json5 theme={null}
{
  agents: {
    list: [
      {
        id: 'main',
        identity: {
          name: 'Samantha',
          theme: 'helpful sloth',
          emoji: '🦥',
          avatar: 'avatars/samantha.png'
        }
      }
    ]
  }
}
```

Written by the macOS onboarding assistant. Derives defaults:

* `messages.ackReaction` from `identity.emoji` (falls back to 👀)
* `mentionPatterns` from `identity.name`/`identity.emoji`
* `avatar` accepts: workspace-relative path, `http(s)` URL, or `data:` URI

***

## Bridge (legacy, removed)

Current builds no longer include the TCP bridge. Nodes connect over the Gateway WebSocket. `bridge.*` keys are no longer
part of the config schema (validation fails until removed; `datzi doctor --fix` can strip unknown keys).

<Accordion title="Legacy bridge config (historical reference)">
  ```json theme={null}
  {
    "bridge": {
      "enabled": true,
      "port": 18790,
      "bind": "tailnet",
      "tls": {
        "enabled": true,
        "autoGenerate": true
      }
    }
  }
  ```
</Accordion>

***

## Cron

```json5 theme={null}
{
  cron: {
    enabled: true,
    maxConcurrentRuns: 2,
    webhook: 'https://example.invalid/legacy',
    // deprecated fallback for stored notify:true jobs
    webhookToken: 'replace-with-dedicated-token',
    // optional bearer token for outbound webhook auth
    sessionRetention: '24h'
    // duration string or false
  }
}
```

* `sessionRetention`: how long to keep completed cron sessions before pruning. Default: `24h`.
* `webhookToken`: bearer token used for cron webhook POST delivery (`delivery.mode = "webhook"`), if omitted no auth
  header is sent.
* `webhook`: deprecated legacy fallback webhook URL (http/https) used only for stored jobs that still have
  `notify: true`.

See [Cron Jobs](/datzi/automation/cron-jobs).

***

## Media model template variables

Template placeholders expanded in `tools.media.*.models[].args`:

| Variable           | Description                                       |
| ------------------ | ------------------------------------------------- |
| `{{Body}}`         | Full inbound message body                         |
| `{{RawBody}}`      | Raw body (no history/sender wrappers)             |
| `{{BodyStripped}}` | Body with group mentions stripped                 |
| `{{From}}`         | Sender identifier                                 |
| `{{To}}`           | Destination identifier                            |
| `{{MessageSid}}`   | Channel message id                                |
| `{{SessionId}}`    | Current session UUID                              |
| `{{IsNewSession}}` | `"true"` when new session created                 |
| `{{MediaUrl}}`     | Inbound media pseudo-URL                          |
| `{{MediaPath}}`    | Local media path                                  |
| `{{MediaType}}`    | Media type (image/audio/document/…)               |
| `{{Transcript}}`   | Audio transcript                                  |
| `{{Prompt}}`       | Resolved media prompt for CLI entries             |
| `{{MaxChars}}`     | Resolved max output chars for CLI entries         |
| `{{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, etc.) |

***

## Config includes (`$include`)

Split config into multiple files:

```json5 theme={null}
// ~/.datzi/datzi.json
{
  gateway: {
    port: 18789
  },
  agents: {
    $include: './agents.json5'
  },
  broadcast: {
    $include: ['./clients/mueller.json5', './clients/schmidt.json5']
  }
}
```

**Merge behavior:**

* Single file: replaces the containing object.
* Array of files: deep-merged in order (later overrides earlier).
* Sibling keys: merged after includes (override included values).
* Nested includes: up to 10 levels deep.
* Paths: resolved relative to the including file, but must stay inside the top-level config directory (`dirname` of the
  main config file). Absolute/`../` forms are allowed only when they still resolve inside that boundary.
* Errors: clear messages for missing files, parse errors, and circular includes.

***

*Related: [Configuration](/datzi/gateway/configuration) · [Configuration Examples](/datzi/gateway/configuration-examples) · [Doctor](/datzi/gateway/troubleshooting)*
