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 writes to .env and .git/. It works on Monday. On Tuesday you run /reload, and the block count in /guard status reads zero. On Wednesday you resume the session from disk and it reads zero again.
Nothing is broken. The count was never stored anywhere that survives a process. It lived in a module-level let that the factory closed over, and the factory runs again on every load. The fix is not “save it to disk somewhere sensible”. The fix is to understand that Pi already hands your extension a durable, branch-aware place to put state: the session file.
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? | No — content is the model-facing half |
No | Yes, converted to a user message | Only once you send 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 cells are read off context rather than stated flat. message-types.md calls a tool result’s details only “tool-specific”; extensions.md draws the line — “Its result requires model-facing content and a details field for rendering or state reconstruction.” And nothing sends external storage to the model: it arrives only when you inject it, which is the pi.sendMessage() column again.
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 durable log. It logs a block, then renders it.
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.
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 example in examples/extensions/ registers both, and that pairing is the pattern to copy whenever your state follows the branch.
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 are about the same mistake: treating the factory’s closure as durable. Your factory runs again after reload and again on resume. Anything you want to outlive the runtime has to be on disk, and the session file is already there.
Persistence you are doing by hand
Sometimes the state genuinely belongs outside the session. The guard might also need a cross-session audit file, because “how many times did this repository trip the guard last week” is not a branch question. That is the last column of the table above: external storage. Use node:fs and write it from session_shutdown, where you are told to release resources, and keep it idempotent, because cancellation, reload, session replacement, and process exit all converge on the same path.
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
In the session: ask the agent to edit .env, watch the block, then run /guard-blocks. Reload with /reload and run it again. The count survives. Now open /tree, navigate back to before the block, and run it once more. The count drops, because the block is on a branch you are no longer on. That behaviour is the whole argument for using entries over a file.
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. Nothing you stored so far is visible to a human — a custom entry sits in the file and in your reconstructed state, and the transcript shows nothing. Giving the guard an interface is the next attachment point, and the rules for it turn out to be stricter than they look.