Three layered design docs exploring how Sprout's baked-in agent (sprout-agent) can use mesh-llm as its inference backend, scoped to the relay/channel Sprout is connected to. - MESH_LLM_INTEGRATION.md: baseline — sprout-agent is the baked-in ACP agent; mesh-llm is wire-compatible via OPENAI_COMPAT_BASE_URL; proposes a sprout-mesh subprocess supervisor crate. - MESH_LLM_INTEGRATION_DEEP.md: mesh-llm's Nostr discovery wire protocol (kind 31990 NIP-89) and relay-scoped public designs. - MESH_LLM_INTEGRATION_PRIVATE.md: current design — private, members-only meshes scoped to a NIP-29 channel. 5 new event kinds (32990-32995), NIP-44-encrypted listings, owner-attested trust via mesh-llm's allowlist policy. 3-stage rollout.
23 KiB
Sprout × mesh-llm (deez) — Integration Report
Date: 2026-05-19
Scope: Does Sprout have a "baked-in" agent? If yes, how do we plug
mesh-llm in as the inference backend — ideally as a Rust
dependency that boots a relay-scoped mesh in serve mode.
TL;DR
-
Yes, Sprout has a baked-in agent. The crate is
sprout-agent— a first-party, ACP-compliant LLM agent. It is the only place in the Sprout workspace that talks HTTP to an LLM. Other "agent-looking" crates (sprout-acp,sprout-mcp,sprout-cli) are protocol bridges, not LLM clients. -
Plugging in mesh-llm is straightforward — at the wire level. mesh-llm serves an OpenAI-compatible HTTP API on
:9337/v1.sprout-agentalready speaks the OpenAI Chat Completions / Responses API via a configurable base URL (OPENAI_COMPAT_BASE_URL). So the zero-code integration is: runmesh-llm serve --autosomewhere and setOPENAI_COMPAT_BASE_URL=http://localhost:9337/v1. That works today. -
The "ultimate" form you described — a crate dep that boots a relay-scoped mesh — is feasible but constrained.
- mesh-llm's client is embeddable as a Rust crate (
mesh-api). - mesh-llm's server is not a library:
mesh-llm-host-runtimeexposes onlyrun()/run_main(); all its modules are private. - The right primitive, therefore, is subprocess supervision: a new
sprout-meshcrate that bootsmesh-llm serve …as a child of the Sprout daemon, scopes its lifetime to a specific relay/workspace, and exposes the local OpenAI endpoint tosprout-agent.
- mesh-llm's client is embeddable as a Rust crate (
-
The provider abstraction in
sprout-agentis enum-based and deliberately not trait-based. The author ofsprout-agentexplicitly rejectedBox<dyn Provider>in favor of amatcharm. Adding aMeshvariant is an option, but unnecessary — mesh-llm is wire-compatible with OpenAI, so the existingProvider::OpenAiarm suffices.
1. Sprout's "baked-in agent" — does it exist?
Yes. Three crates collaborate to give Sprout an end-to-end agent experience, but only one of them is an LLM client:
| Crate | Role | LLM client? |
|---|---|---|
sprout-acp |
Daemon. Listens on relay, spawns and supervises an ACP-speaking child process. | No |
sprout-mcp |
MCP server (stdio) exposing Sprout tools (send_message, get_channel_history, …) to any agent. |
No |
sprout-cli |
Machine-friendly CLI; "agent-first" = JSON in / JSON out / documented exit codes. | No |
sprout-agent |
First-party ACP agent. Talks HTTP to Anthropic / OpenAI / OpenAI-compatible servers. | Yes |
The full process tree at runtime is:
sprout-acp ← harness daemon, no LLM code
├── sprout-agent ← THE agent — calls Anthropic / OpenAI
│ └── sprout-mcp-server ← stdio MCP server exposing Sprout tools
├── sprout-agent (#2)
│ └── sprout-mcp-server (#2)
└── …
How a message becomes a reply
- A user posts a kind-9 message mentioning the agent's pubkey.
sprout-acp(already connected to the relay over WebSocket with NIP-42 auth) receives it, runs the author gate (RespondTo::OwnerOnlyby default), and queues it on a per-channel FIFO.sprout-acpsends an ACPsession/promptto its child agent over stdio.- The child agent (
sprout-agent) loops:POST /v1/messages(Anthropic) orPOST /v1/chat/completions//v1/responses(OpenAI) — seecrates/sprout-agent/src/llm.rs.- Parse tool calls, dispatch them to
sprout-mcp-serverover stdio. - Feed results back; repeat until
stop_reason == end_turn.
sprout-mcp'ssend_messagetool signs a kind-9 event and posts it via the relay'sPOST /eventsendpoint. The reply fans out to subscribers.
Where the LLM provider is configured
Not on sprout-acp — the harness is LLM-agnostic. The provider is
configured on the child:
# sprout-agent — these are the only LLM-control knobs in the workspace
SPROUT_AGENT_PROVIDER=openai # or "anthropic"
OPENAI_COMPAT_API_KEY=…
OPENAI_COMPAT_MODEL=…
OPENAI_COMPAT_BASE_URL=https://api.openai.com/v1 # ← the integration seam
OPENAI_COMPAT_API=auto # auto | chat | responses
OPENAI_COMPAT_BASE_URLis the single point where you can routesprout-agent's inference traffic anywhere that speaks OpenAI's API — vLLM, llama.cpp, Ollama, OpenRouter, Block Gateway, and mesh-llm.
Author's stance on extending providers
From crates/sprout-agent/README.md:
Provideris a Rustenumwith onematchinLlm::complete. There is no trait, noBox<dyn>, no async-trait. Adding a third provider is amatcharm and onebody/parsepair inllm.rs.
This is relevant because it tells us: a "mesh-native" provider variant would be welcome stylistically, but is not required — mesh-llm rides in on the existing OpenAI arm.
2. mesh-llm in one screen
- What it is: A distributed LLM inference layer (custom QUIC mesh via
iroh, embedded patchedllama.cpp) that exposes the union of its nodes as one OpenAI-compatible API onhttp://localhost:9337/v1. - CLI:
mesh-llm serve [--auto | --join <token> | --model <ref> | --client]. - Endpoints (
:9337):/v1/models,/v1/chat/completions,/v1/completions,/v1/responses— SSE streaming. - Management API (
:3131):/api/status,/api/models,/api/runtime/*,/api/plugins/*, SSE/api/events. - MCP: Speaks MCP both ways — as a server (exposes plugin tools /
blackboard over stdio/HTTP/TCP/UNIX) and as a client (can connect out to
external MCP servers via
[[plugin]]entries in~/.mesh-llm/config.toml). - Embeddability:
mesh-api(path:crates/mesh-api) — public Rust client SDK (MeshClient,ClientBuilder,ChatRequest,InviteToken, …).mesh-llm-host-runtime— the actual server runtime, but modules are private.lib.rsexposes onlyrun()/run_main()/VERSION.mesh-llm-plugin— plugin SDK if you want to author a plugin that runs inside mesh.
- Mesh discovery:
--auto,--join <token>, or Nostr-based (--discover,--mesh-discovery-mode nostr|mdns). Worth noting: mesh-llm already uses Nostr relays for mesh discovery, which is a nice cultural fit with Sprout.
The crucial fact for us: from sprout-agent's point of view, mesh-llm is
indistinguishable from OpenAI. Same POST /v1/chat/completions, same SSE,
same tool-call JSON.
3. Integration options, ranked
Option A — Zero-code, env-only (works today)
Run mesh-llm anywhere reachable. Point sprout-agent at it.
# terminal 1
mesh-llm serve --auto --model Qwen3-8B-Q4_K_M
# terminal 2 — Sprout
export SPROUT_ACP_AGENT_COMMAND=sprout-agent
export SPROUT_AGENT_PROVIDER=openai
export OPENAI_COMPAT_BASE_URL=http://localhost:9337/v1
export OPENAI_COMPAT_MODEL=Qwen3-8B-Q4_K_M
export OPENAI_COMPAT_API_KEY=unused # mesh-llm ignores it but reqwest expects one
sprout-acp
- Pros: No new code, no new crates, no coupling. Validates the wire protocol immediately. Easy to demo.
- Cons: mesh lifecycle is the operator's problem. No relay-scoped isolation. Manual config.
Recommendation: ship this first as the "Tier 0" docs path. It is the test that proves all the harder options will work.
Option B — sprout-mesh crate: subprocess supervisor (recommended)
Add a new workspace crate, crates/sprout-mesh, that:
- Spawns
mesh-llm serve …as a tokio child process. - Owns a unique mesh identity (owner keystore) per Sprout workspace/relay.
- Reads relay-scoped config to decide:
- which models to keep warm (
--model …), - whether to
--publish(announce to a public mesh) or stay private, - whether to
--join <token>an existing mesh provisioned for this relay.
- which models to keep warm (
- Probes
http://127.0.0.1:<port>/v1/modelsfor readiness. - Exposes a small Rust API:
MeshHandle { base_url, models(), shutdown() }. - Tears the mesh down when the supervisor exits (SIGKILL via process group,
matching how
sprout-acpalready supervisessprout-agent).
Then sprout-acp (or a thin wrapper binary) becomes:
sprout-acp
├── sprout-mesh ← NEW: supervises mesh-llm
│ └── mesh-llm serve … (child)
├── sprout-agent #1 ← OPENAI_COMPAT_BASE_URL injected
│ └── sprout-mcp-server #1
├── sprout-agent #2
│ └── sprout-mcp-server #2
└── …
Why this shape?
- It matches the established pattern:
sprout-acpalready supervisessprout-agentandsprout-mcp-serverover stdio with process groups, respawn, and idle timeouts.sprout-meshwould be the same idea applied to a third subprocess. - It keeps mesh-llm's licensing and dependency surface (llama.cpp, CUDA,
Metal, iroh, …) out of the Sprout build graph.
sprout-meshdepends on no mesh code at compile time — it only needs themesh-llmbinary onPATH(or a configured path). - Relay-scoped lifecycle: when a Sprout workspace switches relays (desktop) or the daemon stops, the mesh subprocess dies with it.
- It does not require
mesh-llm-host-runtimeto be a library, which it isn't.
Why not depend on the mesh-api crate?
You could — but mesh-api is the client SDK. It joins an existing mesh
and lets you call chat(). It does not run inference itself. If we used
mesh-api, we would still need a mesh server somewhere to talk to. So
embedding mesh-api only makes sense if we explicitly want Sprout to
join a mesh, not host one. (See Option C.)
Sketch — crates/sprout-mesh/Cargo.toml:
[package]
name = "sprout-mesh"
version = "0.1.0"
edition = "2021"
description = "Supervises a local mesh-llm process scoped to a Sprout relay/workspace."
[dependencies]
tokio = { workspace = true, features = ["process", "macros", "rt-multi-thread", "fs", "time"] }
reqwest = { workspace = true, features = ["json"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
Sketch — crates/sprout-mesh/src/lib.rs:
use std::path::PathBuf;
use std::process::Stdio;
use tokio::process::{Child, Command};
use tokio::time::{sleep, Duration, Instant};
pub struct MeshConfig {
pub binary: PathBuf, // SPROUT_MESH_BIN, default "mesh-llm"
pub port: u16, // 9337
pub console_port: u16, // 3131
pub models: Vec<String>, // --model entries
pub auto: bool, // --auto
pub join_tokens: Vec<String>, // --join
pub publish: bool, // --publish
pub headless: bool, // --headless (no React console)
pub owner_key: Option<PathBuf>,
pub extra_args: Vec<String>,
pub runtime_root: Option<PathBuf>, // MESH_LLM_RUNTIME_ROOT, scoped per relay
}
pub struct MeshHandle {
child: Child,
pub base_url: String, // "http://127.0.0.1:9337/v1"
pub console_url: String, // "http://127.0.0.1:3131"
}
impl MeshHandle {
pub async fn spawn(cfg: MeshConfig) -> Result<Self, MeshError> {
let mut cmd = Command::new(&cfg.binary);
cmd.arg("serve")
.arg("--port").arg(cfg.port.to_string())
.arg("--console").arg(cfg.console_port.to_string());
if cfg.auto { cmd.arg("--auto"); }
if cfg.publish { cmd.arg("--publish"); }
if cfg.headless { cmd.arg("--headless"); }
for m in &cfg.models { cmd.arg("--model").arg(m); }
for t in &cfg.join_tokens { cmd.arg("--join").arg(t); }
if let Some(k) = &cfg.owner_key { cmd.arg("--owner-key").arg(k); }
if let Some(root) = &cfg.runtime_root {
cmd.env("MESH_LLM_RUNTIME_ROOT", root);
}
cmd.args(&cfg.extra_args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.process_group(0); // kill tree on shutdown — matches sprout-acp pattern
let child = cmd.spawn()?;
let me = Self {
child,
base_url: format!("http://127.0.0.1:{}/v1", cfg.port),
console_url:format!("http://127.0.0.1:{}", cfg.console_port),
};
me.wait_ready(Duration::from_secs(60)).await?;
Ok(me)
}
async fn wait_ready(&self, timeout: Duration) -> Result<(), MeshError> {
let deadline = Instant::now() + timeout;
let client = reqwest::Client::new();
let url = format!("{}/models", self.base_url);
while Instant::now() < deadline {
if let Ok(r) = client.get(&url).send().await {
if r.status().is_success() { return Ok(()); }
}
sleep(Duration::from_millis(500)).await;
}
Err(MeshError::NotReady)
}
pub async fn shutdown(mut self) -> Result<(), MeshError> {
let _ = self.child.kill().await;
let _ = self.child.wait().await;
Ok(())
}
}
#[derive(thiserror::Error, Debug)]
pub enum MeshError {
#[error("mesh-llm not ready in time")]
NotReady,
#[error(transparent)]
Io(#[from] std::io::Error),
}
Sketch — wiring into sprout-acp:
In crates/sprout-acp/src/lib.rs::run, before AgentPool::spawn(...),
optionally bring up a mesh and inject env into each child:
let mesh = if cfg.mesh_enabled {
Some(sprout_mesh::MeshHandle::spawn(cfg.mesh.clone()).await?)
} else { None };
let extra_env: Vec<(String, String)> = mesh
.as_ref()
.map(|m| vec![
("SPROUT_AGENT_PROVIDER".into(), "openai".into()),
("OPENAI_COMPAT_BASE_URL".into(), m.base_url.clone()),
("OPENAI_COMPAT_API_KEY".into(), "mesh".into()), // placeholder
])
.unwrap_or_default();
// pass extra_env into AgentPool / AcpClient::spawn(..., &extra_env)
Note that operator-set env vars already take precedence (existing
if std::env::var(key).is_err() guard in acp.rs:206), so a user who
wants to point to Anthropic instead of the embedded mesh just exports
SPROUT_AGENT_PROVIDER=anthropic and the mesh-derived defaults defer.
New env vars introduced by sprout-mesh:
| Var | Default | Purpose |
|---|---|---|
SPROUT_MESH_ENABLED |
false |
Master switch. Off by default. |
SPROUT_MESH_BIN |
mesh-llm |
Path / name of the mesh-llm binary. |
SPROUT_MESH_PORT |
9337 |
OpenAI port. |
SPROUT_MESH_CONSOLE_PORT |
3131 |
Management/console port. |
SPROUT_MESH_MODELS |
— | Comma-separated --model refs. |
SPROUT_MESH_AUTO |
false |
Pass --auto (discover + join). |
SPROUT_MESH_JOIN |
— | Comma-separated invite tokens. |
SPROUT_MESH_PUBLISH |
false |
Pass --publish. |
SPROUT_MESH_HEADLESS |
true |
Pass --headless. |
SPROUT_MESH_RUNTIME_ROOT |
~/.sprout/mesh/<relay-id>/ |
Per-relay isolation. |
SPROUT_MESH_OWNER_KEY |
— | Owner keystore path. |
Option C — Sprout joins an external mesh via mesh-api
Instead of (or in addition to) spawning a server, depend on the mesh-api
crate as a Rust path/version dependency and use MeshClient directly from
sprout-agent:
# crates/sprout-agent/Cargo.toml
mesh-api = { path = "../../../deez/crates/mesh-api", optional = true }
[features]
mesh = ["dep:mesh-api"]
Add Provider::Mesh variant in crates/sprout-agent/src/config.rs, add a
match arm in Llm::complete that calls MeshClient::chat, and require
SPROUT_MESH_INVITE_TOKEN + SPROUT_MESH_OWNER_KEY in config.
- Pros: No HTTP hop, no port-binding. Native QUIC. Sprout becomes a legitimate participant in a mesh — e.g., a community runs a shared mesh, every Sprout instance joins it.
- Cons: Couples Sprout's build to mesh-llm's transitive dep tree
(
iroh, protobuf, GGUF, etc.). Adds operational complexity (owner keys, invite tokens). Violates the author's stated "no newdyn Provider" preference — though a new enum arm is still fine. - When to choose: Only if "join the team's mesh" is the dominant UX story. For "the relay also hosts inference", Option B is cleaner.
Option D — mesh-llm as a Sprout plugin (or vice versa)
mesh-llm has a first-class plugin system that can re-expose external MCP servers' tools as part of mesh's surface. Two sub-options:
-
D1. Register
sprout-mcpas a plugin in~/.mesh-llm/config.toml:[[plugin]] name = "sprout" command = "sprout-mcp-server" env = ["SPROUT_RELAY_URL", "SPROUT_PRIVATE_KEY"]Now any client of mesh-llm (Goose, Claude Code, OpenCode, mesh's own MoA gateway) has Sprout tools.
-
D2. Author a tiny mesh-llm plugin in Rust (using the
mesh-llm-pluginSDK) that wrapssprout-sdkdirectly and avoids the stdio MCP hop.
This is complementary to Options A/B/C, not a substitute. It is what you'd do to make Sprout tools available from inside mesh, regardless of who's calling the inference API.
4. Recommended path
A staged rollout that ships value at each step:
Stage 1 — Docs + smoke test (no code).
Add a section to crates/sprout-agent/README.md showing how to point at
http://localhost:9337/v1. This is Option A. Validates the wire protocol.
Stage 2 — crates/sprout-mesh crate (Option B).
Sketched above. ~200–400 LOC. No mesh-llm code in our build graph; just
subprocess supervision. Add an opt-in flag (SPROUT_MESH_ENABLED=true) so
existing deployments are untouched. Inject OPENAI_COMPAT_* env vars into
spawned sprout-agent children. Per-relay MESH_LLM_RUNTIME_ROOT for
isolation.
Stage 3 — Wire sprout-mesh into the desktop app.
Workspace-scoped: the desktop app's useWorkspaceInit.ts already has a
reset hook (see AGENTS.md "Workspace Switching"). Adding mesh.shutdown()
on workspace switch is consistent with the existing pattern for
relayClient.disconnect() etc.
Stage 4 — Register sprout-mcp as a mesh plugin (Option D1). One-line config change on the mesh side. Makes Sprout tools available to every consumer of that mesh.
Stage 5 (optional, later) — mesh-api Rust dependency (Option C).
Only if community-mesh-join becomes a real workflow. Adds Provider::Mesh
to sprout-agent behind a mesh cargo feature.
5. Risks and notes
- Build graph contagion (Option C only). Pulling
mesh-apiin pullsiroh, possiblyprotobuf-codegen, and GGUF helpers. Keep behind a cargo feature, off by default. For Option B this is a non-issue because the dependency is just "mesh-llmbinary exists onPATH". - Port collisions. mesh-llm defaults to
:9337(OpenAI) and:3131(console). The Sprout relay defaults to:3000. No collision, but the desktop app's Vite dev server is:1420— also clear. Add config knobs in case someone wants a non-default port. - Headless vs. console UI. mesh-llm bundles a React console at
:3131. Default--headless trueinsprout-meshto avoid surprise UI; expose a knob to turn it back on for debugging. - Identity surface. mesh-llm has its own owner keypair / invite tokens.
This is separate from Sprout's
SPROUT_PRIVATE_KEY(nsec). They should remain distinct: Sprout's key authorizes the relay, mesh's key authorizes the mesh. Document clearly. - Mesh discovery uses Nostr too. mesh-llm uses a Nostr relay for
discovery (
--nostr-relay). Cultural fit aside, be careful not to share the same relay, or at least, not to point mesh-llm's discovery at the Sprout relay that the agent connects to — unless you've vetted that mesh-llm's discovery events won't pollute Sprout's event surface. Use a public Nostr relay (relay.damus.ioetc.) for mesh discovery by default. - License compatibility. mesh-llm is
MIT OR Apache-2.0. Sprout's workspace is the same (verify in rootCargo.toml). Embedding (Option C) is fine; subprocess (Option B) is trivially fine. - CI.
just cishould not require a mesh-llm binary by default. Gate any mesh-related integration tests behind a feature flag or aSPROUT_MESH_BIN-present check, similar to howjust testalready gates on Postgres/Redis being up. - Tool-call semantics. mesh-llm passes OpenAI tool-call JSON through
unmodified.
sprout-agentalready handles the OpenAI tool-call shape via itschat/completionsandresponsesarms. Worth running the existinge2e_mcp.rsagainst a mesh-backed setup to confirm round-trip. - MoA model id. mesh-llm reserves the model id
"mesh"for its Mixture-of-Agents fan-out. If a Sprout user setsOPENAI_COMPAT_MODEL=mesh, they'll get MoA behavior automatically. Worth surfacing as a knob insprout-meshconfig (e.g.,SPROUT_MESH_USE_MOA=true→ defaultsOPENAI_COMPAT_MODEL=mesh).
6. Concrete next steps
If we want to land Stage 1 + Stage 2 in a single PR:
crates/sprout-mesh/— new crate (Cargo.toml + lib.rs + a smallconfig.rsreadingSPROUT_MESH_*env vars).Cargo.toml(root) — addcrates/sprout-meshtomembers.crates/sprout-acp/Cargo.toml— addsprout-mesh = { path = "../sprout-mesh", optional = true }and ameshfeature.crates/sprout-acp/src/lib.rs— inrun(), behind#[cfg(feature = "mesh")], conditionally spawnMeshHandleand injectOPENAI_COMPAT_*intoextra_envfor the agent pool..env.example— documentSPROUT_MESH_*knobs with a commented example.crates/sprout-agent/README.md— append a "Using mesh-llm" section pointing at the:9337/v1endpoint (Option A docs).AGENTS.md— addcrates/sprout-meshto the repo structure table (and finally fix the missingsprout-agent/sprout-dev-mcpentries while we're there).- Optional test: add a
crates/sprout-test-client/tests/e2e_mesh.rsthat is gated onMESH_LLM_BINbeing set, spawns a mesh-llm with a tiny model, runs a sprout-agent ACP round-trip, and asserts a reply event was posted. Excluded fromjust ciby default.
Total: ~300–500 net new LOC, zero changes to sprout-agent itself, no new
HTTP dependencies, no changes to the relay or DB.
Appendix — file references
Sprout (this repo):
crates/sprout-agent/src/llm.rs—Llm::complete; OpenAI/Anthropic dispatch.crates/sprout-agent/src/config.rs—Providerenum, env var loading.crates/sprout-agent/README.md— provider extension policy.crates/sprout-acp/src/lib.rs—run()(entry),build_mcp_servers(),AgentPool.crates/sprout-acp/src/acp.rs—AcpClient::spawn(command, args, extra_env)..env.example— current env-var contract.
mesh-llm (../deez):
crates/mesh-llm/src/main.rs— binary entry.crates/mesh-llm-host-runtime/src/lib.rs— publicrun()/run_main().crates/mesh-llm-host-runtime/src/cli/mod.rs— CLI flags.crates/mesh-llm-host-runtime/src/network/openai/transport.rs—/v1server.crates/openai-frontend/src/backend.rs—OpenAiBackendtrait.crates/mesh-api/src/lib.rs— embeddable Rust client SDK.crates/mesh-llm-plugin/src/lib.rs— plugin SDK (for Option D2).docs/design/MOA_GATEWAY.md— Mixture-of-Agents semantics.