Tools
A tool is a regular Python callable the agent can invoke. The
@tool decorator derives its JSON schema from type hints + docstring;
sync functions are automatically dispatched to a worker thread so they
never block the event loop.
from loomflow import Agent, tool
@tool
async def get_weather(city: str) -> str:
"""Look up the current weather for a city."""
return f"Sunny, 72°F in {city}."
@tool(destructive=True)
def delete_file(path: str) -> str:
"""Delete a file. Marked destructive so default permissions ask first."""
import os
os.remove(path)
return f"deleted {path}"
agent = Agent(
"You are a productivity assistant.",
model="claude-opus-4-7",
tools=[get_weather, delete_file],
)Schema derivation
| Python annotation | JSON schema |
|---|---|
str | {"type": "string"} |
int | {"type": "integer"} |
float | {"type": "number"} |
bool | {"type": "boolean"} |
list[T] | {"type": "array", "items": {schema for T}} |
dict[str, T] | {"type": "object", "additionalProperties": ...} |
T | None | nullable union |
Pydantic BaseModel | the model’s JSON schema |
Literal["a", "b"] | {"type": "string", "enum": ["a", "b"]} |
The function’s docstring becomes the tool description; per-arg
descriptions can be added via Annotated[type, "description"].
Parallel dispatch
Tool calls in the same model turn run concurrently under a single
anyio.create_task_group. Tool results are appended in arrival order
, deterministic for the model’s next turn. A failing tool doesn’t
poison the others: each exception is captured in its own
ToolResult(ok=False) and the loop continues.
Reading run scope inside a tool
Don’t plumb user_id / session_id through every tool signature.
Use get_run_context():
from loomflow import get_run_context, tool
@tool
async def fetch_orders() -> str:
ctx = get_run_context() # RunContext
return await db.query("orders", user_id=ctx.user_id)ctx.user_id and ctx.session_id are always populated for tools
called inside an agent run.
Marking tools destructive
@tool(destructive=True) is a hint to the permissions
layer:
- With
StandardPermissions(mode=Mode.DEFAULT), destructive tools returnDecision.ask_(...). Routed through theapproval_handler. - With
Mode.ACCEPT_EDITS, destructive tools auto-approve. - With
Mode.BYPASS, every call passes (CI / sandbox only).
See Approval handlers.
Single tool, no list-wrapping
Pass one callable directly:
agent = Agent("...", model="...", tools=get_weather)Lists still work for multiple tools.
InProcessToolHost
When you need a programmable host (custom tool dispatch logic, dynamic
registration, metrics), use InProcessToolHost:
from loomflow import Agent
from loomflow.tools import InProcessToolHost
host = InProcessToolHost([get_weather, delete_file])
host.register(extra_tool)
host.unregister("delete_file")
agent = Agent("...", model="...", tools=host)For external tool sources (MCP servers, LangChain bridges) implement
the ToolHost protocol directly. Three methods.
Where to next
read_tool · write_tool · edit_tool · bash_tool. Workdir-scoped, Claude-Code-shaped.Built-in toolsPlug tool servers from the Model Context Protocol. Git, filesystem, custom.MCP serversTools say what the agent can do; skills say how. Packaged playbooks loaded on demand.SkillsTools in skills. Skills (Mode B / C) ship their own tools that
auto-register when the skill loads. The framework adds a skill__
prefix automatically. No name collisions across skills. See
Three skill modes.