Skip to content

Chapter 17: Skills and Plugin System

What You'll Learn

  1. What a Skill is: how a Markdown file with YAML frontmatter becomes an invocable slash command
  2. How loadSkillsDir.ts discovers and parses skill files — the full pipeline from directory scan to Command object
  3. How Bundled Skills (built-in, compiled into the binary) differ architecturally from file-based Skills
  4. How SkillTool invokes a Skill at runtime — inline vs. fork execution modes
  5. Plugin architecture: what a Plugin can carry, how it is installed and managed, how it merges into the command registry
  6. A complete worked example: building a custom Skill from scratch with every frontmatter field explained
  7. Design philosophy: why Markdown powers Skills while structured code powers Plugins

17.1 What Is a Skill

A Skill is a packaged "intent" — it tells Claude what to do in a specific situation. The carrier is a plain Markdown file. The top of the file holds YAML frontmatter (metadata), and the rest is the instruction text that will be injected into Claude's context.

When a user types /commit or /review-pr 123, Claude Code looks up a Skill named commit or review-pr, expands its Markdown content into the conversation, and lets the model execute the embedded instructions. The process is nearly invisible to the user, yet it involves carefully engineered machinery underneath.

A minimal Skill file looks like this:

markdown
---
description: "Commit staged changes with a conventional commit message"
allowed-tools:
  - Bash(git:*)
when_to_use: "Use when the user wants to commit. Examples: 'commit', 'git commit', 'save my changes'"
---

# Commit

Review the staged diff with `git diff --staged`, write a conventional commit message, and run `git commit`.

This file lives at .claude/skills/commit/SKILL.md. The directory name commit becomes the command name.


17.2 File Discovery and Loading

17.2.1 Directory Layout Convention

Skills are loaded from three tiers, in descending priority:

  • Managed (policy-controlled): paths determined by getManagedFilePath(); cannot be overridden by users
  • User: ~/.claude/skills/ — applies across all projects
  • Project: .claude/skills/ — applies only to the current project

Each Skill must be a directory containing a file named SKILL.md (case-insensitive). A single .md file placed directly inside the /skills/ directory is not recognized:

.claude/
└── skills/
    ├── commit/
    │   └── SKILL.md        # correct: directory + SKILL.md
    ├── review-pr/
    │   └── SKILL.md
    └── my-helper.md        # wrong: bare .md files are ignored

This constraint is enforced in loadSkillsDir.ts at line 424:

typescript
// Only support directory format: skill-name/SKILL.md
if (!entry.isDirectory() && !entry.isSymbolicLink()) {
  // Single .md files are NOT supported in /skills/ directory
  return null
}

The directory convention is intentional: a Skill directory can hold companion files (scripts, data files, schemas) that the Skill prompt can reference through the ${CLAUDE_SKILL_DIR} variable.

17.2.2 Parallel Loading and Deduplication

getSkillDirCommands is the entry point for the entire loading pipeline. It is wrapped with lodash.memoize so that real IO only happens once per cwd value:

typescript
// loadSkillsDir.ts: 638-714
export const getSkillDirCommands = memoize(
  async (cwd: string): Promise<Command[]> => {
    // Load from /skills/ directories and legacy /commands/ in parallel
    const [
      managedSkills,
      userSkills,
      projectSkillsNested,
      additionalSkillsNested,
      legacyCommands,
    ] = await Promise.all([
      loadSkillsFromSkillsDir(managedSkillsDir, 'policySettings'),
      loadSkillsFromSkillsDir(userSkillsDir, 'userSettings'),
      Promise.all(projectSkillsDirs.map(dir =>
        loadSkillsFromSkillsDir(dir, 'projectSettings'),
      )),
      // ...
    ])

All directory reads happen concurrently. After the results arrive they are merged and then deduplicated. Deduplication is based on realpath — the symlink-resolved canonical path — rather than the path string itself. This prevents the same Skill file from being loaded twice through different symlinks or overlapping parent directories:

typescript
// loadSkillsDir.ts: 728-763
const fileIds = await Promise.all(
  allSkillsWithPaths.map(({ skill, filePath }) =>
    skill.type === 'prompt'
      ? getFileIdentity(filePath)   // resolves symlinks via realpath()
      : Promise.resolve(null),
  ),
)

const seenFileIds = new Map<string, ...>()
// First-wins: managed > user > project precedence is preserved by iteration order
for (let i = 0; i < allSkillsWithPaths.length; i++) {
  const fileId = fileIds[i]
  if (seenFileIds.has(fileId)) { continue }   // skip duplicate
  seenFileIds.set(fileId, skill.source)
  deduplicatedSkills.push(skill)
}

17.2.3 Frontmatter Parsing

After reading each SKILL.md, parseFrontmatter splits it into a frontmatter object and a body string. Then parseSkillFrontmatterFields extracts the individual fields:

typescript
// loadSkillsDir.ts: 185-264
export function parseSkillFrontmatterFields(
  frontmatter: FrontmatterData,
  markdownContent: string,
  resolvedName: string,
): {
  displayName: string | undefined
  description: string
  allowedTools: string[]
  argumentHint: string | undefined
  argumentNames: string[]
  whenToUse: string | undefined
  model: ReturnType<typeof parseUserSpecifiedModel> | undefined
  effort: EffortValue | undefined
  hooks: HooksSettings | undefined
  executionContext: 'fork' | undefined
  // ...
}

Here is the complete frontmatter reference:

FieldTypeDescription
namestringDisplay name shown in listings; defaults to directory name
descriptionstringOne-line description shown in the skill listing
when_to_usestringDetailed description of when to auto-invoke, with trigger phrase examples
allowed-toolsstring[]Whitelist of tools this Skill is permitted to use
argument-hintstringArgument hint shown in autocomplete, e.g. "<pr-number>"
argumentsstring[]Named argument list for $arg_name substitution in the body
modelstringOverride the model, e.g. claude-opus-4-5, or inherit to keep parent
effortstring/intThinking effort: low/medium/high or an integer budget
contextstringExecution context: fork runs as an isolated sub-agent
pathsstring[]Gitignore-style path patterns; Skill activates only when matching files are touched
hooksobjectSkill-scoped hooks configuration
user-invocablebooleanWhether the Skill appears in the user-visible listing (default: true)
disable-model-invocationbooleanDisables invocation via SkillTool; only user-typed slash commands work

17.2.4 Conditional Activation: the paths Field

The paths field enables on-demand awakening: instead of being immediately active, a Skill with paths is placed into a conditionalSkills map. It is only promoted to the active skill list when a file operation touches a matching path:

typescript
// loadSkillsDir.ts: 997-1058
export function activateConditionalSkillsForPaths(
  filePaths: string[],
  cwd: string,
): string[] {
  for (const [name, skill] of conditionalSkills) {
    const skillIgnore = ignore().add(skill.paths)  // gitignore-style matching
    for (const filePath of filePaths) {
      const relativePath = relative(cwd, filePath)
      if (skillIgnore.ignores(relativePath)) {
        dynamicSkills.set(name, skill)     // now active
        conditionalSkills.delete(name)
        activated.push(name)
        break
      }
    }
  }
  return activated
}

Pattern matching uses the ignore library with the exact same syntax as .gitignore. A Skill with paths: ["src/api/**"] will only appear in the available commands list after you open or edit a file inside src/api/.


17.3 Building the Command Object

Once all frontmatter fields are extracted, createSkillCommand assembles them into a Command object — the uniform representation used throughout the system:

typescript
// loadSkillsDir.ts: 270-401
export function createSkillCommand({ skillName, markdownContent, ... }): Command {
  return {
    type: 'prompt',
    name: skillName,
    // ...
    async getPromptForCommand(args, toolUseContext) {
      // Prepend base directory so the model can Read companion files
      let finalContent = baseDir
        ? `Base directory for this skill: ${baseDir}\n\n${markdownContent}`
        : markdownContent

      // Substitute $arg_name placeholders from frontmatter arguments list
      finalContent = substituteArguments(finalContent, args, true, argumentNames)

      // Replace ${CLAUDE_SKILL_DIR} with the skill's own directory path
      if (baseDir) {
        finalContent = finalContent.replace(/\$\{CLAUDE_SKILL_DIR\}/g, skillDir)
      }

      // Replace ${CLAUDE_SESSION_ID}
      finalContent = finalContent.replace(/\$\{CLAUDE_SESSION_ID\}/g, getSessionId())

      // Execute inline shell commands (!`...` syntax) — never for MCP skills
      if (loadedFrom !== 'mcp') {
        finalContent = await executeShellCommandsInPrompt(finalContent, ...)
      }

      return [{ type: 'text', text: finalContent }]
    },
  } satisfies Command
}

getPromptForCommand is lazy — it runs only when the Skill is actually invoked, not when it is loaded. Even with dozens of registered Skills, startup IO remains minimal.


17.4 Dynamic Discovery in Subdirectories

Skills are not only loaded at startup; they can also be discovered dynamically during a session. When Claude performs a Read/Write/Edit on a file, the system walks upward from that file's directory looking for .claude/skills/ folders:

typescript
// loadSkillsDir.ts: 861-914
export async function discoverSkillDirsForPaths(
  filePaths: string[],
  cwd: string,
): Promise<string[]> {
  for (const filePath of filePaths) {
    let currentDir = dirname(filePath)
    // Walk up to cwd but NOT including cwd itself (cwd-level is loaded at startup)
    while (currentDir.startsWith(resolvedCwd + pathSep)) {
      const skillDir = join(currentDir, '.claude', 'skills')
      if (!dynamicSkillDirs.has(skillDir)) {   // avoid redundant stat calls
        dynamicSkillDirs.add(skillDir)
        if (await isPathGitignored(currentDir, resolvedCwd)) continue  // security guard
        newDirs.push(skillDir)
      }
      currentDir = dirname(currentDir)
    }
  }
  // Sort deepest first — skills closer to the file take precedence
  return newDirs.sort((a, b) => b.split(pathSep).length - a.split(pathSep).length)
}

This enables per-package Skills in a monorepo: when you work inside packages/api/, the Skills in packages/api/.claude/skills/ automatically become available. Gitignored directories (such as node_modules) are explicitly skipped to prevent accidental loading of Skills bundled with third-party packages.


17.5 Bundled Skills

Claude Code ships with a set of built-in Skills that are compiled into the binary rather than stored on disk. bundledSkills.ts provides the registration mechanism:

typescript
// bundledSkills.ts: 53-100
export function registerBundledSkill(definition: BundledSkillDefinition): void {
  const command: Command = {
    type: 'prompt',
    name: definition.name,
    source: 'bundled',
    loadedFrom: 'bundled',
    contentLength: 0,        // not applicable for bundled skills
    getPromptForCommand: definition.getPromptForCommand,
    // ...
  }
  bundledSkills.push(command)
}

A bundled Skill's getPromptForCommand is a TypeScript function that has full access to runtime context — message history, session state, and so on. This is something file-based Skills cannot do. The skillify Skill, for instance, reads the current session's message history to help the user summarize and codify the just-completed workflow:

typescript
// skills/bundled/skillify.ts: 179-195
async getPromptForCommand(args, context) {
  const sessionMemory = (await getSessionMemoryContent()) ?? 'No session memory.'
  const userMessages = extractUserMessages(
    getMessagesAfterCompactBoundary(context.messages),
  )
  const prompt = SKILLIFY_PROMPT
    .replace('{{sessionMemory}}', sessionMemory)
    .replace('{{userMessages}}', userMessages.join('\n\n---\n\n'))
  return [{ type: 'text', text: prompt }]
}

Some bundled Skills also carry companion files (files field). These files are extracted to disk only on the first invocation and cached for subsequent calls:

typescript
// bundledSkills.ts: 59-73
if (files && Object.keys(files).length > 0) {
  skillRoot = getBundledSkillExtractDir(definition.name)
  let extractionPromise: Promise<string | null> | undefined
  getPromptForCommand = async (args, ctx) => {
    // Memoize the promise — concurrent callers await the same extraction
    extractionPromise ??= extractBundledSkillFiles(definition.name, files)
    const extractedDir = await extractionPromise
    // ...
  }
}

File writes use O_EXCL | O_NOFOLLOW flags (equivalent to 'wx' mode on Windows) to guard against symlink substitution attacks — a sign of security-conscious engineering for a surface that handles content from compiled-in data.


17.6 SkillTool: The Runtime Invocation Gateway

SkillTool is the bridge between the model and the Skills system. When the model determines a Skill is needed, it calls this tool rather than attempting to act directly:

typescript
// tools/SkillTool/constants.ts
export const SKILL_TOOL_NAME = 'Skill'

// tools/SkillTool/SkillTool.ts: 291-298
export const inputSchema = lazySchema(() =>
  z.object({
    skill: z.string().describe('The skill name. E.g., "commit", "review-pr"'),
    args: z.string().optional().describe('Optional arguments for the skill'),
  }),
)

17.6.1 The SkillTool System Prompt

The system prompt for SkillTool tells the model exactly when and how to use it (from prompt.ts line 174):

Execute a skill within the main conversation

When users ask you to perform tasks, check if any of the available skills match.

How to invoke:
- Use this tool with the skill name and optional arguments
- Examples:
  - `skill: "pdf"` - invoke the pdf skill
  - `skill: "commit", args: "-m 'Fix bug'"` - invoke with arguments

Important:
- When a skill matches the user's request, this is a BLOCKING REQUIREMENT:
  invoke the relevant Skill tool BEFORE generating any other response
- If you see a <command_name> tag in the current turn, the skill has ALREADY
  been loaded — follow its instructions directly

The phrase "BLOCKING REQUIREMENT" is deliberate. It forces the model to trigger the Skill before generating any free-form response, preventing the anti-pattern of chatting about the task first and calling the Skill as an afterthought.

17.6.2 Two Execution Modes

A Skill runs in one of two modes, controlled by the context frontmatter field:

Inline mode (default)

The Skill's Markdown content is expanded into a user message and injected into the current conversation context. Claude continues in the main conversation thread, following the embedded instructions. This suits Skills that need ongoing user interaction or need to read the existing conversation.

typescript
// SkillTool.ts: inline call path
const processedCommand = await processPromptSlashCommand(
  commandName, args || '', commands, context,
)
return {
  data: { success: true, commandName, allowedTools, model },
  newMessages,      // skill content injected as a user message
  contextModifier,  // adjusts tool permissions and model settings
}

Fork mode

When a Skill sets context: fork, it runs inside an isolated sub-agent with its own token budget and context. The sub-agent completes autonomously and returns a text summary to the main conversation. This is well-suited for self-contained, non-interactive tasks:

typescript
// SkillTool.ts: 622-633
if (command?.type === 'prompt' && command.context === 'fork') {
  return executeForkedSkill(
    command, commandName, args, context, canUseTool, parentMessage, onProgress,
  )
}

The forked result surfaces as a structured response:

typescript
// SkillTool.ts: 276-288
return {
  data: {
    success: true,
    commandName,
    status: 'forked',
    agentId,
    result: resultText,    // extracted from sub-agent messages
  },
}

17.6.3 Permission Checking

SkillTool applies a property-based permission model. Skills whose properties all fall within a predefined safe allowlist are auto-granted; all others prompt the user for explicit approval:

typescript
// SkillTool.ts: 875-908
const SAFE_SKILL_PROPERTIES = new Set([
  'type', 'progressMessage', 'contentLength', 'argNames',
  'model', 'effort', 'source', 'skillRoot', 'context', 'agent',
  'name', 'description', 'isEnabled', 'isHidden', 'aliases',
  'argumentHint', 'whenToUse', 'paths', 'version',
  'disableModelInvocation', 'userInvocable', 'loadedFrom',
  'getPromptForCommand',
  // ...
])

function skillHasOnlySafeProperties(command: Command): boolean {
  for (const key of Object.keys(command)) {
    if (!SAFE_SKILL_PROPERTIES.has(key)) {
      const value = (command as Record<string, unknown>)[key]
      if (value !== undefined && value !== null) return false
    }
  }
  return true
}

Skills that carry hooks or non-empty allowedTools fall outside the safe set and require user confirmation, because they expand Claude's capability boundaries.


17.7 The Plugin System

17.7.1 Plugin vs. Bundled Skill

Understanding Plugins requires first drawing the boundary against Bundled Skills:

DimensionBundled SkillPlugin
OriginCompiled into binaryInstalled from a Marketplace
Management UINone; updated with Claude Code/plugin UI; toggle independently
Can carrySkills (TypeScript function)Skills + MCP servers + Hooks
Update cadenceTied to Claude Code releasesclaude plugin update on demand
ScopeGlobaluser / project / local

The distinction is stated directly in builtinPlugins.ts lines 7-13:

Built-in plugins differ from bundled skills in that:

  • They appear in the /plugin UI under a "Built-in" section
  • Users can enable/disable them (persisted to user settings)
  • They can provide multiple components (skills, hooks, MCP servers)

17.7.2 Plugin Identifier System

Plugin identifiers follow the format {name}@{marketplace}, for example code-reviewer@anthropic. Built-in plugins use the sentinel value {name}@builtin. This format appears everywhere in the Plugin system:

typescript
// pluginOperations.ts: 71-76
/** Valid installable scopes (excludes 'managed') */
export const VALID_INSTALLABLE_SCOPES = ['user', 'project', 'local'] as const

The three scope levels:

  • user: written to ~/.claude/settings.json — applies to all projects for this user
  • project: written to .claude/settings.json — committed to version control, shared with the team
  • local: written to .claude/settings.local.json — applies only to the current user in the current project

17.7.3 Installation: Settings-First Design

Plugin installation follows a settings-first principle: intent is declared in settings before physical installation happens. If the download fails, the next startup will automatically retry:

typescript
// pluginOperations.ts: 321-418
export async function installPluginOp(
  plugin: string,
  scope: InstallableScope = 'user',
): Promise<PluginOperationResult> {
  // Step 1: Search marketplace for plugin metadata
  const pluginInfo = await getPluginById(plugin)

  // Step 2: Write settings (THE ACTION — declares intent)
  // Step 3: Cache plugin + record version hint (materialization)
  const result = await installResolvedPlugin({
    pluginId, entry, scope, marketplaceInstallLocation,
  })

  return {
    success: true,
    message: `Successfully installed plugin: ${pluginId} (scope: ${scope})`,
    pluginId, pluginName: entry.name, scope,
  }
}

17.7.4 Full Plugin Lifecycle

17.7.5 Disabling and Uninstalling

Disabling and uninstalling are distinct operations. Disable sets enabledPlugins[pluginId] to false but preserves the cached files. Uninstall removes both the settings entry and the physical cache:

typescript
// pluginOperations.ts: 508-514
// Delete the settings key (undefined signals deletion in the merge helper)
const newEnabledPlugins = { ...settings?.enabledPlugins }
newEnabledPlugins[pluginId] = undefined
updateSettingsForSource(settingSource, { enabledPlugins: newEnabledPlugins })

// Remove entry from installed_plugins_v2.json
removePluginInstallation(pluginId, scope, projectPath)

Uninstall also checks for reverse dependencies and issues warnings rather than blocking the operation. Blocking would make it impossible to clean up graphs that contain delisted plugins.


17.8 Built-in Plugins

Beyond marketplace-installed Plugins, Claude Code has a built-in plugin mechanism that exposes certain built-in capabilities as user-togglable components:

typescript
// plugins/builtinPlugins.ts: 21-32
const BUILTIN_PLUGINS: Map<string, BuiltinPluginDefinition> = new Map()
export const BUILTIN_MARKETPLACE_NAME = 'builtin'

export function registerBuiltinPlugin(definition: BuiltinPluginDefinition): void {
  BUILTIN_PLUGINS.set(definition.name, definition)
}

Built-in plugin IDs use the {name}@builtin format. They appear in the /plugin UI under a "Built-in" section where users can enable or disable them independently.

Currently, plugins/bundled/index.ts has an empty initBuiltinPlugins() function. This is scaffolding for a future migration path — some bundled Skills that should be user-toggleable will eventually be promoted to built-in Plugins:

typescript
// plugins/bundled/index.ts: 20-23
export function initBuiltinPlugins(): void {
  // No built-in plugins registered yet — this is the scaffolding for
  // migrating bundled skills that should be user-toggleable.
}

17.9 Practical Guide: Creating a Custom Skill

The following walkthrough demonstrates how to create a code-review Skill that asks Claude to analyze the current git diff.

Step 1: Create the directory

bash
mkdir -p .claude/skills/code-review
touch .claude/skills/code-review/SKILL.md

Step 2: Write the SKILL.md

markdown
---
name: code-review
description: "Review current git diff for issues and improvements"
allowed-tools:
  - Bash(git diff:*)
  - Bash(git log:*)
when_to_use: "Use when the user wants a code review of recent changes. Examples: 'review my changes', 'check this diff', 'code review before commit'"
argument-hint: "[--staged | <commit-range>]"
arguments:
  - range
effort: high
---

# Code Review

Perform a thorough code review of the current changes.

## Inputs
- `$range`: Optional git range or `--staged` flag (defaults to uncommitted changes)

## Steps

### 1. Gather the diff

```bash
git diff $range

If $range is empty, fall back to git diff HEAD to capture all uncommitted changes.

Success criteria: You have the full diff to review.

2. Review the diff

Analyze the diff across four dimensions:

  • Correctness: logic errors, edge cases, off-by-one errors
  • Security: injection vulnerabilities, hardcoded secrets, unsafe deserialization
  • Performance: N+1 queries, unnecessary allocations, blocking calls on hot paths
  • Maintainability: overly complex logic, missing error handling, naming clarity

3. Report findings

Group findings by severity:

  • Critical: must be fixed before merging
  • Major: likely bugs; should be addressed
  • Minor: improvements worth considering
  • Nit: style suggestions

Each finding includes: file and line reference, the issue, and a suggested fix.


### Step 3: Verify the Skill is recognized

Start a new Claude Code session (or wait for dynamic discovery to trigger when you edit a file). Type `/code-review` or ask Claude to review your changes. If the Skill loaded correctly, it will appear in the `/help` listing.

### Step 4: Add fork mode (optional)

If the review workflow does not require mid-process user input, set `context: fork` to run it as an isolated sub-agent that does not consume the main conversation's token budget:

```yaml
---
context: fork
effort: high
---

Step 5: Use skillify to generate Skills automatically

Claude Code ships with a skillify built-in Skill that can generate a new Skill file by analyzing the current session. After completing a workflow you want to capture, run:

/skillify the code review process we just did

skillify will analyze the session history, guide you through a structured interview to clarify details, and then write the SKILL.md file to your chosen location.


17.10 Design Philosophy: Why Markdown for Skills, Code for Plugins

For Skills, Markdown was chosen deliberately over code for two reasons.

First, accessibility. A product manager, a designer, or a developer who does not know TypeScript can author and edit a Skill file. The barrier to extending Claude Code is intentionally low. Any team member who can write clear prose can contribute a Skill.

Second, Markdown is simultaneously documentation and configuration. The text that describes what a Skill does is also the prompt that gets fed to Claude. There is no impedance mismatch between the human-readable description and the machine-executable instruction.

For Plugins, a structured code approach is required because:

Plugins coordinate multiple components — MCP servers, Hooks, Skills — that have dependencies between them. This structural complexity exceeds what Markdown can express cleanly. Plugins also need versioning, dependency resolution, and marketplace distribution, all of which require machine-readable manifests.

The split embodies a core principle: make simple things simple, and complex things possible. A Skill is a text file you can write in five minutes. A Plugin is a package you build when you need to ship something that has real installation and lifecycle requirements.


Key Takeaways

Skills are the lightweight extension mechanism. A Markdown file with YAML frontmatter becomes a slash command through loadSkillsDir.ts, which scans three tier directories in parallel, deduplicates by canonical file path, and assembles lazy Command objects whose content is only materialized at invocation time. The SkillTool serves as the single gateway through which the model calls Skills, supporting both inline (context-injection) and fork (isolated sub-agent) execution modes with fine-grained permission controls.

Plugins are the heavyweight extension mechanism. They use {name}@{marketplace} identifiers and support three installation scopes (user / project / local). Installation follows a settings-first design that guarantees idempotent recovery. A Plugin can carry Skills, MCP servers, and Hooks as a single deployable unit. The entire lifecycle — install, enable, disable, update, uninstall — is managed through pluginOperations.ts and exposed as both CLI commands and interactive UI.

The two systems occupy complementary design spaces: Skills lower the floor for customization, Plugins raise the ceiling for capability packaging.

Built for learners who want to read Claude Code like a real system.