bots.json Configuration

Configure bots via ~/.botmux/bots.json. Run botmux setup to create it interactively, or edit it by hand. The file is an array; each element is a bot (in production, one bot maps to one dedicated daemon process).

Most fields are optional — just larkAppId / larkAppSecret is enough to run; add the rest as needed. Applies to: manually tuning CLI / model / working dir / permissions / sandbox, etc.; for everyday config the dashboard's Bot Config page is preferred (it edits the same bots.json). Run botmux restart to apply changes.

[
  {
    "larkAppId": "cli_xxx_bot1",
    "larkAppSecret": "secret_1",
    "name": "claude-main",
    "cliId": "claude-code",
    "model": "sonnet",
    "lang": "zh",
    "workingDir": "~/projects",
    "allowedUsers": ["alice@company.com"],
    "allowedChatGroups": ["oc_xxx_team"],
    "p2pOpen": true,
    "oncallChats": [{ "chatId": "oc_xxx_oncall", "workingDir": "~/projects/foo" }]
  },
  {
    "larkAppId": "cli_xxx_bot2",
    "larkAppSecret": "secret_2",
    "cliId": "codex",
    "model": "gpt-5-codex",
    "workingDir": "~/work",
    "autoStartOnNewTopic": true
  }
]

There are many fields, listed below grouped by purpose. The vast majority are optional — just larkAppId / larkAppSecret is enough to get running, and you add the rest as needed.

Required

FieldDescription
larkAppIdLark app App ID
larkAppSecretLark app App Secret

CLI and model

FieldDescription
nameProcess name suffix, e.g. claude-main → botmux-claude-main; leave empty to default to botmux-<index>
cliIdCLI adapter, defaults to claude-code. See Multi-CLI adapters
modelModel name used to launch the CLI (e.g. claude --model opus); leave empty to use the CLI default. Multiple bots with the same cliId can run different models. Each adapter's modelChoices are the candidates offered in botmux setup. Resolved from the current config on every CLI launch, resume included: a change (dashboard or this file) also applies to existing sessions without a captured group override, from their next launch/resume onward. Unlike cliId / cliRuntime / wrapperCli, which are frozen when the session is created so a live conversation never has its runtime swapped underneath it
groupDefaultModelsPer-chat defaults for new topics, e.g. { "oc_team": { "codex": { "model": "your-codex-model", "reasoningEffort": "high" } } }. Supports Codex and Claude; configure each bot from Dashboard group management
reasoningEffortDefault reasoning effort for new sessions. Only applies to CLIs with structured reasoning controls (codex / codex-app / traex / grok); values are validated against the selected CLI/model, and unsupported or undeclared combinations are rejected or ignored
modelBackendVariantTraeX-only backend variant: standard / max; omit it to inherit the user's TraeX global configuration. An explicit value is frozen on a new session's first launch, and non-TraeX CLIs clear the field. A session that explicitly selected its CLI through /cli does not inherit the bot-level variant; it only uses a TraeX value saved in that /cli snapshot
nativeSubagentRuntimeTrae-only native subagent runtime policy. Configure model and reasoningEffort independently as { "mode": "custom", "value": "..." }; an absent dimension passes through the value from the subagent request. Remove the whole field when both dimensions pass through. inherit is not a supported mode
cliRuntimeStructured runtime descriptor for a Codex-compatible distribution: { id, displayName?, executable, update? }. It reuses the codex adapter while retaining its own version, update source, and session identity. See Codex-compatible distributions
cliPathOverrideLegacy CLI entry-point override, retained for wrappers / routers and existing custom binaries. Prefer cliRuntime for a new Codex-compatible distribution. To support downgrading BotMux, writers also persist an exact compatibility shadow of cliRuntime.executable; do not manually configure mismatched values
disableCliBypassWhen true, the CLI's auto-approve / sandbox-bypass flags (--yolo, --dangerously-*) are not appended automatically; omitted / false keeps the original behavior
backendTypeSession backend, one of pty / tmux / herdr / zellij. Leave empty to default to tmux (PTY auto-fallback is retired): when a persistent backend (tmux/herdr/zellij) isn't available on this host it hard-gates and posts a card asking you to install it — it does not silently downgrade to pty (zellij requires ≥ 0.44). pty is an explicit fallback only (backendType:"pty" or BACKEND_TYPE=pty) — attaches directly to the process and does not survive daemon restarts. See tmux backend
launchShellShell used to launch the CLI, overriding the daemon's $SHELL: a shell name (zsh / bash / fish / sh) or an absolute path (e.g. /usr/bin/zsh). For when the login $SHELL (e.g. bash) has an rcfile that exec-trampolines into another shell (exec zsh), pre-empting the CLI under botmux's bash -i launch so the session never starts (bare-shell parse error) — pinning it launches under that shell directly, bypassing the skipped rcfile. Note: PATH / nvm / pnpm must then live in the chosen shell's rcfiles (e.g. .zshrc / .zprofile, or ~/.config/fish/config.fish for fish). fish is a first-class launch shell: launchShell: "fish" and absolute fish paths (e.g. /usr/bin/fish) are supported, and the desktop PATH probe reads fish when $SHELL is fish, so fish users don't need to mirror PATH / env into .bashrc / .zshrc. Empty = use $SHELL. Takes effect next session for shell-wrapped persistent backends (tmux / zellij / zmx); pty execs the CLI directly and is unaffected. Also configurable in the dashboard ("Bot defaults → Launch shell") or via /config launchShell <value>
langThe bot's UI language, zh / en; leave empty to fall back to the BOTMUX_LANG / LANG environment variable
customPassthroughCommandsOn top of the fixed passthrough allowlist and the current CLI adapter's default-allowed commands, additionally pass through slash commands to the underlying CLI, e.g. ["/export"] (Claude Code / Codex default-allow /goal). Auto-normalized (a missing / is added, lowercased, only [a-z0-9:_-] kept, deduplicated); entries that would shadow a botmux daemon command (e.g. /status) are dropped and have no effect even if configured. Use /list-slash-command to view the full allowlist. See Slash commands
envPolicyExplicit process inheritance policy: inherit by default; strict retains only baseline, approved names and this bot env (see below).
envPer-bot process environment variables { "KEY": "value" }, injected into this bot's CLI process. Most common use: run a bot on GLM / a third-party Anthropic·OpenAI-compatible provider (see example below); also handy for HTTPS_PROXY or a CLI feature flag. Values accept string / number / boolean; botmux-reserved keys (BOTMUX_, LARK_APP_, …) are ignored. Injected per session (effective from the next session), never written to the shared tmux server env, so it can't leak across bots. Also editable in the dashboard ("Bot defaults → Environment variables")
quotaFallbackBotOptional handoff after the CLI exhausts its quota: { "enabled": true, "targetAppId": "cli_...", "kinds"?: ["usage", "rate"], "message"?: "..." }. Off by default; editable under Dashboard "Bot Configuration → Advanced." See below
codexAppCleanInputExperimental, and only effective for Botmux-managed sessions whose actual CLI is codex-app. When true, the visible / persisted text UserMessage contains only the user's original input while message-level Botmux context primarily moves to additionalContext. Defaults to off, takes effect on the next turn dispatch, and does not rewrite existing history. See details below
codexBrowserExperimental and off by default. Supported only with cliId: "codex-app". Toggle it under Dashboard Advanced → Codex App, or set it to true to let new sessions control Chrome through the locally installed Codex Chrome plugin. Object form: `{ "enabled": true, "family": "chrome"

nativeSubagentRuntime rewrites only new subagents created through Trae's native spawn_agent; it does not alter the parent agent itself. An absent dimension passes through the subagent request, while custom replaces it with a fixed value. When both a custom model and custom effort are configured, BotMux validates that Trae supports the combination. Switching the bot to another CLI removes this field automatically. In the Dashboard, “Pass through request” corresponds to an absent dimension. This policy is behavior configuration and is copied when cloning a bot, but it is intentionally excluded from portable Agent presets. Legacy mode: "inherit" values are invalid and are not applied.

Per-group defaults for new topics

Each bot owns its own groupDefaultModels, keyed by chat ID and CLI. The Dashboard follows the bot’s Agent CLI and shows only its model and reasoning-effort dropdowns, reusing Agent model discovery and effort validation. Both fields can inherit the Agent defaults. Custom models and legacy string-valued entries remain supported. A new topic captures these settings when its session is created; later edits or clearing the group configuration do not alter that topic on restart or resume. Selecting a CLI uses only its matching entry. Topics without a group override retain the existing live bot-model fallback. Direct messages, chat-scoped group sessions, and adopted external sessions do not use the snapshot.

Precedence: explicit trigger model > captured group model > matching bot model > existing cross-CLI fallback. Reasoning effort is also captured for new topics; explicit trigger settings can override it. The CLI and runtime remain unchanged. Dashboard saves apply without restarting the daemon. Choosing inheritance removes the corresponding override for future topics while retaining historical settings for other CLIs; manual file edits follow the existing configuration-loading procedure.

Automatic CLI quota handoff

quotaFallbackBot lets the daemon post one fixed, real @ to a backup Bot at the original session landing point once the current CLI is confirmed quota-limited. It does not call the exhausted primary model, and the existing limit card and owner notification remain unchanged.

Quota-limit handoff under Dashboard Bot Configuration → Advanced

{
  "quotaFallbackBot": {
    "enabled": true,
    "targetAppId": "cli_xxx_backup",
    "kinds": ["usage", "rate"],
    "message": "The primary Bot has exhausted its quota. Please take over this conversation and continue from its context."
  }
}
  • targetAppId is the backup Bot's stable Lark App ID. Never configure or copy an ou_xxx: open IDs are scoped to the sending application. At send time, the daemon resolves a receiver-scoped mention handle from the current chat's live membership.
  • kinds accepts usage and/or rate; omitting it enables both. Omitting message uses the built-in Chinese handoff text. The message is limited to 1000 characters and must be non-blank without a native <at> tag.
  • The target must be a locally configured Bot that is currently in the chat. Cross-deployment/team-directory targets are not supported yet because the daemon cannot safely prove which live open_id belongs to a remote App ID. Non-local, self, absent, and live-resolution failures all fail closed.
  • Save and Bot clone validate the complete impending handoff graph and reject self-reference or cycles such as A → B → C → A; an acyclic chain may continue cascading. If a manual edit introduces a cycle, botmux start/restart skips Bots in that cycle while still starting the Dashboard and unrelated Bots. Bot Config marks the skipped Bots and lets an operator repair the edge under Advanced → Quota-limit handoff; restart after saving to bring them online. A supervisor-driven daemon reload still disables cyclic handoff at load time and logs a warning so malformed configuration cannot spread its impact.

Dashboard marks Bots skipped because of a handoff cycle and opens the Advanced recovery controls

  • Within a daemon, the source Bot and limit kind are deduplicated across all sessions for five minutes. A failed identity lookup or delivery still occupies that window to prevent a short retry storm.
  • Chat-scoped sessions land in the original chat and thread-scoped sessions land in the original thread. The backup Bot reads context from the existing history. Restoring a daemon with an already-limited session does not backfill an old handoff.
  • The whole feature is inert when the block is absent or enabled is not exactly true, preserving previous behavior. Configure it under Dashboard "Bot Configuration → Advanced → Quota-limit handoff," or edit bots.json manually.

Codex-compatible distributions

An independently released CLI that preserves Codex's arguments, interaction, rollout / resume, and authentication semantics does not need a new cliId. Keep cliId: "codex" as the protocol adapter and describe the concrete runtime separately:

{
  "cliId": "codex",
  "cliPathOverride": "vendor-codex",
  "cliRuntime": {
    "id": "vendor-codex",
    "displayName": "Vendor Codex",
    "executable": "vendor-codex",
    "update": { "provider": "npm", "packageName": "@vendor/codex" }
  }
}
  • id is a stable identity using letters, numbers, ., _, or -, up to 64 characters. Changing it is treated as switching distributions.
  • executable is one executable name or path, not a shell command; do not append arguments. The Dashboard performs a read-only --version probe before saving, and its output must contain a recognizable X.Y.Z version.
  • displayName controls cards, status, and Dashboard labels only; it defaults to id.
  • update.provider is one of auto, self, npm, or none. auto trusts only a unique npm package proven to own that exact binary. If no source can be established, the runtime is shown as unmanaged and is never compared with official Codex. Only self uses the CLI's structured doctor data, and its current version must match --version; npm requires the distribution's own packageName; none disables update checks for that runtime.
  • cliRuntime currently applies only to cliId: "codex" and cannot be combined with wrapperCli. BotMux writers generate a cliPathOverride downgrade shadow that exactly matches executable: new versions use cliRuntime as canonical, while old versions still launch the same binary from the shadow. Manual configs must include the same exact shadow as shown above; a missing or mismatched value fails validation so an accepted config is always safe to roll back. Wrappers and gateways keep using the legacy entry-point mechanism below.
  • Existing cliPathOverride configs remain launch-compatible and receive the same safe auto update behavior. The Dashboard shows them in a read-only compatibility state: model-only saves preserve the old entry point, choosing Official Codex explicitly clears it, and choosing Custom Compatible migrates it to cliRuntime. Because a raw path does not assert the full compatibility contract, Codex RPC enhancements remain disabled.

A session freezes its runtime snapshot when created. Model-only changes affect new sessions; switching CLI, runtime, or wrapper immediately closes active sessions that still use the old launch identity so they cannot lazy-resume into the wrong distribution. Existing sessions are never silently switched to another runtime.

Run a bot on GLM / a third-party provider (per-bot env)

Run one bot on a GLM Coding Plan (or any Anthropic-compatible provider) while another keeps using official Claude — give the former an env:

{
  "cliId": "claude-code",
  "env": {
    "ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",
    "ANTHROPIC_AUTH_TOKEN": "your GLM Coding Plan key"
  }
}
  • For GLM in China, use https://open.bigmodel.cn/api/anthropic for ANTHROPIC_BASE_URL.
  • For an OpenAI-protocol CLI like Codex, set OPENAI_BASE_URL / OPENAI_API_KEY (the provider's OpenAI-compatible endpoint) instead of ANTHROPIC_*.
  • Isolation: env is injected per-session into the CLI process, consistently across backends (tmux / zellij inject it per-pane, never into the shared server env), so one bot's provider config can't leak into another's.
  • Security: values live in bots.json and the process environment in plaintext — not a secret vault; chat configuration queries mask values and Dashboard returns names only.
  • Takes effect from the next session.

Clean Codex App input (experimental)

codexAppCleanInput keeps user messages shown in Codex App clean while preserving the context Botmux needs when invoking the model. It defaults to false / off; when disabled, Botmux keeps the original combined-prompt behavior unchanged.

An owner or allowedUsers member can hot-update it with /botconfig; no daemon restart is required:

/botconfig set codexAppCleanInput on
/botconfig set codexAppCleanInput off

You can also add it to the corresponding bot entry directly (manual bots.json edits still require the restart described at the end of this page):

{
  "cliId": "codex-app",
  "codexAppCleanInput": true
}
  • The flag applies only to Botmux-managed sessions whose actual CLI is codex-app; other CLIs and externally bridged /adopt sessions are unaffected. A session-frozen CLI takes precedence over a later bot-default CLI change.
  • When enabled, user-authored turns use the original text as the Codex App text UserMessage; Botmux-authored synthetic turns such as external triggers and document prewarm use a short readable label. Message-level sender, mentions, attachment paths, quotes, role, whiteboard, Skills, and synthetic-turn instructions primarily move to hidden additionalContext. Readable absolute-path images are also sent as localImage; missing, relative, or unreadable images skip the native image item with a diagnostic while their attachment path remains in context.
  • A detectable Codex CLI >= 0.135 enables clean text plus additionalContext; >= 0.136 also attaches a separate clientUserMessageId. Older or unknown versions use the legacy combined prompt directly.
  • The runner retries the legacy prompt once only when app-server explicitly rejects additionalContext / clientUserMessageId before turn/started, then disables clean mode for that runner lifetime. Network, timeout, model, and generic turn errors are never auto-retried, avoiding duplicate work.
  • A /botconfig change is sampled at the next dispatch to the Codex worker. For ordinary live messages this is normally the next message; a first turn waiting on repo selection is sampled when the repo is committed. Already queued or running turns are not rewritten, and existing history is never backfilled.
  • additionalContext is omitted from the ordinary Codex App user-message bubble, but it may still be retained in raw rollout or diagnostic records. When enabled, Botmux also keeps the legacy prompt and structured sidecar for compatibility fallback and retry_last_task. This feature improves App presentation and ordinary history reading; it is not a privacy-erasure or security-redaction mechanism.

Codex App browser bridge (experimental)

Install the browser runtime bundled with the Codex desktop app. The bridge prefers mcp_servers.node_repl, but locates the installed runtime when the desktop removes that MCP registration; set BOTMUX_CODEX_NODE_REPL_PATH for a custom installation. Identity, site safety status, and feature configuration use the official authenticated request channel. The bridge does not read or store login tokens, and fails closed when the runtime or authentication is unavailable.

This option addresses one narrow gap: Codex running through Botmux's app-server path does not otherwise inherit the Chrome tool built into Codex App. The bridge belongs entirely to Botmux and has no dependency on a project repository, Harness, or local-proxy setup.

{
  "cliId": "codex-app",
  "codexBrowser": true
}
  • Install and enable the Codex browser extension in Chrome / Edge under the same OS user first. Botmux selects the newest complete official plugin under CODEX_HOME (or ~/.codex) automatically; use an absolute pluginRoot only for a maintained custom location.
  • The setting registers the botmux_browser dynamic tool when starting or resuming a Codex App thread. Restart or resume an already-running runner to load the updated tool definition.
  • The bridge probes capabilities per tab. It prefers the accessibility tree, falls back automatically to visible-DOM / Playwright-DOM snapshots when AX is unavailable, and exposes typed Playwright locator, DOM, and coordinate operations. A backend without tab.ax no longer breaks the entire Chrome connection.
  • Browser Use safety requests for origin access, uploads, downloads, and similar actions block the operation and appear as Lark authorization cards. Only users accepted by Botmux's canTalk check can choose session approval, persistent approval, or denial; Botmux ask records the answer and reviewer. A plain approval is also scoped to the current runner session and is reused for the same origin, operation type, and risk context; a different origin, operation type, risk context, or new session requires confirmation again. Denial, timeout, or daemon unavailability fails closed.
  • The tool does not expose arbitrary JavaScript, raw CDP, cookies, local storage, browser history, or clipboard. Uploads/downloads run only through Browser Use's controlled file chooser and safety checks. Secure browser-auth flows involving credentials never downgrade to an ordinary Lark card and require a client with a secure credential broker.
  • Each Botmux runner owns isolated browser-session state. The feature is off by default, so unconfigured bots retain their existing launch arguments and behavior.
  • It currently cannot be combined with existingAppServer, sandbox, or readIsolation; conflicting configuration fails at startup instead of running with an incomplete isolation boundary.

Working directory

FieldDescription
workingDirDefault working directory, supports a comma-separated list. Recursively searches downward for git repositories from this directory (up to 3 levels), never scans upward
workingDirsArray form of working directories (["~/a", "~/b"]); takes precedence over the comma-separated form of workingDir when explicitly configured
defaultWorkingDirDefault directory for a single repository: with no oncall and no sibling session in the same group, enters it directly and skips the repo selection card. /cd can still switch mid-session. Purely a runtime fallback — does not write state and does not change the permission model

Permissions and authorization

FieldDescription
ownerOpenIdExplicit primary owner ou_xxx for this bot. It participates in runtime authorization only while it remains in the resolved allowedUsers list; after removal or resolution failure, permissions follow the resolved allowlist, while the raw value is retained only as a DM fallback for resolution failures. When omitted, ownership defaults to the first resolved ou_xxx user. When multiple administrators are configured, grant request cards prioritize @mentioning administrators who are currently present in the chat (avoiding pinging people outside the chat)
allowedUsersThe operate-permission list. Prefer a full email, mobile number, or on_xxx; an ou_xxx is valid only for the same app that issued it and must never be copied across Bots. When allowedChatGroups is configured, at least one is required to serve as owner
allowedChatGroupsConversable groups (oc_xxx). Any member of the group can converse (only canTalk); sensitive operations are still controlled by allowedUsers
p2pOpenWhen true, any user within the Lark app's availability scope may DM this bot (only canTalk). Group behavior is unchanged and sensitive operations still require allowedUsers. Always configure at least one allowedUsers owner
oncallChatsOncall bindings, [{ "chatId": "oc_xxx", "workingDir": "~/projects/foo" }]. See oncall
defaultOncallThe bot's default: the first new topic in a new group chat is automatically bound to oncall. { "enabled": true, "workingDir": "~/foo", "since": <epoch ms> }; older groups that already existed before since are unaffected
globalGrantsGlobal conversable list (ou_xxx, people or bots). Can converse in any group, only canTalk
chatGrantsPer-group, per-user authorization { "oc_xxx": ["ou_yyy"] }, only grants canTalk. Usually written by the /grant card, but can also be configured by hand
messageQuotaMessage-quota override { "defaultLimit": N }: applies only to grantees admitted by grant cards or self-service requests — once a positive integer is configured, new grant cards use an N-message quota; when unset they default to 3 messages per person. Oncall groups are always unmetered and never read this value. An explicit /grant @user N always uses N. Only constrains talk authorization, does not affect canOperate
restrictGrantCommandsWhen true, people granted only via per-user authorization (chatGrants / globalGrants) are disabled from all slash commands and can only have plain conversations; owner / allowedUsers / oncall / whole-group members are unaffected. Defaults to false
autoGrantRequestCardsEnabled by default. Set to false to stop automatically sending /grant request cards to the owner when an unauthorized person or external bot @mentions this bot in a group and the talk gate blocks it; the message is dropped silently instead
grantRequestToOwnerDmOff by default. When true and no admin in the conversation can click the request card (no admin in the group, or a rejected DM), the card is sent to the primary owner's DM instead; the requester gets a neutral acknowledgement and the outcome is posted back to the original conversation. Capped per owner (20 cards per hour; failed sends do not count); over the cap or on a send failure no card is posted and the next message retries. Requires autoGrantRequestCards to stay on. See Permissions & Access · Grant request cards
blockedUsersBlock list (same identifier forms as allowedUsers: email / mobile / on_xxx / ou_xxx), a sender-dimension global deny: effective in both groups and DMs, and evaluated before every allow leg — on-call, whole-group open, guest grants, team trust. A blocked sender gets no grant request card. Owners / admins cannot be blocked (the write entry refuses it). It does not affect message-listener matching. Also maintained in Dashboard Bot Config and the group-member modal. See Permissions & Access · Block list

File sandbox

FieldDescription
sandboxWhen true, launch new sessions in the Linux file sandbox. Writes are isolated and must be landed with /land
sandboxHidePathsPaths masked inside the sandbox with empty dirs/files so the bot cannot read them, e.g. ["~/.ssh", "~/.botmux/bots.json"]
sandboxReadonlyPathsExtra existing paths mounted read-only inside the sandbox, useful for shared source snapshots, reference repos, or generated docs the bot should inspect but not modify
sandboxNetworkNetwork policy for sandboxed sessions. Omitted / true keeps current network and proxy access; false adds --unshare-net and blocks normal network egress

ZMX cannot enforce the file sandbox or effective read isolation, so configurations that enable those boundaries fail closed; see ZMX backend boundaries.

Cards and terminal

FieldDescription
brandLabelBranding text at the bottom of the card (rendered on final replies and top-level broadcasts only). undefined = default Powered by [botmux](https://github.com/deepcoldy/botmux) with :LOVE:; "" = hidden; any other string = rendered as-is (supports markdown). Also gated by the machine-wide dashboard.cardBrandLabel switch (Dashboard "Settings → Feishu Cards", on by default): when off, no bot shows a footer signature and this field is greyed out on the bot config page. Purely cosmetic, does not affect routing / permissions
showUsageInCardFooterWhether reply-card footers show native Context / Token usage from the Agent CLI. Missing / true = show; false = hide both metrics. A missing individual metric is still omitted independently. This controls card display only and does not disable the Usage Ledger or other accounting
modelBackendVariant displayA frozen TraeX backend variant appears only in the runtime identity on the live streaming session card. Reply-card footers show Context / Token usage only; they do not show the variant
disableStreamingCardWhen true, no real-time streaming session card is sent at all (the Web Terminal still runs and the final reply still arrives via botmux send, there's just no auto-refreshing status card). For users who find the real-time card noisy
hiddenStreamingCardButtonsHides selected main controls on live streaming cards. Values: output (also hides text export and screenshot refresh), terminal, writeLink, compact, stop, and close (Disconnect on adopted sessions). Missing or empty shows every control, for example ["terminal", "writeLink", "close"]. Hot-update with /botconfig set hiddenStreamingCardButtons terminal,writeLink,close; unset restores all controls
pinStreamingCardWhen true, the bot pins the current public live-status card. It is opt-in and default-off: only an explicit true enables it. Only the current public live-status real streamCardId participates; repo-picker cards, private /card snapshots, final reply cards, CoT, closed cards, and every other interactive card stay out of scope. The switch is hot-updated: once dashboard or /botconfig set pinStreamingCard on/off successfully writes local config and changes the effective value, Botmux runs a best-effort reconciliation across this bot's existing active sessions, and after a daemon restart it also schedules one fire-and-forget recovery pass for the current bot after restoreActiveSessions. The configuration response and daemon readiness do not wait for Feishu Pin/Unpin calls. Failures never interrupt publication, transfer, resume, close, startup, or configuration itself; during exceptional periods there may temporarily be zero or multiple Pins. This feature adds no durable retry journal and no broad remote cleanup: restart recovery only trusts Feishu Pins whose operator provenance is app_id === current larkAppId, then narrows cleanup to the strict intersection with the enqueue-time local candidate IDs. A colliding current Pin with human, other-app, mixed, or malformed provenance is neither claimed nor re-pinned; an absent current Pin is claimed only when create returns the exact message ID and same-app provenance. Explicit off cleans process-owned IDs plus freshly proven local candidates, while ordinary disable, close, and transfer remain process-ownership-only
noPinStreamingCardChatsArray of chatIds where Botmux must not pin streaming cards even when pinStreamingCard is enabled for the bot. This is the negative set behind `/card pin off
silentTurnReactionsWhen true, card-off sessions no longer add GoGoGo / DONE reactions to the triggering message. Only affects the lightweight status reactions used when disableStreamingCard or noCardChats suppresses live cards; defaults to false
receivedReactionEmojiFeishu emoji_type for the "received" reaction in card-off sessions; undefined = default GoGoGo (冲!). Free-form string; a bad value just silently fails to attach (best-effort)
doneReactionEmojiFeishu emoji_type for the "done" reaction in card-off sessions; undefined = default DONE (✅). Set it equal to receivedReactionEmoji to keep the marker unchanged on turn-end — handy for CLIs whose idle detection can fire early (e.g. Pi), avoiding a premature, misleading ✅
writableTerminalLinkInCardWhen true, the card body directly embeds a writable terminal link (with token, anyone who can see the card can operate it); by default it's hidden behind a "Get write permission" button and sent privately to whoever clicks. Meaningless when disableStreamingCard is enabled
privateCardWhen true, /card uses an ephemeral private card visible only to allowedUsers (talk grantees and the bare triggerer don't receive it), only effective in plain group chats, and cannot live-update. Only affects the /card command itself

Prompt injection

FieldDescription
senderTagBoolean, default true (on). Whether each turn forwarded to the CLI carries a <sender type="user|bot" open_id="ou_…" name="…" email="…" /> tag naming who spoke. Only an explicit false is persisted and disables it; absent or true both keep injecting, leaving the prompt byte-for-byte identical to historical behavior
replyDelivery"transcript" or "send"; every CLI defaults to send (matching upstream behaviour), and transcript must be turned on explicitly. How the final reply reaches Feishu: transcript = the daemon takes the last assistant text of the turn from the CLI transcript and posts it as the final reply card, and the system prompt no longer mentions botmux send; send = the model must run botmux send itself (historical behavior). An explicit "send" is the only way to put claude-code back on the old behavior; both send and transcript are persisted, unset returns to the CLI default

replyDelivery: "transcript"

claude-code defaults to transcript; the other supported CLIs need it set explicitly. Once active it changes three things for that bot's sessions:

  1. The system prompt never mentions botmux send: the intro becomes "your final assistant message is automatically forwarded back to Lark by botmux — just answer directly"; the heredoc rule, the @ decision gate, the attachment usage and the <identity> rule "collaboration requires botmux send --mention" are all dropped, leaving only botmux history / botmux bots list and the BOTMUX_NOTHING_TO_SEND silence sentinel. For the cases that genuinely need botmux send (attachments, cross-bot @) the model can discover the built-in skill (botmux-send under --plugin-dir) on its own;
  2. The per-turn <botmux_reminder> is no longer injected (one less reminder block per prompt);
  3. Solo sessions are unwrapped: in a DM, or a plain 1:1 group whose only participants are the owner and this bot, each turn drops the <user_message> wrapper and the <sender/> tag, so the model sees bare text. Topic groups, multi-member groups, and turns spoken by anyone other than the owner never count as solo; wrapper and tag stay as before.

Supported CLIs: claude-code, plus the structured-transcript bridge CLIs codex / traex / coco / hermes / mtr / pi / oh-my-pi / ebsd / grok. Other CLIs (e.g. cursor, gemini) have no transcript capture, so both /botconfig set and the dashboard reject the value (reply_delivery_unsupported); if the field is already persisted and cli is later switched to an unsupported CLI, the runtime falls back to send (one warn in the log) rather than losing replies.

Hot-updatable by the owner / allowedUsers via /botconfig:

/botconfig set replyDelivery transcript   # enable explicitly on the other supported CLIs
/botconfig set replyDelivery send         # put claude-code back on the old behavior (model runs botmux send itself)
/botconfig unset replyDelivery            # back to the CLI default
  • Two activation points: the per-turn envelope (reminder / wrapper / <sender/>) applies from the next turn; the system prompt is injected at spawn time, so a running session needs /restart to pick up the new value, while new sessions use it directly.
  • Observability cost: the bare-text shape of a solo session has no <user_message> / <sender> structure, so /adopt no longer recognizes such sessions as botmux's own (the same class of cost as senderTag: false).
  • The dashboard "Reply Delivery → Transcript reply mode" toggle saves this field; it is disabled with an explanation when the current CLI does not support it.

senderTag: false

With it off the model cannot see speaker identity: in a multi-person chat it cannot tell participants apart or address them by name. Useful for a CLI whose model copies the tag into its reply body (e.g. cursor — see the <sender_note> anti-echo hint, which disappears together with the tag), or when you do not want per-message identity written into the CLI transcript.

Hot-updatable by the owner / allowedUsers via /botconfig, no daemon restart:

/botconfig set senderTag off
/botconfig set senderTag on

Or write it directly into the bot's config:

{
  "senderTag": false
}
  • botmux send --mention-back is unaffected: it reads the daemon-side record of this turn's triggerer (replyTargets[turnId].senderOpenId), a separate path from this prompt tag.
  • Turning it off costs two observability signals: (1) /adopt loses one fingerprint for recognizing this bot's own sessions (the remaining structural checks still cover every prompt shape we ship, so self-produced sessions are not listed as external); (2) dashboard session insight can no longer read speaker type or A2A agent name from the tag, falling back to the [来自 … 的 @mention] handoff marker — with no such marker, that turn shows no source.
  • Applies immediately (from the next turn); it does not rewrite queued or in-flight turns, nor backfill existing history.
  • The dashboard "Speaker Tag" toggle saves this field.

Proactive start

FieldDescription
autoInviteOwnerOnGroupAddOn by default: when the bot is added to a new chat, its owner is automatically added to the same chat so the bot never ends up somewhere its owner cannot see; set false to turn it off (useful when an alert/on-call platform batch-adds the bot to incident chats). Applies only to the bot being passively added. Editable in Dashboard → Bot defaults → Proactive start or via /botconfig set autoInviteOwnerOnGroupAdd off; switching it back on clears the key to the default
autoStartOnGroupJoinWhen true, the bot starts working automatically when added to a new group containing at least one allowedUsers member (no @ needed). Requires subscribing the im.chat.member.bot.added_v1 event for this app in the Lark admin console
autoStartOnGroupJoinPromptPaired with the above: the first-round prompt for proactive start; if empty / blank, opens with an empty message and lets the bot read the group context itself. Meaningless when autoStartOnGroupJoin is off
autoStartOnNewTopicWhen true, the first message of every new topic in a topic group starts working automatically without an @ (no effect in plain groups). Defaults to passive (only @ triggers)
groupJoinCommandEnabledWhen true and groupJoinCommand is non-empty, the bot runs that command on this host whenever it is added to any chat — no session, no model. Independent of autoStartOnGroupJoin (no allowedUsers membership requirement). Also requires the im.chat.member.bot.added_v1 event. Editable in Dashboard → Bot defaults → Auto-start
groupJoinCommandThe command to run on join. Same execution contract as Hooks: no shell (use bash -c '…' for pipes/redirects), minimal environment (no app secret); stdin is JSON {event:"chat.bot_added", larkAppId, chatId, operatorOpenId, emittedAt}, plus BOTMUX_JOIN_CHAT_ID / BOTMUX_JOIN_LARK_APP_ID / BOTMUX_JOIN_OPERATOR_OPEN_ID env vars; the process group is killed after 10 minutes

Group message listener

Have a bot actively watch a group: matching group messages start a session automatically, no @ required. The classic use is alert operations — your monitoring/alerting system usually already has its own Lark bot posting alerts into a group, so just add this bot to that group and enable the listener; every alert triggers an investigation session, with no need to set up a separate Webhook integration point.

Configure it per-group in the Dashboard "Roles → Message Listener" tab (with Preview of the last 24h of matches and a dry run to validate); or write messageListeners directly in bots.json (keyed by chat_id, valued by the config below):

FieldDescription
enabledWhether the listener is on for this chat. prompt is required when enabled, otherwise the whole entry is ignored
promptListener prompt: tells the bot which messages to handle and how to reply. A matched message is replied to in a new topic beneath it
nameListener name (optional), e.g. "Alert listener", shown in the Dashboard
replyCardTitleReply card title (optional); blank uses the default
workingDirWorking directory for sessions this listener starts (optional); blank uses the bot's default
senderPolicy.modeall_except_excluded (blacklist, default): handle every matching sender type except the excluded ones; include_only (whitelist): handle only the senders in includeSenderOpenIds
senderPolicy.includeSenderTypesSender types to listen to: ["user"] / ["bot"] / both. Listening to a third-party alert bot must include "bot"
senderPolicy.includeSenderOpenIds / excludeSenderOpenIdsExact whitelist / blacklist by open_id
senderPolicy.excludeSelfDefault true; always excludes the bot's own messages (prevents self-triggering)
messagePolicy.includeMsgTypesMessage types to listen to; defaults to text + rich text (post)
{
  "messageListeners": {
    "oc_xxxxxxxxxxxxxxxx": {
      "enabled": true,
      "name": "Alert listener",
      "prompt": "Every alert in this group is a production event. Identify the affected service and give an initial investigation direction; if it's a false alarm, explain why.",
      "senderPolicy": { "mode": "all_except_excluded", "includeSenderTypes": ["bot"] }
    }
  }
}

Conventions and limits (V1):

  • Top-level group messages only: ordinary replies inside an existing topic are not handled; a message that explicitly @s this bot still goes through normal @ routing (no double trigger).
  • One session per matched message, replied to in a new topic beneath it.
  • Delivery: the realtime event path covers messages Lark pushes; messages from other bots, and non-@ messages, are backfilled by a history poll roughly every 30s (so up to ~30s of latency). That's why listening to a third-party alert bot works most reliably in blacklist mode (all_except_excluded + include "bot") — whitelist matches by open_id, but the history API reports third-party bots by app_id, which may not resolve to an open_id and therefore won't match.

Summary command

FieldDescription
summaryRangeHistory range used by the explicit @bot /summary command. limit is the latest N messages in a regular group, defaulting to 50; sinceHours is the latest N hours in a regular group, defaulting to 24. Set either field to 0 to remove that limit. Topic groups always read the current topic/thread history, then apply the summary window
summaryMemoryBoolean, defaults to false (off). When enabled, @bot /summary turns the summary into a Chinese "problem-resolution record" appended to the memory file named by summaryMemoryPath below, instructs the agent to write only that one file and echo back the exact Markdown written, and injects a <summary_memory> reuse hint into later turns so a later question reuses a past conclusion only when key conditions — PSM, environment, task ID, node, error symptom, etc. — match exactly; otherwise the file is treated as reference only
summaryMemoryPathMemory file path, defaults to summary.md. A relative path is resolved by the agent against the "current project root"; an absolute path is used as-is. Empty / unset falls back to summary.md. Only takes effect when summaryMemory is true

Example:

{
  "summaryRange": {
    "limit": 50,
    "sinceHours": 24
  },
  "summaryMemory": true,
  "summaryMemoryPath": "docs/summary.md"
}
  • Only the explicit @bot /summary command triggers a summary. Messages that do not mention the bot still follow the existing group/topic routing rules and are not woken up by keywords.
  • The dashboard "/summary Range" controls this summaryRange field; the "Enable memory" toggle and "Memory file path" input save summaryMemory and summaryMemoryPath respectively.
  • If an earlier @same bot /summary exists before the current trigger, the summary window includes only messages after that earlier command and up to the current trigger; otherwise botmux falls back to limit / sinceHours.
  • limit and sinceHours are safety caps for the default (no explicit boundary) summary window. If both are 0, that dimension is not limited. An explicit boundary intentionally takes precedence over these caps: when summaryMemory is on and /summary carries boundary text, botmux honors the user's explicit "start from this message" intent and includes everything from the matched boundary onward — in a regular group limit still bounds the scan, but a boundary older than sinceHours, and any arbitrarily old boundary in a topic group, is accepted and may exceed the default configured range. If you do not want a bot to read in very old content, the reliable approach is to omit the boundary text; in a regular group you can also lower limit to bound the scan (but sinceHours, and any boundary in a topic group, are not constrained by the configured range).
  • Only when summaryMemory is enabled, text following the /summary command is treated as a hard boundary: it locates the most recent message before the trigger that contains that text and summarizes only from there up to the current trigger; if the boundary is not found in the scanned history, botmux does not fall back to a wider range but hands the agent a "boundary not found" error together with an empty history (the memory write instruction still runs). When summaryMemory is off, text after /summary is only a focus hint for the summary and the history window still follows summaryRange.
  • The memory file is written by the agent within its working directory. If the bot has sandbox enabled and summaryMemoryPath points outside the working directory (an absolute path, or a relative path that escapes via ../), add the file's existing parent directory to sandboxPaths.readWrite; the worker filters out paths that do not yet exist at spawn time, and a new memory file usually does not exist yet, so adding only the file path itself is dropped (unless the file is pre-created). Otherwise the write may be denied by the sandbox.

Legacy content trigger config

FieldDescription
contentTriggersLegacy / no longer active. Older builds used this field for keyword / regex triggers without an @mention, but current message routing no longer wakes a bot from contentTriggers. The parser keeps this field only for bots.json compatibility: if an old dashboard-managed trigger named dashboard-default-summary-trigger exists, botmux may read its limit / sinceHours as a fallback for summaryRange. New configs should use summaryRange

Voice

FieldDescription
voiceThe bot's voice-engine override, merged field-by-field on top of the global voice block in ~/.botmux/config.json (per-bot takes precedence). When valid voice credentials are present, a "🔊 Voice summary" button appears on reply cards. See Voice summary

Meeting listener roles and group placement

vcMeetingAgent.meetingConsumer.consumerProfiles defines reusable meeting-listener roles. responseMode controls whether automatic model output is authorized; listenerDelivery.placement independently controls where authorized output appears:

The Dashboard meeting-role editor includes a local built-in template library: Important information sync, Meeting minutes and action items, Meeting facilitator, Solution review and risk challenge, and Interview and requirement insights. “Use template” copies the selected template into a normal, fully editable profile; later template changes never overwrite user configuration. The catalog has stable template IDs, versions, and source metadata so a community source can use the same model later. This release makes no network request and uploads no usage data, so popularity rankings are intentionally unavailable.

  • silent: automatic model output stays hidden.
  • listener_thread: automatic output may be sent to the listener group and requires listener.output.request.
  • placement auto (also the default when omitted): preserve legacy session routing.
  • placement chat: send every update as a top-level group message.
  • placement topic: use the first useful update as a stable topic root and thread later updates under it. Removing and re-enabling the profile starts a new topic.

Automatic listener_thread output uses an internal skip | publish control protocol. The agent decides whether the current meeting state warrants publication; botmux sends only the publish message body and never renders the control JSON in Lark. There is no semantic-fingerprint deduplication or debounce/interval setting: novelty, observation time, and publication timing remain the agent's responsibility under the profile prompt and full meeting context. Malformed envelopes fail closed. Explicit human IM replies keep their existing quote/thread routing and do not use this protocol.

Example generic “important meeting information sync” profile:

{
  "id": "important-sync",
  "agentAppId": "cli_your_agent_app_id",
  "label": "Important meeting information sync",
  "role": "important-information-sync",
  "instructions": "Continuously listen to the meeting. Publish only new information that materially matters to collaborators: confirmed decisions, status changes, explicit blockers or risks, and items people need to know or act on. When discussion has not formed a clear change, do not publish yet; decide whether to keep observing and when to publish from meeting semantics. Ignore discussion process, repetition, small talk, and unconfirmed speculation. Keep each update concise and include owner, deadline, scope, or impact when known. A correction to a previously stated time, owner, scope, status, or conclusion must be published as new information even when most surrounding details remain unchanged. Re-evaluate transcript revisions without repeating unchanged items.",
  "filter": { "activityTypes": ["transcript_received", "chat_received"] },
  "responseMode": "listener_thread",
  "listenerDelivery": { "placement": "topic" },
  "capabilities": ["listener.output.request", "meeting.read"]
}

Set agentAppId to the bot that executes the role. Add the profile id to defaultConsumerIds with defaultMode: "agents" to enable it by default, or select it manually from the in-meeting consumer card.

Runtime state (auto-maintained, do not edit)

The following fields are written by botmux itself and persisted into bots.json alongside authorizations / switches. They are listed only for reference — do not edit them by hand:

FieldDescription
defaultOncallAutoboundChatsThe chat_ids that defaultOncall has already auto-bound (append-only). Once recorded, it won't auto-bind again even if later unbound
quotaStateScope-level message-quota counters { "chat:<cid>:<oid>" | "global:<oid>": { limit, used } }; when exhausted, automatically revokes the corresponding scope's authorization
noCardChatsThe "don't send streaming cards in this group" list written by /card off|on

Configuration precedence: the BOTS_CONFIG environment variable → ~/.botmux/bots.json. Run botmux restart after editing to take effect.

Strict process environment inheritance (opt in)

Dashboard strict inheritance and write-only environment example

Omit envPolicy or use { "mode": "inherit" } to retain historical host inheritance and mandatory credential/session-marker filtering. Strict mode uses exact approved names:

{
  "envPolicy": { "mode": "strict", "inherit": ["HTTPS_PROXY", "NODE_EXTRA_CA_CERTS", "TOOLCHAIN_ROOT"] },
  "env": { "OPENAI_API_KEY": "<this bot's model credential>" }
}

The fixed baseline contains PATH, HOME, user identity, temporary directories, standard locale, terminal and XDG paths; see src/core/env-policy.ts for the complete list. inherit adds exact names without wildcards. Proxy, CA, toolchain and model-auth variables require explicit approval here or this bot's env. Configured env wins over inherited values and stays per child/pane. Botmux injects its own identity/control variables; reserved names including BOTMUX*, __OWNER_OPEN_ID and CODEX_HOME cannot be overridden or inherited as user grants. Non-reserved adapter variables such as TRAE_HOME and CLI_EXTRA_ARGS also require an explicit inherit grant or a value in this bot's env. Process-level GROK_HOME, DSH_HOME and LARKSUITE_CLI_DATA_DIR may be explicitly inherited. Mandatory sensitive-variable filtering remains enforced in strict mode.

Set the policy in Dashboard under “Process environment inheritance”, with botmux env-policy set '{"mode":"strict","inherit":["HTTPS_PROXY"]}' with --bot to select the target bot, or /botconfig set envPolicy {"mode":"strict"}. Unset restores historical inheritance. Malformed policies, unknown fields and reserved names fail closed.

Combining network policies: When using a version that provides sandboxNetworkPolicy, the HTTPS_PROXY example above must also satisfy this table. An exact environment grant controls whether a value reaches the CLI; it does not authorize network access or silently remove/rewrite a proxy.

Network configurationHTTP/HTTPS/ALL proxy and lowercase equivalents
No network policy; or omitted proxyMode with both zones set to allowNo network-policy proxy rejection; strict mode still requires an inherit grant or this bot's explicit env
Omitted proxyMode with either zone set to block, allowlist or denylistNon-empty proxy values reject launch, including inherited and per-bot configured values
proxyMode: "reject"Non-empty proxy values reject launch even when both zones use allow
proxyMode: "trusted-egress"Explicitly granted proxies may remain; rules must permit the actual proxy endpoint IP/port; final model destinations, proxy DNS and CONNECT/HTTP rules belong to deployment-layer proxy ACLs

trusted-egress neither creates a proxy, grants environment variables nor guarantees the CLI uses it. Authorizing an exit does not restrict destinations behind it. If a model requires a proxy, do not simply remove its inherit grant to pass validation: explicitly trust the exit and configure endpoint rules plus deployment-layer ACLs, or first establish direct model authentication, permitted destination CIDRs/ports and DNS. Network policies still require Linux, a fresh local PTY and sandbox: true / "oncall"; persistent backends such as tmux, adopt and external App Servers remain rejected regardless of trusted-egress or envPolicy. See network sandbox documentation.

Online changes apply on the next worker cold start. The offline terminal command updates bots.json; an already running daemon must be restarted to reload an offline edit. CLI restarts within a live worker retain its frozen policy. Persistent restore compares a secret-free policy fingerprint: absent, corrupt or mismatched strict generations must terminate with a confirmed missing probe before cold start; unconfirmed teardown refuses launch. Environment already read by a live CLI cannot be revoked through a hot update.

Strict mode covers Botmux-owned PTY, tmux, tmux-pipe, zellij, zmx, local Codex/TraeX RPC App Servers and title subprocesses. tmux/zellij exec /usr/bin/env -i directly and skip launchShell profiles. zmx uses a fixed shell without user profiles and an empty-environment exec. Supply PATH/nvm/mise configuration explicitly. Shared-server globals are not cleared wholesale; existing mandatory sensitive-variable scrubbing still applies. Strict panes reset inherited environments and granted credentials never seed shared globals. Strict panes default to TERM=xterm-256color when it is absent and preserve explicitly configured values. v3 workflows freeze the secret-free policy and resolve per-bot env at execution time rather than persist credentials into bot snapshots.

Herdr, Riff, Mojo, Forge launch mode, adopted processes and externally owned App Servers cannot currently establish this boundary, so strict mode refuses those paths. Shells/profiles deliberately invoked by a CLI, credential/config files, OS file permissions and cloud identities remain outside this feature. Per-bot CODEX_HOME, codexAuthSync and file sandbox behavior remain independent.

Dashboard reads only configured names. Its env form is write-only: saving replaces the complete map and saving blank clears it. Strict sessions do not return credential-bearing reproduction commands. Diagnostics show names/policy only. Explicit bot values are still plaintext in bots.json and process environments; this is not a secret vault.