Actions

Actions are fire-and-forget typed capabilities agents discover and invoke through MCP. The handler runs in your SDK process and emits an action.completed event.

Actions are typed capabilities agents can discover and invoke through their MCP tools. An action can spawn another agent, update an operator UI, submit a vote, write a ticket, run a deployment, query an internal system, or publish a result. The handler lives in the SDK process that registered the action; Relay owns the protocol around it.

Fire-and-forget 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 runs in whichever SDK process 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.

There is no relay.actions namespace and no inline invoke(...) that returns a result — react to outcomes with addListener.

Register an action

Use relay.registerAction(...). The handler receives { input, agent, ctx }, where agent is the caller.

register-action.ts
import { z } from 'zod';

const handle = relay.registerAction({
  name: 'github.open_pr',
  description: 'Open a GitHub pull request for a completed agent change.',
  input: z.object({
    branch: z.string(),
    title: z.string(),
    body: z.string(),
  }),
  availableTo: [{ name: 'engineer' }, { name: 'release-manager' }],
  handler: async ({ input, agent }) => {
    const pr = await github.openPullRequest(input);
    // The return value reaches listeners, not the caller — message the caller directly.
    await coordinator.sendMessage({ to: `@${agent.handle}`, text: `Opened ${pr.url}` });
    return { url: pr.url }; // becomes the action.completed payload
  },
});

// Later, if this process should stop exposing the action:
handle.unregister();

Provide an input Zod schema so the agent's MCP can present a typed tool and validate calls. Pass availableTo to restrict which agents may invoke the action; omit it to allow everyone.

Descriptor shape

interface RegisterActionInput<Input, Output> {
  name: string;
  description?: string;
  input?: ZodSchema<Input>;
  output?: ZodSchema<Output>;
  /** Restrict which agents may invoke this action. Omit to allow everyone. */
  availableTo?: AgentRef[];
  handler(args: { input: Input; agent: Caller; ctx: ActionContext }): Promise<Output> | Output;
}

Names should be stable and namespaced by the system that owns the behavior:

  • spawn-claude
  • review.submit_vote
  • ui.show_search_results
  • ticket.create
  • deploy.preview

React to completion

Because the result does not return inline, subscribe to the outcome with a listener — either by event name or with the relay.action(name) predicate.

action-listener.ts
// react after any action completes…
relay.addListener('action.completed', (event) => {
  console.log(event.action, event.output);
});

// …or just this one
relay.addListener(relay.action('github.open_pr').completed(), (event) =>
  planner.sendMessage({ to: '#ops', text: `PR opened: ${event.output.url}` })
);

relay.addListener(relay.action('deploy.preview').failed(), (event) =>
  planner.sendMessage({ to: '#ops', text: `Deploy failed for ${event.agent.name}: ${event.error}` })
);

Spawning agents with actions

A common action spawns other agents. The handler messages the caller to report who showed up.

spawn-claude.ts
import { claude } from '@agent-relay/harnesses';
import { z } from 'zod';

relay.registerAction({
  name: 'spawn-claude',
  description: 'Spawn a new Claude Code instance.',
  input: z.object({ model: z.enum(['opus', 'sonnet']) }),
  availableTo: [taskManager, engineer], // omit to make it available to all agents
  handler: async ({ agent: caller, input }) => {
    // create({ relay }) spawns AND registers the new agent in one step.
    const agent = await claude.create({ relay, model: input.model });
    await taskManager.sendMessage({ to: `@${caller.handle}`, text: `Spawned ${agent.handle}` });
    return { agentId: agent.id, handle: agent.handle }; // becomes the action.completed payload
  },
});

Agent voting

Another good use of actions is collecting structured votes to reach consensus.

submit-vote.ts
import { z } from 'zod';

relay.registerAction({
  name: 'submit-vote',
  description: 'Submit your vote for yes or no.',
  input: z.object({ vote: z.enum(['yes', 'no']) }),
  handler: async ({ agent, input }) => {
    await writeToDb(agent.name, input.vote);
    if (await allVotesAreIn()) {
      await taskManager.sendMessage({ to: '#customer-complaints', text: 'All votes are in!' });
    }
  },
});

MCP tool generation

The agent-relay MCP exposes each registered action as an explicit tool, with JSON Schema generated from the input Zod schema. The acknowledgement ({ invocationId }) is returned to the agent as the tool result; the handler's return value flows to listeners as action.completed.

Continue to MCP tools for messaging and generated actions.