I've been learning about the Pi agent and decided to reimplement it in Rust. What started as a performance experiment turned into something more interesting: a library-first agent SDK that you can embed directly into Rust applications.
This post covers the architecture, the plugin ABI, and a complete minimal example you can run without an API key.
Why a Rust implementation?
Pi is a minimal terminal coding harness. It's designed to be extended and reshaped by the user. But its runtime is TypeScript/Node.
I was running an agent on a low-spec server and kept hitting memory limits. So I ported the core runtime to Rust. But the more useful goal emerged along the way: making the agent runtime embeddable, not just a faster CLI.
If you're building a Rust service that needs to receive tasks, call models, execute custom tools, and stream progress to your own UI, you shouldn't have to spawn a subprocess or treat the terminal as the only entry point.
Library-first architecture
rpi is split into independent crates. Each layer can be used on its own:
| Crate | Responsibility |
|---|---|
| rpi-ai | Unified multi-provider LLM client (Anthropic Messages, OpenAI-compatible, custom gateways) |
| rpi-agent | Async streaming agent loop with events, hooks, queues, cancellation |
| rpi-tools | Built-in tools (read, write, edit, bash, grep, find, ls) with a replaceable execution environment |
| rpi-harness | Session tree, JSONL persistence, context compaction, recovery |
| rpi-cli | Terminal UI built on the same runtime |
If you only need the model and tool loop, use rpi-agent. If you need sessions and recovery, add rpi-harness. The CLI is just one consumer of the same libraries.
A minimal agent in ~100 lines
Here's the complete example. It uses a faux provider — a scripted model that needs no API key and no network. You can run it end-to-end.
Cargo.toml:
[dependencies]
rpi-agent = "0.1"
rpi-ai = "0.1"
tokio = { version = "1", features = ["full"] }
tokio-util = "0.7"
serde_json = "1"
async-trait = "0.1"
main.rs:
use std::io::Write;
use std::sync::Arc;
use rpi_agent::{AgentBuilder, AgentEvent, AgentTool, AgentToolResult, TextContentOrImage};
use rpi_ai::providers::faux::{FauxProvider, FauxScript};
use rpi_ai::types::{Context, Schema, Tool};
use rpi_ai::{Model, Provider, SimpleStreamOptions};
use tokio::runtime::Handle;
use tokio_util::sync::CancellationToken;
// A custom tool: add two integers
struct AddTool { schema: Tool }
impl AddTool {
fn new() -> Self {
Self {
schema: Tool {
name: "add".to_string(),
description: "Add two integers a and b.".to_string(),
parameters: Schema(serde_json::json!({
"type": "object",
"properties": {
"a": { "type": "integer" },
"b": { "type": "integer" }
},
"required": ["a", "b"]
})),
constrained_sampling: None,
},
}
}
}
#[async_trait::async_trait]
impl AgentTool for AddTool {
fn schema(&self) -> &Tool { &self.schema }
fn label(&self) -> &str { "Add" }
async fn execute(
&self,
_tool_call_id: &str,
params: serde_json::Value,
_signal: CancellationToken,
_on_update: Arc<dyn Fn(rpi_agent::ToolResultPartial) + Send + Sync>,
) -> Result<AgentToolResult, rpi_agent::AgentError> {
let a = params.get("a").and_then(|v| v.as_i64()).unwrap_or(0);
let b = params.get("b").and_then(|v| v.as_i64()).unwrap_or(0);
Ok(AgentToolResult::text((a + b).to_string()))
}
}
// Bridge async Provider to sync StreamFn
fn provider_stream_fn(provider: Arc<dyn Provider>) -> rpi_agent::StreamFn {
rpi_agent::stream_fn(
move |model: &Model, ctx: &Context, opts: &SimpleStreamOptions| {
let provider = Arc::clone(&provider);
let model = model.clone();
let ctx = ctx.clone();
let opts = opts.clone();
tokio::task::block_in_place(|| {
Handle::current().block_on(async move {
provider.stream_simple(&model, &ctx, &opts).await
})
})
},
)
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Script the model: first call add, then give the answer
let script = FauxScript::new()
.with_tool_call("add", serde_json::json!({ "a": 2, "b": 40 }))
.with_text("2 + 40 = 42");
let provider = FauxProvider::new(script);
let model = provider.default_model().clone();
let agent = AgentBuilder::new()
.model(model)
.system_prompt("You are a minimal agent. Use tools when needed.")
.tools(vec![Arc::new(AddTool::new())])
.stream_fn(provider_stream_fn(provider))
.build()?;
let mut events = agent.subscribe();
println!("> user: What is 2 + 40?\n");
agent.prompt("What is 2 + 40?").await?;
loop {
let event = events.recv().await?;
match event {
AgentEvent::MessageUpdate {
assistant_message_event:
rpi_ai::types::AssistantMessageEvent::TextDelta { delta, .. },
..
} => {
print!("{delta}");
std::io::stdout().flush().ok();
}
AgentEvent::ToolExecutionStart { tool_name, args, .. } => {
println!("→ tool {tool_name}({args})");
}
AgentEvent::ToolExecutionEnd { result, is_error, .. } => {
let tag = if is_error { "tool error" } else { "tool result" };
println!("← {tag}: {}", result_text(&result));
}
AgentEvent::AgentEnd { messages } => {
println!("\n--- done: {} new message(s) ---", messages.len());
break;
}
_ => {}
}
}
Ok(())
}
fn result_text(result: &AgentToolResult) -> String {
result.content.iter().map(|c| match c {
TextContentOrImage::Text(t) => t.text.clone(),
TextContentOrImage::Image(_) => "[image]".to_string(),
}).collect::<Vec<_>>().join("\n")
}
Run it with:
cargo run
To use a real model, swap FauxProvider for rpi_ai::providers::anthropic or an OpenAI-compatible provider. The rest of the code stays the same.
Extensions: native Rust cdylibs
rpi extensions are compiled Rust dynamic libraries (.so/.dll/.dylib), loaded at runtime via libloading through a stable C ABI. The contract lives in rpi-plugin-sdk.
The ABI is a hand-defined C ABI because Rust doesn't have a stable ABI. The two sides may be compiled with different Rust versions or crate versions, so no Rust type with a non-C repr or a Drop impl can cross. Owned data crosses as ptr+len with an explicit free function.
There are already 15 extension packages in rpi-package, organized into five categories:
| Category | Extensions |
|---|---|
| Task planning | rpi-todo, rpi-goal, rpi-plan-mode |
| Human interaction | rpi-ask-user, rpi-permissions |
| Code analysis | rpi-codegraph, rpi-lens, rpi-subagents |
| Web tools | rpi-websearch, rpi-webfetch, rpi-firecrawl |
| Other | rpi-mcp-adapter, rpi-background-tasks, rpi-memory, rpi-voice |
Install an extension with:
rpi install rpi-todo
This downloads from crates.io, compiles the cdylib for your platform, and places it in ~/.rpi/agent/extensions. The CLI discovers it automatically.
What's not there yet
Pi ecosystem compatibility. Existing Pi extensions won't run directly. There's a Pi npm/Git bridge in beta, but it's not stable.
It's 0.1.x. The core loop, providers, tools, harness, and CLI all work. The plugin ABI is stable, but if I break it, extensions break.
Links
- Repo: https://github.com/bigfish1913/pi-rust
- Docs: https://rpi.laofu.online/docs.html
- Extensions: https://github.com/pi-rust/rpi-package
Happy to answer questions about the architecture, the plugin ABI, or the provider bridging.
Top comments (0)