
1. 为什么我要给 Pi Agent 写扩展从“够用”到“顺手”的那一步Pi Agent 是一个跑在终端里的 AI 编程助手核心代码刻意保持精简只提供 read、write、edit、bash 四个基础工具其余能力全部交给 TypeScript 扩展去拼装。它适合谁适合那些觉得通用编码助手“能用但不够顺手”的开发者——比如你想让 Agent 查一下某个库的最新版本、想让它顺手调一下团队内部的接口、或者想在 Pi Agent 的对话窗口里直接指挥另一个 CLI 工具干活。这些需求官方没有内置但扩展系统留了口子。我自己的触发点很具体项目里经常要查第三方 SDK 的最新文档而模型的知识截止时间摆在那里问它等于赌运气。与其每次手动开浏览器不如给 Pi Agent 加一个搜索工具让它自己判断“这个问题需要联网”然后去查。另一个场景是团队里已经有一套跑得挺顺的 Claude Code 工作流我不想在两个终端之间来回切就想着在 Pi Agent 里加个/claude命令把任务转交出去结果再回到当前会话。这两个需求分别对应 Pi Agent 扩展系统的两条主线注册自定义工具给 LLM 调用和注册自定义命令给用户用斜杠触发。本文就围绕这两条线从目录结构、config.toml 骨架、注册代码到一次真实的工具调用验证把整个链路跑通。你跟着做下来应该能拿到一个可复用、可分享的扩展模板而不是停留在“知道有这么个东西”的层面。需要提前说明的是扩展本身是 TypeScript 模块运行在 Pi Agent 的进程里所以它能拿到 Node.js 的能力也能通过环境变量读取密钥。这一点决定了后面接入统一 API 通道时的写法——密钥不写死在代码里而是走环境变量注入。2. 前置准备TaoToken 统一 Key 与 API 通道配置在写扩展之前先把模型调用这条链路理顺。Pi Agent 支持 30 多家模型提供商配置方式是在~/.pi/agent/config.toml里声明 provider 和 model。如果你手上有多个模型的 Key每个都单独配一遍会很碎用 TaoToken 的好处是拿一个 Key、走一个 Base URL就能在多个模型之间切换扩展里调用的模型和 Pi Agent 主对话用的模型可以共用同一套凭证。先到官网注册并拿到 API Key地址是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 登录后在控制台的 API Keys 页面创建。创建完记得复制保存页面刷新后就看不到完整 Key 了。API 的 Base URL 是 https://taotoken.net/api 注意这个地址不带任何查询参数直接填进配置即可。接下来编辑~/.pi/agent/config.toml。这个文件如果不存在就手动创建目录~/.pi/agent/也要一并建好。下面是一个可复制的最小骨架把sk-开头的部分换成你自己的 Key# ~/.pi/agent/config.toml default_provider taotoken [providers.taotoken] type openai-compatible base_url https://taotoken.net/api api_key sk-你的TaoToken密钥 default_model claude-sonnet-4-20250514 [providers.taotoken.models] claude-sonnet-4-20250514 { name Claude Sonnet 4 } gpt-4.1 { name GPT-4.1 } deepseek-chat { name DeepSeek Chat }这里有几个点容易踩。第一type字段写openai-compatible因为 TaoToken 的接口兼容 OpenAI 的 chat completions 格式Pi Agent 会按这个协议去发请求。第二base_url结尾不要带/v1也不要带斜杠Pi Agent 内部会自己拼路径多写一段就会变成/api/v1/v1/chat/completions这种 404。第三default_model要和下面models表里的键名完全一致大小写敏感。配置写完后可以用一条 curl 先验证通道是否通避免后面扩展报错时还要回头怀疑是 Key 的问题curl https://taotoken.net/api/chat/completions \ -H Content-Type: application/json \ -H Authorization: Bearer sk-你的TaoToken密钥 \ -d { model: claude-sonnet-4-20250514, messages: [{role: user, content: 只回复两个字通了}] }返回的 JSON 里choices[0].message.content如果是“通了”说明 Key、Base URL、模型 ID 三件套都对。这一步别跳过后面扩展里的工具调用最终也是走这条通道前置通了排障范围能缩小一大半。如果你更习惯用环境变量而不是写进 config.toml也可以把 Key 放到TAOTOKEN_API_KEY里然后在 config.toml 中写api_key ${TAOTOKEN_API_KEY}。Pi Agent 支持这种占位符展开团队协作时把 config.toml 提交到仓库、Key 留在本地环境变量是个比较稳妥的做法。3. 扩展目录结构与可复制配置从零搭一个 tavily-search 工具Pi Agent 的扩展存放位置有两个作用域全局的~/.pi/agent/extensions/*.ts对所有项目生效项目本地的.pi/extensions/*.ts只对当前项目生效。我建议开发阶段先用项目本地目录方便调试和版本管理稳定之后再考虑挪到全局。扩展文件是单个.ts文件默认导出一个函数函数接收ExtensionAPI实例在里面调用registerTool、registerCommand等方法完成注册。先建目录mkdir -p .pi/extensions然后创建.pi/extensions/tavily-search.ts。这个扩展注册一个名为tavily_search的工具让 LLM 在需要实时信息时主动调用。代码里用typebox定义参数 schema用fetch发请求密钥从环境变量TAVILY_API_KEY读取——注意这里读的是搜索服务的 Key和模型通道的 TaoToken Key 是两回事别混。// .pi/extensions/tavily-search.ts import type { ExtensionAPI } from earendil-works/pi-coding-agent; import { Type } from typebox; export default function (pi: ExtensionAPI) { pi.registerTool({ name: tavily_search, label: 网络搜索, description: 联网搜索实时信息和最新内容, promptSnippet: 搜索最新信息和实时资讯, promptGuidelines: [ 需要查询最新信息或实时数据时使用, 适合搜索技术文档、错误解决方案、库的最新版本等, ], parameters: Type.Object({ query: Type.String({ description: 搜索关键词 }), max_results: Type.Optional( Type.Number({ description: 返回结果数量默认 5 条 }) ), }), async execute(toolCallId, params, signal, onUpdate, ctx) { const apiKey process.env.TAVILY_API_KEY; if (!apiKey) { return { content: [{ type: text, text: 未配置 TAVILY_API_KEY }], details: { error: missing key }, isError: true, }; } try { const response await fetch(https://api.tavily.com/search, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ api_key: apiKey, query: params.query, max_results: params.max_results || 5, include_answer: true, }), signal, }); if (!response.ok) { throw new Error(API 请求失败${response.status}); } const data (await response.json()) as { answer?: string; results: Array{ title: string; url: string; content: string; score: number }; }; let resultText ; if (data.answer) resultText ## 摘要\n${data.answer}\n\n; resultText ## 来源${data.results.length} 条\n; for (const [i, r] of data.results.entries()) { resultText \n### ${i 1}. ${r.title}\n; resultText **链接**: ${r.url}\n; resultText **相关度**: ${(r.score * 100).toFixed(0)}%\n\n; resultText ${r.content}\n; } return { content: [{ type: text, text: resultText }], details: { query: params.query, count: data.results.length }, }; } catch (error: any) { if (error.name AbortError) { return { content: [{ type: text, text: 已取消搜索 }], details: { cancelled: true }, isError: false, }; } return { content: [{ type: text, text: 搜索出错${error.message} }], details: { error: error.message }, isError: true, }; } }, }); }设置搜索服务的环境变量Windows 和 Linux/macOS 写法不同# Windows PowerShell $env:TAVILY_API_KEY tvly-你的密钥 # Linux / macOS export TAVILY_API_KEYtvly-你的密钥这里有个细节值得说execute的第五个参数signal是 AbortSignal用户在 Pi Agent 里按取消时这个信号会触发fetch收到后抛 AbortError。我在 catch 里单独处理了它返回isError: false这样取消不会被当成失败UI 上不会飘红。这个处理在长时间搜索时体验差别挺明显。另外promptGuidelines是给模型看的提示告诉它什么场景该用这个工具。写得太泛比如“需要信息时使用”模型会乱调写得具体一点“查最新版本、错误解决方案”命中率更高。这是我在调工具触发率时反复改过的地方。4. 注册自定义命令把 Claude Code 桥接进 Pi Agent 会话工具是给 LLM 调的命令是给人用的。Pi Agent 的registerCommand让你定义一个斜杠命令用户在对话窗口输入/claude 帮我重构这个函数就能触发。下面这个扩展用 Node.js 的child_process.spawn启动 Claude CLI捕获输出后通过pi.sendMessage注入回当前会话。创建.pi/extensions/claude-bridge.ts// .pi/extensions/claude-bridge.ts import type { ExtensionAPI } from earendil-works/pi-coding-agent; import { spawn } from child_process; export default function (pi: ExtensionAPI) { pi.registerCommand(claude, { description: 调用 Claude Code CLI 并捕获输出, handler: async (args, ctx) { if (!args || args.trim() ) { ctx.ui.notify(用法: /claude 提示词, error); return; } const prompt args.trim(); try { const child spawn(claude, [-p, prompt], { cwd: ctx.cwd, shell: true, stdio: [ignore, pipe, pipe], windowsHide: false, }); let stdout ; let stderr ; child.stdout?.on(data, (d) (stdout d.toString())); child.stderr?.on(data, (d) (stderr d.toString())); const exitCode await new Promisenumber((resolve) { child.on(close, (code) resolve(code ?? 1)); child.on(error, (err) { ctx.ui.notify(进程错误: ${err.message}, error); resolve(1); }); setTimeout(() { child.kill(); ctx.ui.notify(Claude CLI 执行超时, warning); resolve(1); }, 5 * 60 * 1000); }); let msg ## Claude Code 执行结果\n\n; msg **提示词:** \${prompt}\\n; msg **工作目录:** \${ctx.cwd}\\n; msg **退出码:** ${exitCode}\n\n; if (stdout.trim()) msg ### 输出\n\n\\\\n${stdout.trim()}\n\\\\n\n; if (stderr.trim()) msg ### 错误/警告\n\n\\\\n${stderr.trim()}\n\\\\n\n; pi.sendMessage( { customType: claude-code-result, content: msg, display: true, details: { prompt, cwd: ctx.cwd, exitCode }, }, { deliverAs: followUp } ); ctx.ui.notify( exitCode 0 ? Claude CLI 执行成功 : 退出码 ${exitCode}, exitCode 0 ? info : warning ); } catch (error: any) { ctx.ui.notify(错误: ${error.message}, error); } }, }); }几个关键点。spawn的shell: true在 Windows 上是必须的否则找不到claude这个命令在 Linux/macOS 上开着也无妨。deliverAs: followUp表示这条消息作为后续消息注入模型会把它当成上下文的一部分继续处理而不是打断当前流程。超时设了 5 分钟Claude CLI 处理复杂任务时可能跑挺久这个值可以按需调。这里要提醒一句桥接命令本质上是启动一个外部进程它的输出会原样注入会话。如果 Claude CLI 那边配置了不同的模型通道两边用的是不同的 Key这没问题但如果你想让两边都走 TaoToken 的统一通道需要在 Claude CLI 自己的配置里也指向同一个 Base URL。这部分属于 Claude Code 的配置范畴本文不展开你只要知道桥接层不关心下游用哪家模型即可。扩展写完后在 Pi Agent 里运行/reload热重载不需要重启进程。如果扩展有语法错误reload 时会在 UI 上提示改完再 reload 一次就行。这个热重载机制在开发扩展时省了很多事我基本是改一行 reload 一次。5. 验证请求与常见报错排查扩展加载成功后先验证工具调用。启动 Pi Agentpi在对话窗口输入一个需要实时信息的问题比如“帮我查一下 TypeScript 最新稳定版是多少”。模型应该会识别出需要联网自动调用tavily_search然后基于返回结果回答。如果它没调用工具而是直接编了个版本号说明promptGuidelines写得不够明确回去把触发场景描述得更具体一些。验证命令扩展输入/claude 用一句话说明这个项目是做什么的正常情况下会看到 Claude CLI 的输出被注入到会话里带一个## Claude Code 执行结果的标题块。如果提示“未捕获到输出”多半是 Claude CLI 需要交互式终端-p模式在某些版本下行为不一致可以先用claude -p test在终端里单独跑一下确认。下面是我在调试过程中真实遇到过的几类报错对照着排查能省不少时间。401 Unauthorized。这个最常见出现在模型调用环节。先检查 config.toml 里的api_key是不是完整复制了有没有多余空格。再确认base_url是https://taotoken.net/api而不是别的变体。如果 Key 没问题去控制台看一下这个 Key 是否被禁用或额度耗尽。还有一种情况是 config.toml 里写了${TAOTOKEN_API_KEY}但环境变量没导出展开后是空字符串也会 401。local proxy failed / connection refused。这个报错说明 Pi Agent 尝试连的地址根本不通。检查base_url有没有拼错特别注意结尾不要带/v1。如果你本地有网络层面的代理设置确认它没有拦截taotoken.net这个域名。另外某些公司网络会限制出站请求这种情况需要走公司允许的通道。reading choices of undefined。这个报错通常出现在响应格式不符合预期时。Pi Agent 按 OpenAI 兼容格式解析choices[0].message如果返回的 JSON 结构不对比如错误响应被当成正常响应解析就会读到 undefined。先看完整响应体确认choices字段存在。如果返回的是{error: {...}}那还是 Key 或模型 ID 的问题。模型 ID 要和 config.toml 里models表的键名完全一致写错一个字符就会走到默认模型或者直接报错。OAuth / token expired。如果你用的是需要 OAuth 的 providertoken 过期后会报这个。TaoToken 走的是 API Key 模式正常不会遇到但如果你的 config.toml 里混了其他 provider 的配置检查一下是不是默认 provider 指错了。扩展加载失败但没报错。有时候 reload 之后工具没出现也没提示。先确认文件扩展名是.ts而不是.js再确认默认导出的是一个函数而不是对象。Pi Agent 对扩展的加载是静默的语法错误会在控制台输出但如果你没看终端可能错过。养成 reload 后看一眼终端的习惯。排查顺序建议从外到内先用 curl 确认 API 通道通再确认 config.toml 被正确读取最后才怀疑扩展代码。这样能把问题范围快速缩小到一层。6. 把扩展用起来从验证到日常跑通之后这两个扩展就可以进入日常使用了。搜索工具的价值在于让模型自己判断何时需要联网你不需要每次手动提醒桥接命令的价值在于把已有的 CLI 工作流接进 Pi Agent不用来回切终端。两者结合Pi Agent 就从“一个能读写的助手”变成了“一个能按你的工作流定制的入口”。如果你想把扩展分享给团队可以把.pi/extensions/目录提交到项目仓库其他人 clone 下来 reload 就能用。密钥部分走环境变量不写进代码这样仓库里不会泄露凭证。全局扩展则适合放那些跨项目通用的能力比如统一的日志工具、团队内部的 API 封装。最后留一个实用技巧扩展里的ctx对象能拿到当前工作目录、UI 通知接口等善用ctx.ui.notify做状态反馈调试时比翻日志快得多。另外registerTool的details字段可以塞任意结构化数据UI 上不一定展示但你自己写测试或者做二次处理时能直接读算是个隐藏的便利点。