スキル:Markdown ガイド
責務
スキル (skill) は OpenClaw では呼び出し可能な関数ではなく,Markdown ガイド——具体的には 1 つの SKILL.md ファイル——です。システムはシステムプロンプト構築時に,すべての利用可能スキルの name/description/location を <available_skills> XML としてプロンプトに詰め込みます。モデルはタスクが某スキルの description にマッチすると見なすと,自分で read ツールを使ってその SKILL.md を読み出し,中に書かれた手順に従って行動します。
この仕組みはとても「軽く」見えますが,ツールシステムとは本質的に異なります:ツールはシステムが登録した関数で,モデルは名前を呼ぶだけ。スキルはモデル自身が読むか決めるドキュメントです。スキルの内容は任意の Markdown——操作手順、チェックリスト、用語集、コードテンプレート——で,モデルが読み終わった後,ツールを使うべきならツールを使い,コードを書くべきならコードを書き,スキル自体はランタイム呼び出しに参与しません。
スキルシステムが担当することも純粋です:発見 (discovery) がすべての SKILL.md ファイルを見つけ,読み込み (loading) が frontmatter を解析して Skill オブジェクトにし,注入 (injection) がサマリをシステムプロンプトに詰め込み,ライフサイクル (lifecycle) がスキルのインストール/アーカイブ/アップロードを管理します。残りの「読むかどうか、どう使うか」はモデル自身が担当します。
設計動機
なぜスキルもツールにしないのか?ツール呼び出しは決定論的だからです——パラメータ schema、入力検証、executor ロジックがすべて固定で,同名ツールを呼べば毎回同じ挙動になります。しかし多くのタスクガイドは本来的に叙述的です:「バグに遭遇したらまずログを見て,次に再現し,次に特定する」というフローは JSON schema に詰め込めません。モデルに自然言語でガイドを理解させ,柔軟にツール呼び出しを組み合わせる方が,無理に関数にするよりも適しています。
もう一つの動機は遅延読み込み (lazy loading) です。スキル本文は長くなる可能性(数十 KB の操作マニュアル)がありますが,システムプロンプトにはサマリだけを詰めます。モデルはタスクが本当にマッチしたときだけ read で全文を読み,大部分の対話でスキル本文はコンテキストウィンドウに入りません。formatSkillsForPrompt(skill-contract.ts formatSkillsForPrompt:L34-L58) は name/description/location/version の 4 フィールドだけを出力するのはこのためです。
<version> フィールドが重要な設計です:SKILL.md 内容の安定マーカーです。システムプロンプトはモデルに明示します:「If a skill's <version> differs from a previous turn, re-read its SKILL.md before using it.」(src/skills/loading/skill-contract.ts:L41-L43)——スキル作者が内容を変えたら,モデルは次ラウンドで読み直すべきで,旧バージョンの記憶で勝手に振る舞ってはいけません。
source フィールドはソース标识(openclaw-bundled / workspace / plugin など)を保持します。disableModelInvocation frontmatter フィールドはスキル作者が「モデルは自動呼び出しできず,ユーザが明示的にトリガーしなければならない」と宣言でき,明示的な許可が必要な高リスク操作のために残された口です。
主要ファイル
skill-contract.ts Skill:L5-L15—Skill型定義,name/description/filePath/baseDir/promptVersion/sourceInfo を含む。skill-contract.ts formatSkillsForPrompt:L34-L58— Skill 配列を<available_skills>XML にフォーマットして prompt に詰め込む。local-loader.ts loadSingleSkillDirectory:L38-L89— 単一スキルディレクトリの読み込み:SKILL.md を読み、frontmatter を解析、Skill を組み立て。local-loader.ts loadSkillsFromDirSafe:L107-L151— 一括読み込みエントリ,失敗は diagnostics に転換しスローしない。local-loader.ts readSkillFileSync:L16-L36—openRootFileSyncでスキル root 境界内でファイルを読み,symlink 逃逸を防ぐ。system-prompt.ts スキル注入:L70-L74— システムプロンプト構築時にformatSkillsForPromptでスキルリストを組み込み。system-prompt.ts hasRead 守衛:L169-L172— read ツールが利用可能なときだけスキル段を注入,さもなくばモデルは読んでも読めない。tool-dispatch.ts resolveSkillDispatchTools:L59-L249— スキルがツール呼び出しをトリガーしたときに多層ポリシーでツールセットをフィルタ。install.ts installSkill:L458— スキルインストールエントリ,archive/source/upload などのチャネルからスキルを取得。openclaw-provider-index.ts:L13-L69— ビルトイン provider 索引,スキル事前宣言モードに類似。
データフロー
スキル読み込みはファイルシステムから始まります(local-loader.ts 単一ディレクトリ読み込み:L38-L89):
function loadSingleSkillDirectory(params: {
skillDir: string;
source: string;
rootRealPath: string;
maxBytes?: number;
}): LoadedLocalSkill | null {
const skillFilePath = path.join(params.skillDir, "SKILL.md");
const raw = readSkillFileSync({
rootRealPath: params.rootRealPath,
filePath: skillFilePath,
maxBytes: params.maxBytes,
});
if (!raw) {
return null;
}
let frontmatter: Record<string, string>;
try {
frontmatter = parseFrontmatter(raw);
} catch {
return null;
}
const fallbackName = path.basename(params.skillDir).trim();
const name = frontmatter.name?.trim() || fallbackName;
const description = frontmatter.description?.trim();
if (!name || !description) {
return null;
}
const invocation = resolveSkillInvocationPolicy(frontmatter);
// ...
return {
skill: {
name,
description,
filePath,
baseDir,
promptVersion: computeSkillPromptVersion(raw),
source: params.source,
sourceInfo: createSyntheticSourceInfo(filePath, { /* ... */ }),
disableModelInvocation: invocation.disableModelInvocation,
},
frontmatter,
};
}3 つの関門に注意:ファイル読み込みは openRootFileSync を使用(src/skills/loading/local-loader.ts:L16-L36)し,パスを root の実際のパスに解決し,symlink がスキルディレクトリを逃れるのを防ぎます。frontmatter 解析失敗時は黙って破棄し,1 つの悪いスキルが読み込み全体を死なせないようにします。name と description は必須で,どちらか欠ければスキップします。computeSkillPromptVersion が内容の安定マーカーを計算し,後で <version> フィールドに詰めます。
loadSkillsFromDirSafe(src/skills/loading/local-loader.ts:L107-L151) は上層ラッパーで,渡された dir をまず単一スキルディレクトリとして読み込み,失敗したら親ディレクトリとしてサブディレクトリを列挙して一括読み込みします。この「自分がスキルかどうか先に見て,次にスキル集合かどうかを見る」2 層ロジックにより,skills/ ルートディレクトリ(単一スキル)と skills/coding-agent/(サブスキル)両方のレイアウトが動作します。
読み込み完了した Skill 配列は最終的に system-prompt.ts 注入:L70-L74 でプロンプトに組み込まれます:
// Append skills section (only if read tool is available)
const customPromptHasRead = !selectedTools || selectedTools.includes("read");
if (customPromptHasRead && skills.length > 0) {
prompt += formatSkillsForPrompt(skills);
}ここに重要な守衛があります:read ツールがアクティブリストにあるときだけスキル段を注入します。さもなくばモデルは <available_skills> を見ても SKILL.md を読めず,「あると知っているのに呼べない」という状態に陥ります。formatSkillsForPrompt(src/skills/loading/skill-contract.ts:L34-L58) が出力する XML はおおよそ:
const lines = [
"\n\nThe following skills provide specialized instructions for specific tasks.",
"Use the read tool to load a skill's file when the task matches its description.",
"If a skill's <version> differs from a previous turn, re-read its SKILL.md before using it.",
"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use the absolute path in tool commands.",
"",
"<available_skills>",
];
for (const skill of skills) {
lines.push(" <skill>");
lines.push(` <name>${escapeXml(skill.name)}</name>`);
lines.push(` <description>${escapeXml(skill.description)}</description>`);
lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
if (skill.promptVersion) {
lines.push(` <version>${escapeXml(skill.promptVersion)}</version>`);
}
lines.push(" </skill>");
}
lines.push("</available_skills>");3 行目がモデルに明示していることに注意:「If a skill's <version> differs from a previous turn, re-read its SKILL.md before using it.」——バージョンが変わったら読み直し,記憶で勝手に振る舞わない。
スキルがユーザに明示的にトリガーされる(chat command 経由)か,モデルが自動的に読むと判断したとき,tool-dispatch.ts resolveSkillDispatchTools:L59-L249 に入ります。この関数は「スキルを実行する」のではなく,「スキルがトリガーしたコンテキストでツールセットをフィルタする」こと——例えば coding-agent スキルトリガー時,メインループが子 agent にどのツールを用意するか,profile/global/group/sender/sandbox/subagent/inherited の多層ポリシーを一遍走らせます:
const tools = createOpenClawTools({
agentSessionKey: params.sessionKey,
// ... 大量のコンテキスト
pluginToolAllowlist: collectExplicitAllowlist(explicitPolicyList),
pluginToolDenylist: explicitDenylist,
// ...
});
const policyFiltered = applyToolPolicyPipeline({
tools,
toolMeta: (tool) => getPluginToolMeta(tool),
warn: logVerbose,
steps: [
...buildDefaultToolPolicyPipelineSteps({ /* profile/provider/global/agent/group/sender */ }),
{ policy: sandboxPolicy, label: "sandbox tools.allow" },
{ policy: subagentPolicy, label: "subagent tools.allow" },
{ policy: inheritedToolPolicy, label: "inherited tools" },
],
declaredToolAllowlist: buildDeclaredToolAllowlistContext({ /* ... */ }),
});コメントに「Keep this aligned with the normal tool surfaces so GHSA-mhm4-93fw-4qr2 stays closed across allow/deny, group, sandbox, and subagent policy layers」とあることに注意——これはセキュリティ継ぎ目で,スキルがトリガーしたツール呼び出しは通常のツール呼び出しと同じポリシーパイプラインを走らなければならず,「スキルがトリガーしたから」と言って allowlist をバイパスできません。
境界と失敗
- name/description 欠落時は破棄:
loadSingleSkillDirectory内if (!name || !description) return null;(src/skills/loading/local-loader.ts:L64-L66)。スキルに description がなければモデルはいつ使うべきか分からず,このスキルは無用であり,読み込み段階で破棄します。 - frontmatter 解析失敗時は破棄:
try { parseFrontmatter(raw) } catch { return null; }——1 つの悪い frontmatter が読み込み全体を死なせませんが,このスキルは不可視になります。loadSkillsFromDirSafeはすべての失敗を「このスキルが出現しない」に転換しスローせず,全体可用性を保証します。 - symlink 逃逸防御:
readSkillFileSyncはopenRootFileSyncでスキル root の実際パス境界内でファイルを開きます(src/skills/loading/local-loader.ts:L16-L36)。悪意あるスキルディレクトリが/etc/passwdへの symlink を置き,readツールで読み出されるのを防ぎます。 - read ツール未アクティブ時は注入しない:
hasRead && skills.length > 0が注入の硬条件(src/agents/sessions/system-prompt.ts:L71-L74)。ユーザのツール allowlist がreadを切ったら,システムはモデルにスキルがあると伝えません——「見えるのに食べられない」を避けます。 - disableModelInvocation:
resolveSkillInvocationPolicy(frontmatter)がこのフィールドを解析します。一部の高リスクスキル(例:任意 shell を直接実行するスキル)の作者は「モデルは自動呼び出しできず,ユーザが明示的に chat command しなければならない」と宣言でき,システムは注入段でこれを<available_skills>に入れません。 - promptVersion ドリフト検出:
computeSkillPromptVersionが SKILL.md 内容の安定マーカーを計算し,バージョンが変わったらモデルは次ラウンドで読み直すべきです。これはホットリロードシナリオのため:開発者がスキル内容を変更しても,新ラウンドの対話でモデルが旧バージョン記憶で勝手に振る舞いません。 - スキルはツールの実行体ではない:
resolveSkillDispatchToolsは「スキルを実行する」ように見えますが,実はツールセットフィルタだけで,本当に「スキルに従って仕事する」のはモデル + ツールです。スキルシステムはツールを直接呼び出すことはなく,これが ツールシステム との最大の境界です。
まとめ
スキルはシステムプロンプトに注入される Markdown ガイドで,呼び出し可能関数ではありません。読み込み段階では境界読みで symlink 逃逸を防ぎ,frontmatter 解析失敗を破棄してフォールトトレラントにし,promptVersion で内容ドリフトを検出します。注入段階では read ツールが利用可能なときだけ <available_skills> サマリを詰め込み,本文はモデルが read で按需読みします。スキルがトリガーしたツール呼び出しは resolveSkillDispatchTools の多層ポリシーパイプラインを走り,通常のツール呼び出しと同じ allow/deny ルールを共用します——この継ぎ目が GHSA-mhm4-93fw-4qr2 を閉じます。ツール との違い:ツールは Map 内の関数,スキルは Markdown ガイド。プラグイン との違い:プラグインはディレクトリ約束で発見される拡張パッケージで,スキルはプラグインが携帯できる一つのコンテンツ形式(プラグインの skills/SKILL.md フィールド)です。
公式資料:Skills ドキュメント · README。