Event handlers

Subscribe to relay events so your app can react to messages, agent lifecycle changes, structured results, spawn intercepts, output, and delivery updates.

Event handlers are the main way to observe what the relay is doing in real time. Register listeners on the AgentRelay instance to log activity, update UI, trigger follow-up work, or keep your own state in sync.

Basic pattern

Use addListener(event, handler) to subscribe — it returns an unsubscribe function. Multiple listeners can register for the same event; they fire sequentially in registration order. Async handlers are awaited. Handler exceptions are caught and logged so one bad listener never blocks the others.

const relay = new AgentRelay();

const off = relay.addListener('messageReceived', (msg) => {
  console.log(`${msg.from} -> ${msg.to}: ${msg.text}`);
});

relay.addListener('agentReady', (agent) => {
  console.log(`ready: ${agent.name}`);
});

// Later, when you want to detach:
off();
// or:
relay.removeListener('messageReceived', myHandler);

The TypeScript SDK switched to a multi-listener registry in 7.0; if you're upgrading from 6.x see the migration block below.

Message events

relay.addListener('messageReceived', (msg) => {
  console.log('received', msg.threadId, msg.text);
});

relay.addListener('messageSent', (msg) => {
  console.log('sent', msg.to, msg.text);
});
  • messageReceived / on_message_received: fires when the relay delivers a message into your session.
  • messageSent / on_message_sent: fires after your app or agent sends a message through the relay.

Agent lifecycle events

relay.addListener('agentSpawned', (agent) => {
  console.log(`spawned: ${agent.name}`);
});

relay.addListener('agentReady', (agent) => {
  console.log(`ready: ${agent.name}`);
});

relay.addListener('agentReleased', (agent) => {
  console.log(`released: ${agent.name}`);
});

relay.addListener('agentExited', (agent) => {
  console.log(`exited: ${agent.name}`, agent.exitCode, agent.exitSignal);
});

relay.addListener('agentExitRequested', ({ name, reason }) => {
  console.log(`exit requested: ${name}`, reason);
});

relay.addListener('agentIdle', ({ name, idleSecs }) => {
  console.log(`idle: ${name}`, idleSecs);
});

relay.addListener('agentActivityChanged', ({ name, active, pendingDeliveries, reason }) => {
  console.log(`activity: ${name}`, { active, pendingDeliveries, reason });
});

These are useful for status dashboards, retry logic, timeout nudges, and cleanup when a worker finishes or requests exit.

Call-site spawn / release hooks

Unlike the broker-event listeners above (which fire after the broker emits an event), the four before* / after* hooks fire at the SDK call site — before and after the HTTP request to the broker. They're the right place to integrate cost-tracking tools, audit logs, or anything that needs to observe (or modify) the spawn input.

import { randomUUID } from 'node:crypto';

relay.addListener('beforeAgentSpawn', (ctx) => {
  console.log(`spawning ${ctx.input.name} via ${ctx.input.cli}`);
});

relay.addListener('afterAgentSpawn', (ctx) => {
  if (ctx.error) {
    console.error(`spawn ${ctx.input.name} failed in ${ctx.durationMs}ms`, ctx.error);
  } else {
    console.log(`spawn ${ctx.result?.name} resolved in ${ctx.durationMs}ms`);
  }
});

relay.addListener('beforeAgentRelease', (ctx) => {
  console.log(`releasing ${ctx.name}`, ctx.reason);
});

relay.addListener('afterAgentRelease', (ctx) => {
  console.log(`released ${ctx.name} in ${ctx.durationMs}ms`);
});

Mutating the spawn input

A beforeAgentSpawn handler can return a SpawnPatch to merge into the spawn input before it reaches the broker. Patches are a shallow merge over the resolved input; when multiple handlers return patches they apply in registration order (later wins on conflict). For array fields like args / channels the patch replaces the array — spread the previous value to extend:

relay.addListener('beforeAgentSpawn', (ctx) => {
  if (ctx.input.cli !== 'claude') return; // observe-only for non-Claude
  const sessionId = randomUUID();
  // Preallocate a session id so cost-tracking can stamp it without sidecar matching.
  return {
    args: [...(ctx.input.args ?? []), '--session-id', sessionId],
  };
});

afterAgentSpawn exposes the post-patch resolvedInput so observers can see exactly what was sent. beforeAgentRelease / afterAgentRelease are observe-only.

Structured result events

relay.addListener('agentResult', (result) => {
  console.log(`result from ${result.name}`, result.resultId, result.data);
});

agentResult fires when a spawned agent submits JSON through the submit_result MCP tool. Use it for global observers; use agent.waitForResult(...) or the per-spawn result.onResult callback when one caller owns a specific agent's result.

Output and delivery events

relay.addListener('workerOutput', ({ name, stream, chunk }) => {
  console.log(`[${name}] ${stream}: ${chunk}`);
});

relay.addListener('deliveryUpdate', (event) => {
  console.log('delivery update', event.kind, event);
});
  • workerOutput / on_worker_output: raw stdout or stderr from a worker.
  • deliveryUpdate / on_delivery_update: broker-level delivery events when you need transport visibility.
  • agentActivityChanged: high-level derived activity signal for UI badges like "thinking" without hand-rolling delivery event inference.
const thinkingByAgent = new Map<string, boolean>();

relay.addListener('agentActivityChanged', ({ name, active }) => {
  thinkingByAgent.set(name, active);
  renderThinkingBadge(name, active);
});

Channel subscribe / unsubscribe events

relay.addListener('channelSubscribed', ({ agent, channels }) => {
  console.log(`${agent} subscribed to ${channels.join(', ')}`);
});

relay.addListener('channelUnsubscribed', ({ agent, channels }) => {
  console.log(`${agent} unsubscribed from ${channels.join(', ')}`);
});

Migrating from 6.x

The 6.x TypeScript SDK exposed each event as a single nullable callback field:

// 6.x style — no longer compiles
relay.onAgentSpawned = (agent) => log(agent.name);
relay.onMessageReceived = null;

In 7.0 every event is on a typed multi-listener registry:

// 7.0 style
const off = relay.addListener('agentSpawned', (agent) => log(agent.name));
off(); // unsubscribe when done

Migration rules:

  • relay.onXxx = handler;relay.addListener('xxx', handler); (lower-case first letter of the suffix).
  • relay.onXxx = null; → either call the unsubscribe function returned from addListener, or use relay.removeListener('xxx', handler).
  • relay.onChannelSubscribed = (agent, channels) => ... and relay.onChannelUnsubscribed = ... now receive a single { agent, channels } object instead of positional args.

Per-call option callbacks like spawnPersona({ onStart, onSuccess, onError }) are unchanged — those are scoped to a single invocation, not global hooks.

Good uses for listeners

  • Update a UI timeline or status panel without polling.
  • Capture logs and metrics while agents run.
  • Trigger follow-up work when a message arrives or a worker becomes ready.
  • Detect exit conditions and clean up local resources.
  • Integrate cost-tracking or audit tools by pre-stamping spawns from beforeAgentSpawn.

See also