Register a provider
You want quorum serve to recognise a provider.type the SDK doesn't ship — without forking it. Implement a ProviderFactory, register it, and hand the registry to serve_fleet.
This is the right path when your provider needs its own dispatch key in the fleet YAML. If you only need a one-off custom agent, implement NsedAgent and wire it to a worker directly instead.
A complete, runnable version of everything below lives in the test crates/quorum-rs/tests/custom_provider_e2e.rs — it builds a demo_cli provider and drives it through build_worker over NATS. Copy it as a starting point.
1. Read your config from provider_config
Your provider doesn't get a typed section on the core AgentConfig (that's reserved for the built-ins). Instead, declare your own config type and read it from the generic provider_config map with AgentConfig::provider_config_as:
#[derive(serde::Deserialize)]
struct CodexConfig {
command: Vec<String>, // argv of the codex CLI
#[serde(default)]
permission_mode: String,
#[serde(default)]
sandbox: bool,
}
2. Implement ProviderFactory
The factory receives the resolved AgentConfig and the ProviderEntry it points at, reads its config, and returns an Arc<dyn NsedAgent>.
use std::sync::Arc;
use quorum_rs::agents::config::AgentConfig;
use quorum_rs::agents::NsedAgent;
use quorum_rs::config::ProviderEntry;
use quorum_rs::providers::ProviderFactory;
struct CodexFactory;
impl ProviderFactory for CodexFactory {
fn provider_type(&self) -> &str {
"codex"
}
// `false` (the default) exempts this provider from placeholder
// API-key validation. Return `true` if a remote deployment of
// your provider needs a real key.
fn requires_api_key(&self) -> bool {
false
}
fn build_agent(
&self,
agent_config: &AgentConfig,
_provider: &ProviderEntry,
) -> anyhow::Result<Option<Arc<dyn NsedAgent>>> {
// Deserialize the whole `provider_config:` block into your type.
// (Return Ok(None) instead to skip this agent cleanly — the rest
// of the fleet still boots.)
let config: CodexConfig = agent_config.provider_config_as()?;
let agent = CodexAgent::new(agent_config.name.clone(), config);
Ok(Some(Arc::new(agent)))
}
}
Your agent type must implement NsedAgent (Send + Sync + Debug + Clone). See the agent development guide for that trait.
Reuse the subprocess plumbing
If your provider spawns a CLI (codex, aider, …), don't reimplement the spawn/timeout dance — the same helpers the built-in exec/mcp providers use are public in quorum_rs::providers::cli_base:
use quorum_rs::providers::cli_base;
// inside your NsedAgent::propose / evaluate:
let timeout = cli_base::effective_timeout(self.config.timeout_secs, ctx);
let mut child = cli_base::spawn_child(
"codex", // provider label for error messages
&self.name,
&self.config.command, // argv
None, // working_dir
&self.config.env, // base env
&[("CODEX_PERMISSION_MODE", self.config.permission_mode.clone())], // extra env, layered last
)?;
// drive child.stdin/stdout however your CLI's protocol works.
3. Register it and serve
use std::sync::Arc;
use quorum_rs::config::load_config;
use quorum_rs::providers::ProviderRegistry;
use quorum_rs::serve::{serve_fleet, ServeOptions};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let fleet = load_config(std::path::Path::new("quorum.yml"))?;
let mut registry = ProviderRegistry::with_builtins(); // keep exec/mcp/claude/openai/…
registry.register(Arc::new(CodexFactory));
let opts = ServeOptions {
registry: Some(Arc::new(registry)),
..Default::default()
};
serve_fleet(&fleet, opts).await
}
ProviderRegistry::with_builtins() keeps every built-in provider; register() adds (or overrides) one type. If you want only your providers, start from ProviderRegistry::empty().
4. Reference it from quorum.yml
Operators add your agent under the providers: / agents: blocks of quorum.yml with a provider_config: block — the map your factory deserializes in step 1. No Rust on their side:
providers:
my_codex:
type: codex # ← matches CodexFactory::provider_type()
agents:
- name: CODEX_ALPHA
provider_id: my_codex
model_name: codex-mini
provider_config: # ← deserialized into your CodexConfig
command: ["codex", "--exec"]
permission_mode: "auto"
sandbox: true
Notes
- Default behaviour is unchanged.
ServeOptions::default()leavesregistry: None, which falls back towith_builtins(). Existing fleets need no edits. - Unknown types fail soft. A
provider.typewith no registered factory is skipped (Ok(None)) with a warning listing the registered set — it does not abort the fleet. - Overriding a built-in. Registering a factory whose
provider_type()equals a built-in (e.g."exec") replaces it; the lastregister()wins. - The built-in
claudeprovider uses this too.ClaudeFactoryreads its config fromprovider_configwhen no typedclaude:section is present — soprovider_config_asis exercised by a real shipping provider, not just custom ones. - API keys. A provider whose factory returns
requires_api_key() == false(the default) is treated as local and needs noapi_keyin its YAML — a local CLI provider likecodexis keyless. Returntrueonly if a remote deployment genuinely needs a key (then the entry needs one, unless itsbase_urlislocalhost).
For the design rationale, see About the provider registry.