Skip to content

RFC: Reframe local / fleet serve as node #1204

Description

@willwashburn

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

  1. Add registerNodeCommands(program) mirroring registerCoreCommands +
    registerLocalAgentCommands + registerLocalWorkflowCommands under a node
    group (packages/cli/src/cli/bootstrap.ts).
  2. Fold fleet serve's sidecar/implicit-node logic into node up so a present
    agent-relay.* / --config advertises capabilities; absent = bare node.
  3. node up cwd auto-discovery of agent-relay.*; --config override.
  4. Add relay cloud enroll (redeem + persist node creds); make broker startup
    read persisted enrollment creds.
  5. 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).
  6. Register local (and optionally fleet serve) as deprecated aliases that warn +
    forward.
  7. 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?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions