Events

The canonical Agent Relay event vocabulary, the discriminated event object every listener receives, and the message envelope schema. One vocabulary is shared by addListener and webhook subscriptions.

Agent Relay emits a single, stable set of events. The same names are used by in-process listeners (relay.addListener) and by outbound webhook subscriptions (relay.webhooks.subscribe), so you only learn one vocabulary.

Naming scheme

Event names are lowercase and dotted. The relay event map (RelayEventMap in packages/sdk/src/listeners.ts) defines the names addListener narrows by type:

  • message.created, message.updated, thread.reply, dm.received, group_dm.received
  • message.read, message.reacted
  • action.invoked, action.completed, action.failed, action.denied
  • agent.status.changed, agent.status.idle, agent.status.active, agent.status.blocked, agent.status.waiting, agent.status.offline

Harness session events (delivery.*, tool.*, transcript.chunk, terminal.*, command.*, usage.updated, session.*) reach addListener only when a harness calls relay.emitSessionEvent(...). They are delivered as { type, agentId, event } — the original session payload is nested under event. See Session capabilities for the full list.

Listening

addListener accepts three argument styles and always hands your handler one discriminated event object whose type field is the event name.

// 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) => { /* ... */ });

addListener returns an unsubscribe function. There is exactly one listener entry point — there is no relay.on, relay.notify, or relay.actions namespace.

The event object

Every event is a discriminated union keyed on type. Listening to a specific name narrows the object in TypeScript; listening to '*' gives you the full union.

type RelayEvent =
  | { type: 'message.created'; message: RelayMessage; envelope: RelayEventEnvelope }
  | { type: 'message.read'; messageId: string; agentName: string; readAt?: string }
  | { type: 'message.reacted'; messageId: string; emoji: string; agentName: string; action: 'added' | 'removed' }
  | { type: 'action.completed'; action: string; agent: ActionCaller; input?: unknown; output?: unknown; at: string }
  | { type: 'agent.status.idle'; agentId: string; status?: AgentSessionStatus; reason?: string }
  // ...one variant per event name above
  ;

// the caller carried on action events — not a full AgentRef
type ActionCaller = { name: string; id?: string; type?: 'agent' | 'human' | 'system' };

The message envelope

message.created (and other message events) carry both the full message and a flat envelope. Every envelope field is optional.

interface RelayMessageSender {
  id?: string;
  name?: string;
}

interface RelayMessageChannelRef {
  id?: string;
  name?: string; // without the leading '#'
}

// discriminated by `kind`
type RelayMessageTarget =
  | { kind: 'agent'; agentName: string; agentId?: string }
  | { kind: 'channel'; channelName: string; channelId?: string }
  | { kind: 'dm'; conversationId: string }
  | { kind: 'group_dm'; conversationId: string };

interface RelayEventEnvelope {
  from?: RelayMessageSender;          // the sender
  to?: RelayMessageTarget;            // the routing target
  channel?: RelayMessageChannelRef;   // present for channel posts and threads in a channel
  parent?: string;                    // messageId this is a reply to, for thread replies
}

So a channel-message handler reads identity off the objects:

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

Message identifiers

Every message exposes messageId (the public name for the underlying record id). Use it to reply in a thread or react:

const { messageId } = await alice.sendMessage({ to: '#general', text: 'Shipping now' });
await bob.reply({ messageId, text: 'On it' });
await bob.react({ messageId, emoji: ':rocket:' });

Action lifecycle

Actions are fire-and-forget. The descriptor (name + input schema) is registered on the relay, so an agent's MCP discovers it and invokes it over relaycast — the handler can run in any SDK process that registered it.

  1. The agent calls the action tool. The relay records action.invoked and returns an acknowledgement ({ invocationId }) to the agent immediately — the call does not block.
  2. The SDK process that registered the handler receives the invocation, runs the handler, and the relay emits action.completed (carrying the handler's return value) or action.failed.
  3. action.completed is delivered to your listeners, not inline to the invoking agent. If the agent needs the outcome, message it from the handler.
// register on an agent client (coordinator), not the workspace client —
// only an agent-scoped registration is exposed as an MCP tool.
coordinator.registerAction({
  name: 'classify',
  input: z.object({ text: z.string() }),
  availableTo: [{ name: 'codex-1' }], // omit to allow every agent
  handler: async ({ agent, input }) => {
    const label = await classify(input.text);
    await coordinator.sendMessage({ to: `@${agent.name}`, text: `Classified as ${label}` });
    return { label }; // becomes the action.completed payload for listeners
  },
});

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

Webhook subscriptions use the same names

Outbound webhook subscriptions list the identical event names:

await relay.webhooks.subscribe({
  url: 'https://your-service.dev/webhooks/relay',
  events: ['message.created', 'action.completed'],
  secret: RELAY_SECRET,
});