Plugins and Skills: A Three-Layer Extensibility Architecture

A deep dive into Claude Code's extensibility design — Skill Markdown frontmatter configuration, Plugin two-tier registration, MCP Server dynamic loading, and how all three layers are unified

The Problem

When you type /simplify in Claude Code, it automatically performs a three-dimensional review of your recently modified code — code reuse, quality, and efficiency. When you install a Plugin, its Skills automatically appear in the available list. When an MCP Server exposes Prompts with specific frontmatter markers, those Prompts are also transformed into Skills for the model to invoke.

Behind all this is a three-layer extensibility architecture:

  • Skill layer: Skill definitions carried in Markdown files, with metadata declared via frontmatter, supporting multi-source loading, argument substitution, and conditional activation
  • Plugin layer: Bundles multiple Skills, Hooks, and MCP Servers into installable extension units, split into Built-in and Marketplace tiers
  • MCP layer: Dynamically discovers and loads Skills from remote Servers via the MCP protocol

Each layer has its own independent registration mechanism, but they all ultimately converge into a single Command[] array, with SkillTool serving as the sole entry point to expose them to the model. This article starts from Skill system file loading and progressively dives into the design and implementation of this extensibility architecture.

Skill System Overview

Core Data Structure: Command

All Skills are ultimately represented as the Command type. Understanding this type is foundational to understanding the entire system:

src/types/command.ts
TypeScript
1export type PromptCommand = {
2 type: 'prompt'
3 progressMessage: string
4 contentLength: number
5 argNames?: string[]
6 allowedTools?: string[]
7 model?: string
8 source: SettingSource | 'builtin' | 'mcp' | 'plugin' | 'bundled'
9 pluginInfo?: {
10 pluginManifest: PluginManifest
11 repository: string
12 }
13 hooks?: HooksSettings
14 skillRoot?: string
15 context?: 'inline' | 'fork'
16 agent?: string
17 effort?: EffortValue
18 paths?: string[]
19 getPromptForCommand(
20 args: string,
21 context: ToolUseContext,
22 ): Promise<ContentBlockParam[]>
23}
24
25export type Command = CommandBase &
26 (PromptCommand | LocalCommand | LocalJSXCommand)

The source field marks where the Skill comes from — 'bundled' indicates a skill compiled into the CLI binary, 'plugin' comes from a Plugin, 'mcp' from an MCP Server, and SettingSource values ('userSettings', 'projectSettings', 'policySettings') correspond to file Skills loaded from different directories.

getPromptForCommand is the core method: it accepts user arguments and tool context, returning prompt content to inject into the conversation. Skills from different sources implement their own argument substitution, shell command execution, and security policies within this method.

Multi-Source Loading Architecture

Skill Sources
Managed Skills
(Policy directory)
User Skills
(~/.claude/skills/)
Project Skills
(.claude/skills/)
Legacy Commands
(.claude/commands/)
Bundled Skills
(Compiled in)
Plugin Skills
(Marketplace)
MCP Skills
(Remote Server)
Dynamic Skills
(Runtime discovery)
Loaders
loadSkillsFromSkillsDir()
loadSkillsFromCommandsDir()
registerBundledSkill()
getBuiltinPluginSkillCommands()
mcpSkillBuilders
Unified Registration
getSkillDirCommands()
memoized
getCommands()
merge all sources
SkillTool
Model's sole entry point

Loading priority follows this order, with the first-loaded Skill winning when names collide.

File Skill Loading: loadSkillsDir.ts

The core loading logic for file Skills lives in loadSkillsDir.ts. This file exceeds 1000 lines and is the most complex module in the entire Skill system.

Directory Structure Convention

The Skills directory only supports the directory format: each Skill is a directory containing a SKILL.md file.

Text
1.claude/skills/
2├── review-code/
3│ └── SKILL.md # Skill definition
4├── deploy/
5│ ├── SKILL.md # Skill definition
6│ └── scripts/
7│ └── deploy.sh # Supporting files
8└── frontend:lint/ # Namespace -> "frontend:lint"
9 └── SKILL.md

The loading function loadSkillsFromSkillsDir traverses the directory, reading each SKILL.md:

src/skills/loadSkillsDir.ts
TypeScript
1async function loadSkillsFromSkillsDir(
2 basePath: string,
3 source: SettingSource,
4): Promise<SkillWithPath[]> {
5 const fs = getFsImplementation()
6
7 let entries
8 try {
9 entries = await fs.readdir(basePath)
10 } catch (e: unknown) {
11 if (!isFsInaccessible(e)) logError(e)
12 return []
13 }
14
15 const results = await Promise.all(
16 entries.map(async (entry): Promise<SkillWithPath | null> => {
17 try {
18 // Only directory format supported: skill-name/SKILL.md
19 if (!entry.isDirectory() && !entry.isSymbolicLink()) {
20 return null
21 }
22
23 const skillDirPath = join(basePath, entry.name)
24 const skillFilePath = join(skillDirPath, 'SKILL.md')
25
26 let content: string
27 try {
28 content = await fs.readFile(skillFilePath, { encoding: 'utf-8' })
29 } catch (e: unknown) {
30 if (!isENOENT(e)) {
31 logForDebugging(
32 `[skills] failed to read ${skillFilePath}: ${e}`,
33 { level: 'warn' },
34 )
35 }
36 return null
37 }
38
39 const { frontmatter, content: markdownContent } = parseFrontmatter(
40 content, skillFilePath,
41 )
42
43 const skillName = entry.name
44 const parsed = parseSkillFrontmatterFields(
45 frontmatter, markdownContent, skillName,
46 )
47 const paths = parseSkillPaths(frontmatter)
48
49 return {
50 skill: createSkillCommand({
51 ...parsed,
52 skillName,
53 markdownContent,
54 source,
55 baseDir: skillDirPath,
56 loadedFrom: 'skills',
57 paths,
58 }),
59 filePath: skillFilePath,
60 }
61 } catch (error) {
62 logError(error)
63 return null
64 }
65 }),
66 )
67
68 return results.filter((r): r is SkillWithPath => r !== null)
69}

Note several design points:

  1. Directory format only. Single .md files in the /skills/ directory are ignored, ensuring each Skill can carry supporting files (scripts, templates, etc.).
  2. Symbolic link support. The isSymbolicLink() check enables Skills to be shared via symlinks.
  3. Parallel loading. Promise.all reads all Skill files concurrently.
  4. Graceful degradation. A loading failure for one Skill doesn't affect the others.

Frontmatter Metadata Parsing

The SKILL.md file's frontmatter is the complete declaration of a Skill's behavior. The parseSkillFrontmatterFields function handles all possible fields:

src/skills/loadSkillsDir.ts
TypeScript
1export function parseSkillFrontmatterFields(
2 frontmatter: FrontmatterData,
3 markdownContent: string,
4 resolvedName: string,
5 descriptionFallbackLabel: 'Skill' | 'Custom command' = 'Skill',
6): {
7 displayName: string | undefined
8 description: string
9 hasUserSpecifiedDescription: boolean
10 allowedTools: string[]
11 argumentHint: string | undefined
12 argumentNames: string[]
13 whenToUse: string | undefined
14 version: string | undefined
15 model: ReturnType<typeof parseUserSpecifiedModel> | undefined
16 disableModelInvocation: boolean
17 userInvocable: boolean
18 hooks: HooksSettings | undefined
19 executionContext: 'fork' | undefined
20 agent: string | undefined
21 effort: EffortValue | undefined
22 shell: FrontmatterShell | undefined
23} {
24 // ...parsing logic
25}

A complete SKILL.md frontmatter example:

MARKDOWN
1---
2name: "Code Review Assistant"
3description: "Perform multi-dimensional code review on Git changes"
4when_to_use: "Trigger when user asks to review code or submit a PR"
5allowed-tools:
6 - Bash(git:*)
7 - Read
8 - Grep
9arguments:
10 - branch
11 - focus_area
12argument-hint: "<branch> [focus_area]"
13model: sonnet
14effort: high
15context: fork
16agent: general-purpose
17user-invocable: true
18disable-model-invocation: false
19paths:
20 - "src/**"
21 - "lib/**"
22hooks:
23 PreToolUse:
24 - matcher: Bash
25 hooks:
26 - type: command
27 command: echo "Reviewing..."
28shell:
29 type: bash
30 command: /bin/bash
31---
32
33## Review Process
34
35Review the changes on the $ARGUMENTS branch across the following dimensions...
36
37Current Session ID: ${CLAUDE_SESSION_ID}
38Skill directory: ${CLAUDE_SKILL_DIR}

The semantics of these frontmatter fields are worth explaining one by one:

FieldTypePurpose
namestringDisplay name (doesn't affect command name)
descriptionstringBrief description, used in Skill listings
when_to_usestringTells the model when it should invoke this Skill
allowed-toolsstring[]Additional tools allowed during Skill execution
argumentsstring/string[]Named argument list
modelstringModel override (e.g., sonnet, opus, inherit)
effortEffortValueReasoning effort level
context'fork'Whether to execute in a sub-Agent
pathsstring[]File path patterns for conditional activation
hooksHooksSettingsSkill-level Hook configuration
user-invocablebooleanWhether users can invoke via /name
disable-model-invocationbooleanPrevent model from proactively invoking

Argument Substitution Mechanism

Skill prompt content supports multiple argument substitution patterns:

src/skills/loadSkillsDir.ts
TypeScript
1async getPromptForCommand(args, toolUseContext) {
2 let finalContent = baseDir
3 ? `Base directory for this skill: ${baseDir}\n\n${markdownContent}`
4 : markdownContent
5
6 // 1. User argument substitution: $ARGUMENTS, $1, $2, or named arguments
7 finalContent = substituteArguments(
8 finalContent, args, true, argumentNames,
9 )
10
11 // 2. Built-in variable substitution
12 if (baseDir) {
13 const skillDir =
14 process.platform === 'win32' ? baseDir.replace(/\\/g, '/') : baseDir
15 finalContent = finalContent.replace(/\$\{CLAUDE_SKILL_DIR\}/g, skillDir)
16 }
17
18 finalContent = finalContent.replace(
19 /\$\{CLAUDE_SESSION_ID\}/g,
20 getSessionId(),
21 )
22
23 // 3. Shell command execution (non-MCP Skills only)
24 if (loadedFrom !== 'mcp') {
25 finalContent = await executeShellCommandsInPrompt(
26 finalContent, toolUseContext, `/${skillName}`, shell,
27 )
28 }
29
30 return [{ type: 'text', text: finalContent }]
31}

There are three layers of substitution:

  1. User arguments: $ARGUMENTS is replaced with the full argument string, $1/$2 with positional arguments, and named arguments like ${branch} with their corresponding values.
  2. Built-in variables: ${CLAUDE_SKILL_DIR} points to the Skill's directory, and ${CLAUDE_SESSION_ID} is the current session ID.
  3. Shell commands: The !`command` and ```! command ``` syntax within Skill content is actually executed, with output substituted into the prompt. This is a key capability for Skills to interact with the environment — but for security reasons, MCP-sourced Skills are prohibited from executing shell commands.

Multi-Level Source Aggregation and Deduplication

getSkillDirCommands is the aggregation entry point for file Skills. It uses memoize to cache results and loads Skills from five sources in parallel:

src/skills/loadSkillsDir.ts
TypeScript
1export const getSkillDirCommands = memoize(
2 async (cwd: string): Promise<Command[]> => {
3 const userSkillsDir = join(getClaudeConfigHomeDir(), 'skills')
4 const managedSkillsDir = join(getManagedFilePath(), '.claude', 'skills')
5 const projectSkillsDirs = getProjectDirsUpToHome('skills', cwd)
6
7 const [
8 managedSkills,
9 userSkills,
10 projectSkillsNested,
11 additionalSkillsNested,
12 legacyCommands,
13 ] = await Promise.all([
14 loadSkillsFromSkillsDir(managedSkillsDir, 'policySettings'),
15 loadSkillsFromSkillsDir(userSkillsDir, 'userSettings'),
16 Promise.all(
17 projectSkillsDirs.map(dir =>
18 loadSkillsFromSkillsDir(dir, 'projectSettings'),
19 ),
20 ),
21 Promise.all(
22 additionalDirs.map(dir =>
23 loadSkillsFromSkillsDir(
24 join(dir, '.claude', 'skills'), 'projectSettings',
25 ),
26 ),
27 ),
28 loadSkillsFromCommandsDir(cwd),
29 ])
30
31 // Merge all sources
32 const allSkillsWithPaths = [
33 ...managedSkills,
34 ...userSkills,
35 ...projectSkillsNested.flat(),
36 ...additionalSkillsNested.flat(),
37 ...legacyCommands,
38 ]
39
40 // ... deduplication and conditional Skill separation
41 },
42)

Note the source priority order: Managed (enterprise policy) > User (user global) > Project (project-level) > Additional (--add-dir) > Legacy (old /commands/).

Realpath-Based Deduplication

Multiple sources may point to the same file through different paths (e.g., via symlink). The system uses realpath to resolve to canonical paths for deduplication:

src/skills/loadSkillsDir.ts
TypeScript
1async function getFileIdentity(filePath: string): Promise<string | null> {
2 try {
3 return await realpath(filePath)
4 } catch {
5 return null
6 }
7}

The deduplication process first computes identities for all files in parallel, then synchronously scans to remove duplicates:

src/skills/loadSkillsDir.ts
TypeScript
1const fileIds = await Promise.all(
2 allSkillsWithPaths.map(({ skill, filePath }) =>
3 skill.type === 'prompt'
4 ? getFileIdentity(filePath)
5 : Promise.resolve(null),
6 ),
7)
8
9const seenFileIds = new Map<string, SettingSource | ...>()
10const deduplicatedSkills: Command[] = []
11
12for (let i = 0; i < allSkillsWithPaths.length; i++) {
13 const entry = allSkillsWithPaths[i]
14 if (entry === undefined || entry.skill.type !== 'prompt') continue
15 const { skill } = entry
16
17 const fileId = fileIds[i]
18 if (fileId === null || fileId === undefined) {
19 deduplicatedSkills.push(skill)
20 continue
21 }
22
23 const existingSource = seenFileIds.get(fileId)
24 if (existingSource !== undefined) {
25 logForDebugging(
26 `Skipping duplicate skill '${skill.name}' from ${skill.source}`,
27 )
28 continue
29 }
30
31 seenFileIds.set(fileId, skill.source)
32 deduplicatedSkills.push(skill)
33}

This design chose realpath over inode comparison because some filesystems (NFS, ExFAT, container virtual FS) report unreliable inode values. The pattern of doing IO in parallel first (Promise.all for identities), then synchronous logic (iterative deduplication), is a typical approach that balances performance and correctness.

Paths Conditional Activation

Skills can declare via paths frontmatter that they should only be active under specific file paths:

YAML
1---
2paths:
3 - "src/components/**"
4 - "src/styles/**"
5---

When a Skill has a paths field, it's not immediately loaded into the available list but stored in a conditionalSkills Map. Only when the model operates on files matching the paths is the Skill activated:

src/skills/loadSkillsDir.ts
TypeScript
1export function activateConditionalSkillsForPaths(
2 filePaths: string[],
3 cwd: string,
4): string[] {
5 if (conditionalSkills.size === 0) {
6 return []
7 }
8
9 const activated: string[] = []
10
11 for (const [name, skill] of conditionalSkills) {
12 if (skill.type !== 'prompt' || !skill.paths || skill.paths.length === 0) {
13 continue
14 }
15
16 const skillIgnore = ignore().add(skill.paths)
17 for (const filePath of filePaths) {
18 const relativePath = isAbsolute(filePath)
19 ? relative(cwd, filePath)
20 : filePath
21
22 if (
23 !relativePath ||
24 relativePath.startsWith('..') ||
25 isAbsolute(relativePath)
26 ) {
27 continue
28 }
29
30 if (skillIgnore.ignores(relativePath)) {
31 dynamicSkills.set(name, skill)
32 conditionalSkills.delete(name)
33 activatedConditionalSkillNames.add(name)
34 activated.push(name)
35 break
36 }
37 }
38 }
39
40 if (activated.length > 0) {
41 skillsLoaded.emit()
42 }
43
44 return activated
45}

This uses the ignore library (the same glob matching rules as .gitignore). After matching, the Skill is moved from conditionalSkills to dynamicSkills, and other modules are notified via a signal to clear caches. The activatedConditionalSkillNames Set ensures that when Skills are reloaded (after cache clearing), already-activated Skills won't be demoted back to conditional.

This "lazy activation" design has a clear performance purpose: a project may have many Skills, but not all are relevant to the current work. Through path filtering, the Skill list stays lean, reducing token consumption in the system prompt.

Dynamic Skill Discovery

Beyond conditional activation, the system also supports dynamically discovering new Skill directories from file operations:

src/skills/loadSkillsDir.ts
TypeScript
1export async function discoverSkillDirsForPaths(
2 filePaths: string[],
3 cwd: string,
4): Promise<string[]> {
5 const fs = getFsImplementation()
6 const resolvedCwd = cwd.endsWith(pathSep) ? cwd.slice(0, -1) : cwd
7 const newDirs: string[] = []
8
9 for (const filePath of filePaths) {
10 let currentDir = dirname(filePath)
11
12 // Walk up from file location to cwd, checking each level for .claude/skills/
13 while (currentDir.startsWith(resolvedCwd + pathSep)) {
14 const skillDir = join(currentDir, '.claude', 'skills')
15
16 if (!dynamicSkillDirs.has(skillDir)) {
17 dynamicSkillDirs.add(skillDir)
18 try {
19 await fs.stat(skillDir)
20 // Check if gitignored
21 if (await isPathGitignored(currentDir, resolvedCwd)) {
22 continue
23 }
24 newDirs.push(skillDir)
25 } catch {
26 // Directory doesn't exist, continue
27 }
28 }
29
30 const parent = dirname(currentDir)
31 if (parent === currentDir) break
32 currentDir = parent
33 }
34 }
35
36 // Sort by depth, deeper directories have higher priority
37 return newDirs.sort(
38 (a, b) => b.split(pathSep).length - a.split(pathSep).length,
39 )
40}

When the model Reads or Edits a file like src/modules/payments/handler.ts, the system checks upward for src/modules/payments/.claude/skills/, src/modules/.claude/skills/, and so on. If new Skill directories are found, they're loaded and merged into the available Skills.

Worth noting is that the dynamicSkillDirs Set also records directories that have been checked (whether successfully or not), avoiding duplicate stat calls to the same directory. Additionally, Skill directories under .gitignore paths are skipped, preventing malicious Skills in node_modules from being loaded.

Effort Level

Skills can control the model's reasoning effort level via the effort frontmatter:

src/skills/loadSkillsDir.ts
TypeScript
1const effortRaw = frontmatter['effort']
2const effort =
3 effortRaw !== undefined ? parseEffortValue(effortRaw) : undefined
4if (effortRaw !== undefined && effort === undefined) {
5 logForDebugging(
6 `Skill ${resolvedName} has invalid effort '${effortRaw}'.` +
7 ` Valid options: ${EFFORT_LEVELS.join(', ')} or an integer`,
8 )
9}

When a Skill runs with context: fork, effort is injected into the sub-Agent's definition:

src/tools/SkillTool/SkillTool.ts
TypeScript
1const agentDefinition =
2 command.effort !== undefined
3 ? { ...baseAgent, effort: command.effort }
4 : baseAgent

This allows specific Skills to demand higher or lower reasoning intensity — for example, a code review Skill might set effort: high, while a simple formatting Skill sets effort: low for faster execution.

Bundled Skill System

The registerBundledSkill Registration Pattern

Bundled Skills are compiled into the CLI binary and don't depend on the filesystem. They're registered at startup via the registerBundledSkill function:

src/skills/bundledSkills.ts
TypeScript
1export function registerBundledSkill(definition: BundledSkillDefinition): void {
2 const { files } = definition
3
4 let skillRoot: string | undefined
5 let getPromptForCommand = definition.getPromptForCommand
6
7 if (files && Object.keys(files).length > 0) {
8 skillRoot = getBundledSkillExtractDir(definition.name)
9 let extractionPromise: Promise<string | null> | undefined
10 const inner = definition.getPromptForCommand
11 getPromptForCommand = async (args, ctx) => {
12 extractionPromise ??= extractBundledSkillFiles(definition.name, files)
13 const extractedDir = await extractionPromise
14 const blocks = await inner(args, ctx)
15 if (extractedDir === null) return blocks
16 return prependBaseDir(blocks, extractedDir)
17 }
18 }
19
20 const command: Command = {
21 type: 'prompt',
22 name: definition.name,
23 description: definition.description,
24 aliases: definition.aliases,
25 hasUserSpecifiedDescription: true,
26 allowedTools: definition.allowedTools ?? [],
27 // ...other fields
28 source: 'bundled',
29 loadedFrom: 'bundled',
30 getPromptForCommand,
31 }
32 bundledSkills.push(command)
33}

There's an elegant lazy file extraction mechanism here: when a Bundled Skill declares files (reference files), these files aren't written to disk at registration time but extracted on first invocation. extractionPromise ??= ... uses the nullish assignment operator for memoization — multiple concurrent invocations share the same Promise without duplicate extraction.

Secure File Writing

When extracting files to disk, the system employs multiple layers of security measures:

src/skills/bundledSkills.ts
TypeScript
1const O_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0
2const SAFE_WRITE_FLAGS =
3 process.platform === 'win32'
4 ? 'wx'
5 : fsConstants.O_WRONLY |
6 fsConstants.O_CREAT |
7 fsConstants.O_EXCL |
8 O_NOFOLLOW
9
10async function safeWriteFile(p: string, content: string): Promise<void> {
11 const fh = await open(p, SAFE_WRITE_FLAGS, 0o600)
12 try {
13 await fh.writeFile(content, 'utf8')
14 } finally {
15 await fh.close()
16 }
17}
  • O_EXCL: Fails if the file already exists, preventing overwrite of pre-created malicious files
  • O_NOFOLLOW: Doesn't follow symbolic links, preventing symlink attacks
  • 0o600: File permissions restricted to owner read/write
  • 0o700: Directory permissions restricted to owner only

Path validation is equally strict:

src/skills/bundledSkills.ts
TypeScript
1function resolveSkillFilePath(baseDir: string, relPath: string): string {
2 const normalized = normalize(relPath)
3 if (
4 isAbsolute(normalized) ||
5 normalized.split(pathSep).includes('..') ||
6 normalized.split('/').includes('..')
7 ) {
8 throw new Error(`bundled skill file path escapes skill dir: ${relPath}`)
9 }
10 return join(baseDir, normalized)
11}

Bundled Skill Registration Flow

All Bundled Skills are registered in initBundledSkills:

src/skills/bundled/index.ts
TypeScript
1export function initBundledSkills(): void {
2 registerUpdateConfigSkill()
3 registerKeybindingsSkill()
4 registerVerifySkill()
5 registerDebugSkill()
6 registerLoremIpsumSkill()
7 registerSkillifySkill()
8 registerRememberSkill()
9 registerSimplifySkill()
10 registerBatchSkill()
11 registerStuckSkill()
12
13 // Feature-gated skills
14 if (feature('KAIROS') || feature('KAIROS_DREAM')) {
15 const { registerDreamSkill } = require('./dream.js')
16 registerDreamSkill()
17 }
18 if (feature('AGENT_TRIGGERS')) {
19 const { registerLoopSkill } = require('./loop.js')
20 registerLoopSkill()
21 }
22 // ...more feature-gated skills
23}

There are two registration patterns:

  1. Unconditional registration: Always-available Skills like registerSimplifySkill() are imported at the module top level
  2. Feature-gated registration: Uses feature() to check feature flags, with require() for lazy loading

require() is used instead of import() because after Bun bundling, dynamic import() path resolution points to /$bunfs/root/..., while require() works correctly.

Practical Example: The simplify Skill

Let's see how an actual Bundled Skill works:

src/skills/bundled/simplify.ts
TypeScript
1export function registerSimplifySkill(): void {
2 registerBundledSkill({
3 name: 'simplify',
4 description:
5 'Review changed code for reuse, quality, and efficiency, ' +
6 'then fix any issues found.',
7 userInvocable: true,
8 async getPromptForCommand(args) {
9 let prompt = SIMPLIFY_PROMPT
10 if (args) {
11 prompt += `\n\n## Additional Focus\n\n${args}`
12 }
13 return [{ type: 'text', text: prompt }]
14 },
15 })
16}

SIMPLIFY_PROMPT is a carefully designed multi-stage prompt that instructs the model to:

  1. Run git diff to identify changes
  2. Launch three parallel Agents to review code reuse, code quality, and efficiency respectively
  3. Summarize findings and directly fix issues

This demonstrates the core value of Bundled Skills: encapsulating expert-level multi-step workflows into a single command.

Plugin System

Two-Tier Plugin Architecture

Built-in Plugins
registerBuiltinPlugin()
BuiltinPluginDefinition
BUILTIN_PLUGINS Map
Marketplace Plugins
git clone / install
Plugin Manifest
commands/ skills/ agents/
Plugin Management
/plugin UI
settings.enabledPlugins
getBuiltinPlugins()
getBuiltinPluginSkillCommands()
Output
LoadedPlugin[]
Command[]

Built-in Plugin

The key difference between Built-in Plugins and Bundled Skills is: users can enable/disable Built-in Plugins.

src/types/plugin.ts
TypeScript
1export type BuiltinPluginDefinition = {
2 name: string
3 description: string
4 version?: string
5 skills?: BundledSkillDefinition[]
6 hooks?: HooksSettings
7 mcpServers?: Record<string, McpServerConfig>
8 isAvailable?: () => boolean
9 defaultEnabled?: boolean
10}

A Built-in Plugin can contain multiple components:

  • skills: A list of skills defined via BundledSkillDefinition
  • hooks: Lifecycle hook configuration
  • mcpServers: MCP Server configuration

Plugin IDs use the {name}@builtin format, distinguishing them from Marketplace Plugins' {name}@{marketplace}.

Enable/Disable State Management

src/plugins/builtinPlugins.ts
TypeScript
1export function getBuiltinPlugins(): {
2 enabled: LoadedPlugin[]
3 disabled: LoadedPlugin[]
4} {
5 const settings = getSettings_DEPRECATED()
6 const enabled: LoadedPlugin[] = []
7 const disabled: LoadedPlugin[] = []
8
9 for (const [name, definition] of BUILTIN_PLUGINS) {
10 // Availability check (e.g., platform restrictions)
11 if (definition.isAvailable && !definition.isAvailable()) {
12 continue
13 }
14
15 const pluginId = `${name}@${BUILTIN_MARKETPLACE_NAME}`
16 const userSetting = settings?.enabledPlugins?.[pluginId]
17 // Priority: user setting > Plugin default > true
18 const isEnabled =
19 userSetting !== undefined
20 ? userSetting === true
21 : (definition.defaultEnabled ?? true)
22
23 const plugin: LoadedPlugin = {
24 name,
25 manifest: {
26 name,
27 description: definition.description,
28 version: definition.version,
29 },
30 path: BUILTIN_MARKETPLACE_NAME,
31 source: pluginId,
32 repository: pluginId,
33 enabled: isEnabled,
34 isBuiltin: true,
35 hooksConfig: definition.hooks,
36 mcpServers: definition.mcpServers,
37 }
38
39 if (isEnabled) {
40 enabled.push(plugin)
41 } else {
42 disabled.push(plugin)
43 }
44 }
45
46 return { enabled, disabled }
47}

The state determination chain: isAvailable() -> userSetting -> defaultEnabled -> true. If isAvailable() returns false, the Plugin is completely invisible; otherwise the user's setting in the /plugin UI takes priority, falling back to the Plugin's default value, with a final fallback to enabled.

Skill Conversion from Plugin to Command

Plugin Skills are converted to standard Command objects via skillDefinitionToCommand:

src/plugins/builtinPlugins.ts
TypeScript
1function skillDefinitionToCommand(definition: BundledSkillDefinition): Command {
2 return {
3 type: 'prompt',
4 name: definition.name,
5 description: definition.description,
6 hasUserSpecifiedDescription: true,
7 allowedTools: definition.allowedTools ?? [],
8 // Key point: source is set to 'bundled' not 'builtin'
9 // 'builtin' in Command.source represents hardcoded /help, /clear, etc.
10 // Using 'bundled' ensures Plugin Skills appear in the SkillTool list
11 source: 'bundled',
12 loadedFrom: 'bundled',
13 hooks: definition.hooks,
14 context: definition.context,
15 agent: definition.agent,
16 isEnabled: definition.isEnabled ?? (() => true),
17 isHidden: !(definition.userInvocable ?? true),
18 progressMessage: 'running',
19 getPromptForCommand: definition.getPromptForCommand,
20 }
21}

The choice of source: 'bundled' here is deliberate — a code comment explains why: 'builtin' in Command.source semantics means hardcoded CLI commands (/help, /clear); if Plugin Skills used 'builtin', they would disappear from the SkillTool list.

Marketplace Plugin

Marketplace Plugins are represented by the LoadedPlugin type, with a richer structure:

src/types/plugin.ts
TypeScript
1export type LoadedPlugin = {
2 name: string
3 manifest: PluginManifest
4 path: string
5 source: string
6 repository: string
7 enabled?: boolean
8 isBuiltin?: boolean
9 sha?: string // Git commit SHA version lock
10 commandsPath?: string
11 commandsPaths?: string[] // Additional command paths from manifest
12 agentsPath?: string
13 agentsPaths?: string[]
14 skillsPath?: string
15 skillsPaths?: string[]
16 outputStylesPath?: string
17 outputStylesPaths?: string[]
18 hooksConfig?: HooksSettings
19 mcpServers?: Record<string, McpServerConfig>
20 lspServers?: Record<string, LspServerConfig>
21 settings?: Record<string, unknown>
22}

Marketplace Plugins are distributed via Git repositories. The installation process clones the repository locally, reads the manifest file, then loads various components based on paths declared in the manifest. Skill files use the same directory format as project-level Skills (skill-name/SKILL.md), handled by the same loadSkillsFromSkillsDir loader.

Plugin namespacing uses colon separation: if a Plugin named ralph-loop provides help and cancel-ralph Skills, their full names are ralph-loop:help and ralph-loop:cancel-ralph.

MCP Skill Bridging

mcpSkillBuilders Registration

MCP Servers can expose Skills through the Prompt primitive. When an MCP Prompt's frontmatter contains specific fields, it gets transformed into a Skill rather than a regular Prompt:

src/skills/mcpSkillBuilders.ts
TypeScript
1export type MCPSkillBuilders = {
2 createSkillCommand: typeof createSkillCommand
3 parseSkillFrontmatterFields: typeof parseSkillFrontmatterFields
4}
5
6let builders: MCPSkillBuilders | null = null
7
8export function registerMCPSkillBuilders(b: MCPSkillBuilders): void {
9 builders = b
10}
11
12export function getMCPSkillBuilders(): MCPSkillBuilders {
13 if (!builders) {
14 throw new Error(
15 'MCP skill builders not registered — ' +
16 'loadSkillsDir.ts has not been evaluated yet',
17 )
18 }
19 return builders
20}

This module solves a subtle circular dependency problem. The MCP code needs to call createSkillCommand and parseSkillFrontmatterFields, but directly importing loadSkillsDir.ts would bring in a massive transitive dependency tree, producing numerous circular warnings in dependency-cruiser checks.

The solution is runtime registration: mcpSkillBuilders.ts only imports types (typeof), creating no runtime dependency. loadSkillsDir.ts registers the actual functions during module initialization, which happens before any MCP Server connects.

Differences Between MCP Skills and File Skills

MCP-sourced Skills have two important restrictions:

  1. Shell command execution is prohibited:
src/skills/loadSkillsDir.ts
TypeScript
1// Security: MCP skills are remote and untrusted — never execute inline
2// shell commands (!`…` / ```! … ```) from their markdown body.
3if (loadedFrom !== 'mcp') {
4 finalContent = await executeShellCommandsInPrompt(
5 finalContent, toolUseContext, `/${skillName}`, shell,
6 )
7}
  1. ${CLAUDE_SKILL_DIR} is meaningless: MCP Skills have no local directory, so the Skill directory variable is not substituted.

MCP Skills are retrieved in SkillTool.call() via getAllCommands, which filters loadedFrom === 'mcp' commands from AppState.mcp.commands:

src/tools/SkillTool/SkillTool.ts
TypeScript
1async function getAllCommands(context: ToolUseContext): Promise<Command[]> {
2 const mcpSkills = context
3 .getAppState()
4 .mcp.commands.filter(
5 cmd => cmd.type === 'prompt' && cmd.loadedFrom === 'mcp',
6 )
7 if (mcpSkills.length === 0) return getCommands(getProjectRoot())
8 const localCommands = await getCommands(getProjectRoot())
9 return uniqBy([...localCommands, ...mcpSkills], 'name')
10}

uniqBy deduplicates by name, with local commands taking priority (appearing first in the array).

SkillTool Execution Flow

Execution Modes

SkillTool is the sole tool interface through which the model invokes Skills. It supports two execution modes:

sequenceDiagram
    participant M as Model
    participant ST as SkillTool
    participant V as validateInput
    participant P as checkPermissions
    participant C as call()

    M->>ST: { skill: "simplify", args: "" }
    ST->>V: Verify Skill exists
    V-->>ST: result: true

    ST->>P: Permission check
    alt Safe-attribute Skill
        P-->>ST: allow (auto)
    else Has allow rule
        P-->>ST: allow (rule)
    else Needs user confirmation
        P-->>ST: ask
    end

    ST->>C: Execute Skill

    alt context: 'fork'
        C->>C: executeForkedSkill()
        Note over C: Runs in sub-Agent<br/>Independent token budget
        C-->>ST: { status: 'forked', result: "..." }
    else context: 'inline' (default)
        C->>C: processPromptSlashCommand()
        Note over C: Expands prompt into<br/>current conversation context
        C-->>ST: { status: 'inline', newMessages: [...] }
    end

    ST-->>M: ToolResult

    style M fill:#1e3a5f,color:#e0e0e0
    style ST fill:#2d1f3d,color:#e0e0e0
    style V fill:#1a3d2e,color:#e0e0e0
    style P fill:#3d2e1a,color:#e0e0e0
    style C fill:#3d1a1a,color:#e0e0e0

Inline Mode (Default)

Inline mode expands the Skill's prompt content into the current conversation context:

src/tools/SkillTool/SkillTool.ts
TypeScript
1const processedCommand = await processPromptSlashCommand(
2 commandName,
3 args || '',
4 commands,
5 context,
6)
7
8if (!processedCommand.shouldQuery) {
9 throw new Error('Command processing failed')
10}

Results are returned as newMessages, which are inserted into the current conversation flow. The context is simultaneously modified via contextModifier — adding allowedTools and passing through the effort level.

Fork Mode

When a Skill declares context: 'fork', it executes in an independent sub-Agent:

src/tools/SkillTool/SkillTool.ts
TypeScript
1async function executeForkedSkill(
2 command: Command & { type: 'prompt' },
3 commandName: string,
4 args: string | undefined,
5 context: ToolUseContext,
6 canUseTool: CanUseToolFn,
7 parentMessage: AssistantMessage,
8 onProgress?: ToolCallProgress<Progress>,
9): Promise<ToolResult<Output>> {
10 const agentId = createAgentId()
11
12 const { modifiedGetAppState, baseAgent, promptMessages, skillContent } =
13 await prepareForkedCommandContext(command, args || '', context)
14
15 const agentDefinition =
16 command.effort !== undefined
17 ? { ...baseAgent, effort: command.effort }
18 : baseAgent
19
20 const agentMessages: Message[] = []
21
22 for await (const message of runAgent({
23 agentDefinition,
24 promptMessages,
25 toolUseContext: {
26 ...context,
27 getAppState: modifiedGetAppState,
28 },
29 canUseTool,
30 isAsync: false,
31 querySource: 'agent:custom',
32 model: command.model as ModelAlias | undefined,
33 availableTools: context.options.tools,
34 override: { agentId },
35 })) {
36 agentMessages.push(message)
37 // Report tool call progress to parent
38 }
39
40 const resultText = extractResultText(
41 agentMessages,
42 'Skill execution completed',
43 )
44 agentMessages.length = 0 // Release message memory
45
46 return {
47 data: {
48 success: true,
49 commandName,
50 status: 'forked',
51 agentId,
52 result: resultText,
53 },
54 }
55}

Advantages of Fork mode:

  • Independent token budget: The sub-Agent has its own context window, not consuming the main conversation's tokens
  • Isolation: Tool calls and intermediate results during Skill execution don't pollute the main conversation
  • Memory management: After completion, agentMessages.length = 0 proactively releases message memory, and clearInvokedSkillsForAgent(agentId) cleans up Skill state

Permission Checking

SkillTool's permission checking distinguishes several scenarios:

  1. Deny rules checked first: If the user or policy has configured a deny rule (e.g., Skill(deploy)), reject immediately
  2. Safe attributes auto-allow: If the Skill has only safe attributes (no allowedTools, no hooks, no context: fork), allow automatically
  3. Allow rule matching: Match user-configured allow rules, supporting prefix wildcards (review:* matches all Skills starting with review:)
  4. Ask as fallback: Default to asking the user, providing both "allow this Skill" and "allow this prefix" as shortcut suggestions
src/tools/SkillTool/SkillTool.ts
TypeScript
1const suggestions = [
2 {
3 type: 'addRules' as const,
4 rules: [{ toolName: SKILL_TOOL_NAME, ruleContent: commandName }],
5 behavior: 'allow' as const,
6 destination: 'localSettings' as const,
7 },
8 {
9 type: 'addRules' as const,
10 rules: [{ toolName: SKILL_TOOL_NAME, ruleContent: `${commandName}:*` }],
11 behavior: 'allow' as const,
12 destination: 'localSettings' as const,
13 },
14]

Skill Listing and Token Budget Management

Skill discovery information is exposed to the model through system-reminder messages. However, there may be many Skills, and listing full descriptions for all of them would waste precious context window tokens. prompt.ts implements a budget management mechanism:

src/tools/SkillTool/prompt.ts
TypeScript
1export const SKILL_BUDGET_CONTEXT_PERCENT = 0.01 // 1% of context window
2export const CHARS_PER_TOKEN = 4
3export const DEFAULT_CHAR_BUDGET = 8_000 // Fallback: 200K x 4 x 1%

When total Skill descriptions exceed the budget, the system employs a tiered truncation strategy:

src/tools/SkillTool/prompt.ts
TypeScript
1export function formatCommandsWithinBudget(
2 commands: Command[],
3 contextWindowTokens?: number,
4): string {
5 if (commands.length === 0) return ''
6
7 const budget = getCharBudget(contextWindowTokens)
8
9 // Try full descriptions
10 const fullTotal = fullEntries.reduce(
11 (sum, e) => sum + stringWidth(e.full), 0,
12 )
13 if (fullTotal <= budget) {
14 return fullEntries.map(e => e.full).join('\n')
15 }
16
17 // Partition: bundled (never truncated) vs others
18 // Bundled Skills always keep full descriptions
19 // Other Skills have descriptions truncated proportionally
20
21 if (maxDescLen < MIN_DESC_LENGTH) {
22 // Extreme case: other Skills show name only
23 return commands.map((cmd, i) =>
24 bundledIndices.has(i) ? fullEntries[i]!.full : `- ${cmd.name}`,
25 ).join('\n')
26 }
27
28 // Normal truncation: non-bundled descriptions truncated to maxDescLen
29 return commands.map((cmd, i) => {
30 if (bundledIndices.has(i)) return fullEntries[i]!.full
31 const description = getCommandDescription(cmd)
32 return `- ${cmd.name}: ${truncate(description, maxDescLen)}`
33 }).join('\n')
34}

Design principles:

  • Bundled Skills are never truncated: They are core capabilities, and description quality directly affects the model's invocation decisions
  • Progressive degradation: First truncate description length, in extreme cases keep only names
  • Per-description limit of 250 characters: MAX_LISTING_DESC_CHARS hard limit, no waste even when budget is ample

Unification and Interaction of the Three Layers

Unified Command Registration

The three layers ultimately unify through the getCommands() function:

TypeScript
1// Pseudocode showing the merge logic
2async function getCommands(cwd: string): Promise<Command[]> {
3 const fileSkills = await getSkillDirCommands(cwd)
4 const bundledSkills = getBundledSkills()
5 const pluginSkills = getBuiltinPluginSkillCommands()
6 const dynamicSkills = getDynamicSkills()
7
8 return [...fileSkills, ...bundledSkills, ...pluginSkills, ...dynamicSkills]
9}

SkillTool.getAllCommands() further adds MCP Skills:

TypeScript
1const localCommands = await getCommands(getProjectRoot())
2return uniqBy([...localCommands, ...mcpSkills], 'name')

Name Collision Resolution

Same-named Skills may appear across layers. The resolution strategy is: first registered wins.

  • Within file Skills: Managed > User > Project (determined by flatten order in getSkillDirCommands)
  • File vs Bundled: File Skills come first in getCommands
  • Local vs MCP: uniqBy([...localCommands, ...mcpSkills], 'name') gives local priority

Plugin Skills avoid conflicts with other sources through namespacing (plugin-name:skill-name).

Hooks Unification

Both Skills and Plugins can declare Hooks. Skill Hooks are registered when invoked, while Plugin Hooks take effect as soon as they're loaded:

TypeScript
1// Skill hooks - only active when the Skill is invoked
2command.hooks = parseHooksFromFrontmatter(frontmatter, skillName)
3
4// Plugin hooks - always active once the Plugin is enabled
5plugin.hooksConfig = definition.hooks

Both use the same HooksSettings Schema and can configure PreToolUse, PostToolUse, PreCompact, and other lifecycle hooks.

Caching and Invalidation

File Skill loading results are cached via memoize, but dynamic Skill discovery and conditional Skill activation need to trigger cache invalidation:

src/skills/loadSkillsDir.ts
TypeScript
1export function clearSkillCaches() {
2 getSkillDirCommands.cache?.clear?.()
3 loadMarkdownFilesForSubdir.cache?.clear?.()
4 conditionalSkills.clear()
5 activatedConditionalSkillNames.clear()
6}

After dynamic Skill loading completes, subscribers are notified via a signal:

src/skills/loadSkillsDir.ts
TypeScript
1export function onDynamicSkillsLoaded(callback: () => void): () => void {
2 return skillsLoaded.subscribe(() => {
3 try {
4 callback()
5 } catch (error) {
6 logError(error)
7 }
8 })
9}

This signal mechanism uses the same createSignal utility as GrowthBook feature flags — a lightweight observer pattern implementation. Subscriber errors are caught and logged without interrupting signal propagation.

Bare Mode and Policy Lockdown

Bare Mode

--bare mode skips all auto-discovery logic, only loading Skills explicitly specified via --add-dir:

src/skills/loadSkillsDir.ts
TypeScript
1if (isBareMode()) {
2 if (additionalDirs.length === 0 || !projectSettingsEnabled) {
3 return []
4 }
5 const additionalSkillsNested = await Promise.all(
6 additionalDirs.map(dir =>
7 loadSkillsFromSkillsDir(
8 join(dir, '.claude', 'skills'), 'projectSettings',
9 ),
10 ),
11 )
12 return additionalSkillsNested.flat().map(s => s.skill)
13}

This is useful for CI/CD environments: ensuring only predefined Skills run, unaffected by project directory structure.

Plugin-Only Policy

Enterprises can lock Skill sources to Plugin-only via isRestrictedToPluginOnly('skills'):

src/skills/loadSkillsDir.ts
TypeScript
1const skillsLocked = isRestrictedToPluginOnly('skills')
2const projectSettingsEnabled =
3 isSettingSourceEnabled('projectSettings') && !skillsLocked

When skillsLocked is true:

  • Project-level Skills (.claude/skills/) are not loaded
  • User-level Skills (~/.claude/skills/) are not loaded
  • Legacy commands directories are not loaded
  • Only Managed (policy) level Skills and Bundled/Plugin Skills are available

Portable Patterns

Claude Code's three-layer extensibility architecture contains several reusable design patterns:

1. Markdown-as-Config Pattern

Using Markdown files (with YAML frontmatter) as a configuration carrier:

MARKDOWN
1---
2description: "..."
3allowed-tools: [...]
4paths: ["src/**"]
5---
6
7Actual prompt content...

Advantages of this pattern:

  • Human-readable: Non-developers can write and maintain Skills
  • Version control friendly: Markdown diffs are clear and intuitive
  • Self-documenting: Frontmatter is configuration, body is documentation
  • IDE support: Standard Markdown format with rich editor support

2. Multi-Level Directory Merge Pattern

Loading configuration from multiple directory levels, merging with priority-based deduplication:

Text
1Managed (Policy) > User (Global) > Project (Project) > Dynamic (Runtime)

This pattern is applicable to any system needing "global defaults + project overrides" semantics. Deduplication uses realpath rather than path string comparison, correctly handling symlink scenarios.

3. Conditional Activation Pattern

Resources (Skills, rules, etc.) aren't loaded immediately but declare activation conditions that only take effect when matched at runtime:

Text
1Declare -> store in conditionalMap -> runtime trigger -> move to activeMap -> notify subscribers

This pattern is particularly valuable in large projects. It compresses the Skill list from O(N) to O(active) level while maintaining the ability for on-demand discovery.

4. Register-Rather-Than-Import Pattern

Both Bundled Skills and MCP Skill Builders use the pattern of "registering to a global Map during module initialization rather than importing directly":

TypeScript
1// Register
2export function registerBundledSkill(def: BundledSkillDefinition): void {
3 bundledSkills.push(skillDefToCommand(def))
4}
5
6// Retrieve
7export function getBundledSkills(): Command[] {
8 return [...bundledSkills]
9}

Benefits of this pattern:

  • Breaks circular dependencies: The registration module only imports types, not implementations
  • Supports lazy loading: Feature-gated Skills use require() for on-demand loading
  • Test-friendly: clearBundledSkills() can reset state

5. Secure Write Pattern

Bundled Skill file extraction demonstrates secure writing best practices:

Text
1Per-process nonce dir -> O_EXCL | O_NOFOLLOW -> 0o600 permissions -> path traversal checks

Four layers of defense:

  1. Random directory names prevent pre-creation attacks
  2. O_EXCL prevents overwriting existing files
  3. O_NOFOLLOW prevents symbolic link attacks
  4. Path normalization + .. checking prevents directory traversal

Summary

Claude Code's three-layer extensibility architecture — Skills, Plugins, MCP — may seem complex, but the underlying logic is clear:

  1. Unified representation: All extensible capabilities ultimately become the Command type
  2. Unified entry point: SkillTool is the sole interface through which the model invokes Skills
  3. Unified dispatch: Regardless of source, Skill execution goes through the same validation, permission checking, and inline/fork decisions
  4. Differentiated security: Different sources have different trust levels (MCP prohibits shell commands, Plugins require permission confirmation, Bundled auto-allow)

The design philosophy of this architecture is: let users define new capabilities through Markdown files, let third parties extend the ecosystem through Plugins and MCP, while maintaining clear security boundaries at every layer. File Skill path conditional activation and dynamic discovery ensure performance in large projects, Bundled Skill lazy extraction and secure writing ensure reliability for binary distribution, and the Plugin two-tier architecture (Built-in + Marketplace) balances out-of-the-box experience with extensibility freedom.

The next article will dive into the OAuth and authentication system, exploring how Claude Code securely manages API keys, OAuth tokens, and session credentials.