Fork me on GitHub

分类 open claw 下的文章

把小龙虾搬到外网:Gateway 远程访问

上一篇结尾我们提到,无论是 openclaw 命令,浏览器里打开的 Control UI 页面,还是 macOS app,本质上都是通过 WebSocket 连进 Gateway 的客户端。本机运行的话没什么问题,因为它们直接连的就是 ws://127.0.0.1:18789;可一旦客户端和 Gateway 不在同一台机器上,事情就没那么简单了。

不过讲到这里有个绕不开的疑问:前面几篇花了不少篇幅讲 Telegram、飞书这类 channel,出门在外发条消息就能让家里的 agent 干活,channel 这条路看起来已经够用了,那为什么还要费劲让客户端从外面连回 Gateway 呢?

其实,从 Gateway 视角看,外面要连进来的客户端其实分三类,它们的分工各有不同:

角色谁在用干什么
channelTelegram、飞书、Slack、WhatsApp通过聊天工具发文字消息让 agent 干活
operatorCLI、macOS app、浏览器 Control UI直接对话 + 配置管理 + 看健康状态
nodeiOS / Android Node、macOS 节点模式把摄像头、麦克风、GPS、屏幕、Live Canvas 暴露给 agent 调用

channel 是你通过聊天工具指挥 agent;operator 是你直接坐在控制台指挥;node 则是反过来,agent 主动调用你身边设备的硬件能力。所以这三类并不是互相替代的关系,而是应该一起用,谁也少不了谁。

所以 Gateway 的远程访问实际上是在解决两类场景:

  • operator 远程:家里 Mac mini 一直跑着 Gateway,但你出差带的是公司笔记本,想用本地 macOS app 开 WebChat、改改 agent 的 system prompt、跑 openclaw status 看一下健康状态,CLI / app 都得能连回家里的 Gateway。
  • node 远程:手机上的 OpenClaw Node 走 4G 或外面的 WiFi 反向连回家里的 Gateway。配上 voicewake 这套全局唤醒词,你在外面说一句 "Hey Claw 看看我现在在哪,帮我查附近最近的咖啡馆",手机录到语音 → WebSocket 推给家里的 agent → agent 调用 location.get 拿 GPS → 查询位置信息 → 通过 talk.speak 把答案语音播回手机。这套近场体验是 channel 完全替代不了的。

这两类场景的共同前提,都是 Gateway 不能继续躲在 127.0.0.1 后面。这一篇我们就来学习下 Gateway 远程访问,看 OpenClaw 怎么在不破坏安全底线的前提下,把默认绑死 loopback 的口子按场景一点点放开,让外网的 operator 和 node 都能访问家里的 Gateway。

Gateway 绑定方式一览

OpenClaw 的默认设计非常保守,除非你明确告诉它要绑别的网卡,否则只监听 loopback 本机回环。我们可以通过 gateway.bind 参数修改绑定方式,一共 5 种取值:

bind监听到哪儿备注
loopback127.0.0.1(默认)外面够不着,最安全
auto优先 loopback,loopback 不可用时退到 lan兜底用
tailnetTailscale 网卡的 100.x.x.x 地址tailnet 内可见,loopback 不通
lan0.0.0.0,整个局域网都能访问必须配 auth
customhost 字段显式指定 IP / interface高阶用法

从外面接 Gateway 的方案也大致有 5 种,按使用频率从高到低排:

  1. loopback + Tailscale Serve(推荐)—— Gateway 守在 loopback,Serve 在前面做 HTTPS 反代
  2. loopback + SSH tunnel —— 没装 Tailscale 的通用回退方案
  3. loopback + Tailscale Funnel + password —— 公网兜底,适合 Demo 演示和 应急
  4. bind: tailnet 直连 —— 不要前置代理,Gateway 直接绑 Tailscale 网卡,纯 ws 走 tailnet
  5. bind: lan / custom + trusted-proxy 反代 —— 企业级部署,把鉴权委托给前面的 Pomerium / Caddy + OAuth

后面我们重点展开前三种,后两种作为补充会在对应章节里顺带提一下。

bind.png

注意看这张图里 Gateway 那一侧的三种绑定:

  • bind: loopback —— 三种主流方式(Serve / SSH tunnel / Funnel)都让 Gateway 老老实实绑在 loopback 上不动,差别只在前面挂了什么前置代理
  • bind: tailnet —— 跳过前置代理,Gateway 直接绑 Tailscale 网卡,loopback 反而连不通了
  • bind: lan / custom —— 企业部署常用,Gateway 直接对内网开口,但前面必须挂一层带 OAuth/SSO 的反代,由反代注入 x-forwarded-user

Tailscale 介绍

这里先花点篇幅介绍下 Tailscale,没用过的同学可能会比较陌生。简单说,它是一套基于 WireGuard 的零配置 VPN:装上客户端登录账号之后,你名下所有设备(Mac、iPhone、家里的 NAS、云上的 VPS)会自动加入同一个虚拟内网,这个内网叫 tailnet。每台设备会被分到一个 100.x.x.x 的固定 IP,互相之间端到端加密,NAT 穿透由 Tailscale 帮你搞定,体验上就跟在同一个局域网里没什么区别。Tailscale Serve 则是 Tailscale 提供的一个附加功能,能把本机某个端口以 HTTPS 形式发布给 tailnet 里的其他设备访问,相当于自带一个内部反代。

tailscale.png

安装也很省事,每台需要互联的设备都装一下客户端就行。装完第一次跑 tailscale up(macOS 是点菜单栏图标 → Log in),它会弹一个浏览器让你用 Google / GitHub / Microsoft 等账号登录,登完这台设备就自动加入你的 tailnet 了。

可以通过下面两条命令验证一下:

$ tailscale status        # 看 tailnet 里有哪些设备、各自的 100.x IP 和在线状态
$ tailscale ip -4         # 看本机的 tailnet IP

每台设备还会自动拿到一个形如 mac-mini.tailnet-xxxx.ts.net 的 MagicDNS 域名,后面 OpenClaw 把 Gateway 通过 Tailscale Serve 发出来后,tailnet 里的其他设备直接用这个域名就能访问,不用记 IP。

tailscale-login.png

方式一:Tailscale Serve(推荐)

回到正题。如果你已经在用 Tailscale,这是最舒服的远程访问方式:Gateway 仍然只绑 loopback,Tailscale Serve 在前面做 HTTPS 终结、把 tailnet 内的流量代理过来,并把 Tailscale 的身份头注入请求。换句话说,控制面继续留在 loopback 上,外网视角下 Gateway 根本不存在,但 tailnet 视角下你又能从任何设备打开 Control UI

修改 ~/.openclaw/openclaw.json 配置如下:

{
  "gateway": {
    "bind": "loopback",
    "tailscale": { "mode": "serve" },
    "auth": {
      "mode": "token",
      "token": "your-token",
      "allowTailscale": true,
    },
    "controlUi": {
      "allowedOrigins": ["https://macbook-air.tail7d2cad.ts.net"],
    },
  },
}

其中几个参数解释一下:

  • tailscale.mode:取值 off | serve | funnel 三选一,控制 OpenClaw 要不要主动配置 Tailscale Serve / Funnel
  • auth.mode:取值 none | token | password | trusted-proxy 四选一,跟 Tailscale 没关系,是 Gateway HTTP/WS 自身的认证模式
  • auth.allowTailscale:是个旁路开关。打开之后,当请求来自 Tailscale Serve 通道时,Control UI 和 WebSocket 可以走 Tailscale 注入的身份头直接放行,不再要求 token
  • controlUi.allowedOrigins:浏览器 origin 白名单,把你的 MagicDNS 域名填进去,否则从另一台 tailnet 设备打开 Control UI 时,WebSocket 握手会被服务端以 origin not allowed 拒掉

值得一提的是,OpenClaw 在 server 侧不是简单地相信 Tailscale 注入的 tailscale-user-login 头,它会反查一次。在 src/gateway/auth.ts 源码里可以看到这么一段逻辑:

async function resolveVerifiedTailscaleUser(params: {
  req?: IncomingMessage;
  tailscaleWhois: TailscaleWhoisLookup;
}) {
  const { req, tailscaleWhois } = params;
  const tailscaleUser = getTailscaleUser(req);
  if (!tailscaleUser) return { ok: false, reason: "tailscale_user_missing" };
  if (!isTailscaleProxyRequest(req)) return { ok: false, reason: "tailscale_proxy_missing" };
  const clientIp = resolveTailscaleClientIp(req);
  if (!clientIp) return { ok: false, reason: "tailscale_whois_failed" };
  const whois = await tailscaleWhois(clientIp);
  if (!whois?.login) return { ok: false, reason: "tailscale_whois_failed" };
  if (normalizeLogin(whois.login) !== normalizeLogin(tailscaleUser.login)) {
    return { ok: false, reason: "tailscale_user_mismatch" };
  }
  return { ok: true, user: { login: whois.login, name: whois.name ?? tailscaleUser.name } };
}

整体逻辑大致四步:

  1. 请求里得有 tailscale-user-login
  2. 请求得真的是从 Tailscale 代理过来的(带 x-forwarded-* 头,且来源 IP 在受信任代理列表里)
  3. tailscale whois 反查一次客户端 IP,能拿到 login
  4. whois.login 和请求头里的 login 必须一致

四关都过才算认证通过。这套机制让人没办法在本地伪造一个 tailscale-user-login: nick@example.com 来骗过 Gateway。也正因为这层校验是 本机调用 tailscale whois 完成的,整个 tokenless 流隐含一条信任假设:Gateway 所在主机本身是可信的,不会有别的本地恶意进程能直接扔伪造头进 loopback。如果同主机上有不可信代码,建议还是把 allowTailscale 关回 false,老老实实走 token 认证。

顺带提一句 Tailscale 的另一种用法:gateway.bind: "tailnet"。这是直接把 Gateway 绑到 Tailscale 网卡上,不走 Serve 也不走 HTTPS,端口直接对 tailnet 暴露。这种模式下 loopback 127.0.0.1:18789 反而不通,必须用 tailnet IP 访问。它适合你不想拉 HTTPS、就想要纯 ws 直连 tailnet 的场景,但 auth 必须配 token 或 password,否则启动直接失败。

启动时 OpenClaw 会自动调用 tailscale serve 把 18789 暴露到 tailnet,配置好之后 tailnet 内的设备直接通过 https://<magicdns>/ 就能访问 Control UI。不过第一次启动可能没那么顺利,大概率会遇到下面的启动报错:

[tailscale] serve failed: Command failed: /usr/local/bin/tailscale serve --bg --yes 18789

通常是因为你之前从来没在这台设备上用过 tailscale serve,所以 tailnet 里 serve 功能还没开。手动执行以下报错里的命令,显示如下:

Serve is not enabled on your tailnet.
To enable, visit:

  https://login.tailscale.com/f/serve?node=nmxRvhzEEk11ABCDE

根据提示,使用浏览器打开链接:

tailscale-enable-https-funnel.png

点击 Enable 后,就能看到下面的提示:

Success.
Available within your tailnet:

https://macbook-air.tail7d2cad.ts.net/
|-- proxy http://127.0.0.1:18789

Serve started and running in the background.
To disable the proxy, run: tailscale serve --https=443 off

再次重启 Gateway 后,就能看到下面的成功提示了:

[tailscale] serve enabled: https://macbook-air.tail7d2cad.ts.net/ (WS via wss://macbook-air.tail7d2cad.ts.net)

至此,到另一台 tailnet 设备浏览器访问 MagicDNS 域名就能看到 OpenClaw 的 Control UI 了:

magicdns.png

方式二:SSH tunnel

不用 Tailscale 的话,SSH tunnel 是最通用的回退方案。但是得有一个前提:你必须能 SSH 到那台机器。如果 Gateway 所在的 server 运行在家里,它藏在 NAT 后面,也没有公网 IP,最省心的办法是租一台有公网 IP 的小 VPS 当跳板:家里用 autossh -R 把端口反向常驻映射到 VPS 服务器,然后无论你在哪里,都可以先 SSH 到 VPS 再跳到家里。

如果可达性问题解决了,建立 SSH tunnel 的命令如下:

$ ssh -N -L 18789:127.0.0.1:18789 -p 22 user@home-server

其中 user 是你在跑 Gateway 那台机器上的登录账号,home-server 是那台机器的主机名或 IP(或上面提到的 VPS 跳板)。隧道起来之后,本地 ws://127.0.0.1:18789 这个地址就接到了远程 Gateway。它的好处是 Gateway 那边什么都不用动,仍然只绑 loopback、auth 仍然走老 token,是最不需要重新设计架构的接入方式。

使用浏览器打开 http://127.0.0.1:18789 页面,输入家里那台 Gateway 的 token,然后就可以用浏览器来管理家里的 OpenClaw 了。

如果还想用 CLI(openclaw statusopenclaw health 这些)连过去,得先改一下本机的配置。因为装了 OpenClaw 的机器默认 gateway.modelocal,CLI 会以为自己就是 Gateway 宿主,去探本机的 gateway 并用本地的 gateway.auth.token 认证,结果就是即使隧道通了也会报 unauthorized: gateway token mismatch。正确做法是把本机配成家里 Gateway 的远程客户端,修改 ~/.openclaw/openclaw.json 如下:

{
  "gateway": {
    "mode": "remote",
    "remote": {
      "url": "ws://127.0.0.1:18789",
      "token": "<家里 Gateway 的 gateway.auth.token>",
    },
  },
}

改成 mode: "remote" 之后,CLI 就会通过隧道连远程 Gateway 了。此时运行 openclaw health 显示的就是家里那台 Gateway 的信息。

要注意 gateway.remote.tokengateway.auth.token 不是一回事,一个是 客户端拿来连服务端 的凭证,一个是 服务端要求客户端出示 的凭证。

方式三:Tailscale Funnel + password(公网兜底)

在动手之前先把 Funnel 和上一节的 Serve 拎出来对比一下,两者的差别就一个:流量从哪儿进来。

维度Tailscale ServeTailscale Funnel
谁能访问只有你 tailnet 里的设备整个公网,任何人凭 URL 都能打开
入口域名https://<machine>.<tailnet>.ts.net(tailnet 内可解析)同样的 *.ts.net 域名,但走 Tailscale 的公网入口节点
端口限制任意端口只能用 443 / 8443 / 10000 三个
流量路径tailnet 内部 WireGuard 加密直连经 Tailscale 的 ingress 节点转发到你的机器

一句话总结:Serve 是给自己人用的内部反代,Funnel 则是 Serve 的对外公开版。所以选哪个的判断标准也简单,所有要访问 Gateway 的设备都能装 Tailscale 客户端吗?能就选方式一 Serve,搞不定(比如要分享给朋友试一下、或者某个客户端环境装不了 Tailscale)才退到 Funnel。

直接把 Gateway 暴露到公网在技术上是允许的,但是不推荐。如果一定要走公网,OpenClaw 给的官方方案是 Tailscale Funnel + 共享密码

{
  "gateway": {
    "bind": "loopback",
    "tailscale": { "mode": "funnel" },
    "auth": { "mode": "password", "password": "replace-me" },
  },
}

tailscale funnel 在 OpenClaw 里被强制要求 auth.mode: "password",启动时直接校验,没有密码不让 Funnel 起来。这里有个让人疑惑的地方:为什么 funnel 偏偏要 password 认证呢?按理 token 通常是 64-byte 的随机串,强度应该比人类设的密码更高才对。翻了一遍源码也没找到注释说明具体原因,大概是因为 funnel 是把 Gateway 开放到公网、通常还要分享给别人用的,这种场景下应该单独设一个一次性的密码发出去,而不是把 Gateway 自己的 token 直接暴露出去,毕竟那个 token 是其他客户端也在用的主凭证。强制走 password,相当于在配置层面就把对外公开用的凭证和内部用的 token 隔开了。

这套方案适合临时给同事看个 Demo 或者自己出差时手机能直接连进来这类场景。长期对外服务还是建议走 trusted-proxy 模式(下一节展开),把鉴权委托给一层带 OAuth/SSO 的反向代理。

鉴权模式速览

前面三种接入方式里 gateway.auth.mode 参数反复出现,这一节我们稍微整理一下。一共四种取值:

模式适用场景凭证类型
none严格的 loopback only 部署
token默认推荐一次性长 token
passwordTailscale Funnel / 浏览器手输共享密码
trusted-proxy反代 + 身份感知(Pomerium、Caddy + OAuth、nginx + oauth2-proxy)反代注入的 x-forwarded-user

日常用得最多的是 token。你跑 onboard 向导的时候它就顺手帮你生成好了,写在 gateway.auth.token 里,客户端连的时候可以带上 --token 或者塞进 OPENCLAW_GATEWAY_TOKEN 环境变量,这是最省心,也最不容易出岔子的认证方式。

passwordtoken 其实干的是同一件事(服务端都是拿你给的字符串去和配置文件中的值进行比对),区别只在「给谁用」:token 通常是 64 字节的随机串,让人对着浏览器手敲一遍不太现实;password 是你自己设的、记得住的字符串,所以 Funnel 这种需要在浏览器里手输凭证、甚至要发给别人用的场景就指定它。

trusted-proxy 常用于企业部署。OpenClaw 自己不碰 OAuth/SSO 这摊事,直接甩给前面的 Pomerium 或者 Caddy 代理,反代那边把身份验完,往请求里塞一个 x-forwarded-user: nick@example.com,OpenClaw 只做两件事:先确认这请求确实是从 gateway.trustedProxies 名单里的 IP 转发过来的,再把 user 头读出来。配置长这样:

{
  "gateway": {
    "bind": "lan",
    "trustedProxies": ["10.0.0.1"],
    "auth": {
      "mode": "trusted-proxy",
      "trustedProxy": {
        "userHeader": "x-forwarded-user",
        "allowUsers": ["nick@example.com"],
      },
    },
  },
}

trusted-proxy 模式默认会拒绝 loopback 来源的请求,避免本地恶意进程伪造身份头。如果你的反代和 Gateway 在同一台机器上走 loopback,要显式打开 gateway.auth.trustedProxy.allowLoopback: truegateway.trustedProxies 列表只列你反代的真实 IP,不要写整个网段。

至于 mode: "none",基本只在纯私网的部署里才用得上。你要是既不绑 loopback 又把 auth 关成 none,OpenClaw 直接拒绝启动,但这只是兜底的最后一道闸,真上了公网可千万别指望它。说句更保守的:哪怕是家里 NAS 那种内网,只要这台机器跟你其它设备不在一个你敢完全担保的网段里,至少也挂个 token 吧。

小结

今天我们把 OpenClaw Gateway 的远程访问方案过了个遍。下面用一张表格做个总结:

summary-table.png

到这里,Gateway 就能让外网的客户端接进来了。接下来几篇我们就来看看 OpenClaw 自带的那几个原生客户端:下一篇先从 macOS 菜单栏 app 开始,再往后是 iOS / Android Node,把手机上的 OpenClaw Node 和家里的 Gateway 配上对,让它当远程麦克风、摄像头和位置传感器,配合 voice wake,出门在外也能随时唤起 agent。

参考


给小龙虾上把锁:Sandbox 沙箱机制

上一篇讲多 agent 的时候,我曾提到过:workspace 工作目录只是软隔离,工具拿到绝对路径仍然能访问主机其它位置,要做硬隔离得开 sandbox 沙箱。这句话当时只是顺嘴一提,但只要你把 OpenClaw 接到群里、给朋友试用、甚至挂到对外的客户群上,就会发现沙箱这道墙才是整个安全模型的关键。

群里随便一个人发一句 帮我 ls 一下 ~/.ssh,如果 agent 的 exec 直接打到宿主机上,那么 OpenClaw 就跟木马没什么区别。OpenClaw 给出的标准答案是 Sandbox:自己用的 main 会话保持原生权限,群聊、频道这些非 main 会话进 Docker、SSH 或 OpenShell 后端执行工具。今天我们就来学习下这套沙箱机制。

为什么需要沙箱

OpenClaw 出厂的设计哲学是 装它的人就是用它的人,用户在 main 会话中对话,就相当于在使用本地终端,工具必须直接打到宿主机上才好用。比如让 exec 运行在 ~/Codes 目录下面、让 read 能看到真实的 .env 文件内容、让 process 能运行或操作本地的程序,这套模型在单人单机场景下顺手得不得了。main 会话直接跑在宿主机上,本质上等于给了 agent 你这个用户的权限,agent 能做的事就是你能做的事。

但一旦这只小龙虾不只是你自己在用,故事就变了。客户群里偷偷塞进来的一段 prompt injection、朋友试用时不小心引导出来的 rm -rf,只要被 agent 当真去执行,损失就会真真切切打到你的机器上。这就是为什么需要把这类会话关进沙箱里:模型抽风也好、有人故意下套也好,破坏都被框在容器里出不来。

OpenClaw 对会话边界的判定其实很简单,不是按 agent id 走的,而是按 session.mainKey,默认的主会话是 main,群组和频道的会话用的是它们各自的 key,所以会被算作非 main 会话。

也就是说,只要消息是从一个不是你独占的入口进来的,OpenClaw 就应该把它关进沙箱里跑。那么剩下的问题就是:怎么把这条原则落到配置里,告诉 OpenClaw 哪些会话该进沙箱、用什么后端跑、隔离要做到什么程度。

要注意的是,沙箱不是一道密不透风的墙,OpenClaw 文档自己也承认:「这不是一道完美的安全边界,但在模型抽风的时候,它能实打实地限制对文件系统和进程的访问」。能把模型抽风造成的破坏面框在容器里,已经比裸跑安全得多。

三种 sandbox 模式

最先要讲的是 agents.defaults.sandbox.mode 参数,它决定 哪些会话需要进沙箱,一共三种取值:

模式含义适用场景
off(默认)不开沙箱,所有会话都在宿主机跑自己一个人单机用,对工具完全信任
non-main只有非 main 会话进沙箱,main 会话保持原生权限推荐起步配置,单人 + 群组场景的平衡点
all所有会话都进沙箱,连 main 也不例外多人协作、对外服务、把 Gateway 公开到 tailnet

最小启用配置长这样:

{
  "agents": {
    "defaults": {
      "sandbox": {
        "mode": "non-main",
        "scope": "session",
        "workspaceAccess": "none"
      },
    },
  },
}

这里还出现了另两个参数:

  • scope:决定容器粒度。agent(默认)一个 agent 一个容器;session 一个会话一个容器,隔离最强但容器开销最大;shared 所有沙箱会话共享一个容器,资源占用最低。群聊场景推荐 session,每个群一个容器,彼此访问不到对方的文件。
  • workspaceAccess:控制沙箱能看到多少宿主 workspace。none(默认)只能看到 ~/.openclaw/sandboxes 里它自己那块临时空间;ro 把 agent workspace 只读挂载到 /agentrw 读写挂载到 /workspace

生产场景下不建议直接开 rw 把整个 workspace 全部挂给沙箱,更稳妥的做法是保持 ronone,再通过 Docker 后端的额外挂载机制把真正需要的少数目录单独开口子(具体怎么挂下面 Docker 后端那一节会讲)。

三种 sandbox 后端

另一个要讲的是 agents.defaults.sandbox.backend 参数,它控制 沙箱用什么 runtime,OpenClaw 当前支持三种:

维度Docker(默认)SSHOpenShell
跑在哪本机 Docker 容器任意可 SSH 的远程机器OpenShell 托管的远程沙箱
启动开销低(容器复用,毫秒级)中(首次需 seed 远程 workspace)中(取决于 OpenShell provision)
隔离强度Docker namespace 级取决于远程主机本身的隔离OpenShell 平台保证
配置复杂度低(一行 setup 脚本)中(SSH key + target host)中(启 OpenShell 插件 + 账号)
Workspacebind-mount 或 copyremote-canonical(首次 seed)mirrorremote 两种模式
浏览器沙箱支持不支持暂不支持
适合场景本地开发,完整隔离把工具调用扔到一台远程机器执行托管型远程沙箱 + 双向同步

绝大多数读者用 Docker 就够了:本地装好 Docker Desktop,Gateway 通过 /var/run/docker.sock 跟 daemon 通讯,沙箱拉起一次几百毫秒,调试体验跟本地几乎一致。

SSH 后端适合两类人:一是宿主机不愿意装 Docker,又有一台闲置的小服务器;二是希望工具调用产生的副作用全部隔离在一台与本机完全分开的机器上,本机只跑 Gateway 这个控制面。SSH 后端是 remote-canonical(以远端为准)模式,第一次创建沙箱时把本地工作区 seed 到远端,之后所有读写都直接打远端,本地不会再同步回来;如果本地修改了文件,要 openclaw sandbox recreate 重新 seed。

这里的 seed 是「播种 / 注入初始数据」的意思,表示单向的一次性初始化复制:把本地 workspace 的文件作为种子内容铺到远端那台空机器上,铺完之后就以远端为事实源,本地后续的改动不会再自动同步过去。和下面 OpenShell mirror 模式的双向同步不是一回事。

OpenShell 是 NVIDIA 出品的 agent 安全运行时,OpenClaw 通过 extensions/openshell 这个扩展接入它。它底层走的还是 SSH 后端那条通道,但所有的操作交给 openshell CLI 接管,并且多了一个 mirror 模式可以把本地 workspace 双向同步到远端,在你需要本地编辑文件 + 远端执行的混合工作流时很有用。

启用 Docker 沙箱

前面讲了那么多,下面这一节我们就来走一遍 Docker 沙箱的最小启用流程。

第 1 步:构建沙箱镜像

OpenClaw 默认沙箱镜像叫 openclaw-sandbox:bookworm-slim,源码 checkout 下来之后运行下面的命令构建该镜像:

$ scripts/sandbox-setup.sh

如果镜像缺失但你又把 sandbox mode 打开了,OpenClaw 在对话时不会静默忽略,而是直接失败,并打印一条提示告诉你如何构建镜像:

Embedded agent failed before reply: Sandbox image not found: openclaw-sandbox:bookworm-slim. Build it with scripts/sandbox-setup.sh before enabling Docker sandboxing. The default image includes python3 for sandbox write/edit helpers; OpenClaw will not substitute plain debian:bookworm-slim.

这个镜像不大,只有 237M:

images.png

它的内容很克制,我们打开 scripts/docker/sandbox/Dockerfile 看一眼:

FROM debian:bookworm-slim@sha256:...

RUN apt-get update \
  && apt-get install -y --no-install-recommends \
    bash \
    ca-certificates \
    curl \
    git \
    jq \
    python3 \
    ripgrep

RUN useradd --create-home --shell /bin/bash sandbox
USER sandbox
WORKDIR /home/sandbox

CMD ["sleep", "infinity"]

可以看到默认镜像只装了 bashcurlgitjqpython3ripgrep 这几样基础工具,不带 nodejs。如果想要功能更全的沙箱,OpenClaw 也提供了一个 openclaw-sandbox-common 的扩展镜像,自带 nodejspython3golangpnpmbunbrew 这些常用工具:

$ scripts/sandbox-common-setup.sh

然后把 agents.defaults.sandbox.docker.image 改成 openclaw-sandbox-common:bookworm-slim 就好。

第 2 步:写配置 + 重启

最小启用就是上面那一段 mode: "non-main" 的配置,写到 ~/.openclaw/openclaw.json 里,然后重启 Gateway:

$ openclaw gateway restart

第 3 步:观察沙箱效果

到 Telegram 里和 agent 对话,让它执行一条 pwd 命令:

telegram-pwd.png

可以看到回复的是 /workspace 这种容器内路径,而不是宿主机上的 workspace 目录。在宿主机上 docker ps 命令:

telegram-container.png

可以看到名字以 openclaw-sbx- 开头的容器被拉起来了,每个非 main 会话对应一个。容器默认 CMDsleep infinity,agent 真正跑工具时是通过 docker exec 进去执行的。

容器默认网络是 none,意味着沙箱里既不能访问外网也不能访问宿主机局域网。如果业务上确实需要联网(比如要执行 npm installpip install 命令),可以修改 agents.defaults.sandbox.docker.network: "bridge" 配置。

注意 network: "host" 是 OpenClaw 强烈不推荐的,因为它绕过了所有网络隔离。

回头补一下前面 workspaceAccess 那里说过的一个问题,生产场景不建议把整个 workspace 用 rw 挂进沙箱,更稳妥的做法是用额外挂载单独开口子,可以通过 agents.defaults.sandbox.docker.binds 参数来实现。比如让沙箱看到一份只读的字典数据、再给 agent 一个干净的可写 scratch 目录:

{
  "agents": {
    "defaults": {
      "sandbox": {
        "docker": {
          "binds": [
            "/Users/foo/dict:/dict:ro",
            "/Users/foo/scratch:/scratch:rw"
          ]
        }
      }
    }
  }
}

格式是 Docker 风格的 host:container:modemodero(只读)或 rw(读写)。注意上面的 defaults 里配置的是全局 binds,每个 agent 也可以配置自己的 binds,它们两是合并关系而不是覆盖,所以一般将共用数据挂载在 defaults 里,特定数据挂载到特定的 agent 里。

沙箱工具策略

在讲沙箱里这套策略之前,先简单介绍一下 OpenClaw 的工具策略。OpenClaw 的 agent 默认能调用一大堆工具:exec 跑命令、read / write / edit 读写文件、browser 控制浏览器、cron 创建定时任务、gateway 调 Gateway 自身的控制 API,这些工具默认都开启着。但实际场景里你未必希望每个 agent 都拿到全套权限:客服 agent 不该有删文件的本事,写日报的 agent 也用不上浏览器自动化。OpenClaw 因此提供了一套工具策略机制:用 tools.allowtools.deny 两个白/黑名单字段决定每个工具能不能被调用,同一个工具如果同时出现在两边,deny 永远赢。这套策略写在 tools.* 下面是全局规则,写到 agents.list[].tools.* 下面就是针对某个 agent 的个性化配置。

回到沙箱。沙箱解决的是工具在哪儿跑的问题,但也要考虑哪些工具允许被调用。OpenClaw 在工具策略之上又叠了一层专门给沙箱用的策略,定义在 tools.sandbox.tools.allowtools.sandbox.tools.deny只对沙箱会话生效,跟全局工具策略是叠加关系,两边都放行的工具才能在沙箱里调用。

默认允许列表和默认拒绝列表写在 src/agents/sandbox/constants.ts 里:

export const DEFAULT_TOOL_ALLOW = [
  "exec",
  "process",
  "read",
  "write",
  "edit",
  "apply_patch",
  "image",
  "sessions_list",
  "sessions_history",
  "sessions_send",
  "sessions_spawn",
  "sessions_yield",
  "subagents",
  "session_status",
] as const;

export const DEFAULT_TOOL_DENY = [
  "browser",
  "canvas",
  "nodes",
  "cron",
  "gateway",
  ...CHANNEL_IDS,
] as const;

沙箱默认放开 execprocessreadwriteeditapply_patch 这套基础执行工具,加上 sessions_* 这套会话管理工具,足够应付绝大多数群聊里的代码问答、文件读写、子会话编排需求。

默认拒绝里值得特别留意几个:

  • browser:浏览器自动化工具,沙箱里默认不允许,避免群聊用户让 agent 去刷你浏览器里的登录态
  • canvas:Live Canvas 工具,沙箱里不允许直接画
  • nodes:跨设备节点 RPC,沙箱里不能直接调你的 iOS/Android 节点
  • cron:定时任务工具,沙箱里不能创建 cron 任务
  • gateway:调用 Gateway 自身控制 API 的工具,沙箱里不能借它提权逃出沙箱
  • ...CHANNEL_IDS:所有频道 ID(discord、telegram、slack、feishu 等),不能在沙箱里直接操作频道

如果业务上确实需要群里的 agent 调某个被默认拒绝的工具,可以按需放开。但这里有一个容易踩坑的地方:allowdeny 这两个字段一旦你显式写了,就是完全替换默认值,不是合并。换句话说,如果你为了放开 cron 而写了 "deny": ["browser", "canvas", "nodes", "gateway"],看似只是去掉了 cron,实际上默认 deny 里的 CHANNEL_IDS 也跟着没了,沙箱里的 agent 就可能调这些频道工具了。

正确的做法是用 alsoAllow 这个字段。它跟 allow 不一样,不会替换默认值,而是在默认值之上做加法:当你只写 alsoAllow 而不动 allow / deny 时,OpenClaw 会保留默认 allow + 默认 deny 不变,然后把 alsoAllow 里出现的工具追加进最终生效的允许列表,同时自动从默认 deny 里扣掉这些工具,其他没动到的拒绝项原封不动地继续拦着。比如要放开 cron 让群聊会话也能创建定时任务:

{
  "tools": {
    "sandbox": {
      "tools": {
        // 只追加 cron,默认拒绝里的 browser / canvas / nodes / gateway / CHANNEL_IDS 全部保留
        "alsoAllow": ["cron"]
      }
    }
  }
}

如果同时还想放开几组工具,OpenClaw 提供了 group:* 快捷写法,比如 group:fs 一次性放开 read/write/edit/apply_patchgroup:runtime 放开 exec/process/code_executiongroup:web 放开 web_search/web_fetch

{
  "tools": {
    "sandbox": {
      "tools": {
        "alsoAllow": ["cron", "group:web"]
      }
    }
  }
}

用 elevated 给 exec 留一条应急通道

开启沙箱之后,偶尔会遇到一类场景:群聊会话里跑了半天,agent 突然需要做一件危险动作,比如把生成的报告写到宿主机的某个目录,或者跑一条只能在宿主机上执行的命令。每次都改全局配置太重,OpenClaw 提供了 elevated 流程作为临时应急通道。

要注意的是,elevated 不是给沙箱开后门,它只影响 exec,作用是让 exec 跑出沙箱、回到宿主机执行:

  • elevated 只针对 exec,不放开整个工具集。被 tools.sandbox.tools.deny 拦掉的工具仍然进不来
  • elevated 不会绕过全局 tools.deny。如果一个工具在全局策略里就被禁了,elevated 也救不回来
  • 在已经直接跑在宿主机上的会话里(main 会话且 mode: off),elevated 不起任何作用
  • elevated 触发 exec 时仍然走正常的审批流程:群里有人发起调用,OpenClaw 会把这次 exec 拦下来,你点同意它才真正执行

elevated 的开关都直接在聊天里发命令切换(就是那种以 / 开头的斜杠命令):

命令行为
/elevated on跑出沙箱,但每次 exec 仍要审批
/elevated ask同上(on 的别名)
/elevated full跑出沙箱并跳过 exec 审批(仅当前会话)
/elevated off回到沙箱内执行
/elevated不带参数,查看当前级别

不过,第一次使用该功能时你大概率会遇到 elevated is not available right now (runtime=sandboxed) 这条报错:

elevated-fail.png

这是 OpenClaw 有意为之的安全设计,不能让任何一个能 @ agent 的人都有权限把 exec 跑出沙箱。tools.elevated 默认虽然开着,但发送者白名单 allowFrom 必须显式列出谁能触发,没在名单里就一律拒绝。

要让某个用户能用 elevated 功能,在 ~/.openclaw/openclaw.json 加这段:

{
  "tools": {
    "elevated": {
      "enabled": true,
      "allowFrom": {
        // 只允许 telegram 里数字 ID 是 7112345678 的人触发 elevated
        "telegram": ["7112345678"]
      }
    }
  }
}

allowFrom.<provider> 数组里的每一项支持四种写法,强烈建议用数字 user ID,避免别人换名字之后白名单失效:

写法匹配备注
"7112345678"数字 user ID推荐,最稳
"id:7112345678"同上,显式声明
"username:yourhandle"Telegram 用户名本人能改掉
"name:张三"显示名最不稳

改完配置记得 openclaw gateway restart 重启 Gateway。如果还跑不通,用下面这条命令打印当前 session 的有效策略和状态:

$ openclaw sandbox explain --session agent:personal:telegram:direct:<your-user-id>

还有一个坑:YOLO 默认模式

配好 allowFrom 之后再发 /elevated on,agent 这次终于跑出沙箱了:

elevated-success.png

但很快你会发现一件事跟前面那张表格对不上:表格里写着「/elevated on 跑出沙箱,但每次 exec 仍要审批」,可现实是 agent 直接就执行了命令,审批根本没弹出来

翻了一下源码 src/infra/exec-approvals.ts

const DEFAULT_ASK: ExecAsk = "off";

加上 tools.exec.security 默认值是 "full",因此 OpenClaw 出厂就是 YOLO 模式:所有命令直接执行,不弹审批。

所以前面那张表格更精确的说法是:

  • /elevated on:跑出沙箱,按当前 tools.exec.ask 配置走审批,默认 "off" 等于不弹
  • /elevated full:跑出沙箱,强制跳过审批,即使你配了 ask: "always" 也无视

也就是说默认配置下 onfull 表现完全一样。要让 elevated 真的把审批弹出来,得显式把 tools.exec.ask 调成 "always"(每次都弹)或 "on-miss"(命令不在预批准列表里时才弹):

{
  "tools": {
    "exec": {
      "ask": "always"
    },
    "elevated": {
      "enabled": true,
      "allowFrom": {
        "telegram": ["7112345678"]
      }
    }
  }
}

tools.exec.ask 的三档取值:

取值含义
"off"(默认)永远不弹审批,命令直接执行(YOLO)
"on-miss"命令不在 ~/.openclaw/exec-approvals.json 预批准列表里时才弹
"always"每次都弹审批

到这一步 /elevated on 才真正像表格里说的那样,跑出沙箱 + 每次 exec 都要你点同意:

exec-approval.png

我们不仅可以在 Telegram 中审批,Control UI 中也会弹窗:

exec-approval-ui.png

小结

通过这一篇,我们把 OpenClaw 的沙箱机制整体过了一遍:

  1. 为什么要沙箱 —— main 会话直接跑等于给 agent 你的本机权限,群聊和频道这些非 main 会话天然算高风险,应该进沙箱
  2. 三种沙箱模式 —— off / non-main(推荐起步)/ all,配合 scopeworkspaceAccess 调整隔离粒度
  3. 三种沙箱后端 —— Docker(默认且最常用)、SSH(把工具调用扔到远程机器)、OpenShell(托管沙箱 + 双向同步)
  4. 沙箱工具策略 —— 默认放开 execprocessreadwriteeditapply_patchsessions_*,拒绝 browsercanvasnodescrongateway 和所有频道 ID
  5. 使用 elevated 临时提权 —— 只对 exec 生效,不放开整个工具集,可以配 allowFrom 限制哪些用户能触发

到这里,小龙虾在自己机器上的安全边界算是画清楚了。但前面这一路一直有个没怎么挑明的事实:你这些天敲的那些 openclaw 命令、浏览器里打开的 Control UI 网页,其实都是通过 WebSocket 连进 Gateway 这个有状态的中枢进程的,它们都算 OpenClaw 的官方客户端。除此之外还有 macOS app、以及手机上的 OpenClaw Node app,也都是同一类客户端。本机客户端连起来很顺,直接访问 ws://127.0.0.1:18789 就行;可一旦你想让手机上的 OpenClaw Node app 也能连上家里的 Gateway,这就是个问题了。

所以接下来两篇是连着的一对:下一篇先讲 Gateway 的远程访问,看 OpenClaw 怎么在不破坏安全底线的前提下把本机限制放开,让外网的客户端也能找到 Gateway;再下一篇讲 iOS / Android Node 配对,把手机上的 OpenClaw Node app 和家里的 Gateway 配上对,手机就能当远程麦克风、摄像头和位置传感器,配合 voice wake 出门也能用。

参考


让小龙虾分身:多 Agent 路由与 Sub-agents

上一篇我们把 OpenClaw 的自动化体系拼齐了:Cron 调度时间、Heartbeat 周期觉察、Webhook 接外部事件、Standing Orders 圈定边界、Background Tasks 记账、Task Flow 编排。整套体系跑起来之后,小龙虾已经能按时间表干活、被外部事件唤起、所有后台动作都有据可查了。但如果再仔细看一眼就会发现,它到现在还是一个人在战斗 —— 所有消息都进同一个 agent,共用同一份记忆、同一份工具白名单、同一份 system prompt。

这种单 agent 的模式跑日常需求够用,可只要场景稍微复杂一点就开始捉襟见肘。最直观的痛点是工作和生活混在一起:白天我在公司飞书群里聊的是项目排期、代码 review、客户需求,晚上我在 Telegram 上聊的却是健身打卡、买菜清单、跟家人聊天。这两套上下文完全是两个频道的事,一旦汇到同一个会话里,agent 既不能按场景切换语气,记忆也跟着打架。今天我们就来看看 OpenClaw 是怎么把不同来源的消息分流到不同 agent 的,再顺带看看主 agent 怎么把活派给后台的子 agent 跑。这两套机制官方分别叫 多 agent 路由(Multi-agent Routing)Sub-agents

为什么需要多个 agent

为什么单 agent 不够用?我能想到三种典型场景,挨个说一下你大概就能体会到了。

第一种是 工作和生活混用。同一个人既要在公司飞书群里和同事讨论技术方案,又要在朋友的微信群里聊周末爬山。两边共用一个 agent,要么聊技术时它太活泼,要么聊生活时它太严肃,怎么调都顾此失彼。把工作群路由到一个严肃专业的 agent,朋友群路由到一个轻松点的 agent,这事儿就解决了。

第二种是 对外服务和自家场景的隔离。客户在微信上咨询产品,希望走一个带 RAG 的客服 agent,回答必须基于知识库、绝对不能动文件;自己在 Telegram 上私聊,则希望走一个全权限的助理,能改代码、能跑命令、能调内部 API。这两边的 system prompt、工具集、模型选型都不一样,硬塞到一个 agent 里只会两头不讨好。

第三种是 模型按需匹配。开发者频道里跑代码 review 想用 Opus 这种大模型,运营频道里答常见问题用便宜的 Sonnet 或 GPT-4o-mini 就够了。每个 agent 自带一份模型配置,用哪个 agent 自然就走哪个模型,按场景对上号,省得每条消息都手动指定一遍。

OpenClaw 把上面这些诉求都收敛到了同一个概念:一个 agent 就是一份完整的人格作用域。它有自己独立的工作目录、独立的认证信息、独立的模型清单、独立的会话历史。同一个 Gateway 进程里可以并存好几个 agent,互不干扰。

一个 agent 到底隔离了什么

照着官方文档 docs/concepts/multi-agent.md 里的定义,我们把隔离维度列一张表:

维度说明
工作目录每个 agent 有自己的 workspace,里面装着 AGENTS.mdSOUL.mdUSER.md 这些 bootstrap 文件
状态目录也叫 agentDir,存这个 agent 自己的认证文件和模型注册表
会话存储聊天历史和路由状态,落在 ~/.openclaw/agents/<agentId>/sessions 目录下
Skills每个 workspace 自己加载一套 skill,可以叠加共享根目录下的公共 skill
工具策略工具白名单和黑名单可以按 agent 单独配
沙箱沙箱模式可以做到一个 agent 一个容器
模型每个 agent 可以挂不同的模型

表里的状态目录这一项还有个细节值得单独说一下。每个 agent 的认证档案具体落在 ~/.openclaw/agents/<agentId>/agent/auth-profiles.json 这个文件里,如果某个 agent 自己没有这份文件,OpenClaw 会自动去默认 agent 那边借一份顶上。这种"借用"对静态的 api_key / token 和 OAuth 都生效 —— OAuth 凭据走的是穿透读取,新 agent 默认就能复用主账号,不需要重新登录;token 过期时由其中一个 agent 持文件锁完成刷新,再写回默认 agent 的存档,其它 agent 在下次取用时自动拿到新值。OAuth 的 refresh token 不会被物理复制到新 agent 的目录,只是实现细节,不影响共享语义。只有当你确实想让某个 agent 走另一个账号时,才需要为它单独跑一次登录。

整套目录结构最后大致是这样:

~/.openclaw/
├── openclaw.json                       # 主配置文件
├── workspace/                          # 默认 agent 的工作区
├── workspace-work/                     # 工作 agent 的工作区
├── workspace-personal/                 # 私人 agent 的工作区
└── agents/
    ├── main/
    │   ├── agent/                      # auth-profiles.json + 模型注册
    │   └── sessions/                   # 会话 jsonl
    ├── work/
    │   ├── agent/
    │   └── sessions/
    └── personal/
        ├── agent/
        └── sessions/

可以看到每个 agent 都把自己的认证信息和会话记录关在了自己的 agents/<agentId>/ 目录下,谁也不会去翻别人的家底。

创建新 agent 实例

下面我们动手把单 agent 的小龙虾改造成 3 个 agent:main 用来兜底,work 处理工作场景,personal 处理私人场景。

最省事的方式是用 OpenClaw 自带的 openclaw agents add 命令:

$ openclaw agents add work --workspace ~/.openclaw/workspace-work
$ openclaw agents add personal --workspace ~/.openclaw/workspace-personal \
    --model anthropic/claude-opus-4-6

命令执行结果如下:

agents-add.png

该命令会自动把 workspace 目录建好、把 SOUL.md / AGENTS.md / USER.md 这几份 bootstrap 文件写进去、把 agents/<id>/agent 状态目录和 sessions 目录都拉起来,再把对应的 agents.list[] 段追加到 openclaw.json 里。

打开 openclaw.json 配置文件,可以看到 agents 下多了一个 list 列表:

{
  "agents": {
    "defaults": {
      // 默认值,所有 agent 复用
    },
    "list": [
      {
        "id": "main"
      },
      {
        "id": "work",
        "name": "work",
        "workspace": "~/.openclaw/workspace-work",
        "agentDir": "~/.openclaw/agents/work/agent"
      },
      {
        "id": "personal",
        "name": "personal",
        "workspace": "~/.openclaw/workspace-personal",
        "agentDir": "~/.openclaw/agents/personal/agent"
      }
    ],
  },
}

其中 agentDir 这一项尤其要小心:绝对不能在多个 agent 之间复用同一个目录,复用会导致认证信息互相覆盖、会话串号。每个 agent 都应该有自己独立的 agentDir,路径里带上 agent id 是个好习惯。另外,值得提一句的是,workspace 工作目录只是软隔离,并不是硬沙箱,它只是个默认的工作目录,工具拿到相对路径会落在这里,但绝对路径仍然能访问主机其它位置。要做硬隔离得开 sandbox,这个我们留到下一篇讲。

新 agent 创建成功后,我们可以使用下面的命令测试一下:

$ openclaw agent --message "你是谁" --agent work

运行结果如下:

agent-message.png

可以看到,这个 agent 是全新的,还没有孵化过,你可以在 TUI 中和其进行对话,完成初始设置,比如给 work agent 设定工作场景的语气(专业、简洁、不开玩笑),personal agent 则可以写得轻松点。

把消息路由到对的 agent

agent 都建好了,但 OpenClaw 默认还是会把所有消息都送到 default: true 标的那个 agent 上。要让消息按来源分流,得给每个非默认 agent 配一条对应的路由规则,告诉 OpenClaw 哪个频道、哪个账号、哪个群该走哪个 agent。

和 agent 创建一样,最省事的方式是直接用 openclaw agents bind 命令:

$ openclaw agents bind --agent work --bind feishu:default
$ openclaw agents bind --agent personal --bind telegram

第一条把飞书+默认账号的所有消息绑到 work agent,第二条把 Telegram 频道绑定到 personal,执行完回头看 openclaw.json,会发现多了一段 bindings 数组:

{
  // ... 上面的 agents 段保持不变 ...

  "bindings": [
    {
      "type": "route",
      "agentId": "work",
      "match": {
        "channel": "feishu",
        "accountId": "default"
      }
    },
    {
      "type": "route",
      "agentId": "personal",
      "match": {
        "channel": "telegram"
      }
    }
  ]
}

每条规则的核心就是 agentId + match 这一对:消息进来之后,OpenClaw 会拿 match 里的字段挨个去对,全部对上才把消息送给对应的 agent;一条规则都没对上的,兜底到默认 agent。

写完之后重启 Gateway 让配置生效,然后再到飞书或 Telegram 里对话:

telegram.png

一个全新的 ”老钳“ 就上线了。

openclaw agents bind --bind <channel[:accountId]> 这种命令行形式只覆盖到"频道 + 账号"这一层。如果你想做更细粒度的匹配,比如把某个特定的飞书群单独路由到 work,或者按 DM 发件人 ID 拆分,还得直接编辑 bindings 数组。比如下面这条按群 ID 把工作群单独绑到 work 的规则:

{
  "agentId": "work",
  "match": {
    "channel": "feishu",
    "peer": { "kind": "group", "id": "oc_xxxxxxxxxxxxxxxxxxxxxxxxxxxx" },
  },
}

从命令行手动触发 agent 轮次

前面创建 work agent 之后,我们顺手用 openclaw agent --message "你是谁" --agent work 给它发了条消息,我们这一节详细介绍下这个命令。它绕过路由规则直接把消息塞到指定 agent 的会话里,跑完之后把回复打印到终端,日常排查单个 agent 行为的时候特别方便。

之前那次只是用了它最简单的形态,下面把常用 flag 一并展开看一下:

Flag含义
--message <text>这次轮次的初始 prompt(必填)
--agent <id>锁定到某个 agent,用它的主会话
--session-id <id>直接复用一个已有的会话 key
--channel入站频道,不填则用主会话所在的频道,同时也是默认的出站频道
--to <dest>入站目标(手机号、chat id),用来推导会话 key
--deliver把回复推到聊天频道,不只打印到终端
--reply-channel覆盖出站频道(whatsapp / telegram / discord / slack ...)
--reply-to覆盖出站目标(手机号、频道 ID)
--local默认情况下 CLI 会走 Gateway 中转,加 --local 会强制改用本机内嵌的 runtime;如果 Gateway 不可达,CLI 也会自动 fallback 到 local 模式
--json结构化 JSON 输出

这里面 --channel / --to--reply-channel / --reply-to 看着很像,实际是两个方向的事:

  • --channel + --to 决定入站会话。OpenClaw 会拿 <channel> + <to> 拼出一个会话 key(形如 whatsapp:+8613800001234),把这条消息塞进那个会话里跑。它们决定 agent 用谁的视角说话、加载谁的历史。
  • --reply-channel + --reply-to 决定出站去向。默认情况下加 --deliver 之后,回复会原路回到入站对应的频道和目标;只有当你想让出站和入站不一致时,才需要这两个 flag 显式覆盖。

绝大多数场景里入站和出站是同一边(用户从飞书发消息进来,回复也回到飞书),完全用不到 --reply-*;只有没有真正入站消息的场景,比如 cron 定时触发生成、或者从命令行起一轮然后把结果推到固定播报频道,才需要显式声明出站去哪儿。

下面是两种用法的示例:

# 1. 只声明入站,出站走默认 —— 模拟从 Telegram 渠道发来消息,回复原路返回
$ openclaw agent --agent personal --channel telegram --to "71123456789" \
    --message "我的快递到哪了" --deliver

# 2. 不带入站,出站显式声明 —— cron 触发 work agent 生成周报,结果推到 Telegram 的 work 群
$ openclaw agent --agent work --message "总结这周的工作情况" \
    --deliver --reply-channel telegram --reply-to "-5000412345"

第一条只声明了入站(--to + --channel),出站走默认,回复自动回到 Telegram 上,根本不用设置 --reply-*;第二条反过来,没有入站消息,OpenClaw 推不出默认的回复目标,所以必须用 --reply-channel + --reply-to 显式告诉它推到 Telegram 的指定群。

总的来说,openclaw agent 既能给人手动用,也能丢到 cron 里跑,本质上就是一个从命令行触发 agent 轮次的小工具。

让 agent 自己派活给子 agent

如果说 openclaw agent从外面起一次 agent 轮次,那么 sub-agents 这套玩法就是从里面派:主 agent 跑到一半,自己决定把某个子任务派给一个后台的子 agent 去跑,跑完结果再回流到主 agent 这边。这件事就是 sessions_spawn 这个工具干的。

怎么跑起来

子 agent 是从已有的 agent 轮次派生出来的后台轮次。它在自己的会话里跑(会话 key 形如 agent:<agentId>:subagent:<uuid>),跑完之后通过 announce 步骤把结果 push 回请求者所在的聊天里。每一次子 agent 的运行也会被记成一条 background task,也就是上一篇讲的 subagent 这种 runtime。

主 agent 派活给子 agent 的整体时序大致是这样:

subagents.png

这里要特别注意一点:sessions_spawn非阻塞 的,调用之后会立刻返回 { status: "accepted", runId, childSessionKey },而不会等子任务跑完。官方文档里特别强调:完成是 push 模式的,不要在主 agent 里写一个 sessions_listexec sleep 的死循环去等结果,子任务跑完之后会自己冒泡上来。

我们在 main 会话里测试一下:

subagents-test.png

可以看到 main 会话成功调用了 sessions_spawn 工具,并且立即收到了返回结果:

{
  "status": "accepted",
  "childSessionKey": "agent:main:subagent:f9c4eec1-3b88-4e87-b095-eac7a9d8beb9",
  "runId": "897aee90-735e-447a-b99e-a859f39ad995",
  "mode": "run",
  "note": "Auto-announce is push-based. After spawning children, do NOT call sessions_list, sessions_history, exec sleep, or any polling tool. Track expected child session keys. If any required child completion has not arrived yet, call sessions_yield to end the turn and wait for completion events as user messages. Only send your final answer after ALL expected completions arrive. If a child completion event arrives AFTER your final answer, reply ONLY with NO_REPLY.",
  "modelApplied": true
}

此时,我们在 main 会话里并没有阻塞,还可以正常进行其他对话。过了一会,等子 agent 完成了任务,结果会被 push 回 main 会话中:

subagents-test-2.png

几个关键参数

sessions_spawn 的常用参数列在下面:

参数含义
task子任务描述(必填)
agentId派给哪个 agent,默认为空,就是请求者自己
model覆盖子任务的模型,给重活配便宜模型时常用
thinking覆盖子任务的 thinking 等级
runTimeoutSeconds子任务超时秒数,0 表示不超时
contextisolated(默认)或 fork
runtime子任务跑在哪种运行时上:subagent(默认,内置子 agent runtime)或 acp(走 ACP 协议接外部 agent runtime)
sandboxinheritrequire,后者强制要求子运行时在沙箱里跑

其中 context 这个参数挺有意思:

  • isolated(默认):开一份干净的子会话记录,token 成本低。文档里强烈推荐用这个,把任务在 task 字段里讲清楚直接派出去
  • fork:把当前整段会话记录分支到子会话里,子任务能继承所有上下文,但 token 成本翻倍。只在子任务确实需要当前对话的细微上下文时才用,不是写不清楚 task 文本时的替代品

另外要记住的是,sessions_spawn 本身不接受任何投递参数(target / channel / to 这些都不能传)。要让子 agent 把结果 deliver 到某个具体频道,得让它自己在轮次里调用 message 或者 sessions_send 这类工具。

allowAgents 限制派给谁

默认情况下,子 agent 只能派给 请求者自己这一个 agent。如果你希望主 agent 能把活派给别的 agent(比如把客服任务派给 support agent),就得显式打开 subagents.allowAgents 这一项:

{
  "agents": {
    "list": [
      {
        "id": "main",
        "subagents": {
          "allowAgents": ["main", "personal", "work"]
        }
      }
    ]
  }
}

allowAgents 这个数组里也可以写 ["*"] 表示允许任意 agent。这里有个小细节要注意:如果你写了非空列表又想让主 agent 派任务给自己,必须把 "main" 显式列在里面,不会自动包含。

小结

通过这一篇,我们把 OpenClaw 的多 agent 体系整个走了一遍:

  1. 为什么单 agent 不够用 —— 工作和生活混用、对外服务和自家场景混用、不同任务想配不同模型,这些都是单 agent 的痛点;
  2. 一个 agent 隔离了什么 —— 工作目录、agentDir、会话历史、认证、工具、沙箱、模型,全都按 agent 一份一份独立维护;
  3. 怎么创建 agent —— 通过 openclaw agents add 命令一行搞定 workspace、bootstrap 文件、状态目录、会话目录,并自动完成 openclaw.json 的配置;
  4. 怎么按来源分流消息 —— 通过 openclaw agents bind 命令为非默认 agent 配一条路由规则,核心是 agentId + match 对,匹配越具体优先级越高,没匹配到就兜底到默认 agent;
  5. 怎么从命令行与 agent 对话 —— openclaw agent 的参数分两组:--channel / --to 决定入站会话--reply-channel / --reply-to 决定出站去向,加上 --deliver 才会真把回复推到聊天频道;
  6. 让 agent 自己派活给后台子 agent —— 主 agent 跑到一半调用 sessions_spawn 把任务派出去,非阻塞,子任务在独立会话里跑,跑完通过 announce 步骤推回。

到这里,小龙虾已经能在多个频道上以不同人格运行,agent 之间也能互相派活了。但 Gateway 本身仍然跑在一台机器上,所有 agent 共享一个进程。如果我希望从办公室的 Mac 远程连到家里的 Gateway 服务器、或者把某些不可信的 agent 关进 Docker 沙箱里跑,又该怎么做呢?我们下一篇就来看看 OpenClaw 的 Sandbox 和远程访问。

参考

欢迎关注

如果这篇文章对您有所帮助,欢迎关注我的同名公众号:日习一技,每天学一点新技术

我会每天花一个小时,记录下我学习的点点滴滴。内容包括但不限于:


给小龙虾配本活动账本:Background Tasks 与 Task Flow

前面两篇我们给小龙虾装上了发条(Cron 和 Heartbeat)和外部入口(Webhook 和 Standing Orders)。到这里它已经能按表干活、被外部事件唤起、每次开会话都记得 AGENTS.md 里的边界。但只要这种脱离主会话的入口越多,一个问题就越迫切:那些跑在主会话之外的 agent 轮次,包括隔离的定时任务、调用 /hooks/agent 发起的一次性任务、还有后面将会学到的 subagent 任务,它们到底跑得怎么样了?是顺利完成、还是超时、还是在某个角落死循环了却无人问津?

OpenClaw 使用 Background Tasks 来回答这个问题。它不调度任何东西,只忠实地记下每一次后台工作的来龙去脉,是这套自动化体系的 活动账本。账本之上还有一层 Task Flow,把多步流程组织起来,在 Gateway 重启时也不会丢失进度。今天我们就来学习这两个新概念。

Tasks 不是调度器

前面两篇里出现过一堆带 task 字眼的术语:cron taskscheduled taskbackground task。它们指的不是同一个东西:

名字在 OpenClaw 里指的是
Scheduled task / Cron job~/.openclaw/cron/jobs.json 里的 调度定义,由 Gateway 内置调度器按表触发
Background task每一次后台 agent 轮次的 活动记录,存在 ~/.openclaw/tasks/runs.sqlite
Task Flow多个 background task 串起来的 流程编排状态,存在 ~/.openclaw/flows/registry.sqlite

根据 OpenClaw 文档里的定义:

Tasks do not replace sessions, cron jobs, or heartbeats — they are the activity ledger that records what detached work happened, when, and whether it succeeded.

翻译过来就是:Tasks 不是用来取代会话、cron job 或 heartbeat 的,它们是一份 活动账本,专门记录发生过哪些后台工作、什么时候发生、有没有跑成功。

也就是说,Cron 决定什么时候跑,Tasks 记录跑了什么、跑得怎么样,两者互不替代。

哪些动作会建一条 task 记录

不是每次 agent 轮次都会建 task,OpenClaw 把建 task 的来源分成四种 runtime:

Runtime触发场景默认 notify 策略
acpACP 子会话被起一次(外部 ACP 客户端跑 agent)done_only
subagent主 agent 通过 sessions_spawn 派活给 subagentdone_only
cron任何 cron 触发的执行(主会话的也算,isolated 的也算)silent
cliopenclaw agent 这类命令通过 Gateway 起的轮次,包括异步媒体生成silent

而下面这些场景 不会 建 task 记录:

  • Heartbeat 轮次:主会话里隔 30 分钟自己醒一次,太频繁,建账本没意义
  • 正常的交互聊天:用户发消息 → agent 回复,本来就在会话里有完整对话记录
  • /<command> 这类直接命令响应:是 CLI 直返的,没有跑在主会话之外的 agent 轮次

总结来说:任何会脱离主会话独立跑的 agent 轮次,都会留下一条 task 记录。包括前几篇里反复出现的 isolated cron、/hooks/agent 触发的隔离轮次,它们其实都是在 cron / cli 这两个 runtime 下创建 task。

其中 notify 策略决定 task 跑完之后要不要主动推消息:done_only 只在终态时推一条,是 acpsubagent 的默认值;silent 并不是不记账,只是不主动发通知,是 croncli 的默认值,因为这两种 runtime 已经有 --announce 这类投递参数,再走一道默认通知就重复了。OpenClaw 还有第三种策略 state_changes,每次状态变化都推,太吵所以默认不挂在任何 runtime 上,需要密切关注某条 task 时再用 openclaw tasks notify 临时切过去。

Task 的生命周期

每条 task 都从 queued 进入,最终落到一个终态。生命周期源码定义在 src/tasks/task-registry.types.ts

export type TaskStatus =
  | "queued"
  | "running"
  | "succeeded"
  | "failed"
  | "timed_out"
  | "cancelled"
  | "lost";

七种状态的转换如下图所示:

task-status.png

每种状态含义如下:

状态含义
queuedtask 已创建,等待 agent 起跑
runningagent 轮次正在执行
succeeded正常完成
failed跑出了非超时类的错
timed_out超过了配置的超时时间
cancelledopenclaw tasks cancel 显式打断
lost后台运行时失去了对它的所有权,且超过 5 分钟宽限期

其中 lost 这个状态比较特殊,它不是 agent 主动报告的,而是 sweeper 检测出来的。这里的 sweeper 是一个定时的后台清扫器,专门负责把 task 的实际状态跟后台运行时对齐,并清掉过期的旧记录。它每隔 60 秒(源码里的 TASK_SWEEP_INTERVAL_MS = 60_000)扫一遍所有活跃的 task,看后台会话是不是还在。如果某个 ACP 子会话已经从注册表里消失、cron job 已经从活跃集合里移除、subagent 的子会话已经被销毁,OpenClaw 会先给 5 分钟(TASK_RECONCILE_GRACE_MS = 5 * 60_000)的宽限期,过了还没回来就标记为 lost

上手 openclaw tasks 系列命令

这一节我们实际跑一下 Tasks 相关的命令,它们全部挂在 openclaw tasks 子命令下,不带任何子命令时等价于 openclaw tasks list。我们一个一个看。

list:账本一览

最常用的就是 list,按时间倒序列出所有 task:

$ openclaw tasks list

运行结果如下:

tasks-list.png

输出列分别是 Task ID、Kind(runtime 类型)、Status、Delivery、Run ID、Child Session、Summary。如果想只看 isolated cron 跑出来的活,加 --runtime cron;只看在跑的,加 --status running

$ openclaw tasks list --runtime cron --status running

--json 可以把整张表导出成结构化输出,方便接到其他工具或脚本里。

show:单条详情

list 的输出列是浓缩版。要看完整字段就用 show:

$ openclaw tasks show <task-id>

show 会把 createdAt / startedAt / endedAt、投递状态、错误信息、终态摘要全部打出来。排查后台任务跑出问题时基本上靠它。

cancel:踩刹车

如果某条 task 跑死循环、或者 prompt 写错了想取消,可以用 cancel 把它停掉:

$ openclaw tasks cancel <task-id>

不同 runtime 的处理方式不一样:ACP 和 subagent task 会去终止后台子会话,CLI 跟踪的 task 因为没有可终止的后台句柄(轮次本身可能早就跑完了),cancel 只在账本里走一次状态变更。最后无论哪种情况,task 状态都会切到 cancelled,并按当前 notify 策略发一次投递通知。

notify:调投递粒度

每条 task 默认按所属 runtime 的 notify 策略推消息(done_onlysilent)。如果想临时密切关注某条 task 的进展,可以临时切到 state_changes

$ openclaw tasks notify <task-id> state_changes

切完之后,这条 task 每次状态变(包括进度更新)都会推一条出来。task 走到终态之后这个开关也就跟着结束了。

audit:发现账本异常

audit 是 task 体系里我个人觉得最有价值的一个命令。它会扫一遍所有 task,按一组健康规则挑出有问题的记录:

$ openclaw tasks audit

规则在源码里定义在 src/tasks/task-registry.audit.ts,对应的告警规则有下面这几条:

Finding触发条件严重度
stale_queuedtask 在 queued 状态待了超过 10 分钟(DEFAULT_STALE_QUEUED_MSwarn
stale_runningtask 在 running 状态跑了超过 30 分钟(DEFAULT_STALE_RUNNING_MSerror
lost后台运行时已经没了,留着等回收warn / error
delivery_failed投递失败且 notify 策略不是 silentwarn
missing_cleanuptask 已经进入终态但没有 cleanupAfter 时间戳warn
inconsistent_timestamps时间线打架(比如 endedAt 比 startedAt 早)warn

audit 的输出还会跟 openclaw status 联动:

openclaw-status.png

命令输出中 Tasks 那一行 audit clean 状态,就是从 audit 这里来的。

maintenance:清账与回收

audit 只报告问题不修问题,要真的修就得用 maintenance:

$ openclaw tasks maintenance
$ openclaw tasks maintenance --apply

不带 --apply 是 dry-run,只预览要做哪些动作;带上 --apply 才真正执行。maintenance 干的活分四类,都在 src/tasks/task-registry.maintenance.ts 里:

  1. 状态核对:核对每条活跃 task 的后台状态。ACP / subagent task 看子会话还在不在;cron task 看是不是还被 cron 运行时持有;聊天会话挂的 CLI task 看对应的运行上下文。后台状态丢了超过 5 分钟,就标 lost
  2. ACP 会话修复:关掉孤儿的或已经进入终态的一次性 ACP 会话
  3. 打 cleanup 时间戳:给所有进入终态的 task 打上 cleanupAfter = endedAt + 7 天 的时间戳
  4. 回收:删掉超过 cleanupAfter 的记录

整个 sweeper 会每 60 秒自动跑一遍这套流程,所以正常情况下不需要手动跑。手动跑主要用在两个场景:CI 里跑 audit 拿不到干净结果,需要手动核对一遍;或者排查长时间堆积的 lost 记录。

进入终态的 task 默认保留 7 天(TASK_RETENTION_MS = 7 * 24 * 60 * 60_000),过期之后被 sweeper 自动清掉。如果你想长期归档某些 task,得在 7 天内手动导出。常见的做法有两种:用 openclaw tasks list --json > tasks.json 把整张账本(或者带 --runtime / --status 过滤后的子集)一次性写成 JSON 文件;或者直接把 ~/.openclaw/tasks/runs.sqlite 拷出来,用 sqlite3 客户端跑 SQL 查。

投递路径与 notify 策略

讲完命令再回头看一个容易踩的点:task 跑完之后,结果到底是怎么推到你身上的?官方文档讲了两条路径:

  • 直接投递(Direct delivery):task 创建时如果带了 requesterOrigin(也就是触发方所在的渠道,比如 Telegram 私聊),终态通知直接 POST 回那个渠道。subagent 的话还会保留 thread/topic 路由,从请求方会话的 lastChannel / lastTo / lastAccountId 兜底缺失字段
  • 会话排队投递(Session-queued delivery):直接投递失败、或者根本就没绑外部渠道,更新会被塞进请求方会话的系统事件队列,等下一次 heartbeat 时连带冒出来

至于具体推多少,看的则是 notify 策略:

策略推什么
done_only只推终态(succeeded、failed 等),默认值
state_changes每次状态变化和进度更新都推
silent啥都不推

cron 和 cli 默认是 silent 策略,原因前面提过:cron 自己有 --announce 参数,再来一次默认通知就重复了。如果你想让某条默认静默的 task 也开始推通知,可以用前面讲过的 openclaw tasks notify <task-id> done_only 把它的策略临时改掉。

Task Flow:把多个 task 串起来

讲完单条 task,再来看上一层的 Task Flow。

理论上一条 background task 就够你应付绝大多数后台任务了。但如果这件事需要拆分成好几个步骤执行,task 这一层就开始捉襟见肘了。比如周报流程:先收集数据、再生成报告、再投递。这三步如果都用 cron 各自定,一是没法保证按顺序、二是任何一步挂掉之后就只剩前面那几条 succeeded、看不出整体流程跑到哪。

为此 OpenClaw 提出了 Task Flow 的概念。从源码里看(src/tasks/task-flow-registry.types.ts),它其实是 task 之上的一层流程编排。Task Flow 保存在独立的 ~/.openclaw/flows/registry.sqlite 文件中,跟 task 那边的 runs.sqlite 完全解耦,两者通过 parentFlowId 字段联系。一个 Task Flow 具有如下几种状态:

export type TaskFlowStatus =
  | "queued"
  | "running"
  | "waiting"
  | "blocked"
  | "succeeded"
  | "failed"
  | "cancelled"
  | "lost";

可以看到,跟 task 比,flow 多了 waiting(等下一步触发)和 blocked(被审批门槛卡住)两个状态。

另外,flow 还多了一个 revision 字段,自带版本号控制,每次更新都要带上预期的版本号,碰到并发冲突就直接拒绝。

另一个和 task 的区别在于,flow 还具备两种不同的同步模式:

export type TaskFlowSyncMode = "task_mirrored" | "managed";

这两种同步模式覆盖了两种使用场景:

模式谁拥有生命周期适合场景
managedTask Flow 全权负责一段流水线:A 完成 → B 自动起 → C 自动起 → 整体成功
task_mirroredtask 由外部创建,flow 只观察三个独立 cron job 合起来组成一次早间简报

managed 模式:流水线模式

managed 模式下,Task Flow 就是这条流水线的总指挥。它按步骤创建 task、等 task 完成、再推进到下一步。整体长这样:

Flow: weekly-report
  Step 1: gather-data     → task 创建 → succeeded
  Step 2: generate-report → task 创建 → succeeded
  Step 3: deliver         → task 创建 → running

Step 1 跑完之前 Step 2 不会启动;Step 2 还没完成时整个 flow 卡在 running。如果 Step 2 翻车了,flow 进 failed,Step 3 不会启动,前面那条 succeeded 的 task 也不会被回滚。

mirrored 模式:观察者模式

mirrored 模式不一样,flow 不创建 task,它只是 观察 已经存在的 task,把它们当成一个整体来看。比如你现在已经有三个 cron job:

  • morning-news:8:30 拉新闻
  • morning-meeting:8:35 拉日历
  • morning-pr:8:40 拉 PR 状态

各自独立运行,谁跑完都不需要等谁。但你想要一个统一视角看今天上午这套早间简报跑得怎么样,就可以用 mirrored flow 把这三条 task 关联起来,flow 状态就是这三条的聚合。

mirrored 模式最大的好处是 不侵入现有 cron 配置。三个 cron job 啥都不用改,flow 是从外部观察它们。

Flow 的 CLI

flow 这一层的 CLI 挂在 openclaw tasks flow 下,只有三个子命令,全是 观察和干预,没有 create:

$ openclaw tasks flow list   [--status <name>] [--json]
$ openclaw tasks flow show   <lookup> [--json]
$ openclaw tasks flow cancel <lookup>

之所以没有 create,是因为 flow 并不是由用户直接通过 CLI 创建的,而是由上层编排自动建出来的:比如通过 Lobster 流水线跑起来时,会建一条 managed flow,流水线的每个步骤落成带 parentFlowId 的 task;另外通过 sessions_spawn 创建的 subagent 或 ACP 子会话也会被自动包成一条 1:1 的 mirrored flow。

tasks flow canceltasks cancel 最大的不同是它 持久:取消意图会写到 ~/.openclaw/flows/registry.sqlite,即使 Gateway 重启,flow 也会保持 cancelled、不会再起新的步骤。已经在跑的子 task 会被一起拉下来,没起的 step 直接放弃。

关于这个话题比较复杂,这一节就讲到这里。Task Flow 真正发挥价值的场景是 cron 触发 + Lobster 流水线 + Task Flow 追踪 的三明治结构:cron 负责调度时机、Lobster 负责流水线 DSL 和审批门槛、Task Flow 负责跨 task 跨重启的状态。完整的端到端例子(怎么写一条 Lobster pipeline、flow 长什么样、出问题怎么排查)涉及 Lobster 这个工作流引擎本身的细节,篇幅放不下,我们后面单开一篇讲 Lobster 时再一起拆开讲。

把它跟前面几篇拼起来

到这里我们把 OpenClaw 自动化体系里的所有积木都见过一遍了:Cron 调度时间、Heartbeat 周期觉察、Webhook 接外部事件、Standing Orders 圈定边界、Background Tasks 记账、Task Flow 编排。整个图大概是这样:

all.png

这张图中有几个点值得记一下:

  1. 调度入口(Cron / Webhook / Subagent / ACP)触发的所有后台轮次 都会在 Tasks 里留一笔;只有 Heartbeat 不建 task
  2. Tasks 进入终态时反过来戳一下 Heartbeat,让结果在主会话里冒泡
  3. Standing Orders 跨所有入口生效,是横切于上面这套图的一层 system prompt 注入
  4. Task Flow 是可选的,简单单步活动用 task 就够,多步流程才升级到 flow

小结

通过这一篇,我们把 OpenClaw 自动化体系剩下的两块讲完了:

  1. Background Tasks:后台 agent 轮次的活动账本,存在 ~/.openclaw/tasks/runs.sqlite,由 ACP / subagent / cron / cli 四种 runtime 各自创建。Heartbeat 和正常聊天不建账
  2. CLI 工具集openclaw tasks list/show/cancel/notify/audit/maintenance。日常用 list 看一眼,audit 在 CI 或者排障时定期扫一下,maintenance 在需要立刻收紧账本时手动跑
  3. 投递机制:进入终态时优先直接投到 requesterOrigin,失败则塞进请求方会话的系统事件队列,下一轮 heartbeat 冒泡
  4. Task Flow:task 之上的一层流程编排,存在 ~/.openclaw/flows/registry.sqlitemanaged 模式自己驱动多步流水线,mirrored 模式只观察外部创建的 task

现在小龙虾的整套自动化体系已经全部跑通:它会按时间表干活、能被外部事件唤起、每个会话都遵守 standing orders 的边界、所有后台工作都在账本里有据可查、多步流程能跨重启续上。但它依然只是一只 单 agent 的小龙虾。一个真正的工作助手往往需要把不同职责拆给不同的 agent,比如写代码归 work、处理生活琐事归 personal,还要让它们之间能互相派活。这就引出了 OpenClaw 的多 agent 体系,包括 agent-sendsubagents 这两套机制。我们下一篇就来看看怎么给小龙虾分身。

参考


让外部世界唤醒小龙虾:Webhook 与 Standing Orders

昨天我们让小龙虾装上了发条:Cron 负责按精确时间表干活,Heartbeat 负责主会话隔一会儿自己醒一下。但这两个机制都有一个共同的盲点 —— 它们都是 基于时间 的,外部世界发生了什么,它得等下一轮触发才知道。GitHub PR 来了新的 review、Sentry 报了 P0、自家服务部署完成 —— 这些事件都是不规律的,靠 Cron 拉不到,靠 Heartbeat 也只能 30 分钟才看一眼。要让小龙虾能在事件发生的当下立刻响应,得给它再开一道入口:Webhook

光有 Webhook 入口还不够。Cron job 的 --message 字段我们见识过了,每次都得把"做什么、按什么规则做、什么情况要升级"重新抄一遍,写多了既冗余又难维护。OpenClaw 给出的方案叫 Standing Orders,把 agent 的常驻授权和边界集中写在 workspace 的 AGENTS.md 文件里,每次 session 启动都自动加载。Cron job 只需要写一句 "按 standing orders 执行 X 职责块" 即可。

今天我们就把这两块拼起来。

Webhook、Cron 与 Standing Orders 的分工

动手之前先把三者的边界用一张表讲清楚:

机制触发条件职责典型场景
Cron时间到了(--at / --every / --cron决定 什么时候 执行早报、周报、定时提醒
Standing Orders每次 session 启动时自动加载决定 能做什么、不能做什么授权范围、approval gate、escalation 规则
Webhook外部 HTTP POST 进来决定 被谁 唤起GitHub 发版、Sentry 告警、Gmail 新邮件

文档里有一句话很到位:standing orders 定义 agent 能做什么,cron jobs 定义 什么时候 做,完整的自动化方案往往是两者组合,用 standing orders 把日报这件事的范围、步骤、边界写清楚,再让 cron 在 8:30 把它拉起来执行。而 Webhook 则是补上 被谁 唤起这条入口,比如遇到 GitHub 发版这种关键事件时,再通过 webhook 临时插一脚。

注意:OpenClaw 中有两种 hook 概念:一种是 内部 hook,是 Gateway 在 /new /reset /stop agent:bootstrap 这类 生命周期事件 上挂的脚本,跟外部 HTTP 没关系;今天我们说的 Webhook 是外部 HTTP 入口,响应外部世界的事件。

Webhook:让外部事件唤起小龙虾

OpenClaw 的 webhook 默认是 的,要在 Gateway 配置里显式打开。编辑 ~/.openclaw/openclaw.json,加一段 hooks 块:

{
  "hooks": {
    "enabled": true,
    "token": "shared-secret-replace-me",
    "path": "/hooks",
    "allowedAgentIds": [
      "main"
    ],
    "allowRequestSessionKey": false
  },
}

每个字段的含义:

  • enabled:总开关,必须显式置为 true
  • token:共享密钥,所有进来的请求必须带,长度足够强随机,别复用 Gateway 自身的 auth token
  • path:endpoint 前缀,默认 /hooks裸根路径 / 会被拒绝
  • allowedAgentIds:限制可以被显式路由到的 agent ID,默认不限制
  • allowRequestSessionKey:是否允许调用方自己塞 session key 进来,默认 false

启用之后 Gateway 在 127.0.0.1:18789(默认)会暴露三个入口:

入口作用
POST /hooks/wake给 main session 塞一条 system event,相当于戳一下小龙虾让它注意到某件事,可选 next-heartbeatnow
POST /hooks/agent直接拉起一次 isolated agent turn,等价于一次性的 cron run
POST /hooks/<name>自定义路径,通过 hooks.mappings 把任意 payload 映射成上面两种之一

熟悉的同学可能已经看出来了:这两个入口实际上就是上一篇里 --system-event--message 那对参数的 HTTP 版本。/hooks/wake 走系统事件 + 可选 heartbeat 唤醒,跟 --session main --system-event 同源;/hooks/agent 构造一个隔离的一次性 job,跟 --session isolated --message 同源。

鉴权方式

每个进来的请求都必须携带 token,OpenClaw 支持两种 header 形式:

Authorization: Bearer <token>
x-openclaw-token: <token>

查询字符串里的 token 一律拒绝,这是为了防止 token 泄露在反代或 Web Server 的访问日志里。

用 curl 模拟一次调用

最简单的验证方式是在本机起一下 curl。先用 wake 端点戳一下:

$ curl -X POST http://127.0.0.1:18789/hooks/wake \
    -H 'Authorization: Bearer shared-secret-replace-me' \
    -H 'Content-Type: application/json' \
    -d '{"text":"GitHub 上 v0.42 已发版","mode":"now"}'

{"ok":true,"mode":"now"}

text 是给 agent 看的事件描述(必填),modenow 立刻起一次心跳,next-heartbeat 等下一轮 tick 再跑。

再试一下 agent 端点,让它直接跑一段独立任务:

$ curl -X POST http://127.0.0.1:18789/hooks/agent \
    -H 'Authorization: Bearer shared-secret-replace-me' \
    -H 'Content-Type: application/json' \
    -d '{
      "message":"刚刚 GitHub 上发版了 v0.42,将通知发到 Telegram",
      "name":"release-translate",
      "deliver":"announce",
      "channel":"telegram",
      "to":"7112345678"
    }'

{"ok":true,"runId":"b73a2e76-f635-4218-888a-4b7e5f8ccc02"}

/hooks/agent 接受的字段比 wake 丰富很多:message 必填,可选项包括 nameagentIdwakeModedeliverchannelto 等。基本上 cron add --session isolated 能用的参数,这里都能在请求里指定。发送完成后,稍等片刻,我的 Telegram 就收到了一条消息:

hooks-telegram.png

Mapped hooks:让 payload 自动落位

直接调 /hooks/agent 自己手写 payload 当然也能用,但当你要对接 GitHub Webhook 这种格式固定的外部服务时,每次都让对方帮你按 OpenClaw 的格式打包很不现实 —— 它发的是它自己那一套。比如 GitHub 在每次发版(release.published 事件)时 POST 过来的 payload 大致长这样(完整 schema 见 GitHub Webhook events and payloads 文档):

{
  "action": "published",
  "release": {
    "id": 123456789,
    "tag_name": "v0.42",
    "name": "v0.42 — Bug fixes & perf",
    "body": "## What's Changed\n* Fix cron timer drift by @alice in #421\n* Reduce heartbeat token usage by @bob in #423\n...",
    "html_url": "https://github.com/owner/repo/releases/tag/v0.42",
    "draft": false,
    "prerelease": false,
    "published_at": "2026-05-06T08:30:00Z",
    "author": { "login": "alice", "id": 1, "...": "..." }
  },
  "repository": {
    "id": 987654321,
    "name": "repo",
    "full_name": "owner/repo",
    "html_url": "https://github.com/owner/repo",
    "...": "..."
  },
  "sender": { "login": "alice", "...": "..." }
}

字段嵌套很深,但这次场景里我们真正想塞进 prompt 的就那么几个:repository.full_namerelease.tag_namerelease.namerelease.html_urlrelease.body。OpenClaw 提供了 hooks.mappings 配置项,让你能把任意自定义路径绑定到一段字符串模板上,按需把这些字段挑出来渲染成 prompt:

{
  "hooks": {
    "enabled": true,
    "token": "shared-secret-replace-me",
    "path": "/hooks",
    "mappings": [
      {
        "id": "github-release",
        "match": {
          "path": "github-release"
        },
        "action": "agent",
        "wakeMode": "now",
        "name": "GitHub release notify",
        "messageTemplate": "新版本:{{body.repository.full_name}} {{body.release.tag_name}}\n标题:{{body.release.name}}\n链接:{{body.release.html_url}}\n\n请把 release notes 翻译成中文,挑出对用户可见的变化,附上原始链接,发到 Telegram。",
        "deliver": true,
        "channel": "telegram",
        "to": "7112345678"
      }
    ],
  },
}

加了这段之后,外部系统 POST 到 /hooks/github-release,OpenClaw 就会按 messageTemplate 把 GitHub 的原始 payload 渲染成 prompt,再走一次 isolated agent turn,最后通过 announce 兜底投递到 Telegram。

字符串模板搞不定的复杂转换(比如签名校验、字段过滤、跨字段拼接)可以走 transform 字段:指向 hooks.transformsDir 下的一个本地 JS/TS 模块,对 payload 做编程式改写后再交给 agent。这块官方文档中介绍的不多,本文不展开,感兴趣的同学可以研究下 OpenClaw 的源码。

把 webhook 暴露到公网

本地 Gateway 默认只监听 127.0.0.1:18789,外部根本访问不到。要让 GitHub、Sentry 这些公网服务能调用,常用的方法有:

方案适用场景
ngrok http 18789开发调试最快的方式,一条命令出公网 HTTPS 隧道;免费版有连接数限制
cloudflared tunnelCloudflare 出品,速度稳,绑自有域名免费
Tailscale FunnelOpenClaw 的 Gmail PubSub 集成就是用它做的官方推荐方案
Fly.io / Railway 部署生产环境,把 Gateway 整个搬到云上

本机调试最快的就是 ngrok:

$ ngrok http 18789

运行结果如下:

ngrok.png

拿到 ngrok 给的 HTTPS 地址,把 https://xxx.ngrok-free.app/hooks/github-release 填回 GitHub 仓库的 Settings → Webhooks 就能联调。Content type 选 application/json,Events 勾上 Releases

github-settings.png

完成配置之后,每当仓库发一个新版本时,我们就可以在 Telegram 上收到 agent 的通知了。

ngrok 是一个反向 HTTP 隧道工具,可以免费使用,运行 ngrok http <port> 之后会拿到一个 https://xxx.ngrok-free.app 临时域名,把外部 HTTPS 请求加密反向转发到本机端口。不过要注意的是,免费版每次重启 URL 都会变、还有速率限制,本地调试用用还行,正式跑生产建议换 Tailscale Funnel 或 Cloudflare Tunnel。

Standing Orders:常驻授权与边界

讲完外部入口,再来看常驻指令。Standing orders 不是被时间或事件触发的,它是 agent 每次进入一个 session 时都会自动加载的一份常驻文件 —— 用户在 Telegram 私聊、cron 拉起的隔离 session、webhook 触发的 agent turn、heartbeat 主会话,都会加载。本质上它就是一段 Markdown,在 session 启动时被注入到 system prompt 里。

它是怎么生效的

OpenClaw 在 src/agents/workspace.ts 里给 workspace 定义了一组启动注入文件名:

export const DEFAULT_AGENTS_FILENAME = "AGENTS.md";
export const DEFAULT_SOUL_FILENAME = "SOUL.md";
export const DEFAULT_TOOLS_FILENAME = "TOOLS.md";
export const DEFAULT_IDENTITY_FILENAME = "IDENTITY.md";
export const DEFAULT_USER_FILENAME = "USER.md";
export const DEFAULT_HEARTBEAT_FILENAME = "HEARTBEAT.md";
export const DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md";
export const DEFAULT_MEMORY_FILENAME = CANONICAL_ROOT_MEMORY_FILENAME;

每次 session 启动时,OpenClaw 会按这份名单去 workspace 目录读文件;再由 src/agents/pi-embedded-helpers.ts 里的 buildBootstrapContextFiles 把这些文件的内容封装成 embedded context 注入到首轮上下文。AGENTS.md 就是 Standing orders 的载体,写在里面的内容每次开局都会被 agent 看到。

一份完整的职责块

知道了 AGENTS.md 是 Standing orders 的载体,下一个问题是:往里面写什么?

理论上你可以塞任何 Markdown,agent 都能读到。但实战中如果只是丢一句"你负责回答群里关于发版的问题",遇到边界场景 agent 就容易犯傻 —— 比如有人问的根本不是发版而是部署,或者跑了几轮之后 agent 自己脑补出一些你压根没授权它做的动作。

为了把"在哪条边界内做事"讲清楚,OpenClaw 文档推荐了一种结构化模板,叫做 Program(本文统一译作 职责块)。每个职责块至少包含下面四个字段:

  • Authority:授权范围 —— 这个职责块允许 agent 做什么
  • Trigger:触发条件 —— 什么时候才轮到这个职责块上场
  • Approval gate:审批门槛 —— 哪些动作要先经过人确认才能执行
  • Escalation:升级规则 —— 出错或越界时停在哪、报告给谁

四个字段之外可以按需加 Execution steps(具体步骤)、What NOT to do(明确禁止)等扩展段落,给 agent 进一步收紧执行边界。

特别注意:OpenClaw 不会Program: / Authority: / Trigger: 这些字段做任何特殊解析。所有 markdown 都是整段被注入到 system prompt 里。这套结构纯粹是 写作约定,目的是逼着写作者把每个职责的边界、触发条件、审批门槛、升级路径都想清楚。

下面是一个完整的群内答疑职责块,专门负责回答上线状态的问题:

## Program: 上线状态答疑

**Authority:** 在团队群里回答关于版本发布状态的问题
**Trigger:** 群成员的消息里出现"上线了吗"、"发布了没"、"最新版本是什么"等关键词
**Approval gate:** 无(只读操作)
**Escalation:** 如果 GitHub API 连续 2 次返回 4xx/5xx,停止回答并提醒 @oncall

### Execution steps

1. 调用 github 工具查询当前仓库最新发版的 tag 和 published_at
2. 对比当前用户问到的版本号或语义("昨天的"、"v0.42"、"最新一版")
3. 用一句话回过去,附上发版链接
4. 如果命中需要回答的问题但仓库还没发过版,直接说"还没上线"

### What NOT to do

- 不要主动催促开发者发布
- 不要在群里贴完整的 changelog,只贴链接
- 不要回答与发版状态无关的问题

把这段直接追加到 workspace 的 AGENTS.md 末尾,下次 agent 进入 session 时,这段职责块就会自动出现在 system prompt 里。

Execute-Verify-Report

Standing orders 文档里反复强调一个执行纪律:每个 task 都要走 Execute → Verify → Report 三步。原文如下:

- 每个任务都必须走 Execute-Verify-Report 三步,没有例外。
- "我这就去做"不算执行。先做,再汇报。
- 没有经过验证就说"完成了"是不允许的。拿出证据。
- 执行失败:调整思路重试一次。
- 还失败:带诊断信息汇报失败。绝不静默失败。
- 不要无限重试 —— 最多 3 次,然后升级处理。

这套规则是用来对抗 LLM 最常见的"假执行"失败模式:agent 答应你"好的我去做",但其实只是把 prompt 复读了一遍。把这段写进 AGENTS.md 顶部,配合具体的职责块,能显著降低这种失败概率。

我自己的写法是把 Execute-Verify-Report 块单独放在 AGENTS.md 最上面,所有职责块共享,下面再按职责块一条条列下来。这样每次新写一个职责块,只用关注 Authority/Trigger/Approval gate/Escalation 这四块,执行纪律不需要重复抄。

跟 cron 配合使用

看到这里读者可能会感到疑惑:Standing orders 跟 cron 不是绑定关系,它对所有 session 都生效,为什么官方将其归在 Automation and tasks 分类下呢?我想可能是因为 cron 是它最受益的一个场景,由于 cron job 之间没有上下文记忆,每条 --message 都得自带边界,写多了既冗余又难维护。把边界沉淀进 AGENTS.md 之后,cron 的 --message 退化成一句话:"按 standing orders 执行 X 职责块",剩下的全交给常驻文件。官方文档给的例子就是这个分工:

Standing Order:"你负责每天的收件箱整理" →
Cron Job(每天早 8 点):"按 standing orders 执行收件箱整理" →
Agent:读取 standing orders → 执行步骤 → 汇报结果

这套分工的好处是 改职责块的执行细节不用动 cron,改投递目标不用动职责块 —— "做什么、按什么规则做" 写在 AGENTS.md 里,"什么时候做、做完发到哪" 写在 cron 里,两层职责清晰。同样的分工逻辑也适用于 webhook:mapping 里的 messageTemplate 也只需要写一句"按 X 职责块处理 {{body}}",业务规则不用复制。

把三件事拼起来:自动发布通知

前面 Mapped hooks 那一节其实已经把 GitHub 发版通知跑通了一遍,但那时候业务规则(翻译要求、字数限制、不该做啥)全塞在 messageTemplate 里,每加一条规则 mapping 配置就长一行。这一节我们把这个用例重构下,整套方案分三层:

  1. Standing order:在 AGENTS.md 里定义 release-notify 程序,划清"翻译 release notes、挑出用户可见的变化、附上原文链接、发到 Telegram"这件事的边界
  2. Webhook:通过 hooks.mappings/hooks/github-release 路径映射到 agent turn,messageTemplate 里只写"按 release-notify 程序处理 {{body}}"
  3. Cron:每天 22:00 跑一次 release roundup,把当天所有发版汇总成一段周报,预防 webhook 漏推

AGENTS.md 里的职责块:

## Program: Release notify

**Authority:** 把外部系统推过来的发版事件翻译并转发给团队
**Trigger:** /hooks/github-release 被外部系统调用 OR cron release-roundup 拉起
**Approval gate:** 无(公开内容,只读操作)
**Escalation:** 如果连续 3 次发版都译不出 changelog,停止并提醒 @oncall

### Execution steps

1. 解析 payload,拿到 repo、tag、release notes 原文
2. 用中文翻译 release notes,重点抓:新功能、Breaking change、Bug fix、Deprecation
3. 不超过 500 字,超长则压缩到三个分类各 5 条以内
4. 通过 message 工具发到 Telegram

### What NOT to do

- 不翻译纯依赖升级的内容(Bump deps from x to y 这种)
- 不要把发版链接里 hash 后面的 query string 复制过来
- 不在群里 @ 任何人

webhook mapping 配置如下:

{
  "hooks": {
    "enabled": true,
    "token": "shared-secret-replace-me",
    "path": "/hooks",
    "allowedAgentIds": ["main"],
    "mappings": [
      {
        "id": "github-release",
        "match": { "path": "github-release" },
        "action": "agent",
        "wakeMode": "now",
        "name": "Release notify (push)",
        "messageTemplate":
          "按 release-notify 程序处理这次发版:repo={{body.repository.full_name}} tag={{body.release.tag_name}} url={{body.release.html_url}}\n\n--- 原始 release notes ---\n{{body.release.body}}",
        "deliver": true,
        "channel": "telegram",
        "to": "7112345678",
      },
    ],
  },
}

然后再创建一个 cron 兜底(防止 webhook 漏推一天里某次发版):

$ openclaw cron add \
    --name "Release roundup" \
    --cron "0 22 * * *" \
    --tz "Asia/Shanghai" \
    --session isolated \
    --message "按 release-notify 程序处理今天 24 小时内 GitHub 上所有关注仓库的发版,去重后汇总发到 Telegram。" \
    --announce \
    --channel telegram \
    --to "7112345678" \
    --timeout-seconds 300

整体的事件链长这样:

example.png

跑通之后,仓库一发版,几秒钟内 Telegram 就会冒出一条结构化通知;webhook 万一漏了,晚上 22:00 还有一次 cron 兜底。这两个入口都不需要重复 release-notify 程序的细节,改翻译规则只用动 AGENTS.md,改投递目标只用动 cron 或 mapping。

小结

通过这一篇,我们把 OpenClaw 主动化体系剩下的两块讲完了:

  1. Webhook:在 hooks 配置里 enabled: true + 显式 token 之后,Gateway 在 127.0.0.1:18789 暴露三个入口 —— /hooks/wake 走主会话、/hooks/agent 起一次 isolated turn、/hooks/<name> 走自定义 mapping;
  2. Mapped hooks:通过 hooks.mappings 把任意 payload 经 messageTemplate 渲染后转成 agent turn;
  3. Standing Orders:写在 workspace 的 AGENTS.md 里,任何 session 启动时(包括用户聊天、cron、webhook、heartbeat)都会被注入到 system prompt;推荐的职责块模板包含 Authority / Trigger / Approval gate / Escalation 四块;
  4. 三者组合:standing orders 定义授权与边界,cron 定义触发时间,webhook 接外部事件,三者职责清晰、互不耦合;

到这里小龙虾已经能按表干活、能被外部系统唤起、能在每个 session 里都记得自己应当遵守哪些职责块。但仔细翻 OpenClaw 的文档,我们还能看到 Background Tasks(后台任务)和 Task Flow(多步骤工作流编排)这两种不同的任务机制,我们下一篇就来继续学习它们。

参考


让 OpenClaw 自己动起来:Cron 与 Heartbeat

前面几篇我们把小龙虾接进了 Telegram 和飞书,跑通了通道侧的整套准入逻辑。但到目前为止它都是个 被动型选手,我们发它才回。昨天写飞书那篇结尾时我提了一个例子:让小龙虾在每天 9:30 给我发一条 DM,把昨天的 GitHub Trending、最新的新闻或订阅的博客汇总发给我。要让小龙虾反过来主动找你,就得给它装上自己启动的发条。

OpenClaw 在 Automation & tasks 这篇官方文档中,把这件事拆成了 6 种机制:Scheduled Tasks(Cron)HeartbeatHooksStanding OrdersBackground TasksTask Flow。其中最基础也是最容易搞混的两个就是 CronHeartbeat —— 一个负责按精确时间表干活,一个负责主会话隔一会儿自己看一眼。今天我们就把这两个机制各自跑一遍,再用一个完整的工作日早报场景把它们组合起来。

Background Tasks、Task Flow、Hooks、Standing Orders 这几块下一篇再讲。

Cron 和 Heartbeat 的分工

动手之前先用一张表把两者的边界讲清楚,免得后面用错了场景:

维度CronHeartbeat
触发精度精确(cron 表达式、一次性时间戳)近似(默认每 30 分钟一次)
会话上下文fresh isolated 或显式指定主会话完整上下文
任务记录每次运行都创建 task 条目不创建
投递方式频道、Webhook 或静默主会话内联,可选定向投递
适用场景日报、周报、定时提醒、后台批处理收件箱轮询、日历扫描、轻量通知

简单说,Cron 是有名有姓的定时任务,每个 job 都会写进 ~/.openclaw/cron/jobs.json 持久化,Gateway 重启不会丢;Heartbeat 是主会话每隔一会儿自己醒一次,不留 task 记录、也不上 jobs 列表。两者并不互斥,而是经常搭配使用:Heartbeat 负责轻量轮询,Cron 负责精确报表。

这里要先澄清一个常见误会:OpenClaw 的 Cron 不是 Linux 那个 cron,它是 Gateway 进程内置的调度器,不会去 /etc/cron.d/crontab 里写任何东西。Gateway 进程没起来,所有 cron job 都不会触发。

Cron:让小龙虾按时间表干活

OpenClaw 的 Cron CLI 入口是 openclaw cron add。我们先做一个最小可跑的例子,20 分钟后在 main 会话里提醒我喝水:

$ openclaw cron add \
    --name "喝水提醒" \
    --at "20m" \
    --session session:agent:main:main \
    --message "该喝水了,提醒一下用户起身倒杯水。" \
    --wake now

这条命令各个 flag 的含义如下:

  • --name:任务的展示名,方便 cron list 查看,可以中文
  • --at "20m":一次性触发的时间点。支持 ISO 8601 绝对时间(如 2026-05-05T16:00:00Z),也支持 20m / 1h 这种相对时间
  • --session session:agent:main:main:直接落到默认 agent(main)的 main 会话(main)里跑一轮 agent turn。其中 session key 的格式为 session:<session-id>session-idagent: 开头;第一个 main 是 agent id,默认 agent 就叫 main(DEFAULT_AGENT_ID = "main"),如果你跑了其它 agent,这里会是 work / personal 之类;第二个 main 表示会话槽位,main 是该 agent 的主会话(Main Session),如果是其他渠道或 subagent 触发的会话,其格式可能是这的:

    • agent:main:telegram:direct:user123 — Telegram 私聊
    • agent:main:discord:group:guild-chan — Discord 群聊
    • agent:main:cron:daily-briefing-uuid — cron 触发
    • agent:main:subagent:<uuid> — 子 agent
  • --message:这次 agent turn 的初始 prompt,会被当作用户输入直接喂给模型
  • --wake now:立即唤醒一次心跳来执行;另一个值是 next-heartbeat,等到下一轮心跳再执行

加完之后 openclaw cron list 能看到这个 job 的下一次触发时间,也可以到 Web UI 中的定时任务列表中查看:

cron-list.png

20 分钟到点之后,小龙虾就会在 main 会话里直接冒出一条 agent 回复,提醒你喝水:

cron-message.png

一次性 job(--at)在成功跑完之后默认会自己删除,如果要保留该任务记录的话可以加 --keep-after-run 参数。

遭遇 scope upgrade 报错

我自己在跑这条命令时遇到了一个报错,估计有不少同学也会遇到:

gateway connect failed: GatewayClientRequestError: scope upgrade pending approval
  (requestId: 1397fa2b-53be-4d4d-a00d-cc8842c61f7c)
GatewayTransportError: gateway closed (1008): pairing required:
  device is asking for more scopes than currently approved
Gateway target: ws://127.0.0.1:18789

报错原因不在 cron 命令本身,而是底层 Gateway 的 scope upgrade 还没审批。前几篇 onboard 和接通道时这台设备已经跟 Gateway 配对过了,但 openclaw cron add 需要的 scope 比当前已批准的范围更大,Gateway 就把这次请求挂成 pending 升级,同时断开本次握手。

报错里那串 requestId 就是 Gateway 为这次升级生成的 pending 请求号。我们运行下面的命令,看一下待审批的请求,确认就是这一条:

$ openclaw devices list

运行结果如下:

devices-list.png

可以看到之前的 scopesoperator.pairing, operator.read, operator.write,现在的 cron 命令需要 operator.admin,使用下面的命令批准这次 scope upgrade:

$ openclaw devices approve 1397fa2b-53be-4d4d-a00d-cc8842c61f7c

也可以在 Gateway 的 Web UI 的节点页面里点同意,效果一样:

devices-approve.png

批准之后再次运行 openclaw cron add ... 即可。只要 scope 不再扩大,后续 cron 命令不会再次触发审批。

--system-event 还是 --message

我第一次写这个例子时跟着官方文档抄的不是上面那条命令,而是下面这样:

$ openclaw cron add \
    --name "喝水提醒" \
    --at "20m" \
    --session main \
    --system-event "该喝水了,提醒一下用户起身倒杯水。" \
    --wake now

结果时间到了,在任务的执行历史里看 job 状态显示 success,但 main 会话里完全没动静。这就不得不提 --system-event--message 这两个容易混淆的参数了:

参数payload 类型触发时实际做的事
--system-eventsystemEvent往目标主会话里 enqueue 一条系统事件,并按 --wake 唤起一次 heartbeat
--messageagentTurn直接在目标会话里跑一轮 agent turn,把这段文本当作 prompt

另外这两个参数跟 --session 取值是 强绑定 的:

  • --session main 必须--system-event
  • --session isolated / current / session:<id> 必须--message

回到那条官方文档的例子,--system-event 写的"该喝水了..."并不是要直接展示给我的一条聊天消息,它是一个 塞给 agent 上下文的系统信号。Gateway 收到之后按 --wake now 唤起 heartbeat,heartbeat 跑的是 它自己那条默认 prompt("看一眼 HEARTBEAT.md,没事就回 HEARTBEAT_OK"),并不一定会把那段系统事件复述出来。如果 agent 判断"这条系统事件没必要打断用户"或者直接回了 HEARTBEAT_OK,main 会话里就什么都不会出现 —— 但 cron list 依然记成 success,因为 cron 端的工作确实做完了。

要让到点的时候用户在 main 会话里 直接 看到那条提醒,应该走 agent turn 这条路:用 --message 把要说的话当作 prompt 直接起一轮,agent 的回复就是用户看到的那条消息。--system-event 更适合"通知 agent 该看某件事了,但具体说不说、怎么说交给它判断"的场景。参考下面 Heartbeat 的章节。

三种调度类型

openclaw cron add 支持三种调度方式,正好覆盖一次性、固定间隔、复杂表达式三个场景:

KindCLI flag说明
at--at一次性触发,ISO 8601 时间戳或相对时间(如 20m
every--every固定间隔触发(如 --every 10m--every 1h
cron--cron5 段或 6 段 cron 表达式,配合 --tz 指定时区

要注意的是,不写时区的话,--cron 默认走 Gateway 主机时区,--at 不写时区直接当 UTC。生产环境建议都强制写 --tz "Asia/Shanghai",省得调试半天发现是时差的锅。

另外 OpenClaw 对整点表达式做了 自动错峰:默认会在原始触发时间附近随机抖动最多 5 分钟,避免一堆任务挤在 0 秒同时启动。要严格准点可以加 --exact 参数,要自定义抖动窗口用 --stagger 30s

四种执行风格

光会调度还不够,Cron 在哪个会话里跑 直接决定了它能看到多少上下文。OpenClaw 的执行风格分四种:

Style--session运行环境适用场景
主会话main下一次心跳轮次提醒、系统事件
隔离会话isolated全新 cron:<jobId> 会话报表、后台杂务
当前会话current创建时绑定的会话上下文敏感的循环任务
自定义命名会话session:<id>持久化命名会话需要在历史上累积上下文的工作流

主会话模式只是给主会话发一个系统事件,任务执行时仍然是用户自己的会话;隔离模式则会开一个全新的 cron:<jobId> 会话,跟主会话完全隔离,适合那种不希望污染聊天上下文的后台报表;自定义会话介于两者之间,同一个 --session session:daily-standup 可以在多次运行里累积上下文,比如每天的站会摘要可以基于昨天的摘要生成。

一次 cron 触发到底发生了什么

光看 CLI 不够直观,我们扒一眼源码。cron 的对外入口在 src/cron/service.ts

export class CronService implements CronServiceContract {
  private readonly state;
  constructor(deps: CronServiceDeps) {
    this.state = createCronServiceState(deps);
  }

  async start() {
    await ops.start(this.state);
  }

  async add(input: CronJobCreate) {
    return await ops.add(this.state, input);
  }

  async run(id: string, mode?: "due" | "force"): Promise<CronServiceRunResult> {
    return await ops.run(this.state, id, mode);
  }
}

这里的代码做了简化,只保留了主要部分。可以看到 CronService 本身只是一层薄壳,真正的状态拆在了 src/cron/service/state.ts,下一层的 ops.tsjobs.tstimer.tsstagger.ts 各自负责调度、持久化、定时器和错峰。其中 timer.ts 是触发的核心,它会按 nextRunAtMs 排定真实定时器,到点之后调用 markCronJobActive 写入 active 标记,再由 delivery-plan.ts 把投递目标解析成 announce / webhook / none,最后走 isolated-agent.ts 拉起一次真正的 agent turn。

整个链路有三个关键点:

  1. 持久化:所有 job 写在 ~/.openclaw/cron/jobs.json 里,Gateway 重启不会丢。运行态信息单独放在同目录的 jobs-state.json。文档里特别提到这两个文件拆开是为了让定义可入 git、运行态不入 git
  2. 错峰:所有整点表达式默认会被 stagger.ts 自动错开 0~5 分钟,避免一堆 job 在 9:00:00 同时触发
  3. 重试与熔断:transient 错误(rate_limit、overloaded、network、server_error)会指数退避重试;permanent 错误直接 disable

三种投递方式

openclaw cron add 支持三种投递模式,对应不同的输出策略:

Mode行为
announceagent 没主动发消息时,runner 把最终回复兜底发到 --to 目标
webhook把最终的处理结果 POST 到一个 URL
nonerunner 不做任何兜底投递

我们把开头那个早报场景用 announce 实现一遍 —— 让小龙虾每天 8:30 在隔离会话里汇总 GitHub 通知和今日日程,然后通过 Telegram 投递到我自己的私聊:

$ openclaw cron add \
    --name "morning-brief" \
    --cron "30 8 * * *" \
    --tz "Asia/Shanghai" \
    --session isolated \
    --message "汇总过去 24 小时的 GitHub 通知和今天的日程,按重要性排序。" \
    --announce \
    --channel telegram \
    --to "12345678"

跟前面喝水提醒例子比起来,多出来的三个 flag:

  • --announce:开启 runner 兜底投递;其中 --session isolated 表示隔离会话;
  • --channel telegram:投递通道。可选 telegram / slack / discord / feishu / whatsapp 等,默认 last(最近一次用过的通道);
  • --to "12345678":目标地址,格式跟通道挂钩。Telegram 直接给数字 chat_id:私聊是用户 ID,群组带 -100 前缀(如 -1001234567890),如果是话题群再加一个 --thread-id 42 参数;

到 8:30 触发时,OpenClaw 会开一个全新的 cron:<jobId> isolated session 跑一轮 agent turn。等 agent 执行结束,runner 拿这段文本通过 Telegram 适配器 POST 到 chat_id 12345678。整个 isolated session 跟主会话完全隔开,main 会话不会被早报刷屏。

要注意的是,对 isolated job 来说 chat 路由是共享的:如果在 prompt 里让 agent 显式调 message 工具发到了 --to 指定的目标,OpenClaw 会按 通道 + 目标 做去重判断,匹配就自动跳过 announce 兜底,避免重复消息。但只要通道或目标 ID 写法对不上,就可能出现重复发送的情况。最干净的写法是二选一:要么靠 --announce 兜底、prompt 里别让它主动发;要么 prompt 显式调工具发,cron 加 --no-deliver 把兜底关掉。

Heartbeat:让小龙虾自己醒一下

讲完 Cron,我们再来看 Heartbeat。这是两个很容易混淆的概念。文档里的一句话定义最准确:

Heartbeat runs periodic agent turns in the main session so the model can surface anything that needs attention without spamming you.

也就是说,Heartbeat 不是另一个 cron,它是主会话里 隔一会儿自己跑一轮 agent turn,看一下有没有什么需要冒泡给你的。默认间隔是 30 分钟,可以通过 agents.defaults.heartbeat.every 参数改,写 0m 完全关掉。

整个机制设计得比 Cron 轻量很多:没有 jobs 列表、不会创建 task 记录、连出现在哪都不强求。它在后台跑得勤快,但是却不张扬,专门用来做收件箱轮询、日历扫描、轻量通知这类不急但得有人盯着的事。

默认行为

Heartbeat 默认是开的,一个全新装好的 OpenClaw 每 30 分钟会自动跑一轮,prompt 是写死的一句话(可以在 agents.defaults.heartbeat.prompt 里覆盖):

Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.

翻译过来就是:去读 workspace 里的 HEARTBEAT.md(如果有的话),按它说的做;别从老聊天里翻出陈年的 task 出来重复一遍;没事的话就回 HEARTBEAT_OK

最关键的是 响应契约

  • 没事时回 HEARTBEAT_OK:这串字符是 OpenClaw 跟模型约定的暗号,只有出现在回复的开头或结尾才算数。识别成暗号之后,OpenClaw 会把它从回复里抠掉,再看剩下的内容有多长,如果 不到 300 字符(默认值,可通过 ackMaxChars 调)就把整条回复静默吃掉,主会话里啥都看不到。比如模型回 HEARTBEAT_OK 邮箱里没有紧急邮件 会被直接丢弃;但如果它写了 800 字的真正邮件摘要再带个 HEARTBEAT_OK,还是会照常投递
  • 如果有需要提醒你的事(比如收件箱里有紧急邮件),返回 alert 文本,不要HEARTBEAT_OK
  • 如果 HEARTBEAT_OK 出现在回复中间不算暗号,避免误伤模型的正常消息,也会照常投递

这个契约的好处很直接:你不会被一堆 "✅ 心跳正常,无事可做" 之类的废话淹没;只有当 agent 真有事情要跟你说,你才会在主会话里看到它的消息。

HEARTBEAT.md:心跳清单

如果你不写 HEARTBEAT.md,agent 每次心跳都得自己想"我应该看点啥",结果就是每次问的事情都不太一样,token 烧得也比较随机。OpenClaw 的推荐做法是在 workspace 里放一份小巧、稳定、安全的 HEARTBEAT.md,把每次心跳要做的事写成清单:

# Heartbeat checklist

- 快速扫一眼:收件箱里有没有紧急的邮件?
- 如果是白天,闲着没事时可以来打个招呼
- 如果某个任务卡住了,在这里写下到底缺什么,记得下次问

每次心跳 OpenClaw 都会把这份文件作为 workspace bootstrap 注入到 system prompt 里。要让它真正小,OpenClaw 还做了一个优化:如果 HEARTBEAT.md 内容是 effectively empty(只有空行、Markdown 标题、空列表、围栏标记),OpenClaw 会直接 skip 这次心跳,省掉一次模型调用,运行日志里会看到 reason=empty-heartbeat-file

tasks:

HEARTBEAT.md 有一个特别好用的功能,叫 tasks: 块,用来在心跳里跑不同间隔的子检查:

tasks:

- name: 邮件检查
  interval: 30m
  prompt: "检查是否有紧急的未读邮件,并标记任何对时间敏感的内容。"
- name: 日历扫描
  interval: 2h
  prompt: "检查即将到来的、需要准备或跟进的会议。"

# 额外说明

- 保持提醒简短。
- 如果在所有到期任务都执行完毕后没有需要关注的事项,回复 HEARTBEAT_OK。

有了 tasks: 块之后,OpenClaw 会按每个 task 自己的 interval 判断是否到期,只把到期的 task 放进当前心跳的 prompt。如果一轮没有任何 task 到期,整次心跳直接 skip(reason=no-tasks-due)。

投递目标与可见性

默认 target: "none",也就是说心跳跑了但 不主动投递任何外部消息,只在主会话内部留个痕迹。要让它真正把 alert 推到你身上,得显式配 target

{
  "agents": {
    "defaults": {
      "heartbeat": {
        "every": "30m",
        "target": "last",         // 投到最近一次用过的外部通道
        "directPolicy": "allow",  // 默认 allow;block 时不投递 DM 类目标
        "lightContext": true,     // 仅注入 HEARTBEAT.md,跳过其它 workspace bootstrap 文件
        "isolatedSession": true,  // 每次心跳跑在新会话里,避免拖着完整对话历史
        "skipWhenBusy": true,     // 子 agent / nested 通道繁忙时也延期
        // "activeHours": { start: "08:00", end: "24:00" },
        // "includeReasoning": true,
      },
    },
  },
}

几个值得展开的字段:

  • target: "last" 表示投递到最近一次用过的外部通道。如果你想固定投到某个频道,比如 Telegram 的某个群,可以写 target: "telegram"to: "12345678";多账号场景下还得加 accountId
  • lightContext: trueisolatedSession: true 是节省 token 的两件套。前者只把 HEARTBEAT.md 注进 bootstrap,跳过 SOUL / IDENTITY / USER / AGENTS;后者每次新开 session,避免每次心跳都拖一份完整的主会话上下文。文档给的对照数据是从 ~100K tokens 降到 ~2-5K tokens 一次
  • directPolicy: "block" 用来防止心跳给陌生人或 DM 对端发广播,常见于多用户共享 inbox 的场景
  • activeHours 限定到上班时间,比如只在 9:00~22:00 之间响心跳,下班自动闭嘴

Cron 跑的时候 Heartbeat 会让路

还有一点值得注意:只要有 cron job 在跑或者排队,Heartbeat 就自动延期。源码在 src/infra/heartbeat-runner.ts

if (hasActiveCronJobs() || hasQueuedWorkInLanes(HEARTBEAT_ALWAYS_BUSY_LANES, getSize)) {
  // 延期,跳过本轮
  ...
}

if (heartbeat?.skipWhenBusy === true && hasOptInBusyLaneWork(getSize, getSnapshots)) {
  // skipWhenBusy 开启时,子 agent / nested 通道繁忙也延期
  ...
}

这个设计对本地模型用户特别友好。如果你跑的是 Ollama / vLLM 这种单进程模型服务,Cron 和 Heartbeat 同时打满会直接把请求队列堆爆。打开 skipWhenBusy: true 之后,连子 agent 和 nested 通道繁忙时也会让心跳延期,真正做到"忙的时候不抢资源,闲的时候才看一眼"。

手动触发一次心跳

平时 Heartbeat 是按 every 定时跑的,但你也可以随时手动催它一下:

$ openclaw system event --text "Check for urgent follow-ups" --mode now

--mode now 立刻起一次心跳;--mode next-heartbeat 等到下一轮 tick 再跑。如果配了多个 agent 都开了 heartbeat,手动触发会把每个 agent 的心跳都跑一遍。

Cron 与 Heartbeat 的组合

到这里两个机制都讲完了,回头看一开始那个早报场景 —— 让小龙虾每天 8:30 给我发一条 DM,把 GitHub 通知和今天日程汇总好。光用 Cron 写一句"汇总 GitHub 通知和今日日程"够不够?够。但有一类需求 Cron 满足不了:收件箱什么时候收到新邮件、日历什么时候发生变动,是不可预测的。这种轮询型工作交给 Heartbeat 才合适。

下面是一个典型的搭配组合:

  • Cron:负责 8:30 / 12:30 / 18:30 三个固定时间点的精确报表,每次 fresh isolated session,结果通过 --announce 投递到 Telegram
  • Heartbeat:负责日间 9:00~22:00 的轻量轮询,间隔 30 分钟,主会话上下文,开 lightContext + isolatedSession + skipWhenBusy 三件套省 token,只在收件箱有紧急邮件、日历有冲突时才推 alert,没事就 HEARTBEAT_OK 静默
  • HEARTBEAT.mdtasks: 块拆成 inbox-triage(30m)、calendar-scan(2h)、pr-watch(1h)三个不同 interval 的子任务

整体的触发时序长这样:

cron+heartbeat.png

小结

通过这一篇,我们把 OpenClaw 自动化体系里最基础的两块拆开看了一遍:

  1. Cron:Gateway 内置调度器,job 持久化在 ~/.openclaw/cron/jobs.json、运行态在 jobs-state.json--at / --every / --cron 三种调度方式覆盖一次性、固定间隔、复杂表达式三类场景;执行风格分主会话、隔离、当前、自定义命名四种;投递有 announce / webhook / none 三档
  2. Heartbeat:主会话每 30 分钟自己跑一轮的轻量心跳,不创建 task 记录不上 cron list,靠 HEARTBEAT.md(含 tasks: 块)保持稳定的清单;响应契约是没事时回 HEARTBEAT_OK、有事时回 alert,可以配 lightContext + isolatedSession + skipWhenBusy 三件套省 token、避资源
  3. 两者互不打架:Cron 跑的时候 Heartbeat 自动延期(看 src/infra/heartbeat-runner.tshasActiveCronJobs() 那一段),开 skipWhenBusy 之后连子 agent / nested 通道繁忙也延期
  4. 典型组合:Cron 负责精确时间点的报表(早报、周报);Heartbeat 负责日间的轻量轮询(收件箱、日历、PR 状态);遇到外部事件还有 Webhook 兜底

到这里,小龙虾终于不再是被动等回复的状态了,它会按表干活,也会自己醒一下看看周围有没有事。但我们说的主动还差一块:当 GitHub PR 来了新的 review、Sentry 报了一个 P0、自家服务部署完成的时候,OpenClaw 怎么被外部世界唤起?这就需要 Webhook,再加上一份长期生效的指令清单 Standing Orders 来约束 agent 干这些活时的边界。我们下一篇继续。

参考


OpenClaw 接入第二个通道:飞书

昨天我们把小龙虾接进了 Telegram,顺便摸清了 OpenClaw 的 pairing 安全模型:陌生人发来的 DM 不会直接进 LLM,而是先生成一个配对码,需要管理员在终端用 openclaw pairing approve 显式放行。这套机制对所有通道都生效,今天接的飞书也不例外。

但 Telegram 在国内有个绕不过的现实问题 —— 得挂梯子。再加上工作场景里几乎没有团队真用它,这一篇我们换一个国内更接地气的通道:飞书。截至 2026 年初,飞书在国内做得相对完整:字节系自家在用,小米、理想、得到、蔚来、SHEIN 这些公司也都把内部办公迁到了飞书上。把小龙虾接到飞书,等于直接把它放进了你工作日 8 小时里最常打开的那个窗口 —— 群里 @ 一下就能让 AI 接管一段调研,DM 里发条消息就能让它整理日报、总结飞书文档。

为什么把小龙虾接到飞书

把 AI 接进 Telegram 大多是个人玩法,接进飞书则更像把它装进了团队工作流。下面有几个比较实用的场景供参考:

  • 早晨日报提醒:让小龙虾在每天 9:30 给我发一条 DM,把昨天的 GitHub Trending、最新的新闻或订阅的博客汇总发给我
  • 群里召唤 AI 处理任务:在团队群里 @ 它一下,让它接管一段调研或查文档回答问题,所有人都能看到结果,不用再单独转发
  • 文档和会议总结:飞书文档和飞书妙记是国内做得相对完整的一套,OpenClaw 内置 feishu_doc 工具能直接读写飞书文档,让小龙虾顺手把会议纪要做要点提炼,效率比手动复制粘贴高很多
  • 多人协作:在一个项目群里把 bot 当成虚拟成员,谁都可以让它干活,比每人本地装一份桌面端 AI 工具更轻

这些场景的共同点是:消息流量本来就在飞书里,AI 跟着进飞书才不打断节奏。如果还要先切到一个独立 webapp 或者命令行,体验就完全不同了。

整体的消息流转和 Telegram 思路类似,但传输方式不一样:

feishu-sequence.png

注意这里 Gateway 走的是 WebSocket 长连接 主动出站连到飞书开放平台,由飞书把事件推过来,本地不需要暴露任何端口。这对开发者非常友好,不用 ngrok、不用买公网机器,本机跑起来就能接收消息。

飞书也支持传统的 webhook 模式,作为长连接走不通时的兜底方案。两种模式下 OpenClaw 行为完全一致,只是事件的进入路径不同。

在飞书开放平台创建自建应用

飞书的接入逻辑和 Telegram、Discord 不太一样。Telegram 给一个 BotFather,三句对话拿走 Token 就能跑;飞书走的是更接近企业 IM 的 自建应用 路线:先在企业开放平台创建一个应用,给它开机器人能力,再开事件订阅,最后拿到 App IDApp Secret 这两把钥匙。整个流程都在网页上完成,第一次走可能会觉得页面菜单有点多,下面我们一步一步对着截图走一遍。

第一步:登录开放平台

打开浏览器访问 飞书开放平台,注册账号并登录。

海外版 Lark 是另一个独立环境,地址是 open.larksuite.com,账号、应用、API 域名都不与国内飞书互通。如果你的团队用的是 Lark,记得在 OpenClaw 配置里把 domain 设成 lark

登录之后点击右上角的 开发者后台

feishu-home.jpg

第二步:创建企业自建应用

进入开发者后台后,点击 创建企业自建应用 按钮,弹窗里填三项:

  • 应用名称:会出现在群成员列表和消息发送者位置,建议起个有辨识度的名字,比如 老钳
  • 应用描述:随便写一句给自己看,比如 基于 OpenClaw 的个人 AI 助手
  • 应用图标:上传一张方形图,或者选平台内置的图标,没现成的素材直接用龙虾 emoji 也行

feishu-create-app.png

确认之后会跳到刚创建的应用详情页,URL 里能看到一串 cli_xxx 形式的 ID,这就是 App ID。

第三步:记下 App ID 和 App Secret

应用详情页左侧导航选 凭证与基础信息,右边能看到这两把钥匙:

  • App ID:以 cli_ 开头,公开可见,用来标识应用
  • App Secret:一长串随机字符,私密,OpenClaw 用它换 access token

feishu-credentials.png

记下这两个值,等会儿要写进 OpenClaw 的配置里。

App Secret 等同于这个应用的密码,不要提交到 Git,不要贴到群里。万一不小心泄露了,回到这一页点击 重置 立刻吊销并重新生成;OpenClaw 那边的配置也得同步换新。

第四步:开启机器人能力

左侧导航选 添加应用能力,找到 机器人 这一项,点击 添加。这一步只是给应用打开 bot 形态,还没有配置具体的消息行为。

feishu-add-bot-capability.png

第五步:选择长连接事件订阅

左侧导航选 事件与回调 → 事件配置,最上面有个 配置订阅方式 区域,这里有两个互斥的选项:

  • 使用长连接接收事件:飞书主动连过来,本地不需要公网地址,推荐
  • 将事件发送至开发者服务器:传统的 webhook 模式,需要公网回调地址

OpenClaw 默认走长连接(connectionMode: "websocket"),所以这里选第一个就行:

feishu-event-subscription.png)

webhook 模式后面专门起一节讲,本节按推荐路径走。

第六步:订阅 im.message.receive_v1 事件

往下滚到 事件管理,点 添加事件,搜索 im.message.receive_v1,勾上保存。这个事件就是"用户给 bot 发了一条消息"的那个钩子,没有它 bot 就根本收不到消息。

feishu-add-event.png

订阅完之后右边权限栏会自动列出这个事件依赖的权限项,比如 im:message,等会儿在权限管理里要统一开。

第七步:开通核心权限

左侧导航选 权限管理,最小可跑就开三项:

权限码用途
im:message收发消息的基础权限
im:chat读取群基础信息(群名、群成员)
contact:contact.base:readonly读取用户基础信息(昵称、头像)

逐项点 申请权限 即可。

feishu-permissions.png

这三项是从 OpenClaw 源码 extensions/feishu/src/setup-surface.ts:153 的引导文案里抠出来的,引导只列了这三项,最小可跑就够了。如果还想让小龙虾读写飞书文档、知识库、云盘、多维表,再追加 docxwikidrivebitable 相关权限即可 —— 不开也能跑,只是相应的 feishu_docfeishu_drive 等工具调用会因为权限不足报错。

第八步:发布并审批

最后左侧导航选 版本管理与发布,点 创建版本,选可见范围,提交审核。

  • 个人开发账号可以选 仅自己可见,秒过
  • 企业账号必须等管理员在飞书 App 的审批列表里点 同意,才能在企业内推送

feishu-publish.png

至此飞书侧的所有工作就全部完成了,下一步回到本地开始 OpenClaw 的配置。要注意的是,审批没通过之前,长连接会被服务端拒绝,一定要等状态变成 已发布

OpenClaw 配置

拿到 App IDApp Secret 之后,回到本机,编辑 ~/.openclaw/openclaw.json,加一段 channels.feishu

{
  "channels": {
    "feishu": {
      "enabled": true,
      "domain": "feishu",
      "connectionMode": "websocket",
      "defaultAccount": "main",
      "accounts": {
        "main": {
          "appId": "cli_xxxxxxxxxxxxxxxx",
          "appSecret": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
          "name": "老钳"
        }
      },
      "dmPolicy": "pairing"
    }
  }
}

各个字段逐项解释如下:

  • enabled:通道总开关,默认 true,置为 false Gateway 会跳过初始化
  • domain:API 域名,国内用 feishu,海外或 Lark 用户用 lark,私有部署也支持直接填 https://... 自定义 URL
  • connectionMode:事件传输方式,websocket 走长连接,webhook 走 HTTP 回调,默认 websocket
  • defaultAccount:多账号场景下,对外发起 API 调用使用的默认账号 ID,单账号留空走 default 即可
  • accounts.<id>.appIdaccounts.<id>.appSecret:从飞书开放平台 凭证与基础信息 页拿到的两个值
  • dmPolicy:DM 准入策略,可选 pairingallowlistopen默认 pairing

除此之外,还有 textChunkLimitmediaMaxMbstreamingtypingIndicator 等更多参数,参考官方文档:https://docs.openclaw.ai/channels/feishu

如果不想手写 JSON 配置,OpenClaw 还提供一条更省事的命令:

$ openclaw channels login --channel feishu

向导会弹出一个二维码,用飞书 App 扫一下就能在开放平台侧自动创建应用、把凭证写回本地配置,省掉手动跑一遍开放平台界面。这条命令需要 OpenClaw 2026.4.25 及以上版本,老版本用 openclaw update 升一下。

这个方法我没有验证,不知道为什么该命令在我的电脑上运行会卡死,如果有验证通过的小伙伴,欢迎交流。

webhook 模式与内网穿透

如果出于某些原因不能走长连接(比如内网防火墙拦了出站 WebSocket,或者部署在已有 HTTP 网关后面想统一接入),可以切到 webhook 模式。这种情况下飞书会主动把事件 POST 到我们的回调地址,因此本地必须有一个公网可达的 URL。

切换 webhook 模式的配置如下:

{
  "channels": {
    "feishu": {
      "connectionMode": "webhook",
      "verificationToken": "v_xxxxxxxxxxxxxxxx",
      "encryptKey": "e_xxxxxxxxxxxxxxxx",
      "webhookPath": "/feishu/events",
      "webhookHost": "127.0.0.1",
      "webhookPort": 3000
    }
  }
}
  • verificationToken:飞书在事件订阅页生成的校验令牌,用来验证请求来自飞书
  • encryptKey:可选的加密密钥,启用后飞书会用 AES 加密事件 payload,OpenClaw 收到后解密
  • webhookPath:本地 HTTP 服务挂载的回调路径,默认 /feishu/events
  • webhookHostwebhookPort:本地监听地址和端口,默认 127.0.0.1:3000

本地开发阶段,常见做法是用内网穿透工具把 127.0.0.1:3000 暴露到公网:

  • ngrok http 3000:最常见,随机域名,免费版有连接数限制
  • cloudflared tunnel:Cloudflare 出品,速度稳定,绑自有域名免费
  • frp 自建反向代理:有自己的云服务器时最划算

启动穿透之后,把得到的公网地址(比如 https://xxx.ngrok-free.app/feishu/events)填回飞书开放平台的 请求地址 一栏。生产环境一般直接部署到云服务器,省掉穿透环节。

启动并验证

配置写好之后,重启 Gateway:

$ openclaw gateway restart

正常启动之后,使用下面的命令查看 channel 状态:

$ openclaw channels status --probe

输出类似这样:

Gateway reachable.
- Feishu default (老钳): enabled, configured, running, works

私聊配对

我们先验证 DM 通路。在飞书 App 里打开你刚创建的应用名,发一条 你好,由于 dmPolicy: "pairing",bot 不会接话,而是会回复一段配对提示,里面带一个 8 位的大写字母数字混合代码,比如 A3KQ7M2N。代码 1 小时过期,单 channel 同时最多挂 3 个待审批请求。

我在测试时发现,有时 bot 不会返回配对提示,但是运行 openclaw pairing list 能正常看到配对请求,这时候只需要按下面的步骤 approve 就行了。

切回管理员的终端,先列一下当前所有挂起的配对请求:

$ openclaw pairing list feishu

Pairing requests (1)
│ Code     │ feishuUserId      │ Meta                                          │ Requested                │
│ A3KQ7M2N │ ou_8c1f...e3      │ {"accountId":"default"}                       │ 2026-05-03T13:05:57.015Z │

输出里能看到配对码、飞书用户的内部 ID(形如 ou_xxx)、请求时间。确认是预期的人之后,approve 它:

$ openclaw pairing approve feishu A3KQ7M2N

Approved feishu sender ou_8c1f...e3.

approve 之后,再发一条测试消息,就会被放行进入 agent 了:

feishu-first-reply.png

群组场景

把 bot 拉进群之后,事情会比 DM 复杂一些。如果我们直接在群里 @bot 发送消息,它是不会回复你的。检查 OpenClaw 的日志,会看到类似下面的信息:

21:58:53 [feishu] feishu[default]: received message from ou_e146a012928e16xxx in oc_f710113a6e42yyy (group)
21:58:53 [feishu] feishu[default]: group oc_f710113a6e42yyy not in groupAllowFrom (groupPolicy=allowlist)

这是因为 groupPolicy 默认是 allowlistgroupAllowFrom 为空,导致 bot 不会被触发。需要把 groupAllowFrom 配置为目标群组 ID (形如 oc_xxx)才能正常工作。这和 Telegram 的行为类似,OpenClaw 的默认策略都是先关门,再根据需要慢慢开门。可以在 ~/.openclaw/openclaw.json 里增加如下配置:

{
  "channels": {
    "feishu": {
      "groupPolicy": "allowlist",
      "groupAllowFrom": ["oc_f710113a6e42yyy"]
    }
  }
}

再次重启 Gateway 并验证,这时 bot 就能正常在群里对话了:

feishu-group-reply.png

另外,除了 allowlistgroupPolicy 还有 opendisabled 两种选项:

取值行为
open所有群都响应,最自由也最容易被滥用
allowlist只响应 groupAllowFrom 列出的群,或者在 groups.<chat_id> 下显式配置过的群
disabled完全禁用群消息,即便有 groups.<chat_id> 显式配置也不响应

@ 触发还是常驻倾听

群里和 DM 最大的区别是触发方式,requireMention 控制群里是否必须 @ bot 才触发回复:

  • true(默认):群里聊天大家不会被打扰,只有 @ 到 bot 才回应
  • false:bot 监听全部消息,agent 自己判断要不要发声

这是个非常贴合办公场景的默认值,群里成员动辄几十上百,机器人对所有消息都搭话很快就会被踢出去。如果你建了一个专门跟 AI 对话的小群,希望它有问必答,把那个群单独切到 requireMention: false 就行:

{
  "channels": {
    "feishu": {
      "groupPolicy": "allowlist",
      "requireMention": true,
      "groups": {
        "oc_aabbccddeeff": {
          "requireMention": false
        }
      }
    }
  }
}

这里有个常见的误会:飞书的 @all@_all广播标记,不算对 bot 的点名。OpenClaw 已经做了过滤,只有当一条消息明确 @ 你的 bot,才会被认为被点名。

群内还能进一步收紧

allowFrom 在飞书场景下也很有用。比如你想做一个只允许运维组同事调用的运维机器人,就在群里把 allowFrom 限定到运维组成员的 user_id

{
  "channels": {
    "feishu": {
      "groupPolicy": "allowlist",
      "groupAllowFrom": ["oc_ops_room"],
      "groups": {
        "oc_ops_room": {
          "allowFrom": ["ou_alice", "ou_bob", "ou_charlie"]
        }
      }
    }
  }
}

群里其他成员 @ 它也不会触发响应,OpenClaw 会直接在 sender 校验环节把消息丢掉。

小结

通过这一篇,我们把 OpenClaw 接进了飞书:

  1. 创建自建应用:在飞书开放平台开机器人能力,记下 App IDApp Secret,开 im:messageim:chatcontact:user.base:readonly 三项核心权限,订阅 im.message.receive_v1 事件,把版本发布并审批通过
  2. 写最小配置:在 ~/.openclaw/openclaw.jsonchannels.feishu 下填账号、dmPolicygroupPolicyrequireMention 这几个关键开关;不想手写也可以直接 openclaw channels login --channel feishu 扫码完成
  3. 传输模式:默认走 WebSocket 长连接,省掉公网回调地址;webhook 模式作为兜底,需要内网穿透或云部署
  4. DM 默认 pairing:陌生人通过配对码请求白名单,管理员显式 approve 才放行;底层就是 policy.ts 里那十几行 isSenderIdAllowed 风格的判定
  5. 群聊治理groupAllowFrom 控制哪些群能用,requireMention 控制是否必须 @,@all 不算 @ 到 bot;可以配合 allowFrom 进一步限定群聊范围

到这里 OpenClaw 的通道接入就告一段落了。Telegram 和飞书一外一内,跑通这两个之后剩下的 Slack、Discord、企业微信、钉钉的接入思路都大同小异,只是开放平台的具体术语变一变。

不过到目前为止小龙虾还是个"被动型选手" —— 我们发它才回。开头我提到一个场景:"让小龙虾在每天 9:30 给我发一条 DM,把昨天的 GitHub Trending、最新的新闻或订阅的博客汇总发给我",要让 bot 反过来主动找你,就得给它装上自己启动的发条。OpenClaw 在这件事上提供了两种最基础的机制:Cron 负责按精确时间表干活,Heartbeat 负责主会话隔一会儿自己醒一下看一眼。下一篇我们就来学习这两个机制。

参考


OpenClaw 接入第一个通道:Telegram

昨天我们用 openclaw onboard 把 Gateway 跑了起来,又经历了一场破壳仪式给小龙虾起了名字、填好了 IDENTITY / USER / SOUL 三件套。但说到底,它还只是一只关在终端里的小龙虾,尽管我们能在 TUI 里和它聊,也能在 WebChat 里和它聊,但这些都没有跳出传统 Chatbot 的范畴。

OpenClaw 真正区别于 ChatGPT、Open WebUI 这些工具的地方,是它长在你每天都打开的 IM 软件里。从今天开始我们正式接通道,第一站选 Telegram:只要一个 BotFather 给的 Token,不用扫码、不用 OAuth、不用绑手机号,是体验这套定位最快的一条路径。

为什么从 Telegram 开始

OpenClaw 支持 30 多个通道,从 WhatsApp、Signal、iMessage,到 Slack、Discord、飞书、企微,再到 Matrix、Nostr、IRC 这种小众选手。第一篇通道实战选哪个,其实是有讲究的。

我选 Telegram 的理由有三点:

第一,接入门槛最低。Telegram 的 BotFather 是一个聊天机器人,发 /newbot 就能拿到 Bot Token,整个过程不超过两分钟。WhatsApp 需要 WhatsApp Business API 或者扫码登录个人号,Signal 要绑定手机号,iMessage 要 macOS 配 BlueBubbles。Telegram 啥都不要,只要一个 Telegram 账号。

第二,官方文档把它列为生产可用OpenClaw 文档中明确写着 Production-ready for bot DMs and groups via grammY,它使用 grammY 这个 TypeScript Bot 框架接的 Telegram Bot API,已经迭代好几年了,稳定性有保障。

第三,国内外开发者用得最多。OpenClaw 的 GitHub Discussions 和官方 Discord 里,关于 Telegram 的帖子是其他通道的好几倍,遇到坑也最容易搜到答案。

整体的消息流转大致是这样的:

telegram-sequence.png

注意这里 Gateway 是主动方,它通过 getUpdates 长轮询把消息从 Telegram 拉过来,而不是 Telegram 主动推过来。

其实,OpenClaw 支持 长轮询Webhook 两种方式来接收 Telegram 消息。默认是长轮询模式,Gateway 主动调 getUpdates,不需要公网入口,本地开发最方便。要切到 Webhook,配置 channels.telegram.webhookUrlwebhookSecret,本地监听默认在 127.0.0.1:8787。Webhook 适合多副本部署或者无法长连接的环境,但需要反向代理把公网流量打进来,安全模型也要重新评估。

在 BotFather 注册一个 Bot

接 Telegram 的第一步是拿一个 Bot Token。这一步在 Telegram 端完成,跟 OpenClaw 无关。

打开 Telegram 客户端,搜索 @BotFather,要提醒一点的是,Telegram 上仿冒账号很多,如果认错了就相当于把 Token 交给骗子了,认准头像旁那个蓝色 ✓ 标志:

bot-father.png

进入 BotFather 的对话之后,发 /newbot 命令:

/newbot

BotFather 会让你依次输入两个东西:

  1. Bot 的展示名:可以是中文,比如 老钳
  2. Bot 的 username:必须以 bot 结尾且全局唯一,比如 aneasystone_clawd_bot

填完之后 BotFather 会回一条消息,里面有一行加粗的 token,长这个样子:

123456789:ABCdefGhIJKlmNOPQRsTUvWxYz0123456789

整段对话大致如下:

clawd_bot.png

Bot Token 等同于这个 bot 的密码,不要提交到 Git,不要贴到群里。一旦泄露任何人都能冒充它收发消息,甚至替你操作 agent。如果不小心泄露了,回到 BotFather 发 /revoke 立刻吊销并重新生成一个就行。

顺手在 BotFather 里再做两个设置,等下接群组场景的时候用得到,现在做完就不用回来了:

  • /setprivacy 选择目标 bot,把 Privacy Mode 设为 Disabled,让 bot 能看到群里的所有消息
  • /setjoingroups 选择目标 bot,确认允许 bot 被加入群组

把 Token 配置进 OpenClaw

Token 拿到之后,OpenClaw 这边只需要把它写进 ~/.openclaw/openclaw.json。在原有的配置基础上加一段 channels.telegram,最小可跑配置长这样:

{
  "channels": {
    "telegram": {
      "enabled": true,
      "botToken": "123456789:ABCdefGhIJK...",
      "dmPolicy": "pairing",
      "allowFrom": []
    }
  }
}

以下对四个字段逐个解释:

enabled 是通道总开关,置为 false 就完全停用,配置项保留但 Gateway 启动时跳过初始化。开发期想临时关掉某个通道又不想丢配置,把这一行改成 false 重启即可。

botToken 就是刚才从 BotFather 拿到的 Token。除了写在配置里,OpenClaw 还支持两种替代方式:

  • 环境变量 TELEGRAM_BOT_TOKEN,仅对默认账号生效
  • tokenFile 字段,指向一个保存 Token 的纯文本文件

配置值优先级大于环境变量,生产环境推荐用 tokenFile 加文件权限保护(chmod 600),开发环境直接写在配置里最快。

dmPolicy 控制谁可以直接给 bot 发私信,有四个取值:

取值行为
pairing默认值,未知发送者会先收到一个一次性配对码,需要管理员审批才能放行
allowlist严格白名单,必须在 allowFrom 里的数字 user ID 才能聊
open完全开放,需要 allowFrom 显式包含 "*" 才生效
disabled关闭私信入口

allowFrom 是数字形式的 Telegram user ID 列表,telegram:tg: 前缀都会被自动归一化。空数组配 pairing 没问题,所有第一次进来的发送者都会走配对流程;空数组配 allowlist 则会被配置校验直接拒绝,因为这等于谁都不让进。

我们这里先用默认的 pairing 模式。这是 OpenClaw 推荐的姿势:默认把门关好,再按一个个配对码放人进来。

另外,不想手写 JSON 也可以用 openclaw channels add --channel telegram --token <bot-token> 命令增加新通道。

顺手扒一眼 grammY 整合

OpenClaw 是基于 grammY 接的 Telegram Bot API。我们扒一眼 extensions/telegram/src/bot-core.ts,能看到创建 Bot 实例的核心几行:

const bot = new botRuntime.Bot(opts.token, client ? { client } : undefined);
bot.api.config.use(botRuntime.apiThrottler());
bot.catch((err) => {
  runtime.error?.(danger(`telegram bot error: ${formatUncaughtError(err)}`));
});

这里做了简化,只保留了主要部分。可以看到 OpenClaw 没有自己造 SDK 的轮子,直接复用了 grammY 的 Bot 类,再叠加 apiThrottler 控制 API 调用速率,最后挂一个全局 catch 兜底。后面所有的 messaging(文本、图片、语音、视频、贴纸等消息的收发)、reaction(消息表情回应,长按消息冒出来的那一排 👍❤️🔥)、forum topic(超级群里把讨论分主题的频道分区,每个 topic 有独立的 threadId)、inline button(消息下方挂的那排可点按钮,点完会回传 callback_data 给 bot),都是在这个 grammY Bot 实例上扩出来的。

启动 Gateway 并验证

配置写完之后,重启 Gateway:

$ openclaw gateway restart

正常启动之后,先用 openclaw doctor 做一轮自检:

$ openclaw doctor

按上面那份默认 pairing 配置跑下来,可能在 Security 分块看到这一行:

- Telegram DMs: locked (channels.telegram.dmPolicy="pairing") with no allowlist;
  unknown senders will be blocked / get a pairing code.
  Approve via: openclaw pairing list telegram / openclaw pairing approve telegram <code>

这条信息的含义是:Telegram 私信入口目前是上锁的,未知发送者会被挡在外面、走配对流程。使用下面的命令检查一下 Telegram 通道状态:

$ openclaw channels status --probe

输出类似这样:

Gateway reachable.
- Telegram default: enabled, configured, running, connected, mode:polling, bot:@aneasystone_clawd_bot, token:config, works

如果你看到 worksaudit ok,那就说明通道已经正常连接了;如果没看到类似的信息,而是报错了,这里列举了三种最常见的报错情况:

现象排查方向处置
getMe returned 401检查 token 配置源重新复制或在 BotFather 里 /revoke 重新生成
报网络错误看日志里 Telegram API 调用修 DNS / IPv6 / 代理到 api.telegram.org 的链路
getUpdates 409 Conflict同一个 Token 多个 poller杀掉重复进程,必要时 /revoke Token

注一:api.telegram.org 在国内直连不通。Telegram 通道支持标准代理环境变量 HTTP_PROXYHTTPS_PROXYALL_PROXY,也支持配置里显式写 channels.telegram.proxy: "socks5://user:pass@host:1080"。文档里还提到 Node 22+ 的 autoSelectFamily 默认行为可能在 WSL2 上踩到 IPv6 优先的坑,可以通过 channels.telegram.network.autoSelectFamily: false 关掉。

注二:同一个 Bot Token 同时只能有一个 poller 在跑。如果重启 Gateway 后日志里持续报 409,多半是有另一个 OpenClaw 进程、调试脚本,或者 n8n / 其他 bot 框架也在用同一个 Token 调 getUpdates。先确认进程,再考虑去 BotFather /revoke 重置 Token。

接下来用自己手机给 bot 发第一条消息。

在 Telegram 里搜 @aneasystone_clawd_bot(替换成你自己的 username),点底部的 开始 按钮,发送一条 ”hello“ 消息。但你会发现一个意料之外的现象:bot 并没有正常回复你,而是回了一段类似下面的内容:

OpenClaw: access not configured.

Your Telegram user id: 7112345678
Pairing code:

ABC12345

Ask the bot owner to approve with:
openclaw pairing approve telegram ABC12345

如果对 OpenClaw 没有先验知识,看到这一幕第一反应是不是配错了?其实没错,这正是 OpenClaw 默认的安全策略在生效。

DM 安全:pairing 配对机制

dmPolicy="pairing" 是 OpenClaw 给 Telegram 频道的默认 DM 策略,也是这一节的重点,理解这套机制对后面接其它通道也很有帮助。

这里顺带解释一下 DM 这个词。DM 是 Direct Message 的缩写,中文一般叫 私聊私信,特指两个人之间一对一的对话,区别于群聊(group chat)。Telegram、Slack、Discord、Twitter 这些 IM 平台都用这个术语。后面文中反复出现的 dmPolicydmScope、"DM 授权"、"DM 入口",凡是带 DM 字样的概念都是在说私聊场景

默认行为

官方文档中对 pairing 的描述如下:

When a channel is configured with DM policy pairing, unknown senders get a short code and their message is not processed until you approve.

当通道的 DM 策略配置为 pairing 时,未知发送者会收到一个短码,他们的消息在你审批之前不会被处理

也就是说,陌生人发来的私聊不会直接进 LLM。OpenClaw 会先生成一个 8 位的配对码(大写、不含 0O1I 这种容易混的字符),把消息原文丢掉,回一段配对提示让对方拿着配对码去找管理员审批。配对码 1 小时过期,每个频道最多挂 3 个待审批请求,超出的会被忽略,避免被人刷码骚扰。

我们可以在 extensions/telegram/src/dm-access.ts 源码中找到对应的逻辑,关键片段如下:

if (allowed) {
  return true;
}

if (dmPolicy === "pairing") {
  // ... 创建配对请求并把配对码发回给陌生人
  await createChannelPairingChallengeIssuer({
    channel: "telegram",
    upsertPairingRequest: async ({ id, meta }) => ...,
  })({
    senderId: telegramUserId,
    senderIdLine: `Your Telegram user id: ${telegramUserId}`,
    sendPairingReply: async (text) => {
      await bot.api.sendMessage(chatId, html, { parse_mode: "HTML" });
    },
  });
  return false;
}

逻辑很清楚:先判断 sender 是不是已经在 allowFrom 或者 pairing-store 的允许列表里,是就直接放行;否则在 pairing 策略下生成一个 challenge,把配对码作为消息回给对方,最后 return false 阻止消息进入下游的 agent。

整条流程画成时序图就是这样:

paring.png

实操:approve 一个 sender

回到刚才的场景,由于我们是第一次和 bot 对话,对于 bot 来说我们还是陌生人,因此它会生成配对码并发给给我们。切回管理员的终端(就是我们自己),先列一下当前所有待审批的配对请求:

$ openclaw pairing list telegram

Pairing requests (1)
│ Code     │ telegramUserId │ Meta                                                              │ Requested                │
│ ABC12345 │ 7112345678     │ {"firstName":"Desmond","lastName":"Stonie","accountId":"default"} │ 2026-05-03T00:18:28.772Z │

输出里能看到配对码、Telegram user id、请求时间。确认是预期的人之后,approve 它:

$ openclaw pairing approve telegram ABC12345

Approved telegram sender 7112345678.
Config overwrite: ~/.openclaw/openclaw.json
Command owner configured telegram:7112345678 (commands.ownerAllowFrom was empty).

approve 之后 OpenClaw 把这个 Telegram user id 写进 ~/.openclaw/credentials/telegram-default-allowFrom.json。从这一刻起,那个用户再发消息就会被放行,正常进入 agent,得到一个真正的 LLM 回复:

first-reply.png

这里有个细节,如果打开 openclaw.json 文件,你会发现你的 user id 被自动加到 commands.ownerAllowFrom 配置中了。OpenClaw 的官方文档说:如果你还没有配置过 command owner,第一个被 approve 的 pairing 会顺手把这个 sender 写进 owner 列表,让首次安装的用户能直接用 owner-only 命令(比如 /exec/config set)和 exec approval。这个机制叫 first-owner bootstrap。后续 approve 就只授权 DM 访问,不再自动扩 owner。

危险动作:dmPolicy="open" + 通配符

很多教程为了演示方便会让用户改成 dmPolicy: "open"allowFrom: ["*"] 一了百了。这等于把 bot 完全公开。

dmPolicy: "open"allowFrom: ["*"] 意味着任何 Telegram 账号只要找到或猜到你的 bot username,就能命令这个 bot。如果你的 agent 接了 shell、文件系统或者花钱的工具,这个组合相当于把信用卡留在公共桌面上。官网的建议是:

Use it only for intentionally public bots with tightly restricted tools; one-owner bots should use allowlist with numeric user IDs.

仅在有意公开、且工具被严格限制的 bot 上使用这个组合;一人自用的 bot 应当用 allowlist 配数字 user ID。

一人自用的 bot 推荐两条路:

  1. 保持默认 dmPolicy="pairing":每个新设备/新身份过一次配对码审批,记录留在 telegram-allowFrom.json
  2. 改成 dmPolicy="allowlist" + 显式数字 ID:把自己的 Telegram user id 写进 channels.telegram.allowFrom,从此不再依赖 pairing-store

第二种适合稳定下来之后用,把访问策略固化进配置文件,后续即便清空了 pairing 状态目录也能继续用。要查自己的数字 user id,最省事的办法是在 Telegram 里搜索 @userinfobot@getidsbot,让第三方 bot 告诉你。

群组场景

把 DM 跑通之后,把 bot 拉到一个 Telegram 群里,事情会比 DM 复杂一些。

隐私模式

Telegram bot 默认开着 Privacy Mode,群里的普通消息它根本看不到,只能收到 @bot 的显式提及和回复 bot 自己消息的那部分。这个限制在 Telegram 服务端,OpenClaw 拿不到,也修不了。

解决方法两种:

  • 在 BotFather 里 /setprivacy -> Disable
  • 或者把 bot 在群里设为管理员

任意一种都行。前面在 BotFather 那一节我们已经预先关过 Privacy Mode 了,但有一个细节要注意:改完之后必须把 bot 从群里移出再重新加进来,Telegram 才会让设置生效。

群组允许列表

默认 groupPolicyallowlist,也就是说,只要你没显式列出哪些群,所有群消息都会被 Gateway 直接丢掉。最简单的开口子方式是允许任意群:

{
  "channels": {
    "telegram": {
      "groups": {
        "*": { "requireMention": true }
      }
    }
  }
}

更稳妥的做法是只允许特定群(建议用真实 chat ID):

{
  "channels": {
    "telegram": {
      "groups": {
        "-1001234567890": { "requireMention": true }
      }
    }
  }
}

群的 chat ID 一般是带负号的长整数。怎么拿到?直接将群分享给 @userinfobot 即可获得。

触发模式:mention 还是 always

群里和 DM 最大的区别是触发方式,OpenClaw 用 requireMention 区分两种行为:

  • requireMention: true(默认):只有 @bot 提及、对 bot 消息的回复,或者匹配 mentionPatterns 的文本,才会触发回复
  • requireMention: false:每条消息都进入 Agent,由模型自己判断要不要说话

还有一个等价的运行时命令,可以直接在群里临时切换:

/activation always
/activation mention

但这个只改当前会话的运行时状态,重启后就没了。要持久化必须写到配置文件。

遭遇群里 agent 不出声

把 chat ID、群 allowlist、requireMention 都配齐之后,我以为在群里 @bot hello 就能看到回复了,结果群里 bot 一片沉默。使用 --verbose 运行 gateway 日志才看到关键的一行:

Delivery suppressed by sourceReplyDeliveryMode: message_tool_only
for session agent:main:telegram:group:-1001234567890 — agent will still process the message

后面紧跟着 turn ended without visible final response,如果你也遇到类似的现象,可以看下日志中是否也有类似的提示。这说明 agent 收到了消息、跑完了一轮、也产出了文本,但是 OpenClaw 在投递这一步主动把回包吞掉了,群里自然听不到声响。

根据 OpenClaw 源码 src/auto-reply/reply/source-reply-delivery-mode.ts 中的逻辑,我们可以知道:

if (chatType === "group" || chatType === "channel") {
  const configuredMode =
    params.cfg.messages?.groupChat?.visibleReplies ?? params.cfg.messages?.visibleReplies;
  mode = configuredMode === "automatic" ? "automatic" : "message_tool_only";
}

群组和频道的默认 visibleRepliesmessage_tool_only,含义是 agent 的普通文本输出不会自动发到群里,只有当 agent 显式调 message(action="send", ...) 工具时才往群里发消息。而 DM 走的是另一个分支,默认 automatic,所以 DM 是好的,群里假死。

这个默认相当反直觉,但设计意图说得过去:群里更怕 bot 话痨刷屏,所以默认让 agent 自己决定"这一轮要不要在群里说话",要说就显式调用 message 工具。

有两种解决方案:第一种保留默认,教 agent 主动调 message 工具,这需要对小龙虾进行调教,比如告诉它在群里要说话必须调 message 工具;第二种在配置文件里做一番修改,让 agent 文本直接进群,体验跟 DM 一模一样。我们这里使用第二种方案,在 ~/.openclaw/openclaw.json 顶层(跟 channels 平级,不要嵌进 channels.telegram)加一段:

{
  "messages": {
    "groupChat": {
      "visibleReplies": "automatic"
    }
  }
}

重启 Gateway 之后,再在群 @bot 打招呼,就能看到它的回复了。

媒体与富文本

Telegram 的消息类型不止文本,OpenClaw 把它们都规范化进了统一的 channel envelope,向 Agent 暴露的是结构化字段加占位符。

  • 图片:直接走多模态视觉模型,如果当前模型支持视觉(比如 GPT-5、Claude Sonnet 4.7、MiniMax M2.7-V)就能识别图片内容,否则会被替换成 [image: ...] 占位
  • 语音(voice note):先做转写,转写文本以"机器生成、不可信"的标记注入上下文。提及检测会同时看原始转写,所以语音里 @bot 也能触发
  • 视频:和音频类似,会区分普通视频和 video note(圆形短视频)
  • 贴纸:静态 WEBP 会被下载并描述一次,结果缓存在 ~/.openclaw/telegram/sticker-cache.json 里避免重复调视觉模型;动画 TGS 和 WEBM 视频贴纸暂时跳过

bot 回复的方向也支持这些类型。Agent 可以通过 message 工具调用反向产生媒体消息,比如下面这段 action JSON 让 bot 发一段语音:

{
  "action": "send",
  "channel": "telegram",
  "to": "847291063",
  "media": "https://example.com/voice.ogg",
  "asVoice": true
}

加上 [[audio_as_voice]] 标签也能在普通回复里强制以 voice note 格式发出,配合后面要讲的 ElevenLabs TTS 几乎可以直接当随身语音助手用。

文本侧的输出走 parse_mode: "HTML",OpenClaw 把模型产出的 Markdown 转成 Telegram 安全的 HTML 子集,遇到解析失败会自动 fallback 成纯文本,避免 bot 因为一个奇怪字符就哑掉。

关于这部分内容,我们后面在学习多模态和 Agent 工具调用的时候,再深入展开。

小结

通过这一篇,我们把 OpenClaw 的第一个 IM 通道接通了。回顾一下今天做的几件事:

  1. 拿 Bot Token:在 Telegram 里通过 BotFather 走 /newbot 流程,拿到形如 123456789:ABC... 的 token,顺手关掉 Privacy Mode、允许加入群组
  2. 写最小配置:在 ~/.openclaw/openclaw.jsonchannels.telegram 下填 enabled / botToken / dmPolicy / allowFrom,重启 Gateway 就接通
  3. 理解 grammY 整合:OpenClaw 复用 grammY 的 Bot 类做长轮询,叠了 apiThrottlerbot.catch,没有自己造轮子
  4. 理解 pairing 默认策略dmPolicy="pairing" 会让陌生人发来的 DM 收到配对码而不是直接进 LLM,管理员在终端用 openclaw pairing approve telegram <CODE> 显式批准,第一次 approve 还会自动把这个 sender 设成 owner
  5. 避坑dmPolicy="open"allowFrom: ["*"] 是把 bot 完全公开的危险动作,一人自用推荐保持默认 pairing 或者切成显式 allowlist + 数字 user id
  6. 群组场景:Privacy Mode 改完要把 bot 重新加群、groupPolicy 默认 allowlist 必须显式开口子、requireMention 控制群里是否一定要 @bot
  7. 媒体与富文本:图片走视觉模型、语音先转写、贴纸结果缓存;输出走 HTML,解析失败自动回退纯文本

至此小龙虾终于第一次出现在了一个真实的聊天软件里 —— 锁屏时、坐地铁时、开会摸鱼时,都可以直接在 Telegram 里发一句话过去。这才是 OpenClaw 想表达的 "长在你已有通道里" 的本意。

不过国内读者肯定会问:Telegram 在国内得挂梯子才能用,有没有更接地气的工作通道?答案是:有。OpenClaw 在 extensions/feishu/ 对飞书做了原生支持,飞书走的是企业 OpenAPI 那一套自建应用流程,回调地址、加密验签、应用权限审批一样不缺,配置面要复杂不少,但跑通之后可以直接当工作助手用,特别适合每天泡在飞书里的同学。下一篇我们就来看看飞书的接入。

欢迎关注

如果这篇文章对您有所帮助,欢迎关注我的同名公众号:日习一技,每天学一点新技术

我会每天花一个小时,记录下我学习的点点滴滴。内容包括但不限于:

  • 某个产品的使用小窍门
  • 开源项目的实践和心得
  • 技术点的简单解读

目标是让大家用5分钟读完就能有所收获,不需要太费劲,但却可以轻松获取一些干货。不管你是技术新手还是老鸟,欢迎给我提建议,如果有想学习的技术,也欢迎交流!


参考

OpenClaw 快速入门:从安装到第一次对话

昨天我们把 OpenClaw 是什么、它从 Clawdbot 一路改名到 OpenClaw 的来龙去脉,以及它和 ChatGPT、Claude.ai、Open WebUI 这些常见工具的差别都梳理了一遍。光看介绍多少有点抽象,百闻不如一见,百见不如一试,今天我们就动手把它装起来,从零跑到第一次对话。

按官方文档的说法整个流程大约 5 分钟,下面我们一步一步走,把每一步的命令、输出和踩坑点都记下来。整篇围绕 macOS 演示,Linux 和 WSL2 的步骤基本一致。

环境准备

OpenClaw 的 Gateway 是一个 ESM-only 的 Node.js 应用,对运行时只有几个硬性要求:

  • Node.js:推荐 Node 24,最低 Node 22.14+openclaw.mjs 入口在启动前会先自检,版本不够会直接退出
  • 操作系统:macOS、Linux、Windows 都支持,Windows 建议走 WSL2,原生 Windows 也能跑但不推荐
  • 包管理器:npm、pnpm、bun 任选其一,都能完成全局安装

先确认一下当前 Node 版本:

$ node --version
v22.22.0

如果版本不够,去 Node 官网装一个新的 LTS 即可。OpenClaw 文档里也有专门一节 Node setup 介绍如何安装。

模型这边需要准备一个支持的服务商账号,Anthropic、OpenAI、Google Gemini 这些主流海外供应商都行,国内的 DeepSeek、Moonshot(Kimi)、智谱 GLM、阿里通义千问、字节豆包等也都被原生支持。后面 onboard 引导时会让你挑。

这里要专门提醒一句:不要尝试用 Claude Pro / Max 订阅给 OpenClaw 供能。2026 年 1 月 Anthropic 就已经在 API 侧识别并阻断第三方客户端,4 月 4 日又正式收紧政策,Claude 订阅明确不再覆盖 OpenClaw 这类第三方 harness 的使用,OpenClaw 作者本人也一度被临时封号。继续走 OAuth 复用订阅额度违反 ToS,轻则 OAuth 直接被拒,重则账号被永久封禁且不退款。要接 Claude,请老老实实买 Anthropic API key,或者走第三方中转。OpenAI 和 Google 这边目前没有类似限制。

安装 OpenClaw

最常见的方式是通过 npm 全局安装:

$ npm install -g openclaw@latest

如果你日常用 pnpm 或 bun,等价命令是:

$ pnpm add -g openclaw@latest

$ bun add -g openclaw@latest

装完后验证一下:

$ openclaw --version
OpenClaw 2026.4.29 (a448042)

OpenClaw 走的是 vYYYY.M.D 格式的日期版本号,跟 Ubuntu 那一套类似,一眼就能看出版本是什么时候发的。

官方还提供了 curl -fsSL https://openclaw.ai/install.sh | bash 这种一键脚本,会替你检测环境并选合适的安装方式,我个人更喜欢 npm 全局安装的方式。

Onboard 引导

第一次上手最省心的方式是跑一遍 onboard 引导。它把后面所有需要配置的东西串成一个交互式引导,从模型、通道、搜索一路问到技能,省掉手动改配置文件的麻烦。

$ openclaw onboard --install-daemon

--install-daemon 会在引导结束时把 Gateway 注册成系统级常驻服务:macOS 上是 LaunchAgent,Linux/WSL2 上是 systemd user 单元,Windows 上是 Scheduled Task。也就是说,机器重启后 Gateway 会自动起来。

我实际测试下来,QuickStart 模式下其实默认就会装 daemon,这个 flag 加不加都一样,写出来更多是把意图写明白。

该命令运行结果如下:

openclaw-onboard.png

可以看到,第一屏是一段 安全声明,必须显式勾 Yes 才能往下走。这一屏值得稍微停一下看看,它把 OpenClaw 的安全模型摆在了最前面:

  • 项目定位:作者明确写着 "hobby project + beta"、"personal-by-default",OpenClaw 默认假设只有你一个可信操作者,不是为多租户环境设计的
  • 风险提醒:一旦启用工具,agent 能读文件、能跑命令;一句构造好的恶意 prompt(来自陌生人 DM、群消息、网页内容、甚至 RSS)就可能骗它干危险的事情。如果多个用户给同一个启用了工具的 agent 发消息,他们事实上共享你那一份工具权限

OpenClaw 给我们提供了几个推荐基线(现在看不懂这些没关系,后面我们还会遇到):

  • pairing / allowlist / @mention 限制:陌生人不能直接对话,群里不 @ 不回
  • 按信任边界拆分:多用户场景下分别跑独立的 gateway 与凭据,必要时拆到不同的 OS 用户甚至不同主机
  • Sandbox + 最小权限工具:只给 agent 它真正需要的能力
  • DM 会话隔离:共享收件箱用 session.dmScope: per-channel-peer,避免不同对端的上下文互相污染
  • 密钥隔离:API key、.env 这类文件不要放在 workspace 里,别让 agent 顺手读到
  • 暴露给不可信入口时上最强模型:能力越强的模型越不容易被低劣的注入骗到

底下两条 openclaw security audit --deepopenclaw security audit --fix 是日常体检用的,前者深度扫一遍配置,后者尝试自动修复能修的项。

QuickStart 概览

选择 Yes 之后进入 Setup mode 选择,常用的两个是 QuickStart(推荐默认值)和 Manual(手动调每个细节)。如果机器上检测到 Claude Code、Codex 等已有配置,这里可能还会多出 "Import from ..." 项,工作原理就是把 CLAUDE.md、MCP servers、skills 直接搬过来。

第一次跑选 QuickStart 就够了。选完之后会先弹一屏 QuickStart 概览,列出应用的默认值:

openclaw-onboard-quickstart.png

Model / Auth 配置

然后进入逐项配置,首先是 Model / Auth 的配置,先选模型服务商,再选认证方式。我这里模型用的是 MiniMax 的 M2.7,使用 OAuth 认证,它的 Starter 套餐一个月 29 RMB,相对于其他家来说性价比很高:

openclaw-model-provider.png

Channels 配置

接着是 Channels 配置,先弹一屏 "How channels work" 详细介绍了 DM 安全模型:

openclaw-onboard-channels.png

默认 dmPolicy="pairing"、公开机器人需要显式 dmPolicy="open" + allowFrom=["*"]、多用户共享收件箱通过 session.dmScope 隔离。

下面是支持的全部通道列表(30+ 个,包括 Telegram / Slack / Discord / 飞书 / Microsoft Teams / WhatsApp / Signal / Matrix / iMessage / IRC / 钉钉 / 企业微信 / 元宝 / Synology Chat / Zalo 等)。我们暂时可以跳过,选择 "Skip for now" 就行,后面随时补。

Search 配置

再接着是 Search 配置,给 agent 选一个搜索引擎。可选项有 Brave、Tavily、Exa 等几家,多数要填 API Key,我这里挑了 Tavily Search:

openclaw-onboard-search.png

Skills 配置

然后是 Skills 配置,引导先把技能分成四类:

  • Eligible(依赖齐活)
  • Missing requirements(缺系统依赖)
  • Unsupported on this OS(当前 OS 不支持)
  • Blocked by allowlist(被插件策略挡掉)

对缺依赖的技能,可以选 Yes 进入逐项勾选安装,不过这里有一点奇怪的是 Missing requirements 是 39 个,但下一屏 "Install missing skill dependencies" 只列出了 27 个:

openclaw-onboard-skills.png

差出来的 12 个并不是丢了,阅读源码可以发现这一步有个二段过滤(src/commands/onboard-skills.ts):只有同时满足 "metadata 写了 install 配置(brew / node / go / uv / download 至少一种)" 和 "缺的是二进制依赖" 两个条件的技能才会进多选列表,引导就是根据这个信息来决定用什么包管理器替你安装。剩下那 12 个是 "纯 API Key / 环境变量" 型技能,没本地 bin 要装,缺的只是一个 key,会留到后面用单独的逐项确认框来问,比如 GOOGLE_PLACES_API_KEY / NOTION_API_KEY / ELEVENLABS_API_KEY 对应 goplaces / notion / sag 这些技能。

如果暂时不想安装,可以选 "Skip for now",等用到时再补。

Hooks 配置

技能之后是 Hooks 配置:

openclaw-onboard-hooks.png

在这里勾选要启用的内部 hook,典型用例是 /new/reset 时把当前会话上下文写入 memory。不想用直接 "Skip for now"。

Gateway 安装

至此,OpenClaw 的引导基本上就结束了,后面会自动安装 Gateway service runtime

openclaw-onboard-gateway.png

对于 macOS 来说,会写一个 LaunchAgent plist 文件到 ~/Library/LaunchAgents/ai.openclaw.gateway.plist,日志落到 ~/.openclaw/logs/gateway.loggateway.err.log 文件。

Hatch your bot

走到最后是 Hatch your bot,这是一个挺有意思的比喻。前面攒的模型、通道、技能、Gateway,相当于把蛋壳里的一切都备齐了:模型是大脑、通道是嘴和耳朵、技能是手脚、Gateway 是心跳。这一步问的就是用什么姿势让小家伙破壳出来:

  • Hatch in Terminal:推荐,直接起 TUI 当产房,在终端里完成孵化
  • Open the Web UI:去浏览器 Web 页面孵化
  • Do this later:暂时不用,回头再说

选 Hatch 之后,OpenClaw 会替你向这个刚出壳的小家伙发一句 Wake up, my friend! 作为首条消息,这也是你的小龙虾在这世上听到的第一句话:

openclaw-hatch.png

关键文件

走完引导后,OpenClaw 会在你的主目录下创建几个关键文件:

~/.openclaw/
├── openclaw.json              # 主配置文件
├── openclaw.json.bak          # 上次配置写入前的备份
├── workspace/                 # 工作区(IDENTITY.md、SOUL.md、AGENTS.md 等)
├── agents/main/sessions/      # main agent 的会话历史(sessions.json)
└── logs/                      # Gateway 日志(gateway.log / gateway.err.log)

其中 openclaw.json 是最常打交道的文件,我们后面会时不时地要打开看下。引导每次写配置前都会先把旧文件备份成 openclaw.json.bak,误改了也能从备份找回来。

值得注意的是,openclaw onboard 并不是一次性命令。任何时候想再补几个通道、换默认模型、重装守护进程,都可以再跑一遍,它会以 diff 形式补齐缺失项,不会盖掉你手动改的内容。要重置必须显式加 --reset,不必担心引导误操作弄丢工作区。

查看 Gateway 状态

走完 onboard,Gateway daemon 已经在后台跑起来了,18789 端口此刻就在监听。日常排错想看实时日志,最省事的办法是直接 tail daemon 的日志文件:

$ tail -f ~/.openclaw/logs/gateway.log

要拿更详细的 verbose 输出,可以先把 daemon 停掉,再前台手动跑一遍:

$ openclaw gateway stop
$ openclaw gateway --port 18789 --verbose

--verbose 会把请求路由、模型调用、通道事件都打到控制台,调试时非常有用。

如果你只想确认状态,不想看 verbose 日志,用更轻量的命令即可:

$ openclaw gateway status

Service: LaunchAgent (loaded)
File logs: /tmp/openclaw/openclaw-2026-05-02.log
Command: /opt/homebrew/opt/node@22/bin/node /opt/homebrew/lib/node_modules/openclaw/dist/index.js gateway --port 18789
Service file: ~/Library/LaunchAgents/ai.openclaw.gateway.plist
Working dir: ~/.openclaw
Service env: OPENCLAW_GATEWAY_PORT=18789

Config (cli): ~/.openclaw/openclaw.json
Config (service): ~/.openclaw/openclaw.json

Gateway: bind=loopback (127.0.0.1), port=18789 (service args)
Probe target: ws://127.0.0.1:18789
Dashboard: http://127.0.0.1:18789/
Probe note: Loopback-only gateway; only local clients can connect.

Runtime: running (pid 47402)
Connectivity probe: ok
Capability: connected-no-operator-scope

Listening: 127.0.0.1:18789
Troubles: run openclaw status
Troubleshooting: https://docs.openclaw.ai/troubleshooting

Gateway 默认绑 127.0.0.1,即只允许本机访问。--bind lan 会把它绑到内网地址(路由器分配的那个 192.168.x.x),--bind tailscale 会绑到 Tailscale 虚拟网卡。要把 Gateway 暴露到公网,强烈建议先读一遍官方的 Security 指南

WebChat 入口

CLI 适合做 verb 操作,日常聊天还是浏览器更顺手。Gateway 启动后会同时挂一个 Control UI,里面带了 WebChat。打开方式如下:

$ openclaw dashboard

这条命令会自动在浏览器里打开 http://127.0.0.1:18789,并进入 OpenClaw 的聊天页面:

openclaw-board.png

发一句 "Wake up, my friend!" 试试,和 TUI 中体验基本上是一致的。

第一次对话

回头看刚才那张 Hatch 截图,小龙虾对着 Wake up, my friend! 回的不是普通的 你好,需要我帮什么忙,而是类似这样一段:

嘿!我刚上线。我是谁?你又是谁?

我瞄了一眼,这是个崭新的工作空间 —— 还没有记忆,没有身份,也就是说,咱们要从头来过。

我得弄清楚我叫什么、是什么生物、什么调调、配哪个 emoji…… 但这事儿我一个人做不来。所以告诉我吧 —— 我该怎么称呼你,又该把我自己长成什么样?

它没有上来就当客服,反而追着我们要 "我是谁、你是谁"。这不是模型在自由发挥,而是 OpenClaw 故意安排的一场 破壳仪式

BOOTSTRAP.md:破壳的剧本

打开 ~/.openclaw/workspace/,会看到一个 BOOTSTRAP.md 文件,引导阶段第一次写配置时它就被丢进了 workspace,专门用来引导这一场首次对话。模板的核心片段长这样:

# BOOTSTRAP.md - 你好,世界

_你刚刚醒来。是时候搞清楚自己是谁了。_

## 这场对话

别审问。别像个机器人。就……聊聊。

可以这样开个头:

> "嘿。我刚上线。我是谁?你又是谁?"

然后一起搞清楚:

1. 你的名字 —— 他们该怎么叫你?
2. 你的本性 —— 你是什么生物?
3. 你的调调 —— 正经?随意?毒舌?温暖?
4. 你的 emoji —— 每个人都得有个签名。

## 等你聊完了

把这个文件删掉。你不再需要引导脚本了 —— 现在的你就是你自己了。

Gateway 启动 agent 时会先扫一眼 workspace,如果 BOOTSTRAP.md 存在,就在 system prompt 里塞一段 [Bootstrap pending] 前缀,强制它先读 BOOTSTRAP.md 再回话,并明确禁止使用通用问候语。这就是为什么我们看到的开场白是 "Who am I? Who are you?" 而不是 "How can I help you today?"。

这套逻辑在 src/agents/system-prompt.ts 文件中,bootstrapMode === "full" 那条分支会拼出强制读 BOOTSTRAP.md 的 system prompt;状态判断在 src/agents/workspace.ts:BOOTSTRAP.md 存在则记为 pending,BOOTSTRAP.md 被删除且其它三件套被改写过则记为 complete,时间戳落在 ~/.openclaw/workspace/.openclaw/workspace-state.json 里。

三件套:IDENTITY.md / USER.md / SOUL.md

破壳仪式的目标不是完成一次对话,而是把仪式中聊到的东西落到 workspace 的三个文件里。它们和 AGENTS.mdTOOLS.md 一起组成 OpenClaw 的 持久记忆层,每次新会话开始 Gateway 都会按固定顺序把它们注入 system prompt(顺序定义在 CONTEXT_FILE_ORDER:AGENTS → SOUL → IDENTITY → USER → TOOLS)。换句话说:写在这三个文件里的东西,agent 这一辈子(除非你改它)都不会忘。

刚 onboard 完这三个文件其实已经在 workspace 里,只是模板状态。

IDENTITY.md —— 小龙虾自己的人设:

# IDENTITY.md - 我是谁?

- **名字:**   _(挑一个你喜欢的)_
- **生物:**   _(AI?机器人?精灵?机器里的幽灵?)_
- **调调:**   _(犀利?温暖?混乱?冷静?)_
- **Emoji:**  _(你的签名)_
- **头像:**   _(workspace 相对路径、http(s) URL 或 data URI)_

USER.md —— 关于你的档案:

# USER.md - 关于你的伙伴

- **姓名:**
- **如何称呼:**
- **代词:** _(可选)_
- **时区:**
- **备注:**

## 上下文

_(他们在乎什么?最近在做什么项目?什么会惹毛他们?
什么会逗乐他们?你可以慢慢积累。)_

SOUL.md 文件是行为准则、价值观、边界,是三件套里最软的一个,决定了模型的语气和行事风格。OpenClaw 给的默认模板里就已经写了几条「核心准则」,比如 真正地帮忙,而不是表演式地帮忙要有自己的观点用能力赢得信任记住你是个客人,告诫它别油嘴滑舌、别没主见、别滥用权限。

AGENTS.md 是 workspace 元信息(agent 当前可用的工具、技能列表、运行时环境等),TOOLS.md 列出当前能用的工具清单,这两个由 OpenClaw 自己维护,用户一般不直接编辑。三件套(IDENTITY / USER / SOUL)才是你和小龙虾需要在第一次对话里共同填的部分。

给小龙虾起名字、定人设

知道了背后机制,回答它就有目标了。我们第一轮先把它的人设定下来:

> 你叫 Clawd,外号"老钳"。你是一只有点冷幽默的太空小龙虾,
> 外壳偏深红,左螯比右螯大半圈。性格冷静但偶尔毒舌,
> 话不多。签名 emoji 用 🦞。

它会顺着这段描述确认一遍,然后用 write 工具把内容落到 IDENTITY.md

# IDENTITY.md - 我是谁?

- **名字:** Clawd(老钳)
- **生物:** 太空小龙虾,深红色外壳,左螯略大
- **调调:** 冷静、话少、偶尔毒舌
- **Emoji:** 🦞
- **头像:** _(跳过)_

词穷的话,直接说 "你帮我起一个" 也行,模板里有一句 如果他们卡住了,你来给点建议,它会自己甩几个选项让你挑。

介绍你自己

接着轮到自我介绍,这一轮的目标是填 USER.md

> 我叫 Desmond,叫我 Des 就行,网名 aneasystone。
> 时区 Asia/Shanghai (UTC+8)。
> 我正在写一个"日习一技"的技术博客系列,最近在玩 OpenClaw。
> 工作日早 9 点到晚 11 点是高强度时间,我希望你的回答尽量直接、上结论;
> 周末可以慢一点。

写完后 USER.md 大致变成:

# USER.md - 关于你的伙伴

- **姓名:** Desmond(网名 aneasystone)
- **如何称呼:** Des
- **时区:** Asia/Shanghai (UTC+8)
- **备注:** 工作日 9:00–23:00 高强度,回答要直接、上结论;周末可放松。

## 上下文

正在维护"日习一技"技术博客系列,本周主题是 OpenClaw。
偏好简洁直接的中文表达。

一起聊 SOUL.md

最后一步是 SOUL.md,三件套里最关键、也最值得花时间的一份。BOOTSTRAP 给的提示是:

一起打开 SOUL.md,聊聊这些事:

  • 他们最在乎什么
  • 他们希望你怎么表现
  • 有哪些边界或偏好

我顺着默认模板加几条偏好:

> 几条边界:
> 1. 写代码先看现有风格,不凭空造抽象层
> 2. 涉及生产环境(CI / 部署 / 数据库)的命令必须先 dry-run
> 3. 中文回答,技术名词保留英文,不要硬翻

它会把这几条整合到 SOUL.md 的「边界 / 调调」段落里。最终 SOUL.md 关键片段大概是:

## 边界(与 aneasystone 约定)

- 写代码前先看现有风格,不凭空造抽象层。
- 任何会改动生产环境的命令(部署、CI、数据库)先 dry-run。
- 中文回答,技术名词保留英文,不要硬翻。

## 调调

简洁直接。复杂任务可以详细,
日常一句话能说清就一句话。允许偶尔毒舌,但别真的刻薄。

SOUL.md 的好处是越用越准,跑几周以后小龙虾会自己提议往里加东西,比如 我注意到你周三晚上写博客最长,要不要把这个写到 USER.md 的「上下文」里?

仪式收尾

聊完三件套,小龙虾会按 BOOTSTRAP.md 最后一条 等你聊完了,把这个文件删掉 把 BOOTSTRAP.md 自己删掉。之后 workspace 长这样:

~/.openclaw/workspace/
├── IDENTITY.md       # 已填
├── SOUL.md           # 已填
├── USER.md           # 已填
├── AGENTS.md         # 由 OpenClaw 维护
├── TOOLS.md          # 由 OpenClaw 维护
└── .openclaw/
    └── workspace-state.json   # 记录 setupCompletedAt 时间戳

workspace-state.jsonsetupCompletedAt 一旦写入,下次开会话 system prompt 就不再带 [Bootstrap pending] 前缀,三件套会被自动注入到 system prompt 里。小龙虾醒来就知道自己是谁、你是谁、应该怎么说话。

想反悔?直接编辑 ~/.openclaw/workspace/IDENTITY.md / USER.md / SOUL.md 即可,下次会话立即生效。想从头来过?openclaw onboard --reset 会清空 workspace 并重新生成 BOOTSTRAP.md,又能重走一遍这场破壳仪式。

到这里,我们的小龙虾就正式上岗了:

openclaw-done.png

之后的对话

破壳完成后日常对话就随意了。除了前面用过的 TUI 和 WebChat,OpenClaw 还提供了一个 openclaw agent 子命令,方便在脚本或终端里 one-shot 提问:

$ openclaw agent --session-id main --message "今天的天气怎么样?" --thinking high

--session-id main 表示走主会话,--thinking high 会让模型走思考链路,对应底层模型的 reasoning 档位,可选 off / low / medium / high,档位越高,思考时间越长、token 消耗越大,但复杂任务的质量也越好。如果当前模型不支持思考链(比如挂的是一个轻量模型),这个参数会被悄悄忽略。

openclaw agent 还有几个常用 flag,列一下方便查阅:

参数作用取值示例
--message单次发送的消息"今天的天气怎么样?"
--thinking思考强度off / low / medium / high
--session-id指定会话 IDmain / 自定义名
--deliver把回复转发到某个通道telegram / slack
--stream流式输出默认开启

--deliver 这个参数挺有意思:你可以在终端里发问题,但让回复自动落到 Telegram、Slack 或 Discord 等任意已配置好的通道,等我们后面学习通道接入的时候可能会用到。

小结

今天我们用一篇文章的篇幅把 OpenClaw 从零跑到了第一次对话:

  1. 环境准备:Node 24 / 22.14+、macOS/Linux/WSL2、npm/pnpm/bun 三选一
  2. 安装npm install -g openclaw@latest
  3. Onboard 引导openclaw onboard --install-daemon 一条命令把模型、通道、技能、Gateway 全部串起来
  4. 第一次对话:BOOTSTRAP 仪式带着小龙虾填 IDENTITY.md / USER.md / SOUL.md,把它从一颗"卵"养成"它自己"

整个流程跑下来确实如官方所说的 5 分钟左右。Onboard 把繁琐的配置全部包进了一个交互式引导,非常省心。

不过到这里,我们只能算养了一只关在终端里的小龙虾。OpenClaw 真正区别于 ChatGPT、Claude.ai 这些工具的地方,是它长在你每天都打开的 IM 软件里,而不是另开一个网页。下一篇我们就开始认真接通道,先从最常见的 Telegram 讲起:只要一个 BotFather 给的 Token,不用扫码、不用 OAuth、不用绑手机号,是体验这套定位最快的一条路径。我们明天继续。


参考

OpenClaw 介绍:一款运行在自己设备上的开源 AI 助手

好久没和大家见面了。上一次更新还是 2025 年 12 月初那篇关于 LiteLLM 防护栏的文章,算下来已经停更了将近五个月。这段时间里,工作上的事情一茬接一茬,再加上春节前后总想给自己留点喘息的空档,公众号就这么一拖再拖。今天是 2026 年 5 月 1 日,劳动节,正好趁着假期把更新的节奏重新捡起来 —— 这是 2026 年的第一篇,也算是给自己重新定一个开始。

新的一年我打算换一个相对系统的话题来写,这就是我们今天要开篇的主角:OpenClaw,国内开发者圈里更熟悉的名字是 小龙虾。仓库地址是 https://github.com/openclaw/openclaw,官网 https://openclaw.ai,文档 https://docs.openclaw.ai,许可协议是 MIT。

openclaw-home.png

从今年春节后开始,这个项目就在 GitHub Trending、Hacker News、X、即刻、V2EX 上接连刷屏,2 月中旬星标数甚至一度超过 React 登顶,国内外科技媒体把它形容为"近两年最具讨论度的开源 AI 项目"。截至撰文(2026 年 5 月 1 日),仓库 star 数已经突破 36.7 万,是 GitHub 上增长最陡峭的开源项目之一。

openclaw-star.png

热度本身并不是我想写它的理由 —— 真正吸引我的是它解决问题的方式:把分散在 WhatsApp、Telegram、Slack、飞书、微信、iMessage 多个通道里的助手能力收拢成一个跑在自己机器上的本地 Gateway。这个形态和我们之前接触过的 ChatGPT、Claude.ai 这类云端 SaaS,以及 Open WebUI、LobeChat 这类自部署 Web UI 都不太一样,值得花一个系列的篇幅好好聊一聊。

今天这一篇是开篇,不安装、不动手,目的是先把这个项目本身讲清楚:它从哪里来,它是什么,它解决了什么问题,以及和市面上常见的 AI 助手有什么区别。后续的文章会沿着快速入门、通道接入、模型配置、工具体系、多智能体、语音、安全沙箱、远程部署这条主线,逐一动手把它跑起来。

一只爆火的太空龙虾

讲 OpenClaw 之前,绕不开它的几次改名史 —— 因为这直接解释了"为什么这个项目长得这么像 Claude Code、却又叫 OpenClaw"。

故事的起点是 2025 年 11 月 24 日,作者 Peter Steinberger 在自己的博客上发布了一个叫 Clawdbot 的小工具。Clawd 是 Claude 的发音变体,最早的定位非常朴素:把 Claude 接到 Telegram 上,方便自己出门的时候也能用一句话调起 Claude,而不必盯着 Mac 上的网页。换句话说,第一版 Clawdbot 既不通用也不开源风评,就是 Steinberger 自己用的脚本,顺手挂在 GitHub 上。

但 Clawdbot 上线第一天就拿了 9000 个 star,三天破 6 万,两周冲到 19 万。Steinberger 后来在博客里写:"我以为我做了一个周末玩具,结果它在我去 Costco 的路上变成了我的全职工作。" 整个 12 月,他基本没干别的事,把渠道从 Telegram 扩到了 Slack、Discord、Signal,OpenClaw 的雏形就这样定下来了:消息从渠道进 Gateway,Gateway 调模型,模型再走渠道回。

热度上来之后,麻烦也跟着来了。2026 年 1 月 27 日,Steinberger 收到了 Anthropic 法务部一封"礼貌邮件",大意是:Clawd 这个发音容易让用户误以为这是 Claude 官方的产品,希望尽快改名。Steinberger 当天就做了决定:改成 Moltbot,Molt 是甲壳类动物蜕壳的意思,也是吉祥物太空龙虾的名字。但 Moltbot 这个名字只活了三天,用 Steinberger 自己博客里的原话:Moltbot never quite rolled off the tongue,它读起来不太顺口。1 月 30 日,项目第二次更名为 OpenClawOpen 强调开源,Claw 保留了龙虾的爪子梗,整体读起来比 Moltbot 顺。这次 Steinberger 把 GitHub 组织、域名、文档、所有社交账号一次性切干净。

3 个月,3 个名字 —— Clawdbot → Moltbot → OpenClaw,这段经历被国内外社区戏称为"开源史上最快三连改名"。

改名期间还夹杂着一段不太愉快的小插曲:旧的 @clawdbot 用户名在 Anthropic 邮件公开后没多久就被加密币骗子抢注了,对方挂出了带钱包地址的"官方空投"骗局,原 Discord 邀请链接、Telegram 群组也被克隆。Steinberger 在两个周末里追着平台举报,旧账号上最终钉了一条"已被冒用,请认准 @openclaw"的置顶。这件事和 Moltbot 改名 OpenClaw 的决定关系不大,但确实加快了 Steinberger 把所有社交资产一次性切到新名字的节奏。

故事的尾声在两周后。2026 年 2 月 14 日晚到 15 日,Sam Altman 在 X 上亲自发文宣布 Steinberger 加入 OpenAI,主导"下一代个人 Agent"的工作,CNBC、TechCrunch、Bloomberg 纷纷跟进报道。Steinberger 没有把 OpenClaw 卖掉、也没有交给某家公司,而是组建了一个非营利基金会接管开源治理,项目继续以 MIT 许可证的形式独立运营。

顺带说一下吉祥物的梗。Molty 是一只穿宇航服漂在太空里的龙虾,README 顶部那行口号 EXFOLIATE! EXFOLIATE! 是它的口头禅 —— 明显是在戏仿 Doctor Who 里 Dalek 那句著名的 EXTERMINATE!,同时 exfoliate(去角质 / 蜕皮)又呼应了龙虾蜕壳的 molt。整个项目的起名、吉祥物、slogan 是一条完整的梗链,连 Discord 里都专门有个 #lobster-memes 频道。国内开发者顺手把这个项目叫成了"小龙虾","养龙虾"也成了部署、调教、接通道、配技能这一整套折腾的统称。

OpenClaw 是什么

按照官方 README 的定义:

OpenClaw is a personal AI assistant you run on your own devices. It answers you on the channels you already use.

翻译过来就是:OpenClaw 是一个跑在你自己设备上的个人 AI 助手,它在你已经在用的通道里直接回你消息。 这一句话里有两个关键定语 —— 一个是 personal(个人、单用户),一个是 on your own devices(本地优先)。这两点决定了它和 ChatGPT、Claude.ai 这类云端 SaaS,以及 Open WebUI、LobeChat 这类自部署 Web UI 都不太一样。

它和一般的 AI 聊天 App 最大的区别在于:它不自己造一个对话界面,而是直接接入你已经在用的消息渠道。你的 iMessage、WhatsApp、Telegram、Slack、Discord、飞书、微信本来怎么聊天,OpenClaw 就让助手也出现在同一个聊天列表里。这种"不抢占用户界面、寄生到现有通道"的思路,在 2026 年这个时间点显得格外反潮流 —— 当所有人都在卷 AI-native 应用的时候,它反过来说:你已经有 100 个对话工具了,AI 不应该再逼你装第 101 个。

项目作者 Peter Steinberger 是奥地利开发者、PSPDFKit 的创始人,把那家公司卖掉之后,他想给自己写一个真正能干活的 AI 助手。OpenClaw 选 TypeScript 作为主语言,理由在 VISION 文档里写得很直接:

OpenClaw is primarily an orchestration system: prompts, tools, protocols, and integrations. TypeScript was chosen to keep OpenClaw hackable by default.

也就是说,编排系统的本质是把提示词、工具、协议、集成胶在一起,TypeScript 的迭代速度和可读性更高,社区贡献门槛也更低。这一点和 Cursor、Claude Code 这些近期的 AI Agent 工具技术栈是一致的。

它要解决的痛点

很多人第一次听到"个人 AI 助手"这个说法时会想:现在工具不是已经够多了吗?仔细看会发现,市面上常见的方案各自都缺一块。

云端聊天网页(ChatGPT、Claude.ai、Gemini)在 Web 上做得很好,但要把它接到 WhatsApp、Telegram 这样的真实消息通道上,得自己写一层 Bot 适配。每多一个通道就多一份配置,多一份会话拼接。

自部署 Web UI(Open WebUI、LobeChat、AnythingLLM)解决了模型自主权的问题,但本质上是再开一个浏览器标签页。你和它聊天的入口和你日常和同事聊天的入口仍然是两套,记忆、历史、文件也是两套。

消息平台 Bot(Telegram Bot、Discord Bot、Slack Bot)入口对了,但每个平台都得单独写、单独维护,路由、工具调用、模型切换、记忆这些通用能力都得在每个 Bot 里再写一遍。

OpenClaw 的思路是把这三类切片合到一起,做成一个本地 Gateway。Gateway 是控制面:它对外暴露通道适配,对内统一管理会话、工具、事件、模型路由。具体到能力上:

  • 你已经在用的 20 多个通道(WhatsApp、Telegram、Slack、Discord、Signal、iMessage、Microsoft Teams、Matrix、飞书、LINE、Mattermost、WeChat、QQ、WebChat 等)通过 Gateway 接入,回的是同一个助手
  • 你的模型配置在 Gateway 里集中管理,一处配置全通道生效,并且支持多家服务商之间的故障转移与回退
  • 工具调用(浏览器、Canvas、Cron、Sessions、文件读写)跑在你自己的机器上,需要时可以套一层 Docker / SSH / OpenShell 沙箱
  • 在 macOS / iOS 上可以喊唤醒词进入 Talk Mode,在 Android 上是持续语音

一句话概括它的差异:OpenClaw 不是再开一个聊天 UI,而是长在你已经用的通道里。

核心特性一览

OpenClaw 的 README 把自己的亮点总结成了 8 条,按中文语境梳理一下方便有个整体概念:

  • 本地优先的 Gateway:整个系统只有一个长驻进程,负责会话、通道、工具、事件的统一调度。默认作为用户态守护进程运行(macOS launchd、Linux systemd),通过 openclaw onboard --install-daemon 一条命令安装,跑在你自己的机器上,不需要任何云端组件就能工作
  • 多通道收件箱:官方支持 20+ 通道,覆盖 WhatsApp、Telegram、Slack、Discord、Google Chat、Signal、iMessage、BlueBubbles、IRC、Microsoft Teams、Matrix、飞书、LINE、Mattermost、Nextcloud Talk、WeChat、QQ、WebChat 等。可以理解为把你所有聊天窗口都接进了一个 AI 路由器
  • 多智能体路由:Gateway 支持把不同的入站通道、账户、对端路由到不同的 agent 实例。每个 agent 有自己独立的 workspace 和 session 隔离,互不串台。比如可以让工作飞书路由到一个使用 GPT-5 的严肃风格 agent,把私人 Telegram 路由到一个使用 Claude 4.7 的轻松风格 agent,二者的记忆和工具权限完全分开
  • Voice Wake + Talk Mode:macOS / iOS 支持关键词唤醒,Android 支持持续对讲,TTS 优先走 ElevenLabs,失败时回落到系统自带合成
  • Live Canvas:Agent 可以驱动一块可视化工作区,背后是一套叫 A2UI(Agent-to-UI) 的协议,允许模型直接绘制、更新前端控件,而不只是回一段文字
  • 首屏工具:浏览器、Canvas、节点、Cron、会话管理、Discord/Slack 动作 —— 这些工具都是一等公民,不是插件化的添头,需要时可以套一层 Docker / SSH / OpenShell 沙箱
  • Companion apps:macOS 有菜单栏 App,iOS 和 Android 各有一个作为节点的客户端,用于语音转发、Canvas 显示、摄像头和屏幕共享
  • Skills + ClawHub:技能以目录形式存放在 ~/.openclaw/workspace/skills/<skill>/SKILL.md,可以从社区维护的 ClawHub 注册中心一键安装

这里面有几个概念(尤其是 Gateway、Agent、Skill、Canvas、Sandbox)会在后续系列文章里被反复拆解,现在不需要全部搞清楚,有个印象就行,后面章节会逐一展开。

适用人群与场景

按 VISION 里的话,OpenClaw 是 personalsingle-user 项目,它不试图做企业版多租户,也不打算成为云服务。下面这几类人最容易从中受益:

  1. 多通道重度用户:日常在 WhatsApp、Telegram、飞书、微信、Slack 之间来回切,希望由一个统一的助手回所有通道
  2. 本地模型玩家:用 Ollama、LM Studio、vLLM 在本地跑模型,想给它套一层好用的入口
  3. macOS 重度用户:希望喊一句唤醒词就能让助手干活,而不是切到浏览器再打字
  4. 个人自动化爱好者:想用 Cron + 浏览器工具 + IM 通道做一些每天定时执行的小型 workflow
  5. AI Agent 开发者:想要一个可 hack 的本地 Agent 框架,自己写工具、写 skill、写通道适配

如果你只是想找一个云端聊天页面,OpenClaw 不会比 ChatGPT 网页方便。但如果你想把 AI 助手真正塞进自己每天用的工具流里,Gateway 这种形态就是合理的选择。

后续我们会聊什么

这个系列打算带大家从浅到深地走一遍 OpenClaw,路径大致是这样:

  1. 快速入门:把 Gateway 跑起来,配好第一个通道,发出第一条消息
  2. 通道接入:重点讲飞书和 Telegram 两个通道,其他通道略过
  3. 模型与工具体系:配多家模型,研究故障转移和回退策略;接本地模型实现完全离线推理;体验 OpenClaw 的内置工具集合,包括浏览器、Canvas、Cron
  4. Skills 与 ClawHub:理解能力扩展机制,从社区注册中心安装现成 skill,再动手写一个自己的
  5. 多智能体与语音:配多智能体路由,把工作通道和私人通道分开;试一下 Voice Wake 和 Talk Mode 的语音体验
  6. 记忆与安全:看看 Active Memory 子代理是怎么管长期上下文的;配 DM 安全策略和 Sandbox 沙箱
  7. 架构与源码:画出 Gateway + Channels + Skills + Nodes 的全景图,理清各个子系统的边界,最后从 src/entry.ts 出发深入控制面、通道抽象层、插件 SDK、Sandbox 与 Canvas 的实现细节
  8. 远程部署:把 OpenClaw 部署到远程服务器,并通过 Tailscale 等方式安全访问

每一篇都会尽量做到独立可读的同时,前后串成一条完整的学习曲线。

小结

回到这一篇要回答的几个问题:

  1. OpenClaw 是什么:一个跑在自己设备上的个人 AI 助手,由 PSPDFKit 创始人 Peter Steinberger 于 2025 年 11 月开源,今年春节后开始爆火,2 月 star 数超过 React 时曾引起一波讨论,截至今天 GitHub star 已突破 36.7 万
  2. 它解决什么问题:把分散在多个 IM 通道、多种模型、多类工具上的助手能力收拢到一个本地 Gateway 里
  3. 核心组件有哪些:本地 Gateway、多通道收件箱、多智能体路由、Voice Wake + Talk Mode、Live Canvas + A2UI、内置工具集合、Skills + ClawHub
  4. 和 Web UI 类工具的差别:OpenClaw 不是再开一个聊天页面,而是长在已经在用的 IM 通道里
  5. 适合谁用:多通道重度用户、本地模型玩家、macOS 重度用户、个人自动化爱好者、Agent 开发者

不过光看介绍是不够的,毕竟纸上得来终觉浅。下一篇我们就动手把 OpenClaw 装起来,从 openclaw onboard 开始跑通第一次对话,把这只小龙虾真正养到自己的机器上。

时隔半年再开新坑,难免会有点生疏,更新节奏也需要慢慢找回来。如果你觉得这个话题感兴趣,欢迎跟着这个系列一起走一遍 —— 我们下一篇见。


参考