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").

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")

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 .py script declaring PEP 723 metadata in a # /// script block runs through uv 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 EOFError immediately rather than blocking. Take input through flags, or through script_env= on SkillAgent, 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 is build_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.")