Repository: https://github.com/earendil-works/pi
Accessed on: 2026-05-17
Source merge: consolidates findings from pi.md (Claude direct read), pi_pi.md (Pi maintainer notes, accessed 2026-05-15), and pi_codex.md (Codex agent read, accessed 2026-05-17). Every excerpt below was re-verified against a fresh git clone of the repo on 2026-05-17.
Yes — built-in, spec-conformant, at two layers, with both implicit and explicit invocation paths. Skills exist at the framework level (packages/agent/src/harness/skills.ts) and at the coding-agent app level (packages/coding-agent/src/core/skills.ts). Both implement the open Agent Skills spec: SKILL.md files with name/description/disable-model-invocation frontmatter, name validation (lowercase a-z, 0-9, hyphens; ≤ 64 chars; no leading/trailing/consecutive hyphens), 1024-char description cap.
The progressive disclosure contract is implemented operationally: only compact metadata (name + description + location) is rendered into the system prompt; the full body is loaded on demand via the read tool, or explicitly via the /skill:name slash command which expands into a structured <skill> block injected into the conversation.
Additional capabilities I missed in my first read but verified after merging notes: collision detection across multiple skill sources (with diagnostics), disableModelInvocation to keep a skill available by slash command but hidden from the auto-discovery prompt, and an extendResources API that lets extensions dynamically add skill paths at runtime.
Addition to my earlier
pi.md: I missed the framework-layerharness/skills.ts(which is the reusable skills primitive for non-coding-agent apps) and the/skill:nameslash command path entirely. Both are merged below.
| Concern | File |
|---|---|
Framework-layer skill loading + formatSkillInvocation |
packages/agent/src/harness/skills.ts |
Framework-layer system-prompt assembly with <available_skills> |
packages/agent/src/harness/system-prompt.ts |
| App-layer skill schema, validation, discovery | packages/coding-agent/src/core/skills.ts (500 lines) |
/skill:name slash command expansion |
packages/coding-agent/src/core/agent-session.ts (_expandSkillCommand) |
| Resource loader integration (CLI flags, extension paths) | packages/coding-agent/src/core/resource-loader.ts |
| Frontmatter parser | packages/coding-agent/src/utils/frontmatter.ts |
| User-facing docs | packages/coding-agent/docs/skills.md |
SKILL.md files themselves live outside the repo — typically in ~/.pi/skills/<skill-name>/SKILL.md (user scope) or <repo>/.pi/skills/<skill-name>/SKILL.md (project scope).
// packages/coding-agent/src/core/skills.ts
/** Max name length per spec */
const MAX_NAME_LENGTH = 64;
/** Max description length per spec */
const MAX_DESCRIPTION_LENGTH = 1024;
export interface SkillFrontmatter {
name?: string;
description?: string;
"disable-model-invocation"?: boolean;
[key: string]: unknown;
}
export interface Skill {
name: string;
description: string;
filePath: string;
baseDir: string;
sourceInfo: SourceInfo;
disableModelInvocation: boolean;
}Why relevant: The Skill shape matches the open spec exactly: name, description, optional disable-model-invocation. The constants MAX_NAME_LENGTH = 64 and MAX_DESCRIPTION_LENGTH = 1024 are taken verbatim from the spec. The framework harness/skills.ts uses identical constants — duplicated rather than shared, but consistent.
// packages/coding-agent/src/core/skills.ts
function validateName(name: string): string[] {
const errors: string[] = [];
if (name.length > MAX_NAME_LENGTH) {
errors.push(`name exceeds ${MAX_NAME_LENGTH} characters (${name.length})`);
}
if (!/^[a-z0-9-]+$/.test(name)) {
errors.push(`name contains invalid characters (must be lowercase a-z, 0-9, hyphens only)`);
}
if (name.startsWith("-") || name.endsWith("-")) {
errors.push(`name must not start or end with a hyphen`);
}
if (name.includes("--")) {
errors.push(`name must not contain consecutive hyphens`);
}
return errors;
}Why relevant: Pi enforces the spec at load time. Skills with invalid names are rejected and reported as diagnostics rather than silently included with garbage data.
// packages/coding-agent/src/core/skills.ts (excerpted)
const name = frontmatter.name || parentDirName;
const nameErrors = validateName(name);
// ...
if (!frontmatter.description || frontmatter.description.trim() === "") {
return { skill: null, diagnostics };
}Why relevant: Pi falls back to the parent directory name if frontmatter.name is missing — a pragmatic shortcut so ~/.pi/skills/my-skill/SKILL.md works even without an explicit name: field. Description, however, is required — a skill without a description is dropped entirely (still emits a diagnostic). The progressive-disclosure contract depends on description being present, so this is enforced.
// packages/coding-agent/src/core/skills.ts
const IGNORE_FILE_NAMES = [".gitignore", ".ignore", ".fdignore"];
/**
* Load skills from a directory.
*
* Discovery rules:
* - if a directory contains SKILL.md, treat it as a skill root and do not recurse further
* - otherwise, load direct .md children in the root
* - recurse into subdirectories to find SKILL.md
*/
export function loadSkillsFromDir(options: LoadSkillsFromDirOptions): LoadSkillsResult {
const { dir, source } = options;
return loadSkillsFromDirInternal(dir, source, true);
}
// ... in the recursive scan:
if (entry.name === "node_modules") {
continue;
}Why relevant: Two important behaviors:
- Skill roots don't recurse. Once a directory has
SKILL.md, that directory is treated as the skill's package — subdirs are bundled assets (referenceable from the skill body), not nested skills. .gitignoreis honored +node_modulesis hardcoded-skipped. Important when scanning a project repo: build artifacts won't pollute the skill list.
// packages/agent/src/harness/skills.ts
export async function loadSkills(
env: ExecutionEnv,
dirs: string | string[],
): Promise<{ skills: Skill[]; diagnostics: SkillDiagnostic[] }> {
// ...
}
export function formatSkillInvocation(skill: Skill, additionalInstructions?: string): string {
const skillBlock = `<skill name="${skill.name}" location="${skill.filePath}">\nReferences are relative to ${dirnameEnvPath(skill.filePath)}.\n\n${skill.content}\n</skill>`;
return additionalInstructions ? `${skillBlock}\n\n${additionalInstructions}` : skillBlock;
}Why relevant: Skills are not just a coding-agent UI feature — they are a reusable harness-level primitive for any application built on @earendil-works/pi-agent-core. loadSkills is generic over an ExecutionEnv, and formatSkillInvocation is the standard way to inject a full skill body into a conversation as a <skill> block. The framework also has loadSourcedSkills for typed source tracking.
// packages/coding-agent/src/core/skills.ts (formatSkillsForPrompt)
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.",
"When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.",
"",
"<available_skills>",
];
for (const skill of visibleSkills) {
lines.push(" <skill>");
lines.push(` <name>${escapeXml(skill.name)}</name>`);
lines.push(` <description>${escapeXml(skill.description)}</description>`);
lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
lines.push(" </skill>");
}The framework harness has the same shape (packages/agent/src/harness/system-prompt.ts:formatSkillsForSystemPrompt).
Why relevant: This is the progressive-disclosure contract in concrete form. The model sees a compact XML index — name, description, location per skill — and is told to use the read tool to load the full body on demand. Full skill bodies never enter the system prompt automatically. Token-efficient: a project with 50 skills costs ~50 × ~150 tokens of metadata, not 50 × full-skill-body. The path-resolution instruction in the prompt prevents the model from getting confused about relative paths inside skill files.
// packages/coding-agent/src/core/agent-session.ts
private _expandSkillCommand(text: string): string {
if (!text.startsWith("/skill:")) return text;
// ...
const skill = this.resourceLoader.getSkills().skills.find((s) => s.name === skillName);
// ...
const content = readFileSync(skill.filePath, "utf-8");
const body = stripFrontmatter(content).trim();
const skillBlock = `<skill name="${skill.name}" location="${skill.filePath}">\nReferences are relative to ${skill.baseDir}.\n\n${body}\n</skill>`;
return args ? `${skillBlock}\n\n${args}` : skillBlock;
}Why relevant: When the user types /skill:my-skill some args, Pi reads the skill file, strips frontmatter, and injects the full body as a <skill> block into the conversation before the user's args. This is the deterministic path — instead of advertising the skill and trusting the model to choose read, the user pins the skill down. Useful when you want to force a skill instead of hint at it. The baseDir annotation tells the model how to resolve relative paths inside the skill body.
// packages/coding-agent/src/core/skills.ts
function createSkillSourceInfo(filePath: string, baseDir: string, source: string): SourceInfo {
switch (source) {
case "user":
return createSyntheticSourceInfo(filePath, { source: "local", scope: "user", baseDir });
case "project":
return createSyntheticSourceInfo(filePath, { source: "local", scope: "project", baseDir });
case "path":
return createSyntheticSourceInfo(filePath, { source: "local", baseDir });
default:
return createSyntheticSourceInfo(filePath, { source, baseDir });
}
}Why relevant: A skill carries provenance metadata (user-scoped vs project-scoped vs ad-hoc path). Same security pattern as subagents: user-scoped skills are trusted by default; project-scoped skills (i.e., skills committed in a repo you cloned) are typically gated. The SourceInfo is also surfaced in the UI so users can audit "where did this skill come from?"
// packages/coding-agent/src/core/skills.ts (excerpted)
const existing = skillMap.get(skill.name);
if (existing) {
collisionDiagnostics.push({
type: "collision",
message: `name "${skill.name}" collision`,
// ...
});
} else {
skillMap.set(skill.name, skill);
realPathSet.add(realPath);
}Why relevant: When skills are loaded from multiple paths (user dir + project dir + extension-provided paths), name collisions are detected during merge. The losing skill is shadowed by load-order precedence and the collision is surfaced as a diagnostic — not silently overwritten.
Hidden-from-auto-invocation skills
// packages/coding-agent/src/core/skills.ts (excerpted)
disableModelInvocation: frontmatter["disable-model-invocation"] === true,
// ...
const visibleSkills = skills.filter((s) => !s.disableModelInvocation);Why relevant: A skill can be installed but invisible to the model by setting disable-model-invocation: true in its frontmatter. The skill remains usable via /skill:name (the slash command bypasses the visibility filter and reads the file directly), but it's omitted from <available_skills> so it doesn't bias every prompt. Useful for skills that are expensive, sensitive, or that the user wants to invoke deliberately.
// packages/coding-agent/src/core/skills.ts (function signature)
export interface LoadSkillsResult {
skills: Skill[];
diagnostics: ResourceDiagnostic[];
}Why relevant: Loading is partial-failure-tolerant. Bad frontmatter is skipped (not silently included), the error is recorded as a ResourceDiagnostic returned alongside valid skills. The UI surfaces these so users can fix their skill files. The framework-layer counterpart uses SkillDiagnostic with stable codes (file_info_failed, list_failed, read_failed, parse_failed, invalid_metadata).
// packages/coding-agent/src/core/resource-loader.ts (excerpted)
const skillPaths = this.noSkills
? this.mergePaths(cliEnabledSkills, this.additionalSkillPaths)
: this.mergePaths([...cliEnabledSkills, ...enabledSkills], this.additionalSkillPaths);
this.lastSkillPaths = skillPaths;
this.updateSkillsFromPaths(skillPaths, metadataByPath);// packages/coding-agent/src/core/resource-loader.ts (extendResources)
extendResources(paths: ResourceExtensionPaths): void {
const skillPaths = this.normalizeExtensionPaths(paths.skillPaths ?? []);
// ...
if (skillPaths.length > 0) {
this.lastSkillPaths = this.mergePaths(
this.lastSkillPaths,
skillPaths.map((entry) => entry.path),
);
this.updateSkillsFromPaths(this.lastSkillPaths);
}
}Why relevant: Skills aren't just "scan two hardcoded directories." The resource loader composes paths from: CLI flags (--enable-skill), user/project enabled paths, extension-provided paths via extendResources, and --no-skills to skip the defaults entirely. Extensions can dynamically add skill paths at runtime — useful for plug-in systems that bundle their own skill packs.
- Spec-conformant but spec-bounded. Pi tracks the Agent Skills spec faithfully. New frontmatter fields would require code changes. The
[key: string]: unknownindex signature onSkillFrontmatterpermits extra fields at parse time but they're ignored at runtime. - Two implementations, duplicated. The framework
harness/skills.tsand the appcoding-agent/src/core/skills.tsboth implement loading and validation. Likely intentional (framework primitive vs app composition) but a drift hazard — a spec change requires updates in two places. /skill:namereads the file fresh, not from the resource loader's cache. Each invocation does a synchronousreadFileSync— fine in practice (skill files are small), but means an in-place edit to the file is picked up by the next slash command without a session reload.- Skills are markdown, not code. Bodies are natural-language instruction sets. Skills that need executable behavior typically reference scripts (
run-lint.sh) that the agent invokes via itsbashtool. The spec's intent — skills as playbooks, not plugins. - No skill composition primitives. A skill body can reference others by name in prose ("after this, use the
convert-csvskill") but no first-class dependency relationship. - No registry / network distribution. Skills are loaded from disk only. No
pi skills install <url>or marketplace. - No skill versioning. A skill's
SKILL.mdis whatever's on disk. If a project ships a different version of a same-named skill, load order decides which wins — no semver resolution. - Auto-invocation depends on the model deciding to
readthe file. Pi advertises the skill compactly but does not force execution unless the user calls/skill:name. - Collisions are detected but resolution is precedence-only. The losing skill is shadowed silently from the model's POV (only the diagnostic surfaces this); the user has to notice the warning to know.
- ❌ Skills registry / distribution mechanism
- ❌ Skill versioning / dependency resolution
- ❌ First-class skill composition (skills depending on / extending other skills)
- ❌ Non-markdown skill formats (code-as-skill)
- ❌ Skill marketplace UI
What Pi does ship that the Skills pattern asks for:
- ✅
SKILL.mdfile convention with YAML frontmatter (name / description / disable-model-invocation) - ✅ Spec-conformant validation (name regex, length limits)
- ✅ Recursive discovery with
.gitignorehonor +node_modulesskip - ✅ User-scope vs project-scope provenance tracking
- ✅ Partial-failure-tolerant loading with surfaced diagnostics
- ✅ Progressive disclosure: compact
<available_skills>index in system prompt, full body loaded on demand - ✅ Both implicit (model decides to
read) and explicit (/skill:nameslash command) invocation paths - ✅
disableModelInvocationflag — installed but hidden from auto-discovery, still callable via slash command - ✅ Collision detection across multiple skill sources with diagnostics
- ✅ Extension hook for dynamically adding skill paths (
extendResources) - ✅ Framework-layer skill primitive (
harness/skills.ts) reusable by non-coding-agent apps - ✅ Lenient fallback (parent dir name as
namewhen frontmatter omits it) while keeping description required