diff --git a/web/content/docs/actions.mdx b/web/content/docs/actions.mdx index 1adc80d..43fae4a 100644 --- a/web/content/docs/actions.mdx +++ b/web/content/docs/actions.mdx @@ -155,23 +155,28 @@ taskManager.registerAction({ }); ``` -## Node actions: spawn and release - -The same action model places work onto [nodes](/docs/nodes). A node advertises **capabilities** — named -actions with a `kind` of `spawn` or `action` — and the engine routes an invocation to a node that provides -the capability. Spawning and releasing an agent are both actions: - -- **Spawn** invokes the `spawn` (or `spawn:`) capability with input such as the harness/`cli`, a - `name`, an optional `target_node`, and optional `harnessConfig`. The engine places it on an eligible node, - which runs the harness its capability declares and registers the new agent through the node — so the agent - is born bound to that node and its messages route back there. -- **Release** invokes the `release` action on the node that owns the agent. - -At the node-protocol layer these flow as `action.invoke` to the chosen node and `action.result -{ invocation_id, output | error }` back. That result is delivered to the calling agent as the -`action.completed` (or `action.failed`) event described above, so from the SDK both surfaces are the same -fire-and-forget action. See [Architecture](/docs/architecture) for the full spawn flow and -[Nodes](/docs/nodes) for placement, targeting, and capability declaration. +## Node actions and capacity + +The same action model places work onto [nodes](/docs/nodes-and-providers). 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:` 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:` to **wrap** native spawn, mutating the command, environment, or working directory +before delegating to the agent runtime. See [Nodes and providers](/docs/nodes-and-providers) for capacity, +invocation, and shadowing, and [Architecture](/docs/architecture) for the full spawn flow. ## MCP tool generation diff --git a/web/content/docs/architecture.mdx b/web/content/docs/architecture.mdx index c3efa16..80a4204 100644 --- a/web/content/docs/architecture.mdx +++ b/web/content/docs/architecture.mdx @@ -1,137 +1,120 @@ --- -title: 'Architecture: nodes, the broker, and action runners' -description: 'How Agent Relay routes reliable delivery through nodes, what the broker stack on a machine owns, and the vocabulary for brokers, the broker-pty-engine, the harness driver, and action runners.' +title: 'Architecture: nodes, providers, and the agent runtime' +description: 'How Agent Relay routes reliable delivery through nodes, how providers attach to a node and connect directly to the engine, and the vocabulary for nodes, providers, capacity, and the agent runtime.' --- Agent Relay separates _what_ a message means from _where_ it is delivered. Messaging writes a durable record; [delivery](/docs/delivery) gets that record into a session. This page describes the -delivery side end to end: the node model, the broker stack that runs on a machine, and the action -runners that spawn and drive agents. +delivery side end to end: the node and provider model, the agent runtime that hosts sessions on a +machine, and how spawn flows through the engine. -## Everything is a node +## The engine is the hub -A **node** is a delivery endpoint connected to the engine over `/v1/node/ws`. Every agent is owned by -a node, and that ownership is what tells the engine where to route the agent's future deliveries. +A **node** is an enrolled context in a workspace — usually a project directory, sometimes an +application. **Providers** attach to a node: each is a process that connects directly to the engine +over `/v1/node/ws`, shares the node's token, and registers a subset of the node's capabilities. The +engine is the only hub; there is no local one. -Realtime delivery is **node-only** and reliable: each agent has a per-agent sequence, and the node -acknowledges deliveries so the engine can replay anything unacked after a reconnect. The -workspace stream at `/v1/ws` is **observer-only** — it feeds dashboards and audit views and is never a -delivery path. +Realtime delivery is **node-only** and reliable: each agent has a per-agent sequence, and the +provider that owns an agent acknowledges deliveries so the engine can replay anything unacked after a +reconnect. The workspace stream at `/v1/ws` is **observer-only** — it feeds dashboards and audit +views and is never a delivery path. -A node has a **role**: +## Providers on a node -| Role | Meaning | -| --- | --- | -| `broker` | A node that hosts many agents. One broker process is the machine's node. | -| `direct` | A single self-connected agent — a "node of one." | - -When an agent is spawned through a node's action handler, it is **born owned by that node** -(node-bound), so its messages route back to that node. - - - The node roles here — `broker` and `direct` — are the delivery-ownership model. The [Nodes](/docs/nodes) - page describes the registration _kinds_ (`direct_ws`, `fleet_ws`, `http_push`, `poll`) the SDK and REST - API expose. A `broker` node is a `fleet_ws` host; a `direct` node is an implicit `direct_ws` route. - - -## The broker stack - -One broker stack runs per machine. It is the machine's node, and it is the only thing that talks to -the engine. +A node's abilities are split across its providers, each connecting to the engine on its own: ```text - /v1/node/ws - engine ◄──────────────────────► broker (role: broker — the machine's node) - │ • engine transport - │ (node + agent registration, - │ delivery routing, acks) - │ • broker-pty-engine - │ - │ ◄── @agent-relay/harness-driver (SDK) ──┐ - ▼ │ - broker-pty-engine action runner - (spawns/owns PTYs) (TS / Swift / Python handlers) +engine (cast.agentrelay.com) + ├─ /v1/node/ws ← agent runtime (Rust) — spawn:*, release, agent delivery + ├─ /v1/node/ws ← capability provider (TS) — project agent-relay.ts actions + ├─ /v1/node/ws ← capability provider (py) — project agent-relay.py actions + └─ /v1/node/ws ← app provider (Swift) — its own node ``` -**broker** — the process that is the machine's node (role `broker`). It is the **only** component that -talks to the engine. It owns the engine transport (the `/v1/node/ws` connection, node and agent -registration, delivery routing, and acks) and the broker-pty-engine. +**agent runtime** — the Rust provider that spawns and owns agent processes (PTYs). It registers the +node's **capacity** (`spawn:`, `release`) and the agents it hosts, receives `deliver` frames +for those agents, and heartbeats its own load. It owns a generic PTY engine that reads, writes, +injects deliveries into, and tears down agent processes; it is harness-agnostic, with no per-CLI logic +baked in. -**broker-pty-engine** — the broker's internal generic PTY engine. It spawns and owns agent processes -(PTYs), reads and writes to them, injects deliveries, and tears them down. It is harness-agnostic: no -per-CLI logic is baked in. +**capability provider** — a process in TypeScript, Python, or Swift that registers **actions**: named +handlers the engine dispatches to it. A capability that needs to spawn an agent calls the engine's +placement through `ctx.spawnAgent`, which lands on an agent runtime — it never tunnels through a local +process. -**@agent-relay/harness-driver** — the client SDK used to interface with the broker-pty-engine. A caller -uses it to spawn a PTY (`{ command, args, env }`) and drive it. It is used **inside** action runners; it -is not a standalone component. +**@agent-relay/harness-driver** — the client SDK for the agent runtime's PTY engine, used to spawn a +PTY (`{ command, args, env }`) and drive it. -**action runner** — a pluggable, per-language (TypeScript / Swift / Python / …) handler host. It -registers and runs **actions** (`spawn:claude`, `release`, or custom). When a handler needs a process, -it calls `@agent-relay/harness-driver` to drive the broker-pty-engine. An action runner talks **only to -the broker — never directly to the engine.** +## Capabilities, capacity, and placement -## Actions, capabilities, and placement +Providers register two kinds of capability. An **action** is an invokable handler; the engine +materializes it and dispatches invokes to the registering provider. **Capacity** — `spawn:` +and `release` — is what the node can run; the agent runtime registers it for placement and delegation, +and it is never materialized as an action. -Spawning, releasing, and custom agent-to-agent RPC are all **actions**, invoked over the node -connection. A node advertises its **capabilities** — named actions with a `kind` of `spawn` or `action` — -through the action runner's `defineNode`. The engine **places** each invocation on a node that provides -the capability, weighing liveness, capacity, and load, then sends `action.invoke` to that node. The action -runner handles it and replies `action.result { invocation_id, output | error }`, which is delivered back to -the calling agent. - -See [Nodes](/docs/nodes) for fleets, capabilities, and the placement rules, and [Actions](/docs/actions) -for the registration and discovery model agents use. +Invocation is node-addressed: `POST /v1/nodes/:node/actions/:name/invoke` resolves the capability to +its provider and sends `action.invoke` down that socket. The provider replies +`action.result { invocation_id, output | error }`, delivered back to the calling agent as the +action's completion. See [Nodes and providers](/docs/nodes-and-providers) for the model and +[Nodes](/docs/nodes) for placement and the node API. ## Delivery flow A message travels from the engine to an agent's session like this: -1. The engine sends `deliver { delivery_id, agent_id, seq, payload }` over `/v1/node/ws`. -2. The broker hands it to the broker-pty-engine, which injects it into the agent's PTY. -3. The broker acks with `delivery.ack { agent, up_to_seq }`. +1. The engine sends `deliver { delivery_id, agent_id, seq, payload }` over `/v1/node/ws` to the + provider that owns the agent — its agent runtime. +2. The agent runtime injects it into the agent's PTY. +3. The runtime acks with `delivery.ack { agent, up_to_seq }`. Reactions and receipts ride the same `deliver` frame, distinguished by `payload.type` (`message.reacted` / `message.read`). ## Spawn flow -Spawning an agent runs through the action path. The request carries the capability (the harness or `cli`), -a `name`, an optional `target_node`, and optional `harnessConfig` — not a raw command to run: +Spawning an agent runs through capacity. The request carries the harness or `cli`, a `name`, an +optional `target_node`, and optional `harnessConfig` — not a raw command to run: -1. The engine **places** the spawn on an eligible node and sends `action.invoke(spawn:…)`. -2. The broker dispatches it to the action runner. -3. The runner resolves the **harness its capability declares** and calls `@agent-relay/harness-driver`. -4. The broker-pty-engine spawns the PTY and hosts it. -5. The agent registers **through** the node, so it is born bound to that node (`via_node`), and the broker +1. The engine **places** the spawn on a node whose agent runtime advertises the harness and sends + `action.invoke(spawn:…)`. +2. The agent runtime resolves the harness and spawns the PTY. +3. The new agent registers **through** the node, so it is born bound to that node, and the runtime replies `action.result`. -Because the agent is node-bound, its future deliveries route back to the broker that spawned it — closing -the loop with the delivery flow above. A spawn carrying a session id resumes the agent on its origin node. -**Release** is invoking the `release` action on the owning node. +Because the agent is node-bound, its future deliveries route back to the agent runtime that spawned +it — closing the loop with the delivery flow above. A spawn carrying a session id resumes the agent on +its origin node. **Release** invokes the `release` capacity on the owning node. + +A provider can register an action named `spawn:` that **shadows** native capacity: the engine +routes spawn invokes for that harness to the handler, which mutates the spec and delegates via +`ctx.spawnAgent`. The delegation addresses capacity directly, so a shadow never re-enters itself, and +while it is registered the native capacity is reachable only through it. ## Vocabulary | Term | Meaning | | --- | --- | -| node | an engine delivery endpoint (`role: broker` or `direct`) | -| broker | the machine's node (role `broker`): engine transport + broker-pty-engine; the only thing that talks to the engine | -| broker-pty-engine | the broker's internal generic PTY engine (owns/drives PTYs) | -| @agent-relay/harness-driver | client SDK to the broker-pty-engine, used inside action runners | -| action runner | pluggable per-language handler host; runs actions; talks only to the broker | -| invoke | calling an action | -| action | a named capability (`spawn:claude`, `release`, custom) | +| node | an enrolled context in a workspace (a project directory or an application) | +| provider | a process attached to a node that connects directly to the engine and registers capabilities | +| agent runtime | the Rust provider that owns PTYs and registers the node's spawn/release capacity | +| capability provider | a per-language provider that registers invokable actions | +| @agent-relay/harness-driver | client SDK for the agent runtime's PTY engine | +| capacity | what a node can run (`spawn:`, `release`); feeds placement, never invoked | +| action | an invokable capability with a handler hosted by a provider | +| invoke | calling an action (`POST /v1/nodes/:node/actions/:name/invoke`) | + + The node, provider, and capability model, with onboarding. + - Node kinds, registration, and the binding API the SDK and REST exposes. + Provider registration, placement, delivery kinds, and the node API. Durable delivery records, receipts, retries, and delivery modes. - - Registering, discovering, and invoking typed actions over MCP. - - The optional package that drives managed session lifecycle. + The client SDK that drives the agent runtime's PTY engine. diff --git a/web/content/docs/harness-driver.mdx b/web/content/docs/harness-driver.mdx index b8d0c13..9168f60 100644 --- a/web/content/docs/harness-driver.mdx +++ b/web/content/docs/harness-driver.mdx @@ -5,11 +5,9 @@ description: 'Use the optional harness driver package when Agent Relay needs to Core Agent Relay owns messaging, delivery contracts, actions, and events — not spawning. -`@agent-relay/harness-driver` is the client SDK for the **broker-pty-engine** — the generic PTY engine -inside the [broker](/docs/architecture) that spawns and owns agent processes. A caller uses it to spawn a -PTY and drive it. It is used **inside an action runner**: when a node's `spawn` capability fires, the action -runner resolves the harness that capability declares and calls the harness driver to start it, while the -broker-pty-engine hosts the PTY. It is not a standalone service. +`@agent-relay/harness-driver` is the client SDK for the PTY engine inside a node's **agent runtime** — the +Rust provider that spawns and owns agent processes. A caller uses it to spawn a PTY (`{ command, args, env }`) +and drive it, while the agent runtime hosts the process. It is not a standalone service. The optional harness driver package owns managed session lifecycle: @@ -40,8 +38,8 @@ import { BrokerDriver, registerDriverActions } from '@agent-relay/harness-driver const actions = new ActionRegistry(); const relay = await AgentRelay.createWorkspace({ name: 'release-review', actions }); -// The driver owns the managed harness boundary: broker startup, PTY/headless -// spawn, release, and status. +// The driver owns the managed harness boundary: agent-runtime startup, +// PTY/headless spawn, release, and status. const driver = new BrokerDriver(); // Expose that lifecycle as actions (agent.create, agent.release, agent.status). @@ -76,7 +74,7 @@ relay.addListener(relay.action('agent.create').failed(), (event) => { Pass `{ actionPrefix }` to `registerDriverActions` to namespace the actions, and provide any `AgentDriver` implementation; `BrokerDriver` is the built-in one that -manages a local broker. +manages a local node's agent runtime. This keeps agent creation in line with every other capability. Agents call a tool; the harness driver owns the implementation and reports the result through `action.completed`. diff --git a/web/content/docs/nodes-and-providers.mdx b/web/content/docs/nodes-and-providers.mdx new file mode 100644 index 0000000..997c2e9 --- /dev/null +++ b/web/content/docs/nodes-and-providers.mdx @@ -0,0 +1,161 @@ +--- +title: 'Nodes and providers' +description: '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.' +--- + +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:` 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:` 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. + + + + Provider registration, node-addressed invoke, placement, and the node API. + + + The fire-and-forget lifecycle, descriptor shape, and invoking over MCP. + + + How delivery, providers, and spawn flow through the engine. + + + Enroll a node and bring its runtime online from the CLI. + + diff --git a/web/content/docs/nodes.mdx b/web/content/docs/nodes.mdx index 7a04701..fa3a5e1 100644 --- a/web/content/docs/nodes.mdx +++ b/web/content/docs/nodes.mdx @@ -3,29 +3,31 @@ title: 'Nodes' description: 'Nodes are delivery hosts: they describe where an agent session lives and how Relay should route future deliveries to it.' --- -Agents have identities, and identities need a delivery route. A **node** is that route. Every active agent has one, whether it is a live SDK/MCP WebSocket, a long-running broker on another machine, or an HTTP endpoint. +A **node** is an enrolled context in a workspace — usually a project directory, sometimes an application. One machine can host several nodes. Agents and capabilities live on a node, and **providers** attach to it, connect directly to the engine, and register what the node offers. + +Every active agent still has a delivery route: a live SDK/MCP WebSocket, a node's agent runtime on another machine, or an HTTP endpoint. ## Node Kinds | Kind | Use it for | | --- | --- | | `direct_ws` | An implicit one-agent route for an SDK, MCP, or browser client connected directly to Relay. | -| `fleet_ws` | A broker-controlled WebSocket node that can host multiple agents, advertise capabilities, and receive deliveries over `/v1/node/ws`. | +| `fleet_ws` | A node whose providers connect over `/v1/node/ws` to host agents and advertise capabilities. | | `http_push` | An external HTTP receiver. Relay pushes future deliveries for bound agents to the configured URL. | | `poll` | A registered host for integrations that pull work instead of keeping a live socket. | Direct registrations create or refresh their own `direct_ws` node automatically. You do not create one by hand. -## Node Roles +## Providers -Every node also has a **role** that captures how many agents it owns: +A node's abilities come from its **providers** — processes that attach to the node, share its token, and connect directly to the engine. Each declares its own identity: a stable `name` and a per-connection `instance_id`. -| Role | Meaning | +| Provider | What it registers | | --- | --- | -| `broker` | A node that hosts many agents. The machine's broker is a `broker` node; it accepts capabilities and a non-trivial `max_agents`. | -| `direct` | A single self-connected agent — a node of one. A direct registration's implicit node is a `direct` node and binds at most one agent. | +| agent runtime | The node's spawn/release **capacity** and the agents it hosts. A single Rust provider that owns PTYs and reports a non-trivial `max_agents`. | +| capability provider | Invokable **actions** — named handlers written in TypeScript, Python, or Swift. | -Role follows kind: a `fleet_ws` (WebSocket) node, or any node declaring `max_agents` greater than 1, is a `broker`; a one-agent route is `direct`. See [Architecture](/docs/architecture) for how the broker role connects to delivery routing. +A node is online when at least one provider is connected. A single SDK/MCP client that hosts only itself is a node of one. See [Architecture](/docs/architecture) for how providers connect to delivery routing. ## Registering Agents @@ -45,32 +47,27 @@ await assistant.sendMessage({ That direct registration returns a live agent client and binds the identity to an implicit `direct_ws` node. If a node-hosted runtime later binds the same agent to another node, future deliveries follow that binding. Unbinding from an explicit node falls back to a direct route when one exists. -Node brokers register their hosted agents through the node protocol. App servers and webhook-style agents usually register the identity first, then bind it to an `http_push` node. - -## Fleets And Capabilities +A node's agent runtime registers its hosted agents through the node protocol. App servers and webhook-style agents usually register the identity first, then bind it to an `http_push` node. -A **fleet** is the set of nodes in a workspace that advertise capabilities. A node declares what it can do as a list of named **capabilities**, and the engine uses that roster to place spawns and actions. +## Capabilities -A capability has a **kind**: +A node's capabilities are named handlers hosted by its providers. They come in two kinds: | Kind | Meaning | | --- | --- | -| `spawn` | Launches an agent on the node, for example `spawn:claude` or `spawn:codex`. The capability declares the harness it runs. | -| `action` | Runs a handler function on the node, for example a custom `echo` or `work` action. | +| `action` | An invokable handler — for example `run-etl` or a custom `screenshot`. The engine materializes it and dispatches invokes to the registering provider. | +| `capacity` | What the node can run — `spawn:` and `release`. The node's agent runtime registers it for placement and spawn delegation; it is never materialized as an invokable action. | -Capabilities are declared with `defineNode` from `@agent-relay/fleet` and served as a long-lived node. Each capability is keyed by name; `spawn(...)` builds a spawner and `action(...)` builds a handler: +A TypeScript provider declares its actions with `defineNode` from `@agent-relay/fleet`. Each capability is keyed by name; `action(...)` builds a handler that receives the validated `input` and a handler context: -```ts file="builder.node.ts" -import { defineNode, action, spawn } from '@agent-relay/fleet'; +```ts file="agent-relay.ts" +import { defineNode, action } from '@agent-relay/fleet'; import { z } from 'zod'; export default defineNode({ name: 'builder', - maxAgents: 8, capabilities: { - 'spawn:claude': spawn({ harness: 'claude' }), - 'spawn:codex': spawn({ harness: 'codex' }), - 'run:test': action({ input: z.object({ suite: z.string() }) }, async ({ input }) => { + 'run:test': action({ input: z.object({ suite: z.string() }) }, async (input, ctx) => { return { ok: true, suite: input.suite }; }), }, @@ -78,35 +75,35 @@ export default defineNode({ ``` ```bash -agent-relay node up --config ./builder.node.ts # serve this definition as the machine's node -agent-relay fleet nodes # list fleet nodes in the workspace -agent-relay fleet status # local broker + node sidecar status +agent-relay node up --config ./builder.node.ts # serve this definition on the machine's node +agent-relay fleet nodes # list fleet nodes with per-provider liveness +agent-relay fleet status # node and capability health from provider attachment ``` -`node up` auto-discovers an `agent-relay.{ts,js,...}` definition in the project when `--config` is omitted, and always serves the machine's broker as a fleet node. To run a Cloud-managed node, redeem the one-time enrollment token first with `agent-relay cloud enroll --token `, then run `agent-relay node up` — it picks up the persisted enrollment and serves under the enrolled node name. +`node up` auto-discovers an `agent-relay.{ts,js,...}` definition in the project when `--config` is omitted, and serves this context's node and its providers. To run a Cloud-managed node, redeem the one-time enrollment token first with `agent-relay cloud enroll --token `, then run `agent-relay node up` — it picks up the persisted enrollment and serves under the enrolled node name. -The node manifest sent to the engine carries each capability's `name` and `kind`. See [Architecture](/docs/architecture) for how the action runner that hosts these capabilities relates to the broker, and [Harnesses](/docs/harnesses) for how a `spawn` capability resolves the harness it launches. +An action name is unique within a node; a duplicate is rejected at registration, so the provider fails loudly at startup. To wrap the node's native spawn, register an action named `spawn:` — it shadows that capacity and delegates through `ctx.spawnAgent`. See [Nodes and providers](/docs/nodes-and-providers) for capacity and shadowing, and [Harnesses](/docs/harnesses) for how a spawn resolves the harness it launches. ## Placement -When something invokes a `spawn` or `action` capability, the engine **places** it onto a node. Placement considers, in order: +An action is node-addressed: `POST /v1/nodes/:node/actions/:name/invoke` resolves the capability to the provider that registered it and dispatches down that socket. Spawning uses **placement**: when a spawn is invoked without a target, the engine chooses a node whose agent runtime advertises the capacity. Placement considers, in order: -1. **Capability** — the node must advertise the requested capability (`spawn:claude`, `run:test`, …). -2. **Liveness** — the node must be `online`, have `handlers_live: true`, and have sent a heartbeat within the liveness TTL (45 seconds). -3. **Capacity** — `active_agents` plus reserved agents must be below `max_agents` (a `max_agents` of `0` means unbounded). -4. **Least-loaded** — eligible nodes are ordered by reported `load`, then `active_agents`, then name, and the first is chosen. +1. **Capacity** — the node's agent runtime must advertise the requested `spawn:`. +2. **Liveness** — the provider must be `online`, still list the capability in its heartbeat, and have heartbeated within the liveness TTL (45 seconds). +3. **Load** — `active_agents` plus reserved agents must be below the provider's `max_agents` (a `max_agents` of `0` means unbounded). +4. **Least-loaded** — eligible providers are ordered by reported `load`, then `active_agents`, then name, and the first is chosen. ### Targeted Placement -Pass `target_node` to pin the call to a named node. The node must advertise the capability, or the call fails with `capability_mismatch` (409); a node that is unavailable for a retry or cannot reserve capacity fails with `handler_unavailable` (503). A target of `self` routes to the caller's own node — the node that currently owns the calling agent. +Pass `target_node` to pin the spawn to a named node. Its agent runtime must advertise the capacity, or the call fails with `capability_mismatch` (409); a node that is unavailable for a retry or cannot reserve capacity fails with `handler_unavailable` (503). A target of `self` routes to the caller's own node — the node that currently owns the calling agent. ### Untargeted Placement -With no target, the engine picks the least-loaded eligible node. If no live node advertises the capability, the call fails with `handler_unavailable`; a known-but-mismatched target fails with `capability_mismatch`. +With no target, the engine picks the least-loaded eligible node. If no live node advertises the capacity, the call fails with `handler_unavailable`; a known-but-mismatched target fails with `capability_mismatch`. ## Heartbeat And Roster -Each node heartbeats a roster snapshot to the engine so its placement view stays fresh. A heartbeat reports the node's current `load`, `active_agents`, and `handlers_live`, and may also re-send the node's `name`, `capabilities`, `max_agents`, and `version` so the engine can refresh the node descriptor without waiting for a fresh registration. The engine stamps receipt time server-side as the single source of truth for liveness; a node that stops heartbeating past the TTL drops out of placement. +Each provider heartbeats a roster snapshot to the engine so its placement view stays fresh. A heartbeat reports the provider's current `load`, `active_agents`, and the capabilities it still holds live, and may also re-send its `name`, `capabilities`, `max_agents`, and `version` so the engine can refresh the descriptor without waiting for a fresh registration. The engine stamps receipt time server-side as the single source of truth for liveness; a provider that stops heartbeating past the TTL drops its capabilities out of placement. ## HTTP Push Node @@ -177,14 +174,16 @@ The REST API uses the same resources with snake_case fields: - `GET /v1/nodes/:name/agents` - `POST /v1/nodes/:name/agents` - `DELETE /v1/nodes/:name/agents/:agent_name` +- `POST /v1/nodes/:node/actions/:name/invoke` +- `DELETE /v1/nodes/:node/providers/:name` -Node delivery hosts authenticate with `nt_live_*` node tokens. Use the workspace key to create and manage nodes; use the node token only from the delivery host that owns the route. See [Authentication](/docs/authentication) for how node tokens differ from agent and observer tokens. +Node delivery hosts authenticate with `nt_live_*` node tokens. Use the workspace key to create and manage nodes, and the node token from a provider that owns the route; a node-addressed invoke (`POST /v1/nodes/:node/actions/:name/invoke`) uses an agent token, and `DELETE /v1/nodes/:node/providers/:name` retires a provider's attachment and persisted capabilities. See [Authentication](/docs/authentication) for how node tokens differ from agent and observer tokens. ## Enrollment And Identity -A node enrolls with `POST /v1/nodes` using the workspace key. The request carries the node `name` and, for a fleet host, its `capabilities`, `max_agents`, `tags`, and `version`; the response mints a node token (`nt_live_*`). The host then connects `/v1/node/ws` and sends a `node.register` frame with its capabilities to take ownership of its route. A broker mints and persists this node token at startup, scoped to the workspace and engine, so it reuses the same identity across restarts. +A node enrolls with `POST /v1/nodes` using the workspace key. The request carries the node `name` and, for a fleet host, its `capabilities`, `max_agents`, `tags`, and `version`; the response mints a node token (`nt_live_*`). A provider then connects to `/v1/node/ws` and sends a `node.register` frame with its identity and capabilities to take ownership of its attachment. The node's agent runtime mints and persists this node token at startup, scoped to the workspace and engine, so the node reuses the same identity across restarts; all of the node's providers share it. -A node id supplied or pinned by an operator (`node_id` in the enroll request, used with its node token) is taken as-is. Otherwise a broker derives a stable id from the machine identity plus its working directory, so several brokers on one host — for example one per project directory — do not collide. +A node id supplied or pinned by an operator (`node_id` in the enroll request, used with its node token) is taken as-is. Otherwise the id derives from the machine identity, the working directory, and the workspace, so several nodes on one host — for example one per project directory — do not collide. ## Presence And Context @@ -194,7 +193,7 @@ Workspace observers see node presence events as `node.online`, `node.heartbeat`, - The broker stack, action runners, and how spawn flows through placement. + The provider topology, the agent runtime, and how spawn flows through the engine. Node-only delivery, per-agent sequencing, acks, and receipts. diff --git a/web/content/docs/orchestrating-with-actions.mdx b/web/content/docs/orchestrating-with-actions.mdx index 53d90f7..1b79474 100644 --- a/web/content/docs/orchestrating-with-actions.mdx +++ b/web/content/docs/orchestrating-with-actions.mdx @@ -8,7 +8,10 @@ validated result, and a signal that the work is done. Actions provide both. The action whose **input schema is the worker's typed result shape**, assigns the task over messaging, and advances the pipeline from the typed listener predicates on the handle returned by an agent client's `registerAction(...)`. Register on an agent client (not the workspace `relay`): the handler agent's -identity is what exposes the action as an MCP tool that workers can call. +identity is what exposes the action as an MCP tool that workers can call. The invocation model — +validated input, handler run, typed `action.completed` — is the same regardless of where the handler +runs: an agent connection here, or a [node provider](/docs/nodes-and-providers) that hosts a capability +on a machine. ## The shape of the pattern @@ -134,3 +137,7 @@ When the crew winds down, `scores.unregister()` removes the action. See Event handlers for everything addListener can subscribe to. + + + See Nodes and providers to host the same capabilities on a machine. + diff --git a/web/content/docs/reference-cli.mdx b/web/content/docs/reference-cli.mdx index 72691de..ad1a1de 100644 --- a/web/content/docs/reference-cli.mdx +++ b/web/content/docs/reference-cli.mdx @@ -169,7 +169,7 @@ Fleet commands inspect and configure workspace fleet nodes. To serve a node, run | Command | Description | | --- | --- | | `agent-relay fleet nodes [--capability ] [--name ]` | List fleet nodes in the workspace. | -| `agent-relay fleet status` | Show local fleet broker and node sidecar status. | +| `agent-relay fleet status` | Show node and capability health from provider attachment on the engine. | | `agent-relay fleet config` | Show workspace fleet node configuration. | | `agent-relay fleet enable` / `disable` / `inherit` | Enable, disable, or inherit the workspace fleet node setting. | diff --git a/web/lib/docs-nav.ts b/web/lib/docs-nav.ts index d6a78cb..cdeba2b 100644 --- a/web/lib/docs-nav.ts +++ b/web/lib/docs-nav.ts @@ -44,6 +44,7 @@ export const docsNav: NavGroup[] = [ items: [ { title: 'Architecture', slug: 'architecture' }, { title: 'Delivery', slug: 'delivery' }, + { title: 'Nodes and providers', slug: 'nodes-and-providers' }, { title: 'Nodes', slug: 'nodes' }, { title: 'Harnesses', slug: 'harnesses' }, { title: 'Session capabilities', slug: 'session-capabilities' }, diff --git a/web/lib/test/docs-markdown-components.test.ts b/web/lib/test/docs-markdown-components.test.ts index df7ce13..ad2ca4a 100644 --- a/web/lib/test/docs-markdown-components.test.ts +++ b/web/lib/test/docs-markdown-components.test.ts @@ -16,7 +16,7 @@ describe('docs markdown export for MDX components', () => { expect(doc?.markdown).toContain('[Delivery](https://agentrelay.com/docs/delivery)'); expect(doc?.markdown).toContain('[Actions](https://agentrelay.com/docs/actions)'); expect(doc?.markdown).toContain( - '[Start with a workspace, messaging, delivery, and a Zod-backed action.](https://agentrelay.com/docs/quickstart)' + '[Build a workspace with messaging, events, and a typed action in five minutes.](https://agentrelay.com/docs/quickstart)' ); });