from aimu import paths
skills_dir = paths.examples / "skills" # aimu.paths.skills points here too
conv_dir = skills_dir / "unit-converter"
print("Skills directory:", skills_dir)
for d in sorted(skills_dir.iterdir()):
print(f" {d.name}/SKILL.md")Agent Skills
Demonstrate AIMU’s skill support: giving agents reusable, on-demand capabilities via SKILL.md files.
Skills are discovered from the filesystem, injected as a catalog into the agent’s system message, and loaded on demand via an activate_skill MCP tool call. Python scripts inside a skill’s scripts/ directory are automatically exposed as additional MCP tools.
What this notebook covers: - Discovering and inspecting skills with SkillManager - The skills MCP server (build_skills_server) - Running a SkillAgent that discovers and activates skills - Streaming skill-aware agent output - Script tools from a skill’s scripts/ directory - SkillAgent.from_config with skill_dirs
Setup
Skills are directories containing a SKILL.md file with YAML frontmatter followed by markdown instructions, following the Agent Skills specification. Frontmatter is validated on discovery: name is required, must be lowercase alphanumerics and single hyphens, and must match its directory; a violation raises SkillLoadError naming the rule rather than loading a skill under a name nothing can address. Two sample skills ship with AIMU under examples/skills/, exposed as aimu.paths.skills (== aimu.paths.examples / "skills").
A - Discovering Skills with SkillManager
SkillManager scans one or more directories for skill subdirectories, each containing a SKILL.md file with YAML frontmatter. By default it scans .agents/skills/ and .claude/skills/ at both project and user scope. Here we pass skill_dirs to point it at our sample directory.
from aimu.skills import SkillManager
manager = SkillManager(skill_dirs=[str(skills_dir)])
print(f"Discovered {len(manager.skills)} skill(s):")
for name, skill in manager.skills.items():
print(f" {name!r:25s} {skill.description[:70]}")catalog_prompt() builds the XML block that gets injected into the agent’s system message. This is the catalog tier (tier 1) of progressive disclosure: only names and descriptions, no full instructions.
print(manager.catalog_prompt())get_skill_body() loads the full markdown instructions for a skill; this is what the model receives when it calls activate_skill (tier 2 of progressive disclosure).
print(manager.get_skill_body("haiku-poet"))B - The Skills MCP Server
build_skills_server() creates an in-process FastMCP server from a SkillManager. It registers an activate_skill tool and one tool per Python script found in any skill’s scripts/ directory. Wrap it in MCPClient(server=...) to use it directly.
from aimu.skills import build_skills_server
from aimu.tools import MCPClient
skills_server = build_skills_server(manager)
skills_client = MCPClient(server=skills_server)
print("Tools exposed by the skills server:")
for tool in skills_client.list_tools():
print(f" {tool.name}")You can call activate_skill directly through the client to inspect what the model receives:
result = skills_client.call_tool("activate_skill", {"name": "unit-converter"})
print(result.content[0].text)C - SkillAgent
SkillAgent extends Agent with skill support. Pass a SkillManager at construction (or omit it to auto-discover from default paths). On the first run(), _setup_skills() fires once: it appends the skill catalog to the system message and attaches the skills MCPClient. The model then decides which skills are relevant and calls activate_skill to load them.
You can inspect what gets injected into the system message before running:
from aimu.models import OllamaClient
from aimu.agents import SkillAgent
model_client = OllamaClient(OllamaClient.MODELS.QWEN_3_5_9B)
model_client.system_message = "You are a helpful assistant. Always activate the most relevant skill before answering."
agent = SkillAgent(
model_client,
name="skilled-assistant",
max_iterations=6,
skill_manager=SkillManager(skill_dirs=[str(skills_dir)]),
)
# Peek at the system message after skill injection (before run)
agent._setup_skills()
print("--- System message (truncated) ---")
print(model_client.system_message[:500], "...")# Fresh client; run() calls _setup_skills() automatically on first invocation
model_client = OllamaClient(OllamaClient.MODELS.QWEN_3_5_9B)
model_client.system_message = "You are a helpful assistant. Always activate the most relevant skill before answering."
agent = SkillAgent(
model_client,
name="skilled-assistant",
max_iterations=6,
skill_manager=SkillManager(skill_dirs=[str(skills_dir)]),
)
result = agent.run("Write me a haiku about autumn rain.")
print(result)# Inspect the full message history to see the activate_skill tool call
for msg in model_client.messages:
role = msg["role"]
if role == "system":
print("[system] (omitted; contains catalog)")
elif role == "user":
print(f"[user] {msg['content']}")
elif role == "assistant" and "tool_calls" in msg:
for tc in msg["tool_calls"]:
print(f"[tool→] {tc['function']['name']}({tc['function']['arguments']})")
elif role == "tool":
preview = str(msg["content"])[:120].replace("\n", " ")
print(f"[←tool] {msg['name']}: {preview}...")
elif role == "assistant":
print(f"[asst] {str(msg['content'])[:200]}")D - Streaming SkillAgent Output
run(stream=True) yields StreamChunk objects. The TOOL_CALLING phase shows when the model activates a skill, and GENERATING shows the final answer produced using the loaded instructions.
from aimu.models import StreamingContentType
model_client2 = OllamaClient(OllamaClient.MODELS.QWEN_3_5_9B)
model_client2.system_message = "You are a helpful assistant. Always activate the most relevant skill before answering."
agent2 = SkillAgent(
model_client2,
name="streamer",
max_iterations=6,
skill_manager=SkillManager(skill_dirs=[str(skills_dir)]),
)
current_iteration = -1
current_phase = None
for chunk in agent2.run("How many kilometres is 26.2 miles (a marathon)?", stream=True):
if chunk.iteration != current_iteration:
current_iteration = chunk.iteration
print(f"\n--- iteration {current_iteration} ---")
if chunk.phase != current_phase:
current_phase = chunk.phase
print(f"\n[{chunk.phase.value}]")
if chunk.phase == StreamingContentType.THINKING:
print(chunk.content, end="", flush=True)
elif chunk.phase == StreamingContentType.TOOL_CALLING:
print(f" {chunk.content['name']}({chunk.content['arguments']!r}) → {str(chunk.content['response'])[:120]}")
elif chunk.phase == StreamingContentType.GENERATING:
print(chunk.content, end="", flush=True)E - Script Tools
A skill can include scripts in a scripts/ subdirectory. Each *.py and *.sh file is automatically registered as an MCP tool named {skill_name}__{script_stem}, with both halves slugified to [a-z0-9_] (so the unit-converter skill yields unit_converter__* tools). Scripts are run via subprocess and their stdout is returned as the tool result.
Three things to know when writing one:
- Dependencies can be inline. A
.pyscript declaring PEP 723 metadata in a# /// scriptblock runs throughuv run --script, which resolves those dependencies into an isolated environment. Without a block it runs on the host interpreter. - stdin is closed. Agents run in non-interactive shells, so a script that prompts gets
EOFErrorimmediately rather than blocking. Take input through flags, or throughscript_env=onSkillAgent, which a host uses to pass context a script cannot discover for itself. The agent merges that mapping over the inherited environment every time it builds the skills server; build the server yourself and it isbuild_skills_server(manager, env=...)instead. - There is a 30-second budget, and the call is synchronous, so a long job blocks the event loop on the async path. Keep script work short.
Here we add a scripts/now.py script to the unit-converter skill:
# Add a scripts/ directory to the unit-converter skill
scripts_dir_path = conv_dir / "scripts"
scripts_dir_path.mkdir(exist_ok=True)
(scripts_dir_path / "now.py").write_text("import datetime\nprint(datetime.datetime.now().isoformat())\n")
print("Script added:", scripts_dir_path / "now.py")
print("Contents:")
print((scripts_dir_path / "now.py").read_text())# Rebuild the skills server; it now picks up the new script tool
manager3 = SkillManager(skill_dirs=[str(skills_dir)]) # re-discover to pick up scripts/
server3 = build_skills_server(manager3)
client3 = MCPClient(server=server3)
print("Tools exposed (including script tools):")
for tool in client3.list_tools():
print(f" {tool.name}")
if tool.description:
print(f" {tool.description}")# Call the script tool directly
result3 = client3.call_tool("unit_converter__now", {})
print("unit_converter__now returned:", result3.content[0].text)Host context for scripts
now.py needs nothing from its host. Most useful scripts do: where to write output, which account to send from. stdin is closed and flags come from the model, so that context reaches a script as environment variables the host sets. Leave them out and the failure is quiet, which is the point worth seeing: the variable is simply absent, the script reports itself unconfigured, and nothing raises.
# A script that depends on host context rather than on model-supplied arguments
(scripts_dir_path / "report_dir.py").write_text(
"import os\nprint(os.environ.get('REPORT_DIR', 'REPORT_DIR unset'))\n"
)
manager_env = SkillManager(skill_dirs=[str(skills_dir)]) # re-discover to pick up the new script
client_bare = MCPClient(server=build_skills_server(manager_env))
print("no env: ", client_bare.call_tool("unit_converter__report_dir", {}).content[0].text.strip())build_skills_server(manager, env=...) supplies it. The mapping is merged over the inherited environment rather than replacing it, so PATH (which the interpreter lookup itself needs) survives.
client_env = MCPClient(
server=build_skills_server(manager_env, env={"REPORT_DIR": "/tmp/aimu-reports"})
)
print("with env:", client_env.call_tool("unit_converter__report_dir", {}).content[0].text.strip())A SkillAgent builds that server itself, on the first run() and again in reload_skills(), so a host holding only the agent never sees the env= argument. script_env is the same context, passed at the agent instead, and it is applied at both of those build sites.
model_client_env = OllamaClient(OllamaClient.MODELS.QWEN_3_5_9B)
agent_env = SkillAgent(
model_client_env,
name="env-carrying-agent",
skill_manager=manager_env,
script_env={"REPORT_DIR": "/tmp/aimu-reports"},
)
# Normally implicit on the first run(); called here to reach the tools without a model turn.
agent_env._setup_skills()
script_tool = next(t for t in agent_env._effective_tools() if t.__name__ == "unit_converter__report_dir")
print("via agent:", script_tool().strip())F - SkillAgent.from_config
SkillAgent.from_config accepts a skill_dirs key to create a SkillManager automatically. Omit skill_dirs to auto-discover skills from the standard search paths (.agents/skills/ and .claude/skills/ at project and user scope).
model_client4 = OllamaClient(OllamaClient.MODELS.QWEN_3_5_9B)
agent4 = SkillAgent.from_config(
{
"name": "cfg-agent",
"system_message": "You are a helpful assistant. Activate relevant skills before answering.",
"max_iterations": 6,
"skill_dirs": [str(skills_dir)],
},
model_client4,
)
print("Agent name: ", agent4.name)
print("Skill manager: ", agent4.skill_manager)
print("Skills found: ", list(agent4.skill_manager.skills.keys()))result4 = agent4.run("Write a haiku about the ocean at dawn.")
print(result4)G - Clean Up
# Remove the scripts/ directory added in section E
import shutil
shutil.rmtree(conv_dir / "scripts", ignore_errors=True)
del model_client, model_client2, model_client_env, model_client4, agent, agent2, agent_env, agent4
print("Cleaned up.")