Extension State and Persistence
Where an extension puts its state so it survives reload, resume, and fork, and why the answer is almost always a session entry rather than a module variable.
Your guard extension blocks some writes to protected paths. It works on Monday. On Tuesday you run /reload, and the block count in /guard-blocks reads zero. On Wednesday you resume the session from disk and it reads zero again.
Nothing is broken. The count was never persisted. It lived in a let captured by the extension factory, and a reload or a new process creates a new instance. The fix is not necessarily to invent a separate storage file: Pi already provides a durable, branch-aware place for state in persistent sessions. An ephemeral session, however, does not survive process exit.
Where state can live
Pi’s extensions.md gives you a short table, and it is worth reading as a decision rather than a list. How state participates in the conversation decides where it goes:
| State | Storage |
|---|---|
| Tool state that follows the active branch | Tool-result details |
| Durable data excluded from model context | pi.appendEntry() |
| Custom content stored and sent to the model | pi.sendMessage() |
| Data outside one session | External storage |
Documented. That table is in the source doc, not inferred.
The same four rows, expanded across the properties that actually separate them:
| Property | Tool-result details |
pi.appendEntry() |
pi.sendMessage() |
External storage |
|---|---|---|---|---|
| Written as | a details field on one tool result |
one custom entry, for the session |
one custom_message entry, for the session |
whatever you write |
| In model context? | details is not sent directly; the tool result’s content is |
No | Yes, as an extension-injected message | Only if you inject or expose it |
| Branch-relative? | Yes | Yes — the entry sits in the tree | Yes — the entry sits in the tree | No |
| Survives reload and resume? | Yes | Yes | Yes | Yes |
| Payload owned by | your tool | you, keyed by customType |
you, keyed by customType |
you |
| Best use | state a tool call produced | durable facts the model must not read | content the model must read but the user did not type | anything that crosses sessions |
Two distinctions matter here. A tool result may carry details for rendering or reconstructing tool state; the model-facing part is content, and details is not itself sent as prompt text. External storage also stays outside model context unless an extension or tool deliberately introduces its contents. That is a separate choice from where the data is stored.
Wrong: “my tool returned a result object, so it succeeded.” Correct: returning an object is the success path.
extensions.md: “Throw fromexecute()to produce a failed tool result. Returning an object does not mark it as an error.” Return the result withisError: trueinstead when a failure still has to carry data to scripts.
Appending a durable entry
pi.appendEntry(customType, data) writes a custom session entry. The session format doc calls it CustomEntry, and the shape is small:
{"type":"custom","id":"h8i9j0k1","parentId":"g7h8i9j0","timestamp":"2024-12-03T14:20:00.000Z","customType":"my-extension","data":{"count":42}}
Two facts matter and both are documented. The entry sits in the tree, so it has an id and a parentId like everything else. And it does not participate in LLM context: session-format.md says of CustomEntry, “Does NOT participate in LLM context.”
That second fact is the reason to prefer it over pi.sendMessage(). Your guard’s block count is not something the model needs to reason about. Putting it in context spends tokens and, worse, invites the model to talk about it.
Here is the guard growing a branch-relative log of blocked attempts. It records an entry and displays a notification when a UI is available. This example is a path-pattern guard, not a filesystem security boundary: it checks only write and edit calls and matches substrings of the supplied path, without resolving canonical paths or covering other ways to modify files.
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
interface GuardBlock {
tool: string;
reason: string;
at: number;
}
const PROTECTED = [".env", ".git/", "node_modules/"];
export default function (pi: ExtensionAPI) {
// In-memory mirror, rebuilt from the session on every load.
let blocks: GuardBlock[] = [];
pi.on("tool_call", async (event, ctx) => {
if (event.toolName !== "write" && event.toolName !== "edit") return undefined;
const path = String(event.input.path ?? "");
const hit = PROTECTED.find((p) => path.includes(p));
if (!hit) return undefined;
const entry: GuardBlock = {
tool: event.toolName,
reason: `path matches ${hit}`,
at: Date.now(),
};
blocks = [...blocks, entry];
// Durable, branch-aware, and invisible to the model.
pi.appendEntry<GuardBlock>("guard.block", entry);
if (ctx.hasUI) ctx.ui.notify(`Blocked ${event.toolName} on ${path}`, "warning");
return { block: true, reason: `Path "${path}" is protected` };
});
pi.registerCommand("guard-blocks", {
description: "List blocked tool calls recorded in this session",
handler: async (_args, ctx) => {
ctx.ui.notify(
blocks.length === 0 ? "No blocked calls in this session" : blocks.map((b) => b.reason).join("; "),
"info",
);
},
});
}
appendEntry is generic over the data type, so the same GuardBlock interface describes the in-memory mirror and the persisted entry. That is worth doing: it makes the reconstruction a filter, not a translation.
flowchart TD
E["guard.block entries<br/>persisted, every branch"] --> G["getBranch() at session_start"]
G --> M["module variable<br/>the active history's state"]
M --> R["read during the run<br/>fast, and gone on restart"]
F["a file under .pi/"] -.->|"survives, but not branch-relative"| M
T["a tool result"] -.->|"branch-relative, when the tool is already called"| M
M -.->|"never reaches the model"| N["provider request"]
Only the first path is on the solid line. The two dotted inputs are the alternatives from the table below, and the dotted output is the property that makes this chapter’s approach cheap: bookkeeping a user never asked about stays out of the model’s context.
Four places state can live
Every option in this chapter trades durability against branch-sensitivity, and the trade is easier to see as a table than as prose.
| Survives a restart | Branch-relative | Reaches the model | Cost | |
|---|---|---|---|---|
| Module variable | no | no | no | free, and wrong on a fork |
appendEntry + getBranch() |
yes | yes | no | an entry per event in the file |
A file under .pi/ |
yes | no | no | shared across branches; needs a rewrite on fork |
Tool-result details |
yes | yes | not directly; its content is model-facing |
attached to a tool call that already occurs |
The third row is the tempting one and the second is the correct one for a guard’s
working state, because branch-relative means the abandoned path’s blocks do not count.
A file cannot give you that without extra machinery. Tool-result details can also hold branch-relative state when there is already a relevant tool call, but manufacturing a tool call solely to record a guard event would be unnecessary.
This is also the projection from chapter 26, applied to state rather than messages:
what is persisted, what is active, and what is sent are three separate questions, and
getBranch() answers only the middle one.
Reconstructing on load
The module variable is a cache, not the store. extensions.md tells you exactly how to refill it:
Reconstruct branch-sensitive state from
ctx.sessionManager.getBranch()duringsession_start. Do not rebuild it from every file entry because abandoned branches represent alternative histories.
Documented, and the second sentence is the one that matters. getBranch() returns the active branch. Scanning every entry in the file would give you the union of every alternative history, which is exactly the state you would have if the user had abandoned a different path.
const rebuild = (ctx: ExtensionContext) => {
const next: GuardBlock[] = [];
for (const entry of ctx.sessionManager.getBranch()) {
if (entry.type === "custom" && entry.customType === "guard.block") {
next.push(entry.data as GuardBlock);
}
}
blocks = next;
};
pi.on("session_start", async (_e, ctx) => rebuild(ctx));
pi.on("session_tree", async (_e, ctx) => rebuild(ctx));
session_start covers a fresh or resumed session; session_tree covers navigation inside one. The todo extension shipped in examples/extensions/ registers a reconstructState function on both, and it rebuilds its in-memory state from tool-result details on the branch returned by ctx.sessionManager.getBranch(). That pairing — and that source of truth — is the pattern to copy whenever your state follows the branch, whether you read it back from custom entries as rebuild does or from tool-result details as todo does.
Keying your entries by customType
customType is how you find your own entries again. Pick a namespaced string and never reuse it for a different payload shape. The session format doc notes that Pi itself stores virtual model router state as a custom entry with customType pi.virtual-model-state, which is a useful signal about the naming convention for things that are not yours.
Note what is not a custom entry: custom_message. Session version 3 renamed the hookMessage role to custom for extensions unification, and the two entry types now differ precisely on the context question. CustomEntry (custom) is persisted state with no model visibility. CustomMessageEntry (custom_message) is “Extension-injected messages that DO participate in LLM context.” Chapter 26 takes that pair apart properly.
The lifecycle rule that bites state code
extensions.md warns: “Do not start processes, sockets, watchers, or timers in the factory because some invocations load extensions without starting a session.” It also warns that reload “replaces the extension runtime, so code after await ctx.reload() must not reuse state from the old runtime.”
Both warnings caution against treating the factory’s closure as durable. Reload replaces the runtime, and resuming in a new process creates a fresh extension instance; resuming a session within an existing process does not itself imply a fresh factory invocation. Reconstruct session-scoped state from the active branch on the relevant session events, and persist anything that must outlive the process.
Persistence you are doing by hand
Sometimes state genuinely belongs outside the session. The guard might need a cross-session audit log, because “how many times did this repository trip the guard last week” is not a branch question. Use external storage for that record, but do not rely on writing it only during session_shutdown: an abrupt termination or crash can bypass cleanup. Persist audit events as they occur, with bounded writes and a clear failure policy; use session_shutdown to flush and release resources when it runs.
If you find yourself writing an external file to remember something the conversation already established, stop. Pi has a tree for that.
Verify it yourself
pi --extension ./guard.ts
Use a disposable test repository with a dummy .env file and no real credentials. Ask the agent to attempt a harmless edit to that file, confirm the guard blocks it, and run /guard-blocks. Reload with /reload and check that the entry is reconstructed. Then open /tree, navigate to a point before the blocked attempt, and run /guard-blocks again. The active-branch count should drop because that entry is no longer on the selected path. This tests state reconstruction and the narrow path matcher; it does not establish filesystem containment.
What was run
examples/ch19-state/guard-blocks.ts assembles the chapter’s two excerpts into one file, with the shared blocks variable in scope. Four tests drive it through a real session with a recording UI bound:
- a protected write is blocked, the user gets a warning notification, and a
customentry with the block’s data exists in the session; - the entry never reaches the provider: a later request contained neither
guard.blocknor the reason text; /guard-blocksreads the state back;- state follows the branch: navigating back to before the block and running the command again reports No blocked calls in this session.
That last result is the whole argument for entries over a file, observed rather than asserted.
What this chapter does not settle
It does not tell you how to make a custom entry visible in the transcript. Rendering one is pi.registerEntryRenderer(customType, renderer), documented, but that is interactive-only and belongs with the UI work in the next chapter.
Next: Slash Commands and Custom UI. A custom entry is not automatically rendered as a transcript message, although this guard already exposes its state through /guard-blocks and UI notifications. The next chapter develops richer interface integration and the rules governing it.