S07. Skill Loading — Load Only When Needed
S07. Skill Loading — Load Only When Needed
The Problem
Suppose a project has a React component specification, a SQL style guide, and an API design document. We want the Agent to follow these rules during development, so the most direct approach is to put all of them into the system prompt:
SYSTEM = (
f"You are a coding agent. "
+ open("docs/react-style.md").read()
+ open("docs/sql-style.md").read()
+ open("docs/api-design.md").read()
)This approach lets the Agent read every specification, but it fixes all three documents in the system prompt instead of selecting only the one needed for the current task. Every LLM call sends the full text of all three documents to the model. When the task only changes React components, only the React specification is relevant; the SQL style guide and API design document still consume input tokens and context-window space that could hold code, conversation, and tool results.
The Solution
At startup, SkillLoader scans skills/*/SKILL.md, reads name and description from YAML frontmatter, and adds that catalog to the system prompt. When the model needs the full instructions, it calls load_skill(name); the returned SKILL.md is appended to the message list as a tool_result.
| Content | Model input | Added | |---------|-------------|-------| | Skill name and description | system prompt | At startup | | Full SKILL.md | tool_result | When load_skill is called |
How It Works
Each skill is a directory containing SKILL.md:
skills/
agent-builder/SKILL.md
code-review/SKILL.md
mcp-builder/SKILL.md
pdf/SKILL.mdScan Skills
class SkillLoader:
def scan(self):
self.skills.clear()
for manifest in sorted(self.skills_dir.glob("*/SKILL.md")):
content = manifest.read_text()
metadata, body = self.parse_frontmatter(content)
name = str(metadata.get("name") or manifest.parent.name).strip()
description = metadata.get("description") or body.splitlines()[0]
description = " ".join(str(description).lstrip("# ").split())
self.skills[name] = {
"name": name,
"description": description,
"content": content,
}catalog() returns only names and descriptions:
- code-review: Perform thorough code reviews...
- pdf: Process PDF files...Build the System Prompt
def build_system_prompt() -> str:
return (
f"You are a coding agent at {WORKDIR}. Use tools to solve tasks. "
"Act, don't explain.\n\n"
f"Skills available:\n{SKILL_LOADER.catalog()}\n\n"
"Use load_skill to read the full instructions when a skill applies."
)This function combines the fixed Agent instructions with the catalog found at startup.
Load Full Content
def load(self, name: str) -> str:
skill = self.skills.get(name)
if skill:
return skill["content"]
available = ", ".join(self.skills) or "none"
return f"Error: Unknown skill '{name}'. Available: {available}"name looks up the startup registry; it is not interpreted as a file path. After the tool returns, the existing Agent Loop appends its content as a new tool_result message.
Try It
cd learn-claude-code
python s07_skill_loading/code.pyTry these prompts:
What skills are available?Load the code-review skill and follow its instructionsReview README.md and load the relevant skill first
Check that the system prompt contains only the catalog and that the full SKILL.md appears after load_skill is called.
What's Next
As tool calls accumulate, messages[] retains earlier file contents and tool results.
→ s08 Context Compact: shorten earlier messages and keep context available for later calls.
S07 — Complete teaching code
#!/usr/bin/env python3
"""
s07_skill_loading.py - Skill Loading
The system prompt contains a catalog of skill names and descriptions.
The model loads the full SKILL.md only when it calls load_skill.
skills/ Startup
+------------------+ +------------------+
| code-review/ | ----> | SkillLoader |
| SKILL.md | | name + summary |
| pdf/ | +--------+---------+
| SKILL.md | |
+------------------+ v
system prompt catalog
LLM -- load_skill(name) --> full SKILL.md
^ |
+--------- tool_result --------+
"""
import os
import subprocess
from pathlib import Path
import yaml
try:
import readline
readline.parse_and_bind('set bind-tty-special-chars off')
readline.parse_and_bind('set input-meta on')
readline.parse_and_bind('set output-meta on')
readline.parse_and_bind('set convert-meta off')
except ImportError:
pass
from anthropic import Anthropic
from dotenv import load_dotenv
load_dotenv(override=True)
if os.getenv("ANTHROPIC_BASE_URL"):
os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
WORKDIR = Path.cwd()
SKILLS_DIR = WORKDIR / "skills"
client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
MODEL = os.environ["MODEL_ID"]
# -- Skill catalog --
class SkillLoader:
def __init__(self, skills_dir: Path):
self.skills_dir = skills_dir
self.skills: dict[str, dict[str, str]] = {}
self.scan()
@staticmethod
def parse_frontmatter(text: str) -> tuple[dict, str]:
if not text.startswith("---"):
return {}, text
parts = text.split("---", 2)
if len(parts) < 3:
return {}, text
try:
metadata = yaml.safe_load(parts[1]) or {}
except yaml.YAMLError:
metadata = {}
if not isinstance(metadata, dict):
metadata = {}
return metadata, parts[2].lstrip()
def scan(self):
self.skills.clear()
if not self.skills_dir.exists():
return
for manifest in sorted(self.skills_dir.glob("*/SKILL.md")):
content = manifest.read_text()
metadata, body = self.parse_frontmatter(content)
name = str(metadata.get("name") or manifest.parent.name).strip()
description = metadata.get("description") or body.splitlines()[0]
description = " ".join(str(description).lstrip("# ").split())
self.skills[name] = {
"name": name,
"description": description,
"content": content,
}
def catalog(self) -> str:
if not self.skills:
return "(no skills found)"
return "\n".join(
f"- {skill['name']}: {skill['description']}"
for skill in self.skills.values()
)
def load(self, name: str) -> str:
skill = self.skills.get(name)
if skill:
return skill["content"]
available = ", ".join(self.skills) or "none"
return f"Error: Unknown skill '{name}'. Available: {available}"
SKILL_LOADER = SkillLoader(SKILLS_DIR)
def build_system_prompt() -> str:
return (
f"You are a coding agent at {WORKDIR}. Use tools to solve tasks. "
"Act, don't explain.\n\n"
f"Skills available:\n{SKILL_LOADER.catalog()}\n\n"
"Use load_skill to read the full instructions when a skill applies."
)
SYSTEM = build_system_prompt()
# -- Tools --
def run_bash(command: str) -> str:
try:
result = subprocess.run(
command, shell=True, cwd=WORKDIR,
capture_output=True, text=True, timeout=120,
)
output = (result.stdout + result.stderr).strip()
return output[:50000] if output else "(no output)"
except subprocess.TimeoutExpired:
return "Error: Timeout (120s)"
def run_read(path: str, limit: int | None = None) -> str:
try:
lines = (WORKDIR / path).resolve().read_text().splitlines()
if limit and limit < len(lines):
lines = lines[:limit] + [f"... ({len(lines) - limit} more lines)"]
return "\n".join(lines)
except Exception as e:
return f"Error: {e}"
def run_write(path: str, content: str) -> str:
try:
file_path = (WORKDIR / path).resolve()
file_path.parent.mkdir(parents=True, exist_ok=True)
file_path.write_text(content)
return f"Wrote {len(content)} bytes to {path}"
except Exception as e:
return f"Error: {e}"
def run_edit(path: str, old_text: str, new_text: str) -> str:
try:
file_path = (WORKDIR / path).resolve()
text = file_path.read_text()
if old_text not in text:
return f"Error: text not found in {path}"
file_path.write_text(text.replace(old_text, new_text, 1))
return f"Edited {path}"
except Exception as e:
return f"Error: {e}"
def run_glob(pattern: str) -> str:
import glob
try:
matches = []
for match in glob.glob(pattern, root_dir=WORKDIR):
if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
matches.append(match)
return "\n".join(matches) if matches else "(no matches)"
except Exception as e:
return f"Error: {e}"
TOOLS = [
{"name": "bash", "description": "Run a shell command.",
"input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
{"name": "read_file", "description": "Read file contents.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}},
{"name": "write_file", "description": "Write content to a file.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
{"name": "edit_file", "description": "Replace exact text in a file once.",
"input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
{"name": "glob", "description": "Find files matching a glob pattern.",
"input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
{"name": "load_skill", "description": "Load the full SKILL.md content by skill name.",
"input_schema": {"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]}},
]
TOOL_HANDLERS = {
"bash": run_bash,
"read_file": run_read,
"write_file": run_write,
"edit_file": run_edit,
"glob": run_glob,
"load_skill": SKILL_LOADER.load,
}
# -- Hooks --
HOOKS = {"UserPromptSubmit": [], "PreToolUse": [], "PostToolUse": [], "Stop": []}
def register_hook(event: str, callback):
HOOKS[event].append(callback)
def trigger_hooks(event: str, *args):
for callback in HOOKS[event]:
result = callback(*args)
if result is not None:
return result
return None
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if="]
DESTRUCTIVE = ["rm ", "> /etc/", "chmod 777"]
def permission_hook(block):
"""PreToolUse: block denied operations and ask about risky ones."""
if block.name == "bash":
command = block.input.get("command", "")
for pattern in DENY_LIST:
if pattern in command:
print(f"\n\033[31m[blocked] '{pattern}'\033[0m")
return "Permission denied by deny list"
for keyword in DESTRUCTIVE:
if keyword in command:
print("\n\033[33m[permission] Potentially destructive command\033[0m")
print(f" Tool: {block.name}({block.input})")
choice = input(" Allow? [y/N] ").strip().lower()
if choice not in ("y", "yes"):
return "Permission denied by user"
if block.name in ("read_file", "write_file", "edit_file"):
path = block.input.get("path", "")
if not (WORKDIR / path).resolve().is_relative_to(WORKDIR):
print("\n\033[33m[permission] Access outside workspace\033[0m")
print(f" Tool: {block.name}({block.input})")
choice = input(" Allow? [y/N] ").strip().lower()
if choice not in ("y", "yes"):
return "Permission denied by user"
return None
def log_hook(block):
"""PreToolUse: log every tool call."""
args_preview = str(list(block.input.values())[:2])[:60]
print(f"\033[90m[HOOK] {block.name}({args_preview})\033[0m")
return None
def large_output_hook(block, output):
"""PostToolUse: warn on large output."""
if len(str(output)) > 100000:
print(f"\033[33m[HOOK] Large output from {block.name}: {len(str(output))} chars\033[0m")
return None
def context_inject_hook(query: str):
"""UserPromptSubmit: log the working directory."""
print(f"\033[90m[HOOK] UserPromptSubmit: working in {WORKDIR}\033[0m")
return None
def summary_hook(messages: list):
"""Stop: print the number of tool results in this message list."""
tool_count = sum(
1
for message in messages
for block in (
message.get("content")
if isinstance(message.get("content"), list)
else []
)
if isinstance(block, dict) and block.get("type") == "tool_result"
)
print(f"\033[90m[HOOK] Stop: session used {tool_count} tool calls\033[0m")
return None
register_hook("UserPromptSubmit", context_inject_hook)
register_hook("PreToolUse", permission_hook)
register_hook("PreToolUse", log_hook)
register_hook("PostToolUse", large_output_hook)
register_hook("Stop", summary_hook)
def execute_tool(block) -> str:
blocked = trigger_hooks("PreToolUse", block)
if blocked:
return str(blocked)
handler = TOOL_HANDLERS.get(block.name)
try:
output = handler(**block.input) if handler else f"Unknown: {block.name}"
except Exception as e:
output = f"Error: {e}"
trigger_hooks("PostToolUse", block, output)
return str(output)
def agent_loop(messages: list):
while True:
response = client.messages.create(
model=MODEL,
system=SYSTEM,
messages=messages,
tools=TOOLS,
max_tokens=8000,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
force = trigger_hooks("Stop", messages)
if force:
messages.append({"role": "user", "content": force})
continue
return
results = []
for block in response.content:
if block.type != "tool_use":
continue
output = execute_tool(block)
results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": output,
})
messages.append({"role": "user", "content": results})
if __name__ == "__main__":
print("s07: Skill Loading - catalog first, full content on demand")
print("Enter a question, press Enter to send. Type q to quit.\n")
history = []
while True:
try:
query = input("\033[36ms07 >> \033[0m")
except (EOFError, KeyboardInterrupt):
break
if query.strip().lower() in ("q", "exit", ""):
break
trigger_hooks("UserPromptSubmit", query)
history.append({"role": "user", "content": query})
agent_loop(history)
for block in history[-1]["content"]:
if getattr(block, "type", None) == "text":
print(block.text)
print()
Try it — Skills scenario
The agent sees a cheap skill catalog first, then loads full SKILL.md content only when needed.