← Pi Agents

Writing a Task Pi Can Finish

Pi does not ask before tool calls, so the task statement has to carry the scope, the acceptance check, and the files; here is a shape that works.

The most common failure is not a bad model. It is a task that never said what “done” meant. You wrote “clean up the auth module”, Pi spent forty turns, and produced something you now have to review as carefully as writing it yourself.

Pi does not help you here by asking. quickstart.md says Pi shows each file read, search, command, and edit it performs, and that it does not ask before every tool call. The specification of the task is entirely in your prompt.

What the quickstart actually recommends

The examples in quickstart.md share a shape:

Summarize @meeting-notes.md and save the action items to action-items.md.
Explain how this repository is structured and how to run its checks.
Compare @previous.csv with @current.csv and summarize the important changes.

Three properties, present in all three:

  1. A named input, attached with @.
  2. A named output — a file to write, or an answer to return.
  3. A bounded scope.

None of them says “and do a good job”. Each says what to read and what to produce.

Here is the same idea as a contrast, because “clean up” is the shape most tasks actually arrive in:

Clean up the auth module.
Clean up src/auth/: remove the two exports nothing imports, and split the
300-line verify() into parse() and check().
Leave the call signatures in src/auth/index.ts unchanged.
Add no dependencies.
Verify with: pnpm typecheck && pnpm test auth

The second is not longer for its own sake. It names the directory, the shape of the change, the boundary, and the command that decides whether it worked. The first names a noun and a mood.

Attach the input, do not describe it

usage.md gives the mechanics: type @ to search for a file and add it to your prompt, press Tab to complete a path, paste an image or drag it into a compatible terminal.

Attaching beats describing because the file content enters the request rather than requiring a tool round-trip. It also removes a whole class of failure where Pi guesses which of four similarly named files you meant.

Name the acceptance check

The strongest edit you can make to a task is to state what “finished” looks like:

Add a `--json` flag to src/cli.ts that prints session entries as JSONL.

Requirements:
- Default behaviour is unchanged.
- `--json` prints one JSON object per entry, matching the session file format.
- Errors go to stderr and exit with status 2.

Verify with: npm test && node bin/cli.js --session tests/fixtures/minimal.jsonl --json | head -5
Do not modify files under tests/fixtures.

The last two lines do more work than the first three. A named command gives Pi something to run, and a named forbidden path gives it a boundary it will not cross without reason.

Keep it in one span

Compaction cuts at user-message boundaries, and compaction.md describes a user-message span as a user message plus every turn up to the next one. A task that never receives another user message is a single span — which means it is the thing most likely to be split awkwardly if it grows large.

That is an argument for steering deliberately rather than never steering. usage.md distinguishes two ways of changing direction mid-run:

What you want Action
Adjust the current task Type a message and press Enter
Add work after the current task Type a message and press Alt+Enter
Return queued messages to the editor Press Alt+Up
Stop the current task Press Escape

A message sent with Enter waits until the current response and its tool calls finish, then guides the next response. A follow-up sent with Alt+Enter waits until Pi finishes the current task. Aborting returns queued messages to the editor, which is what makes stopping cheap.

When to branch instead of steering

Steering amends the current branch. When the approach itself is wrong, chapter 4’s answer applies: /tree, select an earlier user message, edit it, submit. The old attempt stays in the file and out of the request.

A rough rule: steer to refine, branch to restart. If Pi has already built the wrong abstraction, no amount of steering recovers the cost.

Say what not to do

Negatives are cheap and effective, provided they are specific:

Do not add new dependencies.
Do not change the public function signatures in src/auth/index.ts.
Do not run `git commit`.
Do not write to anything outside src/auth/.

“Do not be destructive” is not a constraint. “Do not write outside src/auth/” is.

Run the command yourself first

If a verification command is going to decide whether the task succeeded, know whether it currently passes. A task phrased as “make npm test pass” against an already-failing suite is a task with an unstated half.

Baseline: `npm test` currently fails on tests/auth.test.ts with "expected 401, got 403".
Task: fix the token refresh path so tests/auth.test.ts passes.
Do not change the test file.

Both sides of the comparison are now known, which is what makes “fixed” unambiguous.

Working in print mode

Not every task needs an interactive session. cli.md:

pi --print "Summarize this repository"
git diff | pi --print "Review this change"
pi --mode json "Inspect this repository" > events.jsonl

--print runs the supplied prompts, writes the final assistant text to stdout, then exits. @path arguments include a text file or image in the first prompt, and piped stdin is prepended to it. -- stops option parsing so a prompt can begin with -.

With terminal stdin and stdout, Pi opens the terminal UI unless --print, --mode json, or --mode rpc selects another interface. When either stream is redirected and neither JSON nor RPC is selected, Pi uses print mode.

Reuse the task, do not retype it

Retyping a prompt that worked is the signal that the wording has outgrown the session. The question is no longer what to type but what should carry it, and the answer has four rungs with different costs. Chapter 11 is the procedure that picks one; everything after it in this part is that one mechanism, taken as far as it will go.