Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 67 additions & 1 deletion docs-site/docs/en/hooks.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Lifecycle Hooks

botmux can **asynchronously invoke external commands** when key lifecycle events occur. If a command fails, times out, or doesn't exist, it only writes to the log and never blocks botmux's main flow.
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](#synchronous-pre-submit-gate-promptsubmit).

## Configuration Location

Expand Down Expand Up @@ -52,16 +54,21 @@ After any hook event fires, you'll see the JSON payload in the log. `examples/ho
| `event` | string | Required. The event name to subscribe to (see table below) |
| `command` | string | Required. The external executable command; supports arguments, but is not run through a shell |
| `timeoutMs` | number | Optional. Defaults to 5000; on timeout, sends `SIGTERM` first, then falls back to `SIGKILL` |
| `mode` | `"sync"`|`"async"` | Optional, defaults to `async`. `sync` is supported **only** on `prompt.submit`; declaring it on any other event degrades to `async` with a warning in the log |
| `onError` | `"allow"`|`"deny"` | Optional, meaningful only with `mode:"sync"`. Fallback direction when the hook itself fails (timeout / missing command / crash). Defaults to `allow` (fail-open) |
| `filter.chatId` | string|string[] | Optional. Only match the chat of the specified Lark group / topic |
| `filter.senderOpenId` | string|string[] | Optional. Only match the specified sender open_id |
| `redact.fullContentEvents` | string[] | Optional. Long text is truncated by default; events in this allowlist pass through the full text |

> **`outbound.send` / `outbound.reply` cannot intercept.** They fire *after* the Lark API call succeeds (the `messageId` is 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. Declaring `sync` on them degrades to async with a warning.

## Supported Events

| Event | Trigger |
|------|----------|
| `topic.new` | A new topic / @mention is received |
| `thread.reply` | A reply to an existing topic is received |
| `prompt.submit` | A message passed the built-in permission checks and is **about to be submitted to the CLI**. The only event that supports `mode:"sync"` blocking |
| `outbound.send` | botmux successfully sends a regular message |
| `outbound.reply` | botmux successfully replies to a topic message |
| `schedule.fired` | A scheduled task finishes running |
Expand All @@ -79,6 +86,7 @@ Different events carry extra fields:
| Event | Extra fields |
|------|----------|
| `topic.new` | `messageId`, `senderOpenId`, `senderType`, `msgType`, `content` |
| `prompt.submit` | `messageId`, `chatId`, `chatType`, `anchor`, `senderOpenId`, `senderUnionId`, `memberUnionId`, `botSender`, `talkReason`, `content`, `attachments` (`[{type,name}]`, metadata only) |
| `thread.reply` | `messageId`, `rootId`, `parentId`, `senderOpenId`, `senderType`, `msgType`, `content` |
| `outbound.send` | `messageId`, `msgType`, `uuid`, `content` |
| `outbound.reply` | `messageId`, `replyId`, `msgType`, `replyInThread`, `uuid`, `content` |
Expand All @@ -90,6 +98,64 @@ Different events carry extra fields:

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, …).

```json
[
{
"event": "prompt.submit",
"mode": "sync",
"command": "/root/bin/prompt-gate.sh",
"timeoutMs": 3000,
"onError": "allow"
}
]
```

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.

```
Lark message → built-in permission checks → 🚦 prompt.submit gate → charge quota
→ download attachments → createSession/forkWorker → IPC to worker
→ writeInput (type + Enter, atomic) → CLI
```

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.

### Expressing a verdict

Two ways; **JSON on stdout takes precedence over the exit code**:

| Method | How | Notes |
|------|------|------|
| JSON (recommended) | print `{"decision":"deny","reason":"..."}` on stdout | `reason` is shown to the user; `decision` is `allow`|`deny` |
| Exit code | print no JSON, `exit 0` / non-zero | 0 allows, non-zero denies; stderr is used as the reason |

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 returning `allow` cannot 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 `deny` rejects; hooks after the first `deny` do not run.
- **A broken hook is not a rejection.** Timeout, missing command, and crashes all follow `onError`, which defaults to `allow` — a broken checker should not brick the whole bot. Set `onError: "deny"` explicitly for the opposite.
- **The latency lands directly on the inbound path.** Keep `timeoutMs` small (1–3s). Bot-level admission is concurrent so a slow gate will not stall the whole daemon, but replies **within one topic** hold an ordering lock — a slow gate makes later messages in that topic queue up. 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 `attachments` field 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 and workflow-generated prompts do not** — those are automation the operator pre-authorized, not external input. 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.
Expand Down
84 changes: 83 additions & 1 deletion docs-site/docs/zh/hooks.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# 生命周期 Hooks

botmux 可以在关键生命周期事件发生时**异步调用外部命令**。命令失败、超时或不存在只会写日志,不阻塞 botmux 主流程。
botmux 可以在关键生命周期事件发生时调用外部命令。默认是**异步**的:命令失败、超时或不存在只会写日志,不阻塞 botmux 主流程。

另有一类**同步前置校验闸**(`mode: "sync"`,仅 `prompt.submit` 事件支持):daemon 会等它跑完,并按它的裁决决定这条消息要不要提交给 CLI。见下文[同步前置校验闸](#同步前置校验闸-promptsubmit)。

## 配置位置

Expand Down Expand Up @@ -52,16 +54,21 @@ tail -f /tmp/botmux-hook.log
| `event` | string | 必填。订阅的事件名(见下表) |
| `command` | string | 必填。外部可执行命令;支持参数,但不经 shell 执行 |
| `timeoutMs` | number | 可选。默认 5000;超时先 `SIGTERM`,再兜底 `SIGKILL` |
| `mode` | `"sync"`|`"async"` | 可选。默认 `async`。`sync` **仅** `prompt.submit` 支持,用于前置校验;其它事件写 `sync` 会降级为 `async` 并在日志告警 |
| `onError` | `"allow"`|`"deny"` | 可选,仅 `mode:"sync"` 有意义。hook 自身失败(超时/找不到命令/崩溃)时的兜底方向,默认 `allow`(fail-open) |
| `filter.chatId` | string|string[] | 可选。只匹配指定飞书群 / 话题所在 chat |
| `filter.senderOpenId` | string|string[] | 可选。只匹配指定发送者 open_id |
| `redact.fullContentEvents` | string[] | 可选。默认截断长文本;列入 allowlist 的事件透传全文 |

> **`outbound.send` / `outbound.reply` 不能用来拦截。** 它们的发射点在飞书 API 调用**成功之后**(需先拿到 `messageId`),那一刻消息已经在群里了;加 `mode:"sync"` 也只能事后撤回,不是拦截。这两个事件写 `sync` 会降级为 async 并告警。

## 支持事件

| 事件 | 触发时机 |
|------|----------|
| `topic.new` | 收到新话题 / @mention |
| `thread.reply` | 收到已有话题回复 |
| `prompt.submit` | 消息通过内置权限校验、**即将提交给 CLI 之前**。唯一支持 `mode:"sync"` 前置拦截的事件 |
| `outbound.send` | botmux 发送普通消息成功 |
| `outbound.reply` | botmux 回复话题消息成功 |
| `schedule.fired` | 定时任务执行完成 |
Expand All @@ -79,6 +86,7 @@ tail -f /tmp/botmux-hook.log
| 事件 | 额外字段 |
|------|----------|
| `topic.new` | `messageId`、`senderOpenId`、`senderType`、`msgType`、`content` |
| `prompt.submit` | `messageId`、`chatId`、`chatType`、`anchor`、`senderOpenId`、`senderUnionId`、`memberUnionId`、`botSender`、`talkReason`、`content`、`attachments`(`[{type,name}]`,仅元信息) |
| `thread.reply` | `messageId`、`rootId`、`parentId`、`senderOpenId`、`senderType`、`msgType`、`content` |
| `outbound.send` | `messageId`、`msgType`、`uuid`、`content` |
| `outbound.reply` | `messageId`、`replyId`、`msgType`、`replyInThread`、`uuid`、`content` |
Expand All @@ -90,6 +98,80 @@ tail -f /tmp/botmux-hook.log

默认会把 `content`、`message`、`description`、`finalOutput`、`lastScreenContent` 截断到 **600 字符**,并补充 `xxxLength` / `xxxTruncated`;只有 `redact.fullContentEvents` 内的事件透传全文。

## 同步前置校验闸(prompt.submit)

普通 hook 是「通知」——跑完没人看结果。`prompt.submit` + `mode: "sync"` 是「裁决」:daemon 等它、读它、按它放行或拒绝。用于在消息进入 CLI 前做一层**自定义权限校验**(内部权限服务、工作时间限制、高危指令拦截等)。

```json
[
{
"event": "prompt.submit",
"mode": "sync",
"command": "/root/bin/prompt-gate.sh",
"timeoutMs": 3000,
"onError": "allow"
}
]
```

仓库内置可直接改的示例:`examples/hooks/prompt-gate.sh`。

### 触发时机

**闸跑在「prompt 还没送进 CLI 之前」——不是「已经输进输入框、按 Enter 之前」。**

后一种时机在 botmux 里**不存在**:写文本与按 Enter 是**同一次适配器调用中的原子动作**(`writeInput` 逐行打字、末尾那个 Enter 即提交),中间没有可插入的停顿。

```
飞书消息 → 内置权限校验 → 🚦 prompt.submit 闸 → 扣额度 → 下载附件
→ createSession/forkWorker → IPC 到 worker → writeInput(打字+Enter,原子)→ CLI
```

闸执行于 **daemon 进程**,此时 CLI 子进程尚未拿到这一轮输入(新话题下 CLI 甚至还没 fork)。被拒的消息,CLI 完全不知道它存在过。

### 怎么表达裁决

两种写法,**stdout 的 JSON 优先于退出码**:

| 方式 | 写法 | 说明 |
|------|------|------|
| JSON(推荐) | stdout 打印 `{"decision":"deny","reason":"原因"}` | `reason` 会回给用户;`decision` 取 `allow`|`deny` |
| 退出码 | 不打印 JSON,`exit 0` / `exit 非0` | 0 放行,非 0 拒绝;stderr 内容当作原因 |

stdout 必须是**整段 JSON 对象**才会被当作裁决。打印一行普通日志不会被误判成裁决——那种情况回退到看退出码。

### 边界与保证

- **只能收紧,不能放宽**:内置权限模型(`allowedUsers` / `grant` / oncall / 额度)先跑,全部通过后才会问这个 hook。hook 说 `allow` 不会让内置闸拒掉的人进来。
- **拒绝不扣额度**:闸排在扣费之前,被拒的消息不消耗用户的消息额度。
- **拒绝会明确告诉用户**(附 `reason`),不静默丢弃——有权限却消息凭空消失是最难排查的形态。
- **多个 sync hook 是 AND**:任一 `deny` 即拒绝,第一个 `deny` 之后的 hook 不再执行。
- **hook 坏了不等于拒绝**:超时、找不到命令、崩溃都走 `onError`,默认 `allow`——校验器挂掉不该让整个 bot 变砖头。要反过来就显式写 `onError: "deny"`。
- **延迟直接加在收信路径上**:`timeoutMs` 建议设小(1-3s)。bot 级并发不会因此卡死整个 daemon,但**同一话题**的续聊持有顺序锁——慢闸会让该话题的后续消息排队。别指望用大超时兜住一个慢服务。没配 sync hook 时零开销,不会给每条消息加 spawn。
- **消息监听器(message listener)命中的第三方内容也会过闸**:那类内容来自告警 bot 等外部来源、同样会进 CLI,正是最该校验的。该路径本来就不扣额度,被拒时只记日志、不回消息(没有可回复的真人发送者)。
- **闸拿到的是完整正文,不受 600 字符截断影响**:截断是为通知类 hook 设计的,而闸的判断依据就是内容本身——截断会让它对超长输入结构性失明(把恶意内容垫到 600 字符之后即可绕过)。
⚠️ **隐私含义**:配了 sync 闸就等于把**完整消息正文**交给那个命令。异步 hook 仍按原规则截断,未受影响。
- **闸看得到附件的元信息,但看不到附件内容**:`attachments` 字段给出本轮的 `[{type,name}]`(如 `[{"type":"file","name":"prod.env"}]`),足以写「禁止上传 .env」「只许图片」这类策略;但闸跑在**附件下载之前**(下载必须排在授权之后,否则未授权者也能让 bot 去拉文件),所以**无法按文件内容判断**。
- **覆盖范围:只管「人/外部消息进 CLI」这条入口**。新话题、话题续聊、斜杠命令冷启动、会话群出生轮、消息监听器命中——都过闸。但**定时任务与 workflow 自动跑出来的 prompt 不过闸**(那是运维自己预先授权的自动化,不是外部输入)。别把它当成「所有进 CLI 的文本都查过了」。
- 同一条 hook 配置**只会跑一次**:作为闸执行后,不会再作为异步通知重复触发。

### 快速验证

```bash
cat > /tmp/gate.sh <<'SH'
#!/bin/bash
cat >/tmp/gate-payload.json
echo '{"decision":"deny","reason":"闸测试:暂时不放行"}'
SH
chmod +x /tmp/gate.sh

cat > ~/.botmux/data/hooks.json <<'JSON'
[{ "event": "prompt.submit", "mode": "sync", "command": "/tmp/gate.sh", "timeoutMs": 3000 }]
JSON
```

在飞书里发一条消息:应当收到「本条消息被前置校验拦截」的回复,且 `/tmp/gate-payload.json` 里能看到本轮 payload。验证完记得清空 `hooks.json`。

## 实践:用 session.start hook 自动更新 Skills

botmux 原生集成了 agentbuddy 作为 skill 来源(`botmux skills install <agentbuddy命令>` 安装,`botmux skills update <name>` 更新)。配合 `session.start` hook,可以在每次新会话启动时自动检查并更新已安装的 skills,等效于 Relay / Claude Code settings.json 中的 SessionStart Hook。
Expand Down
29 changes: 29 additions & 0 deletions examples/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,35 @@ Then trigger a matching event and inspect `/tmp/botmux-hook.log`.
| `echo-to-log.sh` | Appends every payload to `/tmp/botmux-hook.log` |
| `osascript-notify.sh` | Shows a macOS Notification Center alert |
| `http-webhook.sh` | POSTs the stdin payload to an HTTP endpoint |
| `prompt-gate.sh` | **Synchronous** pre-submit check: allows or denies a message before it reaches the CLI |

## Synchronous gate hooks

The scripts above are asynchronous notifications — nothing reads their result.
`prompt-gate.sh` is different: configured on the `prompt.submit` event with
`"mode": "sync"`, the daemon waits for it and uses its verdict to decide whether
the message is submitted to the CLI at all.

```json
[
{
"event": "prompt.submit",
"mode": "sync",
"command": "/absolute/path/to/prompt-gate.sh",
"timeoutMs": 3000,
"onError": "allow"
}
]
```

Print `{"decision":"deny","reason":"..."}` on stdout to reject (the reason is
shown to the user), or `{"decision":"allow"}` to permit. A script that prints no
JSON falls back to its exit code: 0 allows, non-zero denies.

Keep `timeoutMs` small — the wait lands directly on the inbound message path.
A hook that times out or cannot be spawned follows `onError`, which defaults to
`allow` so a broken checker cannot brick the bot. Full contract:
`docs-site/docs/en/hooks.md`.

Use absolute paths in `hooks.json`. If the command needs configuration, pass it
as command arguments or environment variables inherited by the botmux daemon.
Loading
Loading