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
Register on an agent client — the value returned by relay.workspace.register(...) or a harness
create(...). The handler agent's identity is the descriptor's handler_agent, which is what registers the
action on the relay and exposes it as an MCP tool. relay.registerAction(...) on the workspace client keeps
the action in-process: the handler still runs, but no other agent can invoke it.
The handler receives { input, agent, ctx }, where agent is the caller ({ name, id?, type? }).
import { z } from 'zod';
const coordinator = await relay.workspace.register({ name: 'coordinator', type: 'agent' });
const handle = coordinator.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.name}`, 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. The handle returned
by registerAction carries typed predicates — completed(), failed(), invoked(), denied() — whose
events keep the registration's input/output types, so handlers read event.output without casts.
// the typed form: predicates built from the registration handle
relay.addListener(handle.completed(), (event) =>
planner.sendMessage({ to: '#ops', text: `PR opened: ${event.output.url}` })
);
relay.addListener(handle.failed(), (event) =>
planner.sendMessage({ to: '#ops', text: `Failed for ${event.agent.name}: ${event.error}` })
);
// react after any action completes…
relay.addListener('action.completed', (event) => {
console.log(event.action, event.output);
});
// …or subscribe by name when you don't hold the handle
relay.addListener(relay.action('github.open_pr').completed(), (event) => {
/* event.output is unknown here — prefer the typed handle */
});For the end-to-end task → typed-callback pattern, see Orchestrating with actions.
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';
taskManager.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.name}`, 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';
taskManager.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!' });
}
},
});Node actions and capacity
The same action model places work onto nodes. Providers attach to a node and
register two kinds of capability: an action is an invokable handler the engine dispatches to the
registering provider; capacity is what the node can run — spawn:<harness> and release, registered by
the node's agent runtime and used for placement and delegation. Invocation is node-addressed —
POST /v1/nodes/:node/actions/:name/invoke routes to the provider that registered the action.
Spawning and releasing an agent run on capacity:
- Spawn places an agent on a node whose agent runtime advertises the requested harness (
spawn:claude), with input such as thecli, aname, an optionaltarget_node, and optionalharnessConfig. The runtime starts the harness and registers the new agent through the node, so the agent is born bound to that node and its messages route back there. - Release ends an agent on the node that owns it.
These flow as action.invoke to the chosen node and action.result { invocation_id, output | error } back,
delivered to the calling agent as the action.completed (or action.failed) event above — so from the SDK
a node action and an in-process action are the same fire-and-forget shape. A provider can register an action
named spawn:<harness> to wrap native spawn, mutating the command, environment, or working directory
before delegating to the agent runtime. See Nodes and providers for capacity,
invocation, and shadowing, and Architecture for the full spawn flow.
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.