# OpenClaw Skill 開發指南

> 從零開始建立一個 OpenClaw workspace skill 的完整步驟與慣例。
> 基於 x550v 主機上的實作經驗整理（2026-03-02）。

---

## 目錄

1. [檔案結構](#1-檔案結構)
2. [SKILL.md 格式](#2-skillmd-格式)
3. [handler.ts 格式](#3-handlerts-格式)
4. [觸發機制（Triggers）](#4-觸發機制triggers)
5. [可用工具（Tools）](#5-可用工具tools)
6. [Context 物件](#6-context-物件)
7. [回傳格式](#7-回傳格式)
8. [Skill 間呼叫（callSkill）](#8-skill-間呼叫callskill)
9. [Internal Skill（僅供內部呼叫）](#9-internal-skill僅供內部呼叫)
10. [安裝與驗證](#10-安裝與驗證)
11. [實戰範例](#11-實戰範例)
12. [常見踩坑](#12-常見踩坑)

---

## 1. 檔案結構

每個 skill 是一個資料夾，包含兩個檔案：

```
skill-name/
├── SKILL.md      # 元資料（frontmatter）+ Markdown 說明
└── handler.ts    # TypeScript 實作
```

| 路徑 | 用途 |
|------|------|
| `/home/selig/openclaw-skill/skills/{name}/` | 原始碼（版本控管） |
| `~/.openclaw/workspace/skills/{name}/` | 安裝位置（Gateway 讀取） |

> 資料夾名稱必須與 SKILL.md frontmatter 的 `name` 欄位一致。

---

## 2. SKILL.md 格式

```yaml
---
name: my-skill                          # 必填，和資料夾同名，kebab-case
description: 一句話說明功能               # 必填，顯示在 skills list
triggers:                                # 必填，陣列；空陣列 = 使用者不可觸發
  - "關鍵字1"
  - "keyword2"
tools:                                   # 必填，宣告此 skill 可使用的工具
  - exec
  - web_fetch
internal: false                          # 選填，true = 隱藏（不顯示、不可由使用者觸發）
---

# My Skill 標題

## 功能說明
詳細描述 skill 做什麼、怎麼做。

## 觸發範例
使用者怎麼說會觸發這個 skill、agent 會怎麼回應。

## 設定（如果需要）
需要哪些環境變數、外部服務、CLI 工具。
```

### Frontmatter 欄位一覽

| 欄位 | 必填 | 型別 | 說明 |
|------|------|------|------|
| `name` | 是 | string | Skill 識別名，kebab-case，等同資料夾名 |
| `description` | 是 | string | 一行描述，顯示在 `openclaw skills list` |
| `triggers` | 是 | string[] | 觸發關鍵字陣列，空陣列 `[]` 表示僅供內部呼叫 |
| `tools` | 是 | string[] | 可用工具宣告（`exec`, `web_fetch`, `web_search`, `memory`） |
| `internal` | 否 | boolean | 預設 `false`；`true` 時對使用者隱藏 |

---

## 3. handler.ts 格式

```typescript
/**
 * my-skill handler
 * 功能簡述
 */

import { execSync } from 'child_process';
import { readFileSync, writeFileSync, existsSync } from 'fs';
import { join } from 'path';

export async function handler(ctx: any) {
  const message = ctx.message?.text || ctx.message?.content || '';

  if (!message.trim()) {
    return { reply: '請提供輸入。' };
  }

  // ... 業務邏輯 ...

  return {
    reply: '回覆文字（支援 Markdown）',
    metadata: { key: 'value' },
  };
}
```

### 重點

- 必須 `export async function handler(ctx: any)`
- 函式名必須是 `handler`，必須 async，必須 export
- 可使用 Node.js 內建模組（`fs`, `path`, `child_process`, `util` 等）
- **不能** `import` 第三方 npm 套件（skill 環境沒有 node_modules）
- 外部依賴用 `execSync` / `exec` 呼叫 CLI 或 curl

---

## 4. 觸發機制（Triggers）

OpenClaw 收到使用者訊息時，對 triggers 做 **case-insensitive substring match**：

```
使用者訊息：「幫我搜尋 nginx 設定」
                     ^^^^
triggers: ["搜尋", "查找", "recall"]
               ↑ 命中 → 啟動 qmd-brain skill
```

### 設計觸發詞的原則

| 原則 | 說明 |
|------|------|
| 中英文兼備 | 使用者可能用中文或英文，如 `["tts", "文字轉語音"]` |
| 避免太短 | 一兩個字容易誤觸發（如「找」可能在任何對話出現） |
| 避免太泛 | 「幫我」會攔截大量無關訊息 |
| 口語化 | 使用者自然語言，如「唸出來」、「記一下」 |
| 順序無關 | 只要訊息中包含任一觸發詞即命中 |

### 多 skill 觸發衝突

若多個 skill 的 trigger 同時命中，agent 會根據上下文選擇最適合的 skill。盡量讓 trigger 夠獨特以避免衝突。

---

## 5. 可用工具（Tools）

在 SKILL.md 的 `tools` 陣列中宣告，OpenClaw 會依此限制 skill 的能力。

| 工具 | 用途 | handler 中的使用方式 |
|------|------|---------------------|
| `exec` | 執行 shell 指令 | `execSync()` / `exec()` from `child_process` |
| `web_fetch` | HTTP 請求 | `fetch(url)` 或 `execSync('curl ...')` |
| `web_search` | 搜尋引擎 | 由 agent 呼叫，非 handler 直接使用 |
| `memory` | 對話記憶 | `ctx.memory.*`（需 memory plugin） |

> 實務上，大部分 skill 只需要 `exec`。需要打 API 或抓網頁時加 `web_fetch`。

---

## 6. Context 物件

handler 收到的 `ctx` 結構：

```typescript
interface SkillContext {
  message: {
    text: string;        // 使用者原始訊息
    content: string;     // 同 text（備用欄位）
  };
  env: {
    OPENCLAW_WORKSPACE: string;  // workspace 路徑（預設 ~/.openclaw/workspace）
    HOME: string;                // 使用者家目錄
    [key: string]: string;       // 其他環境變數
  };
  agent: {
    id: string;          // 當前 agent 名稱（如 "main", "kaiwu"）
  };
  callSkill: (name: string, params: any) => Promise<any>;  // 呼叫其他 skill
  memory?: any;          // memory plugin 提供的介面（若有）
}
```

### 常用存取模式

```typescript
// 取得使用者訊息
const message = ctx.message?.text || ctx.message?.content || '';

// 取得 workspace 路徑
const workspace = ctx.env?.OPENCLAW_WORKSPACE || process.env.HOME + '/.openclaw/workspace';

// 讀寫 workspace 檔案
const filePath = join(workspace, 'TODO.md');
if (existsSync(filePath)) {
  const content = readFileSync(filePath, 'utf-8');
}
writeFileSync(filePath, newContent, 'utf-8');
```

---

## 7. 回傳格式

```typescript
return {
  reply: string;           // 必填：回覆使用者的文字（支援 Markdown）
  metadata?: {             // 選填：結構化資料（記錄 / 追蹤用，不顯示給使用者）
    [key: string]: any;
  };
  files?: string[];        // 選填：附件檔案路徑陣列（如產出的音訊、圖片）
};
```

### 範例

```typescript
// 簡單文字回覆
return { reply: '✅ 完成！' };

// 附帶 metadata
return {
  reply: '🧠 搜尋完成',
  metadata: { query: 'nginx', results: 5 },
};

// 附帶檔案
return {
  reply: '🔊 語音合成完成',
  files: ['/tmp/output.wav'],
  metadata: { text: '你好' },
};
```

---

## 8. Skill 間呼叫（callSkill）

一個 skill 可以呼叫另一個 skill：

```typescript
export async function handler(ctx: any) {
  // 呼叫 dispatch-webhook skill
  const result = await ctx.callSkill('dispatch-webhook', {
    target: 'vps-a',
    payload: { task: '部署新版本' },
    webhookUrl: 'https://vps-a.example.com/webhook',
  });

  return { reply: `派發結果：${result.reply}` };
}
```

被呼叫的 skill 會收到 `ctx.message` 為傳入的參數物件。

---

## 9. Internal Skill（僅供內部呼叫）

設定 `internal: true` + 空 triggers，使 skill 對使用者不可見：

```yaml
---
name: dispatch-webhook
description: 發送 Webhook 到 VPS
triggers: []
tools:
  - web_fetch
  - exec
internal: true
---
```

用途：底層工具 skill，由其他 skill 透過 `callSkill` 呼叫。

---

## 10. 安裝與驗證

### 從原始碼安裝

```bash
# 1. 複製到 workspace
cp -r /home/selig/openclaw-skill/skills/my-skill \
      ~/.openclaw/workspace/skills/

# 2. 確認檔案結構
ls ~/.openclaw/workspace/skills/my-skill/
# 應該看到：SKILL.md  handler.ts

# 3. 重啟 Gateway（載入新 skill）
systemctl --user restart openclaw-gateway

# 4. 確認載入成功
openclaw skills list
# 應該看到 my-skill 狀態為 ✓ ready
```

### 更新已安裝的 skill

```bash
# 修改原始碼後，重新複製 + 重啟
cp -r /home/selig/openclaw-skill/skills/my-skill \
      ~/.openclaw/workspace/skills/
systemctl --user restart openclaw-gateway
```

### 除錯

```bash
# 查看 Gateway 日誌（skill 載入錯誤會在這裡）
journalctl --user -u openclaw-gateway -f

# 常見問題
# - SKILL.md frontmatter 格式錯誤（YAML 語法）
# - handler.ts 語法錯誤（TypeScript 編譯失敗）
# - name 和資料夾名不一致
# - tools 未宣告就使用 → 權限被擋
```

---

## 11. 實戰範例

### 範例 A：最簡 Skill（純文字處理）

**task-capture**：使用者說「記一下…」→ 解析優先級/截止日/標籤 → 寫入 TODO.md

```
skills/task-capture/
├── SKILL.md       triggers: ["記住", "記一下", "待辦", "todo", ...]
└── handler.ts     讀寫 workspace/TODO.md
```

重點技巧：
- `cleanTaskText()` 移除觸發詞，提取純任務文字
- `detectPriority()` / `detectDueDate()` / `detectTags()` 自動分類
- 直接用 `readFileSync` / `writeFileSync` 操作 workspace 檔案

---

### 範例 B：呼叫外部 API 的 Skill

**tts-voice**：使用者說「tts 你好」→ 呼叫本機 LuxTTS API → 回傳音訊檔

```
skills/tts-voice/
├── SKILL.md       triggers: ["tts", "文字轉語音", "語音合成", ...]
└── handler.ts     curl → localhost:7860 API
```

重點技巧：
- 用 `execSync('curl ...')` 呼叫 HTTP API（無法直接 `import axios`）
- 認證：先取 cookie 再帶 cookie 呼叫 API
- 帳密：`readFileSync('.env')` + regex 解析
- 長時間操作（合成 ~20 秒）：設定 `timeout: 120000`
- 回傳 `files: ['/tmp/output.wav']` 讓 agent 附送檔案

---

### 範例 C：外部 CLI 工具 + 多重搜尋引擎

**qmd-brain**：使用者說「搜尋 nginx」→ 並行 BM25 + pgvector → 整合結果

```
skills/qmd-brain/
├── SKILL.md       triggers: ["搜尋", "查找", "recall", "知識庫", ...]
└── handler.ts     execAsync → qmd CLI + embed_to_pg.py
```

重點技巧：
- `promisify(exec)` 做非同步 shell 呼叫
- `Promise.all([qmdSearch(), pgSearch()])` 並行多搜尋
- `detectIntent()` 判斷使用者意圖（搜尋 / 更新索引 / 統計）
- `extractQuery()` 移除觸發詞，提取搜尋關鍵字
- 結果截斷防止 Telegram 訊息過長

---

### 範例 D：Internal Skill（底層工具）

**dispatch-webhook**：由 assign-task 呼叫，發送 webhook + 重試

```
skills/dispatch-webhook/
├── SKILL.md       triggers: [], internal: true
└── handler.ts     fetch → VPS webhook endpoint
```

重點技巧：
- `triggers: []` + `internal: true` → 使用者看不到
- 由其他 skill 用 `ctx.callSkill('dispatch-webhook', payload)` 呼叫
- 實作重試邏輯、錯誤碼分類、超時處理

---

## 12. 常見踩坑

| 問題 | 原因 | 解決 |
|------|------|------|
| `openclaw skills list` 看不到新 skill | 未重啟 Gateway | `systemctl --user restart openclaw-gateway` |
| handler.ts 中 `import axios` 失敗 | skill 環境無 node_modules | 改用 `execSync('curl ...')` 或內建 `fetch` |
| SKILL.md parse 失敗 | frontmatter YAML 語法錯（如缺引號、縮排錯） | 用 YAML lint 檢查，字串值加引號 |
| skill name 和資料夾名不一致 | Gateway 比對 name ↔ 資料夾名 | 確保兩者完全相同（kebab-case） |
| `sudo openclaw skills list` 看不到 workspace skills | sudo 以 root 跑，PATH 不同 | 改用 `openclaw skills list`（不加 sudo） |
| trigger 誤觸發無關對話 | 觸發詞太短/太泛 | 用更具體的詞，避免單字觸發 |
| `ctx.env.OPENCLAW_WORKSPACE` undefined | 舊版或特殊環境 | fallback：`process.env.HOME + '/.openclaw/workspace'` |
| 呼叫 nvm 安裝的 CLI 找不到 | Gateway PATH 不含 nvm 路徑 | symlink 到 `~/.local/bin/` |
| handler 回傳後 Telegram 沒顯示 | `reply` 欄位為空字串 | 確保 reply 有內容 |
| `.env` 讀不到（Permission denied） | 檔案權限 600 但 Gateway 是 selig user | selig user service 可以讀自己的 600 檔案，正常不會有問題；確認檔案 owner |

---

## 快速模板

建立新 skill 時，複製此模板：

```bash
SKILL_NAME="my-new-skill"
mkdir -p ~/openclaw-skill/skills/$SKILL_NAME
```

**SKILL.md**：
```yaml
---
name: my-new-skill
description: 一句話描述功能
triggers:
  - "觸發詞1"
  - "trigger2"
tools:
  - exec
---

# My New Skill

## 功能說明
做什麼、怎麼觸發。

## 觸發範例
使用者：「觸發詞1 某些參數」→ skill 做某事 → 回覆結果
```

**handler.ts**：
```typescript
import { execSync } from 'child_process';

const TRIGGER_WORDS = ['觸發詞1', 'trigger2'];

function cleanInput(message: string): string {
  let cleaned = message;
  for (const t of TRIGGER_WORDS) {
    cleaned = cleaned.replace(new RegExp(t, 'gi'), '');
  }
  return cleaned.replace(/^[\s：:，,]+/, '').trim();
}

export async function handler(ctx: any) {
  const message = ctx.message?.text || ctx.message?.content || '';
  const input = cleanInput(message);

  if (!input) {
    return { reply: '請提供輸入內容。' };
  }

  // TODO: 業務邏輯

  return {
    reply: `✅ 完成：${input}`,
    metadata: { input },
  };
}
```

**安裝**：
```bash
cp -r ~/openclaw-skill/skills/$SKILL_NAME ~/.openclaw/workspace/skills/
systemctl --user restart openclaw-gateway
openclaw skills list  # 確認 ✓ ready
```
