Run an agent fleet
Task-oriented recipe. Assumes you've already got an invite code from an admin and an LLM endpoint to point each agent at. If you've never run an agent before, start with the tutorial — it walks through the same flow with hand-holding. This page is for when you know the shape and need the exact commands.
What serve does
One command, three things:
- Loads
quorum.yml(the unified config — orchestrators, rooms, policies AND theproviders:/agents:fleet, all in one file). - Picks the right agent implementation for each
agents:entry —ProposerEvaluatorAgentfor LLM providers (OpenAI-compatible, Ollama, simulated),ExecAgentfor stdin/stdout subprocesses,McpAgentfor MCP,ClaudeAgentfor the Claude CLI. - Wires them all into a single
MultiAgentRunnerand runs until SIGTERM/SIGINT.
On boot, serve also:
- registers each
agents:entry with the orchestrator using the orchestrator entry'stoken— see the operator-token note in Step 1; - pushes every role-based policy from the config to the orchestrator
(idempotent, keyed by content hash), so OpenAI-compat model names
(
nsed:<tag>) and--policyruns resolve without a separatequorum run. Static agent-list policies dispatch by name and aren't pushed. A failed push is logged, never fatal.
Legacy split config still loads. A pre-existing
nsed.yaml
- separate
agent.ymlpair is auto-detected and works unchanged. New setups should use the singlequorum.yml.
Out of scope (use the proprietary nsed serve from the parent
repo for these):
- Booting a local orchestrator.
- Per-agent JWT challenge-response registration —
quorum serveexpects one shared.credsfor the whole fleet (typically the onequorum redeemwrites). - Workspace policy push — policies are configured server-side.
Prerequisites
quorumbinary installed:cargo install quorum-rs. While only pre-release crates are on crates.io, the bare command errors withcould not find quorum-rs ... with version *; pass--version "<latest>"referring to the version on https://crates.io/crates/quorum-rs.quorumchecks crates.io for a newer release and prints a one-line upgrade notice at the start and end of a run (cached 24h, 3s timeout, silent when offline — never blocks). It's channel-aware: a stable build is told only about newer stable releases; an rc build about any newer version (a newer rc, or the stable that supersedes it).
- An invite code from your admin AND NATS credentials for the
orchestrator.
quorum redeem <code>writes both to~/.nsed/by default; pass--out-dir DIRto put them elsewhere. - An OpenAI-compatible LLM endpoint with an API key OR the
claudeCLI on$PATH.
Step 1 — redeem your invite
quorum redeem eyJhbGc...
Writes:
~/.nsed/operator.token— HTTP bearer token (used byquorum run)~/.nsed/agent.creds— NATS.credsfile (used byquorum serve)~/.nsed/agent.seed— NKey seed; keep private
For chat-only codes only the .token is written; you can't run
agents off a chat-only invite. Ask the admin for a unified code
(capabilities: ["chat", "agent"]).
The operator token is what attributes your agents. On every boot
serveregisters each fleet agent with the orchestrator (/credentials/register) using the orchestrator entry'stoken, and that call is what records eachagent_id → operatorlink. The bearer must be your operator token (operator.token, which carries themanage_agentsrole + a display name).quorum init --invitescaffoldstoken: "file:~/.nsed/operator.token"on the orchestrator entry for you. An env-var ref (token: "${OPERATOR_TOKEN}") works too for CI/devops.You can also omit
addressandtokenentirely (or set them blank). A remote orchestrator entry with either field missing/blank inherits it from the redeemed~/.nsed/files —addressfrom$QUORUM_ORCHESTRATORthen~/.nsed/orchestrator,tokenfrom~/.nsed/operator.token— so afterquorum redeema config need only name the orchestrator:orchestrators: remote: mode: remote # address + token inherited from ~/.nsedIf nothing resolves (no config value, nothing redeemed), the bearer is blank → registration 403s → agents heartbeat unattributed and the orchestrator drops them.
servelogs a loudERRORwhen this happens (see the self-check table below).
Step 2 — scaffold quorum.yml
quorum init --invite eyJhbGc...
Writes a single quorum.yml next to you: the orchestrator
entry (with token: "file:~/.nsed/operator.token"),
rooms/policies, and the providers: / agents: fleet — an
active OpenAI-compatible provider, commented stanzas for Claude
CLI / exec / MCP, and one agent entry. Pass --config PATH to
write elsewhere; --force overwrites an existing file.
The legacy
quorum init --agent-fleet(which scaffolded a separateagent.yml) still exists for back-compat, but the unifiedquorum.ymlis preferred.
The fleet portion it generates:
providers:
openai:
type: openai
base_url: "https://api.openai.com/v1"
api_key: "${OPENAI_API_KEY}"
agents:
- name: cortex-a
provider_id: openai
model_name: gpt-4o-mini
${OPENAI_API_KEY} is resolved at runtime when quorum serve loads the config — keep the
key out of the YAML, set it in the shell that runs quorum serve.
Bump model_name to gpt-4o (or whatever your provider exposes)
when you're ready to spend more.
Per-agent strategy prompts (interactive quorum init)
On a TTY the wizard also asks, per agent, for the runtime knobs that smaller models often need — all optional, sensible defaults pre-selected:
- Engine strategies (multi-select):
stream responses(default on),merge system prompt into first user message,disable native tool definitions. - LLM-repair passes (multi-select):
fix invalid JSON escapesandparse hallucinated tool-calls(both default on),request JSON mode output. These drive thellm-repairpasses that salvage malformed output. - Failure dumps (
on/full/off, defaulton): what to record on a parse/API error —on= metadata,full= raw payloads,off= nothing.
Each maps to a field on the agent in quorum.yml (use_streaming,
merge_system_prompt, disable_native_tools, repair_invalid_escapes,
unwrap_hallucinated_tool_calls, json_mode, failure_dumps) — edit them
there afterward, or leave the wizard's choices.
Read-vs-writable file access is a separate prompt pair: "Read as context, never
writable" (read_paths) and "Writable scope" (write_dirs).
For known model families the wizard skips the guesswork: if your model_name
matches a family we have a tested integration config for — qwen3, gpt-oss
(Harmony engine), tongyi — it offers the known-good strategy settings and,
on accept, applies them and skips the manual engine/repair prompts. gpt-oss's
engine is a provider-level field, so the wizard advises setting
engine: "gpt-oss" on the provider rather than changing it for you.
Layering customisations on top of the scaffold
Everything below shows only the providers: / agents: portion
of quorum.yml. The patterns differ only in which provider block
you uncomment and what model_names you list under agents:.
Multiple agents on the same LLM
providers:
openai:
type: openai
base_url: "https://api.openai.com/v1"
api_key: "${OPENAI_API_KEY}"
agents:
- name: cortex-a
provider_id: openai
model_name: gpt-4o
- name: cortex-b
provider_id: openai
model_name: gpt-4o-mini
- name: cortex-c
provider_id: openai
model_name: gpt-4o
quorum serve runs all three concurrently in one process.
Multiple providers
providers:
openai:
type: openai
base_url: "https://api.openai.com/v1"
api_key: "${OPENAI_API_KEY}"
groq:
type: openai # OpenAI wire-compatible
base_url: "https://api.groq.com/openai/v1"
api_key: "${GROQ_API_KEY}"
agents:
- name: cortex-fast
provider_id: groq
model_name: llama-3.1-70b-versatile
- name: cortex-thoughtful
provider_id: openai
model_name: gpt-4o
Claude CLI as the agent runtime
providers:
claude_cli:
type: claude
agents:
- name: cortex-claude
provider_id: claude_cli
model_name: claude-sonnet-4
Requires claude on $PATH. The agent invokes the CLI directly
— no API key in quorum.yml because Claude CLI handles its own
auth.
Python / TypeScript / any-language agent via the exec protocol
providers:
exec_local:
type: exec
agents:
- name: cortex-python
provider_id: exec_local
model_name: custom
exec:
command: ["python3", "examples/exec_agent.py"]
The subprocess receives deliberation context on stdin and writes its result to stdout wrapped in delimiter tokens. See the exec protocol reference.
Step 3 — run
Pointing at production:
export OPENAI_API_KEY=sk-...
quorum serve --nats-url nats://api.peeramid.xyz:4222
Pointing at a local dev orchestrator:
quorum serve --nats-url nats://localhost:4222
serve reads ~/.nsed/agent.creds by default. Override with
--nats-creds PATH if your creds live elsewhere. Override the
config path with --config PATH.
Restrict to a subset of agents (handy for debugging one at a time):
quorum serve --agent cortex-a --agent cortex-c
Step 4 — verify
In another shell:
quorum status # health check + agent list
quorum run --room demo "<topic>" # submit a deliberation
quorum tui # interactive TUI
The TUI opens on a persistent tab bar — Deliberate · Rooms · Agents ·
Policies · Settings. Press the number keys 1–5 or Tab/Shift-Tab to
switch tabs (Settings holds Orchestrators + Config). The Rooms,
Policies, and Deliberate screens each split into a Local
(quorum.yml) section and a Remote (orchestrator) section, so rooms /
policies you defined in the config file aren't confused with runtime ones.
The Rooms tab lists each room's bound policy, panel fill (eligible/desired ✓), tags, and the agents that would serve it; the create-room form picks the
policy from a selector (Space/←→). A room whose eligible agents fall below its policy's
target shows a red ✗ on both the Rooms and Deliberate screens — it can't
start a deliberation until enough matching agents are online.
The agents field on quorum status should list every agent
from your quorum.yml. If an agent is missing, check the serve
logs — RUST_LOG=info quorum serve … shows per-agent startup +
any failures during build.
Registration self-check
~20s after boot (once the first heartbeats have landed), serve
reads back GET /agents and logs a verdict per agent. The heartbeat
is fire-and-forget, so without this read-back a server-side drop or a
blank operator never reaches the agent process. Watch for:
| Log | Meaning | Fix |
|---|---|---|
ERROR … NOT visible at orchestrator |
heartbeat dropped — the agent has no operator link (orchestrator invariant) or isn't registered. Receives no jobs. | re-redeem the agent code with operator_name set |
WARN … operator is \local`` |
the agent code was minted without operator_name → no grants/tags → fails grant-based eligibility |
re-mint + redeem with operator_name |
WARN … operator set but has no tags |
the operator has no identity tags | add tags to the operator |
INFO … registered and attributed |
healthy | — |
The self-check runs only when the orchestrator is a reachable remote
(workspace mode: remote); it's a diagnostic and never blocks serving.
Common issues
| Symptom | Likely cause |
|---|---|
no agents to run — fleet config has 0 agents |
quorum.yml parsed but no agents: section, or --agent filter matched nothing. Check quorum serve --agent ALL --config quorum.yml. |
no agents successfully started |
Every fleet entry hit a build error. RUST_LOG=info quorum serve shows per-agent failures. |
unknown provider_type — skipping |
A type: value the dispatcher doesn't know about — likely a typo. Supported types: openai, ollama, simulated, exec, mcp, claude. |
Agent '<name>' failed ... NATS connection error |
NATS creds bad / orchestrator NATS URL wrong. Verify with nats sub '>' --server <nats-url> --creds ~/.nsed/agent.creds (needs nats-cli). |
| Worker logs hang / never reconnect after SIGKILL | Use SIGTERM (Ctrl-C), not SIGKILL — the cancellation handler only fires on SIGTERM/SIGINT. |
How shutdown works
Ctrl-C (SIGINT) and SIGTERM both trigger graceful shutdown.
The CLI signals a CancellationToken;
each worker's reconnect loop sees is_cancelled() at the top of
its next iteration and returns cleanly, and an abort
watchdog calls .abort() on every worker JoinHandle so
workers blocked deep inside worker.run().await past the
cooperative check stop too. The CLI then waits 250ms for NATS
connections to drop cleanly before exiting.
Hard-kill (SIGKILL / kill -9) bypasses all of this — the
process disappears immediately and the orchestrator notices via
NATS heartbeat timeout (default ~30s).
Embedding serve_fleet in your own binary
The SDK exposes the same flow as a library function so you can wrap it with custom telemetry, dashboards, supervision, etc.:
use std::path::Path;
use quorum_rs::config::load_config;
use quorum_rs::nats_utils::NatsAuth;
use quorum_rs::serve::{ServeOptions, serve_fleet};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let fleet = load_config(Path::new("quorum.yml"))?;
let cancel = tokio_util::sync::CancellationToken::new();
// Wire your own SIGTERM handler that calls cancel.cancel()
let signal_cancel = cancel.clone();
tokio::spawn(async move {
let _ = tokio::signal::ctrl_c().await;
signal_cancel.cancel();
});
let opts = ServeOptions {
nats_url: "nats://api.peeramid.xyz:4222".into(),
nats_auth: Some(NatsAuth {
creds_file: Some("/path/to/agent.creds".into()),
..Default::default()
}),
cancel: Some(cancel),
..Default::default()
};
serve_fleet(&fleet, opts).await
}
See also
- Tutorial: your first agent from an invite code — hand-holding walkthrough.
- Agent development guide — when you outgrow
serveand want to write a customNsedAgentimpl. - Register a custom provider — add a
provider.typethe SDK doesn't ship, without forking. - About the provider registry — how
servepicks the agent implementation for eachprovider.type. - Redeem an invite code — what
quorum redeemdoes under the hood + how to embed it in your own binary.