Fork me on GitHub

2026年5月

给小龙虾配齐工具箱:OpenClaw 的工具体系(二)

上一篇我们把 OpenClaw 的内置工具分成了八大类,并且把前四类一一看了一遍,这一篇书接上文,继续后面四类的学习。

设备控制

这一类管的是小龙虾对配对进来的设备 / node 的操控,两个工具配合着用:nodes 负责发现、配对、指定要遥控哪个 node,以及对它干什么(拍照、录屏、取定位、推送通知等),canvas 负责在某个 node 的屏幕上画东西(显示页面、跑 JS 脚本、截屏画面)。

我们先来看 nodes 的参数,它和昨天学习的 message 类似,也是个按 action 分派的工具:

nodes.png

完整的 action 一共 19 个,每个 action 还各带一批专属的细分参数(定位精度、通知优先级、录屏的 screen 选择等等),一张表列不下,用到时再查文档即可。这 19 个动作按功能分组大致如下:

nodes-actions_01.jpg

图里的分组命名,正好牵出我们之前曾学习过的一组概念:设备(Device)节点(Node)。一台 Mac、一部手机这样的物理设备,在 OpenClaw 里其实分两层看:Device 是它的「身份证」,管它是谁、准不准进来;Node 是它以 role: node 连进来之后对外贴出的「能力清单」,管它能被调用哪些功能。

nodes 这个工具名字虽叫 nodes,实际上横跨了这两层:图里的「配对审批」和「设备信息」管的是 Device(这台设备是谁、批不批),「摄像头 / 屏幕 / 定位 / 通知 / 自定义命令」几组才是真正在调 Node 的能力。说白了它就是一台「设备遥控器」,node 参数选遥控哪一台,action 选按哪个键。

接下来,我们再来看下 canvas 工具的核心参数:

canvas.png

这个工具其实我们之前在 macOS app 篇里已经见过了,那块吸在菜单栏边、给小龙虾当可视化工作区的面板,就是它的画布;它和 nodes 一样,每个 action 底层都对应一条 canvas.* 节点命令,发给目标 node 上的画布去执行。

运行时管理

这一类是小龙虾用来管理自身运行时的两个工具:cron 管定时任务(比如每天早上八点提醒我打卡),对应前面 cron / heartbeat 那篇;而 gateway 则能查看和修改自己的运行时配置,甚至重启和自我升级。

我们先看 cron,它的参数也是按 action 分派,外加一个嵌套的 job 对象,核心部分如下:

cron.png

job 对象里最关键的是 schedule(什么时候跑)和 payload(跑的时候干什么):

job.png

光看参数还是有点抽象,举个具体例子:开头说的「每天早上八点提醒打卡」,用 add 建出来就是这么一个 job:

{
  action: "add",
  job: {
    name: "打卡提醒",
    schedule: { kind: "cron", expr: "0 8 * * *", tz: "Asia/Shanghai" }, // 何时跑:每天上海时间 8:00
    payload: { kind: "systemEvent", text: "提醒主人:该打卡了" },          // 跑时干啥:推一条提醒
    delivery: { mode: "announce" },                                      // 结果去哪:播报到当前通道
  },
}

这个 job 拆开看其实就三块:

先是 schedule,它指的是任务什么时候跑,有三种写法:at 是「某个时间点只跑一次」,给一个具体时刻就行(比如下周一上午十点),适合一次性的提醒;every 是「每隔多久跑一次」,给一个毫秒间隔(比如每两小时一次);cron 最灵活,用一条 cron 表达式配上时区来描述周期,上面例子里的 0 8 * * *Asia/Shanghai,意思就是「每天上海时间早上八点」。

再是 payload,它说的是到点之后具体干什么,分两种。一种是 systemEvent,只往通道里推一条提醒文本,内容写在 text 字段里,上面打卡那个例子就是它,跑起来无非是按时吼你一嗓子。另一种是 agentTurn,到点会触发一整轮完整的 agent 任务,要它干的事写在 message 字段里。比如把上面的 payload 换成:

payload: { kind: "agentTurn", message: "拉一下我今天的日程,挑出最重要的三件发我" }

到了八点,小龙虾就不只是提醒你,而是真的去翻日程、筛选、再把结果发过来。注意这两种类型的字段不一样:systemEventtextagentTurnmessage

最后是 delivery,它管干完之后结果往哪儿送,有三档:none 是不往外送,任务在后台默默跑完就算(常配那些不需要回话的任务);announce 是把结果播报到当前通道,你在 Telegram、飞书里就能直接收到,也是最常用的一档;webhook 则是把结果 POST 到你指定的一个回调地址,方便接进自己的系统或别的自动化流程里。

对照着这个例子回头看,就能明白前面 cron / heartbeat 篇里那些定时任务是怎么配出来的了。

第二个工具是 gateway,下面是它的参数定义:

gateway.png

这个工具比较特殊,它是 仅限 owner(也就是你本人) 才能调用的,OpenClaw 根据这轮消息是谁发来的判定:本机 CLI、菜单栏 app、Control UI 这些本地操作天然算 owner;远程 IM 通道则需要把自己的账号加到 commands.ownerAllowFrom 配置里:

{
  commands: {
    ownerAllowFrom: [
      "telegram:123456789",  // 通道名 + 该通道里的用户 id,只在这个通道生效
      "feishu:ou_xxxxxxxx",  // 别的通道照此再写一条
      "+8613800138000",      // 不带通道前缀:按账号本身匹配,不限通道(手机号会规范成 E.164)
      "*",                   // 所有人都是 owner,强烈不建议使用,等于把门彻底敞开
    ],
  },
}

它的六个 action 按用途可分为三组:

先是「看」的两个。config.schema.lookup 用来查某一块配置长什么样、有哪些字段、各是什么类型,相当于动手之前先翻一遍说明书;config.get 则是把当前生效的配置整个拉出来看一眼,并附带一个 hash,这个 hash 是这份配置算出来的指纹,记住它,等下改的时候用得上。

再是「改」的两个。config.patch 只动其中一小块,日常最常用,注意它并不靠某个 path 参数来定位,而是把要改的内容写成一个 只包含目标项的嵌套对象 塞进 raw 里,OpenClaw 再把它 深合并 进当前配置:你写到的字段被改掉,没提到的一律原样保留,嵌套的层级本身就等于一条路径(这个看下面的例子就明白了)。config.apply 则相反,raw 给的是 一整份 新配置、直接把旧的全量覆盖,你漏写哪项哪项就没了,动静大、容易误伤,非必要不用。两者都可以带上刚才那个指纹(参数叫 baseHash),OpenClaw 会拿它跟当前配置比一比,要是中途配置被别处改过、对不上号,就拒绝这次改动,免得两边并发把对方的修改覆盖掉。

最后是「重启 / 升级」的两个。restart 直接重启 Gateway;update.run 触发一次自我升级再重启。这俩重启完,如果还有没回完的话,可以靠 note(重启后由系统替它转达的一句完成提示)和 continuationMessage(提醒它重启后该回头去核实、汇报什么)把上下文接回来。

对于一次局部配置的修改,官方推荐的做法是,先用 config.schema.lookup 看清楚配置的格式,然后再用 config.patch 小改,别动不动就整份 config.apply。比如想把子 agent 的默认思考强度从 medium 提到 high,一次 patch 就够了:

{
  action: "config.patch",
  baseHash: "<config.get 拿到的那个指纹>",
  raw: `{ "agents": { "defaults": { "subagents": { "thinking": "high" } } } }`,
}

这里还藏着一道挺关键的安全设计:config.patch / config.apply 并不是想改什么就能改什么,它们背后有一道安全守卫,专门拦那些会削弱小龙虾自身安全的改动。比如关掉沙箱、放开插件加载、改写 Gateway 的鉴权 token、去掉 web_fetch 的 SSRF 防护,当然也包括前面 exec 那节讲的 tools.exec.asktools.exec.security 那两个开关。换句话说,小龙虾可以借 gateway 给自己做点微创手术,但没办法通过改配置悄悄突破自己的安全防线,这道兜底写死在代码里,谁也绕不过去。

媒体生成

这一类让小龙虾造出图片、视频、音乐和语音,一共五个工具:image 是看图(分析理解一张已有的图),image_generate 生成或修改图,video_generate 生成视频,music_generate 生成音乐,tts 把文字读成语音。

这一组工具参数都不少,但骨架高度一致,我们不妨对照着看。先看「看图」和「文字转语音」这两个最简单的:

image_tts.png

image 是这五个工具里唯一读而不写的,其余四个都在产出新东西,它却反过来,输入一张已有的图,让小龙虾看懂里面有什么。参数 prompt 表示你想知道什么(比如:这张图里有几个人,把发票上的金额读出来),参数 imageimages 表示图的本地路径或 URL(一次最多塞 20 张,方便让它对比着看几张),参数 model 只在你想指定某个视觉模型时才填,不填就用自动探测到的那个。

tts(text to speech,文字转语音)则是把一段文字读成语音。必填的只有 text,也就是要念的那段话;另一个 channel 表示发到哪个通道,OpenClaw 根据这个自动匹配输出格式,必要时还用 ffmpeg 转码一道,因为各家 IM 认的格式不一样,大致分三档:支持语音条的通道(Telegram、飞书、WhatsApp、Matrix 等)优先用 Opus(48kHz,裹在 Ogg/WebM 里,听感好、体积小),普通通道发 MP3(约 44.1kHz / 128kbps),Talk、电话这类实时语音通道则要原始 PCM(或 Gradium 那种 ulaw 8kHz),此外本地 CLI、Gemini、xAI 等还能产出 WAV,这些你基本不用操心,填对 channel 它自己会挑。最后还有一点值得留意:tts 是明示意图才触发的,平时聊天默认还是回文字,只有你明确说「读出来」、或者用了 /tts 斜杠命令、或者开了 Auto-TTS 模式,它才会输出语音,不会动不动就给你来段朗读。

再看三个「生成」类工具,它们骨架相通:都用 action 切换模式,prompt 给提示词,model 指定模型,timeoutMs 设置超时。但生成的东西不一样,各自的专属参数也不一样。

先是 image_generate(生成或编辑图片),关键在尺寸、张数和图片专属的质量、格式、背景等参数:

image_generate.png

video_generate(生成视频)的重头是参考素材和视频特有的时长、宽高比等参数,它支持文生视频、图生视频、视频生视频三种玩法:

video_generate.png

music_generate(生成音乐)则换成歌词、纯伴奏这类音乐专属的参数:

music_generate.png

这几个工具在使用之前,都得先配好至少一个对应的 provider 才行。四个生成型工具的 provider 可选如下:

  • image_generate:OpenAI(gpt-image 系列)、Google(Gemini 图像)、xAI(Grok Imagine)、fal、DeepInfra(FLUX)、MiniMax、OpenRouter、ComfyUI、Vydra 等。
  • video_generate:OpenAI(Sora)、Google(Veo)、Runway、MiniMax(Hailuo)、阿里(Wan)、字节 BytePlus(Seedance)、Qwen、fal、xAI、Vydra 等十几家。
  • music_generate:就 Google(Lyria)、MiniMax、ComfyUI 三家。
  • tts:OpenAI、ElevenLabs、Google、Azure Speech、MiniMax、火山引擎、Inworld、小米 MiMo,外加免 key 的微软 Edge 神经语音和本地 CLI 等。

前三个工具的配置相似,都在 agents.defaults.<X>GenerationModel 下写 primary(首选)和 fallbacks(依次兜底),取值用 provider/model 的形式:

{
  agents: {
    defaults: {
      imageGenerationModel: {
        primary: "openai/gpt-image-2",
        fallbacks: ["google/gemini-3.1-flash-image-preview", "fal/fal-ai/flux/dev"],
      },
      // videoGenerationModel、musicGenerationModel 同理
    },
  },
}

tts 略有不同,配在 messages.tts 下,可以挂好几个 provider,再用 provider 指定默认那家:

{
  messages: {
    tts: {
      provider: "openai", // 默认用哪家;没配则按已配好的挨个兜底
      providers: {
        openai: { apiKey: "${OPENAI_API_KEY}", model: "gpt-4o-mini-tts", voice: "alloy" },
        elevenlabs: { apiKey: "${ELEVENLABS_API_KEY}", voiceId: "EXAVITQu4vr4xnSDxMaL" },
      },
    },
  },
}

到底用哪家 provider,OpenClaw 有一套自己的优先级规则:调用时 model 参数写死的最优先,且不再往下兜底;其次是配置里的 primaryfallbacks;如果两样都没指定,还会走一遍 自动探测,把配了 API key 的 provider 按固定顺序挨个试,第一个能用的就用它(这个自动兜底可以用 agents.defaults.mediaGenerationAutoProviderFallback: false 关掉)。想知道当前手头到底有哪些可用,可以通过 action: "list" 列出 provider 清单。

最后说回第五个 image(看图)工具。前面说过它不算生成而是理解,配置也和上面四个不一样:不在 agents.defaults 下,而是在 tools.media 里按能力(image / audio / video)各列一组 models,每个条目指明用哪个 provider/模型来「读」媒体,列多条就是按序兜底(一条失败、或媒体太大超限,就换下一条):

{
  tools: {
    media: {
      image: {
        models: [
          {
            type: "provider",     // 也可以是 "cli",跑一条本地命令来看图
            provider: "openai",
            model: "gpt-5.5",
            prompt: "用不超过 500 字描述这张图",
            maxChars: 500,        // 摘要最多多少字
            timeoutSeconds: 60,
          },
          // 再列一条,就是下一顺位的兜底
        ],
      },
    },
  },
}

要是你压根没配 tools.media 参数,OpenClaw 也有一个自动探测机制,如果当前回复用的模型本身带视觉能力,就直接拿它来看图。

这里你可能会犯嘀咕:tools.media 支持配置 image / audio / video 三种能力,为什么内置工具里却只有 image 一个,没有 audiovideo 呢?这是因为 tools.media 配的其实是「媒体理解管线」,而不是工具。用户发进来的附件,会在 agent 开始干活之前先被它自动消化一道:图片转成描述、音频转写成文字、视频生成摘要,三种能力都覆盖,模型直接读到消化好的文字即可,不用主动调工具。而模型能主动调的「读媒体」工具,目前只给了 image 一个,我猜是因为主动看图这种需求最独立、最常见(比如分析一个刚抓到的图片 URL、或带个具体问题把某张图重看一遍);音频、视频的理解基本都发生在「用户发来语音或视频」这条入站通道上,直接处理即可,也就不必再单列成可调用的工具了,当然也不排除后面 OpenClaw 将这些能力也变成内置工具。

provider 和配置说完了,最后再补两条容易被忽略的注意事项:

  1. 这些工具的调用会自动区分同步还是异步,这关系到结果什么时候回来。同步的(图片生成、TTS)当场就出,跟着这一轮回复一起给你;异步的(视频和音乐)因为生成慢,会先告诉你「在做了」,等后台跑完再把成品单独推回来;
  2. 这些工具只有在你至少配了一个对应的 provider 时,才会出现在小龙虾能看到的工具清单里。如果没配图片 provider,模型压根看不到 image_generate 这个工具的存在,自然也就不会、也没法去调它。所以如果你发现「让它画图它说不会」,那么多半是 provider 没配。

子 agent 编排

这一类就是小龙虾把任务拆分后交给「分身」并行干,我们在前面「多 agent 与子 agent」那篇已经学习过。它包含一组 sessions_* 开头的工具(派生、列会话、读历史、发消息、收结果)、管理已派出去分身的 subagents、以及列出可用 agent 的 agents_list。其中派活儿的主力是 sessions_spawn,先看它的核心参数:

sessions_spawn.png

配套的 subagents 工具用来管理已派出去的子 agent:

subagents.png

剩下几个都比较简单:

  • sessions_list:列出自己派生的那些子会话,默认谁派生的归谁看,也可按 agentId、标签(label)、关键词(search)筛选,还能用 limitactiveMinutes 限定条数和最近多久活跃过。
  • sessions_history:读某个会话的历史。sessionKey 指定读哪个会话,limit 控制取最近几条,includeTools 决定要不要把工具调用消息也带上。
  • sessions_send:往某个会话发条消息。用 sessionKey / label / agentId 任选一种方式指定目标,message 是要发的内容,timeoutSeconds 设定回复的超时时间。
  • sessions_yield:主动结束当前这一轮、把发言权让出去,自己先歇着,等某个子 agent 的结果作为下一条消息推回来时再被唤醒接着处理。
  • agents_list:列出有哪些 agent 可用。
  • session_status:轻量回读一个会话的状态,相当于 /status 命令,看它当前用的什么模型、用量、时间、可用时还有花费,以及挂在它名下的后台任务。两个参数都可选:sessionKey 指定看哪个会话(传 current 表示当前),model 还能临时修改这个会话的模型(传 default 恢复默认)。

我们挑最核心的 sessions_spawn 工具,详细看看它的几个设计点,这些我们在之前的文章中也学习过,这里复习一下。

最要紧的是隔离。每个子 agent 默认都在一个全新的会话里起步,有自己独立的上下文,有自己的记忆和对话历史,它不知道你和主 agent 之前聊过什么。可以将 context 设置成 fork,这样 OpenClaw 才会把当前这条对话分叉,复制一份给它当起点,不过要注意这样 token 消耗会明显增大,只有那种必须依赖当前对话才说得清的任务才建议开这个参数。

然后任务派出去之后,结果是推送式送回来的,不会阻塞主会话。调一次 sessions_spawn 会立刻返回一个 run id(这次任务的编号),子 agent 随后在后台慢慢干,干完会主动把结果推送回发起方所在的通道。所以派完任务就可以去忙别的,不用反复查 sessions_list 轮询结果。

还有一点,默认情况下,子 agent 不能再派生自己的子 agent,防止无限繁殖。maxSpawnDepth 默认是 1,只允许「主 → 子」这一层。把它调到 2,才解锁所谓的编排者(orchestrator)模式:主 agent 先派一个编排者子 agent,编排者再把活儿拆给一批 worker 并行干,最后结果逐层往上汇总(worker 报给编排者、编排者汇总后报给主 agent,每一层只看得见自己直接下级)。再往上调到 3、4、5 会逐层加深嵌套(上限是 5),只是层数越深越烧 token、链路越难排查,实际很少用得着。另外,OpenClaw 会按照深度分配工具权限,只有编排者那层才拿得到 sessions_spawn 这类调度工具,最底层的 worker 一律不给,从机制上就堵死了它继续往下套娃的可能。配额上也有几道兜底:单个 agent 同时最多挂 5 个活跃子 agent(maxChildrenPerAgent)、全局并发上限 8 条(maxConcurrent)、每个 spawn 默认 900 秒超时(runTimeoutSeconds),这些都集中在配置文件的 agents.defaults.subagents 这块下面:

{
  agents: {
    defaults: {
      subagents: {
        maxSpawnDepth: 2,        // 允许的嵌套层数,默认 1(不嵌套),上限 5
        maxChildrenPerAgent: 5,  // 单个 agent 同时最多挂几个活跃子 agent,默认 5
        maxConcurrent: 8,        // 全局并发上限,默认 8
        runTimeoutSeconds: 900,  // 每个 spawn 的默认超时(秒),0 表示不超时
        model: "openai/gpt-4.1-mini", // 子 agent 默认用的模型,不写就继承主 agent
        thinking: "low",         // 子 agent 默认思考强度,同样不写就继承
      },
    },
  },
}

这些是默认值,写在 agents.defaults 下对所有 agent 生效;想给某个 agent 单独设,挪到 agents.list[].subagents 下即可。

最后留意一下 cleanup 的语义,它表示子 agent 运行结束后这个会话留不留:默认 keep 留着,delete 则是在推送完结果后清理,这个清理其实是归档而非物理删除,会话记录会被改名留存,事后照样能翻出来看。另外,子 agent 干活期间开的浏览器标签和进程,在它跑完时也会被尽力关掉,不至于留一堆孤儿进程在后台。

所谓改名留存,就是给会话记录那个 .jsonl 文件名末尾加一个「原因 + 时间戳」后缀。归档原因一共三种:.deleted. 是被主动清理掉的(对应这里的 cleanup: "delete")、.reset. 是会话被重置(对应用户手动执行 /clear 命令)、.bak. 是备份。

最后的最后,我们再看看 runtime 参数,小龙虾在派任务时不只能派给自己的子 agent,通过 runtime: "acp" 模式,小龙虾还能将任务丢给 外部的编码工具,比如 Claude Code、Gemini CLI、Codex、OpenCode 这些。不过严格说这已经不算内置工具了(得额外装官方插件 @openclaw/acpx),细节也自成体系,后面有机会再单开一篇吧。

工具治理

讲了这么多工具,接下来看看怎么管它们。道理很简单:不是每个 agent 都需要全量工具,一个只在群里闲聊的客服 agent,没必要能 exec 跑命令,更没必要能 gateway 改配置。OpenClaw 为此提供了几层控制,我们从最熟悉的说起。

最直接的两个配置是 tools.allowtools.deny,前面几篇配 agent 时已经反复用过:allow 是白名单、deny 是黑名单,黑名单永远压过白名单,同一个工具两边都写了,最终结果就是禁用:

{
  tools: {
    allow: ["group:fs", "browser", "web_search"],
    deny: ["exec"],
  },
}

正如之前学过的,allow / deny 里不必一个个工具地列,可以用 group:* 简写批量指定。常用的组有:

包含的工具
group:runtimeexec, process, code_execution
group:fsread, write, edit, apply_patch
group:webweb_search, x_search, web_fetch
group:uibrowser, canvas
group:sessionssessions_list, sessions_history, sessions_send, sessions_spawn, sessions_yield, subagents, session_status
group:memorymemory_search, memory_get
group:automationcron, gateway
group:messagingmessage
group:nodesnodes
group:agentsagents_list
group:mediaimage, image_generate, music_generate, video_generate, tts
group:openclaw所有内置工具(不含插件工具)

另外,光用 allow 从零开始一个个往上加,配起来还是有点累。所以 OpenClaw 还有一个 tools.profile 参数:它先按用途给你预设一套工具集合当起点,加工具用 tools.alsoAllow,砍工具用 tools.deny。内置的 profile 有四档:

  • full:所有核心和插件工具,最宽的基线。
  • coding:文件、运行时、web、会话、记忆等组,外加 cronimage/image_generate/music_generate/video_generate(注意:不含 tts,也不含 browser)。
  • messaging:消息组加几个会话工具,通道 agent 专用。
  • minimal:只有 session_status

这四档是写死的,profile 是个固定枚举,暂时没法自己新增一个 profile,也改不了某档里到底含哪些工具。想要一套专属工具集就挑个最接近的 profile 当基线,再用 alsoAllow / deny 做加减。

小结

上一篇我们学习了前四类工具,命令执行、网络访问、文件读写、消息收发。这一篇接着把剩下的四类工具和工具治理讲完了,简单回顾一下:

  1. 设备控制nodes 是台「设备遥控器」,一个工具横跨 Device(身份、准入)和 Node(能力清单)两层;canvas 配合它在某台设备的屏幕上「画东西」,两者底层都转成 camera.snaplocation.getcanvas.snapshot 这类节点命令派给目标 node 执行。
  2. 运行时管理cronschedule(何时跑)+ payload(干什么)+ delivery(结果送哪)三块拼出定时任务;gateway 仅 owner 可用,能查看 / 修改 / 重启 / 自升级自己的运行时配置。
  3. 媒体生成image 看图、image_generate 生成或修改图、video_generate 生成视频、music_generate 生成音乐、tts 把文字读成语音;用之前记得先配 provider,没配 provider 工具压根不出现,慢活儿(视频、音乐)还会丢后台异步跑。
  4. 子 agent 编排sessions_spawn 把任务拆给隔离的分身并行干,完成后推送式把结果送回。
  5. 工具治理:用 tools.allow / tools.deny 圈定某个 agent 能用哪些工具,工具名还能用 group:* 简写批量指定;嫌从零配麻烦,就用 tools.profile 选一套预设基线,再拿 alsoAllow 往上加、deny 往下减。

现在小龙虾手里有哪些工具,哪个工具能用,哪个工具不能用,我们都清楚了。其中有两个工具细节多,坑也深,因此后面准备花两篇分别学习下:下一篇专讲 browser 浏览器工具,再下一篇讲 ACP 工具。敬请期待~

参考


给小龙虾配齐工具箱:OpenClaw 的工具体系

在上一篇的最后,我们举了一个场景,对着手机喊一句让小龙虾查附近的咖啡馆,它先调 location.get 拿坐标、再用 web 搜索查咖啡馆、最后把结果合成语音念回来。这一连串动作里,location.get、web 搜索、语音合成,其实都是 OpenClaw 里的工具。除此之外,小龙虾的工具箱里还有很多内置工具,我们今天就来系统的学习一遍。

工具 vs. 技能 vs. 插件

在正式学习之前,我们先看下 OpenClaw 里三个容易混淆的概念:

  • 工具(Tool)是 agent 实际调用的东西。一个工具就是一个带类型签名的函数,比如 execbrowserweb_searchmessage。OpenClaw 自带一批内置工具,还能通过插件注册新工具。对模型来说,工具是以结构化函数定义的形式发给模型 API 的。
  • 技能(Skill)教 agent 什么时候、怎么用工具。它是一份注入到 system prompt 的 SKILL.md,给 agent 提供上下文、约束和分步指引。
  • 插件(Plugin)把这些打包到一起。一个插件可以同时注册通道、模型 provider、工具、技能等任意组合。

三者的关系大致是这样:

tools-vs-skills.png

简单说,工具是手脚,技能是手册,插件是打包。今天这篇只讲最底下那层手脚,手册和打包留到后面再讲。

内置工具清单

按官方文档,下面这个表格列出了 OpenClaw 所有的内置工具:

工具干什么
exec跑 shell 命令
process管后台进程
code_execution跑沙箱化的远程 Python 分析
browser控制 Chromium 浏览器(导航、点击、截图)
web_search搜网页
x_search搜 X 帖子
web_fetch抓页面内容
read读工作区内的文件
write写工作区内的文件
edit编辑工作区内的文件
apply_patch多块(multi-hunk)文件补丁
message把消息发到各个通道
canvas驱动 node 上的 Canvas(演示、求值、截屏)
nodes发现并指定已配对的设备
cron管定时任务
gateway查看、改配置、重启或自更新网关
image分析图片
image_generate生成或编辑图片
music_generate生成音乐
video_generate生成视频
tts一次性文本转语音
memory_search按语义/关键词检索记忆笔记
memory_get读取指定记忆文件或行区间
sessions_*列出会话、读会话历史、给别的会话发消息、派生子会话
subagents子 agent 编排
agents_list列出可用 agent
session_status轻量的 /status 式回读,可临时切换会话模型

这张表看着很多,其实可以分为八类:命令执行、网络访问、文件读写、消息收发、设备控制、运行时管理、媒体生成、子 agent 编排。下面我们一类一类来看,重点挑前面几篇文章里反复出现过的工具展开讲。每个工具我都先附一张参数表,直接从 OpenClaw 源码里的 schema 定义整理出来的,列清楚它收哪些参数、每个参数什么意思、哪些必填。看懂一个工具的参数表,基本就摸清了它能干什么、边界在哪。你会发现,决定一个工具好不好用、安不安全的,往往不是它能干什么,而是那些藏在参数里的细节。第一次接触的话,不用每个参数都背下来,先扫一眼有个印象,建立「哪类活找哪个工具」的直觉就够了。

命令执行

这一类管的是「让小龙虾运行命令或代码」,一共三个工具:exec 在你的机器上跑 shell 命令,几乎是整个工具箱里用得最多的一个:装依赖、跑测试、提交代码,几乎都靠它;processexec 配对,专管那些转入后台还没退出的命令;code_execution 则只需你说清「要算什么」,由 xAI 的 Grok 自己写 Python 并在远程沙箱里跑,全程不碰你的机器。下面一个个看它们的参数。

先看 exec 的参数定义:

exec.png

一眼看过去,必填的只有 command 一个,丢一条命令进去就能跑。不过剩下那一长串可选参数,才是这个工具真正的门道所在:

  • 跑在前台还是后台? 默认前台跑,命令执行完结果就回来了。但有些命令(比如启动一个开发服务器)会一直挂着不退出,总不能让小龙虾干等着。所以 OpenClaw 设了个超时:默认跑超过 10 秒(参数 yieldMs)就自动转后台,先把控制权交还给对话,命令继续在后台跑。你也可以一上来就 background: true 直接丢后台。命令转后台之后,再想看它输出了什么、给它敲个回车或粘点东西进去,就得换用配套的 process 工具,它的 actionpoll(看最新输出)、send-keys(发按键)、submit(回车)、paste(粘贴)四种。可以把这一对工具想象成:exec 开一个会话,process 是事后回头去操作这个还开着的会话。这些后台会话还按 agent 各管各的,A 这个 agent 看不见 B 的后台进程,互不打扰。
  • 要不要一个伪终端? 有些命令行程序很挑剔,只肯在真正的终端里跑,检测到自己不是终端就罢工或者输出乱掉(典型的是带交互界面的 TUI 工具或者编码类 agent)。这时给 exec 加上 pty: true,OpenClaw 会造一个 伪终端(pty,pseudo-terminal),让它以为自己跑在真终端里。一般命令是用不到的。
  • 命令在哪台机器上跑? 这是 host 参数,它决定了命令的执行环境,共四个取值:sandbox(隔离沙箱)、gateway(OpenClaw 主程序所在的机器)、node(某台配对的设备,比如你的手机或另一台 Mac)、auto(有沙箱就进沙箱,没有就落到网关)。
  • 要不要先问过你? 这由 securityask 两个参数控制。security 管「允许跑哪些命令」:deny(基本不让跑)、allowlist(只放行白名单里的)、full(不限制)。ask 管「跑之前要不要确认」:off(不确认)、on-miss(不在白名单里才确认)、always(每次都确认)。沙箱里跑的代码本就被当作不可信,所以在隔离之外再叠一层最严的 deny;而网关和 node 是机主自己的机器,OpenClaw 默认假设你想在自己机器上丝滑运行,于是给了 full + ask=off,也就是所谓的 YOLO 模式,它是明知有风险仍为了顺手而放开,所以放到真实机器上跑时尤其要留神这组配置。

配套的 process 工具参数定义如下:

process.png

这里比较有意思的是,send-keys 动作有 keys / literal / hex 三个参数,OpenClaw 将「往终端里送一段输入」抽象为三个层次,由高到低分别是:

  • keys(按键 token):用一个符号名字代表一次按键,而不是字面文本。像「回车」「Ctrl-C」「方向键上」这些按键根本不是可打印字符,回车是字节 \r、Ctrl-C 是字节 0x03、方向上键是转义序列 ESC [ A,没法直接当文本传给终端,所以 keys 接受 token,由 OpenClaw 翻译成终端真正认的字节。支持的写法大致三类:具名键(entertabescapeupbackspacef1 等)、Ctrl 组合(^c 即 Ctrl-C)、修饰键前缀(c- Ctrl / m- Alt / s- Shift,如 c-cm-xs-tab)。

    • literal(原样字符串):直接发、不翻译,你写什么就送什么字符。适合往程序里「打字」,比如填一句 hello world、回答一个文件名。注意它不会自动帮你按回车,要回车得再配 keys: ["enter"]
    • hex(十六进制字节):最底层,自己一个字节一个字节地拼,比如 ["1b","5b","41"] 就是 ESC [ A(方向上键)。token 表达不了的冷门控制序列才用得到,平时基本碰不上。

三者可以在同一次 send-keys 调用里一起传,OpenClaw 按 literalhexkeys 的固定顺序拼成最终要写入的字节流。常见组合就是「先 literal 打一段文本,再 keys: ["enter"] 敲回车」。

这个工具和 exec 是天生一对:exec 起一个会话,processsessionId 认准那个还开着的后台会话,再用 action 去操作它。

第三个工具是 code_execution,它的名字和 exec 长得很像,但是它干的却是完全不同的活。它的参数定义简单到只有一个:

code_execution.png

exec 跑的是你机器上的 shell,要你自己给出确切命令;code_execution 则不用你写代码,你只给一句话描述「要算什么」,由 xAI 的 Grok 自己写出 Python 并在它的远程沙箱里跑,再把结果回给你,全程不碰你的电脑、文件和代码仓库。使用这个工具的典型场景包括,精确数值计算、复杂数据分析、金融建模、科学计算、生成并实测一段代码等。

值得注意的是,这里的沙箱并不是 OpenClaw 自己的沙箱,而是直接套用了 xAI 官方的 Code Execution 工具

xai-code-execution-tool.png

按官方的说法,Code Execution 工具让 Grok 能实时编写并执行 Python 代码,代码跑在一个沙箱化的 Python 环境里,预装了 NumPy、Pandas、Matplotlib、SciPy 这些常用库。code_execution 工具本质上就是对这个接口的封装,它注册在 xai 插件里,默认用 grok-4-1-fast 模型。

网络访问

这一类是小龙虾「上网查东西」的本事,一共四个工具:web_search 搜网页、x_search 搜 X 帖子、web_fetch 抓某个具体网页、browser 真的开一个浏览器像人一样操作。前三个是轻量级工具,本质是「发送 HTTP 请求获取结果」,速度快、省资源、不调用浏览器,绝大多数查资料的活,使用这三件套就够了;只有遇到要登录、要点按钮、内容靠 JS 渲染的网站,才需要动用 browser 工具。

我们先看 web_search 的参数定义:

web_search.png

web_search 能传的参数也比想象中多,比如 freshness(只要最近一天/周/月/年的结果)、country/language(限定国家语言)、date_after/before(限定日期范围),还有 Perplexity 专属的 domain_filter(只在指定网站里搜)等等。

另外,使用 web_search 必须提前配置好至少一个搜索引擎的 provider,支持的 provider 如下:

  • Brave —— 结构化结果带摘要,支持国家/语言/时间过滤,有免费额度;环境变量 BRAVE_API_KEY
  • Perplexity —— 有两条路:直连 PERPLEXITY_API_KEY 走原生 Search API,返回结构化结果、支持域名/时间/语言等过滤;或用 OPENROUTER_API_KEYsk-or- 开头)经 OpenRouter 调 Perplexity 的联网模型 Sonar,拿到带引用的合成答案,但那些结构化过滤用不了。
  • Exa —— 关键词+语义 搜索,带内容抽取(highlights 相关摘录 / text 正文全文 / summary AI 摘要);环境变量 EXA_API_KEY
  • Tavily —— 结构化结果,带搜索深度、主题过滤,还配套一个 tavily_extract(相当于 Tavily 版的批量抓正文工具,一次能抽 1~20 个 URL,比 web_fetch 一次一个更省事,常用来把搜到的一批链接一口气捞回正文);环境变量 TAVILY_API_KEY
  • Firecrawl —— 结构化结果,擅长配合 firecrawl_search / firecrawl_scrape 做深度抽取;环境变量 FIRECRAWL_API_KEY
  • Gemini —— 对接 Google 搜索接口,返回带引用的 AI 合成答案;环境变量 GEMINI_API_KEY
  • Grok —— 对接 xAI 的 web 搜索接口,返回带引用的 AI 合成答案;环境变量 XAI_API_KEY
  • Kimi —— 走 Moonshot 网络搜索,返回带引用的合成答案(注意:若退化成无搜索的纯聊天会显式报错);环境变量 KIMI_API_KEY / MOONSHOT_API_KEY
  • MiniMax —— 走 MiniMax Token Plan 搜索 API,结构化结果;环境变量 MINIMAX_CODE_PLAN_KEY / MINIMAX_CODING_API_KEY / MINIMAX_OAUTH_TOKEN
  • DuckDuckGo —— 无需账号和 key,非官方 HTML 集成(没有官方 API,靠抓取并解析 DuckDuckGo 网页拿结果,脆且易被限流,仅作保底),最常用的兜底。
  • Ollama Web Search —— 走你本地登录的 Ollama 主机,或配 OLLAMA_API_KEY 调托管的 ollama.com
  • SearXNG —— 自建的元搜索,聚合 Google/Bing/DuckDuckGo 等,需配 SEARXNG_BASE_URL

三种配置方式:

  1. 命令行向导(最省事):跑 openclaw configure --section web,它会引导你选 provider 并存好凭证。
  2. 环境变量:直接设上面列的那个变量(如 export BRAVE_API_KEY=...),API 类 provider 就能用了,连配置文件都不用动。
  3. 配置文件(最可控):在 tools.web.search 里指定用哪家,key 放在对应插件的 plugins.entries.<plugin>.config.webSearch.apiKey 下:
{
  "tools": {
    "web": {
      "search": {
        "enabled": true,        // 默认就是 true
        "provider": "brave",    // 钉死用哪家;省略这行就走自动探测
        "cacheTtlMinutes": 15,  // 同一搜索词的缓存时长(分钟)
      },
    },
  },
  "plugins": {
    "entries": {
      "brave": { "config": { "webSearch": { "apiKey": "YOUR_KEY" } } },
    },
  },
}

如果你不写 provider(即自动探测),OpenClaw 会按一个固定优先级自己挨个试:先试配了 key 的,顺序是 Brave → MiniMax → Gemini → Grok → Kimi → Perplexity → Firecrawl → Exa → Tavily;一个能用的都没有,才退回到免 key 的 DuckDuckGo → Ollama → SearXNG。

同样的搜索词 15 分钟内再搜会直接给缓存结果(这个时长就是上面的 cacheTtlMinutes),省得重复花钱。

再来看下 x_search 的参数定义:

x_search.png

x_search 专门搜 X(推特)上的帖子,它和前面的 code_execution 类似:同样由 xai 插件注册,同样通过 xAI 的 Responses API(https://api.x.ai/v1/responses)调用,由 Grok 在 xAI 那边执行。区别只在带的服务端工具不同,code_execution 带的是 code_interpreterx_search 带的是 xAI 官方的 X Search 工具,默认模型 grok-4-1-fast-non-reasoning

x_search 返回的是一段带引用出处的合成答案(不是一堆原始链接)。如果你想要某条帖子精确的转发数、点赞数这类细粒度数据,最好分两步走,先用关键词搜,搜到那条帖子,拿到它的链接,再用这条确切的帖子 URL 跑第二次 x_search。因为一次宽泛的关键词搜索通常能帮你「找到」帖子,却给不全单条帖子的精确数据。

接下来是 web_fetch 的参数定义:

web_fetch.png

web_fetch 是给定一个具体网址,然后把那一页的内容抓回来。它默认不依赖任何 provider,开箱即用,自己发一个普通 HTTP GET,再用 Readability(网页主内容抽取)把正文扒出来,转成 markdown 或纯文本。

Readability 是 Mozilla 开源的一个网页正文提取库(Firefox「阅读模式」用的就是它):丢一个 HTML 页面进去,它会按一套启发式规则把导航栏、广告、侧边栏、页脚这些噪音剥掉,只留下文章主体(标题 + 正文)。web_fetch 用它把抓回来的原始 HTML 清洗成干净的 markdown/纯文本,省得把整页杂七杂八的标签都塞给模型。

当 Readability 抽取失败了(页面被反爬、结构太怪),你也可以配置 provider 再抓一次,当前内置的 provider 只有 Firecrawl 一个(可通过插件扩展)。它的配置如下:

{
  "tools": {
    "web": {
      "fetch": {
        "provider": "firecrawl",  // 可省略,省略则按凭证自动探测
      },
    },
  },
  "plugins": {
    "entries": {
      "firecrawl": {
        "enabled": true,
        "config": { "webFetch": { "apiKey": "fc-..." } },  // 或设 FIRECRAWL_API_KEY
      },
    },
  },
}

关于 web_fetch,它还有一道独立的安全防护,叫 SSRF 防护。SSRF 全称服务端请求伪造,它的问题根源在于 web_fetch 要抓的那个 URL 是不可信的外部输入,它可能来自搜索结果、模型读到的某段网页内容、用户的一句话,甚至是别人通过提示注入诱导模型去填的 URL。攻击者只要能影响这个 URL,就能让小龙虾以服务器自己的身份去访问本不该碰的地址,比如公司内网的机器、本机回环地址(127.0.0.1)、或者云服务器上那个一访问就能读到密钥的「元数据接口」(169.254.169.254)。更阴的一招是用一个看着人畜无害的公网链接,靠 30x 跳转把请求重定向到内网地址。web_fetch 默认就把这些私网、环回、元数据地址全挡掉,并且对重定向也逐跳重新校验,除非你明确放行。

169.254.169.254 是一个特殊的保留 IPv4 链路本地地址(Link-local address),几乎所有的云计算平台(如 AWS EC2、Azure、阿里云等)都将其作为 实例元数据服务(Instance Metadata Service, IMDS)。它允许虚拟机从其主机获取配置信息、安全凭证等,且该地址仅在局域网内有效,不可路由到外网。正因为它的特殊性,所以它往往是 SSRF 攻击的头号目标,攻击者只要能让服务器替自己请求这个地址,往往就能直接拿到云凭证、进而接管你的云上资源。

除了这三个轻量级工具,我们还可以使用 browser 进行网络访问,它会真的开一个 Chromium 浏览器,像人一样去操作网页。这个工具能力最强,风险也最高,比较复杂,我们放到下一篇专门学习。

文件读写

这一类管的是小龙虾在工作区里读写文件,一共四个工具:read 读文件、write 整篇覆盖写、edit 按原文精确替换地改、apply_patch 把多文件多处改动打成一个补丁一次性提交。

我们先来看下这四个工具的定义:

read_write_edit_apply_patch.png

前三个比较好理解,它们能力递进,参数从少到多:read 只要个路径;write 多一个 content,是「整篇覆盖」;editedits 是一组 {oldText → newText},并要求 oldText 在文件里唯一,并且各处替换互不重叠,所以它比 write 安全,因此日常编辑文件或修改代码,几乎清一色用 editwrite 一般只在新建文件时才上场。

另外 read 还有一个细节,它默认最多读 2000 行或 50KB,先到为准,如果拿来读长日志可能会被截断,这时候得搭配 offset / limit 分段读。

最后的 apply_patch 参数也很简单,只有一个 input,但它是这一组里功能最强、也最复杂的一个,门道全在这段字符串的格式里。先解释一个词:hunk(块)。我们改一个文件,往往不是只动一处,而是东一段西一段,好几处不相邻的改动。程序员之间习惯用一种叫 diff 的文本格式来记录这个文件改了什么;这份文本拿去给工具自动应用,就叫 补丁(patch)。在这种格式里,每一段连续的改动就被叫作一个 hunk。edit 一次只能改一处,要改五处就得调五次,文件一大很容易对不上行,改错地方。apply_patch 的思路是:把「这个文件第几段删什么加什么、那个文件又改哪里、再删掉某个文件」全写进一段补丁文本里,一次性提交。它接受的 input 参数长这样:

*** Begin Patch
*** Add File: 新文件路径         # 新建文件
*** Update File: 已有文件路径    # 改已有文件,下面可跟多个 @@ 块
*** Move to: 新文件路径          # 可选:顺带给这个文件改名
@@
-旧的一行
+新的一行
*** Delete File: 要删的文件      # 删文件
*** End Patch

可以看到,一个补丁里能同时 Add / Update / Delete 多个文件,每个文件里还能有多个 hunk,Update 时跟一行 Move to: 还能顺带对文件进行改名。

这套格式初看和 git diff 很像,但要注意的是,它并不是标准的 git diff 格式,它是 OpenAI 自己定的一套语法,外面套着 *** Begin Patch*** End Patch@@ 后面也不带行号(标准 diff 是 @@ -10,7 +10,7 @@,模型容易数错),改用上下文行来定位。也正因为它是 OpenAI 阵营的格式,所以只有 OpenAI 家的模型被专门训练成会输出这种补丁,所以 apply_patch 在 OpenClaw 里默认只对 OpenAI / OpenAI Codex 模型开启,换成其他模型效果可能会不太理想。

消息收发

这一类只有 message 一个工具,它是小龙虾跟各个通道(Telegram、飞书等各种 IM)打交道的总接口,也是整个工具箱里参数最多的一个。它的 schema 是按「动作」动态拼出来的,光 action 一个枚举就有五十多个值:send(发消息)、reply(回复)、react(贴表情)、poll(发起投票)、pin(置顶消息)、thread-create(建话题)、kick(踢人)、ban(封禁)……几乎一个 IM 机器人能干的事都归它:

message-actions_01.jpg

这里只挑最常用的几个参数看下:

message.png

还剩下几十个参数都是某个具体 action 才用得到的(比如建话题、改群名、踢人),运行时还会按当前通道的能力过滤。我们暂时只要知道 message 是个按 action 分派的大工具就够了,具体动作用到时再查。

未完待续

这一篇我们先把 OpenClaw 工具体系的地基和前半批工具过了一遍:

  1. 三层关系:工具是 agent 实际调用的带类型函数,技能是教它何时怎么用工具的手册,插件是把这些打包到一起的箱子
  2. 内置工具分八类:命令执行、网络访问、文件读写、消息收发、设备控制、运行时管理、媒体生成、子 agent 编排,这一篇讲完了前四类
  3. 命令执行exechost(在哪跑)+ security(让不让跑)+ ask(要不要问你)三个旋钮划定边界,配套的 process 用来操作转后台的会话,code_execution 则是套了 xAI 官方的 Code Execution 工具、让 Grok 远程编写并运行 Python 脚本
  4. 网络访问:轻量三件套 web_search(认 provider,要先配)、x_search(走 xAI)、web_fetch(自带 SSRF 防护),重型的 browser 留到后面单独讲
  5. 文件读写read / write / edit / apply_patch 能力递进,日常改代码最常用 editapply_patch 用的是 OpenAI 那套补丁格式、默认只对 OpenAI / Codex 模型开启
  6. 消息收发message 一个工具按 action 分派,五十多个动作几乎涵盖一个 IM 机器人能干的所有事

剩下的设备控制(nodes / canvas)、运行时管理(cron / gateway)、媒体生成和子 agent 编排这四类工具,内容还不少,我们下一篇继续学习。


把小龙虾装进口袋:iOS / Android Node 配对

在上一篇里,我们装好并体验了 macOS app,它横跨 operator 和 node 两类身份,既能当控制台,又能把这台 Mac 的屏幕、摄像头、命令行暴露给 agent。今天我们把目光转到手机上:iOS Node 和 Android Node,两个纯 node 客户端。它们身上没有 operator 那半边,唯一的任务就是当小龙虾的远程感官,把手机的麦克风、摄像头、GPS、屏幕暴露给家里的 agent 调用。配合上一篇讲的 Gateway 远程访问,出门在外也能随时唤起家里的小龙虾。

配对 iOS Node

在 Gateway 协议里 iOS Node 的 rolenode,走的是和 Telegram bot 那种 channel 完全不同的握手路径。channel 那套叫 DM 配对(DM pairing),陌生人第一次给 bot 发消息时,bot 回一段 8 位的一次性 pairing code,你在 CLI 里 openclaw pairing approve 批准这个码,它管的是谁能跟 bot 说话。iOS Node 这套叫 设备配对(device pairing),手机带着自己的 ED25519 设备身份连上 Gateway 的 WebSocket,Gateway 挂起一条 pending 请求(用 requestId 标识),你在 CLI 里 openclaw devices approve 批准这台设备,Gateway 随即签发一个会轮换的 token,它管的是哪台设备能进网络。这套握手流程里还有另一组容易混的词,Node 和 Device,我们先了解下它们的区别。

Node vs. Device

明明连进来的是一台 Node(iOS 这台手机),批准用的却是 openclaw devices approve,查状态又换回 openclaw nodes status。它俩在 OpenClaw 里是上下两层:

  • Device(设备身份) 管这是谁。可以把它理解成这台设备的身份证。设备自己生成一对 ED25519 密钥,由公钥算出一个 deviceId 当唯一编号,再配一份授权记录,记下它被谁批准过、能用哪些角色。Gateway 靠它做身份识别和准入:没见过的 deviceId 必须经操作员批准,批准后按角色发 token(这个 token 可以随时换发新的、把旧的作废,也可以直接吊销,但不管怎么换都不能超出配对时批准的 role / scope 范围)。一个 Device 还能同时持有多个角色的 token,比如上一篇那台 Mac,既是 operator 又是 node
  • Node(能力主机) 管它能干啥。可以把它理解成这台设备对外贴出的一张能力清单。设备以 role: node 连进 Gateway 时,会报上自己的 caps(有哪些硬件能力,比如摄像头、屏幕、定位、麦克风)和 commands(允许别人调用的命令白名单,像 camera.snapcanvas.snapshotlocation.get)。Gateway 把它记进一张运行时的 node 名册,别的客户端就能用 node.invoke 把命令派给它执行。

拿到 app

值得注意的是,iOS app 暂时还没上架 App Store,官方给它贴的标签是「极早期 alpha」,因此把它当成尝鲜,别当成生产工具。能拿到 app 的途径只有两个:

  1. TestFlight 内测:如果你在内测名单里
  2. 自己编译:根据官方文档,从源码用 Xcode 编译,我走的就是这条开发路径

首先确认几个前置工具装齐了:

  • Xcode 16+:低于这个版本工程文件可能打不开
  • Node 的 pnpm:OpenClaw 整个仓库的包管理器,iOS 这边的脚本也靠它
  • xcodegen:这个工程不把 .xcodeproj 提交进仓库,而是用一份 YAML 描述、靠 xcodegen 现场生成,所以这步不能省(brew install xcodegen 装一下)
  • Xcode 里配好 Apple 开发者签名:哪怕只是个人免费 Apple ID 也行,但得先在 Xcode 的 Settings → Accounts 里登进去

一切就绪之后,在仓库根目录依次运行如下命令:

$ pnpm install                        # 装 monorepo 依赖
$ ./scripts/ios-configure-signing.sh  # 给你这台机器生成一套唯一的本地 bundle ID
$ cd apps/ios
$ xcodegen generate                   # 用 YAML 现场生成 OpenClaw.xcodeproj
$ open OpenClaw.xcodeproj              # 用 Xcode 打开

这几步等价于一条 pnpm ios:open 快捷命令,效果一样。

工程在 Xcode 里打开后,设置如下:

  • SchemeOpenClaw
  • Destination 选你那台用数据线连上来的真机。文档明确建议用真机而不是模拟器,因为摄像头、定位、麦克风这些 node 能力在模拟器上要么没有要么是假的,跑真机才看得到真实行为
  • Build ConfigurationDebug

最后点击顶部菜单 Product → Run,编译并运行,标题栏显示 "Build Succeeded" 说明编译通过:

xcode-build.png

在编译的过程中遇到了几个问题,将报错信息丢给小龙虾,很快自己修复了。主要是 SettingsTab.swiftbody 视图嵌套过深导致 Swift 编译器类型检查超时,把它拆成多个子视图后才编译通过,另外还补了 GatewayOnboardingView.swift 缺失的 import OpenClawKit、给 DeepLinks.swift 静态方法调用加上 self. 前缀。

Run 成功后 Xcode 会把 app 直接装到那台连着的手机上,几秒之后手机桌面上就出现了那只红色的小龙虾:

openclaw-icon.png

另外个人开发者签出来的 app 证书有效期一般只有 7 天,过期了 app 会打不开,重新 Run 一次刷新即可。

配对流程

第一次点开这个图标,会先跳一个 "Welcome to OpenClaw" 引导页:

welcoma-to-openclaw.png

页面把流程拆成了三步:Connect to your gateway → Choose device permissions → Use OpenClaw from your phone,下面还有一条橙色的 Security notice,提醒连进来的 agent 可以使用你勾上的设备能力(摄像头、麦克风、照片、联系人、日历、位置等),只在你信任这个 gateway 和 agent 的时候继续。这条提醒值得认真对待。iOS Node 的所有能力命令都跑在这台手机本地,读的也是手机上的真实硬件,谁连进来就能把这台手机当远程感官。所以 Gateway 是否暴露给互联网、Gateway 后面挂的 agent 是不是你能管的,直接决定了风险有多大。

点 Continue 进入 Connect Gateway 配对页:

connect-gateway.png

页面给了两个入口:Scan QR Code(扫二维码)和 Set Up Manually(手动)。两条路最终都是把 Gateway 的 WebSocket 地址加一段一次性的 bootstrap token 喂给 app,区别只在于这段东西是用相机拍进来还是手动粘进来。

方式一:setup code(手动配)

在 WebChat 里给 agent 发一条 /pair,Gateway 会生成一个 setup code 并给出操作步骤:

pair-code.png

回复里最关键的是 Setup codeeyJ1cm...),它是一段 base64 编码的 JSON,形如 {"url": "wss://<gateway-host>", "bootstrapToken": "<single-use-token>"}。它不是设备私钥,而是一张一次性入场券,app 拿它换一个正式的 device token,正式 token 才会落进 iOS Keychain。SecurityExpires 两行合起来看,这张券只能用一次、10 分钟内有效,过期或用过就作废,得重发一条 /pair 再生成一次。最后 Gateway 那行 wss://macbook-air.tail7d2cad.ts.net,就是上一篇配 Tailscale Serve 时拿到的 magicDNS 名加 WSS,远程访问已经就绪时 setup code 里塞的就是它。

/pair 这条 slash 命令本质上是 WebChat 里的一个 operator 操作,等价于在 Gateway 主机上预签一份单次 bootstrap 凭证。它和 openclaw devices approve <requestId> 方向正好相反:devices approve 是设备先连过来挂起一个 requestId 等操作员批准,setup code 是操作员先签好凭证、设备再扫码进门。

把整段 setup code 复制下来,回到手机,进 OpenClaw 的 Settings → Gateway:

gateway-setting.png

将 setup code 粘贴到输入框中,如果 Tailscale 网络正常的话,几秒之后这一页就会变成这样:

gateway-setting-done.png

可以看到状态显示已连接。

方式二:扫二维码

这种方式的原理和第一种几乎一样,但要更简单。

在 WebChat 里发 /pair qr,agent 会把同样的 setup code 渲染成一张二维码。回到 app 的 Connect Gateway 页(或 Settings → Gateway 里的 Scan QR Code 按钮),把相机对准二维码,扫到之后 app 自动解析出里面的 {url, bootstrapToken} 并完成连接。

方式三:Bonjour + 手动批准(同子网时的另一条路)

前面两种方式对应的就是配对页上那两个入口,共同点是都得先去 Gateway 那头要一段 setup code。不过如果你的手机和 Gateway 本来就在同一个局域网里,还有第三条路,连 setup code 都省了,靠 Bonjour 让 app 自己把 Gateway 找出来。

先解释下 Bonjour 是什么。它是苹果的零配置局域网服务发现协议(苹果对 mDNS / DNS-SD 这套标准的实现),作用是让同一个局域网里的设备不用手填 IP 和端口就能互相广播、发现服务。你平时见到的 AirDrop、AirPlay、打印机自动出现在列表里、局域网里能直接访问 xxx.local 主机名,背后靠的都是它。

回到这里,Gateway 启动后会用 Bonjour 在局域网里广播一个 _openclaw-gw._tcp 服务(TXT 记录里带着 host、端口、TLS 指纹);iOS app 用 Network.framework 的 NWBrowser 浏览这个服务类型,前面那张截图里的 Settings 里那个 "Discovery: Searching..." 就是它在扫。扫到了 Gateway 就自动出现在列表里,点一下就能连,不用 setup code、也不用手填地址。选好之后 app 会发起配对请求,这时回到 Gateway 主机上批准它:

$ openclaw devices list
$ openclaw devices approve <requestId>

这种方式最大的局限是,只在同子网有效,跨网段(比如走 Tailscale 远程)mDNS 广播过不去就发现不到,那种情况只能回到方式一或方式二。

iOS Node 的能力详情

配对之后,打开 Settings 配置页,再展开 Device → Features,所有 node 能力的总开关都在这里:

device-features.png

  • Voice Wake:唤醒词模式,持续监听麦克风,当听到唤醒词后开始录音,并将识别到的文本作为一条普通消息发给 agent
  • Talk Mode:连续对话模式,唤醒词命中后不再是录音 + 转写 + 发文字,而是直接把 app 切到对话模式,这是一个 STT → LLM → TTS 的连续语音对话循环;iOS 的静默判定窗口默认 900 毫秒,且默认开 interrupt-on-speech,你在它说话时插话会自动打断播放;
  • Speech Language:转写和合成的语言,我这里选的是 中文(中国),会传给 STT/TTS 服务
  • Background Listening:允许 Voice Wake / Talk Mode 在 app 不在前台时继续监听麦克风。iOS 这块管得很严,开了也只是尽量维持、不保证一直管用,app 不在前台、或者锁屏几分钟之后,系统会切断麦克风,喊唤醒词没反应是正常的
  • Wake Words:触发词列表,自动从网关同步,可以看到就是我们上一篇在 macOS app 里的配置的
  • Allow Camera:摄像头能力总闸,和 macOS app 不一样的是,iOS 这边默认是开的,另外 iOS 有后台限制,canvas.* / camera.* / screen.* 这几类命令要求 app 在前台,切到后台调用一律返回 NODE_BACKGROUND_UNAVAILABLE,这是 iOS 系统层面的限制,OpenClaw 绕不过去
  • Location Access:三段选择器,Off 完全不开,While Using 只在 OpenClaw 在前台时可用,Always 允许后台持续读位置
  • Prevent Sleep:防止屏幕自动锁定。出门跑 voice + location 场景时开着它能减少 app 被系统挂起的概率
  • Advanced:网络、日志、Talk provider 的细参数

这一页本质上就是 iOS Node 的能力声明,和 agent 对话时,它可以通过 node.invoke 来调你手机的这些能力,你也可以通过 CLI 命令手动触发来验证。这些在上一篇已经介绍过,此处不再赘述。

配上之后的首页

把 Settings 关掉回到主视图,能看到 app 已经标记 "CONNECTED TO MACBOOK-AIR.TAIL7D2CAD.TS...":

app-home.png

下面的 Live agents 列表(main / personal / work),是我们之前创建的多个智能体,点击任意一个将它设置为这台手机当前的 Active agent,下次连同一个 Gateway 时还能记得用的是哪个 agent。

iOS app 上也内置了一个轻量 Chat 入口,发条消息测试一下:

main-chat.png

Android Node 简介

Android 的玩法和 iOS 大体一致,下面挑几处不一样的地方说一说。

  • 安装包一样得自己编译。 和 iOS 一样,应用商店里没有,得自己用 Android SDK 加 Java 17 手工打包。
  • 它会一直挂一条通知,这是正常的。 Android 不允许应用长期在后台偷偷跑,想保持连接就得开一个前台服务,代价是状态栏会常驻一条通知。这是系统强制要求,不是 bug,别去关它。进入 Talk Mode 时,它会临时给自己加上麦克风权限,对话结束再收回去。
  • 配对方式和 iOS 一样。 在 Connect 标签页里,同样是 Setup Code(输入配对码)或 Manual(手填地址)两条路,连上后照例回到 Gateway 主机上跑 openclaw devices approve 批准这台设备。
  • 远程连接必须走加密通道。 只要不是在同一个局域网里(比如通过上一篇讲的 Tailscale 远程访问),就必须用加密的 wss:// 地址,明文的 ws:// 在首次远程配对时会被拒。只有当手机和 Gateway 确实在同一个内网时(比如局域网 IP、.local 主机名、本机 localhost、模拟器的桥接地址 10.0.2.2 这些情况)才允许用明文 ws://
  • Android 没有唤醒词模式。 取而代之的是 Voice 标签页里两个手动模式(同时只能开一个):Mic 是手动单次录音,说一段、停顿一下就发一轮,切出应用就停;Talk 是连续语音对话,开了之后一直聊,直到你手动关掉或者连接断开。之所以砍掉唤醒词,是因为 Android 对后台录音管得比 iOS 还死,常驻监听很容易被系统直接掐掉。
  • 多了一批读取手机个人数据的能力。 具体能用哪些,取决于设备型号和你给了哪些权限,大致覆盖这几类:读通知和点通知按钮、取最近的照片、查/加联系人、查/加日历日程、翻通话记录、查/发短信、读运动步数,外加查设备本身的状态、信息、权限和电量。对应的命令名分别是 notifications.*photos.latestcontacts.*calendar.*callLog.searchsms.*motion.*device.*,需要时再去文档查具体参数即可。
  • 通知转发可以按需收窄。 默认会把手机通知都转给 agent,嫌吵的话可以调:用 allowPackages / denyPackages 指定只转(或不转)哪些应用的通知,用 quietHours 设一个免打扰时段,用 rateLimit 限制每个应用每分钟最多转几条。这套要 Android 的通知使用权限,应用首次引导时会主动来要。
  • 还能让系统语音助手当入口。 你可以直接对 Google Assistant 说 "Hey Google, ask OpenClaw…",这句话会被当成一条普通聊天消息送进 OpenClaw。这靠的是应用清单里声明的一段元数据,Gateway 那边不用做任何配置。

我手边没有 Android 设备,就不实际操作了,感兴趣的朋友可以参考文档自己体验试试。

实战 iOS app

这一节我同样让 cc 给我设计了一个场景:出差路上,对手机说 "老钳,看看我现在在哪,帮我查附近最近的咖啡馆",手机录到语音 → 推给家里的 agent → agent 调 location.get 拿 GPS → 查询位置和附近的咖啡馆 → 通过 talk.speak 把答案语音播回手机。这条链路把 node、location、语音、远程访问全串起来了,分五步。

第一步:先把 iOS Node 跟 Gateway 配上对。 在 WebChat 里给 agent 发一条 /pair 拿到 setup code,整段复制下来,回到手机进 OpenClaw 的 Settings → Gateway 粘贴进去,状态显示已连接即可。

第二步:在 iOS app 里打开 Location 权限。 Settings → Location,选 While Using。在 Gateway 主机上验证一下能不能拿到坐标:

$ openclaw nodes location get --node "iOS Node"

正常会返回 { "lat": 31.23, "lon": 121.47, "accuracyMeters": 12.5, "isPrecise": true, ... } 这样的 payload。如果报 LOCATION_DISABLED 是 app 里的选择器没开,LOCATION_PERMISSION_REQUIRED 是系统权限没给,LOCATION_BACKGROUND_UNAVAILABLE 是 app 被切到后台了,测的时候保持 app 在前台。

第三步:配好 Talk provider,让 agent 能把答案念回来。 编辑 ~/.openclaw/openclaw.jsontalk 段,最省事的是先用 macOS 自带的系统 TTS 兜底(不要 API key),跑通了再换 ElevenLabs 这种更自然的:

{
  "talk": {
    "provider": "system",
    "providers": {
      "system": {},
      "elevenlabs": {
        "voiceId": "elevenlabs_voice_id",
        "modelId": "eleven_v3",
        "apiKey": "elevenlabs_api_key"
      }
    }
  }
}

Talk 的几个 provider 是按配置顺序回退的:ElevenLabs 不通就走 mlx(macOS 本地推理),再不通就走 system(macOS 自带的 say)。个人用 ElevenLabs 配额挺紧(免费档每月就 1 万字符),先用 system 把链路跑通是更稳的选择。

第四步:检查唤醒词。 唤醒词是一份由 Gateway 统一管的全局列表,存在 ~/.openclaw/settings/voicewake.json 里:

{
  "triggers": [
    "老钳",
    "老钱",
    "老秦"
  ],
  "updatedAtMs":  1778973181234
}

任何客户端 UI 改了它都会广播给所有 node 同步。在 iOS app 的 Settings → Wake Words 里确认有你想喊的词(建议别用 heyok 这种太常见的,误触发率高),iOS 端再把本地的 Voice Wake 开关打开。

第五步:出门,让手机走 Tailscale 连回家里的 Gateway。 上一篇配的 Tailscale Serve 这时候就派上用场了,家里的 Gateway 通过 https://<magicdns> / wss://<magicdns> 对 tailnet 内可见,手机装了 Tailscale 客户端、登录同一个账号之后,无论走 4G 还是外面的 WiFi,都能反向连回家。这时候对手机喊 "老钳,看看我现在在哪,帮我查附近最近的咖啡馆":

  1. iOS Node 的 VoiceWakeManager 在本地命中唤醒词,开始录音
  2. 检测到一段静默后截断,把转写文本通过 WebSocket 推给 Gateway
  3. Gateway 把它送进 agent 一轮 turn
  4. agent 判断需要位置,调 location.get 拿到经纬度
  5. agent 用 web 搜索工具查这个坐标附近的咖啡馆
  6. agent 把结果整理成一句话,通过 talk.speak 经配置的 Talk provider 合成语音
  7. 音频流回 iOS Node 播放,你在耳机里听到答案

整条链路的时序大致是这样:

sequence.png

小结

这一篇我们把 OpenClaw 在手机上的两个客户端(iOS 和 Android)走了一遍,最后来总结一下:

  1. Device 和 Node 的区别: Device 是设备的身份证,管的是这台设备是谁、准不准进来;Node 是它进来之后贴出的一张能力清单,管的是它能被调用哪些功能。
  2. iOS Node 的配对和使用: 目前还很早起,没上架 App Store,得走 TestFlight 内测或自己用 Xcode 编译。一共三种配对方式,配上之后,它能为家里的 agent 提供这些远程感官:拍照、录视频、截屏、获取 GPS 位置、连续语音对话、唤醒词唤起等。
  3. Android Node 和 iOS 大体一致: 但是它默认不开唤醒词,主要靠手动的录音/对话模式;为了保持连接,它会在状态栏常驻一条通知。作为补偿,它比 iOS 多出一批读手机数据的能力(通知、通讯录、日历、短信、通话记录、运动步数等)。它同样没有公开发布,需要自己编译。

目前为止,我们忙活的全是怎么给小龙虾接上手脚、眼睛、耳朵和 GPS,却从没系统看过它接到任务之后到底能调哪些工具,这些工具又是怎么串起来的。回头看看这两篇的实战例子,上一篇让小龙虾帮忙排查屏幕上的报错,这一篇让它查找附近的咖啡馆,我们会发现这两个任务其实都不简单。就拿查咖啡馆来说,小龙虾先调 location.get 拿坐标、再用 web 搜索查、最后把结果合成语音念出来。这一连串的动作其实涉及两个问题:OpenClaw 有哪些工具可用,OpenClaw 拿到任务后使用什么工具,按什么顺序来调用工具。

下一篇我们就先把 OpenClaw 的工具箱挨个翻一遍,之后再讲怎么给它配操作手册。

参考


把小龙虾钉在菜单栏:OpenClaw 的 macOS app(二)

上一篇我们把 OpenClaw 的 macOS app 装上了,弄清了它「菜单栏伴侣」的定位,看了它作为 operator 那半边的功能,结尾停在了它的另一重身份上:它启动后还会以 role: node 连进同一个 Gateway,把自己的一组 capscommands 报上去、登记进 node 注册表,别的客户端就能用 node.invoke 把命令转过来。

这一篇就接着上一篇的结尾,详细学习下 macOS app 作为 node 的几个能力 —— Canvas 画板、摄像头与录屏、system.run 和 Exec approvals、Voice Wake 与 Push-to-talk,最后再把它们串起来,跑一个实战的例子。

Canvas

Canvas 是 macOS app 内嵌的一块 WKWebView 面板,专门给 agent 当轻量可视化工作区用,渲染 HTML/CSS/JS、A2UI、小的交互 UI。它的状态存在 ~/Library/Application Support/OpenClaw/canvas/<session>/ 下,面板通过一个自定义 URL scheme openclaw-canvas://<session>/<path> 来读这些文件(这样不用起 loopback server,也天生挡掉了目录穿越)。如果某个 session 根目录下没有 index.html,app 会显示一个内置的脚手架页。

面板本身是无边框、可缩放的,吸在菜单栏附近(或鼠标位置),按 session 记住大小和位置,本地 canvas 文件一改它自动重载,同一时刻只显示一个 Canvas 面板。可以在 Settings 配置页面的 Allow Canvas 选项来整个关掉,关掉之后所有 canvas 命令返回 CANVAS_DISABLED

平时是 agent 在对话里通过 Gateway WebSocket 驱动 Canvas,但 CLI 也能手动操作来验证。最直接的就是让它弹出来:

$ openclaw nodes canvas present --node <node-id>

跑完之后,菜单栏附近会弹出一块画板,但里面几乎是空白的:

canvas-a2ui.png

这个空白页是正常现象,canvas.present 只管「把面板显示出来」,不管往里塞内容。macOS 第一次打开 Canvas 会跳到 Gateway 托管的 A2UI host 页,这个页面本身就是个空壳:它只是 A2UI 的渲染运行时,在 agent 真正推内容之前页面上当然什么都没有。

注意:画板里有时会显示一行 Unauthorized 错误。这不是 macOS 的 TCC 权限问题,而是 WebView 去拉 A2UI host 页时 token 失效了(token 有 10 分钟 TTL,Gateway 重启过、连接断过、或长时间没动过画板都会让它过期)。可以从菜单栏退出 app 再重开,让它重新握手拿到新鲜的 token 就好了;要么在 Gateway 配置里把 canvasHost.enabled 设成 false 彻底关掉这个托管页(直接的 canvas.* 命令和本地脚手架页照常用)。

这里顺便介绍下 A2UIA2UI(Agent-to-UI) 是一套声明式的 UI 协议,agent 不用手写 HTML,而是发一串 JSONL 消息来描述「界面长什么样」:用 ColumnText 这类组件搭出一棵界面树,配上 usageHint(标题 / 正文之类)这种语义提示,由客户端的 A2UI 渲染器统一把它画成真正的控件;用户在界面上的点击、输入再作为事件反向回传给 agent,是双向的。OpenClaw 把这个渲染器(A2UI host)做成了 Canvas 面板里的内嵌页,上面的 canvas.present 默认跳过去的就是它。目前 Canvas 只认 A2UI v0.8 的 server→client 消息(beginRendering / surfaceUpdate / dataModelUpdate / deleteSurface),v0.9 的 createSurface 还不支持。

a2ui.png

除了 canvas.present 命令,也可以用 canvas.navigate 打开一个本地 canvas 路径,或者 http(s)file:// URL,或者直接传 "/" 返回本地脚手架页:

$ openclaw nodes canvas navigate "/" --node <node-id>

显示如下:

canvas-home.png

如果想看到自定义内容,可以用 canvas.a2ui push 推一段 A2UI 协议:

$ openclaw nodes canvas a2ui push --text "hi from canvas" --node <node-id>

面板中就会显示出这段文本:

canvas-hi.png

还可以用 canvas.eval 直接在 WebView 里跑 JS:

$ openclaw nodes canvas eval --node <node-id> --js "document.body.innerHTML='<h1>hi</h1>'"

然后用 canvas.snapshot 把当前画面截成图:

$ openclaw nodes canvas snapshot --node <node-id>

在 agent 对话里怎么用

上面这一串 openclaw nodes canvas ... 是给你手动验证用的,平时你不会这么敲。Gateway 给 agent 装了一个 canvas 工具,动作就是 present / hide / navigate / eval / snapshot / a2ui_push / a2ui_reset 这几个,跟上面的命令一一对应。你在对话里用自然语言提需求,agent 自己决定调它,往菜单栏这块面板里渲染东西。比如:

  • 「把这周的 commit 按作者画个柱状图给我看」→ agent 写段 HTML/JS、navigate 到一个本地 canvas 文件,或直接 a2ui_push 推一段 A2UI
  • 「做个表单让我填 name / 环境 / 分支三个参数」→ agent 推一段带输入框和按钮的 A2UI,你在面板上填完点提交,A2UI 把事件回传给 agent,它接着往下走 —— 这就是前面说的「双向」
  • 「这份 JSON 太长,渲染得好看点」→ agent eval 往面板里塞渲染好的 HTML
  • agent 渲染完想确认效果,会自己 snapshot 把面板截成图喂回自己,再决定要不要改

canvas-form.png

调哪个 node 一般不用你指定,这个工具默认优先挑本机的 macOS node,没有再挑第一个连着的、有 canvas 能力的 node。所以只要 macOS app 开着、Allow Canvas 没关,跟 agent 说「画个 xxx」它就会渲染到这儿。另外,如果你用的是 Control UI / WebChat 这种带聊天气泡的客户端,agent 还会把 HTML 写成 Gateway 托管的 canvas 文档,在回复里用 [embed ...] 标签直接嵌进聊天气泡。

摄像头与录屏

摄像头这边,macOS app 暴露 camera.snap(拍一张 jpg 照片)和 camera.clip(录一段 mp4 视频,可带音频,时长上限 60 秒)。和 iOS / Android 不一样的是,macOS 上摄像头默认是关的(Settings → General → Allow Camera)。打开之后 camera.snap 在拍之前会等一个 delayMs(默认 2000 毫秒)让曝光稳定,照片会被重新压缩保证 base64 在 5 MB 以下。

可以通过下面两个 CLI 命令进行测试:

$ openclaw nodes camera snap --node <node-id>
$ openclaw nodes camera clip --node <node-id> --duration 10s --no-audio

录屏走 screen.snapshot(截当前屏)和 screen.record(录一段 mp4 视频,同样 60 秒上限),需要 macOS 的 Screen Recording TCC 权限。这也是为什么这类操作必须由 app 来做,无头进程拿不到这个权限。

测试命令如下:

$ openclaw nodes screen record --node <node-id> --duration 10s --fps 15

值得注意的是,openclaw nodes screen 下面没有 snapshot 子命令,可以使用 openclaw nodes invoke 来调用:

$ openclaw nodes invoke \
  --node <node-id> \
  --command screen.snapshot \
  --params '{"screenIndex":0}'

这里还有一个 Gateway 的配置需要注意:由于 camera.snapcamera.clipscreen.record 这几个命令的隐私分量很重,需要在 gateway.nodes.allowCommands 里显式开启。

system.run 与 Exec approvals

system.run 是 macOS app 最有分量的一项 node 能力,它可以让 agent 在你这台 Mac 上运行命令。它跟 agent 自带的 exec 工具是两个很容易混淆的概念,其实,它们两不在一个层面上:exec 是模型在对话里直接调的工具,跟 read_file 平级,它内部有三种后端 —— sandbox(丢进沙箱隔离环境跑)、gateway(在 Gateway 进程里直接 spawn)、node(转交给一台远端 node);system.run 则是 macOS app 作为 node 暴露出来的一项能力,跟 screen.snapshotcanvas.present 平级。当 agent 的 exec 选了 host=node 这个后端时,请求会被序列化成 node.invoke system.run 走 Gateway 转过来,也就是说,system.run 可以理解成 exec(host=node) 在你这台 Mac 上的远端实现。

分为两种情况:

  • Local 模式:Gateway 把 system.run 请求直接交给 macOS app,app 在自己的 UI / TCC context 里执行
  • Remote 模式:远程 Gateway 先把请求发给本机那个 headless node host 服务(它以 node 身份连进远程 Gateway),node host 再通过一个本地 Unix socket(带 token + HMAC + TTL)把请求转给 macOS app 去跑,弹窗和输出都留在 app 里

不论哪种情况,最后都会被 macOS app 里的 Exec approvals 拦下(Settings → Permissions → Exec approvals):

settings-permissions.png

这些配置全部存储在这台 Mac 本地的 ~/.openclaw/exec-approvals.json 文件里,默认大致长这样:

{
  "version": 1,
  "defaults": {
    "security": "deny",
    "ask": "on-miss"
  },
  "agents": {
    "main": {
      "security": "allowlist",
      "ask": "on-miss",
      "allowlist": [{ "pattern": "/opt/homebrew/bin/rg" }]
    }
  }
}

需要注意的几点:

  • security 三选一:deny(全挡,默认)、allowlist(只放行白名单里的)、full(全放,等于 YOLO 模式)
  • ask 三选一:off(不弹)、on-miss(只在不命中白名单时弹)、always(每条都弹)
  • allowlist 里要么写解析后的二进制路径 glob(/opt/homebrew/bin/rg~/.local/bin/*),要么写裸命令名(rg,只匹配走 PATH 调起来的,不匹配 ./rg/tmp/rg
  • 命令文本里只要带 shell 控制或展开语法(&&||;| ` $<>()),一律算 allowlist miss,得单独批,或者把那个 shell 二进制本身 allowlist 掉
  • 弹窗里点 Always Allow,那条命令就被加进 allowlist
  • allowlist 是按 agent 分的:多个 agent 的话,在 app 里切换你正在编辑哪个 agent,一个 agent 的批准不会泄漏到别的 agent
  • 如果 macOS app 没在跑(比如你只起了 CLI 和 Gateway),任何本来该弹窗的请求会走 ask fallback,默认就是 deny

Voice Wake 与 Push-to-talk

macOS app 还能监听全局麦克风,把语音变成 agent 的输入。不过有一个前提条件,该功能依赖 macOS 26 才引入的新接口,macOS 26 以下整块 Voice Wake 配置都是灰色的,用不了:

voice-wake.png

满足版本之后,设置页里有三个触发模式和两个 Talk Mode 行为开关:

voice-wake-available.png

先说三个触发模式:

  • Voice Wake(唤醒词 → 文字,默认):一直挂着 macOS 的 Speech recognizer(即系统自带的 SFSpeechRecognizer,跟 Siri / 听写共用一套引擎,支持完全离线),听到唤醒词才开始录音,识别到的文本作为一条普通消息发给 agent。唤醒词是一份由 Gateway 统一管的全局列表(没有按 node 定制的唤醒词),存在 Gateway 主机的 ~/.openclaw/settings/voicewake.json 里,默认是 ["openclaw", "claude", "computer"],任何客户端 UI 改了它都会广播给所有 node 同步。
  • Talk Mode(唤醒词 → Talk Mode):勾上这个选项后,唤醒词命中后不再是录音 + 转写 + 发文字,而是直接把 app 切到 Talk Mode,这是一个 STT → LLM → TTS 的连续语音对话循环,屏幕上一直挂着一个小面板,在「聆听(listening)→ 思考(thinking)→ 回复(speaking)」三个阶段之间循环切换,各自播一段对应的动画,回复也是直接念出来而不是只写进 WebChat。
  • Push-to-Talk(按住右 Option):按住右 Option 键立刻开始录音,松开就停,不用喊唤醒词,松开后会留一小段延迟让你改文字再发。Push-to-talk 一启动会暂停 Voice Wake 的麦克风,松开后自动恢复;如果 Talk Mode 正开着,Push-to-talk 会被自动让位,等 Talk Mode 关掉再回来。

再两个是 Talk Mode 用的辅助开关,仅在用 Talk Mode 时才有意义:

  • 播放阶段切换音效(Play phase-transition sounds):Talk Mode 在 listening / thinking / speaking 之间切换时放一小段系统提示音,方便你在不看屏幕时也能听出现在 agent 处在哪个阶段。
  • 打断 agent 说话(Press Right Option to stop speech):agent 正在说话时,轻点一下右 Option 键就能打断 TTS,立刻回到 listening 状态。

使用 Voice Wake 模式时,唤醒词命中后会等一个约 0.55 秒的停顿(确认这是唤醒词,不是某个长词的一部分),然后在屏幕右上角飘出一个浮在所有窗口最上层的小面板,把你正在说的话实时转成文字显示在里面。

说话有内容时静默窗口是 2 秒,只听到唤醒词没听到后续是 5 秒;最外面还有个 120 秒的硬上限兜底,万一前面的静默检测失灵(比如背景一直有人声、或者识别卡住没吐结果),到点也会强制截断,不让它无限录下去。识别到的文本会被转发给当前 agent,回复送到上次用的那个主 channel(WhatsApp / Telegram / Discord / WebChat);如果投递失败,错误记下来,这一轮运行仍然能在 WebChat / 会话日志里看到。

设置页这几个开关下面还有一组语音相关的细项可以调:

voice-wake-settings.png

  • Recognition language(识别语言):识别用的语言,我这里选的是 “中文”,这样它就能听懂我说的话了。可以点 "Add additional language" 加几个备选,按列表顺序往下试。
  • Microphone(麦克风):选哪个麦克风去监听,默认是 System default,app 会记着你选的那个设备的唯一编号,万一拔了,会贴个 "Disconnected" 的小提示,临时回退到系统默认,等它接回来会自动切回去。
  • Live level(实时电平):实时电平条加 dB 数值,告诉你 app 现在到底听到了多少声音,选完麦克风后看一眼这个,就能立刻知道选对了没有。
  • Test Voice Wake(测试语音唤醒):本地测一下唤醒词识别能不能通,点 Start 后说一个唤醒词,如果能检测到,会显示 Detected。
  • Sounds(提示音):唤醒词命中(Trigger sound)和文本发出去(Send sound)两个时刻分别可以挂一段提示音,默认都是 macOS 的 "Glass" 系统音。
  • Trigger words(唤醒词列表):前面提到的那份 Gateway 全局共享的列表,可以在这里增删,改完会通过 voicewake.set 广播给其他客户端 / node。截图里我自己加了 "老钳"、"老钱" 两个中文唤醒词(有时候识别不准,多加几个),配合上面把识别语言切到 "中文",就能用中文喊它了。唤醒词越短越容易识别出来,但太短也更容易在日常聊天里被误触发,要权衡下。

实战 macOS app

把 macOS app 的几样能力串起来,我让 cc 帮我设计了一个场景:你正在 VS Code 里调一个跑不起来的脚本,懒得复制粘贴报错,直接对着电脑喊一句 "老钳,看看我屏幕上这个报错是怎么回事,运行 npm test 帮我排查下",菜单栏的小龙虾接收消息 → 调 screen.snapshot 拿屏幕截图 → 多模态识别 → 调 system.runnpm test → app 弹 exec approval → 你点 Always Allow → agent 把分析回到菜单栏面板的 WebChat 里。这条链路把 Voice Wake、screen 截图、system.run、Exec approvals 全串起来了,分六步。

sequence.png

第一步:开 Voice Wake,把麦克风 / 语音识别 / 录屏三个权限给上。 Voice Wake 开关在 Settings → Voice Wake,第一次开它会要 Microphone 和 Speech Recognition;screen.snapshot 需要 Screen Recording。到「系统设置 → 隐私与安全」确认这三项都给了 OpenClaw。然后在 Voice Wake 设置页配上你自己的唤醒词。

第二步:给 Exec approvals 加 allowlist。 agent 待会儿要跑 npm test,但 system.run 默认是 deny。到 Settings → Exec approvals,给 main agent 把 npm(或解析后的 /opt/homebrew/bin/npm)加进 allowlist,也可以等弹窗时点 Always Allow,效果一样,都会写进 ~/.openclaw/exec-approvals.json

{
  "version": 1,
  "defaults": { "security": "deny", "ask": "on-miss" },
  "agents": {
    "main": {
      "security": "allowlist",
      "ask": "on-miss",
      "allowlist": [{ "pattern": "/opt/homebrew/bin/npm" }]
    }
  }
}

第三步:喊唤醒词。 对着 Mac 说 "老钳,看下我屏幕上这个报错"。由于 VoiceWakeRuntime 一直挂着,听到 老钳 之后等那 0.55 秒停顿确认,然后开始录音,菜单栏弹出带转写文本的浮框;说完停 2 秒它自动截断,把转写文本发送给 agent。

第四步:agent 看屏幕。 agent 判断要看屏幕,调 screen.snapshot。Local 模式下这一步直接由 macOS app 执行(它有 Screen Recording 权限);Remote 模式下远程 Gateway 先发给本机的 node host 服务,node host 再通过本地 Unix socket 把请求转给 macOS app 去截,截完图回传给 agent。

第五步:agent 跑命令。 agent 看完图,觉得还得看测试结果,调 system.runnpm test。如果第二步没提前 allowlist,macOS app 这时弹出 exec approval 弹窗,列出要跑的命令、cwd、哪个 agent 发起的。你点 Always Allow,命令在 app 的 UI context 里跑(不是在 Gateway 进程里),stdout / stderr / 退出码回传,同时 allowlist 里多了一条 npm

第六步:看结果。 agent 把屏幕报错加测试输出综合分析,回一段话到 main session。点菜单栏的小龙虾,控制面板的 WebChat 里就能看到这段回复,跟你在键盘上敲进去的消息一样。agent 干活的那几秒,菜单栏图标会从小龙虾换成忙碌态的小图示。

这个场景整条链路比较长,中间可能会遇到各种问题,我也没有完全跑通,有兴趣的朋友可以试试看。

小结

这一篇我们深入学习了 macOS app 作为 node 的几样能力:

  1. Canvas:内嵌的 WKWebView 面板,给 agent 当轻量可视化工作区;
  2. 摄像头与录屏:使用 camera.snap / camera.clip 调用你的摄像头拍照或录视频,使用 screen.snapshot / screen.record 对你的电脑屏幕进行截屏或录屏;
  3. system.run 与 Exec approvals:让 agent 直接在你的 Mac 上执行命令,最后通过 Exec approvals 经过人工审批;
  4. Voice Wake 与 Push-to-talk:让 app 监听全局麦克风,把语音变成 agent 的输入;

最后通过一个实战的例子,把上面这些串起来。至此 macOS app 这只横跨 operator 和 node 的小龙虾就搞定了。下一篇我们接着这条线往下走,把视线挪到揣在兜里的那台手机,学习 iOS / Android Node 配对,把手机上的 OpenClaw Node 和家里的 Gateway 配上对,让它当远程麦克风、摄像头和位置传感器,出门在外也能随时唤起 agent。

参考


把小龙虾钉在菜单栏:OpenClaw 的 macOS app

上一篇我们把 Gateway 从 127.0.0.1 搬了出来,让外网的客户端也能访问。当时我们把连进 Gateway 的客户端分成了三类:

  • channel:Telegram、飞书、Slack、WhatsApp 这类聊天工具,你通过它们发文字消息让 agent 干活
  • operator:CLI、macOS app、浏览器 Control UI,你直接坐在控制台跟 agent 对话、改配置、看健康状态
  • node:iOS / Android Node、macOS 节点模式,把摄像头、麦克风、GPS、屏幕、Live Canvas 暴露给 agent 调用

在之前的系列文章里,channel(Telegram、飞书)和 operator(Control UI、openclaw CLI)这两类客户端我们都用得不少,唯独 node 还没怎么介绍过。今天就把这块补上。说到 operator,还有一个客户端前面只是顺嘴提过、一直没真正装来用过 —— macOS 菜单栏 app。它的身份比较特殊:既是 operator,又是 node,刚好横跨两类。所以这一篇先把 macOS app 装来用一遍,体验下它的各项能力;下一篇再讲纯 node 的两个移动端 iOS / Android Node。它们解决的是 channel 解决不了的另一件事:channel 让小龙虾出现在你聊天的地方,原生客户端让小龙虾调用你设备上的能力。

三个原生客户端一览

动手之前先看下这三个客户端的现状:

  • macOS app(macOS 15+,operator + node):GitHub Release 有打包好的 .dmg(外加给 Sparkle 自动更新用的 .zip),也可从源码编译。能力上是菜单栏常驻、拥有所有 TCC 权限、托管本地 Gateway,再加上 Canvas / 摄像头 / 录屏 / system.run / Voice Wake / push-to-talk 等功能
  • iOS Node(iOS 18+,纯 node):还处在内部预览阶段,没上架 App Store,只能走 TestFlight 内测或自己用 Xcode 编译。能提供 Canvas、摄像头、屏幕快照、Location、Talk Mode、Voice Wake 等能力
  • Android Node(Android 12+,纯 node):未公开发布,得用 Android SDK + Java 17 自己编 APK。除了 Canvas、摄像头、Talk Mode,还多一批通知转发、通讯录 / 日历 / 短信 / 通话记录 / 运动传感器命令

可以看到现状是:只有 macOS app 有现成的二进制可下,iOS 还在很早期的 alpha 阶段(仓库里 apps/ios/README.md 第一行就写了 internal-use only),Android 也是只给了源码。所以在 App Store / Google Play 暂时还搜不到,得靠我们自己动手。这一篇专门讲 macOS app,iOS / Android 留到下一篇。

安装 macOS app

macOS app 的官方说法是 menu-bar companion(菜单栏伴侣)。它跟前面一直在用的 openclaw CLI 是配套关系,不是替代关系:CLI 负责跑 Gateway 和敲命令,app 负责那些 CLI 干不了的事,弹 macOS 系统权限弹窗、在菜单栏挂个图标、把这台 Mac 当成一个 node 暴露摄像头和录屏。

第一步:装上 app

GitHub Release 页面 找最新版本,macOS 这块一次会放三个文件:

openclaw-assets.png

  • OpenClaw-<版本>.dmg —— 给人用的标准安装包,一般用户直接下载这个。双击挂载,把 OpenClaw.app 拖进 /Applications,弹出磁盘,启动。dmg 是签名 + 公证过的,Gatekeeper 不会拦。
  • OpenClaw-<版本>.zip —— 同一个 OpenClaw.app,只是换成 zip 打包。它真正的用途是提供给 app 自带的 Sparkle 自动更新:装完之后,以后的小版本 app 会自己提示升级,后台拉的就是这个 zip,不用你每次手动下。当然你想跳过 dmg、直接解压这个 zip 拖进 /Applications 也行,结果一样。
  • OpenClaw-<版本>.dSYM.zip —— 调试符号(debug symbols),给崩溃日志做符号化(symbolication)用的,普通用户用不到,下载列表里直接略过。

注一:Sparkle 是 macOS 上用得最广的开源应用自动更新框架,专门解决一个问题:不走 Mac App Store 分发的 app,怎么让用户自动收到新版本。Transmission、IINA、Bartender 这些老牌 Mac app 用的都是它,算是这个领域 20 多年的事实标准。

注二:为什么同一个版本要发 .dmg.zip 两个包呢?因为 .dmg 带 Finder 拖拽界面,对第一次装的人友好;而 Sparkle 使用 zip/tar.bz2 这种能直接解压替换的格式,.dmg 它反而不好处理,所以自动升级走的是 .zip。至于为什么 OpenClaw 不干脆上 Mac App Store 省掉这一步呢?因为它要弹一堆 TCC 权限、要跑本地 launchd 服务、要 system.run 在你机器上运行命令,过不了 App Store 的沙箱审核,只能自分发;自分发就得自己解决更新,所以 Sparkle 就成了最优选择。

第二步:首次启动向导

第一次启动 app,会弹出一个分页的引导向导。

首先是 欢迎页。 一句话介绍 OpenClaw,下面跟着一个橙色的安全提示,大意是:连进来的 AI agent(比如 Claude)能在你这台 Mac 上跑命令、读写文件、截屏,能干多少取决于你给它多少权限。仅当你了解这些风险再使用。

welcome.png

然后是 选择 Gateway。 让你挑这台 Mac 上的 OpenClaw 连哪个 Gateway:

  • This Mac(在本机起 Gateway,会自动装那个 launchd 服务)
  • Nearby Gateways(发现附近的 Gateway,同局域网或 tailnet 里 Bonjour 扫到的)
  • Configure later(先不起 Gateway)

点 Advanced 还能填远程 Gateway 的 SSH 目标或直连的 wss:// 地址。这一页其实就是 Local 和 Remote 之分。选了 This Mac 之后,本地模式还会接着跑一遍 Gateway 端的配置向导(配模型 API key 那些),跟前面几篇讲过的 openclaw onboard 是一样的,这里不重复。

gateway.png

接着 开启 TCC 权限。 这一页才是重头戏,我们根据需要逐项申请 macOS 的系统权限。大致分为下面这八类,每一类对应 app 的一组能力:

  • 通知(Notifications):菜单栏 app 弹原生系统通知(system.notify
  • 辅助功能(Accessibility):UI 自动化、监听全局快捷键(push-to-talk 的右 Option 快捷键就靠它)
  • 屏幕录制(Screen Recording)screen.snapshot 截屏、screen.record 录屏
  • 摄像头(Camera)camera.snap 拍照、camera.clip 录短视频(注意 macOS 上摄像头默认是关的,见后文)
  • 麦克风(Microphone):Voice Wake / Talk Mode / push-to-talk 录音,以及 camera.clip 带音频
  • 语音识别(Speech Recognition):把麦克风进来的语音在本地转成文字(Voice Wake、Talk)
  • 自动化(Automation):通过 AppleScript 控制别的 app(比如 PeekabooBridge 那套 UI 自动化)
  • 地理位置(Location)location.get 把这台 Mac 的位置交给 agent

TCC 全称 Transparency, Consent and Control(透明、知情同意与控制),是 macOS 的隐私授权机制:摄像头、麦克风、屏幕录制、辅助功能、输入监控、定位、通讯录这些敏感资源都被它挡在一道系统弹窗后面,app 第一次访问时会弹窗问你「允许吗」,确认后的结果记在 TCC.db 文件中,之后能在「系统设置 → 隐私与安全」里逐项开关。

permissions.png

这里有个关键点:这些权限是 macOS app 申请的,不是 Gateway 申请的。Gateway 是个无 UI 的后台进程,申请不了 macOS 的 TCC 弹窗流程,所以但凡涉及麦克风、录屏、辅助功能这些,都必须由有 GUI 的 app 来代申请。你不想用的能力这页可以先空着,后面要用了再回这页补。

最后向导走完,一切准备就绪:

allset.png

此时菜单栏右上角会出现一只小龙虾图标,本地模式的 Gateway 也起来了,点一下图标就能开始跟 agent 聊天:

first-chat.png

第三步:装 CLI(如果还没装)

如果你是从前面几篇一路跟下来的,openclaw CLI 早就装好了,这步可以跳过。如果是直接从 app 入坑的,打开 app → General 设置页 → 点 Install CLI,它会用 npm / pnpm / bun 帮你装一个全局的 openclaw 命令。app 内部的一些后台操作(管理定时任务之类)依赖这个 CLI。

第四步:选 Local 还是 Remote 模式

app 有两种工作模式,对应上一篇讲的两类部署:

  • Local(默认):Gateway 就在这台 Mac 上。app 会自动 attach 到正在跑的本地 Gateway;如果还没起,它会通过 openclaw gateway install 装一个 launchd 用户级服务(label 是 ai.openclaw.gateway,用 --profile 时是 ai.openclaw.<profile>)让它常驻。注意 app 自己不会把 Gateway 当子进程拉起来,它只负责装好 launchd 服务、然后 attach 上去。
  • Remote:Gateway 在别的机器上(比如家里那台 Mac mini)。app 通过 SSH 或 Tailscale 连过去,不会在本机起 Gateway 进程,但会在本机起一个 headless 的 node host 服务(也是 launchd),这样远程的 Gateway 反过来也能调用这台 Mac 的能力。Remote 模式下 app 的 Gateway 发现机制现在优先用 Tailscale 的 MagicDNS 名而不是裸 tailnet IP,所以 tailnet IP 变了也能自愈。

出差带的笔记本就选 Remote,家里那台主力机就选 Local。Remote 模式的 SSH / Tailscale 怎么配,上一篇「Gateway 远程访问」已经讲过,这里也就不再重复了。

macOS app 作为 operator

正如上文所述,macOS app 不仅可以跟 agent 对话,还可以作为 operator 来使用。我们在菜单栏图标上右键,点击 ”Settings“ 进入设置页:

general.png

这里的功能很多之前都学习过,和 CLI 和 Control UI 是等价的,比如渠道管理、配置管理、实例管理、会话管理、定时任务、SKILLS 技能;也能看最近的会话和 token 用量、Gateway 健康状态、已配对的 node 设备等。其中 Voice Wake 和 Permissions 是 macOS app 特有的。Permissions 就是我们上面讲的八大 TCC 权限,以及 Exec 审核策略配置,Voice Wake 我们后面再讲。

这里还有一个小彩蛋:app 注册了 openclaw:// URL scheme。运行 open 'openclaw://agent?message=Hello' 就能从命令行 / 脚本 / 快捷指令里直接调一次 agent 对话。不带 key 参数时 app 会弹确认框防误触,而且会无视 deliver / to / channel 这几个参数;带上配置好的 key 可以静默执行,适合做个人自动化。

macOS app 作为 node

macOS app 启动后会以 role: node 的身份也连进同一个 Gateway,握手时声明自己的 caps(canvas、camera、screen、system ...)和 commands(命令白名单),Gateway 把它登记进运行时的 node 注册表,别的客户端(CLI、Control UI、agent 本身)就能用 node.invoke 把命令转给它跑。常见命令是这几组:

组别命令干什么
Canvascanvas.present / canvas.navigate / canvas.eval / canvas.snapshot / canvas.a2ui.*agent 驱动的可视化画板
Cameracamera.snap / camera.clip拍照、录短视频
Screenscreen.snapshot / screen.record截屏、录屏
Systemsystem.run / system.notify在这台 Mac 上跑命令、发系统通知

未完待续

这一篇我们把 OpenClaw 的 macOS app 安装体验了一把,了解了:

  1. 它是「菜单栏伴侣」:跟 openclaw CLI 是配套不是替代 —— CLI 跑 Gateway、敲命令,app 干 CLI 干不了的事,比如弹 TCC 弹窗、菜单栏挂图标、把这台 Mac 当 node 等
  2. 安装 macOS app:GitHub Release 里的三个文件,分别是给人用的 .dmg、给 Sparkle 自动更新用的 .zip、给开发者排崩溃的 .dSYM.zip
  3. 首次启动向导:欢迎页 → 选 Gateway → 开 TCC 权限 → 完成配置
  4. Local / Remote 两种模式:Local 托管本地 Gateway(自动装 launchd 服务 ai.openclaw.gateway),Remote 连远程 Gateway、不在本机起 Gateway 但起一个 headless 的 node host 服务,让远程 Gateway 反过来也能调用这台 Mac
  5. 作为 operator:菜单栏图标右键 → Settings,渠道 / 配置 / 实例 / 会话 / 定时任务 / SKILLS 这些跟 CLI、Control UI 等价,外加 macOS 特有的 Permissions(八大 TCC 权限 + Exec 审核策略)和 Voice Wake
  6. 作为 node:以 role: node 也连进同一个 Gateway,握手时报上自己的 capscommands,被登记进 node 注册表,别的客户端就能用 node.invoke 把命令转过来

其中,作为 node 刚刚开了个头,Canvas 画板、摄像头、录屏、system.run 和 Exec approvals、Voice Wake 与 push-to-talk,这些内容值得一样样掰开了看,我们下一篇再见。


把小龙虾搬到外网: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(多步骤工作流编排)这两种不同的任务机制,我们下一篇就来继续学习它们。

参考