# Pi Runtime Notes > Verified against **pi 0.84.3** on Debian 13 / node v22.23.2, 2026-08-27. > Every claim below is either quoted from `dist/` source or reproduced by the > probe harness in [`evidence/probe-harness/`](evidence/probe-harness/). > Results: [`evidence/2026-08-27-isolation-probe.md`](evidence/2026-08-27-isolation-probe.md). This file records behaviour that the official docs either do not state or state only in passing, but which determines whether a dedicated Pi agent works at all. Re-verify after every `pi update`. --- ## 1. The skills section requires an active `read` tool `dist/core/system-prompt.js`: ```javascript const hasRead = tools.includes("read"); // Append skills section (only if read tool is available) if (hasRead && skills.length > 0) { prompt += formatSkillsForPrompt(skills); } ``` `tools` is the **active** tool-name list, passed as `selectedTools` from `agent-session.js:_rebuildSystemPrompt(this.getActiveToolNames())`. **Consequence:** `--no-tools` silently removes the entire skills block. Any `SKILL.md` is then unreachable, and `--skill ` becomes a no-op. Probe evidence (`--no-tools` vs `--no-builtin-tools`): | Flags | `available_skills` in prompt | prompt length | |---|---|---| | `--no-tools` | **false** | 1859 | | `--no-builtin-tools` | true | 3413 | This was a live defect in the Curator scenario: it ran `--no-tools --skill …` for its whole lifetime, so its 64-line media policy never reached the model. ## 2. Skill injection is progressive, not full text `formatSkillsForPrompt` emits only ``, `` and `` per skill, plus the instruction "Use the read tool to load a skill's file when the task matches its description." So the body of a `SKILL.md` costs nothing up front but **is only ever read via the `read` tool**. A skill body is unreachable without a working `read`. Corollary: if you override `read` with a path-restricted implementation, the allowlist must include the skill directory or skill bodies cannot be loaded. ## 3. `--skill` does not double-load an auto-discovered skill `dist/core/skills.js:loadSkills()` dedupes twice — by canonical realpath, and by skill `name`. Defaults are added first, so on a name collision the **auto-discovered** skill wins and a collision diagnostic is emitted. Same file via symlink is skipped silently. ## 4. Tool gating semantics | Flag | registry filter | initial active set | extension tools | |---|---|---|---| | *(none)* | none | `settings.defaultTools ?? [read,bash,edit,write]` | all activated | | `--tools a,b` | **strict allowlist across every source** | `[a,b]` | must be named to exist | | `--no-tools` | empty allowlist | `[]` | **also killed** | | `--no-builtin-tools` | none | `[]` | **all activated** | | `--exclude-tools x` | removes `x` | filtered | applies to all sources | From `dist/core/agent-session.js`: ```javascript const isAllowedTool = (name) => (!allowedToolNames || allowedToolNames.has(name)) && !excludedToolNames?.has(name); ``` Under `--no-builtin-tools` the built-ins stay **registered but inactive**, so an extension can re-enable them with `pi.setActiveTools([...])`. Under `--no-tools` they were never registered and cannot be revived. Probed: `--no-builtin-tools` + one extension → `ACTIVE_TOOLS=["probe_plain_schema","read"]`, while `bash/edit/write/grep/find/ls` remain listed as `[builtin]` and inactive. ## 5. `registerTool` accepts a plain JSON Schema object — verified Declared type is TypeBox `TSchema`, but a raw JSON Schema object works: ```typescript pi.registerTool({ name: "probe_plain_schema", parameters: { type: "object", properties: { q: { type: "string" } }, required: ["q"] } as any, ... }); ``` Probe result: the tool appears as `probe_plain_schema[cli]` and is active. **Why this matters:** a backend can serve its tool definitions as JSON Schema over HTTP and a generic bridge extension can register them at runtime, so the schema has exactly one source of truth. See `shared/extensions/`. Note `--tools` is a *registry-level* allowlist, so dynamically registered tools must either be named in `--tools` or you must use `--no-builtin-tools` (no allowlist) and let the extension govern the active set. ## 6. An extension can override a built-in tool by name — verified Registering `name: "read"` replaces the built-in in the registry: the probe showed `read[cli]` instead of `read[builtin]` while other built-ins kept `[builtin]`. This is the supported way to give an agent a path-restricted `read` while still satisfying the requirement in §1. `memo-guard.ts` in the memo-inbox scenario has used this in production. ## 7. Only tools with `promptSnippet` appear in the human-readable tool list `dist/core/system-prompt.js`: "A tool appears in Available tools only when the caller provides a one-line snippet." The tool *schemas* are always sent through the provider's tool-calling API, so an omitted `promptSnippet` does not break invocation — but the model loses the prose overview. Always set `promptSnippet`, and `promptGuidelines` where useful. ## 8. `.pi/SYSTEM.md` replaces the default prompt; the replacement branch omits tools and guidelines Default prompt (`system-prompt.js:82`) begins: > You are an expert coding assistant operating inside pi, a coding agent harness. > You help users by reading files, executing commands, editing code, and writing new files. and ends with a block of **absolute paths to pi's own README / docs / examples** plus "read the docs and examples, and follow .md cross-references before implementing". For a non-coding agent this is not merely noise: it is a ready-made escalation path for prompt injection. The `customPrompt` branch (`system-prompt.js:13-33`) concatenates only: ``` SYSTEM.md → APPEND_SYSTEM.md → (AGENTS.md) → skills section → cwd ``` It does **not** include `toolsList` or `Guidelines`. A replacement `SYSTEM.md` must therefore carry its own tool overview and output discipline. `.pi/SYSTEM.md` lives under `.pi/`, so it requires **project trust** — pass `--approve` in non-interactive modes. Probe evidence: | Combination | coding-assistant text | pi-docs block | SYSTEM.md applied | prompt length | |---|---|---|---|---| | isolation flags only | true | true | false | 2619 | | `+ .pi/SYSTEM.md + --approve` | **false** | **false** | **true** | **960** | ## 9. Context files are loaded from every parent directory, and `AGENTS.override.md` does not stop that Load order: `~/.pi/agent/AGENTS.md`, then each parent directory walking up from cwd, then cwd. `AGENTS.override.md` replaces `AGENTS.md`/`CLAUDE.md` **for its own directory only**. Parent directories still layer normally — the docs say so explicitly ("Context files from other directories still layer normally") and it is easy to get this wrong. Probed: with `AGENTS.override.md` present in the workspace and a marker file at `/tmp/AGENTS.md`, the marker **still appeared** in the system prompt. | Configuration | parent `/tmp/AGENTS.md` in prompt | |---|---| | workspace has `AGENTS.override.md` | **yes** | | `--no-context-files` (`-nc`) | no | So a stray `~/AGENTS.md` or `~/pi-workspaces/AGENTS.md` silently contaminates every scenario rooted below it, and an override file will not save you. Neither existed on this host as of 2026-08-27, but nothing prevents one from appearing. **The only way to make the personality deterministic is `-nc`.** Since that also drops the workspace's own context file, the durable role content has to move into `.pi/SYSTEM.md` and `.pi/APPEND_SYSTEM.md`, which are system-prompt files rather than context files and are therefore unaffected by `-nc`. Verified combination — no coding-assistant framing, no pi-docs block, own identity and role text present, parent pollution absent, only the scenario's own skill listed: ``` --no-builtin-tools --no-extensions -e --no-skills --skill --no-prompt-templates --no-themes --approve -nc + .pi/SYSTEM.md + .pi/APPEND_SYSTEM.md ``` Note also that `cwd` is what anchors this discovery: launching pi from the wrong working directory silently drops `.pi/SYSTEM.md` and every workspace context file. A probe that forgot `cwd` produced a 2601-character prompt with the coding-assistant persona intact; with the correct `cwd` it produced 4082 characters with the persona replaced. ## 10. Project trust gates `.pi/`, and CLI `-e` bypasses it Trust is required when the directory contains `.pi/settings.json`, `.pi/{extensions,skills,prompts,themes}`, `.pi/SYSTEM.md`, `.pi/APPEND_SYSTEM.md`, or a project `.agents/skills`. A bare `.pi` does not count. Always loaded regardless of trust: `AGENTS.override.md`, `AGENTS.md`, `CLAUDE.md`, user/global extensions, and **CLI `-e` extensions** — the last one is deliberate so they can handle the `project_trust` event. Non-interactive modes (`-p`, `--mode json`, `--mode rpc`) never prompt; they use `defaultProjectTrust` (`ask` default / `always` / `never`) unless `--approve` / `--no-approve` overrides for the run. **Design consequence:** loading a scenario extension with an absolute `-e /path/to/ext.ts` avoids the trust question entirely, which is the most robust option for a systemd-managed gateway. ## 10b. `PI_CODING_AGENT_DIR` isolates the agent directory — but not `~/.agents/skills` ``` PI_CODING_AGENT_DIR Override the config directory; default is ~/.pi/agent ``` This is the strongest isolation lever available, and it is stronger than the `--no-*` flags because it repoints **credentials and trust** as well as resources. The `pi-grok` scenario on this host uses it: ```sh export PI_CODING_AGENT_DIR="$PI_GROK_HOME/.pi-agent" ``` Measured, with a marker extension planted in both directories and no `--no-*` flags at all: | Resource | default agent dir | `PI_CODING_AGENT_DIR=` | |---|---|---| | `/extensions/*` | `DEFAULT_EXT_LOADED` | `ISO_EXT_LOADED` — **isolated** | | `/skills/*` | *(none)* | `iso-skill` present — **isolated** | | `~/.agents/skills/*` | `find-skills, modsearch, summarize` | `find-skills, modsearch, summarize` — **still leaks** | So it isolates `settings.json`, `models.json`, `auth.json`, `trust.json`, `extensions/`, `skills/`, `prompts/` and `themes/` **under the agent directory**, but `~/.agents/skills/` is a separate discovery root that it does not touch. Practical consequences: * Use `PI_CODING_AGENT_DIR` per scenario when scenarios should not share provider credentials, trust decisions or model defaults. It is the only way to stop one scenario's `auth.json` from being readable by another's agent. * It does **not** replace `--no-skills`. Keep the loading flags as well. * A companion variable exists: `PI_CODING_AGENT_SESSION_DIR`, overridden by `--session-dir`. ## 10c. `--append-system-prompt` accepts a file path The help text says "Append text **or file contents**". `pi-grok` relies on this: ```sh --append-system-prompt "$PI_GROK_HOME/AGENTS.md" ``` This is a third way to inject durable role text, alongside `.pi/APPEND_SYSTEM.md` and `AGENTS.md`. Unlike a context file it is immune to the parent-directory walk, and unlike `.pi/APPEND_SYSTEM.md` it needs no project trust. Useful when the role text must live outside the workspace. ## 11. User-global resources leak into every scenario Probed with no isolation flags, from an unrelated workspace, the skills list was: ``` PROBE_SKILLNAMES=["find-skills","modsearch","summarize"] ``` Those come from `~/.agents/skills/`. `find-skills` in particular instructs the agent to discover and install further skills — an unwanted capability surface for a narrow-purpose agent. `~/.pi/agent/extensions/*.ts` leak the same way. With `--no-skills --skill ` the list becomes exactly `["probe-skill"]`. ## 12. No output-schema flag; use a terminating tool with constrained sampling There is no `--output-schema` / `--response-format`. Structured output is achieved with a tool whose `parameters` is the desired schema: ```typescript { name: "emit_result", parameters: , constrainedSampling: { type: "json_schema", strict: "prefer" }, async execute(_id, params) { return { content: [...], details: params, terminate: true }; } } ``` `ConstrainedSamplingConfig`: ```typescript | { type: "json_schema"; strict: "prefer" | "require" } | { type: "grammar"; variants: Partial> } ``` `strict: "prefer"` falls back gracefully; `"require"` fails the request if the provider cannot honour it. `terminate: true` ends the turn only when *every* finalized tool result in the batch sets it. This replaces regex-scraping JSON out of prose. ## 13. Tools must truncate their own output Built-in caps are **50 KB** and **2000 lines**. Helpers exported for this: `truncateHead`, `truncateTail`, `truncateLine`, `formatSize`, `DEFAULT_MAX_BYTES`, `DEFAULT_MAX_LINES`. Errors: **throw** from `execute` to set `isError: true`. Returning an error-shaped object does not mark the call failed. `AgentToolResult.content` goes to the model; `details` is persisted in the session but **not** sent to the model. ## 14. Per-call gating hook ```typescript pi.on("tool_call", async (event) => { if (!ALLOWED.includes(event.toolName)) { return { block: true, reason: "…" }; } }); ``` `ToolCallEventResult = { block?, reason?, terminate? }`. To rewrite arguments, mutate `event.input` in place. Later handlers observe earlier mutations and no re-validation happens afterwards. Combined with `pi.setActiveTools()` on `session_start` and `resources_discover`, this gives two independent layers of capability control that survive a mistake in the CLI flags. ## 15. RPC mode is strict JSONL and is meant to be long-lived `--mode rpc`: commands as JSON objects on stdin, one per line; responses and events as JSON lines on stdout. > Split records on `\n` only. Accept optional `\r\n` by stripping a trailing > `\r`. Node `readline` is **not** protocol-compliant because it also splits on > `U+2028`/`U+2029`, which are valid inside JSON strings. 33 commands, including `prompt`, `steer`, `follow_up`, `abort`, `new_session`, `get_state`, `set_model`, `set_thinking_level`, `compact`, `set_auto_compaction`, `get_session_stats`, `switch_session`, `fork`, `get_entries`. `message_update` carries **deltas only** — no cumulative `message`, no `partial`. Treat `message_end.message` as authoritative. There is **no** command to inject a tool result from the host. Host-side capabilities must be exposed as an extension tool that calls back out. Probing `get_state` alone starts the agent, fires `session_start`, and exits without ever contacting the model — a zero-cost way to inspect the resolved system prompt and tool set. ## 16. Usage and cost are recorded per assistant message Every `AssistantMessage` carries: ```typescript Usage { input, output, cacheRead, cacheWrite, totalTokens, cost: { input, output, cacheRead, cacheWrite, total } } ``` `ToolResultMessage`, `CompactionEntry` and `BranchSummaryEntry` carry an optional `usage` for nested LLM work. Live equivalent: RPC `get_session_stats`, which also returns `contextUsage { tokens, contextWindow, percent }`. Observability therefore needs no extra instrumentation — read the session JSONL or subscribe to `message_update`. ## 17. Sessions never rotate or expire `--session-id ` is an idempotent "use this project session, create if missing" primitive (mutually exclusive with `--session`, `--continue`, `--resume`; validated against `^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$`). There is no built-in TTL, rotation, size cap or pruning. Files grow monotonically until deleted by hand. ## 18. Auto-compaction rarely triggers on large-context models Condition: `contextTokens > contextWindow - reserveTokens`. ```json { "compaction": { "enabled": true, "reserveTokens": 16384, "keepRecentTokens": 20000 } } ``` With a 1,050,000-token context window the threshold is ~1.03 M tokens, so latency and cost degrade for a very long time before compaction ever fires. A gateway must implement its own rotation policy — see [`gateway-patterns.md`](gateway-patterns.md). ## 19. `allowed-tools` in SKILL.md frontmatter is not enforced `dist/core/skills.js:loadSkillFromFile` consumes only `name`, `description` and `disable-model-invocation`. `allowed-tools` is documented as experimental and is **not read** in 0.84.3. The memo-inbox scenario declares `allowed-tools:` in its `SKILL.md`; the real enforcement is the `ALLOWED_TOOLS` array plus `setActiveTools` and the `tool_call` hook inside `memo-guard.ts`. Do not rely on the frontmatter field for security. ## 20. There is no sandbox `docs/security.md`: "Pi does not include a built-in sandbox. Built-in tools can read files, write files, edit files, and run shell commands with the permissions of the pi process. Extensions are TypeScript modules that run with the same permissions." A path-restricted `read` override is an application-level boundary, not a kernel one. Real isolation requires bubblewrap / container / micro-VM; pi ships `examples/extensions/sandbox/` (bubblewrap, `.pi/sandbox.json`) and `examples/extensions/gondolin/` (micro-VM) as starting points. --- ## Re-verification ```bash cd docs/evidence/probe-harness ./collect-evidence.sh > ../$(date -u +%Y-%m-%d)-isolation-probe.md ``` The harness costs zero model tokens: it sends a single `get_state` over RPC and reads the resolved system prompt from `session_start`.