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.
- 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.
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.
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-claudereview.submit_voteui.show_search_resultsticket.createdeploy.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.
// 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.
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.
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.