Openclaw 实战:用 TypeScript CLI 与 Webhook 实现小龙虾给你打电话
1. Openclaw voice-call 到底能做什么适合谁用Openclaw 的 voice-call 插件简单说就是给你的 AI 助手装了一张“能打电话的嘴”。它不是那种在 App 里弹个通知的伪外呼而是通过 Twilio、Telnyx、Plivo 这类电信服务商真正拨通一个 PSTN 电话号码对方手机响铃、接听、听到 TTS 合成的语音整个过程由 TypeScript 运行时驱动。你写一条 CLI 命令或者让 Agent 触发一个工具调用电话就拨出去了通话状态、录音、转录文本再通过 Webhook 回调到你的服务端。它适合谁我梳理了三类人。第一类是做运维告警的线上 P0 故障时短信容易被忽略电话能强制触达第二类是做客户回访或预约提醒的需要批量外呼并记录通话结果第三类是想把语音能力接进自己 Agent 工作流的开发者比如让 AI 在完成某个任务后主动打电话汇报。这三类场景的共同点是需要“真实电话”而不是“应用内消息”需要“可编程”而不是“手动拨号”。voice-call 插件的技术结构分四层。最底层是电信服务商适配层负责和 Twilio 等 API 通信往上是通话状态管理维护每通电话的 callSid、状态机、超时再往上是 TTS/STT 集成把文本转成语音播放、把对方语音转成文本最上层是 RPC 方法、CLI 命令和 Agent 工具三个入口。你从 CLI 触发走的是最上层入口但底层四层都会参与。配置上它确实比飞书那种“发一句话就装好”的方案复杂。你需要准备服务商 API Key、一个可用的电话号码、一个公网可访问的 Webhook URL。这三样缺一不可尤其是 Webhook URL本地开发时得用内网穿透工具暴露出去否则服务商的回调打不到你的机器。这也是很多人第一次跑 voice-call 卡住的地方。我实测下来整个链路跑通的关键不在 CLI 参数而在 Webhook 的签名校验和状态机对齐。服务商回调过来的 JSON 里有个CallStatus字段取值是queued、ringing、in-progress、completed、busy、failed这些你的服务端必须按这个状态机更新本地记录否则会出现“电话已挂断但系统显示还在通话中”的鬼状态。下面我从环境准备开始一步步把这条链路搭起来。2. TaoToken 统一 Key 与 API 通道的前置准备在写 CLI 之前先把模型调用通道理顺。voice-call 插件本身负责电信侧但通话中的 TTS 文本生成、STT 后的意图理解、以及 Agent 触发外呼的决策都需要调用大模型。如果你每个环节都单独配一家厂商的 Key管理成本会很高而且不同厂商的 Base URL、鉴权头格式还不一样。TaoToken 在这里的作用是提供一个统一的 Key 和 API 通道。你申请一个 Key就能通过同一个 Base URL 访问多家模型省去在多个控制台之间切换的麻烦。对于 voice-call 这种“TTS 生成 STT 理解 Agent 决策”都要用模型的场景统一通道能明显减少配置项。具体操作路径是这样的。先打开 https://taotoken.net/api 对应的控制台入口注册后在 API Keys 页面创建一个新 Key。创建时建议给 Key 起个能识别的名字比如openclaw-voice-call方便后续排查是哪个项目在用。Key 创建后只显示一次复制下来存到环境变量里别直接写进代码。拿到 Key 之后你需要确认两件事Base URL 和 Model ID。Base URL 统一用https://taotoken.net/api注意这个地址不带任何查询参数。Model ID 根据你实际要用的模型填比如做 TTS 文本生成可以用通用对话模型做 STT 后理解可以用推理能力强的模型。这两个值在后面的 CLI 配置和 Webhook 服务里都会用到。如果你打算长期跑编码类或 Agent 类任务可以看一下 Coding Plan 的入口它针对高频调用场景做了额度优化。但如果你只是先跑通 voice-call 这一条链路用按量计费的 API Key 就够了。模型对话的调试入口在 https://taotoken.net/api 对应的对话页面你可以先在那里发一条测试消息确认 Key 和 Base URL 能通再去配 Openclaw。这里有个容易踩的坑TaoToken 的 Key 是统一鉴权但不同模型对请求体的字段要求可能略有差异。比如有的模型要求max_tokens有的用max_completion_tokens。在 voice-call 的 TTS 生成环节如果你把参数写死成某一家厂商的格式换模型时可能报 400。建议在配置里把模型相关参数抽成变量方便切换。环境变量建议这样组织后面 CLI 和 Webhook 服务都从这里读export TAOTOKEN_API_KEYsk-你的Key export TAOTOKEN_BASE_URLhttps://taotoken.net/api export TAOTOKEN_MODEL_ID你的模型ID export VOICE_CALL_PROVIDERtwilio export VOICE_CALL_ACCOUNT_SID你的服务商SID export VOICE_CALL_AUTH_TOKEN你的服务商Token export VOICE_CALL_FROM_NUMBER1xxxxxxxxxx export VOICE_CALL_WEBHOOK_BASEhttps://你的公网域名把这些变量写进~/.zshrc或~/.bashrc然后source一下。注意服务商的 Auth Token 和 TaoToken 的 Key 是两套东西别混用。前者用于拨号后者用于模型调用。3. 可复制的 TypeScript CLI 与 Webhook 配置片段这一节是核心我把 CLI 触发和 Webhook 回调拆成两个可复制的片段。先看 CLI 部分。Openclaw 的 voice-call 插件在extensions/voice-call/目录下CLI 命令的入口通常在src/cli.ts或bin/下。你不需要改插件源码而是写一个自己的触发脚本通过 RPC 调用插件的voice.call方法。先建一个项目目录初始化 TypeScriptmkdir openclaw-voice-cli cd openclaw-voice-cli npm init -y npm install typescript ts-node types/node axios dotenv npx tsc --inittsconfig.json里把target设为ES2020module设为commonjsoutDir设为dist。然后创建.env文件把上一节的环境变量写进去注意.env不要提交到 git。CLI 脚本src/call.ts的核心逻辑是读取环境变量构造请求体调用 Openclaw 的 RPC 端点。假设 Openclaw 的 RPC 服务跑在本地http://127.0.0.1:18789请求体格式如下import axios from axios; import dotenv/config; interface VoiceCallRequest { to: string; from: string; message: string; webhookUrl: string; modelId: string; } async function triggerCall(req: VoiceCallRequest): Promisevoid { const rpcEndpoint process.env.OPENCLAW_RPC_ENDPOINT || http://127.0.0.1:18789/rpc; const payload { jsonrpc: 2.0, id: Date.now(), method: voice.call, params: { to: req.to, from: req.from, tts: { text: req.message, model: req.modelId, baseUrl: process.env.TAOTOKEN_BASE_URL, apiKey: process.env.TAOTOKEN_API_KEY, }, webhookUrl: req.webhookUrl, provider: process.env.VOICE_CALL_PROVIDER, accountSid: process.env.VOICE_CALL_ACCOUNT_SID, authToken: process.env.VOICE_CALL_AUTH_TOKEN, }, }; try { const res await axios.post(rpcEndpoint, payload, { headers: { Content-Type: application/json }, timeout: 15000, }); console.log(call triggered:, JSON.stringify(res.data, null, 2)); } catch (err: any) { console.error(call failed:, err.response?.data || err.message); process.exit(1); } } const to process.argv[2]; const message process.argv[3] || 你好这是一条来自 Openclaw 的语音通知。; if (!to) { console.error(usage: ts-node src/call.ts phone [message]); process.exit(1); } triggerCall({ to, from: process.env.VOICE_CALL_FROM_NUMBER!, message, webhookUrl: ${process.env.VOICE_CALL_WEBHOOK_BASE}/voice/webhook, modelId: process.env.TAOTOKEN_MODEL_ID!, });运行方式npx ts-node src/call.ts 86189xxxxxxxx 线上故障请立即处理再看 Webhook 服务端。服务商回调过来的请求需要你校验签名、解析状态、更新记录。用 Express 写一个最小服务import express from express; import crypto from crypto; import dotenv/config; const app express(); app.use(express.urlencoded({ extended: false })); app.use(express.json()); const callRecords new Mapstring, { status: string; updatedAt: number }(); app.post(/voice/webhook, (req, res) { const signature req.headers[x-twilio-signature] as string; const url ${process.env.VOICE_CALL_WEBHOOK_BASE}/voice/webhook; const params req.body; const expected crypto .createHmac(sha1, process.env.VOICE_CALL_AUTH_TOKEN!) .update(url Object.keys(params).sort().map(k k params[k]).join()) .digest(base64); if (signature ! expected) { console.warn(invalid signature, reject); return res.status(403).send(forbidden); } const callSid params.CallSid; const status params.CallStatus; callRecords.set(callSid, { status, updatedAt: Date.now() }); console.log(call ${callSid} - ${status}); if (status completed || status failed || status busy) { console.log(final state for ${callSid}: ${status}); } res.type(text/xml).send(Response/Response); }); app.listen(3000, () console.log(webhook listening on 3000));这个服务做了三件事校验 Twilio 签名、按CallStatus更新内存记录、对终态做日志输出。生产环境要把callRecords换成数据库并且加上重试幂等逻辑因为服务商可能重复回调同一个状态。配置片段里最关键的是webhookUrl和签名校验的 URL 必须完全一致包括协议、域名、路径、末尾斜杠。我踩过的坑是本地用http://测试通过上线换成https://后签名一直失败原因是反向代理改写了路径但没同步改签名计算用的 URL。解决办法是在代理层保留原始 Host 和路径或者用服务商提供的X-Forwarded-Proto头重建 URL。4. 从本地到云端的验证请求与成功结果配置写完后先做本地验证再做云端验证。本地验证的目标是确认 CLI 能触发 RPC、Webhook 能收到回调。云端验证的目标是确认公网可达、签名校验通过、状态机完整。本地验证第一步启动 Openclaw 的 RPC 服务。如果你是用openclaw start启动的RPC 默认监听127.0.0.1:18789。确认端口在听lsof -i :18789第二步启动 Webhook 服务npx ts-node src/webhook.ts第三步用内网穿透把 3000 端口暴露出去。这里不展开具体工具你选一个能给你分配公网 HTTPS 域名的即可。拿到域名后把VOICE_CALL_WEBHOOK_BASE更新成这个域名重启 Webhook 服务。第四步触发一次本地到本地的调用先不拨真实号码用服务商的测试号码。Twilio 的测试号码是15005550006拨这个号码不会产生真实通话但会走完整的状态回调。运行npx ts-node src/call.ts 15005550006 测试语音请忽略预期结果分两处。CLI 侧会打印call triggered:加上一段 JSON里面有callSid和初始状态queued。Webhook 侧会连续打印几行状态变化顺序大致是queued→ringing→in-progress→completed。如果只看到queued就没了说明 Webhook 没收到后续回调检查公网域名是否可达、路径是否匹配。本地验证通过后换成真实号码做云端验证。把to换成你自己的手机号运行同样的命令。手机应该在一两秒内响铃接听后听到 TTS 播报你传入的message。挂断后Webhook 侧打印completed并且callRecords里这条记录的状态是终态。成功结果的特征有三个一是 CLI 返回的callSid和 Webhook 里收到的CallSid一致二是状态流转完整没有跳变或缺失三是终态是completed而不是failed。如果终态是failedWebhook 的请求体里会有ErrorMessage字段常见的有number not verified测试账号未验证号码、insufficient funds余额不足、invalid webhook回调地址不可达。我实测下来从触发到手机响铃的延迟通常在 1.5 到 3 秒之间取决于服务商路由和你的公网链路。TTS 播报的清晰度取决于你选的模型和音色如果听起来机械可以在 TaoToken 的模型对话入口先试几个不同的 TTS 模型找到合适的再写进配置。验证完成后把callRecords换成持久化存储加上通话录音的下载和 STT 转录。STT 转录可以用服务商自带的也可以把录音 URL 传给 TaoToken 的模型做转录。转录文本再喂给模型做意图理解就能实现“对方说了什么AI 记录并决策”的闭环。5. 本篇常见报错排查401、local proxy failed、reading choices、OAuth这一节按真实报错来。第一个401 Unauthorized。这个报错有两个来源要分开看。如果报错信息里带taotoken或api key字样说明是 TaoToken 的 Key 有问题。检查.env里的TAOTOKEN_API_KEY是否完整、有没有多余空格、是否已经过期。如果报错信息里带twilio或accountSid说明是服务商鉴权失败检查VOICE_CALL_ACCOUNT_SID和VOICE_CALL_AUTH_TOKEN是否配对注意 Auth Token 不是 API Key两者在控制台不同位置。第二个local proxy failed。这个报错通常出现在你本地配了 HTTP 代理但 Openclaw 的 RPC 或 Webhook 服务不走代理。表现是 CLI 能连上 RPC但 RPC 内部调用服务商 API 时超时。解决办法是在启动 Openclaw 前设置NO_PROXY127.0.0.1,localhost让本地回环地址绕过代理。如果你用的是容器环境检查容器内的 DNS 和出站规则确保能解析并访问服务商域名。第三个reading choices。这个报错来自模型调用侧完整信息通常是Cannot read properties of undefined (reading choices)。原因是模型返回体里没有choices字段而你的代码直接取了res.data.choices[0]。触发条件有三种一是 Base URL 写错请求打到了非模型端点二是 Model ID 不存在服务端返回了错误对象三是请求体格式不对比如把messages写成了message。排查方法是在 CLI 里把原始响应console.log(res.data)打出来看返回结构到底是什么。如果是 TaoToken 通道确认 Base URL 是https://taotoken.net/api不要多加/v1或路径后缀。第四个OAuth相关报错。如果你在配置里用了需要 OAuth 的模型或服务报错可能是invalid_grant或token expired。这类问题的根源是刷新令牌失效或时钟偏移。检查服务器时间是否同步date命令看时区是否正确。如果是 Codex 的auth.json场景确认文件里的access_token和refresh_token都在有效期内并且auth.json的路径和 Openclaw 读取的路径一致。Codex 的配置三件套是 Base URL、Key、Model ID缺一个都会导致鉴权失败。还有一个不常见但很坑的报错Webhook 返回 200 但状态不更新。原因是服务商要求 Webhook 返回特定格式的 XML你返回了 JSON 或空响应。Twilio 要求返回Response/ResponseTelnyx 要求返回 200 空体。对照你用的服务商文档把响应格式改对。排查顺序建议这样先看 CLI 侧报错定位是 RPC 问题还是模型问题再看 Webhook 侧日志定位是签名问题还是状态问题最后看服务商控制台的调用日志那里有最原始的请求和响应。三层日志对齐时间戳基本能定位到具体环节。6. 把 voice-call 接进你的工作流下一步怎么走链路跑通后你可以把 CLI 触发换成 Agent 工具调用。Openclaw 的 Agent 工具注册在extensions/voice-call/里你可以在 Agent 的配置中启用voice_call工具然后让 Agent 在特定条件下自动拨号。比如监控系统触发告警后Agent 先查值班表再调用 voice-call 拨给值班人通话结束后把转录文本写进工单。如果你需要长期跑这类外呼任务Coding Plan 的额度模型比按量计费更适合高频场景。接入文档在 https://taotoken.net/api 对应的文档入口里面有各语言 SDK 的示例和错误码说明。API Keys 管理在控制台的 API Keys 页面建议给不同项目建不同的 Key方便按项目统计用量和吊销。模型对话的调试入口建议常备每次换 TTS 模型或 STT 模型前先在那里发几条测试消息确认返回格式和延迟符合预期再写进 voice-call 配置。这样能避免“电话拨出去了但语音是乱码”这种线上事故。最后说一个实用技巧把每次通话的callSid、to、status、transcript存成一张表按天分区。跑一段时间后你能看出哪些号码经常busy、哪些时段接通率低、TTS 文本多长时对方挂断率最低。这些数据反过来优化你的外呼策略比盲目调参数有效得多。