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.receivedmessage.read,message.reactedaction.invoked,action.completed,action.failed,action.deniedagent.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.
- The agent calls the action tool. The relay records
action.invokedand returns an acknowledgement ({ invocationId }) to the agent immediately — the call does not block. - 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) oraction.failed. action.completedis 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,
});