Built-in tools
Four tools any Agent can register to gain the canonical “Claude-Code-
shaped” capability set:
| 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. |
All four are factory functions: they 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]. The three
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 four tools use the same path resolver: any argument that resolves
outside the workdir (via absolute path or ../ traversal) raises
PathEscapeError, caught and returned as "ERROR: ..." to the model.
The model sees it as a tool result and can adjust.
For symlink-aware path validation across an arbitrary tool host, wrap
with FilesystemSandbox.
Why Claude-Code-shaped? The four tools’ interface mirrors Claude Code’s: 1-indexed line numbers in reads, exact-match edits with uniqueness, structured bash output. This is on purpose. It’s the shape that produces the highest-quality outputs from current frontier models, and it’s the foundation of the upcoming Deep Agent architecture (planner + filesystem state + subagent registry).