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 doneMigration rules:
relay.onXxx = handler;→relay.addListener('xxx', handler);(lower-case first letter of the suffix).relay.onXxx = null;→ either call the unsubscribe function returned fromaddListener, or userelay.removeListener('xxx', handler).relay.onChannelSubscribed = (agent, channels) => ...andrelay.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.