RFC: Reframe local / fleet serve as node
Summary
Replace the local command group and fleet serve with a single node command
group. Every running relay is a node on relaycast that serves a fleet of agents —
the current local (where it runs) vs fleet serve (whether it tells the cloud)
split is an artifact, not a real distinction. One verb, relay node up, lights up
more of itself as you add a workspace key, a config file, or cloud enrollment —
none of which require a login.
relay node up [--config agent-relay.ts] # was: local up + fleet serve
relay node down
relay node tail
relay node status
relay node metrics
relay node agent list|spawn|new|release|attach|set-model|message
relay node workflow run|logs|sync
relay fleet nodes|status|config|enable|disable|inherit # workspace-wide; `serve` removed
relay cloud enroll # NEW: redeems enrollment token, persists node creds
relay cloud login|logout|session|whoami|connect|run|schedule|status|logs|sync|cancel
Motivation
local names where the broker runs (here vs. cloud). But the interesting thing
about a running relay isn't its location — it's that it hosts and serves a fleet of
agents as a node in the relaycast mesh.
The split is already cosmetic in the code:
local up (core.ts → runUpCommand in lib/broker-lifecycle.ts) starts the
Rust broker and serves agents — without registering capabilities with the cloud.
fleet serve (fleet.ts → runFleetServe) starts the same broker, then runs a
sidecar that advertises a node definition. When you don't hand it a definition
file it synthesizes an implicit local node out of teams.json — i.e. it
reconstructs exactly what local up does.
These are one concept with options, not two commands. "local" is the misleading
half: a relay that isn't on relaycast is just a dead broker.
The identity model (already true today, no login required)
The Rust broker already does "key if present, else new workspace," with no login on
either branch (crates/broker/src/relaycast/auth.rs:445-471):
- Workspace key present (env
RELAY_WORKSPACE_KEY, rk_…) → join that
workspace, register an agent, self-mint a node token.
- No key →
create_workspace(deterministic_workspace_name()) → a new workspace
named relay-<hash of user:cwd>. Same user + same dir re-derives the same
workspace next run; a fresh dir is a fresh workspace.
The end user thinks about none of this. They run relay node up. If
RELAY_WORKSPACE_KEY is in their env, that's their workspace; if not, they get one.
| Axis |
Controlled by |
Default |
Login? |
| On relaycast? |
always |
yes |
no |
| Which workspace? |
workspace key in env if present, else auto-create relay-<hash> |
new workspace |
no |
| Advertises capabilities? |
agent-relay.* / --config |
none (bare node) |
no |
| Cloud-managed node? |
relay cloud enroll (token minted in dashboard) |
no (self-derived node id) |
yes, to mint the token |
Config / capabilities file
node up auto-discovers agent-relay.{ts,js,…} in the cwd; --config <file>
overrides. This is the relay equivalent of a Dockerfile/Procfile.
The file is the existing defineNode(...) definition (capabilities + triggers +
maxAgents), not just capabilities — hence the general --config name rather than
--capabilities-file. It replaces today's positional fleet serve [file] arg.
Note on polyglot (agent-relay.py, agent-relay.swift): only TS/JS load today
(via jiti → defineNode). Other languages need a defined contract before
agent-relay.py/.swift are real; out of scope for this RFC, flagged so the
filename convention doesn't over-promise.
Enrollment moves under cloud
Enrollment is the one login-touching path, so it belongs in the cloud group next
to login. You log into the Cloud dashboard, run "Enroll node" to mint a one-time
token (ocl_node_enr_…), then:
relay cloud enroll --token ocl_node_enr_…
relay node up # picks up the persisted Cloud-managed node creds
relay cloud enroll redeems the token (the existing enrollFleetNode exchange,
packages/cloud/src/fleet.ts:132) and persists nodeToken / relaycastUrl /
relayWorkspaceId. A later plain node up runs as a Cloud-managed node with a
Cloud-pinned nodeId.
New plumbing this requires
Today enrollment is ephemeral: fleet serve injects RELAY_NODE_TOKEN /
RELAY_BASE_URL into the broker child's env and strips the flags from the
supervised restart argv so the one-time token never re-redeems
(fleet.ts:381-390, 465-484). The creds survive only via the supervision env,
within a single invocation.
Splitting enrollment (cloud enroll) from startup (node up) means the creds must
become persistent: cloud enroll writes them to a durable file, and node up's
broker startup resolves them from there. The broker already has a node-token cache
(dirs::data_local_dir()/agent-relay/node-tokens/<node_id>.json) and an
RELAY_NODE_TOKEN override path (init.rs:654-728), so this is "write the cache
enroll-side, read it boot-side," not a new auth system. This is the only genuinely
new behavior; everything else is a rename.
SDK parity (everything node does must be expressible through the SDK)
Per the dogfood rule, the node CLI should be a thin wrapper over the SDK, and
external consumers must be able to do everything node up does programmatically.
Today there are two gaps.
Consumer reality check — ../pear (pear-by-agent-relay, the Electron pairing
workspace): it depends on @agent-relay/sdk (and @agent-relay/harness-driver),
not @agent-relay/fleet. It does not use defineNode at all — it spawns agents
imperatively via HarnessDriverClient.spawnPty(...) and hardcodes its capabilities
(['spawn:claude','spawn:codex']). Its declarative relay-backed path
(RelayFleetClient) is an unimplemented stub:
// TODO(relay#1056): implement this over the ../relay SDK once the fleet protocol lands there.
This RFC should deliver relay#1056.
Gap 1 — authoring API isn't on the SDK umbrella
defineNode, action, spawn, onMessage, and the types (FleetNodeDefinition,
FleetCapability, …) are exported only from @agent-relay/fleet
(packages/fleet/src/index.ts). @agent-relay/sdk does not re-export them and
doesn't even depend on @agent-relay/fleet. So a consumer that has @agent-relay/sdk
(like pear) cannot import { defineNode } from '@agent-relay/sdk'.
Proposal: re-export the @agent-relay/fleet authoring surface from
@agent-relay/sdk so import { defineNode, spawn, action, onMessage } from '@agent-relay/sdk'
works. @agent-relay/fleet stays the implementation home; the SDK is the public
umbrella.
Gap 2 — no programmatic serve
Bringing a node up is CLI-only. The runtime (serveFleetSidecar /
startFleetSidecar, packages/cli/src/cli/lib/fleet-sidecar.ts) lives inside the
CLI package and isn't published. @agent-relay/fleet is purely declarative — it
defines a node but ships no connection/serve loop.
Proposal: publish a programmatic serve entry — serveNode(definition, options)
— by promoting the sidecar runtime out of the CLI into @agent-relay/fleet (exposed
via @agent-relay/sdk). It returns a handle (stop(), status/events), applies the
same identity model (workspace key from env, else auto-create; persisted enrollment
creds), and accepts the same config. Then:
relay node up becomes a thin CLI wrapper over serveNode(...) — dogfooding.
- pear's
RelayFleetClient can be implemented on serveNode + defineNode,
declaring spawn:claude / spawn:codex instead of hardcoding them — closing
relay#1056.
Backwards compatibility
- Keep
local as a deprecated hidden alias of node for now: local up warns
and forwards to node up, etc. Docs, examples, and the orchestrating-agent-relay
skill all reference local, plus user muscle memory and scripts. Remove in a
later major.
fleet serve is removed; its enrollment + implicit-node behavior is absorbed by
node up + cloud enroll. (Consider a deprecation alias for fleet serve too,
routing to node up.)
fleet keeps only the workspace-wide verbs (nodes, status, config,
enable, disable, inherit).
Mental model
node = this machine's instance — lifecycle (up/down/tail/agent). Singular, mine.
fleet = the workspace's collection of nodes — inspect/govern. Plural, everyone's.
cloud = account, auth, enrollment, workflow runs.
You bring your node up; you inspect the fleet.
Implementation sketch
- Add
registerNodeCommands(program) mirroring registerCoreCommands +
registerLocalAgentCommands + registerLocalWorkflowCommands under a node
group (packages/cli/src/cli/bootstrap.ts).
- Fold
fleet serve's sidecar/implicit-node logic into node up so a present
agent-relay.* / --config advertises capabilities; absent = bare node.
node up cwd auto-discovery of agent-relay.*; --config override.
- Add
relay cloud enroll (redeem + persist node creds); make broker startup
read persisted enrollment creds.
- Promote the sidecar runtime out of the CLI into
@agent-relay/fleet as
serveNode(definition, options); re-export the @agent-relay/fleet authoring
surface from @agent-relay/sdk; make node up a thin wrapper over serveNode
(relay#1056).
- Register
local (and optionally fleet serve) as deprecated aliases that warn +
forward.
- Update docs (
web/content/docs/*.mdx, web/lib/docs-nav.ts), examples
(examples/relay-node.ts), and the orchestrating-agent-relay skill.
Open questions
- Persist enrollment creds in the existing HOME-scoped
node-tokens/ cache, or a
dedicated enrollment file?
- Deprecation window length before
local is removed.
- Should
fleet serve get its own forwarding alias, or just removal + a clear error?
serveNode home: @agent-relay/fleet (re-exported via @agent-relay/sdk) vs.
living directly in @agent-relay/sdk. Promoting the sidecar out of the CLI may
pull CLI-only deps into the package — needs a dependency audit.
- Re-export the whole
@agent-relay/fleet surface from the SDK, or curate a subset?
RFC: Reframe
local/fleet serveasnodeSummary
Replace the
localcommand group andfleet servewith a singlenodecommandgroup. Every running relay is a node on relaycast that serves a fleet of agents —
the current
local(where it runs) vsfleet serve(whether it tells the cloud)split is an artifact, not a real distinction. One verb,
relay node up, lights upmore of itself as you add a workspace key, a config file, or cloud enrollment —
none of which require a login.
Motivation
localnames where the broker runs (here vs. cloud). But the interesting thingabout a running relay isn't its location — it's that it hosts and serves a fleet of
agents as a node in the relaycast mesh.
The split is already cosmetic in the code:
local up(core.ts→runUpCommandinlib/broker-lifecycle.ts) starts theRust broker and serves agents — without registering capabilities with the cloud.
fleet serve(fleet.ts→runFleetServe) starts the same broker, then runs asidecar that advertises a node definition. When you don't hand it a definition
file it synthesizes an implicit local node out of
teams.json— i.e. itreconstructs exactly what
local updoes.These are one concept with options, not two commands. "local" is the misleading
half: a relay that isn't on relaycast is just a dead broker.
The identity model (already true today, no login required)
The Rust broker already does "key if present, else new workspace," with no login on
either branch (
crates/broker/src/relaycast/auth.rs:445-471):RELAY_WORKSPACE_KEY,rk_…) → join thatworkspace, register an agent, self-mint a node token.
create_workspace(deterministic_workspace_name())→ a new workspacenamed
relay-<hash of user:cwd>. Same user + same dir re-derives the sameworkspace next run; a fresh dir is a fresh workspace.
The end user thinks about none of this. They run
relay node up. IfRELAY_WORKSPACE_KEYis in their env, that's their workspace; if not, they get one.relay-<hash>agent-relay.*/--configrelay cloud enroll(token minted in dashboard)Config / capabilities file
node upauto-discoversagent-relay.{ts,js,…}in the cwd;--config <file>overrides. This is the relay equivalent of a
Dockerfile/Procfile.The file is the existing
defineNode(...)definition (capabilities + triggers +maxAgents), not just capabilities — hence the general
--configname rather than--capabilities-file. It replaces today's positionalfleet serve [file]arg.Note on polyglot (
agent-relay.py,agent-relay.swift): only TS/JS load today(via jiti →
defineNode). Other languages need a defined contract beforeagent-relay.py/.swiftare real; out of scope for this RFC, flagged so thefilename convention doesn't over-promise.
Enrollment moves under
cloudEnrollment is the one login-touching path, so it belongs in the
cloudgroup nextto
login. You log into the Cloud dashboard, run "Enroll node" to mint a one-timetoken (
ocl_node_enr_…), then:relay cloud enrollredeems the token (the existingenrollFleetNodeexchange,packages/cloud/src/fleet.ts:132) and persistsnodeToken/relaycastUrl/relayWorkspaceId. A later plainnode upruns as a Cloud-managed node with aCloud-pinned
nodeId.New plumbing this requires
Today enrollment is ephemeral:
fleet serveinjectsRELAY_NODE_TOKEN/RELAY_BASE_URLinto the broker child's env and strips the flags from thesupervised restart argv so the one-time token never re-redeems
(
fleet.ts:381-390,465-484). The creds survive only via the supervision env,within a single invocation.
Splitting enrollment (
cloud enroll) from startup (node up) means the creds mustbecome persistent:
cloud enrollwrites them to a durable file, andnode up'sbroker startup resolves them from there. The broker already has a node-token cache
(
dirs::data_local_dir()/agent-relay/node-tokens/<node_id>.json) and anRELAY_NODE_TOKENoverride path (init.rs:654-728), so this is "write the cacheenroll-side, read it boot-side," not a new auth system. This is the only genuinely
new behavior; everything else is a rename.
SDK parity (everything
nodedoes must be expressible through the SDK)Per the dogfood rule, the
nodeCLI should be a thin wrapper over the SDK, andexternal consumers must be able to do everything
node updoes programmatically.Today there are two gaps.
Consumer reality check —
../pear(pear-by-agent-relay, the Electron pairingworkspace): it depends on
@agent-relay/sdk(and@agent-relay/harness-driver),not
@agent-relay/fleet. It does not usedefineNodeat all — it spawns agentsimperatively via
HarnessDriverClient.spawnPty(...)and hardcodes its capabilities(
['spawn:claude','spawn:codex']). Its declarative relay-backed path(
RelayFleetClient) is an unimplemented stub:// TODO(relay#1056): implement this over the ../relay SDK once the fleet protocol lands there.This RFC should deliver relay#1056.
Gap 1 — authoring API isn't on the SDK umbrella
defineNode,action,spawn,onMessage, and the types (FleetNodeDefinition,FleetCapability, …) are exported only from@agent-relay/fleet(
packages/fleet/src/index.ts).@agent-relay/sdkdoes not re-export them anddoesn't even depend on
@agent-relay/fleet. So a consumer that has@agent-relay/sdk(like pear) cannot
import { defineNode } from '@agent-relay/sdk'.Proposal: re-export the
@agent-relay/fleetauthoring surface from@agent-relay/sdksoimport { defineNode, spawn, action, onMessage } from '@agent-relay/sdk'works.
@agent-relay/fleetstays the implementation home; the SDK is the publicumbrella.
Gap 2 — no programmatic serve
Bringing a node up is CLI-only. The runtime (
serveFleetSidecar/startFleetSidecar,packages/cli/src/cli/lib/fleet-sidecar.ts) lives inside theCLI package and isn't published.
@agent-relay/fleetis purely declarative — itdefines a node but ships no connection/serve loop.
Proposal: publish a programmatic serve entry —
serveNode(definition, options)— by promoting the sidecar runtime out of the CLI into
@agent-relay/fleet(exposedvia
@agent-relay/sdk). It returns a handle (stop(), status/events), applies thesame identity model (workspace key from env, else auto-create; persisted enrollment
creds), and accepts the same config. Then:
relay node upbecomes a thin CLI wrapper overserveNode(...)— dogfooding.RelayFleetClientcan be implemented onserveNode+defineNode,declaring
spawn:claude/spawn:codexinstead of hardcoding them — closingrelay#1056.
Backwards compatibility
localas a deprecated hidden alias ofnodefor now:local upwarnsand forwards to
node up, etc. Docs, examples, and theorchestrating-agent-relayskill all reference
local, plus user muscle memory and scripts. Remove in alater major.
fleet serveis removed; its enrollment + implicit-node behavior is absorbed bynode up+cloud enroll. (Consider a deprecation alias forfleet servetoo,routing to
node up.)fleetkeeps only the workspace-wide verbs (nodes,status,config,enable,disable,inherit).Mental model
node= this machine's instance — lifecycle (up/down/tail/agent). Singular, mine.fleet= the workspace's collection of nodes — inspect/govern. Plural, everyone's.cloud= account, auth, enrollment, workflow runs.You bring your node up; you inspect the fleet.
Implementation sketch
registerNodeCommands(program)mirroringregisterCoreCommands+registerLocalAgentCommands+registerLocalWorkflowCommandsunder anodegroup (
packages/cli/src/cli/bootstrap.ts).fleet serve's sidecar/implicit-node logic intonode upso a presentagent-relay.*/--configadvertises capabilities; absent = bare node.node upcwd auto-discovery ofagent-relay.*;--configoverride.relay cloud enroll(redeem + persist node creds); make broker startupread persisted enrollment creds.
@agent-relay/fleetasserveNode(definition, options); re-export the@agent-relay/fleetauthoringsurface from
@agent-relay/sdk; makenode upa thin wrapper overserveNode(relay#1056).
local(and optionallyfleet serve) as deprecated aliases that warn +forward.
web/content/docs/*.mdx,web/lib/docs-nav.ts), examples(
examples/relay-node.ts), and theorchestrating-agent-relayskill.Open questions
node-tokens/cache, or adedicated enrollment file?
localis removed.fleet serveget its own forwarding alias, or just removal + a clear error?serveNodehome:@agent-relay/fleet(re-exported via@agent-relay/sdk) vs.living directly in
@agent-relay/sdk. Promoting the sidecar out of the CLI maypull CLI-only deps into the package — needs a dependency audit.
@agent-relay/fleetsurface from the SDK, or curate a subset?