← Pi Agents

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 from execute() to produce a failed tool result. Returning an object does not mark it as an error.” Return the result with isError: true instead 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() during session_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 custom entry with the block’s data exists in the session;
  • the entry never reaches the provider: a later request contained neither guard.block nor the reason text;
  • /guard-blocks reads 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.