Three skill modes
A skill can be pure documentation, or it can ship its own tools. Three modes coexist freely. A single skill can use any combination.
Mode A. Pure markdown
SKILL.md teaches the model how to use the tools it already has
(read, write, bash, your @tool functions). The model issues
those tool calls itself based on the body’s instructions.
skills/git-discipline/
└── SKILL.md---
name: git-discipline
description: Conventional Commits format, signed-off-by, and the
pre-commit hook checklist.
---
# Git discipline
Use Conventional Commits format:
type(scope): subject
body
Run `bash` with `pre-commit run --all-files` before any commit.
Push with `git push --signed`.
If pre-commit fails, fix the lint issues — never `--no-verify`.The skill ships no code. The model already has bash from the
agent setup; the skill’s body teaches it what commands to issue.
Mode B, tools.py ships @tool functions
Auto-discovered by filename presence. tools.py is imported when
the skill is loaded (not at Skill(...) construction); its
@tool functions are registered into the agent’s tool host at that
point.
skills/greeter/
├── SKILL.md
└── tools.py# skills/greeter/tools.py
from loomflow import tool
@tool
async def say_hi(name: str) -> str:
"""Say hi."""
return f"Hi {name}!"---
name: greeter
description: Greet users by name.
---
Always greet by name. Use the `greeter__say_hi` tool.The model calls greeter__say_hi(name="Anupam") directly. In-process,
fast, can share the agent’s state.
Lazy tools.py import (0.9.26+)
Skill('path/') no longer imports tools.py at construction time ,
the import is deferred until the first load_skill call inside
the running agent loop. This fixes a Jupyter footgun:
# tools.py with module-level event-loop work used to crash
# Skill('skills/pdf/') if the caller was already inside a running
# event loop — the new lazy import path defers it until inside the
# agent loop, where event-loop work is fine.Internally, Skill.materialize_tools(ctx) does the import on first
call and caches the result. The framework’s built-in load_skill
tool calls materialize_tools from inside the agent’s run.
If tools.py STILL does asyncio.run(...) at module level (rare ,
back-compat skills that pre-date this change), the resulting
SkillError includes a hint pointing at the build_tools(ctx)
factory pattern below instead of just the raw asyncio traceback.
Migration note. Tests that asserted “construction raises on a bad
tools.py” now need to callskill.materialize_tools(ctx)to trigger the import. The error surfaces at load time, not construction.
build_tools(ctx) factory protocol (0.9.26+)
Skills can ship tools that close over caller-supplied state
without globals. Define build_tools(ctx) in tools.py and the
framework calls it with the live RunContext at load_skill time:
# skills/pdf-retrieval/tools.py
from loomflow import tool
def build_tools(ctx):
# ctx is the live RunContext for this run.
# ctx.metadata is whatever the caller passed via
# agent.run(metadata={...}).
vs = ctx.metadata["vectorstore"]
@tool
async def retriever(query: str) -> list:
return await vs.search_hybrid(query=query)
return [retriever]Now any code that loads this skill can inject its own vector store:
agent = Agent("...", model="...", skills=["skills/pdf-retrieval/"])
result = await agent.run(
"Find the 2024 financial report.",
metadata={"vectorstore": my_chroma_store},
)Resolution order:
- If
tools.pyexportsbuild_tools, the framework calls it with the activeRunContextand uses the returned list. - Otherwise the framework falls back to discovering module-level
@toolfunctions (the legacy auto-discovery path). Back-compat unchanged.
build_tools must return list[Tool]; anything else raises
SkillError with a clear message pointing at the factory.
This is dependency injection for skill tools. Useful when:
- The same skill ships in multiple agents that need different vector stores / DB connections / API clients.
- You want to test a skill with a
FakeVectorStorewithout rewriting itstools.py. - The skill’s tools reference state that isn’t available at module-import time.
Mode C. Frontmatter declares a script as a typed tool
Any language. The framework wraps the script in a subprocess-backed
Tool with proper args; the model calls it like any built-in tool.
skills/calc/
├── SKILL.md
└── scripts/
└── add.py---
name: calc
description: Arithmetic helpers.
tools:
add:
description: Sum two integers.
script: scripts/add.py
args:
a:
type: string
description: First int
b:
type: string
description: Second int
---# scripts/add.py — plain Python, no decorators
import sys
print(int(sys.argv[1]) + int(sys.argv[2]))The model calls calc__add(a="2", b="3") → framework execs the
script → captures stdout → returns to the model.
Mode C scripts can be Python, Bash, Node, anything that can be exec’d. Args become positional argv to the script.
Modes coexist
A single skill can ship all three. SKILL.md (the body), tools.py (Mode
B), AND a tools: frontmatter block declaring scripts (Mode C). The
modes don’t conflict. They layer.
When to use each mode.
- Mode A for skills that are pure documentation (style guides, process recipes, output templates).
- Mode B when you want fast in-process Python tools that share the agent’s runtime.
- Mode C when you want language-agnostic scripts (a Bash linter, a Node compiler, a Python helper that depends on a separate venv) or when the tool already exists as a CLI you want to expose.