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.

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

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? }).

register-action.ts
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-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. 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.

action-listener.ts
// 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.

spawn-claude.ts
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.

submit-vote.ts
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 the cli, a name, an optional target_node, and optional harnessConfig. 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.

Continue to MCP tools for messaging and generated actions.