Built-in tools
Loomflow ships ready-made tools for reading, writing and searching files and running shell commands, modelled on the tools Claude Code uses. Register them when you want an agent that can work in a directory (a coding agent, say) without writing those tools yourself. There are seven:
| Tool | What it does |
|---|---|
read_tool() | Read a text file with line numbers. Pageable for long files. |
write_tool() | Create or overwrite a text file. |
edit_tool() | Find-and-replace inside an existing file with uniqueness check. |
bash_tool() | Run a shell command with timeout + destructive-command denylist. |
grep_tool() | Search file contents by regex within the workdir. |
find_tool() | Find files by glob pattern within the workdir. |
ls_tool() | List directory contents within the workdir. |
Two helpers go with them: filesystem_tools() returns all of the
file tools at once (everything except bash_tool), and
default_workdir() gives you a default directory to scope them to.
For web search there is also web_tool(), which registers a
web_search tool (DuckDuckGo by default, or Serper with an API key).
The tool factories take a workdir (and a few safety knobs for
bash_tool) and return a ready-to-register Tool instance. The
closure captures the workdir, so the resulting tool is workdir-scoped.
The model can’t escape via ../ or absolute paths.
Quickstart
from loomflow import Agent
from loomflow.tools import bash_tool, edit_tool, read_tool, write_tool
agent = Agent(
"You are a research agent.",
model="claude-opus-4-7",
tools=[
read_tool(workdir="/tmp/agent_work"),
write_tool(workdir="/tmp/agent_work"),
edit_tool(workdir="/tmp/agent_work"),
bash_tool(workdir="/tmp/agent_work", timeout=30.0),
],
)Or as a bundle:
from loomflow.tools import bash_tool, filesystem_tools
agent = Agent(
"...",
model="...",
tools=filesystem_tools("/tmp/agent_work")
+ [bash_tool("/tmp/agent_work")],
)filesystem_tools(workdir) returns [read, write, edit, grep, find, ls] — the six filesystem tools, no bash.
Default workdir
Pass workdir=None (the default) and the framework lazily creates a
shared temp directory under $TMPDIR/jeeves_agent_* once per process.
All built-in tool factories called without an explicit workdir share
that same directory:
from loomflow import Agent
from loomflow.tools import bash_tool, default_workdir, read_tool
agent = Agent(
"...",
model="...",
tools=[read_tool(), bash_tool()], # both see the same tempdir
)
print(default_workdir()) # /var/folders/.../jeeves_agent_xxxxxThe directory is NOT auto-cleaned at process exit. Leave that to the OS’s tempdir cleanup so debug data survives a crash.
read_tool
Returns the file’s text with 1-indexed line numbers prefixed (one line per output line):
1 from typing import Any
2
3 def main() -> None:
4 ...This format lets edit_tool work without ambiguity later. The
model has the surrounding lines in scope.
read_tool(
workdir=None, # None → default tempdir
name="read", # the tool name the model sees
line_limit=2000, # max lines per call
)The model passes path, optionally offset (default 0) and limit
(default 2000). Long files are truncated and the tool returns a hint
like "... (32 more line(s); call read() again with offset=2000)".
write_tool
Creates or overwrites a text file. Returns a confirmation with the byte count.
write_tool(
workdir=None,
name="write",
create_parents=True, # auto-mkdir parents
)Marked destructive=True. Overwrites can lose data, so
StandardPermissions(mode=Mode.DEFAULT) will route through the
approval handler.
edit_tool
Find-and-replace inside an existing file. Behaviour matches Claude Code’s Edit tool exactly:
old_stringmust be EXACTLY present (whitespace, indentation, line breaks). Mismatch → error.old_stringmust appear EXACTLY once in the file unlessreplace_all=Trueis passed. Forces the model to give enough surrounding context for unambiguous matches.new_stringreplaces the matched region (or every occurrence ifreplace_all=True).
edit_tool(workdir=None, name="edit")Marked destructive=True.
bash_tool
Runs a shell command via /bin/sh -c. Output is structured:
$ pytest -q
[exit=0]
--- stdout ---
.....
5 passed in 0.32sbash_tool(
workdir=None,
name="bash",
timeout=30.0, # seconds; subprocess killed on timeout
allow_pattern=None, # callable; OVERRIDES default deny list
extra_env=None, # dict merged into subprocess env
)Default deny list
The default deny list rejects clearly-dangerous commands:
| Pattern | Blocks |
|---|---|
rm -rf / (root only) | Recursive root delete |
\bsudo\b | Privilege escalation |
\bmkfs\b | Format a filesystem |
dd if=.*of=/dev/ | Write to raw device |
Fork bomb (:(){ :|:& };:) | Resource exhaustion |
> /dev/sd | Write to raw disk |
chmod 777 / | Permissions on root |
The denylist is conservative, rm -rf /tmp/foo (a normal cleanup)
is NOT flagged because rm -rf / only matches when the slash is
followed by whitespace or end-of-line.
Overriding with allow_pattern
When you pass allow_pattern=callable, the default deny list is
disabled entirely. Your callable is the only filter. You take
full responsibility:
import re
ALLOW = re.compile(r"^(ls|cat|grep|head|tail|find|wc|date) ")
bash_tool(allow_pattern=lambda cmd: bool(ALLOW.match(cmd)))Output truncation
stdout and stderr are truncated to 10,000 characters each, with a
... (truncated, N more bytes) hint. Long-running tasks shouldn’t
flood the model’s context.
Path safety
All filesystem tools use the same path resolver: any argument that resolves
outside the workdir (via absolute path, ../ traversal or a symlink)
raises PathEscapeError, caught and returned as "ERROR: ..." to the
model. The model sees it as a tool result and can adjust. grep also
skips files in the tree that are symlinks to somewhere outside the
workdir, as read refuses them. (Before 0.17, grep followed them.)
For symlink-aware path validation across an arbitrary tool host, wrap
with FilesystemSandbox.
Why Claude-Code-shaped? The tools’ interface mirrors Claude Code’s: 1-indexed line numbers in reads, exact-match edits with uniqueness, structured bash output. This is the shape that produces high-quality outputs from current frontier models.