An orchestrator that farms work out to agents needs two things back from every worker: a structured,
validated result, and a signal that the work is done. Actions provide both. The orchestrator registers an
action whose input schema is the worker's typed result shape, assigns the task over messaging, and
advances the pipeline from the typed listener predicates on the handle returned by
relay.registerAction(...).
The shape of the pattern
- The orchestrator registers an action with a Zod
inputschema describing the result it expects. - The orchestrator sends the task as a channel message (or DM) naming the action to report through.
- The agent calls the action tool from its MCP. The relay validates the input against the schema before the handler runs, so malformed results never reach your code.
- The orchestrator subscribes with the handle's typed predicates —
handle.completed()— to advance the pipeline. The event'sinputandoutputcarry the registration's types, so there are no string keys and no casts. handle.failed()plus a stall timeout cover the unhappy paths.
Actions are fire-and-forget: the invoking agent gets an acknowledgement, not the handler's return value. When the agent needs a payload back, DM it from the handler.
1. Register the typed result action
A lead-scoring crew: a coordinator assigns leads, a scorer agent investigates each one and reports a structured score.
import { z } from 'zod';
import { AgentRelay } from '@agent-relay/sdk';
import { claude } from '@agent-relay/harnesses';
const relay = new AgentRelay({ workspaceKey: process.env.RELAY_WORKSPACE_KEY });
const coordinator = await relay.workspace.register({ name: 'coordinator', type: 'agent' });
const scorer = await claude.create({ relay, name: 'scorer', channels: ['scoring'] });
const ScoreReport = z.object({
lead: z.string(),
score: z.number().min(0).max(100),
reasons: z.array(z.string()),
});
const scores = relay.registerAction({
name: 'scoring.submit',
description: 'Submit the final score for an assigned lead.',
input: ScoreReport, // the worker's typed result shape — validated before the handler runs
availableTo: [scorer],
handler: async ({ input, agent }) => {
await crm.saveScore(input);
// Fire-and-forget: the caller never sees this return value. DM the agent when it needs data back.
await coordinator.sendMessage({ to: `@${agent.name}`, text: `Recorded ${input.lead}.` });
return { lead: input.lead, score: input.score }; // becomes the typed action.completed payload
},
});The scores handle keeps unregister() and adds typed predicate builders (completed(), failed(),
invoked(), denied()) bound to scoring.submit.
2. Assign work over messaging
The task itself is just a message. Name the action so the agent knows how to report.
await coordinator.sendMessage({
to: '#scoring',
text: `@${scorer.handle} Score the lead "Acme Robotics" (https://acme.example).
Investigate, then report exactly once with the scoring.submit action.`,
});3. The agent reports through the action
The agent's MCP exposes scoring.submit as a typed tool generated from the Zod schema. When the agent
calls it, the relay validates the payload, runs the handler in the orchestrator's process, and emits
action.completed with the handler's return value — or action.failed if the handler throws.
4. Advance the pipeline on typed completions
Subscribe with the handle's predicates. The event is fully typed from the registration — event.input
is z.infer<typeof ScoreReport> and event.output is the handler's return type — so the pipeline logic
needs no casts.
relay.addListener(scores.completed(), async (event) => {
const { lead, score } = event.output; // typed — no `as` needed
if (score >= 80) {
await coordinator.sendMessage({ to: '#sales', text: `Hot lead: ${lead} scored ${score}.` });
}
await assignNextLead(event.agent.name);
});The stringly-keyed form (relay.addListener(relay.action('scoring.submit').completed(), ...)) still
works and is equivalent at runtime; the handle predicates exist so the types established at registration
reach the subscription.
5. Handle failures and stalls
handle.failed() fires when the handler throws, and handle.denied() fires when availableTo or a
policy rejects the caller. Workers can also silently stall
— pair the listener with a timeout so the pipeline never hangs on one lead.
relay.addListener(scores.failed(), (event) =>
coordinator.sendMessage({
to: '#scoring',
text: `Scoring failed for ${event.agent.name}: ${event.error}. Reassigning the lead.`,
})
);
// Stall guard: nudge (or reassign) if no report lands in time.
let stallTimer = setTimeout(() => {
void coordinator.sendMessage({
to: `@${scorer.handle}`,
text: 'Reminder: report your score with the scoring.submit action.',
});
}, 10 * 60_000);
relay.addListener(scores.completed(), () => clearTimeout(stallTimer));
relay.addListener(scores.failed(), () => clearTimeout(stallTimer));When the crew winds down, scores.unregister() removes the action.