Embedding Leviath in a Rust application
Leviath is also a library. The same runtime the lev daemon serves can run
inside your own process: add the leviath crate, build a world, spawn agents,
and consume their events as an async stream. No CLI, no daemon, no config
file, no socket.
[dependencies]
leviath = "0.3"
tokio = { version = "1", features = ["full"] }The shape of it
use leviath::prelude::*;
#[tokio::main]
async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
let world = AgentWorld::builder()
.provider(ProviderCreds {
api_key: std::env::var("ANTHROPIC_API_KEY").ok(),
..ProviderCreds::simple("anthropic")
})
.build()?;
let mut events = world.events();
let run = world
.spawn(SpawnSpec::new(
BlueprintSource::Path("coder.leviath".into()),
"Build a CSV parser",
std::env::current_dir()?,
))
.await?;
while let Some(event) = events.next().await {
match event {
AgentEvent::StageTransition { from, to, .. } => println!("{from} -> {to}"),
AgentEvent::ToolCallFinished { tool, ok, .. } => println!("{tool}: ok={ok}"),
AgentEvent::Completed {
run_id,
status,
final_output,
..
} if run_id == run.as_ref() => {
println!("finished: {status}");
if let Some(output) = final_output {
println!("{}", output.content);
}
break;
}
_ => {}
}
}
world.shutdown().await;
Ok(())
}AgentWorld::builder() must run inside a Tokio runtime (or be handed one via
.runtime(handle)). The serve loop runs as a background task on that runtime;
shutdown() stops it and drains any pending persistence writes before
returning.
Builder options
| Method | What it does |
|---|---|
provider(creds) |
Register a provider from credentials. Repeatable. ProviderCreds::simple(name) covers key-free providers like ollama. |
register_provider(name, arc) |
Register your own Provider implementation, including mocks for tests. Wins over a credentials entry with the same name. |
default_model(provider, model) |
The fallback when none of a stage's listed models has a registered provider. |
fallback_model(provider, model) |
Where a run moves when its provider fails mid-run, so a single-model blueprint survives an outage. |
prompt_hints(hints) |
Turn on the batch-tool and shell hints, which are off by default on the embed path. |
tool_service(arc) |
Replace the built-in tool service with your own (see below). |
state_dir(dir) |
Persist runs on disk in the daemon's layout (dir/runs/<run_id>/). Without it the world stays in memory. |
inference_pool(config) |
Per-model inference concurrency limits. |
tool_concurrency(n) |
How many tool batches may execute at once (default 4). |
runtime(handle) |
Run on a specific Tokio runtime instead of the ambient one. |
Blueprints come from three places: BlueprintSource::Path for a .leviath file,
BlueprintSource::Toml for blueprint text you already have in memory, and
BlueprintSource::Inline for a Blueprint value you built yourself.
Not every seed kind works when embedded, because some of them are daemon behaviour:
| Seed kind | Embedded |
|---|---|
caller_input |
Filled from SpawnSpec::regions. The task prompt fills the task key |
literal |
Resolves as written |
files, glob, rhai, command |
Not run. They only produce an error when the region is required |
Events
Everything the world does streams through world.events(), an async stream of
AgentEvent (the same enum the daemon broadcasts to WebSocket clients).
| Event | When |
|---|---|
Spawned |
A run appeared in the world. |
Status |
Status, stage, iteration, or tool-call count changed. |
Tokens / Context |
Token totals or context-window usage changed. |
StageTransition |
The run moved from one stage to another. |
ToolCallStarted / ToolCallFinished |
A tool call entered the async lane / returned, paired by call_id. |
Interaction |
The agent asked something and is waiting on an answer. |
Log |
A readable output or operational log line. |
Completed |
The run reached a terminal status, with whatever it handed back. |
The enum is non-exhaustive; keep a catch-all arm. Every variant carries the
run id (event.run_id()), so one stream serves any number of concurrent
agents. A consumer that falls behind the channel skips ahead rather than
erroring, and the stream ends after shutdown().
Getting the answer back
Completed carries final_output: the answer the agent submitted, its format label, and the stage
that produced it. Reading it from the event avoids a second call and avoids racing the write to
disk.
AgentWorld::result(&run_id) asks for the same thing at any point while the run is loaded.
Ask for a shape when you spawn. The label reaches the model untouched, so your own house format works with no support from this crate.
let spec = SpawnSpec::new(source, "audit the auth module", cwd)
.output("a2ui", Some("One card per finding.".to_string()));Final outputs covers the whole cascade, including schema validation.
Answering an agent's questions
When a blueprint uses ask_user_text, ask_user_choice, ask_user_confirm,
present_for_review, or edit_document, the call parks on the interaction
hub and surfaces as an Interaction event carrying the request. Answer it and
the agent resumes:
AgentEvent::Interaction { request, .. } => {
let reply = my_ui.ask(&request.prompt).await;
world.answer(InteractionResponse::text(request.id.clone(), reply));
}world.pending_inputs() lists everything currently waiting, if you would
rather poll than watch the stream.
Controlling runs
status, pause, resume, cancel, and send_message all address a run by
its RunId. A completed run is unloaded from memory shortly after its
Completed event; with state_dir set, its snapshots stay on disk in the
same format lev ps and the dashboard read.
What the built-in tool service covers
Embedded agents get the built-in tools (file reads and writes, directory
listing, shell) confined to the spawn's workdir, plus the interaction tools
routed through the hub. The daemon-only layers are deliberately absent: MCP
servers, Rhai script tools, sandboxes, taint gates, and tool-approval
prompts. The embedder is code, and code that wants richer behavior implements
the ToolService trait and passes it to tool_service(); the trait is one
method plus optional per-stage hooks.
How much can break under you
The API comes in three layers, and how careful you need to be depends on which one you reach for:
| Layer | What it is | Stability |
|---|---|---|
AgentWorld and the other embed types |
The normal way in. Everything above uses it | Stable. Breaking changes get a major version |
WorldHost and PipelineWorld |
The machinery underneath, for hosts that assemble their own spawners, hooks, or tick loop | Semi-stable. May change between minor versions |
PipelineWorld::world_mut() |
The raw ECS world, for anything the layers above cannot express | Unstable. No guarantees at all |
Stay on the first row unless you have a reason not to.
If you do use world_mut(), note that it hands you bevy_ecs types directly, so your code is
coupled to whichever version Leviath uses. It is re-exported as leviath::runtime::ecs for exactly
that reason: import it from there and your types stay aligned with the runtime's.
The daemon's control-socket transport is compiled out of library builds by
default. If you are writing a client for a running lev daemon rather than
hosting agents yourself, enable the control-socket feature on the leviath
crate.
A complete runnable program lives at crates/leviath/examples/embedded_agent.rs
in the repository: cargo run --example embedded_agent -p leviath.