openheim as a Rust library

Openheim can be embedded directly in your Rust application. The library exposes the full agent runtime — sessions, streaming, conversation history, RAG, skills, MCP servers, and tools — through a single OpenheimClient facade; wire-level ACP (openheim::acp, plus the ACP-typed facade ergonomics) is available behind the acp feature. See the Agent Client Protocol repo for the protocol itself.


Add to your project

# Cargo.toml
[dependencies]
openheim = "0.9"
tokio = { version = "1", features = ["full"] }

Feature flags

By default the openheim dependency also builds the CLI/TUI binary stack (clap, ratatui, crossterm, tracing-subscriber), the ACP stack (agent-client-protocol), and the WebSocket server stack (axum, tower-http, notify, walkdir). Embedders that drive the agent through OpenheimClient (or their own ACP wiring) usually don't need those. futures is not behind any feature — the agent loop uses it directly — so it is built regardless.

openheim = { version = "0.9", default-features = false }
# optionally: features = ["acp"]     # ACP vocabulary + ACP-typed facade methods (agent-client-protocol)
# optionally: features = ["server"]  # axum WS/REST server (openheim::transport::ws)
# optionally: features = ["tui"]     # ratatui terminal UI (openheim::tui)
# optionally: features = ["rag"]     # remember/search_memory/edit_memory/forget long-term memory (rusqlite FTS5 + sqlite-vec)

Everything else — the client facade, agent loop, providers, tools, MCP, and config — is always available. The whole facade (prompt, resume_session, list_sessions, get_session, delete_session, …) speaks openheim's own types. Only the two ACP adapters, SessionHandle::acp_updates and SessionHandle::acp_replay, need features = ["acp"].


Quick start

use openheim::{OpenheimClient, StreamEvent};

#[tokio::main]
async fn main() -> openheim::Result<()> {
    // Loads ~/.openheim/config.toml
    let client = OpenheimClient::builder().build().await?;

    let session = client
        .new_session()
        .cwd("/my/project")
        .start()
        .await?;

    session
        .prompt("What files are in the current directory?", |event| {
            if let StreamEvent::LlmResponse { content } = event {
                print!("{content}");
            }
        })
        .await?;

    Ok(())
}

Client initialisation

From ~/.openheim/config.toml (default)

let client = OpenheimClient::builder().build().await?;

From a custom config file

let client = OpenheimClient::from_config("/etc/myapp/openheim.toml")
    .build()
    .await?;

From a config you built or adjusted

.app_config(config) hands the builder a whole AppConfig, and no file is read. Load one with load_config_from and change what you need, or build one with AppConfig::new and its with_* setters. .config_path() still names the file that config writers (the TUI's :theme) update. It can't be combined with .provider(), .api_key() or .api_base(); put those on the config's provider entry.

use openheim::config::{AppConfig, ProviderConfig, load_config_from};

// Load, then drop the MCP servers that spawn a process (e.g. on mobile).
let mut config = load_config_from("/path/to/config.toml")?;
config.mcp_servers.retain(|_, server| server.command.is_none());
config.allow_shell = false;

let client = OpenheimClient::builder()
    .app_config(config)
    .config_path("/path/to/config.toml")
    .build()
    .await?;

// Or entirely in code:
let config = AppConfig::new("local").with_provider(
    "local",
    ProviderConfig::new("http://localhost:11434/v1", "llama3").with_models(["llama3", "qwen3"]),
);
let client = OpenheimClient::builder().app_config(config).build().await?;

Overriding just the model

.model() alone (with no .provider()/.api_key()/.api_base()) doesn't switch to programmatic config — it still loads the config file, but resolves this model instead of the default one, the same as passing --model to openheim run:

let client = OpenheimClient::builder()
    .model("claude-opus-4-7") // must be listed under some provider in the config file
    .build()
    .await?;

Programmatic config (no file needed)

let client = OpenheimClient::builder()
    .provider("anthropic")
    .api_key("sk-ant-...")
    .model("claude-opus-4-7")
    .max_iterations(15)
    .build()
    .await?;

Supported provider values: "openai", "anthropic", "gemini", or any string for OpenAI-compatible endpoints (Ollama, vLLM, LM Studio, etc.).

Default models when .model() is omitted:

  • "anthropic" → claude-sonnet-4-6
  • "gemini" → gemini-3.8-flash
  • everything else → gpt-4o

Security controls

Two builder methods control the agent's access boundary. Both override the corresponding config.toml fields when set.

let client = OpenheimClient::builder()
    .provider("openai")
    .api_key("sk-...")
    // Restrict file access to this directory tree
    .work_dir("/home/user/projects/myproject")
    // Remove the execute_command tool from the LLM's tool list entirely
    .allow_shell(false)
    .build()
    .await?;

.work_dir(path) — sets the root directory the agent may read and write. The agent cannot access files outside this tree. Relative paths in tool arguments are resolved against this directory. Defaults to the directory from which the process was invoked when not set in the builder or config file. Whichever work_dir is used is resolved in build() (relative to the current directory, symlinks followed) and must be an existing directory, or build() fails with Error::ConfigError.

.allow_shell(bool) — controls whether the execute_command tool is exposed to the LLM. When false the tool is removed from the tool list entirely; the LLM never sees it and cannot request it. Defaults to false.

Data directory

.data_dir(path) — repoints openheim's own state at a directory of your choosing: conversation history, skills, system.md, subagent profiles, and — unless [memory].db_path says otherwise — the long-term memory database. Defaults to ~/.openheim (and overrides the data_dir config-file field when set). The config file itself is still loaded from ~/.openheim/config.toml. Two agents in one process can hold separate data_dirs; a sandboxed CI run can point at a temp directory and never touch the real home directory:

let client = OpenheimClient::builder()
    .provider("openai")
    .api_key("sk-...")
    .data_dir(std::env::temp_dir().join("openheim-ci"))
    .build()
    .await?;

With MCP servers

MCP servers can be added in either mode. Their tools become available to the agent automatically as {server_name}__{tool_name}.

use openheim::{McpServerConfig, OpenheimClient};

let client = OpenheimClient::builder()
    .provider("openai")
    .api_key(std::env::var("OPENAI_API_KEY").unwrap())
    // stdio MCP server
    .mcp_server(
        "filesystem",
        McpServerConfig::stdio(
            "npx",
            ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"],
        ),
    )
    // Streamable HTTP MCP server, with an auth header
    .mcp_server(
        "my-tools",
        McpServerConfig::http("https://my-tools.example.com/mcp")
            .with_header("Authorization", "Bearer my-key"),
    )
    .build()
    .await?;

MCP servers defined in a config file are always loaded; builder .mcp_server() calls are merged in on top. McpServerConfig is #[non_exhaustive], so build it with stdio/http and the with_env/with_header setters rather than a struct literal.

With custom tools

.tool() registers an in-process ToolHandler alongside the built-ins and any MCP-sourced tools. Each call receives the turn's TurnContext (cancel token, work_dir, client I/O), so a custom tool can enforce the same sandbox boundary the built-ins do. See Custom Tools for how to implement ToolHandler.

let client = OpenheimClient::builder()
    .provider("openai")
    .api_key(std::env::var("OPENAI_API_KEY").unwrap())
    .tool(Box::new(FetchUrlTool::new()))
    .build()
    .await?;

Sessions

Sessions are the unit of conversation. Each session has its own message history, model, skills, and working directory.

Create a session

let session = client
    .new_session()
    .model("gpt-4o")                          // optional — overrides the config default
    .skills(vec!["rust".into(), "tdd".into()]) // optional — names of ~/.openheim/skills/*.md
    .cwd("/my/workspace")                      // optional — tools' working directory, inside work_dir
    .start()
    .await?;

println!("session id: {}", session.id());

Send a prompt (streaming)

prompt runs one turn and calls your callback with each StreamEvent as the agent works: streamed text and thinking, tool calls and results, Usage (context size, live per LLM call), ContextTrimmed (older turns left out of the request to fit the context window), and Finished. It returns the turn's StopReason (EndTurn/MaxIterations/MaxTokens/Refusal/Cancelled/NoContent); StopReason::notice() gives a short user-facing line for the abnormal ones.

use openheim::StreamEvent;

let stop_reason = session
    .prompt("Refactor the auth module to use JWTs", |event| {
        match event {
            StreamEvent::LlmResponse { content } => print!("{content}"),
            StreamEvent::ThinkingContent { content } => eprint!("{content}"),
            StreamEvent::ToolCall { tool_name, .. } => println!("\n[tool] {tool_name} — running…"),
            StreamEvent::ToolResult { tool_name, .. } => println!("[tool] {tool_name} — done"),
            StreamEvent::Usage { usage } => { /* update a live context-size indicator */ let _ = usage; }
            StreamEvent::Finished { .. } => { /* turn done */ }
            _ => {}
        }
    })
    .await?;

Send a prompt with images

The first argument is anything that converts into a PromptInput: plain text as above, or text with images, for any vision-capable provider (Anthropic, OpenAI, Gemini). Each image is its raw base64 data plus a MIME type. The text (when non-empty) comes first, then the images in the order you add them.

use openheim::PromptInput;

let png = std::fs::read("screenshot.png")?;
let data = base64::engine::general_purpose::STANDARD.encode(&png);

session
    .prompt(
        PromptInput::text("What's in this screenshot?").image(data, "image/png"),
        |event| { /* same events as any other prompt */ },
    )
    .await?;

Receive ACP SessionUpdates instead

With features = ["acp"], wrap an ACP-typed callback with session.acp_updates(…) to get each event as an ACP SessionUpdate. Events ACP has no room for (IterationStart, Usage, Finished, MessageAppended) are dropped.

use agent_client_protocol::schema::v1::{ContentBlock, SessionUpdate};

session
    .prompt("Refactor the auth module to use JWTs", session.acp_updates(|update| {
        match update {
            SessionUpdate::AgentMessageChunk(chunk) => {
                if let ContentBlock::Text(t) = chunk.content {
                    print!("{}", t.text);
                }
            }
            SessionUpdate::ToolCall(call) => println!("\n[tool] {} — running…", call.title),
            SessionUpdate::ToolCallUpdate(update) => println!("[tool] {} — done", update.tool_call_id),
            _ => {}
        }
    }))
    .await?;

Multi-turn conversation

Call prompt multiple times on the same handle. The agent accumulates history on disk automatically.

session.prompt("My name is Alice", |_| {}).await?;
session.prompt("What's my name?", |event| { /* prints "Alice" */ }).await?;

Switch model

A live session can move to any model listed in the config; the history carries over. Use the handle, or the client when you only have the session id:

let (provider, model) = session.switch_model("anthropic", "claude-opus-4-7").await?;
client.switch_model(session.id(), "openai", "gpt-4o").await?;

Context usage

session.context_usage().await? returns the token usage of the most recent LLM call — how full the context window is right now, not a running total across the session. None until a provider has reported usage.

if let Some(usage) = session.context_usage().await? {
    println!(
        "context: {} in / {} out ({} cache read / {} cache write)",
        usage.input_tokens, usage.output_tokens, usage.cache_read_tokens, usage.cache_creation_tokens
    );
}

It's also persisted on the conversation, so it's readable via client.get_session(id) (conversation.meta.context_usage) without an active SessionHandle — see Get full conversation below.

Permission gate, cancellation, and client I/O

By default a SessionHandle allows every tool call unconditionally (AllowAll) — the embedder is trusted to have already consented to the run. For an interactive embedder, supply your own PermissionGate so the agent asks before running a tool call:

use openheim::core::permission::{PermissionDecision, PermissionGate, PermissionRequest};
use std::sync::Arc;

struct CliConfirmGate;

#[async_trait::async_trait]
impl PermissionGate for CliConfirmGate {
    async fn check(&self, request: &PermissionRequest<'_>) -> PermissionDecision {
        let who = match request.subagent {
            Some(name) => format!("subagent '{name}'"),
            None => "the agent".to_string(),
        };
        eprintln!(
            "allow {who} to run {}({})? [y/N]",
            request.tool_name, request.arguments
        );
        let mut line = String::new();
        std::io::stdin().read_line(&mut line).ok();
        if line.trim().eq_ignore_ascii_case("y") {
            PermissionDecision::AllowOnce
        } else {
            PermissionDecision::RejectOnce
        }
    }
}

let session = client
    .new_session()
    .start()
    .await?
    .permission_gate(Arc::new(CliConfirmGate));

PermissionGate::check is called before a tool call executes — including tool calls made by a delegate_task subagent, which asks the parent turn's gate rather than always-allowing. A subagent's calls never appear in the session's events, so for those request.subagent holds the subagent's name ("inline" for an inline one); show it when asking. Your gate only has to ask. The runtime remembers AllowAlways/RejectAlways answers for the rest of the session and returns them for matching calls without calling your gate again. Most tools match by tool name; execute_command matches only the exact same command string.

.client_io(Arc<dyn ClientIo>) similarly lets read_file/write_file/edit_file be delegated to the embedder's own I/O (e.g. an editor's unsaved buffers) instead of local disk — see ClientIo. edit_file uses it for both the read and the write, since an edit is a read followed by a write. A handle from resume_session starts from the defaults, so set both again on it.

session.cancel().await cancels the turn currently in flight for that session (no-op if none is running) — call it from another task while prompt() is awaiting. Tool calls the model had already asked for still get a result in the history: ones that finished keep theirs, and the rest are recorded as a Cancelled by user. error (each with a ToolResult event), so the conversation can carry on with the next prompt.


History & session management

List sessions

use std::path::Path;

// All sessions, newest first
let all = client.list_sessions(None).await?;

// Only sessions from a specific working directory
let workspace = client.list_sessions(Some(Path::new("/my/workspace"))).await?;

for info in &workspace {
    println!("{} — {}", info.id, info.title.as_deref().unwrap_or("untitled"));
}

Each entry is a ConversationMeta — id: Uuid, title, cwd, created_at/updated_at, model/provider, context_usage — a core type, so this works with default-features = false. ACP's SessionInfo projection happens inside acp::serve, not on the facade.

Get full conversation (messages + metadata)

let conv = client.get_session("550e8400-e29b-41d4-a716-446655440000").await?;

println!("model: {:?}", conv.meta.model);
println!("messages: {}", conv.messages.len());

for msg in &conv.messages {
    println!("[{:?}] {}", msg.role, msg.text().unwrap_or_default());
}

msg.content is a Vec<core::models::ContentBlock> (Text/Thinking/RedactedThinking/Image/ToolUse/ToolResult) rather than a plain string — msg.text() concatenates the Text blocks. Use msg.tool_calls() / msg.tool_result_block() for the other block types; see docs/custom-llm-provider.md for the full ContentBlock shape.

conv.meta.context_usage is an Option<core::models::Usage> — see Context usage above.

Resume a session (load + continue prompting)

resume_session registers the conversation in the live sessions map and hands back its full message history for you to render however you like — no acp feature needed.

let (session, loaded) = client
    .resume_session(
        "550e8400-e29b-41d4-a716-446655440000",
        "/my/workspace".into(),
    )
    .await?;

for msg in &loaded.messages {
    println!("[{:?}] {}", msg.role, msg.text().unwrap_or_default());
}
if let Some(warning) = &loaded.warning {
    println!("{warning}"); // saved provider/model no longer resolves; fell back to default
}

// Continue where the conversation left off
session.prompt("Continue from where you left off", |event| { /* … */ }).await?;

Like a new session, the returned handle starts from the defaults (AllowAll permission gate, local-disk I/O); call .permission_gate(..)/.client_io(..) on it to change either.

With features = ["acp"], session.acp_replay(&loaded.messages, cb) replays the history through your callback as the ACP SessionUpdates a live turn would have produced, in order and with thinking tagged via _meta.kind:

let (session, loaded) = client
    .resume_session("550e8400-e29b-41d4-a716-446655440000", "/my/workspace".into())
    .await?;
session.acp_replay(&loaded.messages, |update| {
    // replay previous messages into your UI
    match update {
        SessionUpdate::UserMessageChunk(chunk) => { /* render user bubble */ }
        SessionUpdate::AgentMessageChunk(chunk) => { /* render agent bubble */ }
        _ => {}
    }
});

Delete a session

client.delete_session("550e8400-e29b-41d4-a716-446655440000").await?;

Skills

client.skills() lists the skills in the data directory's skills/, sorted. Each is a Markdown file, skills/<name>.md, so read or edit its content there directly.

let skills = client.skills()?;
// → ["debugging", "rust", "tdd"]

History is reached through the session methods above (list_sessions, get_session, resume_session, delete_session).


Long-term memory

With the rag feature, client.long_term_memory() returns the LongTermMemory behind the remember / search_memory / edit_memory / forget tools. It is keyword search (FTS5) unless the config's [memory] section names an embedding provider, in which case search is semantic. You can drive it directly, for example to seed memories or build a memory browser:

let memory = client.long_term_memory();
let note = memory.remember("The user's staging cluster is eu-west-1.").await?;

// Best match first; `hit.method` says whether the score is cosine similarity or a BM25 rank.
for hit in memory.search("where is staging?", Some(3)).await? {
    println!("#{} {} ({:?} {:.2})\n{}", hit.record.id, hit.record.created_at, hit.method, hit.score, hit.record.content);
}

memory.edit(note.id, "The user's staging cluster is eu-west-2.").await?;
memory.forget(note.id).await?;

To use a custom embeddings backend, implement openheim::rag::EmbeddingClient, build the memory yourself and hand it to the builder. The agent's memory tools then use it instead of the one the config's [memory] section describes:

use openheim::rag::{LongTermMemory, VectorStore};

let memory = LongTermMemory::new(VectorStore::open(&db_path)?, Some(Arc::new(my_embedder)), 5);
let client = OpenheimClient::builder()
    .long_term_memory(memory)
    .build()
    .await?;

Introspection

Available tools

for tool in client.tools() {
    println!("{}: {}", tool.function.name, tool.function.description.as_deref().unwrap_or(""));
}

MCP server statuses

for status in client.mcp_servers() {
    println!(
        "{} [{}] connected={} tools={}{}",
        status.name,
        status.transport,
        status.connected,
        status.tool_count,
        status.error.as_deref().map(|e| format!(" error={e}")).unwrap_or_default(),
    );
}

Available models

let models = client.models();
println!("default provider: {}", models.default_provider);
for (provider, info) in &models.providers {
    println!("  {provider}: {} (default)", info.default_model);
    for model in &info.models {
        println!("    - {model}");
    }
}

Full example — multi-provider app with MCP and history

use openheim::{McpServerConfig, OpenheimClient, StreamEvent};

#[tokio::main]
async fn main() -> openheim::Result<()> {
    let client = OpenheimClient::builder()
        .provider("anthropic")
        .api_key(std::env::var("ANTHROPIC_API_KEY").unwrap())
        .model("claude-opus-4-7")
        .max_iterations(20)
        .mcp_server(
            "fs",
            McpServerConfig::stdio(
                "npx",
                ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"],
            ),
        )
        .build()
        .await?;

    // Print MCP connection status
    for s in client.mcp_servers() {
        println!("[mcp] {} — connected={} tools={}", s.name, s.connected, s.tool_count);
    }

    // Check for an existing session or start fresh
    let all_sessions = client.list_sessions(Some(std::path::Path::new("/workspace"))).await?;
    let session = if let Some(last) = all_sessions.first() {
        println!("Resuming session: {}", last.id);
        let (session, _loaded) = client
            .resume_session(&last.id.to_string(), "/workspace".into())
            .await?;
        session
    } else {
        client
            .new_session()
            .skills(vec!["rust".into()])
            .cwd("/workspace")
            .start()
            .await?
    };

    session
        .prompt("Summarise the project structure", |event| {
            if let StreamEvent::LlmResponse { content } = event {
                print!("{content}");
            }
        })
        .await?;

    println!("\nDone. Session id: {}", session.id());
    Ok(())
}

ACP event reference

With features = ["acp"], callbacks wrapped in session.acp_updates(…) and the session.acp_replay(…) callback receive agent_client_protocol::schema::v1::SessionUpdate variants:

Variant When
AgentMessageChunk(ContentChunk) Streaming text from the LLM (thinking too, tagged with _meta.kind == "thinking")
UserMessageChunk(ContentChunk) A past user message (acp_replay only)
ToolCall(ToolCall) Agent is about to invoke a tool
ToolCallUpdate(ToolCallUpdate) Tool finished; contains status and raw output

ContentChunk.content is a single ContentBlock. Match on ContentBlock::Text(t) to get the text string.


Error handling

All fallible operations return openheim::Result<T> (std::result::Result<T, openheim::Error>).

use openheim::{Error, OpenheimClient};

match client.get_session("bad-id").await {
    Ok(conv) => { /* … */ }
    Err(Error::ConfigError(msg)) => eprintln!("config: {msg}"),
    Err(Error::Other(msg)) => eprintln!("error: {msg}"),
    Err(e) => eprintln!("unexpected: {e}"),
}

Transient LLM errors (rate limits, 5xx, network timeouts) are retried automatically with exponential backoff before surfacing as Error::HttpError or Error::ApiError.