Skip to content
Agentic Control Plane

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.json of 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 governance 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.

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.