Example

Persist a guard's block count as a session entry

Log a blocked write with pi.appendEntry(), read it back with /guard-blocks, and watch the count follow the branch.

A module-level let dies with the process: /reload and resume both reset it. The fix is to use the durable store Pi already hands you. This example logs every blocked write as a custom session entry that the model never sees, and rebuilds the count from the active branch at session_start and on navigation. The claims are observed over a scripted model and a recording UI.

Run it

Use Pi 1.0.4 and one of the example repository’s test runners. The file below is an excerpt of the chapter’s canonical guard-blocks.ts:

	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",
			);
		},
	});

The in-memory blocks array is a cache, not the store. Refill it from the active branch, never from every entry in the file — abandoned branches are alternative histories, and their blocks must not count:

	// The module variable is a cache, not the store. Refill it from the active
	// branch, never from every entry in the file.
	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));

Expected outcome

Step Result (observed)
The model writes .env The write is blocked; the user sees a warning notification; a custom entry with customType "guard.block" lands in the session
The next request goes out Neither the entry nor the reason text reaches the provider
Run /guard-blocks It reports path matches .env
Navigate to before the block, run it again No blocked calls in this session — the count is branch-relative

Mechanism and limitations

Documented: pi.appendEntry(customType, data) writes a custom session entry; the session-format doc states that a CustomEntry “does NOT participate in LLM context”; extensions.md says to reconstruct branch-sensitive state from ctx.sessionManager.getBranch() during session_start rather than from every file entry. Observed: guard-blocks.test.ts drives the real extension through an AgentSession with a scripted provider and a recording UI, asserting each row above — including that the reason text is absent from the transcript the provider was sent, the strongest form of “invisible to the model”.

This example does not show a real model, and it does not show the entries in the transcript: rendering them is pi.registerEntryRenderer, which is interactive-only and belongs to the next chapter. The count survives reload only because /reload re-runs the factory and session_start rebuilds it — the factory itself is not durable.

Full source and test .

Understand this example

Copy this prompt into your AI tool. No code runs here.

Apply this example

Copy this prompt into your AI tool. No code runs here.