The pi control model, explained
Most harnesses answer “what are your controls?” with a list of modes and flags. pi answers it with a philosophy: four tools, no permission system, extend it yourself. pi — from Mario Zechner (libGDX) and Armin Ronacher (Flask, Jinja), MIT, ~80k stars — deliberately ships less than Claude Code or Cursor and makes the rest your job. That makes its “control model” unusual to document: there are almost no native controls to describe, and instead an unusually clean seam where controls attach. This page covers both.
This page covers pi’s own model. For adding ACP to pi, see the install guide; for the cross-harness picture, the controls comparison.
What pi ships natively: the honest zero
pi’s own security documentation is refreshingly direct — it does not claim a boundary it doesn’t have:
- No permission prompts. The four built-in tools (
read,write,edit,bash) run with the permissions of the user who launched pi. - No allowlist / denylist / rules file. There is no
settings.jsonof permission patterns, because there is no permission system to configure. - No sandbox built in. pi runs in your process, with your access to the filesystem, network, and credentials.
Instead of pretending otherwise, pi points at containment as the boundary, and documents three patterns: the Gondolin extension (keeps pi and your provider auth on the host while routing the built-in tools and ! commands into a local Linux micro-VM), plain Docker, and OpenShell. This is a coherent stance — bound where the agent can act with a container, and treat which actions run as an extension’s job — and it’s the same layering we’d recommend for any harness. What pi doesn’t ship is the second half: the deciding and the record.
The seam: two typed events
The reason pi is a first-class control target despite shipping no controls is its extension model. pi dispatches every tool through a typed event pipeline, and extensions are TypeScript modules that subscribe to it. Two events carry the whole story:
| Event | Fires | An extension can |
|---|---|---|
tool_call |
before the tool runs | return { block: true, reason, terminate? } to deny; mutate event.input in place to rewrite arguments (no re-validation after) |
tool_result |
after the tool runs | return { content, isError, details, usage } to rewrite or redact what the model reads |
That’s the full trifecta — deny, input-rewrite, and inline output-rewrite — the same capability set as dsh and Claude Code, and stronger than opencode (whose post-hook is observational). Coverage is complete because pi has no MCP layer and no second tool path: bash, read, write, edit, and any custom or extension-registered tool all flow through these two events. An extension on both sees everything.
tool_call handlers chain (later handlers see earlier mutations), and tool_result handlers chain like middleware (each sees the previous one’s patch). Load order is the composition mechanism.
The empty-chair signal
pi hands extensions the one piece of context most harnesses leave implicit: whether a human is watching. ctx.hasUI is true in the interactive TUI (and RPC mode) and false in print (-p) and JSON modes, and ctx.mode names the exact mode. pi’s shipped permission-gate.ts example uses it the right way — prompt via ctx.ui.select/confirm when there’s UI, and block by default when there isn’t:
In non-interactive mode, block by default.
That is the empty-chair posture expressed natively: an approval with nobody to answer it is a deny, and pi tells you which situation you’re in rather than making you infer it from a timeout. Few harnesses give an extension this signal directly; pi does, and it makes correct unattended behavior a one-line check.
Loading and trust
Extensions load from:
| Path | Scope | Trust |
|---|---|---|
~/.pi/agent/extensions/*.ts |
global (all projects) | loads immediately |
.pi/extensions/*.ts |
project-local | loads only after you trust the project |
pi -e ./x.ts |
one run | for testing |
pi loads TypeScript directly — no build step (like dsh’s plain ESM). A control extension belongs in the global path, so control is active before any repository is opened; project-local extensions are gated behind pi’s project_trust flow, which itself is extensible. pi requires Node 22+ — Node 20 boots it cryptically.
What pi’s model doesn’t include
pi is honest that it ships the mechanism and not the policy, so the gaps are the whole point rather than an oversight:
- No policy. Nothing decides allow/deny until an extension does. Out of the box, everything runs.
- No record beyond the session tree. pi keeps a branchable session tree — excellent for editing your own history, not an audit trail: it’s the run’s own account, on the run’s own machine, and it isn’t a decision ledger (what was evaluated, against which policy, on whose authority).
- No identity. Tool calls aren’t attributed to a user or a delegation chain.
- No cross-machine anything. Extensions and their config live per machine; two developers share nothing unless they build the sharing.
What the extension adds
The ACP extension for pi is one TypeScript file, dependency-free (it imports only a pi type), dropped in ~/.pi/agent/extensions/. It subscribes to the same two events this page just described and turns interception into enforcement:
tool_call— checks workspace policy before the call runs and returns{ block: true, reason }on deny, or lets it through on allow; anaskdecision surfaces a prompt whenctx.hasUIis true and denies by default when it’s false, matching pi’s own empty-chair posture rather than working around it.tool_result— scans output for policy violations (secrets, PII patterns) and can redact or rewrite before the model reads it, and writes the decision plus latency to an off-machine ledger regardless of allow/deny.
Configuration is environment variables, set once in the shell that launches pi: ACP_BEARER_TOKEN (workspace key, gsk_...), ACP_GOVERN_BASE (gateway URL, defaults to the hosted one), ACP_AGENT_TIER (labels the session interactive/subagent/background/api for policy purposes), and ACP_SHADOW (record decisions without enforcing them, for a dry run before turning policy on for real).
That covers tool-call policy and audit — the extension never sees a model call, so it doesn’t add cost. Cost is a separate, optional addition: a provider entry in ~/.pi/agent/models.json pointing baseUrl at the ACP proxy, launched with pi --model acp/<model-id>. The two pieces are independent installs that happen to report to the same console; see pi cost tracking for the full setup.
Where ACP fits
pi’s design is the cleanest possible statement of the thesis this whole site is built on: in a minimal-core harness, control is an extension, and the extension seam is where it has to arrive. The ACP extension for pi is an ordinary pi extension on tool_call and tool_result — workspace policy decides allow/ask/deny before the call, output scanning redacts or blocks after, ctx.hasUI drives the attended/unattended split, and every decision lands in a ledger off the machine. Same policy pi, Claude Code, dsh, and the rest all run; one place to answer what happened, everywhere, and why. It’s dependency-free — a single TypeScript file that imports only a pi type — so it adds nothing to pi except the half it deliberately left out.
Running pi? See and control every tool call it makes — allow/ask/deny before each call, the reason on every row, one installer:
curl -sf https://agenticcontrolplane.com/install.sh | bash
Full pi guide → · see your first checked call → · free up to 5 agents