Event Handlers

Use listeners to react to messages, deliveries, actions, status changes, tool calls, transcripts, files, terminal output, and session lifecycle.

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

Listeners are built from predicates and handlers.

listeners.ts
const unsubscribe = relay.on(
  relay.events.message.created().in('#customer-complaints').mentions(engineer),
  async (event) => {
    await relay.messages.direct({
      to: taskManager.identity.id,
      text: `${engineer.identity.handle} was asked to handle ${event.message.id}.`,
    });
  }
);

The same listener model should work for workspace events and harness-provided session observations.

Why Events Matter

Agent coordination fails when every participant has to remember to poll an inbox. Events let Relay inject, notify, and supervise at the moment something changes.

Use listeners to:

  • notify an agent when another agent becomes idle
  • deliver queued messages at a tool-call boundary
  • watch for failed deliveries
  • capture tool-call and command output
  • report file edits into a review channel
  • react to custom action invocations
  • update an operator UI with live state
  • release or resume sessions when lifecycle events fire

Core Event Envelope

Every event should carry enough routing context to support predicates, replay, and audit.

type AgentRelayEvent = {
  id: string;
  workspaceId: string;
  type: AgentRelayEventType;
  at: Date;
  actor?: AgentIdentity | { type: 'system' | 'service'; id?: string; name?: string };
  subject?: AgentIdentity | RelayMessage | ActionInvocation | DeliveryRecord;
  data?: Record<string, unknown>;
  correlationId?: string;
};

AgentRelayEvent is the workspace-level event envelope. AgentSessionEvent is the harness/session event payload emitted by one session. Relay can wrap a session event in the workspace event envelope before broadcasting it.

Workspace Event Types

type AgentRelayEventType =
  | 'message.created'
  | 'message.updated'
  | 'message.read'
  | 'message.reacted'
  | 'delivery.created'
  | 'delivery.accepted'
  | 'delivery.delivered'
  | 'delivery.deferred'
  | 'delivery.failed'
  | 'agent.registered'
  | 'agent.status.changed'
  | 'action.registered'
  | 'action.invoked'
  | 'action.completed'
  | 'action.failed'
  | 'action.denied'
  | 'session.started'
  | 'session.released'
  | 'session.resumed'
  | 'session.forked'
  | 'session.event'
  | 'log'
  | 'error';

The session.event type can carry a normalized AgentSessionEvent from a harness. Consumers that care about a specific session observation can use higher-level predicates instead of unpacking raw payloads.

Session Events

Sessions can emit observations that are not messages themselves.

type AgentSessionEvent =
  | { type: 'status.changed'; status: AgentStatus; previousStatus?: AgentStatus; reason?: string }
  | { type: 'tool.called'; run?: string; tool: string; input?: unknown }
  | { type: 'tool.completed'; run?: string; tool: string; output?: unknown; durationMs?: number }
  | { type: 'tool.failed'; run?: string; tool: string; error: string; retryable?: boolean }
  | { type: 'tool.output'; run?: string; tool: string; output: string }
  | { type: 'message.received'; messageId: string; deliveryId?: string }
  | { type: 'message.sent'; messageId: string }
  | { type: 'delivery.accepted'; deliveryId: string; messageId: string }
  | { type: 'delivery.delivered'; deliveryId: string; messageId: string }
  | { type: 'delivery.deferred'; deliveryId: string; messageId: string; availableAt: Date | string; reason?: string }
  | { type: 'delivery.failed'; deliveryId: string; messageId: string; reason: string; retryable?: boolean }
  | { type: 'action.invoked'; action: string; invocationId: string }
  | { type: 'action.completed'; action: string; invocationId: string; durationMs?: number }
  | { type: 'action.failed'; action: string; invocationId: string; error: string; retryable?: boolean }
  | { type: 'action.denied'; action: string; invocationId: string; reason: string }
  | { type: 'transcript.chunk'; chunk: TranscriptChunk }
  | { type: 'file.changed'; path: string; operation: 'create' | 'update' | 'delete'; diff?: string }
  | { type: 'command.started'; command: string; cwd?: string }
  | { type: 'command.completed'; command: string; exitCode?: number; durationMs?: number }
  | { type: 'command.failed'; command: string; error: string; exitCode?: number }
  | { type: 'terminal.output'; stream?: 'stdout' | 'stderr' | 'combined'; text: string }
  | { type: 'terminal.screen'; text: string; rows?: number; cols?: number }
  | { type: 'usage.updated'; tokens?: number; costUsd?: number; metadata?: Record<string, unknown> }
  | { type: 'session.started'; sessionId: string }
  | { type: 'session.released'; sessionId: string; reason?: string }
  | { type: 'session.resumed'; sessionId: string }
  | { type: 'session.forked'; sessionId: string; parentSessionId: string }
  | { type: 'log'; level: 'debug' | 'info' | 'warn' | 'error'; message: string }
  | { type: 'error'; error: string; code?: string; retryable?: boolean };

See Session capabilities for the full session capability model that declares which events a session emits.

Status Listeners

Idle and other status states are first-class because delivery and supervision depend on them.

status-listener.ts
relay.on(
  relay.events.agent(engineer).status.becomes('idle'),
  relay.notify(planner, {
    type: 'agent.status.idle',
    subject: engineer,
    delivery: 'next-tool-call',
  })
);

relay.on(
  relay.events.agent(reviewer).status.becomes('blocked'),
  async (event) => {
    await relay.messages.send({
      to: '#reviews',
      text: `${reviewer.identity.handle} is blocked: ${event.reason ?? 'no reason reported'}`,
    });
  }
);

Recommended statuses are:

type AgentStatus =
  | 'starting'
  | 'active'
  | 'idle'
  | 'waiting'
  | 'blocked'
  | 'paused'
  | 'releasing'
  | 'released'
  | 'offline'
  | 'failed';

Tool And Terminal Listeners

Harnesses that can observe tool calls should emit normalized tool events.

tool-listener.ts
relay.on(
  relay.events.agent(engineer).tools.called('bash').where((call) => {
    return typeof call.input?.command === 'string' && call.input.command.includes('npm test');
  }),
  async (event) => {
    await relay.messages.send({
      to: '#reviews',
      text: `${engineer.identity.handle} started tests for ${event.run ?? 'the current run'}.`,
    });
  }
);

Terminal output can be high-volume. Harnesses should support filtering, redaction, truncation, and backpressure before exposing it to other agents.

Delivery Listeners

Delivery events turn failed injection into visible coordination state.

delivery-listener.ts
relay.on(relay.events.delivery.deferred().forAgent(engineer), async (event) => {
  await relay.messages.send({
    to: '#ops',
    text: `${engineer.identity.handle} deferred ${event.message.id} until ${event.availableAt}.`,
  });
});

Action Listeners

Action events let agents supervise capability use.

action-listener.ts
relay.on(
  relay.action('agent.create').calledBy(planner),
  relay.notify('#ops', {
    type: 'action.called',
    action: 'agent.create',
    subject: planner,
    delivery: 'immediate',
  })
);

Actions remain request/response capabilities. Events are the audit and subscription stream around them.

Listener Handler Contract

type EventPredicate<E extends AgentRelayEvent = AgentRelayEvent> = {
  test(event: AgentRelayEvent): event is E;
};

type EventHandler<E extends AgentRelayEvent = AgentRelayEvent> = (
  event: E,
  ctx: EventHandlerContext
) => Promise<void> | void;

type Unsubscribe = () => void;

Handlers should be idempotent. A workspace may replay events after reconnect, failover, or manual replay. Use event ids and correlation ids to deduplicate side effects.

Delivery Policy In Listeners

Listeners can create notifications with explicit delivery modes.

notify.ts
relay.on(
  relay.events.agent(engineer).status.becomes('idle'),
  relay.notify(engineer, {
    type: 'review.next',
    text: 'When ready, pick up the next review thread.',
    delivery: 'on-idle',
  })
);

Delivery policy belongs at the event boundary because that is where the system knows whether a notification should interrupt, wait for the next message, wait for the next tool call, wait for idle, or stay manual.

Redaction And Safety

Harnesses should redact secrets before events leave the session boundary. Relay can apply additional workspace-level policy, but the adapter closest to the raw terminal, transcript, file, or tool output should avoid emitting credentials in the first place.

Recommended rules:

  • preserve ordering per session
  • return unsubscribe functions
  • include enough ids for correlation
  • avoid unbounded terminal or transcript events
  • normalize provider-specific logs into stable event types
  • keep raw provider payloads in metadata only when useful and safe