Tools
The built-in tool set — read, write, edit, bash, grep, glob, ls, fetch, websearch — parameters, output, and limits.
On this page
The built-in set
@arcturn/tools ships nine tools, built once per agent via createDefaultTools:
{ "read": "…", "write": "…", "edit": "…", "bash": "…", "grep": "…", "glob": "…", "ls": "…", "fetch": "…", "websearch": "…" }| Tool | Permission? | What it does |
|---|---|---|
read |
No | Read a file, optionally a line range, or (for large files) an auto-generated declaration outline. |
write |
Yes | Create or overwrite a file. |
edit |
Yes | Apply an exact find-and-replace edit to an existing file. |
bash |
Yes | Run a shell command, in the foreground or the background. |
grep |
No | Recursively search file contents by regular expression. |
glob |
No | Find files by glob pattern, sorted newest-first. |
ls |
No | List a directory. |
fetch |
Yes | HTTP GET a URL and return readable text. |
websearch |
No | Search the web and return a numbered list of results. |
Those nine are what @arcturn/tools provides. The CLI registers more beside them, from
other packages — memory, subagent and skill have pages of their own (Project
memory, Sub-agents, Model-invoked
skills). Three belong here, because they are part of the same
read-and-edit loop:
| Tool | Permission? | Ships from | What it does |
|---|---|---|---|
search_code |
Asked | @arcturn/index |
An offline BM25 + structural index of the repository, returning file:line addresses rather than file bodies. Registered unconditionally — see Code search. |
todo |
No | @arcturn/core |
The turn's task list. |
plan |
No | @arcturn/core |
Presents a plan for approval; see Sub-agents for plan mode and delegation. |
todo and plan ship from @arcturn/core rather than @arcturn/tools because they mutate
agent state instead of the outside world, and they are on the engine's always-allow list, so
they never prompt. search_code is the odd one: it never calls ctx.requestPermission
itself, but it is not on the read-only list either, so the call still reaches the engine and
is prompted in default mode — see its own
limits for why, and for the rule that settles it.
Every tool's permission decision, when it needs one, ultimately runs through the permission
engine — this page covers what each tool asks for and does once
allowed. sandbox (a filesystem jail for bash, see below), dryRun (route file
mutations to a shadow tree — see Dry-run mode), and taint/canary
(injection defenses — see Injection defense) all layer on top
of these tools without changing their parameters.
# equivalent to: import { createDefaultTools } from "@arcturn/tools";createDefaultTools({ cwd, sandbox }) returns { tools, read, write, edit, bash, grep, glob, ls, fetch, websearch, backgroundTasks } — call it once per agent, since it hands
back a fresh BackgroundTaskManager per call, so two agents never share background task
state.
Two features change what actually reaches the model on top of these definitions, without
changing any tool's parameters: an oversized result from any of these tools (bash and
MCP tools especially) can be written to a file and replaced with a stub — see
Context management — and, opt-in, most tool schemas can be
withheld from the request entirely until the model asks for them via tool_search — see
Deferred tools.
multiedit: reserved, and currently inert
Three lists in the codebase name a multiedit tool. No package registers one.
createDefaultTools returns exactly the nine tools above, and the CLI's
BUILT_IN_TOOL_NAMES — the list an extension may not shadow — does not carry the name in
either spelling.
| Where the name appears | What that list decides | Effect today |
|---|---|---|
DEFAULT_EDIT_TOOLS (@arcturn/core) |
Which tools acceptEdits auto-approves |
None. Nothing is named multiedit, so the entry matches no call. |
WRITE_TOOLS (/workflow dispatch) |
Which lane a role's step runs on | A role that declares multiedit is classed write lane — a worktree, a captured patch — while holding no tool that can write. |
DEFAULT_ALWAYS_ACTIVE_TOOLS (deferred tools) |
Which schemas are never withheld | None. The spelling there is multi_edit, and a name matching no tool is ignored. |
Two spellings, three lists, zero tools. The name is reserved for a batch-edit tool that has
not shipped. Until it does, "acceptEdits auto-approves multiedit" is a statement about
an empty set, and the only observable consequence of the name anywhere is that middle row.
Arcturn's security page says adversarial review found features that were present but unreachable. This is that class, found in Arcturn's own documentation rather than its code: pages across these docs described a tool the harness has never registered. They now describe what is there. The name stays in the table above so the reservation is visible instead of being rediscovered.
read
Reads a file, or, for large files, a structural outline instead of the body.
| Parameter | Type | Required | Notes |
|---|---|---|---|
path |
string | yes | Absolute or relative to the working directory. |
offset |
number | no | 1-indexed starting line. Default 1. Forces literal-line mode. |
limit |
number | no | Max lines returned. Default 2000. Forces literal-line mode. |
outline |
boolean | no | Force (true) or suppress (false) an outline. Ignored if offset/limit given. |
- Text output is formatted like
cat -n: right-padded line number, tab, text. Individual lines are truncated past 2000 characters. - Image files (
.png,.jpg,.jpeg,.gif,.webp) are returned as base64-encoded image content blocks instead of text. - Files at or above 16,384 bytes auto-outline: instead of the body,
readreturns a structural declaration list (kind, name, signature, line number) so a large file never floods context. Passingoffsetorlimitalways returns literal lines and skips the outline regardless of size; an outline attempted but unavailable (unrecognized language, minified content, no declarations found) silently falls back to a truncated body. - Output past the line limit is followed by
[Showing lines A-B of N. Use offset=B+1 to continue reading.]. - Requires no permission — reading is always allowed.
write
Creates or overwrites a file, including any missing parent directories.
| Parameter | Type | Required |
|---|---|---|
path |
string | yes |
content |
string | yes |
Always requests permission first (Create file <path> or Overwrite file <path>), with
a suggested "always allow" rule scoped to the parent directory (<dir>/**). On success,
returns Created <path> (N bytes). or Updated <path> (N bytes)..
Streaming, and a model that sends broken JSON
On first-party Anthropic the model's write content streams as it is generated
(eager_input_streaming), and the TUI shows the target path and the last four lines under
the progress line while it does; the same goes for an edit's newText. Only write and
edit are streamed this way, and not through a custom baseUrl, Bedrock or Vertex, where
older deployments answer 400.
Because the API does not validate a streamed input, it can arrive incomplete or invalid. A
call whose arguments are not valid JSON never reaches a hook or the tool: the model gets an
error result explaining so, with the raw text (first 2000 characters) as
{"INVALID_JSON": …}, and tries again. A write cut off by the output-token limit is
refused the same way rather than written half-complete.
edit
Exact substring replacement in an existing file.
| Parameter | Type | Required | Notes |
|---|---|---|---|
path |
string | yes | File must already exist — edit never creates one. |
oldText |
string | yes | Must match file contents verbatim, including whitespace. |
newText |
string | yes | Must differ from oldText. |
replaceAll |
boolean | no | Replace every occurrence instead of requiring a unique match. |
Fails with a clear error if the file doesn't exist, oldText isn't found, or (without
replaceAll) oldText matches more than once — the model is told the occurrence count
and asked to add more context or pass replaceAll: true. On success, requests permission
(subject: the file path, suggested rule allowing that exact path) and returns a unified
diff alongside the replacement count.
bash
Runs a shell command through the platform's shell: /bin/sh -c on macOS/Linux, or
%ComSpec% (cmd.exe by default) on Windows, which has no /bin/sh to fall back to.
This is a real behavior difference, not a shim — the tool's own description names the
shell it's running under so the model writes commands for the platform it's actually on;
POSIX idioms (single-quoted strings, $(...), heredocs) fail under cmd.exe the same as
they would if you typed them there yourself. See
Platform support. Stdout and stderr are merged.
| Parameter | Type | Required | Notes |
|---|---|---|---|
command |
string | yes | |
timeoutMs |
number | no | Foreground only. Default 120,000ms (120s), capped at 600,000ms (600s). |
background |
boolean | no | Start the command and return a taskId immediately instead of blocking the turn. |
Always requests permission first, with a suggested rule allowing the command's first word
followed by a wildcard (e.g. git *). Output is truncated to roughly the trailing 50KB
either way. Non-zero exit codes mark the result as an error, and the exit code is always
appended: [Exit code: N].
Background bash
{ "command": "npm run build:watch", "background": true }The BackgroundTaskManager spawns the command detached, as its own process group leader,
buffers merged stdout/stderr (same ~50KB trailing cap), and exposes poll/kill/list
operations. The runtime surfaces backgroundTaskStart / backgroundTaskOutput /
backgroundTaskEnd events as the command runs, so a UI — or the model, on its next turn —
can check in without blocking. Killing a task sends SIGTERM to its whole process group,
then SIGKILL after a 2 second grace period if it hasn't exited. Background commands
are never sandboxed (see below) — requesting background: true while sandbox is
"workspace-write" is refused outright rather than silently running unsandboxed.
grep
Pure-JS recursive regex content search — no dependency on system grep.
| Parameter | Type | Required | Notes |
|---|---|---|---|
pattern |
string | yes | JavaScript regex source, no surrounding slashes. |
path |
string | no | File or directory to search. Defaults to cwd. |
glob |
string | no | Restrict which files are searched, e.g. **/*.ts. |
caseInsensitive |
boolean | no | |
contextLines |
number | no | Lines of context before/after each match. Default 0. |
Skips .git and node_modules, and skips files that sniff as binary (a NUL byte in the
first 8000 bytes). Capped at 200 matches total, after which results are truncated with
a note to narrow the pattern, path, or glob. Each match line is formatted
path:N:text (or path-N-text for context lines), joined by -- between match blocks.
Requires no permission.
glob
Finds files by glob pattern via tinyglobby.
| Parameter | Type | Required | Notes |
|---|---|---|---|
pattern |
string or string[] | yes | One or more glob patterns. |
path |
string | no | Base directory. Defaults to cwd. |
Skips .git and node_modules. Results are sorted most-recently-modified first and
capped at 500 results. Requires no permission.
ls
Lists a directory's contents.
| Parameter | Type | Required |
|---|---|---|
path |
string | no (defaults to cwd) |
Directories are suffixed /; files show a human-readable size (B/KB/MB/GB).
Sorted directories-first, then alphabetically. Capped at 500 entries. Requires no
permission.
fetch
HTTP GET a URL and return its content as readable text.
| Parameter | Type | Required | Notes |
|---|---|---|---|
url |
string | yes | Must be http: or https:. |
maxBytes |
number | no | Default 102,400 (100KB). |
Redirects are followed. HTML responses (content-type: text/html) have tags stripped and
entities decoded down to readable plain text. A 30 second timeout applies. Always requests
permission first, with the subject and suggested "always allow" rule scoped to the URL's
origin (not the full URL), so allowing one fetch to https://api.example.com/v1/x
covers future fetches to that origin.
websearch
Searches the web and returns numbered results.
| Parameter | Type | Required | Notes |
|---|---|---|---|
query |
string | yes | |
maxResults |
number | no | Default 5, capped at 10. |
Uses the Brave Search API when BRAVE_API_KEY is set:
export BRAVE_API_KEY=...Without a key, falls back to scraping DuckDuckGo's HTML results page — no key required,
no extra setup. Output is title — url per result with an indented snippet underneath. A
15 second timeout applies. websearch never calls ctx.requestPermission — it can't
mutate anything, so it runs in every permission mode without a prompt.
Native web search
websearch can be served by the model provider instead. In .arcturn/config.json (or user
config):
{ "websearch": { "provider": "anthropic", "maxUses": 5 }, "webfetch": { "provider": "anthropic" } }Both default to "builtin". With "anthropic" the client websearch or fetch tool is
left out of the request and the API's own is sent in its place: the dynamic-filtering
web_search_20260209 / web_fetch_20260209 on Opus and Sonnet 4.6 and later, the basic
web_search_20250305 / web_fetch_20250910 on other first-party Claude models, only the
basic search on Vertex, and nothing on Bedrock or behind a proxy — there the built-in tool
stays. webfetch: anthropic is honoured only when it comes from your user config, not
a project's: fetched URLs are sent to the provider.
Domain restrictions are derived, not configured. Host-shaped allow and deny permission
rules for the tool become the API's allowed_domains and blocked_domains. The API
refuses both lists on one tool, so when both would apply only the deny list is sent and a
warning says so. Rules you change mid-session do not update the lists already sent.
Searches and fetches, and their errors, appear as tool rows in the TUI and in /export.
Per-search fees are not priced; tokens are. None of this has been run against the live API.
Background tasks and todos, together
bash's background mode and the todo tool (from @arcturn/core, described above) are
meant to be used together: the model starts a long build or test watcher in the
background, records it as an in-progress todo, and checks the task's output on a later
turn instead of blocking the whole session on it. Neither tool talks to the other
directly — the model is what threads a taskId through the todo text if it wants to.
Filesystem sandbox for bash
Set sandbox to "workspace-write" in .arcturn/config.json (default "off") to have
the OS itself deny writes outside a small set of roots for bash's foreground
commands — the working directory, the OS temp directory, and $HOME/.arcturn. Reads,
network access, and process spawning are untouched; only file writes are restricted.
{ "sandbox": "workspace-write" }The backend is platform-specific:
| Platform | Mechanism |
|---|---|
| macOS | sandbox-exec -p <profile>, denying file-write* outside the writable roots |
| Linux | bwrap, with / bound read-only and the writable roots bound read-write |
| Anything else, or a missing binary | Runs unsandboxed |
When sandboxing was requested but couldn't be applied — no bwrap on PATH, an
unsupported platform — the command still runs, and a
note: sandbox requested but unavailable on this platform line is prepended to its
output so the gap is visible rather than silent. Background commands are never sandboxed
(see above). Full permission-mode interaction is covered in
Permissions; dry-run and taint/canary defenses that also touch tool
output are covered in Dry-run mode and
Injection defense respectively.
Which tools are strict
A strict tool asks the model's API to guarantee that the arguments match the schema.
read, write, edit, bash, grep, ls, fetch, websearch, plan, subagent,
tool_search, brain, memory, symbols, skill, find_references and todo declare
strict: true. These do not:
| Tool | Why |
|---|---|
glob, search_code, find_symbol |
A nested oneOf the strict grammar does not take. |
todo on OpenAI |
Its id is optional, which Anthropic's grammar allows and OpenAI's does not. |
| MCP tools | Their schemas belong to the server; never strict. |
Which tools are strict is decided per request from the whole tool set, in name order against the per-request limits below — so the strict set is a function of the tools, not of the order they were registered in, and it does not change between turns.
Anthropic gets strict only when the model has the grammar, the schema fits, and the
request is under the per-request limits (20 strict tools, 24 optional properties, 16
union-typed properties); OpenAI only for a schema whose properties are all required;
Google ignores it.
The Tool contract
Every tool — built-in, MCP-bridged, or one you write — implements the same shape from
@arcturn/types:
{ "definition": { "name": "…", "description": "…", "parameters": "JsonSchema" }, "execute": "fn(input, ctx) -> ToolResult" }ToolExecutionContext is what the runtime hands every tool call: cwd, signal (aborts
on user interrupt), requestPermission (ask before a sensitive action), onUpdate
(stream incremental progress), sessionId, toolCallId.
A ToolResult carries rendered content plus an optional details payload for UIs that
want structured data instead of parsing text: content (array of text/image blocks),
isError (optional), details (optional, arbitrary structured data — every built-in tool
above populates one, e.g. EditToolDetails { path, replacements, diff }).
Tools should resolve with isError: true for expected failures (a missing file, a
non-zero exit code) and only reject the promise for genuine programming errors — the
runtime turns a rejection into a notice event, not a clean tool result the model can
react to.
Permission subjects
Tools don't talk to the permission engine directly for the decision — that's the
runtime's job — but they do report a subject: the specific thing being acted on
(a command, a path, a URL), which the permission engine matches rule specifiers against.
defaultSubject reads it from whichever of these keys the tool's input carries first:
command, file_path, filePath, path, url, pattern, query, target. That's
why a rule like { tool: "bash", specifier: "git *", action: "allow" } matches any bash
call whose command starts with git .
A path-valued subject is resolved against the working directory and through any symlinks in it before the rules see it, so a rule cannot be walked around by pointing a link at the file it protects. See Permissions for the full rule schema, scopes, the four permission modes, and what symlink resolution does and does not change.
Writing your own tool
A custom tool is just an object matching the Tool interface — add it to the tools
array passed to createAgent:
{
"definition": {
"name": "deploy",
"description": "Deploy the current branch to the staging environment.",
"parameters": { "type": "object", "properties": {}, "additionalProperties": false }
}
}Its execute(input, ctx) calls ctx.requestPermission({ toolName, toolCallId, subject, description }), checks decision.behavior === "allow", does the work, and returns a
ToolResult. See Embedding with the SDK for a full runnable example.
For tools that need to read or mutate agent state (todos, plan, permission mode) rather
than the outside world, implement BindableTool instead — its bindAgent(controller)
hook receives an AgentStateController the first time the tool is attached to an agent.
createTodoTool and createPlanTool in @arcturn/core are the reference
implementations: see Sub-agents for how plan mode uses this to gate
execution on user approval.