Guides
Scale out with worker nodes
Run agent execution on multiple machines: start HTTP worker nodes
on port 7999, health-check them from a coordinator, and let
DistributedExecutor dispatch every task to the least-loaded healthy node —
with worker-level circuit breakers handling the failover.
GET /health, a coordinator that routes submit() calls across remote and local nodes, and automatic failover when a worker's circuit opens.REPLICATE_API_TOKEN exported on each worker, and the [http] extra (uvicorn) installed on worker hosts.Route map
-
Start a worker
Launch the HTTP worker server with
replicate-agent workers startorserve_worker(). -
Health-check from the coordinator
Confirm reachability with
curl /healthandreplicate-agent workers pingbefore writing any coordinator code. -
Build the coordinator
Wire
RemoteWorkerNode+HttpWorkerTransportinto aDistributedExecutor, with a local fallback node. -
Verify failover
Understand how worker circuit breakers report state via
/healthand how routing skips OPEN nodes.
Coordinator and workers: the topology
The distributed layer has exactly two roles. A worker executes agent
invocations — it owns an AgentExecutor and talks to the Replicate API. A
coordinator owns a DistributedExecutor and decides which
worker gets each task. Workers come in two flavors:
- Local nodes —
WorkerNodeinstances that run in the coordinator's own process on asyncio queues. Required: anode_id(auto-generated if omitted). Optional:max_queue_depth(default 100) andconcurrency(default 4). - Remote nodes —
RemoteWorkerNodeinstances that delegate over HTTP through anHttpWorkerTransportto a worker server on another machine.
Dispatch is least-loaded: every node exposes a load metric
(active tasks plus queued tasks for local nodes; in-flight requests for remote ones), and
submit() picks the healthy node with the lowest value across both pools. There
is no separate scheduler process to deploy — the coordinator is just your Python program.
Step 1 — Start a worker
On each worker machine, export the token and start the server. The CLI route needs the
[http] extra for uvicorn:
export REPLICATE_API_TOKEN=r8_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
replicate-agent workers start --host 0.0.0.0 --port 7999 --node-id gpu-node-1 --concurrency 8
All flags shown are optional — --host 0.0.0.0, --port 7999,
and --concurrency 8 are the defaults; only --node-id is worth
setting explicitly (it defaults to worker-<pid>). The same server is
available programmatically, which is how you would embed it in your own service:
import asyncio
from replicate_mcp.worker_server import serve_worker
asyncio.run(serve_worker(host="0.0.0.0", port=7999))
serve_worker() also accepts node_id,
max_concurrency=8, enable_circuit_breaker=True, and an optional
circuit_config. Either way the worker exposes three HTTP endpoints:
POST /execute— run an agent invocation, return aTaskResultas JSON.GET /health— liveness probe; includes circuit-breaker state, and returns503when the circuit is OPEN.GET /metrics— load counters:active_tasks,total_processed, plus circuit metrics.
Step 2 — Health-check from the coordinator
Before writing coordinator code, prove the network path works from the coordinator
machine. Raw curl shows you exactly what the transport will see:
curl http://gpu-node-1:7999/health
The friendlier CLI wrapper does the same check plus a metrics read:
replicate-agent workers ping http://gpu-node-1:7999
Step 3 — Build the coordinator
The coordinator combines remote and local nodes in one pool. Save as
coordinator.py (this assumes the llama_chat agent from the
Quickstart is registered on the workers):
import asyncio
from replicate_mcp.distributed import (
DistributedExecutor,
HttpWorkerTransport,
RemoteWorkerNode,
WorkerNode,
)
async def main():
async with DistributedExecutor() as executor:
# Remote GPU workers — HTTP transport, 120 s default request timeout
for host in ("gpu-node-1", "gpu-node-2"):
transport = HttpWorkerTransport(f"http://{host}:7999")
executor.add_remote_node(RemoteWorkerNode(host, transport=transport))
# Local in-process fallback node
executor.add_node(WorkerNode("local-fallback", concurrency=4))
handle = await executor.submit("llama_chat", {"prompt": "Say hello in 5 words"})
result = await handle # TaskResult
print(result.node_id, result.status.value, f"{result.elapsed_ms:.0f}ms")
for chunk in result.chunks:
if chunk.get("done"):
print(chunk.get("output"))
asyncio.run(main())
python coordinator.py
submit() returns a TaskHandle — a future you can hold while
submitting more work, then await for the TaskResult. The result
carries task_id, agent_name, node_id (which machine
ran it), chunks (the streamed output), status
(pending / running / done / failed),
error, and elapsed_ms. For batches,
await executor.run_many([("llama_chat", {...}), ...]) submits concurrently and
returns results in input order; executor.stream(...) yields chunks from a
single task as an async iterator.
Two failure modes are worth knowing by name: NodeOverloadError means a
local node's queue hit max_queue_depth (the executor retries on another node
up to max_retries=2 times before raising), and
NoHealthyNodesError means the candidate pool is empty — every node is
unhealthy or circuit-open.
Step 4 — Worker circuit breakers and failover
Since v0.8.0 each worker server tracks its own reliability with a
WorkerCircuitBreaker (enabled by default; same
CLOSED → OPEN → HALF_OPEN machine described in
Resilience & caching, applied to the whole
worker rather than one model). The state is serialized as a
WorkerCircuitState — the circuit object you saw in the
/health response — so coordinators can make routing decisions without any
shared infrastructure:
- When the worker's circuit is OPEN,
GET /healthreturns503andPOST /executerejects new tasks with503. - The coordinator checks circuit state before dispatch (
RemoteWorkerNode.check_circuit_state()) and skips OPEN nodes entirely, raisingWorkerCircuitOpenErrorinternally and routing to the next healthy node. - HALF_OPEN nodes stay in the pool but their load is multiplied by 1.5 during selection, so probe traffic trickles back instead of flooding a recovering worker.
To force a failover test, stop one worker (Ctrl+C on its host) and re-run
coordinator.py: the printed node_id moves to a surviving node,
and once nothing remote is healthy, to local-fallback.
Verification
You're done when
replicate-agent workers ping http://gpu-node-1:7999reports healthy with task counters.curl /healthfrom the coordinator returns200with acircuitobject whosestateisclosed.python coordinator.pyprints a remotenode_idanddonestatus.- Killing one worker and re-running routes the task to another node instead of raising
NoHealthyNodesError.
Common mistakes
| Symptom | Cause | Fix |
|---|---|---|
Worker unreachable from the coordinator (ping fails, curl times out) |
Worker bound to 127.0.0.1, or a firewall blocks TCP 7999 between the machines |
Start with --host 0.0.0.0 (the default — check you did not override it) and open port 7999 on the worker's firewall / security group |
NoHealthyNodesError on every submit() |
All remote circuits are OPEN (workers are failing) and no local node is registered | Add an in-process WorkerNode fallback; check worker logs for the underlying failures and wait out recovery_timeout (60 s) |
| Tasks pile up on one node while others idle | A proxy or firewall in front of the workers blocks GET /metrics and GET /health, so the coordinator's load and health view is stale |
Allow both read endpoints end-to-end; confirm with curl /metrics that active_tasks changes while a task runs |
Next steps
- Troubleshooting — runbook entries for unreachable workers and open circuits.
- Plugins & observability — export per-node metrics and traces to your collector.
- Python API reference — full signatures for
DistributedExecutor,WorkerNode, and the transports.