Any CLI Agent

A zero-dependency Node script for agents with a plain command line — claude -p, codex exec, aider, or your own script. It polls EventPort, pipes each event as JSON to your command's stdin, and dead-letters failures locally. This is the fallback when no first-class adapter exists.

Prerequisites

  • Node >= 18
  • An EventPort subscription — you need its gateway URL and consumer token (starts with egc_, from the subscription's Agent tab)

Installation

1. Create the destination directory

mkdir -p ~/.local/bin

2. Save the file below

Save to ~/.local/bin/event-agent.mjs:

#!/usr/bin/env node
/**
 * event-agent — generic eventport consumer for any agent CLI.
 *
 * The gateway DELETES events as they are read (consume-on-read is the
 * default subscription mode), so delivery is at-most-once and the server
 * never redelivers. This wrapper compensates with a local dead-letter file:
 *
 *   GET /events  → rows deleted on read → pipe event JSON to COMMAND stdin
 *   exit 0    → done
 *   exit ≠ 0  → event appended to the dead-letter file (EG_DLQ), so nothing
 *               is silently lost; replay later with --replay
 *
 * Usage (daemon mode, poll every 60s):
 *   EG_URL=https://gw.eventport.dev EG_TOKEN=egc_xxx \
 *     node event-agent.mjs -- claude -p --output-format text
 *
 * One-shot mode (drive it from cron / launchd / Task Scheduler):
 *   node event-agent.mjs --once -- claude -p
 *
 * Replay failed events through the same command:
 *   node event-agent.mjs --once --replay -- claude -p
 *
 * Environment:
 *   EG_URL       gateway base URL (required unless --replay)
 *   EG_TOKEN     consumer token, egc_* (required unless --replay)
 *   EG_INTERVAL  poll interval seconds, default 60 (consumer limit: 60 req/min)
 *   EG_DLQ       dead-letter file, default ./eventport.dead.ndjson
 *
 * The child command receives on stdin:
 *   { "messageId": "...", "payload": { ...raw upstream webhook body... },
 *     "timestamp": 1721203200000 }
 *
 * Node >= 18, zero dependencies.
 */
import { spawn } from 'node:child_process';
import { appendFileSync, readFileSync, writeFileSync, existsSync } from 'node:fs';

// ------------------------------------------------------------------ args

const argv = process.argv.slice(2);
const onceIdx = argv.indexOf('--once');
const ONCE = onceIdx !== -1;
if (ONCE) argv.splice(onceIdx, 1);
const replayIdx = argv.indexOf('--replay');
const REPLAY = replayIdx !== -1;
if (REPLAY) argv.splice(replayIdx, 1);
const sep = argv.indexOf('--');
const COMMAND = sep === -1 ? [] : argv.slice(sep + 1);

const URL = process.env.EG_URL?.replace(/\/$/, '');
const TOKEN = process.env.EG_TOKEN;
const INTERVAL = (Number(process.env.EG_INTERVAL ?? 60) || 60) * 1000;
const DLQ = process.env.EG_DLQ ?? 'eventport.dead.ndjson';

if (((!URL || !TOKEN) && !REPLAY) || COMMAND.length === 0) {
  console.error('usage: EG_URL=... EG_TOKEN=egc_... node event-agent.mjs [--once] [--replay] -- <command> [args...]');
  process.exit(2);
}

const log = (...a) => console.log(new Date().toISOString(), ...a);

// ---------------------------------------------------------------- gateway

async function pollEvents() {
  const res = await fetch(`${URL}/events`, {
    headers: { Authorization: `Bearer ${TOKEN}` },
  });
  if (!res.ok) throw new Error(`GET /events -> ${res.status}`);
  const body = await res.json();
  // consume-on-read: the rows below are already deleted server-side
  return body.events ?? [];
}

// ------------------------------------------------------------ dead-letter

function deadLetter(event, reason) {
  appendFileSync(DLQ, `${JSON.stringify({ ...event, reason, failedAt: Date.now() })}\n`);
  log(`event ${event.messageId} failed (${reason}) — appended to ${DLQ}`);
}

function readDeadLetters() {
  if (!existsSync(DLQ)) return [];
  return readFileSync(DLQ, 'utf8')
    .split('\n')
    .filter(Boolean)
    .map((line) => {
      try {
        return JSON.parse(line);
      } catch {
        return null;
      }
    })
    .filter(Boolean);
}

// ---------------------------------------------------------------- command

/** Run COMMAND with the event JSON on stdin. Resolves true on exit 0. */
function runCommand(event) {
  return new Promise((resolve) => {
    const child = spawn(COMMAND[0], COMMAND.slice(1), { stdio: ['pipe', 'inherit', 'inherit'] });
    child.on('error', (err) => {
      console.error(`spawn ${COMMAND[0]} failed:`, err.message);
      resolve(false);
    });
    child.on('close', (code) => resolve(code === 0));
    child.stdin.end(JSON.stringify(event));
  });
}

// ------------------------------------------------------------------- loop

/** Poll cycle. Events are consumed on read; failures go to the DLQ file. */
async function pollCycle() {
  const events = await pollEvents();
  let ok = 0;
  for (const event of events) {
    log(`event ${event.messageId} -> ${COMMAND.join(' ')}`);
    if (await runCommand(event)) ok += 1;
    else deadLetter(event, 'command-exit-nonzero');
  }
  log(`cycle done: ${events.length} event(s), ${ok} ok, ${events.length - ok} dead-lettered`);
}

/** Replay dead-lettered events through the same command; successes are removed. */
async function replayCycle() {
  const entries = readDeadLetters();
  if (entries.length === 0) {
    log('replay: dead-letter file is empty');
    return;
  }
  const failed = [];
  for (const entry of entries) {
    const event = { messageId: entry.messageId, payload: entry.payload, timestamp: entry.timestamp };
    log(`replay ${event.messageId} -> ${COMMAND.join(' ')}`);
    if (await runCommand(event)) log(`replay ${event.messageId}: ok`);
    else failed.push(entry);
  }
  writeFileSync(DLQ, failed.map((e) => JSON.stringify(e)).join('\n') + (failed.length ? '\n' : ''));
  log(`replay done: ${entries.length} total, ${entries.length - failed.length} recovered, ${failed.length} still failing`);
}

async function main() {
  for (;;) {
    try {
      if (REPLAY) await replayCycle();
      else await pollCycle();
    } catch (err) {
      console.error('poll error:', err.message ?? err);
    }
    if (ONCE) process.exit(0);
    await new Promise((r) => setTimeout(r, INTERVAL));
  }
}

main();

3. Make it executable (optional)

chmod +x ~/.local/bin/event-agent.mjs

Run

# daemon mode, polls every 60s
EG_URL=https://gw.eventport.dev \
EG_TOKEN=egc_xxx \
node ~/.local/bin/event-agent.mjs -- claude -p --output-format text

# one-shot mode — let cron / launchd / Task Scheduler drive it instead
node ~/.local/bin/event-agent.mjs --once -- claude -p

Your command receives one event per invocation, as JSON on stdin:

{ "messageId": "…", "payload": { …upstream webhook body… }, "timestamp": 1721203200000 }

When the queue is empty the script just logs cycle done: 0 event(s) and waits for the next interval.

Configuration

| Env | Meaning | Default | |-----|---------|---------| | EG_URL | gateway base URL | required | | EG_TOKEN | consumer token (egc_*) | required | | EG_INTERVAL | poll interval in seconds | 60 | | EG_DLQ | dead-letter file for failed runs | ./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.

  • Exit 0 → done, the event was already consumed by the poll.
  • Non-zero exit → the event is appended to the local dead-letter file (EG_DLQ, default ./eventport.dead.ndjson) — nothing is silently lost. Replay failed events with:
node ~/.local/bin/event-agent.mjs --once --replay -- claude -p
  • Make handlers idempotent by messageId — dead-letter replays can present the same event twice.
  • One instance tracks one subscription (the one that issued EG_TOKEN). To consume another subscription, run another instance with its token.