OpenClaw 最近在代理自动化圈子里热度很高,但我发现很多人还是把它当成一个“装完就能自动干活的工具箱”,实际用起来却在自定义能力上卡壳。这篇内容从一个真实需求出发,带你从零写一个自定义 Agent 工具,再一路把接上 requireApproval 审批钩子,把 OpenClaw 插件开发的完整链路走一遍。文章不预设你已经很熟悉这个框架,但也不会把时间浪费在安装向导上,适合已经跑通基本部署、想给自己代理增加私有能力的开发者。
1. 动手前必须搞清楚的三个底层概念
1.1 Agent Harness 是编排者, 不是工具
先记住一个最关键的心智模型:OpenClaw 这样的 agent harness,它的职责是发起工具调用,而不是自己成为工具。换句话说,它是台前的调度中心,而不是每个具体任务的执行者。你可以把它理解成一个项目经历:项目经历不会亲自去写代码、画图、发消息,但它知道在什么时候把什么任务派给哪个人。OpenClaw 里的工具就是那些被派活的工程师,每个工具只负责一件具体的事。
这个理解直接决定了你写插件的方式。很多人第一次接触时会问:能不能写一个插件,让 OpenClaw 自己完成一个完整的视频剪辑流程?可以,但你写的不应该是一个巨大的单体脚本,而是一组被代理编排的工具:提取音频、剪辑片段、渲染导出,每一个都是独立的能力块。代理根据用户的指令,在正确的时机调用正确的那一个。如果你把所有逻辑都塞进一个工具,代理就失去了中途插手、调整参数、跳过步骤的空间,整个流程会变得极其僵硬。
所以在动手之前,先接受一个事实:你写的工具不是一个“给代理用的程序”,而是一个“被代理调度的函数”。这意味着接口约定比实现逻辑更重要:输入要严格按参数 schema 来,输出要是干净的 JSON,返回的信息要让模型能直接理解。后面所有步骤都围绕这个原则展开。
1.2 Skill、Tool、Hook 到底有什么区别
OpenClaw 的文档里会反复出现三个词:Skill、Tool、Hook。很多人把混在一起讲,实际上它们的层次是不一样的。Skill 是一个打包好的能力集合,通常以目录形式存在,里面可以包含多个工具的实现、一个描述文件、依赖和资源。Tool 是最小的可执行单元,有固定的名字、描述、参数定义,代理通过 function calling 决定是否调用它。Hook 则是事件钩子,它不在工具列表里暴露给模型,而是由 OpenClaw 在特定生命周期主动调用。
用一张表来对比会更清楚:
| 概念 | 层次 | 谁调用 | 典型例子 |
|---|---|---|---|
| Skill | 打包单位 | 开发者在配置中启用 | server_health 技能目录 |
| Tool | 执行单元 | 模型通过 function calling 调用 | server_health 检查 URL |
| Hook | 事件回调 | OpenClaw 框架在生命周期调用 | requireApproval 审批钩子 |
再换一个生活类比:Skill 是工具箱,Tool 是箱子里的一把扳手,Hook 是借工具时需要过的审批流程。三者的关系不是替代关系,而是不同层面的设计机制。理解这一点,你在配置文件里看到skills、hooks、requireApproval就不会觉得混乱了。
1.3 为什么自定义工具必须考虑审批
很多人一上来就急着写工具,忽略了一个关键问题:权限边界才是代理自动化真正的风险点。一个助手如果只能聊天,出问题也就是话说错;一旦它能写文件、执行命令、发消息、调用支付接口,一次 prompt injection 或者模型误判,就可能产生实质损失。
举个直接的例子:你给代理加了一个“发送邮件”工具,结果某次对话里,用户输入的内容包含恶意指令,让代理向通讯录里所有人发送垃圾邮件。单纯依赖模型的安全对齐是不够的,因为模型可能被绕过,也可能在复杂上下文中判断失误。requireApproval 审批钩子的存在,就是在框架层面强制加一道人工确认闸门,让敏感操作在执行前必须经过你同意。
所以你在设计自定义工具时,要先想清楚:这个工具如果被误调用,后果是什么,然后决定它属于“直接放行”还是“需要审批”的类别。这不是可选的加分项,而是一个成熟插件的基本素养。
2. 搭好开发环境:安装、目录与最小 Skill
2.1 安装方式怎么选:脚本、源码还是容器
动手写代码之前,先把环境准备好。OpenClaw 的安装方式常见的有几种:官方安装脚本是最省事的,也可以通过脚本指定 git 安装方式,从 GitHub 的 main 分支直接检出源码来跑。这种方式适合想跟踪最新特性的场景,但代价是配置字段可能随时变化,今天能用的写法明天可能就废弃了,需要有心理准备。
如果你在服务器上使用,我建议用 Docker 方式部署,隔离性和可复现性都更好。社区也有一些 Windows 离线整合包,适合本地快速体验,但我不建议在生成环境中依赖这类包,因为你不知道它内置了什么版本、什么依赖,出了问题很难排查。安装完之后,先确认版本:运行openclaw --version和openclaw --help,看看这份版本的命令和字段长什么样。
还有一个容易踩的坑是语言运行时版本。OpenClaw 的插件脚本可以用多种语言写,但不同语言运行时版本会影响依赖安装。比如你用 Python 写 skill,本地环境是 3.8,而技能里用到了 3.10 才有的语法,加载时就会报错。建议在一个干净的虚拟环境或容器里做开发,避免全局依赖污染。
2.2 配置文件与插件目录怎么组织
OpenClaw 的配置文件通常是一个openclaw.json,位置可能在项目根目录,也可能在用户目录下的.openclaw文件夹里。配置里会有模型提供商、启用的 skill、hooks、审批规则、日志级别等信息。插件(Skill)的默认目录一般是~/.openclaw/skills/,每个 Skill 都放在一个独立子目录里。
你可以在配置里指定额外的 skill 目录,比如把团队公用的 skill 放在一个共享路径下,或者把当前项目的自定义 skill 指向代码仓库,这样改完代码直接重启服务就能加载。我的习惯是开发阶段一定把 skill 目录指向仓库,而不是复制到默认目录,这样版本管理、代码 review、回滚都方便。
配置文件本身是 JSON 格式,不支持注释。我经常看到有人为了方便在里面加了//注释,结果服务启动直接报解析错误。如果你需要注释,可以把不同环境的配置拆成多个文件,或者用配置管理工具来维护。下面是一个简化配置示例:
{ "model": { "provider": "anthropic", "name": "claude-sonnet-4-20250514" }, "skills": { "dirs": ["~/.openclaw/skills", "./my-skills"] }, "hooks": { "requireApproval": "python ~/.openclaw/hooks/require_approval.py" }, "logLevel": "debug" }hooks.requireApproval这里指向的是一个外部脚本,后面第 4 章会详细说。
2.3 先跑通一个最小 Skill:Hello World
在写复杂工具之前,先做一个最小可用的 Skill 验证整个链路。创建一个目录~/.openclaw/skills/hello_skill/,里面放两个文件。
第一个是SKILL.md,负责描述这个 Skill 的用途和工具定义:
--- name: hello_skill description: 一个用于测试的最简技能。当用户和你打招呼、或者要求你说Hello时使用。 input_schema: type: object properties: name: type: string description: 打招呼时使用的名字,可省略。 required: [] ---第二个是main.py,负责真正执行逻辑:
#!/usr/bin/env python3 import sys import json def main(): payload = json.loads(sys.stdin.read()) name = payload.get("name", "world") print(json.dumps({"message": f"Hello, {name}!"})) if __name__ == "__main__": main()保存后重启 OpenClaw,让重新扫描 skill 目录。之后你在对话里问一句“打个招呼”或“say hello”,如果代理正确调用了这个工具,日志里会看到工具执行记录,对话里也会出现 “Hello, world!” 这样的内容。
这里有个非常容易忽略的点:SKILL.md里的description是模型决定是否调用工具的主要依据。它写给模型看,不是写给人看。要写触发条件和使用场景,比如“当用户询问服务器状态、检查接口是否在线时使用”,而不是写“这是一个健康检查模块”。
3. 从零实现一个自定义 Agent 工具:服务健康检查实战
3.1 需求案例:让代理检查服务是否在线
下面进入正题,写一个真正有实际价值的工具。我选的案例是服务健康检查:代理接收一个或多个 URL,返回每个地址的 HTTP 状态码和可用性。
选这个案例有几个原因。第一,代码简单,我故意用 Python 标准库的urllib而不是requests,这样你的技能目录里不需要额外装依赖,放到任何环境都能跑。第二,它典型地体现了“给代理一个最小权限工具”的思想:你不需要给代理 shell 权限让它自己去 curl,因为那会带来命令注入等风险。第三,这个工具的参数里可能包含内网地址,非常适合后面演示 requireApproval 动态审批。
在没有这个工具的时候,代理面对“检查一下某个网站是否活着”这类问题,只能说自己无法访问外部网络,或者干脆瞎猜。有了这个工具,它就能给出真实的状态数据,后续还可以基于状态码做告警、重试等更复杂的操作。
3.2 编写 SKILL.md 和 main.py
在~/.openclaw/skills/server_health/目录下,先创建SKILL.md:
--- name: server_health description: 检查一个或多个URL的HTTP健康状态。当用户询问网站、服务或接口是否在线、是否可以访问、状态码是什么时使用。参数urls为逗号分隔的URL列表。 input_schema: type: object properties: urls: type: string description: 需要检查的URL列表,多个URL用英文逗号分隔,例如 https://example.com,https://api.example.com/health required: - urls ---然后是main.py:
#!/usr/bin/env python3 import json import sys import urllib.request def check_url(url: str) -> dict: req = urllib.request.Request(url, method="GET", headers={"User-Agent": "openclaw-server-health"}) try: with urllib.request.urlopen(req, timeout=5) as resp: return {"url": url, "status": resp.status, "ok": resp.status < 400} except Exception as exc: return {"url": url, "status": 0, "ok": False, "error": str(exc)} def main(): raw = sys.stdin.read() if not raw.strip(): print(json.dumps({"error": "empty input"})) return payload = json.loads(raw) urls_str = payload.get("urls", "") urls = [u.strip() for u in urls_str.split(",") if u.strip()] if not urls: print(json.dumps({"error": "no valid urls"})) return result = [check_url(url) for url in urls] print(json.dumps(result)) if __name__ == "__main__": main()有几个细节需要提醒。第一,脚本从标准输入读入 JSON 参数,从标准输出输出 JSON 结果,这是 OpenClaw 插件的通用约定。第二,输出必须是一个有效的 JSON,不能有多余的 print 日志,否则解析会失败。第三,错误处理要在工具内部完成,而不是抛异常让框架处理,因为模型需要得到一个可读的错误信息来决定下一步动作,而不是收到一串堆栈。
超时时间我设的是 5 秒。如果你检查的是大量 URL,可以考虑用线程并发,但为了示例简单这里用的串行。实际使用中如果 URL 很多,代理一次调用会等很久,所以最好在 description 里提示“一次不要超过 10 个 URL”。另外,返回值尽量精简,只保留模型需要的关键字段,不要堆砌无关数据。
3.3 用调试日志验证工具加载与调用
写完文件后,重启 OpenClaw 服务,用调试模式启动,比如openclaw --debug,或者在配置里把logLevel设为debug。正常启动日志里会列出当前加载的 skills 和 tools。如果没有看到server_health,先检查目录路径是不是在配置的skills.dirs里,再检查 SKILL.md 的 frontmatter 格式是否完整。
加载成功后,直接在对话里输入“检查一下 https://example.com 是否在线”。如果一切正常,代理会调用server_health工具,并在回复中总结结果。这时候打开调试日志,你就能看到工具收到的原始参数和返回的原始结果,这是排查问题最有用的信息。
有时候你会发现,代理明明看到了工具,但就是不调用。这时不要急着改代码,先看日志里模型返回的内容。有些模型会把工具调用写成文本,而没有走标准的 function calling,这种情况和工具本身无关,而是模型能力或提示词的问题,后面第 5 章会展开。
3.4 提高工具调用命中率的三个技巧
第一个技巧是认真写 description。我见过太多人把 description 写成功能说明书,比如“本工具用于检查服务器在线状态,通过 HTTP 请求获取响应”。这种写法模型很难判断什么时候该调用它。更好的写法是直接写触发场景:“当用户询问网站、服务或接口是否在线时使用”。模型看到这句话,遇到相关提问就会自然想起这个工具。
第二个技巧是控制工具粒度。如果一个 Skill 暴露 10 个工具,模型选择准确率会下降。更好的做法是把相关工具拆成几个小 Skill,或者把调用频率低的工具描述写得更保守,让模型优先使用高频工具。这不是框架限制,而是模型决策的现实规律。
第三个技巧是给参数名和工具名保持一致的风格。比如工具名server_health是下划线风格,参数名urls也是下划线风格,别混用驼峰。模型在生成调用时很多时候直接照抄你给的参数名,不一致会导致参数传错。
4. requireApproval 审批钩子:静态配置与动态脚本
4.1 requireApproval 在工具调用链路中的位置
requireApproval 位于工具调用的生命周期中间。大致流程是:模型决定调用某个工具 → harness 检查这个工具是否命中审批规则 → 如果命中,暂停执行,向用户发起审批请求 → 用户同意后才真正执行工具 → 执行结果返回给模型。
这个机制必须是强制的,不能依赖模型自觉。你可以把它想成一道物理门禁:代理想进机房,门禁不会问“你是不是有权限”,而是直接拦住请求,等保安来确认。审批钩子就是那个保安的执法依据。
在 OpenClaw 中,requireApproval 可以通过两种方式配置。一种是静态的工具名单,适合固定的、简单的场景;另一种是注册一个外部钩子脚本,根据工具名和参数动态判断,适合规则复杂的场景。下面分别说。
4.2 静态名单:哪些工具必须审批
如果你的需求很简单,比如“凡是执行 shell 命令、写文件、发消息的工具都需要人工确认”,那直接在openclaw.json里配一个名单就好:
{ "requireApproval": { "tools": ["shell", "file_write", "server_health"] } }这段配置的含义是:当代理尝试调用这些工具时,先暂停,等用户确认。注意,工具名必须和 SKILL.md 里定义的 name 保持一致。如果你写成server-health而实际是server_health,审批不会触发,工具会直接执行,这种问题特别隐蔽,排查起来也特别费劲。
还有一种常见变体是配置一个布尔开关,比如"requireApproval": true,会让所有工具都进入审批流程。我不建议在生成环境中这样做,因为用户会被审批请求烦死,最终变成机械式点允许,完全失去安全意义。审批要用在关键的少数操作上,而不是用在高频的日常操作上。
4.3 动态钩子:按参数规则决定审批
静态名单只能按工具名判断,做不到更细粒度控制。比如我们的server_health工具,检查公网网站没什么风险,但参数里如果出现内网地址,就可能泄露内网信息,或者被用来做内网探测。这种情况需要根据参数动态决定是否审批。
这时就需要注册一个动态钩子脚本。在配置里:
{ "hooks": { "requireApproval": "python ~/.openclaw/hooks/require_approval.py" } }然后创建~/.openclaw/hooks/require_approval.py:
#!/usr/bin/env python3 import json import sys def is_internal(url: str) -> bool: internal_markers = ["localhost", "127.0.0.1", "192.168.", "10.", "172.16.", "172.17.", "172.18.", "172.19.", "172.30.", "172.31."] return any(marker in url for marker in internal_markers) def main(): payload = json.loads(sys.stdin.read()) tool_name = payload.get("tool_name") or payload.get("name") arguments = payload.get("arguments", {}) if tool_name == "server_health": urls = arguments.get("urls", "") if any(is_internal(url.strip()) for url in urls.split(",")): print(json.dumps({"approved": False, "reason": "检测到内网地址,为防止内网探测风险,需要人工确认后执行。"})) return print(json.dumps({"approved": True, "reason": "无风险操作,直接放行。"})) if __name__ == "__main__": main()这个脚本的逻辑很简单:只有在参数中检测到内网特征时才要求审批,其他情况直接放行。它演示了一个重要思路:审批钩子不仅是一个开关,可以是一段完整的业务逻辑。
但这里有几个坑你必须注意。第一,钩子脚本的输入结构在不同版本可能不同,有的版本用tool_name,有的用name,有的会把参数放在args而不是arguments。我写这个脚本时是先打印一次原始输入,确认字段结构后再写判断逻辑。第二,脚本必须在很短时间内返回结果,不要在钩子里做网络请求或慢查询,否则会阻塞整个工具调用流程。第三,脚本一旦抛异常,框架的处理方式可能是直接拒绝或直接放行,如果是放行就会有安全风险。所以最好在脚本里加一个全局的 try-except,出错时按拒绝处理。
4.4 审批被拒后,代理该怎么办
当审批钩子返回拒绝后,用户端会收到一个提示,代理也会拿到一个拒绝反馈。这个反馈的质量直接决定后续对话走向。如果 reason 写得太模糊,比如只写 “not approved”,模型会不知道该怎么办,反复尝试或者干脆放弃。
所以钩子里给出的 reason 要给可操作的建议。比如上面例子写了“检测到内网地址,需要人工确认”,用户看到之后可以批准,也可以要求代理换一个公网地址。如果你设计的工具涉及敏感数据,还可以在 reason 里说明当前即将执行的参数是什么,方便用户复核。
从产品角度看,审批流程的目标是让用户在最少干扰下获得最大安全。如果一个工具 10 次有 9 次都会被批准,那这个工具也许不需要放在审批名单里;如果一个工具 10 次有 9 次被拒绝,那说明这个工具的行为预期和用户意图不匹配,应该回头检查 description 是否写得太宽泛,导致模型在不该调用的时候调用了。
5. 常见问题与排查技巧:工具加载、审批失效与模型适配
5.1 工具加载不上,按这个顺序排查
工具加载不上是新手最常遇到的问题。按下面顺序排查基本能解决:第一,确认目录位置在配置的skills.dirs列表里;第二,确认SKILL.md的 frontmatter 有开始和结束的---,字段名没有拼写错误;第三,看启动日志,有没有关于这个 skill 的报错;第四,确认脚本文件有执行权限,尤其是在 Linux 上。
有一次我被一个问题卡了半天:SKILL.md 的 description 里有个未转义的冒号,导致 YAML 解析失败,整个 skill 被框架静默跳过。日志里只有一行 “skip skill xxx”,不仔细看根本发现不了。所以调试时一定要开 debug 日志,改完配置后养成检查日志的习惯。
另外,有些版本的 OpenClaw 在 skill 文件变更后不会热加载,需要重启服务。如果你改了代码但测试时发现行为没变,先确认是否重启过,不要急着怀疑代码逻辑。
5.2 审批钩子不生效,五个常见原因
审批钩子不生效通常有几个原因。第一,配置里的钩子路径用了相对路径,而 OpenClaw 的工作目录和你想象的不一样,导致找不到脚本。建议用绝对路径。第二,脚本本身有执行权限,但输出的字段名和版本期望的不一致。比如有的版本要求返回{"allow": true}而不是{"approved": true},字段对不上时框架可能按默认行为放行,这很危险。写完钩子后,一定要在一个敏感工具上试一次,确认审批提示真的弹出来了。
第三,静态名单里的工具名和实际注册的工具名不一致。排查时直接把日志里的工具名复制过来用,不要手动敲。第四,钩子报错但被框架吞掉了。建议在钩子脚本里加一点日志,比如把每次判断的原始输入和输出写到文件,这样可以快速定位是脚本逻辑问题还是框架调用问题。
第五个原因比较隐蔽:钩子脚本里用了相对路径读取其他模块或配置文件,导致运行环境不对。所有这些问题的通用排查思路,都是先确认能拿到钩子的原始输入,再逐步检查输出和返回值。
5.3 本地模型调用工具效果差怎么破
很多人为了隐私或成本,会用本地 Ollama 或类似方案部署模型。但本地模型的 function calling 能力,普遍比商业模型弱很多。典型表现是:代理看得到工具,但就是不调用,或者偶尔调用一次参数还是错的。
这时候有几个应对思路。一个思路是换一个对 function calling 支持更好的模型。社区里有人用 ccswitch 这类工具在模型之间快速切换,可以用来对比测试。如果你只是在开发插件,建议先用一个成熟的云模型验证工具逻辑,再切到本地模型调效果,否则你很难判断是工具问题还是模型问题。
另一个思路是改造工具的交互方式。有些本地模型不支持标准的 function calling,但你可以让代理通过一个统一入口工具来调用,比如一个call_tool工具,参数里带上目标工具名和参数。这种做法虽然绕,但确实能在老模型上跑通一些自动化流程,代价是提示词复杂度和模型理解成本都会上升。
还有一个容易被忽略的点:本地模型的上下文长度有限,工具描述如果太长,会占大量上下文,影响模型其他能力。所以本地模型场景下,工具描述尽量精简,一个工具三行以内最好。
5.4 平台对接、容器部署与版本升级的坑
插件开发里还有一类问题和工具本身无关,而是出在对接平台上。比如你把 OpenClaw 接到微信或其他聊天平台,会话可能因为平台风控或会话残留而卡住,导致审批请求发不出去,或者回复位置错乱。我遇到过一种情况:代理已经发起了审批请求,但用户在手机上看不到任何提示,过了一会儿整个会话超时。排查下来是平台侧对连续消息有频率限制,不是 OpenClaw 的锅。处理方式是给插件加节流,或者把审批请求合并到同一条消息里。
容器部署也有一些经典的坑。比如用容器控制 Chrome 做浏览器自动化时,容器里的/dev/shm太小会导致浏览器崩溃,需要启动时加挂载参数把/dev/shm扩大。还有容器内网络权限,如果你的工具需要访问内网资源,容器网络模式要提前规划好。
版本升级同样是个风险点。OpenClaw 迭代很快,配置字段和 hook API 都可能变化。升级前一定备份配置和自定义 skill,升级后先跑一遍你的最小验证用例。我个人习惯是把自定义 skill 单独放在 git 仓库,升级前提交一次,出问题可以快速回滚。
最后把这几个高频问题整理成一个速查表,方便你以后直接对照:
| 问题 | 可能原因 | 处理方式 |
|---|---|---|
| 工具加载不上 | 目录不在 skills.dirs 列表里 | 检查配置并重启 |
| 工具加载不上 | SKILL.md frontmatter 格式错误 | 查看 debug 日志 |
| 审批钩子不生效 | 钩子路径是非绝对路径 | 改用绝对路径 |
| 审批钩子不生效 | 返回字段与版本不匹配 | 打印原始输入确认字段 |
| 代理不调用工具 | description 写得太模糊 | 重写触发条件 |
| 本地模型调用差 | 模型 function calling 弱 | 切换模型或改文本协议 |
| 审批请求发不出去 | 平台消息频率限制 | 加节流或合并消息 |
我在实际开发中最深的体会是,插件开发的核心不在于代码写得多花哨,而在于你多了解代理的决策逻辑。工具的 description 写得好,审批规则设计得清楚,比任何复杂的技术都管用。最后再分享一个小技巧:每次改完插件,我都会在日志里确认工具注册和审批触发两个节点,确认无误再进对话测试,这能省掉大量反复试错的时间。如果你正准备动手,建议从一个小而清晰的工具开始,先跑通加载、调用、审批这条链路,再逐步扩展更多能力。