Run multiple agents (multi-key / multi-process)¶
The SDK's runtime is a process-scoped singleton — transport pool,
WebSocket subscription, and event batch buffer — created lazily on
the first @protect call. The supported pattern is one runtime per
process and one process per workflow key.
For everything beyond a single one-shot script, run one process per key. This page shows the three patterns that cover real workloads.
Pattern 1 — multiple agents on one host (one process per key)¶
The simplest production deployment. Each workflow gets its own
process, its own env var, its own log stream. Common supervisors
include systemd, Docker Compose, and Kubernetes — pick whichever
fits the platform. The rule is the same regardless of supervisor:
each process gets its own NULLRUN_API_KEY so the dashboard's
Workflows view shows separate per-workflow spend, kill/pause
works independently, and you can restart one without affecting the
other.
Pattern 2 — fan-out inside one container (multiprocessing.Pool)¶
When you have one entrypoint but N workflows to run, use
multiprocessing.Pool so each child initializes its own SDK runtime
and its own key:
import multiprocessing as mp
import nullrun
from nullrun import protect
def _agent_main(key: str, prompt: str) -> str:
# Each child process lazily initializes its own runtime from
# NULLRUN_API_KEY on the first @protect call. To force a specific
# key per child, set NULLRUN_API_KEY in the subprocess env.
import os
os.environ["NULLRUN_API_KEY"] = key
@protect
def step(p: str) -> str:
return your_llm_call(p)
return step(prompt)
def fan_out(jobs: list[tuple[str, str]]) -> list[str]:
# jobs is [(key, prompt), ...] — one key per workflow.
with mp.Pool(processes=len(jobs)) as pool:
async_results = [
pool.apply_async(_agent_main, args=(k, p))
for k, p in jobs
]
return [r.get(timeout=120) for r in async_results]
Each child gets its own copy of the SDK state, so each runtime initializes lazily and cleanly with no shutdown collisions. Do not share a runtime across children — that's the multi-key-in-one- process anti-pattern and you'll get shutdown warnings the moment the first child finishes.
Pick the pool start method that matches your platform (see the
multiprocessing docs). The rule is the same regardless: each child
is a fresh interpreter and initializes its own runtime on the first
@protect call.
Pattern 3 — one entrypoint, multiple keys, hard process boundary¶
If you have one CLI / API server that needs to handle requests for many workflows, route at the process level rather than the function level:
import os
import subprocess
def run_workflow(key: str, prompt: str) -> str:
"""Spawn a fresh subprocess for each request. Each one is its own
SDK singleton, so multi-key isolation is automatic."""
result = subprocess.run(
["python", "agent.py", prompt],
env={**os.environ, "NULLRUN_API_KEY": key},
capture_output=True,
text=True,
timeout=120,
check=True,
)
return result.stdout
The subprocess startup cost (~150 ms for the lazy runtime init + WebSocket connect) is the price for clean isolation. For high- throughput paths, see Pattern 2 — multiprocessing keeps workers warm in a pool.
What doesn't work¶
Mixing multiple keys in one process leads to interleaved events on the wrong workflow. The supported alternative is one process per key (Pattern 1) or one subprocess per request (Pattern 3).
What if I want a single dashboard view across all my processes?¶
You don't need anything special — the dashboard already aggregates per
workflow across all processes holding that workflow's key. As long as
every subprocess binds to the same workflow (i.e. uses the same
key), all their /gate and /track calls land on the same workflow
record in the backend.
The case where this doesn't hold is the "many workflows, one process" anti-pattern above: prior workflow's events have already gone to the prior key's workflow.
See also¶
- Configuration → env vars
- Concepts → API keys — workflow-scoping
and the
1:1binding between key and workflow - Concepts → Workflow context