Skip to main content

Go Relay: Multi-Location Control Plane

Go Relay is the orchestration layer that sits above go-code's execution runtime. While a single harnessd instance executes one run inside one workspace, Relay answers a higher-level question: where, what, and how should each run execute across a fleet of registered workers?

Think of go-code as a capable executor and Relay as the dispatcher. You register workers — processes running on your laptop, in containers, on VMs, or in sandboxes — and Relay decides which worker is best suited for each piece of work, then hands the work off with a fully specified RunContract that describes the prompt, the workspace, the granted capabilities, and the expected outputs.

Go Relay is a library-level control-plane design, not a shipped multi-region service. Today only worker registration and heartbeats are exposed over HTTP. Placement routing, run contract composition, handoff, and the cloud worker pool exist as in-memory Go packages and are not yet network-accessible. This page documents what exists in the codebase and clearly marks what is deferred.


What Relay owns vs. what go-code owns​

The division of responsibility is intentional and stable:

Go Relay owns

  • Worker registration and heartbeats
  • Capability inventory and policy enforcement
  • Placement routing (where does this run go?)
  • Run contract composition (what does the worker receive?)
  • Checkpointed handoff between workers
  • Event, log, and artifact relay

go-code owns

  • Executing one run inside one workspace
  • Provider and model calls
  • Tool execution and sandbox enforcement
  • Event emission and conversation history
  • Workspace lifecycle (provision, destroy)
  • Replay and continuation

A direct go-code workflow — the TUI, single-shot CLI, daemon mode, run control, replay, search — continues to work without Go Relay. Relay is an additive layer for multi-worker scenarios.


Workers and capabilities​

Worker model​

A worker is any process that can accept and execute a run contract. Workers register themselves with the Relay control plane over HTTP and stay alive by sending periodic heartbeats.

Each worker carries these key attributes:

FieldTypeDescription
idstringUnique worker identifier
tenant_idstringTenant this worker belongs to
namestringHuman-readable name
location_typestringWhere the worker runs (see table below)
statusstringCurrent availability state
trust_tierstringPermission level granted to this worker
loadintCurrent concurrency count (0 = idle)
supported_workspace_modes[]stringWorkspace backends this worker supports
last_heartbeattimeTimestamp of the most recent heartbeat

Location types​

ValueMeaning
"local"Developer's own machine
"worktree"Isolated git worktree on the same machine
"container"Docker container
"vm"Cloud or local virtual machine
"sandbox"Restricted execution sandbox

Worker status values​

ValueMeaning
"online"Connected and accepting new runs
"offline"Explicitly disconnected
"stale"No heartbeat received within 30 seconds (StaleDuration)
"draining"Finishing current work, not accepting new runs

Trust tiers​

ValueMeaning
"untrusted"Minimal permissions; destructive tools are denied by default
"standard"Normal operation permissions
"privileged"Full permissions, including cross-surface output; requires admin scope to assign

Setting trust_tier to "privileged" requires the admin API scope. Standard write tokens (runs:write) cannot promote a worker to privileged.

Stale detection​

Workers that have not heartbeated within 30 seconds transition to "stale" when MarkStaleWorkers is called. There is no background sweeper in the current codebase — callers or operators must invoke MarkStaleWorkers explicitly. The ListWorkers query excludes stale workers by default unless you filter by status explicitly.

Capability inventory vs. capability pack​

The capability system separates what a worker can provide from what a specific run receives:

  • A CapabilityInventory is everything a worker advertises. It is keyed by WorkerID. Examples of capability types: "tool", "mcp_server", "memory", "repo", "workspace_mode", "secret", "output_surface", "browser", "docker".

  • A CapabilityPack is the bounded subset of capabilities actually granted to one specific run contract. It is keyed by RunID. Every capability in a pack must be explicitly approved — inventory is never automatically inherited.

Secret handling: SecretCapability stores only a reference (Ref) plus non-sensitive metadata (name, scope, provider), never the actual secret value. The SanitizeInventoryForDisplay function redacts repository paths and secret refs for non-local workers, and redacts any secret Ref longer than 128 characters (replacing it with a placeholder) for display.


Placement and run contracts​

Three-phase placement​

When a run needs to be dispatched, the PlacementRouter selects a worker in three phases:

  1. Hard constraints — workers that fail any mandatory check are immediately rejected. Checks include: wrong status, insufficient trust tier, wrong location type, LocalOnly flag set, missing required workspace modes, missing required capabilities.

  2. Soft scoring — each surviving worker receives a base score of 100 plus bonuses:

    ConditionBonus
    Worker is local (default local bias)+5
    PreferLocal requested and worker is local+25
    PreferCleanWorkspace and worker is non-local+25
    PreferCloudForLongRunning and worker is vm, sandbox, or container+20
    Worker load == 0 (idle)+10
    Worker load < 3 (lightly loaded)+5
    Worker is privileged+5
  3. Select best — the highest-scoring worker wins. Ties are broken by worker ID for determinism.

When no worker passes hard constraints, the placement fails with a PlacementRecord that explains every rejection. Rejection categories are: "offline", "tenant", "capability", "trust", "location", "workspace", "repo".

Every placement decision is recorded in a PlacementRecord that includes the selected worker, all eligible workers, all rejected workers with reasons, a routing reason string, and per-worker soft-score breakdowns.

Run contracts​

A RunContract is the fully specified unit of work that Relay hands to a worker. It is produced by the Composer from a user or connector request and includes:

FieldDescription
IDGenerated by Relay; format: "rc-" followed by 24 hex characters
PromptThe task prompt
SourceTrigger origin: "api", "cron", "github", "linear", or "slack"
WorkspaceTarget workspace mode: "local", "worktree", "container", "vm", or "sandbox"
CapabilitiesThe CapabilityPack granted to this run
PermissionsPermission set (sandbox scope, approval policy)
LimitsCost, step, and time limits
OutputsExpected output types: "artifact", "approval", "comment", "patch", "pr", "summary"
MobilityHow the run can move between workers (see below)
MetadataTenant, agent ID, labels
ContextHintAdditional context up to 64 KiB; longer inputs are truncated

The Composer applies sensible defaults: workspace mode defaults to "local" when not specified; api-sourced runs without explicit outputs receive a summary output expectation (type: "summary", format: "markdown") automatically.

Mobility classes​

Mobility determines whether and how a run can be transferred to a different worker mid-execution:

ValueCan be handed off?Notes
"pinned"NoRun stays on its original worker
"resumable"YesDefault; work can be picked up by another worker
"cloneable"YesState can be copied to a new worker
"ephemeral"NoState is not preserved after execution

The default mobility when unspecified is "resumable".

Capability policy defaults​

The default CapabilityPolicy (created with NewCapabilityPolicy) enforces:

  • DenyUntrustedTools: untrusted workers cannot use "bash:destructive", "git:push", "write:outside_workspace", or "network:outbound".
  • DenyRemoteSecrets: true — org-scoped secrets cannot be sent to non-local, non-privileged workers.
  • DenyCrossTenantMemory: true — memory cannot cross tenant boundaries.
  • RequireExplicitOutputSurface: true — every run must declare its output surface.

For non-local-only runs, ["bash:destructive", "git:push", "write:outside_workspace"] require explicit approval by default.


What ships today​

The table below distinguishes between capabilities that are HTTP-exposed (usable via curl or any HTTP client today) and capabilities that exist as in-memory Go library code (usable by embedding Go packages, but not via network API).

CapabilityStatusNotes
Worker CRUD + heartbeatHTTP-exposed/v1/relay/workers routes; requires HARNESS_RELAY_DB for persistence
Worker persistence (SQLite)HTTP-exposedEnabled by setting HARNESS_RELAY_DB
Placement routerIn-memory libraryPlacementRouter exists in internal/relay/; no HTTP route
Run contract composerIn-memory libraryComposer exists in internal/relay/; no HTTP route
Checkpointed handoffIn-memory libraryHandoffManager exists; packages are in-memory only, lost on restart
Cloud worker poolIn-memory libraryCloudWorkerConfig exists; no auto-provisioning or HTTP route
Transport layerIn-memory libraryNo network transport; worker-to-relay communication is in-process only
Operator UX surfaceIn-memory libraryOperatorUX exists; no HTTP route
Event/artifact storeIn-memory librarySQLite implementation exists; not wired to HTTP routes yet

HTTP routes: /v1/relay/workers​

All relay worker routes require HARNESS_RELAY_DB to be set. Without it, these endpoints return 501 Not Implemented.

MethodPathScopeDescription
GET/v1/relay/workersruns:readList workers; filter by status, location_type, trust_tier, tenant_id
POST/v1/relay/workersruns:writeRegister a new worker
GET/v1/relay/workers/{id}runs:readGet a worker by ID
PUT/v1/relay/workers/{id}runs:writeUpdate worker fields
DELETE/v1/relay/workers/{id}runs:writeDeregister a worker
POST/v1/relay/workers/{id}/heartbeatruns:writeSubmit a heartbeat (load and status in body)

Heartbeat status must be "online" or "draining" — sending "stale" or "offline" as a heartbeat status is rejected.

Workers are tenant-scoped. Requests can only see workers belonging to the authenticated tenant.

Enabling worker persistence​

Set HARNESS_RELAY_DB to a file path before starting harnessd:

export HARNESS_RELAY_DB=/var/lib/harness/relay.db
go run ./cmd/harnessd

The SQLite store uses WAL mode (PRAGMA journal_mode=WAL), a 5-second busy timeout, and foreign key enforcement. It creates a single relay_workers table with indexes on tenant_id, status, location_type, and last_heartbeat.

Registering a worker and sending a heartbeat​

The following example uses curl against a locally running harnessd. It assumes auth is disabled (HARNESS_AUTH_DISABLED=true) for simplicity — see Authentication for production token usage.

# Register a worker (id is required — the server never generates one)
curl -s -X POST http://localhost:8080/v1/relay/workers \
-H "Content-Type: application/json" \
-d '{
"id": "dev-laptop-1",
"name": "dev-laptop",
"location_type": "local",
"trust_tier": "standard",
"supported_workspace_modes": ["local", "worktree"]
}' | jq .

# The response echoes the worker you registered (with the id you supplied), e.g.:
# { "id": "dev-laptop-1", "status": "online", "last_heartbeat": "...", ... }

# Send a heartbeat
curl -s -X POST http://localhost:8080/v1/relay/workers/dev-laptop-1/heartbeat \
-H "Content-Type: application/json" \
-d '{
"load": 0,
"status": "online"
}' | jq .

# List registered workers
curl -s "http://localhost:8080/v1/relay/workers" | jq .

# Filter by location type
curl -s "http://localhost:8080/v1/relay/workers?location_type=local" | jq .

# Deregister the worker
curl -s -X DELETE http://localhost:8080/v1/relay/workers/dev-laptop-1

Deferred capabilities​

These capabilities are designed and implemented as Go packages but are not yet connected to HTTP routes. Issue references are from the design document.

The transport protocol for worker-to-relay communication (WebSocket, long-poll, or SSE plus command channel) is explicitly deferred. Secret resolution at execution time is tracked in issue #686. Cloud auto-provisioning (Relay provisioning VMs and containers itself) is tracked in issue #684. The HandoffManager uses in-memory storage only — handoff packages do not survive a restart.

Checkpointed handoff — the HandoffManager can create a HandoffPackage capturing a run's conversation summary, current todos, patch refs, artifact refs, workspace fingerprint, and non-portable state notes. The package records lineage (which worker ran which phase) and checks mobility class, tenant isolation, and target worker availability before allowing a transfer. This works today in Go unit tests but has no HTTP surface and no SQLite persistence.

Cloud worker pool — CloudWorkerConfig supports "hetzner", "aws", "gcp", and "sandbox" providers with cost hints (e.g., "~$0.05/hr" for a VM) and risk hints. The IsCloudWorker and IsLocalWorkerType helpers classify workers. Auto-provisioning logic is deferred.

Operator UX — OperatorUX provides sorted worker summaries, per-run placement explanations, sanitized capability views, and artifact references for building dashboards. Not yet HTTP-exposed.


Next steps​

  • Run a workflow — see the Script Workflows API page for composing multi-agent pipelines that the placement router can eventually dispatch.
  • Understand workspaces — the Workspaces page documents the local, worktree, container, and vm backends that map to Relay's location types.
  • Configure persistence — the Configuration reference covers all HARNESS_* environment variables including HARNESS_RELAY_DB.
  • HTTP API reference — the full route table is in the HTTP API reference.