Orchestrating with actions

The canonical request → typed-callback pattern: send tasks over messaging, collect structured results through typed actions, and advance the pipeline from handle.completed() listeners.

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

  1. The orchestrator registers an action with a Zod input schema describing the result it expects.
  2. The orchestrator sends the task as a channel message (or DM) naming the action to report through.
  3. 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.
  4. The orchestrator subscribes with the handle's typed predicates — handle.completed() — to advance the pipeline. The event's input and output carry the registration's types, so there are no string keys and no casts.
  5. 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.

orchestrator.ts
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.

assign.ts
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.

advance.ts
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.

failure.ts
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.

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

See Event handlers for everything addListener can subscribe to.