# Event Relay

> For agents: start with the [agent guide](https://pi-tui.ratstack.sh/llms.txt). Every page here has a Markdown twin, and every pattern has a JSON record.

Behavioral / Lifecycle and mounting · `event-relay` · [HTML](https://pi-tui.ratstack.sh/patterns/event-relay/) · [JSON](https://pi-tui.ratstack.sh/patterns/event-relay.json) · [all patterns](https://pi-tui.ratstack.sh/patterns.md)

Also known as Event-driven view.

## Intent

Update visible UI from named extension event channels.

## Motivation

Nico's extension bus coordinates tools and hooks through named channels used by progress UI.

## Applicability

- Use this when tools and hooks coordinate without direct module imports.

## Structure

```text
publisher -> named channel
channel -> validate -> visible state
```

## Participants

- [`pi.events`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/extensions.md#choose-an-integration-point): Exposes the shared extension event bus.
- [`EventBus`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/extensions.md#choose-an-integration-point): Publishes unknown payloads on named channels.
- [`ctx.ui.setStatus`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/extensions.md#interact-with-the-user): Writes or clears one named footer slot.
- [`TUI.requestRender`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#understand-the-component-model): Requests a coalesced redraw after state changes.
- `Payload validator`: Checks unknown channel data before changing visible state.

Pi screen APIs: [`pi.events`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/extensions.md#choose-an-integration-point), [`EventBus`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/extensions.md#choose-an-integration-point), [`ctx.ui.setStatus`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/extensions.md#interact-with-the-user), [`TUI.requestRender`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#understand-the-component-model), `createEventBus`, `EventBus.on`, `EventBus.emit`, `setStatus`, `setWidget`

## Consequences

- A visible view can react without directly importing the publisher.
- Unknown payloads need validation and listeners need cleanup.

## Implementation

- Validate unknown event payloads.
- Unsubscribe when the owning view ends.

## States

Each state was rendered at 40, 60, 80 and 120 columns in Pi's dark and light themes. Each frame links one WebP. An animated frame links its first image, then the animation.

### Waiting view

`subscribed`: Show a synthetic status awaiting a named event.

Checks at every width and theme:

- width: ✓ pass
- style-leak: ✓ pass
- hard-coded-colour: ✓ pass
- height: ✓ pass

Frames:

- 40 columns: ![Event Relay, Waiting view, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/event-relay/subscribed-40-dark.da9223178d29.webp) ![Event Relay, Waiting view, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/event-relay/subscribed-40-light.ba04dae87aba.webp)
- 60 columns: ![Event Relay, Waiting view, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/event-relay/subscribed-60-dark.7933cb047207.webp) ![Event Relay, Waiting view, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/event-relay/subscribed-60-light.5bbdbb792942.webp)
- 80 columns: ![Event Relay, Waiting view, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/event-relay/subscribed-80-dark.6ffdb5b21f37.webp) ![Event Relay, Waiting view, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/event-relay/subscribed-80-light.baf2d4f2b3d2.webp)
- 120 columns: ![Event Relay, Waiting view, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/event-relay/subscribed-120-dark.9d130f692f48.webp) ![Event Relay, Waiting view, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/event-relay/subscribed-120-light.c6fbeb9a9222.webp)

### Event applied

`event`: Publish a validated payload and update the status.

Checks at every width and theme:

- width: ✓ pass
- style-leak: ✓ pass
- hard-coded-colour: ✓ pass
- height: ✓ pass

Frames:

- 40 columns: ![Event Relay, Event applied, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/event-relay/event-40-dark.2f938ea134c3.webp) ![Event Relay, Event applied, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/event-relay/event-40-light.adba8644cbed.webp)
- 60 columns: ![Event Relay, Event applied, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/event-relay/event-60-dark.def66719943a.webp) ![Event Relay, Event applied, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/event-relay/event-60-light.c12584e36edd.webp)
- 80 columns: ![Event Relay, Event applied, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/event-relay/event-80-dark.8511c4512090.webp) ![Event Relay, Event applied, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/event-relay/event-80-light.1403fe943004.webp)
- 120 columns: ![Event Relay, Event applied, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/event-relay/event-120-dark.bc95ceab6ee3.webp) ![Event Relay, Event applied, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/event-relay/event-120-light.64774c03bb88.webp)

### Owner gone

`unsubscribed`: Show that a later event no longer updates the cleared slot.

Checks at every width and theme:

- width: ✓ pass
- style-leak: ✓ pass
- hard-coded-colour: ✓ pass
- height: ✓ pass

Frames:

- 40 columns: ![Event Relay, Owner gone, 40 columns, dark theme](https://pi-tui.ratstack.sh/frames/event-relay/unsubscribed-40-dark.a6d15e9bb6c5.webp) ![Event Relay, Owner gone, 40 columns, light theme](https://pi-tui.ratstack.sh/frames/event-relay/unsubscribed-40-light.75fe18457a67.webp)
- 60 columns: ![Event Relay, Owner gone, 60 columns, dark theme](https://pi-tui.ratstack.sh/frames/event-relay/unsubscribed-60-dark.7ef957d634f2.webp) ![Event Relay, Owner gone, 60 columns, light theme](https://pi-tui.ratstack.sh/frames/event-relay/unsubscribed-60-light.0dd749dee0d7.webp)
- 80 columns: ![Event Relay, Owner gone, 80 columns, dark theme](https://pi-tui.ratstack.sh/frames/event-relay/unsubscribed-80-dark.504acca08cfc.webp) ![Event Relay, Owner gone, 80 columns, light theme](https://pi-tui.ratstack.sh/frames/event-relay/unsubscribed-80-light.d3f5943071d1.webp)
- 120 columns: ![Event Relay, Owner gone, 120 columns, dark theme](https://pi-tui.ratstack.sh/frames/event-relay/unsubscribed-120-dark.131622c0b830.webp) ![Event Relay, Owner gone, 120 columns, light theme](https://pi-tui.ratstack.sh/frames/event-relay/unsubscribed-120-light.46a6caf39057.webp)

## Sample code

`stories/patterns/behavioral/event-relay.ts`, the story the frames above were rendered from.

```ts
// Event Relay: validate named-channel payloads before updating a keyed view.
import { Text } from "@earendil-works/pi-tui";
import { createEventBus } from "@earendil-works/pi-coding-agent";
import type { PatternStory } from "../../../src/pattern.ts";

type Progress = { task: string; completed: number; total: number };
function isProgress(value: unknown): value is Progress {
  return typeof value === "object" && value !== null &&
    "task" in value && typeof value.task === "string" &&
    "completed" in value && typeof value.completed === "number" && Number.isInteger(value.completed) &&
    "total" in value && typeof value.total === "number" && Number.isInteger(value.total) &&
    value.total > 0 && value.completed >= 0 && value.completed <= value.total;
}

export const story: PatternStory = {
  id: "event-relay",
  title: "Event Relay",
  kind: "screen",
  apis: ["createEventBus", "EventBus.on", "EventBus.emit", "setStatus", "setWidget", "TUI.requestRender"],
  states: [
    { id: "subscribed", label: "Waiting view" },
    { id: "event", label: "Event applied", steps: [
      { type: "action", name: "invalid-event" }, { type: "action", name: "progress-event" },
    ] },
    { id: "unsubscribed", label: "Owner gone", steps: [
      { type: "action", name: "unsubscribe" }, { type: "action", name: "late-event" },
    ] },
  ],
  setup({ ui, theme, tui, action }) {
    const events = createEventBus(); // Same exported bus used by pi.events; isolated fixture.
    const channel = "task:progress";
    let applied = 0;
    let rejected = 0;
    ui.setHeader(() => new Text(theme.fg("accent", "Pi · Event Relay"), 0, 0));
    ui.setEditorText("Review the token boundary tests");
    ui.setStatus("progress", theme.fg("muted", "waiting"));
    ui.setWidget("relay", [
      theme.fg("accent", "Subscribed → " + channel),
      theme.fg("muted", "Waiting for parser test progress"),
    ]);
    const unsubscribe = events.on(channel, payload => {
      if (!isProgress(payload)) { rejected++; return; }
      applied++;
      ui.setStatus("progress", theme.fg("success", payload.completed + "/" + payload.total));
      ui.setWidget("relay", [
        theme.fg("accent", channel + " → validated view"),
        theme.fg("text", payload.task + ": " + payload.completed + "/" + payload.total),
        theme.fg("muted", "Applied " + applied + " · rejected " + rejected),
      ]);
      tui.requestRender();
    });
    action("invalid-event", () => events.emit(channel, { completed: "not a count" }));
    action("progress-event", () => events.emit(channel, { task: "Parser tests", completed: 3, total: 8 }));
    action("unsubscribe", () => {
      unsubscribe();
      ui.setStatus("progress", undefined);
      ui.setWidget("relay", [
        theme.fg("muted", "Progress view removed · unsubscribed"),
        theme.fg("dim", "Later events cannot recreate its slot"),
      ]);
    });
    action("late-event", () => {
      events.emit(channel, { task: "Parser tests", completed: 8, total: 8 });
      ui.setWidget("publisher", [
        theme.fg("text", "Publisher sent another progress event"),
        theme.fg("dim", "Applied remains " + applied + " · footer slot absent"),
      ], { placement: "belowEditor" });
    });
    return () => { unsubscribe(); events.clear(); ui.setStatus("progress", undefined); };
  },
};
```

## Known uses: seen in Nico's repos

- [**earendil-works/pi**](https://github.com/earendil-works/pi): Add an event bus for tools and hooks
  - [packages/coding-agent/src/core/event-bus.ts:1-33](https://github.com/earendil-works/pi/blob/9c9e6822e3af7e0c17cd4116b8eab01d79eb69a8/packages/coding-agent/src/core/event-bus.ts#L1-L33) @9c9e6822
- [**dot314**](https://github.com/nicobailon/dot314): Bound input interception to foreground progress
  - [extensions/handover/index.ts:700-747](https://github.com/nicobailon/dot314/blob/17cce138f3f687211d0485d204408a9650953c61/extensions/handover/index.ts#L700-L747) @17cce138
- [**pi-prompt-template-model**](https://github.com/nicobailon/pi-prompt-template-model): Bound input interception to foreground progress
  - [subagent-step.ts:443-462](https://github.com/nicobailon/pi-prompt-template-model/blob/6da205917e549cbe8e855c1d241c616ae1fb1627/subagent-step.ts#L443-L462) @6da20591
  - [subagent-step.ts:533-558](https://github.com/nicobailon/pi-prompt-template-model/blob/6da205917e549cbe8e855c1d241c616ae1fb1627/subagent-step.ts#L533-L558) @6da20591
  - [subagent-step.ts:748-762](https://github.com/nicobailon/pi-prompt-template-model/blob/6da205917e549cbe8e855c1d241c616ae1fb1627/subagent-step.ts#L748-L762) @6da20591
- [**pi-prune**](https://github.com/nicobailon/pi-prune): Bound input interception to foreground progress
  - [index.ts:63-75](https://github.com/nicobailon/pi-prune/blob/0194b7a3eb87db71648fef672effbfb65411a82a/index.ts#L63-L75) @0194b7a3
  - [index.ts:131-132](https://github.com/nicobailon/pi-prune/blob/0194b7a3eb87db71648fef672effbfb65411a82a/index.ts#L131-L132) @0194b7a3
- [**pi-subagents**](https://github.com/nicobailon/pi-subagents): Bound input interception to foreground progress
  - [src/slash/slash-commands.ts:350-440](https://github.com/nicobailon/pi-subagents/blob/6826b0545216077195ae8ffe468a6434715814b7/src/slash/slash-commands.ts#L350-L440) @6826b054
  - [src/slash/slash-commands.ts:770-835](https://github.com/nicobailon/pi-subagents/blob/6826b0545216077195ae8ffe468a6434715814b7/src/slash/slash-commands.ts#L770-L835) @6826b054
- [**dot314**](https://github.com/nicobailon/dot314): Add an event bus for tools and hooks
- [**pi-coordination**](https://github.com/nicobailon/pi-coordination): Add an event bus for tools and hooks
- [**pi-interactive-shell**](https://github.com/nicobailon/pi-interactive-shell): Add an event bus for tools and hooks
- [**pi-intercom**](https://github.com/nicobailon/pi-intercom): Add an event bus for tools and hooks
- [**pi-mcp-adapter**](https://github.com/nicobailon/pi-mcp-adapter): Add an event bus for tools and hooks
- [**pi-prompt-template-model**](https://github.com/nicobailon/pi-prompt-template-model): Add an event bus for tools and hooks
- [**pi-rewind-hook**](https://github.com/nicobailon/pi-rewind-hook): Add an event bus for tools and hooks
- [**pi-subagent-enhanced**](https://github.com/nicobailon/pi-subagent-enhanced): Add an event bus for tools and hooks
- [**pi-subagents**](https://github.com/nicobailon/pi-subagents): Add an event bus for tools and hooks
- [**surf-cli**](https://github.com/nicobailon/surf-cli): Add an event bus for tools and hooks

## For agents: choose and check

Choose Event Relay when your job matches its intent and applicability above. Its neighbours in Lifecycle and mounting are listed below. Read the one whose intent fits your job more closely before you commit.

- [Elastic Overlay](https://pi-tui.ratstack.sh/patterns/elastic-overlay.md): Resolve overlay size and placement from current terminal dimensions.
- [Process Shell](https://pi-tui.ratstack.sh/patterns/process-shell.md): Bind a temporary terminal process view to one custom interaction.
- [Output Vault](https://pi-tui.ratstack.sh/patterns/output-vault.md): Keep completed job output available after its foreground view closes.
- [Deferred Crest](https://pi-tui.ratstack.sh/patterns/deferred-crest.md): Mount optional startup content only after deferred discovery remains eligible.
- [Session Memento](https://pi-tui.ratstack.sh/patterns/session-memento.md): Reconstruct deliberate view state from typed custom session entries.
- [Mode Fence](https://pi-tui.ratstack.sh/patterns/mode-fence.md): Keep terminal components separate from dialog-capable and no-UI modes.
- [Snapshot Lens](https://pi-tui.ratstack.sh/patterns/snapshot-lens.md): Derive compact status and bounded detail from lifecycle snapshots.

Check your version:

- Render your version at 40, 60, 80 and 120 columns in the dark and light themes.
- Check width: no rendered line is wider than the terminal.
- Check style-leak: no line ends with colour, bold or a link still switched on.
- Check hard-coded-colour: every colour on screen comes from the active theme.
- Check height: the output fits in the rows the terminal has.
- Read the Pi 1.0.3 docs for [`pi.events`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/extensions.md#choose-an-integration-point), [`EventBus`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/extensions.md#choose-an-integration-point), [`ctx.ui.setStatus`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/extensions.md#interact-with-the-user), [`TUI.requestRender`](https://github.com/earendil-works/pi/blob/v1.0.3/packages/coding-agent/docs/tui.md#understand-the-component-model), `createEventBus`, `EventBus.on`, `EventBus.emit`, `setStatus`, `setWidget` before you use them.
- Every reference frame gets the verdict its state expects.

Next actions: `related({ id: "event-relay" })` lists what to read next, and `states({ id: "event-relay", width: 40 })` returns every frame and verdict at one width. The [build-pi-tui-component skill](https://pi-tui.ratstack.sh/skills/build-pi-tui-component.md) walks through the whole loop.

## Related

- [Refresh Lease](https://pi-tui.ratstack.sh/patterns/refresh-lease.md): owns subscriptions and redraws

## Linked from

No other pattern links here.
