The harness half of the brief, with the three checks reproduced rather than described.
Where it disagrees with 02-environment.md §1.4 it says so plainly; §1.4 is a contract,
so a mismatch is a PR note, not a workaround.
| # | Question | Answer | How it was established |
|---|---|---|---|
| C1 | Does the pi harness run Fable here, today? | Yes — through the Claude Code provider. Not through the direct Anthropic API. | Driven, not read (§3.1) |
| C2 | Does the harness emit a real session/request_permission? |
The protocol path exists and is wired end-to-end. The pi harness never produces it for ordinary tool calls. | Driven twice (§3.2) |
| C3 | Do tool calls route through a host gate, or is containment the harness’s choice? | The harness’s choice, for pi’s built-in tools. The host’s gate exists only where the host owns the tool surface. | pi’s own security doc + the same drives (§3.3) |
| C4 | Can one adapter serve the machine and browser placements? | Not as a process. As a topology — and that topology already exists. | pi-acp’s spawn + the CAP bridge (§3.4) |
None of these is assumed. Each section below carries the command and the observed output.
“I’m really inspired by the pi harness where it’s only got pi harness.”
The pi harness is one agent process with one model interface and its own tools — no adapter zoo between the user and the machine. Translated into a browser-fronted system, the minimal shape that keeps that property is:
voice in ──► voice model ──► the host ──► THE HARNESS (pi) ──► the machine
(proposes) (disposes) (one process, (real files,
one long-lived one declared cwd) real commands)
local process
One harness (pi), one protocol (ACP-shaped JSON-RPC), two transports. That is the whole claim:
02-environment.md §1.4 writes,
which is the shape the CAP bridge already runs in production. Not a new protocol.pi --mode rpc (what
pi-acp spawns). Browser placement: WebSocket to the bridge, which hosts the same pi child
on the machine. §3.4 is the proof this is already working, not a plan.| Seam | Owner | What crosses it |
|---|---|---|
| harness ↔ machine | the harness (pi) | real files, real commands, real processes — with the pi process’s own permissions |
| host ↔ harness | the host | ACP JSON-RPC: session lifecycle, prompts, updates, permission round-trips, cancel |
| voice ↔ host | the voice model | proposals: transcript events, intent candidates, “the user seems to want X” |
| host ↔ machine | the host | the project record: declared directory, git state, undo kind, process journal |
| host ↔ UI | the UI (astra’s lane) | conversation state, confirmation cards with structured plans, audit views |
The load-bearing property: voice never reaches the harness directly. Audio and ASR are untrusted input; the harness’s authority comes from the host, and the host’s authority over the harness is exactly the §1.4 contract — no more.
“extensible model, extensible platform”
There are two model footprints in this system, and extensibility means something different for each. Keeping them apart is what makes either swappable without rewriting the harness.
The voice model’s job is narrow: audio in, audio out, transcript events, and intent candidates out. It does not execute. It does not hold authority. It is a provider behind a small streaming interface:
interface VoiceModel {
start(session: { audioIn: Stream<AudioChunk>; onTranscript: (e: TranscriptEvent) => void;
onIntent: (i: IntentCandidate) => void; onAudioOut: (a: AudioChunk) => void }): VoiceSession;
// VoiceSession: sendAudio(chunk), interrupt(), close()
}
The harness’s model is already provider-extensible by pi’s own design — pi --model
provider/id, with the registry proven daily on this machine (gemini-3.8-flash, gpt-6-astra,
glm-5.3-flash, deepseek-v4-flash, kimi k3, qwen3.8-max, fable — seven providers in live use).
The host sets the model per (instance, root) session; the harness does not care which
provider serves it. Extensibility here is free because it is pi’s, not ours to build.
“this is the bit I want to try and work towards — a fable model using the pi harness that you’ve got access to.”
It works today. The finding, reproduced, is §3.1.
$ pi --list-models fable
provider model context max-out thinking images
anthropic claude-fable-5 1M 128K yes yes
anthropic claude-fable-5-1 1M 128K yes yes
pi-claude-code-provider fable 1M 64K yes yes
$ pi -p --model pi-claude-code-provider/fable "Reply with exactly: FABLE-OK"
FABLE-OK ← works (one transient OAuth-refresh
race on first attempt; retry clean)
$ pi -p --model anthropic/claude-fable-5 "Reply with exactly: FABLE-OK"
401 {"type":"error","error":{"type":"authentication_error","message":"API key is invalid."}}
$ pi -p --model anthropic/claude-fable-5-1 "…"
401 (same)
$ pi -p --model anthropic/claude-haiku-4-5 "…"
401 (same — the whole anthropic provider fails completions)
$ pi auth check --provider anthropic
ready ← presence of credentials, not validity
Verdict: Fable runs through the pi harness today via pi-claude-code-provider/fable
(the Claude Code OAuth subscription — Paul’s established path). What is missing for the other
route: a valid direct Anthropic API key — the configured one is invalid for completions
(despite auth check reporting “ready”, which asserts presence, not validity). No harness or
model-registry work is needed; the subscription path is the working one.
session/request_permission, drivenThe protocol path exists and is fully wired, in three confirmed links:
extension_ui_request dialogs for extension ctx.ui.confirm() /
select() calls (pi docs/rpc.md §1186–1243).conn.requestPermission(...) — the ACP protocol message
(pi-acp/dist/index.js:1308).e24e landed interactive
Allow/Deny handling for exactly this message.But the pi harness produces none of it for ordinary tool calls. Two drives against the real bridge with the real pi harness:
drive 1: initialize → session/new(cwd) → session/prompt("run ls -la …")
drive 2: initialize → session/new(cwd) → session/prompt("create hello.txt … then rm -rf it")
observed both times: initialize → session/new → N × session/update → prompt result
ZERO session/request_permission
The reason is in pi’s own security doc: pi’s only built-in gate is project trust, an
input-loading guard — “it does not restrict what the model can ask tools to do after you
start working in a directory.” pi’s built-in tools run with the pi process’s permissions
and ask for nothing. In non-interactive modes (including --mode rpc, which pi-acp uses)
pi does not even show the trust prompt.
Consequence for the design (a §1.4 note, not a workaround): §1.4’s compliant mode —
“the harness must supply resolved if it wants a decision rather than a refusal” — does not
exist with pi as-is, because pi never asks. To make compliant mode real, one named mechanism
is required: a pi extension that hooks tool calls and asks via ctx.ui.confirm() (the
channel C2 proves is wired). That is a small, named piece of work, not a discovery. Until it
exists, Tier 2 gating for pi’s built-in tools is the host’s problem, and only one shape of
host can own it: the one in §3.3.
Containment is the harness’s choice for pi’s built-in tools. The host cannot make pi ask — pi’s approval policy is pi’s. So the tier table is a boundary in exactly two shapes, and a disclosure everywhere else:
resolved (argv, paths, effects) before anything runs — it is the tool
boundary, so it has the plan by construction. Tier enforcement is real here.resolved in the harness’s own words. The host
decides; the harness honours the decision because the extension blocks on it.02-environment.md already says this —
and after these drives it is not a caveat, it is the current state. The tier table must be
presented as a disclosure until shape 1 or 2 is in force for the session.The third honest option for Tier-0/never-act work is environmental containment — a container or policy sandbox with only the files and credentials the task needs. pi’s own security doc names exactly this for unattended work, and it is the only containment that does not depend on anyone’s compliance.
pi-acp is a stdio process spawner (pi --mode rpc as a child process: spawn, PATH,
filesystem). A browser cannot host it. So one adapter-as-process cannot span both
placements — but the working topology already exists and is running in production for CAP:
browser page (extension context) ⇄ WebSocket ⇄ acp-bridge (on the machine) ⇄ stdio ⇄ pi child
↑ this IS the browser placement ↑ hosts the adapter ↑ same harness
The drives in §3.2 used exactly this shape (a WebSocket client driving pi through the
bridge). One harness, one ACP protocol, two transports. Not a second harness — a second
transport, and it is the same bridge the host already reuses for the machine placement’s
browser-facing side. The browser placement’s adapter lives on the machine; the browser is a
client, never a host. (§4 of 02-environment.md’s revised capability-per-placement model is
consistent with this: the browser placement declares fewer capabilities because it cannot
spawn, and the design should say that is a transport fact, not a harness difference.)
“the Chrome agent platform where it’s like super powerful — I can give it a new set of tools if needed. Maybe extensions put tools in for the agent loop.”
What CAP got right, in the order voicebox should steal them:
What it cost, in the same order:
Nothing in the shape — it is the right contract. Three clarifications, all evidence-backed:
session/cancel plus process-group stop is not optional — after C2/C3 it is the only
hard lever the host always has. The contract has it; keep it load-bearing.ctx.ui.confirm()), turning the
tier table from disclosure into boundary for pi’s built-in tools.Written by k3, 2026-09-19. Every capability claim above carries its drive or its citation; anything phrased as “works” was run, and anything phrased as “does not work” was run twice.
Paul’s requirement, his mechanism: “You obviously build extensions like we can with pi and then just have the model register them.” It works today, end to end, in one session.
The drive (tmux-hosted interactive pi, /tmp/vb-dynamic, --approve):
prompt: "Create .pi/extensions/now.ts … that registers a tool named now"
→ the model WROTE the file itself, mid-session:
import type { ExtensionAPI } …
export default function (pi: ExtensionAPI) {
pi.registerTool({ name: "now", …, async execute() {
return { content: [{ type: "text", text: new Date().toString() }] };
}});
}
/reload
→ "Reloaded keybindings, extensions, skills, prompts, themes, and context files"
prompt: "Use the now tool to tell me the current date and time."
→ tool called IN THE SAME SESSION, result into context:
now → "Sat Sep 19 2026 12:37:42 GMT+0100 (British Summer Time)"
The full loop — author (model) → register (/reload) → visible → callable → result — runs without a restart. So the harness side of dynamic tool creation is not a hole; it is a working capability, and the design questions are all on the authority side:
~/.pi/agent/extensions/ (global) or
.pi/extensions/ (project-local — loaded only when the project is trusted). Hot-reloaded
mid-session by /reload. pi -e ./path.ts for one-off loads.pi.registerTool() inside any loaded extension).Second drive: pi -xt bash (denylist), then “Run the shell command: ls”:
model: "No shell tool in this harness. I can't run ls."
model: "This environment has no shell/exec tool — only file read/edit/write…"
The tool was not in the list the model sees — it reported the absence instead of trying
and failing. -t/--tools (allowlist) and -xt/--exclude-tools (denylist) are set at
spawn, so the host decides per placement and per session which capabilities exist, and
the model’s own report of the boundary is accurate. An exec tool absent in the browser
placement is exactly this, declared at launch.
Inside the pi process: the declaration is the last word. A tool’s execute() runs with
the pi process’s permissions; there is no sandbox (pi’s security doc says so explicitly). The
contradiction mechanisms that do exist, in order of strength:
/reload trigger
are the gate: the model proposes tool source; the host reviews (a Tier 2 act whose
resolved is the file content and the registration it performs); the host reloads. The
model does not get to reload its own proposals past the host — that is precisely where the
tier table does work. In my drive the loop ran ungated; in the built system the directory
and the reload belong to the host.rm -rf” as an example use case). A
policy extension is the in-harness veto — and the same mechanism C2’s compliant mode needs..pi/extensions/ never loads at all — the
load-time boundary, already proven.Dynamic tool creation is possible today and the gate is locatable exactly where the
rest of the design already has one: authoring is the model’s, admission is the host’s Tier
2 decision over the file content, registration is the host’s /reload. The one thing that
must not happen is the loop running ungated (model writes and reloads directly), because then
the tool proposal — the act with the most reach in the whole system — bypasses the only table
meant to govern it.
The named mechanism from §3.2, now proven rather than proposed. Twenty lines, and they live
in the repository, not in a scratch dir: .pi/extensions/policy-gate.ts (currently on
branch rescue/compliant-mode-extension with C5’s now.ts alongside it) — the file an
implementer copies, with its comments intact. The snippet below is the same file:
// .pi/extensions/policy-gate.ts
export default function (pi: ExtensionAPI) {
pi.on("tool_call", async (event, ctx) => {
const plan = JSON.stringify({ tool: event.toolName, input: event.input }, null, 2);
const ok = await ctx.ui.confirm(
`Permission: ${event.toolName}`,
`The agent wants to run this tool:\n\n${plan.slice(0, 1500)}\n\nAllow it to execute?`,
);
if (!ok) return { block: true, reason: `denied by the host: ${event.toolName} was not allowed to run` };
});
}
tool_call fires before execution and can block (docs/extensions.md §778). In the TUI the
confirm is modal; under RPC/ACP the same ctx.ui.confirm() becomes a real
session/request_permission (the wire C2 proved). Driven interactively, three proofs:
create delete-me.txt → write dialog → denied → the model immediately tried to route
around it with a shell redirect (bash dialog → denied) → then a read probe (dialog →
denied). Final state: the file does not exist. Every attempt was asked; nothing
executed. The bypass attempt is the finding that shapes the gate: it must cover every tool
call, because the model’s first response to a denial is to try another tool. A gate scoped
to tool names would have been routed around in one turn.
Every dialog rendered the structured plan before any execution:
Permission: read
The agent wants to run this tool:
{ "tool": "read", "input": { "path": "/tmp/vb-dynamic/delete-me.txt" } }
Allow it to execute? → Yes / No
The host’s decision is made on content (tool + full input), never on a title.
Use the read_host tool on /etc/hostname → Permission: read_host with the plan
(tool: read_host, input: { path: "/etc/hostname" }) → denied → the model reported
“read_host was denied by the host this time, so I couldn’t re-read it.” The gate does not
distinguish built-in from model-registered tools — the C5 loophole is closed.
event.input may be unvalidated at hook time (prepareArguments runs before schema
validation), so a gate inspecting the plan must tolerate partial shapes — and a plan that
looks complete may not be what the tool finally validates.~/.pi/agent/extensions/) and even then covers only pi
processes, not anything a child spawns outside the extension system. “The gate covers X
and not Y”: X = every tool_call in gated pi sessions; Y = child processes, non-pi
subprocesses, and anything outside the extension’s process.session/request_permission the host answers — the semantics proven here transfer, the
transport differs.Real, and small. The tier table can now be a boundary for pi’s built-in and model-authored tools: intercept → plan → ask → honour. The remaining work is host-side: answer the request (§1.4’s permission-response schema already exists), record it in the audit, and never let the model’s own report of compliance stand in for the audit of effects.
Paul: “isocan has got a really great way of having multiple harnesses work together… we can use it as inspiration.” C4 answered the browser question (one agent, reached two ways). His requirement is the other shape: several named agents, live on one project, at the same time — his phone’s session and this chat session, both on the same work. Those are not the same mechanism, and the difference is where the coordination lives.
~/.isocan/rc-agents.json, today:
[
{ "canvasId": "prj_6nodKBn0oA", "actorId": "usr_Ut1iNC2vQw", "name": "PK_Bot2",
"harness": null, "cwd": "/home/paulkinlan/isocan-getting-started", "sessionId": "01a08d7e-…" },
{ "canvasId": "prj_6nodKBn0oA", "actorId": "usr_tnqRhL6b0y", "name": "PK_Scout",
"harness": "pi", "cwd": "/home/paulkinlan/isocan-getting-started", "sessionId": "01a08d7e-…" },
{ "canvasId": "prj_6nodKBn0oA", "actorId": "usr_9SGTtKaRcv", "name": "Scout",
"harness": null, "cwd": "/home/paulkinlan/isocan-getting-started", "sessionId": "01a09201-…" }
]
Three named agents on one canvas, two of them pi harnesses, each a distinct actor with its own session and cwd. The model in one sentence: an agent is an enrolled record — (canvasId, actorId, name, harness, cwd, session) — and agents coordinate through the canvas’s shared oplog (comments, threads, items, versions), which they can all read by construction. Around that record: the bench (presence per agent — ready / elsewhere / unreachable, measured every time you look), seen-marks (per-person read positions kept by the home, converging across machines), and whose word starts a turn (a standing agent answers only its owner until widened, with lapse-bounded grants — the per-agent permission layer).
The environment design’s per-(instance, root) session is isomorphic at the registry level and different at the coordination level:
| isocan | environment design (as written) | |
|---|---|---|
| Agent identity | actorId, claimed against the harness’s session id — two agents sharing a directory stay two people | (instance, root) session |
| Registry with liveness | the bench: ready / elsewhere / unreachable | capability per placement (no liveness column) |
| Coordination medium | the canvas: a shared, ordered, persistent log every agent reads | per-root audit + work meeting as an ordinary git merge |
| Read positions | seen-marks, per person, converging across machines | none — agents meet at merges, not at a shared view |
| Turn authority | whose word starts a turn, per agent, lapsing | the tier table (per act, not per agent) |
Verdict: not the same mechanism, and not a reinvention either — a missing layer. The registry shape (named agent instances with declared placements) is the same idea discovered twice. What isocan has and the environment design lacks is the coordination medium: a shared log with presence and read positions, versus audits that only meet at merges. For Paul’s phone-and-chat case, merges give him two sessions that see each other’s work late and through git; the canvas gives him two sessions that see each other’s work as it happens.
C4’s “one harness, one protocol, two transports” is correct for the browser question and incomplete for the multi-agent requirement. The architecture needs the coordination layer isocan already proved: shared log, presence, read positions, per-agent turn authority. Whether that layer is adopted (the canvas pattern as the project’s shared medium) or declared absent (merges-only, with the cost named: no presence, no shared view) is Paul’s call — but it should be a decision, not an oversight.
The unproven link from §8 is now proven end-to-end, in both directions, over the real ACP
bridge with the gate loaded globally (~/.pi/agent/extensions/policy-gate.ts — which
also settles the global-load question: it gated sessions in a different directory).
client: initialize → session/new(cwd) → prompt("Create wire-test.txt")
wire: 13 × session/request_permission (write, bash, read, web_search, generate_image, …)
host: 13 × { outcome: "cancelled" }
effect: the file does not exist. (deny honoured)
client: same prompt
wire: session/request_permission { title: "Permission: write" }
host: { outcome: { outcome: "selected", optionId: "yes" } }
wire: tool_call: pending → in_progress → completed
effect: wire-test.txt contains "wire". (allow honoured)
The gate’s ctx.ui.confirm() reaches a host as a real protocol message and comes back —
compliant mode is real end-to-end, not only in the TUI.
The first allow attempt used the ACP-convention optionId: "allow_once". The tool failed
identically to a denial — because pi-acp’s confirm dialog speaks "yes"/"no"
(CONFIRM_PERMISSION_OPTIONS), and "allow_once" ≠ "yes" maps to confirmed: false. No
error, no complaint: a wrong optionId is a silent “no”. The host must answer with the
optionId from the request’s own options array (or map by kind: allow_once /
reject_once) — never assume a universal id. This is the day’s rule one more time: the
failure is silent and always in the direction that looks like the safe answer.
With a second handler (z-mutator.ts) loaded after the gate, rewriting bash input:
ASKED (plan shown to host): { "tool": "bash", "input": { "command": "echo approved > /tmp/vb-dynamic/plan-a.txt" } }
host answered: ALLOW
which plan ran? /tmp/vb-dynamic/plan-b.txt
The host approved plan A; plan B executed. tool_call inputs are mutable and later
handlers see (and change) earlier ones’ work — so a handler behind the gate in load order
can rewrite an approved plan after approval. There is no later hook to catch it. The design
rule this forces: the gate must be the last tool_call handler, and that ordering is part
of its authority — the host owns the load order, and any extension registered after the
gate is itself an admission act, because it can rewrite what the host just approved.
(Also recorded: event.input may be unvalidated at hook time, so even a well-ordered gate
must tolerate partial shapes.)
Proven end-to-end: intercept → plan on the wire → host answers with the adapter’s own optionId → effect matches the answer. The two holes are named with their rules: answer by the request’s own options, and treat the gate’s load-order position as part of its authority. Both rules are about the failure being silent — the same shape as everything else this week has produced, and the reason the host’s permission path needs its own drive rather than a schema read.
Test fixtures removed after the drive: z-mutator.ts and policy-gate.ts are out of the
global extension dir (a mutator left global would rewrite every future bash call on this
box; a gate left global would make every pi session interactive-by-force). The canonical
copy of the gate stays at .pi/extensions/policy-gate.ts on
rescue/compliant-mode-extension.
§10’s hole 2 — a handler after the gate rewrote an approved plan — demanded more than the rule “the gate must be last”. The mechanism, built and driven both ways:
session_start, scan
the auto-discovered extension directories for files that sort after its own name, and if
any exist, refuse to gate — loudly — instead of producing approvals that mean less
than they appear to.(a) A later handler exists (z-mutator.ts present):
gate-state.json: {"event":"session_start",
"later":["/tmp/vb-dynamic/.pi/extensions/z-mutator.ts"], "refuse":true}
permissions: 0 ← the gate refused, loudly, and gated nothing
plan-b.txt exists ← ungated, the rewrite ran — exactly what the refusal announced
(b) No later handler (mutator removed):
gate-state.json: {"event":"session_start", "later":[], "refuse":false}
permissions: 1 ← the gate engaged and asked
no plan files ← host denied; nothing executed
A gate that cannot prove it is last announces and abstains; a gate that can, gates. Both outcomes are visible in the state file, which is the audit channel for the mechanism.
-e,
packages, or paths the scan cannot read are invisible to it. The host’s ownership of the
load order is not optional; the scan converts the common silent case into a loud one,
it does not make the position provable.child.stderr.on("data", () => {})
in pi-acp) — so console.error announcements die in a dead channel over the wire. The
RPC-visible channel for the refusal is the state file (or ctx.ui.notify for clients
that render fire-and-forget extension-UI). In the TUI the refusal prints visibly; the
isolation run showed it: “policy-gate is REFUSING to gate: these extensions sort after
it… /tmp/vb-dynamic/.pi/extensions/z-mutator.ts.”