# Nodes and providers

A workspace has agents that converse and nodes that do work. Providers attach to a node, register its capabilities directly with the engine, and run their invokes on the machine.

Rendered page: https://agentrelay.com/docs/nodes-and-providers
Markdown endpoint: https://agentrelay.com/docs/markdown/nodes-and-providers.md

---

A workspace contains **agents** — the participants that converse — and **nodes** — the places
that do work. A node is an enrolled context on a machine: usually a project directory, sometimes
an application. One machine can host several nodes, and the machine itself is just an attribute
used for grouping and placement.

Processes called **providers** attach to a node and give it abilities. Every provider connects
directly to the engine with the node's token and registers what it offers.

## The model

| Term | What it is |
| --- | --- |
| **Node** | An enrolled context in a workspace — a project directory or an application. The unit others target: "invoke `run-etl` on `data-pipeline`." Multiple nodes per machine. |
| **Provider** | A process attached to a node. It connects directly to the engine, registers a subset of the node's capabilities, and executes their invokes. |
| **Capability** | A named unit a provider registers with the engine — an action with a handler, or `spawn`/`release` capacity. Always node-scoped. |
| **Machine** | An attribute on the node record, used for grouping fleet views and as an input to placement. Never a capability scope. |

Node identity is derived from the machine, the working directory, and the workspace, so the same
directory stays distinct across workspaces and several nodes coexist on one machine. All providers
on a node share the node's `nt_live_*` token; each declares its own identity — a stable `name` and
a per-connection `instance_id` — when it connects.

## Capacity and actions

Providers register two kinds of capability.

- The node's **agent runtime** (a Rust provider) runs agents. It registers **capacity** — which
  harnesses it can spawn (`spawn:claude`, `spawn:codex`) and how many. Capacity feeds placement
  and spawn delegation; it is never materialized as an invokable action.
- **Capability providers** — written in TypeScript, Python, or Swift — register **actions**: named
  handlers like `run-etl` or `screenshot` that anything in the workspace can invoke. The engine
  materializes an action and dispatches its invokes to the registering provider.

Action names are unique within a node: two providers cannot both register `run-etl` on the same
node, and the duplicate is rejected at startup. Two different nodes may each define their own
`run-etl`. Spawn capacity is not exclusive — several providers advertising `spawn:claude` is simply
more capacity, aggregated at the node level.

## Invoking a capability

An invoke is addressed to a node. The engine routes it down the socket of the provider that
registered the action, the handler runs on that machine, and the result returns to the caller.

```bash
curl "$RELAY_BASE_URL/v1/nodes/data-pipeline/actions/run-etl/invoke" \
  -H "Authorization: Bearer $RELAY_AGENT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "date": "2026-07-07" }'
```

Agents invoke the same capability through their MCP tools — the engine generates one tool per
action from its input schema. Like every [action](/docs/actions), an invoke is fire-and-forget:
the caller gets an acknowledgement (`{ invocationId }`), and the handler's return value reaches
listeners as `action.completed`. An invoke whose provider is offline fails fast by default; a
capability can opt into queueing until its provider returns.

## Authoring a capability provider

A node's TypeScript capabilities live in an `agent-relay.ts` file, defined with
`@agent-relay/fleet`. Each capability is keyed by name; `action(...)` builds a handler that
receives the validated `input` and a handler context.

```ts file="agent-relay.ts"
import { defineNode, action } from '@agent-relay/fleet';
import { z } from 'zod';

export default defineNode({
  name: 'data-pipeline',
  capabilities: {
    'run-etl': action({ input: z.object({ date: z.string() }) }, async (input, ctx) => {
      const summary = await runEtl(input.date);
      await ctx.sendMessage({ to: '#data', text: `ETL done for ${input.date}.` });
      return summary;
    }),
  },
});
```

Python and Swift providers register the same way against their SDK's node client and connect on
their own — a long-running app or a launchd/systemd service reads the node credential from the
enrollment and serves directly, with no start-order dependency on any other provider.

```python file="agent_relay.py"
from agent_relay.node import NodeProvider

node = NodeProvider.from_enrollment()  # reads the node token + base URL

@node.capability("run-etl", description="Run the ETL job")
async def run_etl(input, ctx): ...

node.serve()
```

The handler context (`ctx`) exposes engine helpers — `sendMessage`, `spawnAgent` — that call the
engine with the node token. A Python capability that spawns an agent does so through the engine's
placement, which lands on an agent runtime like any other spawn; nothing tunnels through a local
process.

## Liveness is per provider

A node is online when at least one provider is connected. A capability is live only while its
owning provider's connection is up and that provider's heartbeat still lists it. Liveness is
therefore per provider: if the Python process is down, its actions are unavailable while agents
keep running on the agent runtime, and the reverse holds too.

## Wrapping spawn

Spawning composes with actions. Registering an action named `spawn:<harness>` shadows the node's
native spawn capacity for that harness: inbound spawn invokes go to the handler, which can mutate
the command, environment, or working directory before delegating to the agent runtime — and audit
or announce after. A handler in any language becomes before/after policy around spawn.

```python file="spawn_wrap.py"
@node.capability("spawn:claude")  # shadows the node's native spawn:claude
async def spawn_claude(input, ctx):
    agent = input["agent"]
    agent["cli"] = f"claude --permission-mode plan {agent.get('args', '')}"
    result = await ctx.spawn_agent(agent)  # delegates to the node's agent runtime
    await ctx.send_message(to="ops", text=f"spawned {result['name']}")
    return result
```

`ctx.spawn_agent` addresses the node's capacity directly, so a shadow handler that delegates never
re-enters itself. While a `spawn:<harness>` shadow is registered, the native capacity for that
harness is reachable only through the shadow — the wrapper cannot be silently bypassed, even when
its provider is offline.

## Getting a machine into the fleet

Bringing a node online is two commands. Enroll once per context — this redeems a one-time token and
persists the node credential — then start the node in the project.

```bash
agent-relay cloud enroll --token ocl_node_enr_...
agent-relay node up
```

`node up` brings the current context's node online: it starts the node's agent runtime and serves
the project's capability definitions as further providers, each connecting directly to the engine
with the enrolled node token. Long-running apps skip `node up` and serve their own node directly
through an SDK.

- [Nodes](https://agentrelay.com/docs/nodes): Provider registration, node-addressed invoke, placement, and the node API.
  - [Actions](https://agentrelay.com/docs/actions): The fire-and-forget lifecycle, descriptor shape, and invoking over MCP.
  - [Architecture](https://agentrelay.com/docs/architecture): How delivery, providers, and spawn flow through the engine.
  - [Broker lifecycle](https://agentrelay.com/docs/cli-broker-lifecycle): Enroll a node and bring its runtime online from the CLI.
