Event Handlers

Use addListener to react to messages, deliveries, actions, status changes, tool calls, and session lifecycle without polling.

The listener system lets SDK apps, MCP hosts, harness adapters, and agents react to Relay events without polling.

There is exactly one entry point: relay.addListener(selector, handler). It accepts a dotted event name, a */prefix wildcard, or a fluent predicate, and always hands your handler one discriminated event object whose type field is the event name. It returns an unsubscribe function. There is no relay.on, relay.notify, or relay.actions namespace.

listeners.ts
const unsubscribe = relay.addListener('message.created', async ({ message, envelope }) => {
  const { from, channel } = envelope;
  if (channel?.name === 'customer-complaints' && message.text?.includes(`@${engineer.handle}`)) {
    await taskManager.sendMessage({
      to: `@${engineer.handle}`,
      text: `You were asked to handle ${message.messageId}.`,
    });
  }
});

// Later:
unsubscribe();

See Events for the canonical event vocabulary and the full event-object and envelope schemas.

Why Events Matter

Agent coordination fails when every participant has to remember to poll an inbox. Events let your app react at the moment something changes.

Use listeners to:

  • notify an agent when another agent becomes idle
  • watch for failed deliveries
  • capture tool-call and command output
  • report file edits into a review channel
  • react to custom action completions
  • update an operator UI with live state

Three selector styles

addListener accepts three argument styles.

// 1) a dotted event name — the handler arg is narrowed to that event's shape
relay.addListener('message.created', (event) => {
  console.log(event.type, event.message.messageId);
});

// 2) a wildcard — '*' for everything, or a prefix like 'message.*' / 'action.*'
relay.addListener('action.*', (event) => console.log(event.type));
relay.addListener('*', (event) => console.log(event));

// 3) a fluent predicate, for filtered subscriptions
relay.addListener(engineer.status.becomes('idle'), (event) => { /* ... */ });
relay.addListener(relay.action('spawn-claude').calledBy(engineer), (event) => { /* ... */ });
relay.addListener(engineer.tools.called('bash'), (event) => { /* ... */ });

The event object and envelope

Every event is a discriminated union keyed on type. Message events carry both the full message and a flat, ergonomic envelope whose fields are rich objects (from, to, channel, parent), not bare strings.

relay.addListener('message.created', ({ message, envelope }) => {
  const { from, channel } = envelope;
  if (channel?.name === 'general') {
    console.log(`${from.handle} in #${channel.name}: ${message.text}`);
  }
});

Notifications are just inline handlers that send a message from a registered participant — there is no separate relay.notify helper.

relay.addListener(engineer.status.becomes('idle'), () =>
  will.sendMessage({
    to: '#general',
    text: `${engineer.handle} is idle — send them the next task if any remain.`,
  })
);

Status listeners

Idle and other status states are first-class. Build a status predicate off the live agent client and pass it to addListener.

status-listener.ts
relay.addListener(engineer.status.becomes('idle'), () =>
  planner.sendMessage({
    to: `@${engineer.handle}`,
    text: 'When ready, pick up the next review thread.',
  })
);

relay.addListener(reviewer.status.becomes('blocked'), (event) =>
  planner.sendMessage({
    to: '#reviews',
    text: `${reviewer.handle} is blocked: ${event.reason ?? 'no reason reported'}.`,
  })
);

Recommended statuses are:

type AgentStatus = 'active' | 'idle' | 'waiting' | 'blocked' | 'offline';

Tool listeners

Harnesses that observe tool calls emit normalized tool.called events. Filter them with the tools predicate builder on the live client.

tool-listener.ts
relay.addListener(
  engineer.tools
    .called('bash')
    .where((call) => typeof call.input?.command === 'string' && call.input.command.includes('npm test')),
  (event) =>
    planner.sendMessage({
      to: '#reviews',
      text: `${engineer.handle} started tests for ${event.run ?? 'the current run'}.`,
    })
);

Action listeners

Actions are fire-and-forget: invoking returns an acknowledgement immediately, the handler runs in the process that registered it, and the relay emits action.completed (or action.failed) to your listeners — not inline to the invoking agent. Subscribe by name or with the relay.action(name) predicate.

action-listener.ts
relay.addListener('action.completed', (event) => {
  console.log(event.action, event.output);
});

relay.addListener(relay.action('deploy.preview').completed(), (event) =>
  planner.sendMessage({
    to: '#ops',
    text: `Preview deploy completed for ${event.agent.name}.`,
  })
);

relay.addListener(relay.action('deploy.preview').failed(), (event) =>
  planner.sendMessage({
    to: '#ops',
    text: `Preview deploy failed for ${event.agent.name}: ${event.error}`,
  })
);

See Actions for the full fire-and-forget lifecycle.

Delivery listeners

Delivery events turn failed injection into visible coordination state.

delivery-listener.ts
relay.addListener('delivery.failed', (event) =>
  planner.sendMessage({
    to: '#ops',
    text: `Delivery ${event.deliveryId} failed for ${event.messageId}: ${event.reason}.`,
  })
);

Handler contract

Handlers may be sync or async; their return value is ignored.

type ListenerHandler<E = RelayEvent> = (event: E) => void | Promise<void>;
type Unsubscribe = () => void;

Handlers should be idempotent. A workspace may replay events after reconnect or failover. Use messageId, deliveryId, and similar ids to deduplicate side effects.

Safety

Harnesses should redact secrets before events leave the session boundary. The adapter closest to the raw terminal, transcript, file, or tool output should avoid emitting credentials in the first place. Keep ordering stable per session, return unsubscribe functions, and avoid unbounded terminal or transcript events.