Three skill modes
A skill can be pure documentation, or it can ship its own tools.
There are three ways to package a skill: plain markdown instructions
(Mode A), a tools.py of Python tools (Mode B), or scripts declared
in the frontmatter (Mode C). Start with Mode A; add tools only when
the skill needs to do something the agent’s existing tools can’t. A
single skill can combine all three.
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
Skill('path/') does not import tools.py at construction time —
the import is deferred until the first load_skill call inside
the running agent loop. This keeps construction safe inside an
already-running event loop:
# A tools.py with module-level event-loop work is imported lazily
# inside the agent loop, where event-loop work is fine — so
# Skill('skills/pdf/') is safe to construct from within a running
# event loop (e.g. a Jupyter cell).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 calls asyncio.run(...) at module level (rare, and
only in older skills), 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
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.
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.
Script tools are marked destructive, since they run arbitrary
code: under StandardPermissions(mode=Mode.DEFAULT) (or
ACCEPT_EDITS) each call goes to the approval handler, like bash.
When you pass a sandbox as the agent’s tools=, skill tools are
registered under that sandbox too, so its checks apply to them. Under
an OSSandbox the script runs
in the sandboxed child, which can read the skill’s own directory.
(Before 0.17, script tools weren’t destructive and were registered
beside the sandbox, outside its checks.)
Mode B tools run in the child too: under SubprocessSandbox or
OSSandbox the child loads tools.py from its file, and an OS
sandbox lets it read the skill’s directory. Closures returned by a
build_tools(ctx) factory can’t cross a process boundary, so under
those two sandboxes their calls fail with an error result; write them
as module-level @tool functions, use FilesystemSandbox, or ship
the logic as a Mode C script. (In 0.17.0 to 0.17.2 every Mode B tool
failed under a process sandbox.)
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.