Skip to Content
DocsSkillsThree skill modes

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 call skill.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.py exports build_tools, the framework calls it with the active RunContext and uses the returned list.
  • Otherwise the framework falls back to discovering module-level @tool functions (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 FakeVectorStore without rewriting its tools.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.
Last updated on