前言

OpenClaw 最迷人的地方,从来不是官方预置的几十种基础能力,而是“你能为它定制专属技能”

官方 Skills 能满足文件整理、浏览器操控、邮件发送等通用需求,但面对你的个性化场景 —— 比如对接公司内部 CRM 系统、自动生成行业专属报表、同步个人理财数据,就显得力不从心。而自定义 Skills,正是让 OpenClaw 从 “通用 AI 助手” 变成 “专属数字员工” 的核心钥匙。

很多人觉得 “开发 Skill 是高手的事”,但实际门槛远低于想象:只要你懂基础的 JavaScript/TypeScript,遵循 OpenClaw 的标准化规范,就能在 1 小时内开发出第一个可运行的自定义 Skill。

本文是纯实战导向的开发指南,不堆砌复杂原理,只讲 “怎么写、怎么测、怎么用”:从 Skill 核心结构拆解,到两个典型案例(基础文件处理 + 第三方 API 对接)的完整开发流程,再到调试、部署、优化的全环节,全程附带可直接复制的代码,新手也能跟着落地。

读完这篇文章,你不仅能掌握自定义 Skill 的开发方法,更能理解:OpenClaw 的 “全能”,本质是 “可扩展”—— 而自定义 Skills,就是你扩展它能力边界的最佳方式


一、先搞懂:OpenClaw 自定义 Skill 到底是什么?

在动手前,先明确 3 个核心认知,避免走弯路:

1.1 自定义 Skill 的本质

OpenClaw 的 Skill 不是 “黑盒插件”,而是遵循固定规范的 TypeScript/JavaScript 模块—— 核心作用是 “接收 OpenClaw 内核的标准化指令,执行具体操作,返回标准化结果”。

它的定位是:

  • 不参与 “意图解析”:用户说的话由 OpenClaw 内核转成结构化指令,Skill 只负责 “干活”;
  • 不管理权限:所有文件访问、网络请求的权限,都由 OpenClaw 内核统一校验;
  • 专注单一能力:一个 Skill 只做一件事(比如 “查询快递”“生成周报”),简单、可复用、易维护。

1.2 自定义 Skill 的核心结构

所有 OpenClaw Skill 都遵循 “3 文件核心结构”,这是能被内核识别、调度的基础:

plaintext

my-custom-skill/
├── plugin.json       # Skill 元信息(名称、权限、接口声明)
├── index.ts/js       # 核心执行逻辑(接收参数、处理任务、返回结果)
└── package.json      # 依赖配置(可选,有第三方依赖时需要)

其中:

  • plugin.json 是 “身份证”:告诉 OpenClaw 这个 Skill 叫什么、能做什么、需要什么权限;
  • index.ts/js 是 “干活的手”:实现具体的执行逻辑;
  • package.json 是 “补给包”:声明 Skill 依赖的第三方库(比如请求 API 的 axios)。

1.3 开发核心原则

  • 标准化输入输出:参数和返回值必须符合 OpenClaw 规范,否则内核无法调度;
  • 最小权限原则:只申请完成任务必需的权限(比如读取文件就不申请写入权限);
  • 异常处理完备:必须捕获执行中的错误,返回清晰的错误信息,避免内核崩溃;
  • 无状态设计:Skill 不保存用户数据,所有上下文由 OpenClaw 内核传递。

二、环境准备:5 分钟搭好开发环境

自定义 Skill 开发不需要复杂环境,只需准备 4 样东西:

2.1 基础环境

  • Node.js:v18+(OpenClaw 核心依赖,推荐 v18/v20 稳定版);
  • 包管理器:npm/yarn/pnpm(Node.js 自带 npm,无需额外安装);
  • 代码编辑器:VS Code(推荐,搭配 TypeScript 插件);
  • 已部署的 OpenClaw:本地已运行 OpenClaw 网关(参考官方文档部署即可)。

2.2 环境验证

打开终端,执行以下命令,验证环境是否正常:

bash

运行

# 检查 Node.js 版本
node -v  # 输出 v18.x.x 或更高即可

# 检查 npm 版本
npm -v   # 输出 9.x.x 或更高即可

# 确认 OpenClaw 已运行
# 若 OpenClaw 以默认端口启动,访问 http://localhost:3000 能看到状态页即正常

2.3 初始化 Skill 目录

创建一个专属的 Skill 开发目录,后续所有代码都放在这里:

bash

运行

# 创建目录
mkdir openclaw-custom-skills
cd openclaw-custom-skills

# 初始化第一个 Skill 项目
mkdir file-report-skill
cd file-report-skill

# 初始化 package.json(一路回车即可)
npm init -y

# 安装必要依赖(TypeScript + 类型声明)
npm install typescript @types/node --save-dev

# 生成 tsconfig.json(TypeScript 配置)
npx tsc --init --target ES2020 --module CommonJS --outDir dist

三、实战 1:开发基础 Skill—— 文件统计报表生成

先从最简单的场景入手:开发一个 “统计指定目录下文件类型和数量,生成 Markdown 报表” 的 Skill。这个案例覆盖 “文件读取、数据处理、结果输出” 核心能力,是自定义 Skill 的入门标配。

3.1 步骤 1:编写 plugin.json(Skill 元信息)

file-report-skill 目录下创建 plugin.json,这是 OpenClaw 识别 Skill 的关键:

json

{
  "name": "file-report-skill",
  "version": "1.0.0",
  "description": "统计指定目录的文件类型和数量,生成Markdown格式报表",
  "author": "Your Name",
  "skills": [
    {
      "action": "generate-file-report",  // Skill 唯一动作名(内核调用时用)
      "description": "统计目录文件并生成Markdown报表",
      "parameters": [                    // 接收的参数列表
        {
          "name": "dirPath",             // 参数名:目标目录路径
          "type": "string",
          "required": true,
          "description": "要统计的目录绝对路径,如 D:/Documents 或 /home/user/docs"
        },
        {
          "name": "outputPath",          // 参数名:报表输出路径
          "type": "string",
          "required": false,
          "default": "./file-report.md", // 默认值
          "description": "报表保存的路径(含文件名)"
        }
      ],
      "permissions": [                   // 申请的权限
        "file.read",                     // 读取文件权限
        "file.write"                     // 写入文件权限
      ]
    }
  ]
}

3.2 步骤 2:编写核心执行逻辑(index.ts)

创建 index.ts,实现文件统计和报表生成的核心逻辑:

typescript

运行

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';

// 解决 ESModule __dirname 问题(若用 CommonJS 可省略)
const __dirname = path.dirname(fileURLToPath(import.meta.url));

/**
 * 统计目录下的文件类型和数量
 * @param dirPath 目标目录路径
 * @returns 文件统计数据
 */
function countFilesByType(dirPath: string): Record<string, number> {
  const stats: Record<string, number> = {};
  
  // 检查目录是否存在
  if (!fs.existsSync(dirPath)) {
    throw new Error(`目录不存在:${dirPath}`);
  }

  // 读取目录下所有文件
  const files = fs.readdirSync(dirPath, { withFileTypes: true });
  
  for (const file of files) {
    // 跳过目录
    if (file.isDirectory()) continue;
    
    // 获取文件扩展名
    const ext = path.extname(file.name).toLowerCase() || '无扩展名';
    // 统计数量
    stats[ext] = (stats[ext] || 0) + 1;
  }

  return stats;
}

/**
 * 生成 Markdown 格式报表
 * @param stats 文件统计数据
 * @param dirPath 目标目录
 * @returns Markdown 字符串
 */
function generateMarkdownReport(stats: Record<string, number>, dirPath: string): string {
  const now = new Date().toLocaleString();
  let markdown = `# 文件统计报表\n`;
  markdown += `**统计目录**:${dirPath}\n`;
  markdown += `**统计时间**:${now}\n\n`;
  markdown += `| 文件类型 | 数量 |\n`;
  markdown += `|----------|------|\n`;
  
  // 遍历统计数据生成表格
  Object.entries(stats).forEach(([ext, count]) => {
    markdown += `| ${ext} | ${count} |\n`;
  });

  // 计算总文件数
  const total = Object.values(stats).reduce((sum, val) => sum + val, 0);
  markdown += `\n**总文件数**:${total}\n`;

  return markdown;
}

/**
 * Skill 核心执行函数(必须导出 default 函数)
 * @param action 调用的动作名(对应 plugin.json 中的 action)
 * @param params 传递的参数
 * @returns 标准化执行结果
 */
export default async function run(action: string, params: any) {
  try {
    // 只处理 generate-file-report 动作
    if (action !== 'generate-file-report') {
      return {
        success: false,
        message: `不支持的动作:${action}`,
        data: null
      };
    }

    // 提取参数(带默认值)
    const { dirPath, outputPath = './file-report.md' } = params;

    // 1. 统计文件
    const fileStats = countFilesByType(dirPath);
    
    // 2. 生成报表
    const markdown = generateMarkdownReport(fileStats, dirPath);
    
    // 3. 写入文件(处理相对路径)
    const fullOutputPath = path.isAbsolute(outputPath) 
      ? outputPath 
      : path.join(__dirname, outputPath);
    fs.writeFileSync(fullOutputPath, markdown, 'utf8');

    // 返回成功结果
    return {
      success: true,
      message: `文件统计报表已生成`,
      data: {
        stats: fileStats,
        reportPath: fullOutputPath,
        totalFiles: Object.values(fileStats).reduce((sum, val) => sum + val, 0)
      }
    };
  } catch (error) {
    // 捕获所有错误,返回标准化结果
    return {
      success: false,
      message: `执行失败:${(error as Error).message}`,
      data: null
    };
  }
}

3.3 步骤 3:编译 TypeScript(可选)

若使用 TypeScript 开发,需要编译为 JavaScript:

bash

运行

# 执行编译(根据 tsconfig.json 输出到 dist 目录)
npx tsc

# 编译后会生成 dist/index.js,后续部署用这个文件

3.4 步骤 4:部署 Skill 到 OpenClaw

将开发好的 Skill 接入 OpenClaw 内核,才能被调用:

  1. 找到 OpenClaw 的 Skills 目录:OpenClaw 安装目录下的 skills/ 文件夹(若没有,手动创建)。
  2. 复制 Skill 目录:将 file-report-skill 整个目录复制到 skills/ 下。
  3. 重启 OpenClaw:停止 OpenClaw 网关,重新执行 npm start,内核会自动扫描并加载新 Skill。

3.5 步骤 5:测试 Skill

通过 OpenClaw 的交互入口(如 Telegram、WebUI)发送指令:

plaintext

执行 generate-file-report,参数:{"dirPath":"D:/桌面","outputPath":"D:/桌面/文件报表.md"}

若配置正确,OpenClaw 会:

  • 统计 D 盘桌面的文件类型和数量;
  • 在桌面生成 文件报表.md
  • 返回执行结果:“文件统计报表已生成”,并附带统计数据。

四、实战 2:进阶 Skill—— 对接第三方 API(快递查询)

基础案例掌握后,我们开发一个对接外部 API 的 Skill,覆盖 “网络请求、参数校验、第三方服务集成” 核心场景 —— 以 “快递查询” 为例。

4.1 步骤 1:准备工作

  1. 申请快递 API 密钥:去「快递 100」或「聚合数据」申请免费的快递查询 API,获取 API Key。
  2. 安装依赖:在 Skill 目录下安装 axios(用于网络请求):

    bash

    运行

    npm install axios
    npm install @types/axios --save-dev
    

4.2 步骤 2:编写 plugin.json

json

{
  "name": "express-query-skill",
  "version": "1.0.0",
  "description": "通过快递100 API 查询快递物流信息",
  "author": "Your Name",
  "skills": [
    {
      "action": "query-express",
      "description": "查询快递物流信息",
      "parameters": [
        {
          "name": "expressNo",
          "type": "string",
          "required": true,
          "description": "快递单号"
        },
        {
          "name": "companyCode",
          "type": "string",
          "required": false,
          "default": "",
          "description": "快递公司编码(如 sf=顺丰,yt=圆通,空则自动识别)"
        },
        {
          "name": "apiKey",
          "type": "string",
          "required": true,
          "description": "快递100 API Key"
        }
      ],
      "permissions": [
        "network.request"  // 网络请求权限
      ]
    }
  ]
}

4.3 步骤 3:编写核心逻辑(index.ts)

typescript

运行

import axios from 'axios';

/**
 * 调用快递100 API 查询物流
 * @param expressNo 快递单号
 * @param companyCode 快递公司编码
 * @param apiKey API密钥
 * @returns 物流信息
 */
async function queryExpress(expressNo: string, companyCode: string, apiKey: string) {
  // 快递100 API 地址(参考官方文档)
  const url = 'https://www.kuaidi100.com/api';
  
  const params = {
    type: companyCode,
    postid: expressNo,
    id: 1,
    valicode: '',
    temp: Math.random(),
    key: apiKey
  };

  const response = await axios.get(url, { params });
  
  // 检查 API 返回状态
  if (response.data.status !== '200') {
    throw new Error(`API 返回错误:${response.data.message || '未知错误'}`);
  }

  return response.data;
}

/**
 * 格式化物流信息为易读文本
 * @param expressData API 返回的物流数据
 * @returns 格式化文本
 */
function formatExpressData(expressData: any): string {
  let result = `📦 快递查询结果\n`;
  result += `单号:${expressData.postid}\n`;
  result += `快递公司:${expressData.kuaidiName || '未知'}\n`;
  result += `状态:${expressData.state === '0' ? '运输中' : expressData.state === '1' ? '已签收' : '异常'}\n\n`;
  
  // 遍历物流轨迹
  if (expressData.data && expressData.data.length > 0) {
    result += `🚚 物流轨迹:\n`;
    expressData.data.forEach((item: any, index: number) => {
      result += `${index + 1}. ${item.time} - ${item.context}\n`;
    });
  } else {
    result += `暂无物流信息\n`;
  }

  return result;
}

/**
 * Skill 核心执行函数
 */
export default async function run(action: string, params: any) {
  try {
    if (action !== 'query-express') {
      return {
        success: false,
        message: `不支持的动作:${action}`,
        data: null
      };
    }

    // 提取并校验参数
    const { expressNo, companyCode = '', apiKey } = params;
    if (!expressNo) throw new Error('快递单号不能为空');
    if (!apiKey) throw new Error('API Key 不能为空');

    // 1. 调用快递API
    const expressData = await queryExpress(expressNo, companyCode, apiKey);
    
    // 2. 格式化结果
    const formattedResult = formatExpressData(expressData);

    return {
      success: true,
      message: formattedResult,
      data: expressData  // 返回原始数据,方便后续扩展
    };
  } catch (error) {
    return {
      success: false,
      message: `快递查询失败:${(error as Error).message}`,
      data: null
    };
  }
}

4.4 步骤 4:部署与测试

  1. 编译 TypeScript(若使用);
  2. express-query-skill 复制到 OpenClaw 的 skills/ 目录;
  3. 重启 OpenClaw;
  4. 发送测试指令:

    plaintext

    执行 query-express,参数:{"expressNo":"7894561230123","companyCode":"sf","apiKey":"你的API密钥"}
    
    成功后,OpenClaw 会返回格式化的快递物流信息。

五、自定义 Skill 调试与避坑指南

开发过程中,难免遇到各种问题,这里总结高频坑点和调试方法:

5.1 调试技巧

  1. 日志打印:在关键逻辑处添加 console.log(),OpenClaw 启动日志会输出这些信息;
  2. 本地测试:先写测试脚本,单独调用 Skill 的 run 函数,验证逻辑是否正常:

    typescript

    运行

    // test.js
    import run from './index.js';
    
    // 模拟 OpenClaw 调用
    run('generate-file-report', { dirPath: 'D:/桌面' })
      .then(result => console.log(result))
      .catch(err => console.error(err));
    
  3. 查看 OpenClaw 日志:启动 OpenClaw 时,终端会输出 Skill 加载和调用的日志,能快速定位 “加载失败”“参数错误” 等问题。

5.2 高频坑点

  1. 路径问题:Skill 运行目录可能不是自身目录,务必使用绝对路径或 __dirname 处理;
  2. 权限问题:忘记在 plugin.json 中声明权限,导致 OpenClaw 内核拦截操作;
  3. 参数格式:传递的参数类型不匹配(比如传数字而非字符串),需在代码中做类型校验;
  4. 异步处理:网络请求、文件操作未用 async/await,导致返回结果为空;
  5. 错误捕获:未捕获所有异常,导致 Skill 崩溃,进而影响 OpenClaw 内核。

六、进阶优化:让自定义 Skill 更专业

基础 Skill 开发完成后,可做这些优化,提升稳定性和复用性:

6.1 配置分离

将 API Key、默认路径等敏感 / 可变配置,抽离到单独的 config.json 文件,避免硬编码:

json

// config.json
{
  "expressApiUrl": "https://www.kuaidi100.com/api",
  "defaultReportPath": "./file-report.md"
}

6.2 权限细化

遵循 “最小权限”,比如只读文件的 Skill 只申请 file.read,不申请 file.write

6.3 版本管理

plugin.json 中维护版本号,后续迭代时,OpenClaw 能识别 Skill 版本;

6.4 多动作支持

一个 Skill 可声明多个动作,比如快递 Skill 可同时支持 “查询物流” 和 “订阅物流提醒”。


总结:自定义 Skills,让 OpenClaw 真正属于你

OpenClaw 的魅力,在于它不是一个 “成品工具”,而是一个 “能力平台”—— 官方提供骨架,你用自定义 Skills 填充血肉。

本文通过两个实战案例,拆解了自定义 Skill 的完整开发流程:

  1. 遵循 plugin.json 规范,声明 Skill 元信息和权限;
  2. 编写核心执行逻辑,保证输入输出标准化;
  3. 部署到 OpenClaw 目录,重启加载;
  4. 测试、调试、优化,确保稳定运行。

核心要点回顾:

  • 结构标准化:3 文件核心结构是被 OpenClaw 识别的基础;
  • 逻辑单一化:一个 Skill 专注一件事,易维护、易复用;
  • 结果标准化:无论成功失败,都返回 success/message/data 结构;
  • 权限最小化:只申请必需的权限,保证安全。

当你掌握了自定义 Skill 开发,就可以:

  • 对接企业内部系统,实现办公自动化;
  • 集成个人常用服务,打造专属助理;
  • 开发行业专属能力,解决垂直场景问题。

这正是 OpenClaw “本地优先、开放扩展” 理念的最佳体现 —— 你的 AI,理应由你定义它的能力。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐