Blog

Pi extensions in production: isolate, then write one

Part of: How Orbi is built on the Pi coding agent

How Orbi runs Pi coding agent extensions unattended: a declared --no-extensions allowlist, the D-Bus hang behind it, and two extensions we wrote ourselves.

On this page · 7 sections
  1. What Pi extensions are, and the three ways Pi loads them
  2. The D-Bus hang that made Orbi stop trusting Pi's default extension loading
  3. How Orbi loads Pi extensions: --no-extensions plus a pinned allowlist
  4. Writing a Pi extension with one accepted output channel
  5. A Pi extension as the only write path: Orbi Cloud's draft tool
  6. What to copy for your own Pi extensions
  7. Related

Pi extensions are TypeScript (or JavaScript) modules that the Pi coding agent loads at startup: each one exports a function that receives the extension API, and with it the module can register tools the model may call, add commands, and subscribe to session events. Pi has several ways of finding Pi extensions, and on a laptop every one of them is convenient. In a service that runs Pi with nobody watching, the two automatic ones are also a way for code you did not choose to end up inside the session.

I'm Lawrence Liu, the creator of Orbi. Orbi runs Pi unattended: it takes a GitHub issue, starts Pi to write the change, starts another Pi to review it, and merges when the review and CI pass. The Pi hub describes the runner around those sessions and the first post of this series walks one issue through them. This post is about Pi extensions only: how Orbi loads them, what went wrong before it did it this way, and two extensions we ended up writing ourselves. The commands below, and the shape of the config, are what runs today on Pi 1.2.0; the Orbi version numbers are the open-source engine's releases.

What Pi extensions are, and the three ways Pi loads them

The simplest Pi extension is one file. This one, from Pi's extension documentation, loads and does one thing:

import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";

export default function (pi: ExtensionAPI) {
  pi.on("session_start", async (_event, ctx) => {
    ctx.ui.notify("Extension loaded!", "info");
  });
}

Tools and commands are registered from the same function (pi.registerTool, pi.registerCommand); a complete tool appears further down. An extension can also be a directory with an index.ts or index.js, or a package whose package.json declares its entry points.

Pi loads extensions from three places, and the distinction matters for everything that follows:

  1. Discovery. A *.ts or *.js file, a directory with index.ts or index.js, or a package under ~/.pi/agent/extensions/ (global) or .pi/extensions/ (project-local) loads automatically. Discovery goes one level deep. Project-local extensions load only after you have answered Pi's one-time prompt to trust that project.
  2. Settings. pi install <source> records an npm or git package in ~/.pi/agent/settings.json, and Pi loads it on every start. pi list shows what is installed.
  3. Explicit paths. pi -e ./review.ts loads one file for this process only. The docs call this the way to test an extension quickly.

Besides those three sources of your own extensions, Pi ships built-in ones, such as MCP support, that load unless told otherwise. Then there is the switch that turns the automatic loading off. Three lines excerpted from pi --help in 1.2.0:

--extension, -e <path>   Load an extension file or builtin:<name> (can be used multiple times)
--no-extensions, -ne     Disable extension discovery and built-in extensions (explicit -e paths still work)
--no-builtin-tools, -nbt Disable built-in tools by default but keep extension/custom tools enabled

--no-extensions disables discovery, the packages in settings.json, and Pi's own built-in extensions such as MCP support (the CLI reference spells it out as "discovered, configured, and built-in extensions"). Explicit -e paths still load, and a built-in can be named that way too: pi -ne -e builtin:mcp is a session whose only extension is MCP, and the same shape with your own file is a session whose only extension is yours. Note what that flag does not do: it leaves Pi's built-in tools alone. The four on by default (read, bash, edit, write) are still there, and ls, find and grep are built in too, off by default, which is where the --tools lists below get those names. Turning those off is a separate switch, --no-builtin-tools, and the clarification gate below uses both.

The D-Bus hang that made Orbi stop trusting Pi's default extension loading

Orbi's runner on my machine is a systemd user service. On 4 September 2026 a run stopped before it did anything: the Pi process was alive, but minutes went by with no session file and no request to the model. The full write-up, with the diagnosis script, is in the first post's incident section; the short version is what matters here.

The runner's Pi had loaded the packages declared in my own ~/.pi/agent/settings.json, one of which was pi-mcp-adapter. That plugin initializes @napi-rs/keyring at startup, which makes a Secret Service call over D-Bus. Inside a systemd --user service that call can hang (it did, in several runs that evening; the same setup started by hand worked every time), and node waits on it before the first model request goes out. Nothing in the run had asked for MCP. The plugin was there because I had installed it for interactive use, and Pi's default loading, which reads settings.json, handed it to the unattended session too. Removing it from settings.json fixed that run: restarted, the same run got its session file and its first model response within 15 seconds.

It was one evening's incident, and one was enough: a package the runner's own user account had installed for interactive use (on my machine, me) had stopped an unattended run, and nothing in Orbi's config said it was allowed in. The fix for that, Issue #249, merged on 7 September (UTC): Orbi declares the extensions a run may have, and starts the implementer and the reviewer with --no-extensions. The third role, ticket, the session that writes issue comments, followed a month later; that story is in the table below.

How Orbi loads Pi extensions: --no-extensions plus a pinned allowlist

Orbi's runner reads its own config file, orbi.toml (the .github/orbi.toml that appears later is a different file, a repository's settings on GitHub). The list of Pi extensions is one repeatable table in it:

[[pi_extensions]]
source = "npm:some-extension@1.4.2"
enabled = true
[pi_extensions.env]
SOME_EXTENSION_MODE = "unattended"

[[pi_extensions]]
source = "git:github.com/org/repo@v1"

[[pi_extensions]]
source = "extensions/local-check.ts"

enabled defaults to true. Orbi validates this at config load (src/orbi/config.py): an npm source without a concrete version, a git source with nothing after an @, a local path that does not exist, the same source twice, or two entries setting the same env name to different values, and the config is rejected before any run starts. At launch, the env tables of the enabled entries are merged into the environment of that Pi process only (pi_extension_env in src/orbi/pi_session.py); they are never logged. The git check is textual: the SSH form git:git@host:org/repo passes it, so write git sources as git:<host>/<owner>/<repo>@<ref>.

Two limits of that validation are worth knowing. The env values are literals: "$SOME_TOKEN" is passed to Pi literally, not expanded, so a secret the extension needs should be read by the extension from the service's own environment, not written here. And a git ref can be a tag or a branch that moves; if you need the implementer and the reviewer, which are separate Pi processes, to run byte-identical extension code, pin a commit SHA or ship an unchanged file from the same repository revision.

The command side is a few lines in src/orbi/pi_command.py:

def _pi_extension_args(config: RunnerConfig) -> list[str]:
    """Return the isolated extension flags for every Pi role."""
    args = ["--no-extensions"]
    for extension in config.pi_extensions:
        if extension["enabled"]:
            args.extend(("--extension", extension["source"]))
    return args

The implementer, the reviewer, the ticket role, and the suggestion-writing session of orbi suggest get --no-extensions and one --extension per enabled entry. The combination is what makes the allowlist work: --no-extensions denies discovery, settings, and built-ins, and the explicit entries are the only extensions the session can have. -e takes more than a path: the help text says "file or builtin:<name>", but on Pi 1.2.0 pi -ne -e npm:<package>@<version> fetches the package from the npm registry (a wrong name fails with npm's own 404) and -e with git:<host>/<owner>/<repo>@<ref> clones it, which is how Orbi's npm: and git: sources reach Pi unchanged. An empty pi_extensions list means a Pi with no extensions at all, which is Orbi's default.

Not every Orbi session uses that list, though. These are the three shapes, and each is a different answer to "what should this session be able to do":

Session Extension flags Why
implementer, reviewer, the ticket role that writes comments, and the suggestion-writing session of orbi suggest --no-extensions + the configured --extension entries (the orbi suggest session adds --no-skills) the allowlist above
the repository-summary session of orbi suggest (the command that reads a repository and proposes tasks) --no-extensions with no --extension at all; its other flags are --tools read,ls,find --no-skills --no-context-files it only reads; it does not need even the configured extensions
the clarification gate (clarify_thin_tickets = true in the repository's .github/orbi.toml) --no-builtin-tools --no-extensions --extension <clarify_verdict.ts> exactly one tool, shipped with the engine; nothing else callable

The first row was not always complete. Until 5 October the ticket role was started with no extension flags at all, so a user's installed extensions could load into the sessions that write issue comments. Issue #1558 fixed it in Orbi v0.6.2 that day. Skills are the other half of the same picture. A skill is a directory with a SKILL.md of instructions; at startup Pi puts each skill's name and description into the prompt and reads the file when the model asks for it, and it discovers skills much as it discovers extensions, except recursively. There the isolation is still partial: the implementer, the reviewer, and the ticket role get Orbi's own --skill entries but not --no-skills, so skills the OS user installed can load into them. Among the runner's sessions, only orbi suggest passes --no-skills, in both of its sessions (the one that writes the suggestions and the one that writes the repository summary). The clarification gate below is a ticket session whose extension flags differ (the single --extension instead of the configured list), so its skills are the ticket role's too.

Writing a Pi extension with one accepted output channel

If you want to write a Pi extension and are not sure what for, the clarification gate is the one I would point to first, because it solves a problem that has nothing to do with MCP or tooling: getting a structured answer out of a model reliably.

When a repository turns it on (clarify_thin_tickets = true), the gate runs before Orbi starts a delivery. One Pi session gets the issue text as its prompt and judges it on three checks (does it state an observable result, an acceptance condition, and a single outcome). If the issue is thin, Orbi stops the attempt, swaps the issue's label to ai-needs-detail, and leaves a comment (the stop comment) that lists what is missing and, when the model can propose one, a fix: split it into these tickets, or add these acceptance lines. Through Orbi v0.7.20 the gate asked the model for JSON in free text and parsed stdout. On Orbi Cloud's beta environment the gate dropped every proposal: the model put kind and items at the top level next to missing, the parser read only suggestion, and no stop comment ever showed a proposal; the same prompt sent three times to DeepSeek returned a usable split every time, at the wrong level (Issue #1955).

The fix was to stop parsing text. Since Orbi v0.7.21 (28 October) the engine ships src/orbi/extensions/clarify_verdict.ts, an extension that registers one tool. Below is an abridged version: the split proposal shape, the exported constants and the comments are cut, and the description is shortened. The full file is 138 lines:

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
import { writeFileSync } from "node:fs";

const parameters = Type.Object(
  {
    missing: Type.Array(
      Type.Union([
        Type.Literal("observable_result"),
        Type.Literal("acceptance_condition"),
        Type.Literal("single_outcome"),
      ]),
      { description: "The failed checks; empty when all three checks pass" },
    ),
    suggestion: Type.Optional(
      Type.Object(
        {
          kind: Type.Literal("criteria"),
          items: Type.Array(Type.String(), { minItems: 1 }),
          // the "split" variant (kind: "split", at least two items) is omitted here
        },
        { additionalProperties: false },
      ),
    ),
  },
  { additionalProperties: false },
);

export default function (pi: ExtensionAPI) {
  pi.registerTool({
    name: "clarify_verdict",
    label: "Clarify verdict",
    description: "Return the ticket-gate verdict. Call this exactly once.",
    parameters,
    async execute(_toolCallId: string, args: object) {
      const out = process.env.ORBI_CLARIFY_OUT;
      if (!out) throw new Error("ORBI_CLARIFY_OUT is not set");
      writeFileSync(out, JSON.stringify(args), "utf8");
      return {
        content: [{ type: "text" as const, text: "Verdict recorded." }],
        details: undefined,
        terminate: true,
      };
    },
  });
}

Three parts do the work. parameters is a TypeBox schema with additionalProperties: false at every level, and Pi validates the model's arguments against it before execute runs, so a top-level kind, an unknown check id, or (in the full file) a split with one item never reaches the file. execute writes the validated arguments to the path the engine put in ORBI_CLARIFY_OUT, so the engine reads a file instead of stdout. And terminate: true ends the run after that call, so there is no follow-up turn to pay for; Pi honours it when every tool call in the turn returned it, so a turn that mixes one valid verdict with a failed call still continues. The session is started with --no-builtin-tools --no-extensions --extension <that file>: built-in tools off, discovery off, one extension on.

What the schema protects against is a malformed verdict, not a missing one. A rejected call only fails that call; the model may try again. If the run ends with no file, because the model answered in prose or never got a call past the schema, the engine treats that exactly as it treats a model that is down: the gate fails open and the delivery starts without the check. A thin issue then goes through as it did before the gate existed; what no longer happens is a proposal lost without a trace: the engine still checks each proposal it reads (an empty line is rejected, for example) and logs the reason when it drops one.

Pi aliases typebox to its own pinned copy when it loads an extension, so a file like this needs no package.json of its own. To try the shape yourself, save the code above as verdict.ts and run:

out="$(mktemp -d)/verdict.json"   # a fresh path each run, so you never read a previous result
ORBI_CLARIFY_OUT="$out" \
  pi -ne -e ./verdict.ts -nbt \
  -p 'Call clarify_verdict once with {"missing":[]}.'
cat "$out"

The failures look different from each other. Pi reports no error and cat says the file does not exist: the model never called the tool. Pi reports a validation error on the call: the arguments did not match the schema. The tool throws: ORBI_CLARIFY_OUT was not set or not writable.

A Pi extension as the only write path: Orbi Cloud's draft tool

The second of our Pi extensions is in Orbi Cloud, which hosts the same runner and adds a page where you describe a change in a conversation and Orbi drafts the issue for you. That drafting run is a Pi session in the tenant's sandbox, started with:

pi --no-extensions --no-context-files --no-skills -e <draft extension> \
   --tools read,grep,find,ls,write_draft,ask_user_question \
   --session-dir <dir> --session-id <id>
# the request is piped to stdin; with stdin not a terminal, pi runs non-interactively and exits

so the allowlist rule holds here too. --tools is itself an allowlist: it replaces Pi's default tool selection, so bash, edit and write are not callable in this session. The extension registers the two tools at the end of that list:

  • write_draft takes the complete draft and its issue title and writes them to fixed files. Those files are the only thing the page accepts as a draft, so whatever the model prints to the screen never becomes one. With that tool set the session has no general write or edit tool, so it cannot change files in the repository. A second, separate Pi session then checks the draft against what the person asked for, with the request and the draft as its input text; it loads the same extension with write_draft as its only tool and hands the checked draft back through it.
  • ask_user_question is for the case where the request is underspecified. The model asks one to four questions, each with two to four options, the recommended option first. The questions are written to a file next to the draft and the round ends there. This drafting session is persisted to a file, which the run names with --session-dir and --session-id; when the person answers on the page, the next round starts Pi on the same session file with the answer (and the current draft, so edits made in between are kept); between rounds there is no resident process, and each model call still has its own timeout.

The tool's name and parameter shape came from a community extension, @juicesharp/rpiv-ask-user-question, which asks the questions in the terminal. We did not take it as a dependency: in pi -p, the non-interactive mode Orbi always uses, that package withdraws its tool entirely, because there is nobody at a terminal to answer. We copied its name and parameter shape and wrote our own.

What to copy for your own Pi extensions

If you run Pi without a person at the keyboard, the short version of this post is three habits:

  1. Start every unattended Pi with --no-extensions, then add back exactly the Pi extensions you mean to load with -e. Keep the list in config, pin npm packages to a version, give git sources at least an @ref (a tag or a branch passes validation) and pin a commit SHA when two sessions must run identical code, and reject the config when a pin is missing; remember that for git sources Orbi's validation only checks that something follows an @, not whether the ref can move.
  2. When you need a structured answer from the model, give it one tool with a schema and terminate: true, write the result to a file, and treat anything else as no answer. Do not parse stdout.
  3. When the model must produce an artifact, make the extension tool the only write path and leave the general write tools out of --tools.

One gap is still open on our side: skills do not get the same treatment yet, so skills installed by the user account that runs the runner can still load into every Orbi runner session except the two orbi suggest ones (Orbi Cloud's drafting sessions pass --no-skills themselves).

This is the latest post in Orbi's Pi series. The Pi hub has the full runner, the first post follows one issue through every Pi session Orbi started for it, and the harness comparison stops three unattended Pi setups mid-task and compares what comes back. Orbi is open source: orbi-build/orbi. If you would rather not run the runner yourself, read the OpenHands comparison and Cloud.

One real delivery, every week

One email a week about one real delivery. Unsubscribe anytime.