# Adding Herdr support to a Pi extension

post · 2026-07-27

import HLSVideo from '@components/content/HLSVideo.astro';

I'd heard about [Herdr](https://herdr.dev?utm_source=aliou.me&utm_medium=post) before but never tried it. I already know tmux and didn't want to learn yet another tool. Then I saw it used on [a podcast](https://www.youtube.com/watch?v=-DKSg1-v1Gg&utm_source=aliou.me&utm_medium=post) in a way that looked like it could work for me. They also mentioned that you can ask an agent to port your config over from tmux, shortcuts and keymaps included, so there isn't that much to relearn.

The same day or the day after, [jokklan opened a pull request](https://github.com/aliou/pi-guardrails/pull/72?utm_source=aliou.me&utm_medium=post) to add Herdr support to pi-guardrails. I took it as a sign, [had an agent port my tmux config over](https://x.com/aliouftw/status/2080265103778152678), and tried it.

A couple hours later, I realized it matched my workflow and how my brain works. I especially like seeing the status of agents across different projects at once, including the long-running ones that don't need me to babysit them.

A Guardrails approval prompt is exactly when I want Herdr to show a pane as blocked: the agent needs me, and I should be able to see that without opening the pane. [pi-guardrails v0.16.0](https://github.com/aliou/pi-guardrails/releases/tag/v0.16.0?utm_source=aliou.me&utm_medium=post) adds that support.

## Guardrails now shows up as blocked

As of v0.16.0, pi-guardrails ships a fourth extension, [`extensions/herdr/index.ts`](https://github.com/aliou/pi-guardrails/blob/v0.16.0/extensions/herdr/index.ts?utm_source=aliou.me&utm_medium=post).[^pr] While a [permission-gate](/projects/pi-guardrails/#permission-gate) or [path-access](/projects/pi-guardrails/#path-access) prompt waits for an answer, the Herdr pane list shows the pane as blocked, with the label "Guardrails approval required".

Here's what that looks like:[^video]

<HLSVideo slug="herdr-blocked" assetPath="blog/adding-herdr-support-to-a-pi-extension/herdr-blocked" alt="The Herdr pane list zooms into the sidebar as the space and agent indicators turn red (blocked) while a Guardrails approval prompt waits, then return to working after the prompt is answered." />

## How it works

### Herdr installs a Pi extension

During onboarding, Herdr asks which coding agents you use and offers to install an integration for each of them ([docs](https://herdr.dev/docs/0.7.5/integrations/?utm_source=aliou.me&utm_medium=post)). You can also install it for Pi by hand:

```bash
herdr integration install pi
```

The integration is a [single extension file](https://github.com/ogulcancelik/herdr/blob/v0.7.5/src/integration/assets/pi/herdr-agent-state.ts?utm_source=aliou.me&utm_medium=post), installed globally next to your other Pi extensions. It is not injected per session: Pi loads it in every session, and the file itself decides whether it should be active by checking for environment variables that Herdr only sets inside its own panes:

```ts source="https://github.com/ogulcancelik/herdr/blob/v0.7.5/src/integration/assets/pi/herdr-agent-state.ts#L10-L19"
const HERDR_ENV = process.env.HERDR_ENV;
const socketPath = process.env.HERDR_SOCKET_PATH;
const paneId = process.env.HERDR_PANE_ID;

function enabled() {
  return HERDR_ENV === "1" && !!socketPath && !!paneId;
}

export default function (pi) {
  if (!enabled()) {
    return;
  }
  // ...
}
```

### The extension owns the pane's agent state

Inside a Herdr pane, the extension reports three states: `working`, `idle`, and `blocked`. The first two come from Pi's own `agent_start` / `agent_settled` hooks. But Pi has no hook for "waiting on a human", so `blocked` comes from a custom event on [Pi's event bus](https://pi.dev/docs/latest/extensions?utm_source=aliou.me&utm_medium=post#pi-events) that anyone can emit:

```ts source="https://github.com/ogulcancelik/herdr/blob/v0.7.5/src/integration/assets/pi/herdr-agent-state.ts#L230-L246"
pi.events.on("herdr:blocked", (data) => {
  if (!data?.active) {
    blockedCount = Math.max(0, blockedCount - 1);
    if (blockedCount === 0) {
      blockedMessage = undefined;
    }
    publishState();
    return;
  }

  blockedCount += 1;
  blockedMessage = data.label;
  publishState();
});
```

Herdr expects `{ active: true, label }` when something blocks and `{ active: false }` when it clears. There is no schema enforcement on the event bus, so you just cross your fingers that both sides agree. Herdr also counts active blocks instead of storing a boolean. If two prompts are open, closing one should not make the pane look unblocked.

Blocked also outranks the two lifecycle states:

```ts source="https://github.com/ogulcancelik/herdr/blob/v0.7.5/src/integration/assets/pi/herdr-agent-state.ts#L210-L218"
function desiredState() {
  if (blockedCount > 0) {
    return { state: "blocked", message: blockedMessage };
  }
  if (agentActive) {
    return { state: "working", message: undefined };
  }
  return { state: "idle", message: undefined };
}
```

### From the event bus to the Herdr socket

When the state changes, the extension writes it as a JSON line into the unix socket Herdr exposes for the pane. The [socket API reference](https://herdr.dev/docs/0.7.5/socket-api/?utm_source=aliou.me&utm_medium=post) has the full protocol. The extension tries once, retries once, then gives up so it never stalls Pi.

Herdr calls Pi a "lifecycle authority" integration ([integrations](https://herdr.dev/docs/0.7.5/integrations/?utm_source=aliou.me&utm_medium=post#pi), [agents](https://herdr.dev/docs/0.7.5/agents/?utm_source=aliou.me&utm_medium=post)). While the extension is reporting, Herdr stops guessing from what is on the screen and trusts these reports instead. The pane shows a red dot for `blocked`, a spinner for `working`, and a check for `idle`.

### The Guardrails adapter

Guardrails already emitted two [prompt lifecycle events](/projects/pi-guardrails/#extension-events): `guardrails:prompt:opened` and `guardrails:prompt:closed`. The adapter listens to both and keeps a set of the prompt IDs it has seen. That stops duplicate events from throwing Herdr's counter off, and closing one prompt won't clear another one that is still open.

```ts source="https://github.com/aliou/pi-guardrails/blob/v0.16.0/extensions/herdr/index.ts"
export default function herdr(pi: ExtensionAPI): void {
  const activePrompts = new Set<string>();

  pi.events.on(GUARDRAILS_PROMPT_OPENED_EVENT, (data) => {
    const id = (data as GuardrailsPromptOpenedPayload)?.prompt?.id;
    if (!id || activePrompts.has(id)) return;

    activePrompts.add(id);
    pi.events.emit("herdr:blocked", {
      active: true,
      label: "Guardrails approval required",
    });
  });

  pi.events.on(GUARDRAILS_PROMPT_CLOSED_EVENT, (data) => {
    const id = (data as GuardrailsPromptClosedPayload)?.prompt?.id;
    if (!id || !activePrompts.delete(id)) return;

    pi.events.emit("herdr:blocked", { active: false });
  });
}
```

There is no Herdr import in Guardrails. Without Herdr's Pi integration loaded, nobody listens to these events and nothing happens.

## Doing it in your own extension

If your own extension waits on the user, emit `{ active: true, label }` when it starts waiting and `{ active: false }` when it stops. Just make sure the pairs balance.

For example, you could show when a run ended in an error:

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

const HERDR_BLOCKED_EVENT = "herdr:blocked";

export default function errorState(pi: ExtensionAPI): void {
  let blocked = false;

  const block = (label: string) => {
    if (blocked) return;
    blocked = true;
    pi.events.emit(HERDR_BLOCKED_EVENT, { active: true, label });
  };

  const unblock = () => {
    if (!blocked) return;
    blocked = false;
    pi.events.emit(HERDR_BLOCKED_EVENT, { active: false });
  };

  pi.on("agent_end", (event) => {
    const last = event.messages.findLast((m) => m.role === "assistant");
    if (last?.stopReason !== "error") return;

    block("Agent run failed");
  });

  // A retry starts a new agent run, so the pane is no longer waiting on me.
  pi.on("agent_start", unblock);
  // Nothing follows the last failed retry, so clear it when the session settles.
  pi.on("agent_settled", unblock);
  // The pane goes away with the session anyway, but cleaning up after
  // yourself is good hygiene.
  pi.on("session_shutdown", unblock);
}
```

`getLastAssistantText()` would be convenient here, but extensions cannot access it and I need the `stopReason`, not the text. `agent_end` already has the messages for that run.

Pi retries errors three times with exponential backoff by default. Each retry emits `agent_start`, which clears the block while Pi tries again. `agent_settled` handles the final failure, and `session_shutdown` is there because cleaning up your own state is a good habit.

The adapter ships with [pi-guardrails](/projects/pi-guardrails/) v0.16.0. Install it with `pi install npm:@aliou/pi-guardrails`; [Herdr](https://herdr.dev?utm_source=aliou.me&utm_medium=post) installs its Pi side during onboarding.

[^pr]: [PR #74](https://github.com/aliou/pi-guardrails/pull/74?utm_source=aliou.me&utm_medium=post) is the version that shipped.
[^video]: I've been letting agents record these videos on my desktop or edit them afterwards. They get most of it right now, with caveats. GLM-5.2 handled this one except for zooming on the indicators, so I had it build an HTML editor where I could mark what to zoom and when.
