Skip to content

Skills: Markdown instructions

源码版本v2026.6.11

Responsibilities

A skill in OpenClaw isn't a callable function — it's a Markdown instruction, specifically a SKILL.md file. When building the system prompt, the system lists all available skills' name / description / location as an <available_skills> XML block and inserts it into the prompt. When the model sees a task matching a skill's description, it uses the read tool itself to read the SKILL.md and follows the steps written there.

This mechanism looks "light", but it's fundamentally different from the tool system: tools are functions registered by the system, and the model can only call names; skills are documents the model decides whether to read. Skill content can be any Markdown — operating steps, checklists, glossaries, code templates — and after reading, the model uses tools or writes code as needed. Skills themselves don't participate in runtime calls.

So the skill system's responsibilities are pure: discovery finds all SKILL.md files, loading parses frontmatter into Skill objects, injection puts summaries into the system prompt, and lifecycle manages skill installation / archiving / uploading. The model handles the rest — whether to read and how to use.

Design motivation

Why not make skills into tools too? Because tool calls are deterministic — parameter schema, input validation, and executor logic all have to be fixed, and calling the same tool always behaves the same way. But many task instructions are naturally narrative: "when hitting a bug, first read logs, then reproduce, then localize" can't be stuffed into a JSON schema. Letting the model understand instructions in natural language and flexibly compose tool calls is a better fit than forcing them into functions.

Another motivation is lazy loading. Skill bodies can be long (tens-of-KB operating manuals), but the system prompt only needs summaries. The model only reads the full body when a task actually matches; in most conversations the skill body never enters the context window. formatSkillsForPrompt in skill-contract.ts formatSkillsForPrompt:L34-L58 outputs only name / description / location / version — for this reason.

The <version> field is a key design: a stable marker of SKILL.md content. The system prompt explicitly tells the model: "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) — if the skill author changes content, the model should re-read on the next turn rather than rely on stale memory.

The source field preserves the origin label (openclaw-bundled / workspace / plugin, etc.), and the disableModelInvocation frontmatter field lets skill authors declare "the model can't auto-invoke this; only the user can explicitly trigger it" — an escape hatch for high-risk operations that need explicit permission.

Key files

Data flow

Skill loading starts from the filesystem (local-loader.ts load single directory:L38-L89):

typescript
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,
  };
}

Note three gates: reading files uses openRootFileSync (src/skills/loading/local-loader.ts:L16-L36), which resolves paths against the root real path to prevent symlinks from escaping the skill directory; frontmatter parse failures silently drop that skill, so one broken skill doesn't take down the whole load; name and description must both exist, otherwise skip. computeSkillPromptVersion computes a stable marker over the content, later inserted into the <version> field.

loadSkillsFromDirSafe (src/skills/loading/local-loader.ts:L107-L151) is the upper-layer wrapper. It first tries the passed dir as a single-skill directory; on failure, treats it as a parent directory and enumerates subdirectories for batch loading. This "first check if it's a skill itself, then check if it's a skill collection" two-layer logic lets both layouts work: skills/ root (single skill) and skills/coding-agent/ (sub-skills).

The loaded Skill array is finally folded into the prompt at system-prompt.ts inject:L70-L74:

typescript
// Append skills section (only if read tool is available)
const customPromptHasRead = !selectedTools || selectedTools.includes("read");
if (customPromptHasRead && skills.length > 0) {
  prompt += formatSkillsForPrompt(skills);
}

There's a key guard here: the skills section is only injected when the read tool is in the active list. Otherwise the model would see <available_skills> but couldn't read SKILL.md, ending up in the awkward "knows it exists but can't call it" state. formatSkillsForPrompt (src/skills/loading/skill-contract.ts:L34-L58) produces XML roughly like:

typescript
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>");

Note the third line explicitly tells the model: "If a skill's <version> differs from a previous turn, re-read its SKILL.md before using it." — if the version changed, re-read; don't rely on memory.

When a skill is triggered by the user explicitly (via chat command) or by the model's own decision to read, it enters tool-dispatch.ts resolveSkillDispatchTools:L59-L249. This function doesn't "execute the skill" — it "filters the tool set in the context triggered by the skill". For example, when the coding-agent skill triggers, the main loop needs to give the sub-agent a specific tool set, going through profile / global / group / sender / sandbox / subagent / inherited multi-layer policies:

typescript
const tools = createOpenClawTools({
  agentSessionKey: params.sessionKey,
  // ... lots of context
  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({ /* ... */ }),
});

Note the comment "Keep this aligned with the normal tool surfaces so GHSA-mhm4-93fw-4qr2 stays closed across allow/deny, group, sandbox, and subagent policy layers" — this is a security seam. Skill-triggered tool calls must go through the same policy pipeline as normal tool calls; "triggered by a skill" must not bypass the allowlist.

Boundaries and failures

  • missing name/description drops the skill: loadSingleSkillDirectory does if (!name || !description) return null; (src/skills/loading/local-loader.ts:L64-L66). Without a description, the model can't know when to use it — such a skill is useless; loading drops it.
  • frontmatter parse failure drops: try { parseFrontmatter(raw) } catch { return null; } — one broken frontmatter doesn't take down the whole load, but that skill is invisible. loadSkillsFromDirSafe turns all failures into "this skill doesn't appear" rather than throwing, keeping the whole usable.
  • symlink escape defense: readSkillFileSync opens files within the skill root real path via openRootFileSync (src/skills/loading/local-loader.ts:L16-L36). This prevents a malicious skill directory from placing a symlink to /etc/passwd and then reading it out via the read tool.
  • read tool not active means no injection: hasRead && skills.length > 0 is the hard condition (src/agents/sessions/system-prompt.ts:L71-L74). If the user's tool allowlist chops read out, the system doesn't tell the model about skills — avoiding "see it but can't eat it".
  • disableModelInvocation: resolveSkillInvocationPolicy(frontmatter) parses this field. Some high-risk skills (like one that executes arbitrary shell) can declare "the model can't auto-invoke; requires an explicit chat command"; the system then omits it from <available_skills> during injection.
  • promptVersion drift detection: computeSkillPromptVersion computes a stable marker over the SKILL.md content; if the version changes the model should re-read next turn. This is designed for hot-reload scenarios: a developer edits skill content; in the next conversation turn the model doesn't run on stale memory.
  • a skill isn't a tool executor: resolveSkillDispatchTools looks like "execute the skill" but only does tool-set filtering. What actually "does work according to the skill" is still the model + tools. The skill system never directly invokes tools — this is its biggest boundary from the Tool system.

Summary

Skills are Markdown instructions injected into the system prompt, not callable functions. The load phase uses boundary reads to prevent symlink escape, drops broken frontmatter for fault tolerance, and detects content drift via promptVersion. The injection phase only inserts the <available_skills> summary when the read tool is available; the body is read on demand by the model. Skill-triggered tool calls go through the resolveSkillDispatchTools multi-layer policy pipeline, sharing the same allow/deny rules as normal tool calls — this seam closes GHSA-mhm4-93fw-4qr2. Difference from Tools: tools are functions in a Map; skills are Markdown instructions. Difference from Plugins: plugins are extension packages discovered by directory convention; skills are one form of content a plugin can carry (the plugin skills/SKILL.md field).

Official references: Skills docs · README.