# pi (earendil-works) Permissions & Control Model, Explained

pi ships four tools and no permission system, on purpose. How its TypeScript extension events (tool_call, tool_result), ctx.hasUI, and containment patterns actually work — what an extension can intercept, and where the native model ends.

# 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](https://github.com/earendil-works/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](/integrations/pi); for the cross-harness picture, [the controls comparison](/controls).*

## 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](/controls/dsh) and Claude Code, and stronger than [opencode](/controls/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](/blog/interactive-vs-autonomous-the-empty-chair-test) 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](/integrations/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; an `ask` decision surfaces a prompt when `ctx.hasUI` is 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](/blog/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](/integrations/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](/controls/claude-code), [dsh](/controls/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.

## Frequently asked questions


