5. Tools
Tools are what turn a chat model into an agent. The model asks for one, plank runs it, feeds the result back, and the model continues โ repeating until a generation asks for nothing more. That whole loop is one turn.
You do not invoke tools directly; you ask for an outcome and the model chooses. What you control is the policy: the sandbox, plan mode, which extensions are installed, and how much of the machinery you see.
The built-in set
Files
| Tool | What it does |
|---|---|
read |
read a file (or a slice of one) |
more |
continue reading where the last read stopped |
write |
create a file, or overwrite one |
list |
list a directory |
glob |
find files by name pattern across a tree โ ** crosses
directories, * stays within one component |
edit |
replace a region of a file, anchored on surrounding text |
search |
search file contents |
When the sandbox is on, write and edit are
held to the same boundary as the shell: a path outside the workspace
(the working directory, the system temp directories, any configured
writablePaths, and ~/.plank only while you
have granted that) is refused with
Tool error: write path escapes workspace: <path>.
Reads are not contained.
edit and an overwriting write render as
diff cards in the TUI: an Update(path)
header, an added/removed summary, and red/green @@ hunks. A
genuinely new file streams its contents dimmed as it is written, so you
can watch it appear.
Shell
| Tool | What it does |
|---|---|
bash |
run a shell command |
bash_status |
poll a running job's output |
bash_stop |
stop a running job |
Bash commands are tracked jobs, not blocking one-shot calls: each owns a process, reader threads, and an output file, so the model can start a long build, do something else, and check back. The first observation is head-biased so headers and early errors are visible; later ones are tail-biased so you see recent output rather than the top of a log.
Every job runs in its own process group, and so do the commands you
type with !. Stopping a job or hitting its timeout kills
the whole tree, so a sleep 600; echo ok or a
cmd | tee pipeline cannot outlive plank. A job that ran
past its timeout is reaped by whatever tool call comes next, not only
when the model polls it. Interrupting a running command (Ctrl-C in the
REPL, Esc in the TUI) kills its group and reports exit status 143.
Commands that need the GPU
A local model holds the Mac's GPU for as long as plank runs, so a
command that wants the GPU for itself, such as mex
generating an image with a diffusion model, would have to fail or wait.
plank sets PLANK_GPU_YIELD=1 in every bash
job, and in the commands you run with ! and
!!, to say it can step aside. Each of those commands also
gets PLANK_GPU_YIELD_FILE, the path of a signal file that
is new for every run and lives in a private directory under the system
temp dir ($TMPDIR/plank-gpu-yield-<uid>, readable
only by you). plank never creates the file. A command that cannot get
the GPU asks for it in one of two ways. The first, and the one to
prefer, is to write one line into that file, for example
GPU not available: held by plank (PID 1234), whatever it
then exits with. The second is to exit with status 75 and print a line
that starts with GPU not available (on stdout or
stderr).
The file matters because models almost always pipe a command's
output: in mex notes.md 2>&1 | tail -20 the shell
reports the exit status of tail, which is 0, so the
exit-status rule never sees the 75. The signal file does not depend on
the exit status or on where the output went, so it works through pipes,
2>&1 and tail. mex uses it
from version 0.2.4. plank reads the file after the command exits, quotes
its line in the notice, and deletes it whether or not a cycle
follows.
When a bash call ends that way, plank prints one notice,
saves the session and the conversation's KV cache, unloads the model,
runs the same command once more, then reloads the model with the
settings it started with and restores the cache, so the next reply does
not re-read the conversation. The second run is waited out to the end,
however long it takes: the model's refresh_sec does not
apply to it, only the command's own timeout_sec or your Esc
(Ctrl-C in the plain REPL) stops it, and the model is reloaded only once
the command has exited. The model sees only the second run's result. If
the second run fails the same way, that result is what the model gets;
plank does not try a third time, and reloads the model regardless. A
turn gets at most two of these cycles; a third GPU request in the same
turn returns its first result with the line
GPU yield skipped: limit of 2 per turn reached. That cap is
also what keeps a tool that writes its signal file on every run from
reloading the model without end.
Only a bash call that finishes inside the call can start
a cycle. A background job, one still running when the call returned, is
never retried, even when it writes its signal file; plank deletes that
file when the job is reaped.
A command you run yourself with ! or !!
gets the same treatment: the notice, the second run streaming where the
first did, then
plank: reloading the model, this takes a moment while the
screen waits for the model to come back. Only the second run counts
afterwards, for the exit line, for what ! records for the
model and for the !! panel. Each such command gets one
cycle of its own, whatever the turn limit says, and a second refusal, by
either signal, is shown as it is.
Hooks see both runs, because both really happened:
PreToolUse and PostToolUse fire for the first
run and again for the second, and any PostToolUse warning
from the first run stays in the result.
Before reloading, plank checks that the model file and its companions
(the DSpark draft model, the vision encoder) are still there and
readable. If one is gone, or another plank took the model lock in the
meantime, the reload fails cleanly: replies fail with a message naming
the cause, the background memory and suggestion passes wait, and plank
tries again at your next prompt. The engine's own output during the
unload and the reload goes to ~/.plank/gpu-yield.log rather
than onto the screen. One risk remains: the engine ends the whole
process, rather than reporting an error, when it cannot load a model for
a reason those checks do not catch, or when another plank grabs the lock
between the check and the load. plank then puts the terminal back,
prints the last line of ~/.plank/gpu-yield.log, and exits;
since the session was saved before the unload, /resume
picks the conversation up from the command that asked for the GPU.
The exit status plank looks at is the command's, which for a pipeline
is the last stage's. mex generate โฆ | tee gen.log reports
tee's status, 0, so the exit-status rule never sees the 75;
write set -o pipefail; mex generate โฆ | tee gen.log to pass
it through. A tool that writes the signal file instead still starts the
cycle piped to tee or not, since the file does not depend
on the exit status at all; set -o pipefail only matters for
the exit-status route.
The model does not have to wait for a refusal. When it knows a
command needs the GPU, it can add suspend_model="true" to
the bash call; plank's prompt tells it about the parameter,
with the exact call form, whenever a local model is loaded. plank then
prints plank: suspending the model for this command, saves
the session and the KV cache, unloads the model, runs the command once
to the end (the same waiting rules as a second run above), reloads the
model and restores the cache. That run's result is what the model sees,
and it is never repeated, even if the command still reports that the GPU
is taken. It counts toward the limit of two per turn. When the parameter
cannot apply, the command simply runs and its result ends with one line
saying why:
suspend_model ignored: no local model to release with a
provider, a remote engine or no model to reload,
suspend_model ignored for background jobs for a command
that ends in &, and
suspend_model ignored: limit of 2 per turn reached past the
limit. 1 and yes count as true too, in any
case; any other value is ignored.
This happens only when plank holds a local model; with a provider or
a remote engine the first result is returned as it is. It applies to a
command that finishes inside the bash call: a job still
running when the call returns, and so observed later through
bash_status or a background notification, is never
re-run.
Background jobs
refresh_sec is how long a bash or
bash_status call waits before it gives up and reports
status=running. bash waits 60 seconds by
default; bash_status returns at once unless
refresh_sec is given. Out of the box the model then has to
poll: every bash_status is a generation pass that reads
"still running" and asks again. With tools.bashNotify on,
the wait moves to plank. The model leaves the job running and ends its
turn, you get the prompt back, and when the job exits plank appends a
[BACKGROUND JOB NOTIFICATION] message carrying the same
observation bash_status would have returned, then starts a
turn so the model can report. Each job is announced once, and never if
the model polled it to completion itself. A job that finishes while a
turn is still running is announced at the next tool boundary
instead.
The wake is careful about your prompt: in the TUI it waits while you
have unsent text in the editor or a pane open, and in the plain REPL it
prints a blank line before the notice so nothing lands on your
half-typed line. Headless runs print
+DWARFSTAR_JOBS_FINISHED <n> on stderr before the
turn, so a driver can tell an automatic turn from a reply to its own
prompt.
While any job runs the status bar shows โง N jobs. Click
it, or type /jobs, for the job table: id, pid, elapsed
time, state and output file, in a panel that keeps counting while it is
open and works mid-turn. The setting is off by default because turning
it on adds one sentence to the system prompt (telling the model it need
not poll), which starts a fresh prompt cache; enable it in
settings.json and start a new session.
Memory
| Tool | What it does |
|---|---|
remember |
append a typed entry to user or project memory |
forget |
delete a memory entry by the short id shown beside it in context |
recall |
search earlier sessions of this project, and the pre-compaction part of this one |
remember and forget write to the memory
files on disk and take effect at the next session start; nothing
rewrites the cached prompt mid-conversation. Both are gated on
tools.remember, and every change lands in
~/.plank/memory-log.jsonl. See Memory.
Documents
read on a .pdf transparently converts the
file to Markdown first, so a PDF is just a readable file:
summarize the first few pages of Claude-Code-Manuale-Completo.pdf
Conversion uses spatial text extraction, with OCR filling in pages
that have no text layer โ so both born-digital and scanned PDFs work.
The result is cached in ~/.plank/doc-cache/, keyed by
content, so a second read of the same document costs nothing.
Everything else behaves normally: paging, line numbers, and
more continuation all work, and the model only ever sees
your path, never the cache path. A file that cannot be parsed
comes back as a plain tool error.
PDF is currently the only converted format.
view_image hands the model an actual picture instead of
a path. It is served unconditionally, backed by the vision encoder plank
keeps beside the main model, and it works on the usual formats:
what does the error in screenshot.png say?
The image is encoded locally within a 384 visual token budget, so a
large screenshot is scaled down before the model sees it. Pasting an
image into the prompt is the same road: your message carries the cached
path, and the model calls view_image on it. See The interface.
Web
| Tool | What it does |
|---|---|
google_search |
search the web |
visit_page |
fetch and read a page |
Web access asks for your consent the first time, and you can grant it standing so it stops asking.
The pattern that works is to say what you want done with the results, not just what to look up:
search the web for the latest news about <topic> and summarize
The model searches, opens the pages worth opening, and reports. If it answers from memory when you wanted current information, say so โ "it's a different one, find it online" โ and it will go look.
Agent machinery
| Tool | What it does |
|---|---|
task |
maintain a task list that survives compaction |
agent |
delegate a bounded sub-task to a fresh subagent |
skill |
invoke an installed skill by name |
ask |
ask you a multiple-choice question |
EnterPlanMode / ExitPlanMode |
enter and leave read-only plan mode |
EnterWorktree / ExitWorktree |
work in an isolated checkout of the repository |
mcp_describe |
fetch the full schema of a non-primary MCP tool |
mcp_list_resources /
mcp_read_resource |
list and read MCP resources |
mcp__<server>__<tool> |
any tool from a connected MCP server |
The bash sandbox
On macOS, model-initiated shell commands run under
/usr/bin/sandbox-exec with a generated Seatbelt profile:
read everywhere, write only under the working directory, temp
directories, and any roots you allow. It is on by default,
because a model-chosen command should not be able to write outside the
project it was pointed at.
Commands you type with ! or !! are
never sandboxed โ you typing the command is the authorization.
Configure it in ~/.plank/sandbox.json, overlaid by
./.plank/sandbox.json:
{
"enabled": true,
"writablePaths": ["/some/extra/root"],
"excludedCommands": ["git push*", "brew *"]
}
The project file can only tighten the policy. Its
"enabled": true is honoured, but
"enabled": false, writablePaths and
excludedCommands in ./.plank/sandbox.json are
ignored, since a cloned repository must not be able to widen the sandbox
of whoever opens it; anything that relaxes the sandbox goes in
~/.plank/sandbox.json. excludedCommands
glob-matches the whole command line and skips the sandbox for it โ a
convenience escape hatch, not a security boundary.
The same roots contain the write and edit
tools (see Files), so a file the shell could not
write, the model cannot write by another route either.
Turn it off for a run with --no-sandbox, or permanently
with safety.sandbox: false.
Plan mode
When a task is risky or ambiguous, the model can call
EnterPlanMode. While it is active, every workspace-mutating
tool is refused โ write, edit,
bash โ and only read-only tools work, so the model
researches and designs without touching anything.
It leaves by calling ExitPlanMode with a proposed plan,
which you approve or reject. On approval the gate lifts and it may edit;
on rejection plan mode stays on and it refines the plan.
You can also just ask for it: "plan this out before you touch anything."
Worktrees
A worktree is a second checkout of the same repository, on its own branch, in its own directory. Ask for one and the model moves into it: every tool's working directory switches, so whatever it edits cannot touch the files you have open.
in a worktree called refactor-parser, try splitting the parser into two modules
Worktrees live under .plank/worktrees/<name> in
the repository root, on a branch called
worktree-<name>. The model only reaches for one when
you say so โ for ordinary branch work it runs git through
bash, which is what you want.
When the work is done it leaves, either keeping the worktree (it stays on disk with its branch, for you to review, merge, or come back to) or removing it. A removal that would destroy uncommitted files or commits that exist nowhere else is refused, and the refusal says what would be lost:
Tool error: worktree 'refactor-parser' has unsaved work, so it was not removed.
2 commits not present on the base branch
1 uncommitted file(s): src/parse.rs
The model can override that, but it has to do so explicitly. If plank cannot verify the state at all โ git fails, say โ it refuses too, on the principle that not knowing is not the same as knowing there is nothing there.
To start a whole session in a worktree instead, pass
--worktree:
plank --worktree refactor-parser
plank --worktree-pr 412 # based on pull request #412
That form goes further than the tool: the worktree becomes the session's project, so the hooks, subagent definitions, and project settings that apply are the ones found there.
Two settings tune it, both under worktree in
settings.json: sparsePaths narrows a new
worktree to a few directories of a large repository, and
symlinkDirectories links heavy build directories
(target, node_modules) from the main checkout
instead of duplicating them. A .worktreeinclude file in the
repository root lists gitignored files โ a .env, a local
build config โ to copy into each new worktree; only files that are
both listed there and actually gitignored are copied.
Questions
When a turn is genuinely ambiguous, the model can call
ask instead of guessing: a multiple-choice question with a
short header and two to seven options, shown as a panel in the TUI or a
numbered list in the REPL. It blocks until you answer, and degrades
cleanly when there is nobody to ask (headless mode).
Every question also carries a Chat about this row beneath the model's options, so a question you think is wrong does not have to be answered as asked โ see The interface.
ask.maxOptions caps how many options one question may
offer (default 7; the minimum of 2 is fixed).
Task lists
The task tool keeps a plan the model can add to and
update โ statuses are pending, in_progress,
and done. The list is model-visible and survives
compaction, which is the point: a long job does not lose its
plan when the conversation is summarized. /tasks shows it,
and the status bar carries a task counter.
Subagents
The agent tool hands a self-contained sub-task to a
fresh subagent working in its own scoped context, and returns
only its final report to the main conversation. This is
how a long research detour stays out of your transcript.
Delegation is bounded: a subagent cannot itself delegate. You can
also start one yourself with /subagent <task> (or
/subagent:<name> <task>), and define named
subagents with their own instructions โ see Extending plank.
Watching (or not watching) tools run
By default the UI stays clean: tool-call banners and tool output are hidden, and the status bar just names what is running. Turn them on when you want to see the mechanics:
/config ui.showToolCalls true
/config ui.showToolResults true
Neither changes what the model receives โ the tools run and the results are fed back either way.
Tool calls inside thinking
By default plank dispatches tool calls the model emits inside its
thinking block. Set engine.thinkingToolCalls to
false for strict parity with the ds4 reference, where such
a call is instead recovered forward by force-closing the thinking
block.
Next: Sessions โ