Lifecycle Hooks
botmux can invoke external commands when key lifecycle events occur. By default they are asynchronous: if a command fails, times out, or doesn't exist, it only writes to the log and never blocks botmux's main flow.
There is also a synchronous pre-submit gate (mode: "sync", supported only on the prompt.submit event): the daemon waits for it and uses its verdict to decide whether the message reaches the CLI. See Synchronous pre-submit gate.
Configuration Location
In order of precedence (highest to lowest):
- The
BOTMUX_HOOKS_JSONenvironment variable (pass a JSON array directly) - The file path specified by
BOTMUX_HOOKS_FILE - The default
~/.botmux/data/hooks.json
Quick Check: Write to a Local Log
The repo ships an example script you can copy and use right away:
After any hook event fires, you'll see the JSON payload in the log. examples/hooks/ also includes examples for macOS Notification Center (osascript-notify.sh) and HTTP webhooks (http-webhook.sh).
Configuration Fields
outbound.send/outbound.replycannot intercept. They fire after the Lark API call succeeds (themessageIdis required to build the payload), by which point the message is already in the chat;mode:"sync"there could only retract after the fact, which is not interception. Declaringsyncon them degrades to async with a warning.
Supported Events
Payload Fields
Every payload is written to the hook command via stdin, and the environment variable BOTMUX_HOOK_EVENT is also set. Each payload includes event and emittedAt; the event context may include sessionId, chatId, chatType, larkAppId, scope, anchor, title, cliId, workingDir, hasHistory, spawnedAt, and lastMessageAt.
Different events carry extra fields:
schedule.fired.status and the task's lastStatus use ok, error, or skipped: ok means the task was handed to the executor, not that model generation or message delivery has completed; error means the precondition check or dispatch failed; skipped means the precondition did not pass and no model was called. A skip does not consume the repeat count or delete the task, precondition configuration, or execution logs. Enabled one-shot tasks remain enabled and become eligible for another scheduler check after at least 30 seconds; if an early manual run is skipped, subsequent automatic checks do not precede the original scheduled time. Recurring tasks keep their existing cadence.
skipped is a new status and requires no migration of existing tasks. Custom hooks that accept only ok/error must handle it separately instead of treating it as a successful execution.
By default, content, message, description, finalOutput, and lastScreenContent are truncated to 600 characters, with xxxLength / xxxTruncated added; only events in redact.fullContentEvents pass through the full text.
Synchronous pre-submit gate (prompt.submit)
A regular hook is a notification — nothing reads its result. prompt.submit with mode: "sync" is a verdict: the daemon waits for it, reads it, and allows or rejects the message accordingly. Use it to add a custom authorization layer before a message reaches the CLI (an internal permission service, working-hours limits, dangerous-command interception, …).
A ready-to-adapt example ships in the repo: examples/hooks/prompt-gate.sh.
When it fires
The gate runs before the prompt is handed to the CLI — not "typed into the box, before Enter".
That second moment does not exist in botmux: writing the text and pressing Enter are a single atomic adapter call (writeInput types line by line and the trailing Enter is the submit), with no insertion point between them.
The gate runs in the daemon process, before the CLI subprocess has this turn's input at all (for a new topic the CLI has not even been forked). A denied message never existed as far as the CLI is concerned.
Using it as a plain notification (async)
mode:"sync" is not required. With no mode (or an explicit async) this event behaves like any other notification hook: the daemon does not wait for it and its result cannot affect admission — good for audit trails, metrics and alerting.
Both can coexist — the first only records, the second actually blocks. Three rules:
- Observers are dispatched before the verdict, so a notification hook still sees a message that ends up denied — "whose message was blocked" is exactly what an audit trail wants.
- Observers receive the truncated body (600 characters by default, same as every other event). Only adjudicating
synchooks are exempt from truncation; subscribing to this event does not by itself hand a hook the full message text. - An observer's exit code and stdout never participate in the verdict.
Expressing a verdict
Two ways; JSON on stdout takes precedence over the exit code:
Stdout must be a whole JSON object to count as a verdict. Printing an ordinary log line will not be mistaken for one — that case falls back to the exit code.
Boundaries and guarantees
- It can only tighten, never loosen. The built-in permission model (
allowedUsers/grant/ oncall / quota) runs first; the hook is asked only after all of it passes. A hook returningallowcannot let in someone the built-in gate rejected. - A rejection costs no quota. The gate sits before the charge, so a denied message does not consume the user's message quota.
- A rejection tells the user (with
reason) instead of dropping silently — an authorized user whose messages vanish is the hardest failure to diagnose. - Multiple sync hooks are ANDed. Any
denyrejects; hooks after the firstdenydo not run. - A broken hook is not a rejection. Timeout, missing command, and crashes all follow
onError, which defaults toallow— a broken checker should not brick the whole bot. SetonError: "deny"explicitly for the opposite. - ⚠️
timeoutMs: 0does not mean "no timeout" — it means instant timeout: the gate falls straight through toonError(defaultallow), i.e. the gate is effectively disabled. A warning is logged at load time. There is no "unlimited" option (the gate sits on the inbound path); set a real budget such as3000. - The latency lands directly on the inbound path. Keep
timeoutMssmall (1–3s). Bot-level admission is concurrent so a slow gate will not stall the whole daemon, but it holds up two things: (1) replies within one topic hold an ordering lock, so later messages in that topic queue up; (2) the gate runs inside that bot's admission lease, so that bot's Dashboard operations (closing/pruning sessions, changing Agent config) may time out until the gate returns. Do not lean on a large timeout to paper over a slow service. With no sync hook configured there is zero overhead — no spawn is added per message. - Message-listener traffic is adjudicated too. That content comes from third parties (alert bots and the like) and still reaches a CLI, so it is exactly what a gate should inspect. That path never charges quota; a denial is logged only, with no reply (there is no human sender to answer).
- A gate receives the full content, exempt from the 600-character truncation. That truncation exists for notification hooks; for a gate the content is the input to the decision, so truncating it makes the gate structurally blind past the limit (pad 600 characters and hide the payload behind them). ⚠️ Privacy implication: configuring a sync gate hands that command the full message text. Async hooks are unaffected and still truncate.
- A gate sees attachment metadata, not attachment content. The
attachmentsfield carries this turn's[{type,name}](e.g.[{"type":"file","name":"prod.env"}]), enough for "no .env uploads" or "images only" policies. But the gate runs before the files are downloaded (downloading must stay behind authorization, or an unauthorized sender could make the bot fetch files), so it cannot decide on file contents. - Coverage: the inbound-message entry only. New topics, thread replies, slash-command cold starts, session-group birth turns, and message-listener matches all pass through the gate. Scheduled tasks do not — that is automation the operator pre-authorized, not external input. v3 saved workflows do pass through the gate, but carry no prompt body — sender-level rules such as
senderOpenIdstill apply; content-level rules are blind to them. Do not read the gate as "everything reaching the CLI was checked". - A given hook entry runs only once: after running as the gate, it is not fired again as an async notification.
Practical: Auto-Update Skills with session.start
botmux natively integrates agentbuddy as a skill source (botmux skills install <agentbuddy-command> to install, botmux skills update <name> to update). Combined with the session.start hook, you can automatically check for and update installed skills on every new session — equivalent to the SessionStart Hook in Relay / Claude Code's settings.json.
Update a Single Skill
Update All Installed Skills
botmux skills update accepts only a single skill name — no * or regex. To update everything, loop in a script:
Call agentbuddy CLI Directly to Update Global Skills
If you prefer running npx agentbuddy update directly (updating the user's global skills rather than botmux-managed skills), be aware of botmux's hook execution constraints: shell: false (no redirection or piping) and a scrubbed environment (only PATH/HOME/TMPDIR/SHELL/USER and a few other basics are preserved). Use a wrapper script:
Notes
- Timeout: The default
timeoutMsis 5000ms. agentbuddy update involves network requests and typically takes longer — set it explicitly (60s+ recommended). On timeout, botmux sendsSIGTERMfirst, thenSIGKILLto the entire process group. - Fire-and-forget: Hooks run asynchronously and never block session startup; updated skills take effect in the next session.
- Filter: Use
filterto limit updates to specificchatIdorsenderOpenId, avoiding unnecessary updates for every session. - Recommended approach: Prefer
botmux skills update(first approach) — it goes through botmux's telemetry scrubbing (clearAgentbuddyTelemetry) and updates the skill versions botmux injects, staying consistent with botmux's skill lifecycle.
Writing Your Own Hook
A hook command can be any executable: a bash / Python / Node / Go binary, an internal company CLI, or an HTTP forwarder. A command that does exit 0 is treated as a success; non-zero exits / timeouts / missing commands only write to the botmux log and never affect message send/receive, scheduled tasks, or the session lifecycle.
