Pi
A hook for Pi that runs inside the pi
process, polls EventPort on a timer, and injects each event into the session
via pi.send() — a new agent loop starts when idle, the message is queued
when busy.
Prerequisites
- Node >= 18
- The Pi coding agent (
pi --versionruns) - An EventPort subscription — you need its gateway URL and consumer
token (starts with
egc_, from the subscription's Agent tab)
Installation
1. Create the hook directory
Pick one scope — global (all projects) or the current project:
# global scope
mkdir -p ~/.pi/agent/hooks
# project scope (inside your project root)
mkdir -p .pi/hooks
2. Save the two files below
The examples use the global path; if you chose project scope, replace
~/.pi/agent/hooks with .pi/hooks.
File 1 of 2: poller.ts
Save to ~/.pi/agent/hooks/poller.ts:
/**
* eventport poller hook for Pi (pi-mono coding agent).
*
* Pi hooks are TypeScript modules loaded inside the pi process. This hook
* starts a timer that polls eventport for buffered webhook events and
* injects each one into the session via `pi.send()` — when the agent is idle
* a new agent loop starts immediately, when busy the message is queued.
* That wake/queue semantics is documented in the Pi README ("Inject messages
* from external sources to wake up the agent").
*
* Gateway semantics (as implemented in apps/gateway/src/index.ts):
* 1. GET /events (Bearer egc_*) returns pending events and DELETES them
* server-side (consume-on-read, the default subscription mode).
* 2. Delivery is at-most-once: there is no /ack endpoint and no
* redelivery. A poll that fails before the GET leaves events in place
* for the next cycle.
* 3. If `pi.send()` throws, the event is dead-lettered to a local NDJSON
* file (EG_DLQ) so nothing is silently lost.
*
* Configuration (environment variables of the `pi` process):
* EG_URL gateway base URL, e.g. https://gw.eventport.dev
* EG_TOKEN consumer token (egc_*)
* EG_INTERVAL poll interval in ms, default 60000 (limit is 60 req/min)
* EG_SOURCES optional comma-separated allowlist, e.g. "github"
* EG_DLQ dead-letter file, default ./eventport.dead.ndjson
*/
import { appendFileSync } from 'node:fs';
import type { PiSession } from './pi-types';
const EG_URL = (process.env.EG_URL ?? '').replace(/\/$/, '');
const EG_TOKEN = process.env.EG_TOKEN ?? '';
const EG_INTERVAL = Number(process.env.EG_INTERVAL ?? 60_000);
const EG_DLQ = process.env.EG_DLQ ?? 'eventport.dead.ndjson';
const EG_SOURCES = (process.env.EG_SOURCES ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
interface GatewayEvent {
messageId: string;
payload: unknown;
timestamp: number;
}
export default function eventPortPoller(pi: PiSession) {
if (!EG_URL || !EG_TOKEN) {
console.warn('[eventport] EG_URL / EG_TOKEN not set — hook idle');
return;
}
/** messageIds already injected in this process lifetime */
const delivered = new Set<string>();
let polling = false;
let timer: ReturnType<typeof setInterval> | undefined;
// ---------------------------------------------------------------- gateway
async function pollEvents(): Promise<GatewayEvent[]> {
const res = await fetch(`${EG_URL}/events`, {
headers: { Authorization: `Bearer ${EG_TOKEN}` },
});
if (!res.ok) throw new Error(`GET /events → ${res.status}`);
const body = (await res.json()) as { events?: GatewayEvent[] };
// consume-on-read: returned rows are already deleted server-side
return body.events ?? [];
}
function deadLetter(event: GatewayEvent, reason: string): void {
try {
appendFileSync(
EG_DLQ,
`${JSON.stringify({ ...event, reason, failedAt: Date.now() })}\n`,
);
console.warn(`[eventport] ${event.messageId} dead-lettered to ${EG_DLQ} (${reason})`);
} catch (err) {
console.error(`[eventport] cannot write dead-letter file ${EG_DLQ}:`, err);
}
}
// ------------------------------------------------------------------ task
/** Map a raw webhook payload to an instruction the agent can act on. */
function buildTask(event: GatewayEvent): string {
const p = event.payload as Record<string, unknown> | null;
if (p && typeof p.action === 'string' && p.pull_request) {
const pr = p.pull_request as Record<string, unknown>;
const repo = (p.repository as Record<string, unknown> | undefined)?.full_name ?? 'unknown repo';
return [
`GitHub PR event (${p.action}) on ${repo} #${pr.number ?? '?'} — ${pr.title ?? ''}`,
`Review the changes (gh pr diff), look for bugs and security issues,`,
`and post your findings as a PR comment. Event messageId: ${event.messageId}.`,
].join(' ');
}
return [
'An external webhook event arrived from eventport.',
`messageId: ${event.messageId}`,
'Inspect the payload below and handle it according to your skills:',
JSON.stringify(event.payload).slice(0, 8_000),
].join('\n');
}
// ------------------------------------------------------------------- loop
async function cycle(): Promise<void> {
const events = await pollEvents();
for (const event of events) {
if (delivered.has(event.messageId)) continue;
const source = (event.payload as Record<string, unknown> | null)?.source;
if (EG_SOURCES.length > 0 && typeof source === 'string' && !EG_SOURCES.includes(source)) {
// Not in the allowlist: already consumed by the poll, just skip.
console.log(`[eventport] ${event.messageId} skipped (source '${source}' not in EG_SOURCES)`);
continue;
}
try {
pi.send(buildTask(event)); // queues when busy, new loop when idle
delivered.add(event.messageId);
} catch (err) {
console.warn(`[eventport] send failed for ${event.messageId}:`, err);
deadLetter(event, 'pi-send-failed');
}
}
if (events.length > 0) {
console.log(`[eventport] ${events.length} event(s) consumed`);
}
}
pi.on('session', () => {
if (timer) clearInterval(timer); // one timer across session restarts
timer = setInterval(() => {
if (polling) return;
polling = true;
cycle()
.catch((err) => console.warn('[eventport] poll failed:', err))
.finally(() => (polling = false));
}, EG_INTERVAL);
console.log(`[eventport] polling ${EG_URL} every ${EG_INTERVAL}ms (consume-on-read)`);
});
}
File 2 of 2: pi-types.ts
Save to ~/.pi/agent/hooks/pi-types.ts:
/**
* Minimal local typings for the Pi hook API surface used by poller.ts.
* Pi (pi-mono) loads hooks as TypeScript modules and passes a session-ish
* object; `send()` queues the message when the agent is streaming and starts
* a new agent loop when idle. If the official API exports types later,
* switch to those and delete this file.
*/
export interface PiSession {
/** Inject a user message into the session (text + optional attachments). */
send(text: string, attachments?: unknown): void;
/** Subscribe to hook events, e.g. 'session' (fires on session start). */
on(event: string, handler: () => void): void;
}
3. Start Pi with the gateway configuration
EG_URL=https://gw.eventport.dev \
EG_TOKEN=egc_xxx \
pi
4. Verify
On session start the hook logs one line:
[eventport] polling https://gw.eventport.dev every 60000ms (consume-on-read)
You can also check the queue directly (it should return {"events":[]} when
empty):
curl -s -H 'Authorization: Bearer egc_xxx' https://gw.eventport.dev/events
Configuration
| Env | Meaning | Default |
|-----|---------|---------|
| EG_URL | gateway base URL | required |
| EG_TOKEN | consumer token (egc_*) | required |
| EG_INTERVAL | poll interval in ms | 60000 |
| EG_SOURCES | comma allowlist, e.g. github | all |
| EG_DLQ | dead-letter file for failed sends | ./eventport.dead.ndjson |
Delivery semantics
The gateway deletes events on read (consume-on-read): once GET /events
returns an event it is removed server-side, and delivery is at-most-once with
no redelivery.
pi.send()accepting the message (new loop when idle, queued when busy) counts as delivered. Ifpi.send()throws, the event is dead-lettered to the local fileEG_DLQso nothing is silently lost.- Make agent actions idempotent by
messageId— the pi send queue and any manual dead-letter replay can present the same event twice. - One hook instance tracks one subscription (the one that issued
EG_TOKEN). To consume another subscription, repeat this setup with its token.
