fabro/docs/public/reference/sdk.mdx
Bryan Helmkamp 619cb44e3c
Import lithos-llm types directly instead of through fabro-types
fabro-types no longer re-exports the lithos catalog and request types
(ProviderId, ModelId, ModelHandle, Message, ContentPart, TokenCounts,
Cost, Speed, ReasoningEffort, ReasoningOutput, and the rest). Every
crate that uses them depends on lithos-llm and names them there, and
the fabro-api progenitor replacements point at the lithos paths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 10:03:08 -06:00

516 lines
23 KiB
Text

---
title: "Fabro SDK"
description: "Using Fabro as a Rust library for AI agents and multi-provider LLM completions"
---
Fabro can be used as a Rust SDK with two primary entry points:
- **`fabro-agent`** — a full AI coding agent with tool use, sandboxed execution, event streaming, and context management. Use this when you want to build an agent that can read files, run commands, and interact with a codebase.
- **`fabro-llm`** — a standalone LLM client for multi-provider completions, streaming, and tool execution loops. Use this when you want direct control over LLM calls without the agent layer.
Both crates can be used independently of Fabro's workflow engine.
## Agent (`fabro-agent`)
The `fabro-agent` crate provides a session-based AI agent that runs an LLM with tool use in a sandboxed environment. The agent loop streams LLM responses, executes tool calls (`shell`, `read_file`, `write_file`, `edit_file`, `glob`, `grep`, `web_fetch`, `web_search`), feeds results back, and repeats until the model responds with text or hits a safety limit.
```toml title="Cargo.toml"
[dependencies]
fabro-auth = { git = "https://github.com/fabro-sh/fabro" }
fabro-agent = { git = "https://github.com/fabro-sh/fabro" }
fabro-llm = { git = "https://github.com/fabro-sh/fabro" }
fabro-types = { git = "https://github.com/fabro-sh/fabro" }
tokio = { version = "1", features = ["full"] }
```
### Quick start
```rust
use std::path::PathBuf;
use std::sync::Arc;
use fabro_agent::{AgentProfile, AgentProfileBuilder, LocalSandbox, Session, SessionOptions};
use fabro_auth::VaultCredentialSource;
use fabro_llm::ClientOptions;
use fabro_types::AgentProfileKind;
use lithos_llm::catalog::builtin;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let catalog = Arc::new(fabro_llm::default_catalog());
let client = fabro_llm::build_client(
(*catalog).clone(),
Arc::new(VaultCredentialSource::environment_only()),
ClientOptions::standard(),
)
.await?
.client;
let sandbox = Arc::new(LocalSandbox::new(PathBuf::from(".")));
let profile: Arc<dyn AgentProfile> = Arc::from(
AgentProfileBuilder::new(
AgentProfileKind::Anthropic,
builtin::anthropic(),
"claude-sonnet-4.5",
Arc::clone(&catalog),
)
.build(),
);
let config = SessionOptions::default();
let mut session = Session::new(client, profile, sandbox, config);
session.initialize().await?;
// Subscribe to events before sending input
let mut events = session.subscribe();
tokio::spawn(async move {
while let Ok(event) = events.recv().await {
if let fabro_agent::AgentEvent::TextDelta { delta } = &event.event {
print!("{delta}");
}
}
});
session.process_input("List the files in this directory").await?;
session.close();
Ok(())
}
```
### Session
`Session` is the core type. It holds the LLM client, a provider profile, a sandbox, and configuration. The main loop lives inside `process_input()`.
**Constructor:**
```rust
pub fn new(
llm_client: Client,
provider_profile: Arc<dyn AgentProfile>,
sandbox: Arc<dyn Sandbox>,
config: SessionOptions,
) -> Self
```
**Lifecycle methods:**
| Method | Description |
|---|---|
| `initialize().await` | Discovers project docs, skills, and MCP servers. Call before `process_input`. |
| `process_input(input).await` | Sends user input and runs the agent loop until the model stops, the session is interrupted, or an error occurs. |
| `close()` | Ends the session and emits `SessionEnded`. |
| `interrupt()` | Cancels the current `process_input` call. |
| `cancel_token()` | Returns a `CancellationToken` for external cancellation. |
**Inspection:**
| Method | Description |
|---|---|
| `state()` | Returns `SessionState`: `Idle`, `Thinking`, `Executing`, or `Closed`. |
| `history()` | Returns the conversation as `&History` (a sequence of `Turn` values). |
| `subscribe()` | Returns a broadcast receiver for `SessionEvent` values. |
**Steering:**
| Method | Description |
|---|---|
| `steer(message)` | Injects a system-level guidance message into the next LLM call. |
| `follow_up(message)` | Queues a follow-up user message after the current turn completes. |
### SessionOptions
All fields are public. Key settings with their defaults:
| Field | Default | Description |
|---|---|---|
| `default_command_timeout_ms` | `10,000` | Default timeout for Bash tool commands. |
| `max_command_timeout_ms` | `600,000` | Maximum allowed timeout for Bash tool commands. |
| `enable_loop_detection` | `true` | Detect and break out of repetitive tool call patterns. |
| `enable_context_compaction` | `true` | Automatically summarize old turns when approaching the context window limit. |
| `compaction_threshold_percent` | `80` | Context window usage percentage that triggers compaction. |
| `max_subagent_depth` | `1` | Maximum nesting depth for sub-agents. |
| `wall_clock_timeout` | `None` | Hard timeout for `process_input`. Triggers `InterruptReason::WallClockTimeout`. |
| `tool_hooks` | `None` | Pre/post hooks around tool execution (see [Tool hooks](#tool-hooks)). |
| `mcp_servers` | `[]` | MCP server configurations to connect on startup. |
| `skill_dirs` | `None` | Directories to discover `SKILL.md` files. `None` uses convention defaults. |
### Sandbox
The `Sandbox` trait abstracts where tools execute — local filesystem, Docker container, SSH remote, or a cloud sandbox. All tool operations go through this interface.
```rust
#[async_trait]
pub trait Sandbox: Send + Sync {
async fn read_file_bytes(&self, path: &str) -> Result<Vec<u8>, String>;
async fn read_file_text(&self, path: &str) -> Result<String, String>;
async fn read_file(&self, path: &str, offset: Option<usize>, limit: Option<usize>) -> Result<String, String>;
async fn write_file(&self, path: &str, content: &str) -> Result<(), String>;
async fn delete_file(&self, path: &str) -> Result<(), String>;
async fn file_exists(&self, path: &str) -> Result<bool, String>;
async fn list_directory(&self, path: &str, depth: Option<usize>) -> Result<Vec<DirEntry>, String>;
async fn exec_command(
&self,
command: &str,
timeout_ms: u64,
working_dir: Option<&str>,
env_vars: Option<&HashMap<String, String>>,
cancel_token: Option<CancellationToken>,
) -> Result<ExecResult, String>;
async fn grep(&self, pattern: &str, path: &str, options: &GrepOptions) -> Result<Vec<String>, String>;
async fn walk_files(&self, base: &str, relative_start: &str, options: &WalkOptions) -> Result<Vec<SandboxFile>, String>;
async fn glob(&self, pattern: &str, path: Option<&str>) -> Result<Vec<String>, String>;
async fn initialize(&self) -> Result<(), String>;
async fn cleanup(&self) -> Result<(), String>;
fn working_directory(&self) -> &str;
fn platform(&self) -> &str;
fn os_version(&self) -> String;
// ... optional methods with defaults: setup_git(), git_push_ref(), etc.
}
```
**Built-in implementations:**
| Type | Description |
|---|---|
| `LocalSandbox` | Executes directly on the local filesystem. |
| `DockerSandbox` | Runs inside a Docker container (feature-gated: `docker`). |
The `DaytonaSandbox` implementation (feature-gated: `daytona`) runs inside a Daytona cloud sandbox.
### Provider profiles
The `AgentProfile` trait encapsulates LLM-specific system prompts, tool definitions, and capability metadata. It controls how the agent presents itself to the model.
```rust
pub trait AgentProfile: Send + Sync {
fn provider(&self) -> Provider;
fn model(&self) -> &str;
fn tool_registry(&self) -> &ToolRegistry;
fn tool_registry_mut(&mut self) -> &mut ToolRegistry;
fn build_system_prompt(&self, env: &dyn Sandbox, ...) -> String;
fn capabilities(&self) -> ProfileCapabilities;
fn tools(&self) -> Vec<ToolDefinition>;
// ...
}
```
Profiles are built with `AgentProfileBuilder::new(kind, provider, model, catalog)`. The `AgentProfileKind` values are `anthropic`, `claude-5`, `openai`, `gemini`, `kimi`, `gpt56`, and `gpt6`; the catalog's `metadata.agent.profile` picks one per provider or model.
### Events
All operations emit `AgentEvent` values through a tokio broadcast channel. Subscribe before calling `process_input()`.
```rust
let mut rx = session.subscribe();
tokio::spawn(async move {
while let Ok(event) = rx.recv().await {
match event.event {
AgentEvent::TextDelta { delta } => print!("{delta}"),
AgentEvent::ToolCallStarted { tool_name, .. } => {
println!("[calling {tool_name}]");
}
AgentEvent::ToolCallCompleted { tool_name, is_error, .. } => {
println!("[{tool_name} done, error={is_error}]");
}
AgentEvent::LoopDetected => println!("[loop detected]"),
AgentEvent::CompactionCompleted { .. } => println!("[context compacted]"),
_ => {}
}
}
});
```
Key `AgentEvent` variants:
| Variant | Description |
|---|---|
| `SessionStarted` / `SessionEnded` | Session lifecycle. |
| `TextDelta { delta }` | Incremental text from the model. |
| `ReasoningDelta { delta }` | Incremental reasoning/thinking text. |
| `AssistantMessage { text, model, usage, tool_call_count }` | Complete assistant turn with token usage. |
| `ToolCallStarted { tool_name, tool_call_id, arguments }` | A tool call is about to execute. |
| `ToolCallCompleted { tool_name, tool_call_id, output, is_error }` | A tool call finished. |
| `Error { error }` | An `AgentError` occurred. |
| `LoopDetected` | The agent is repeating itself. |
| `CompactionStarted` / `CompactionCompleted` | Context window compaction. |
| `SubAgentSpawned` / `SubAgentCompleted` | Sub-agent lifecycle. |
| `McpServerReady` / `McpServerFailed` | MCP server connection status. |
### Tool hooks
Implement `ToolHookCallback` to intercept tool calls for approval, logging, or transformation:
```rust
use fabro_agent::{ToolHookCallback, ToolHookDecision};
use async_trait::async_trait;
struct MyHooks;
#[async_trait]
impl ToolHookCallback for MyHooks {
async fn pre_tool_use(
&self,
tool_name: &str,
tool_input: &serde_json::Value,
) -> ToolHookDecision {
if tool_name == "shell" {
println!("Agent wants to run: {}", tool_input["command"]);
}
ToolHookDecision::Proceed // or Block { reason }
}
async fn post_tool_use(&self, tool_name: &str, _call_id: &str, _output: &str) {
println!("{tool_name} completed");
}
async fn post_tool_use_failure(&self, tool_name: &str, _call_id: &str, error: &str) {
eprintln!("{tool_name} failed: {error}");
}
}
```
Pass hooks via `SessionOptions`:
```rust
let config = SessionOptions {
tool_hooks: Some(Arc::new(MyHooks)),
..Default::default()
};
```
For simple sync approval, use `ToolApprovalAdapter` to wrap a closure:
```rust
use fabro_agent::ToolApprovalAdapter;
use std::sync::Arc;
let config = SessionOptions {
tool_hooks: Some(Arc::new(ToolApprovalAdapter(Arc::new(|tool_name, _args| {
if tool_name == "shell" {
Err("shell is not allowed".into())
} else {
Ok(())
}
})))),
..Default::default()
};
```
### Error handling
All fallible `Session` methods return `Result<T, AgentError>`:
| Variant | Description |
|---|---|
| `Llm(Box<ErrorData>)` | An error from the LLM provider: the lithos `ErrorData`, the stored form of a lithos `Error`. |
| `SessionClosed` | `process_input` was called on a closed session. |
| `InvalidState(String)` | The session is in an unexpected state. |
| `ToolExecution(String)` | A tool execution failed. |
| `Interrupted(InterruptReason)` | The session was cancelled or timed out. |
---
## LLM client (`fabro-llm`)
The `fabro-llm` crate is Fabro's integration layer over [lithos-llm](https://docs.rs/lithos-llm), a provider-neutral LLM catalog and client. lithos owns the request and response vocabulary, the provider catalog, the wire codecs, streaming, and retries. `fabro-llm` adds what Fabro needs on top: building the catalog from lithos built-ins plus Fabro policy and the operator `[llm]` overlay, constructing a client from a Fabro credential source, inlining local file attachments, normalizing reasoning output, one-shot structured output, model probes, and the `fabro exec` server gateway adapter.
Everything below the Fabro layer is the lithos API. `fabro_llm` re-exports the pieces Fabro code touches most: `Client`, `Request`, `Response`, `StreamEvent`, `Error`, `ErrorKind`, `FinishReason`, and the `lithos_catalog`, `types`, `middleware`, `adapter`, and `credentials` modules. See the lithos-llm README for the full client, middleware, and streaming contract.
```toml title="Cargo.toml"
[dependencies]
fabro-auth = { git = "https://github.com/fabro-sh/fabro" }
fabro-llm = { git = "https://github.com/fabro-sh/fabro" }
fabro-types = { git = "https://github.com/fabro-sh/fabro" }
tokio = { version = "1", features = ["full"] }
serde_json = "1"
```
### Quick start
Build a catalog, build a client over a credential source, then send a lithos `Request`. `VaultCredentialSource::environment_only()` reads provider keys such as `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, and `GEMINI_API_KEY` from the process environment.
```rust
use std::sync::Arc;
use fabro_auth::VaultCredentialSource;
use fabro_llm::{ClientOptions, Request};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let catalog = fabro_llm::default_catalog();
let built = fabro_llm::build_client(
catalog,
Arc::new(VaultCredentialSource::environment_only()),
ClientOptions::standard(),
)
.await?;
for issue in &built.build_issues {
eprintln!("provider {} is unavailable: {}", issue.provider, issue.cause);
}
let client = built.client;
let request = Request::builder()
.model("claude-sonnet-4.5")
.user("Explain ownership in Rust in two sentences.")
.build()?;
let response = client.complete(request).await?;
println!("{}", response.text());
println!("Tokens used: {}", response.usage.input + response.usage.billable_output());
Ok(())
}
```
### Catalog
`fabro_llm::default_catalog()` is the lithos built-in catalog with Fabro's policy layer applied. `fabro_llm::build_catalog(&overlay, &env_lookup)` adds an operator `[llm]` overlay on top, the same layering the server and CLI use. `fabro_config::load_llm_overlay(None)` reads that overlay from the active settings file.
```rust
use fabro_config::load_llm_overlay;
let overlay = load_llm_overlay(None)?;
let catalog = fabro_llm::build_catalog(&overlay, &|name| std::env::var(name).ok())?;
```
The `fabro_llm::catalog` module reads Fabro policy from the catalog: `enabled_providers`, `models`, `model_on_provider`, `default_model`, `probe_model`, `small_default_for_ready`, and `agent_profile`. Disabled providers and models are invisible to every query. `fabro_llm::selection` chooses a provider and model before a request exists, the way run creation and validation do: a known selector resolves to its canonical offering, `provider/model` pins the provider, and an unknown selector on a passthrough provider passes through verbatim.
### Client
`fabro_llm::build_client(catalog, credentials, options)` takes any lithos `CredentialProvider` and returns a `FabroClient`: the lithos `Client`, the providers that are ready, the providers whose credentials could not be used, and the providers lithos could not build an adapter for. Credentials are read from the provider on every attempt, so a refreshed OAuth token is picked up without rebuilding the client.
`ClientOptions::standard()` turns on the lithos retry middleware (three attempts with short exponential backoff) and local attachment inlining. Add middleware with `with_middleware`, replace a provider's adapter with `with_adapter`, or set `http` to inject a configured HTTP client. `fabro_llm::build_offline_client(catalog, options)` builds a client whose only providers are custom adapters, which is how `fabro exec --server` routes every call through a Fabro server.
Credential sources live in `fabro-auth`: `VaultCredentialSource` reads a Fabro vault with an optional process-environment fallback (`VaultCredentialSource::environment_only()` for SDK callers with no vault), and `SqlVaultCredentialSource` reads the server's secret store. lithos-llm decides which secret names a provider reads (`OPENAI_API_KEY`, `MODAL_TOKEN_ID` and `MODAL_TOKEN_SECRET`, or `<PROVIDER>_API_KEY` for an operator-defined provider); Fabro's vault is keyed by those same names.
#### Requests and responses
`Request::builder()` is the lithos request builder. `model` takes a `provider/model` route, a model id or alias, or a provider id. `system`, `user`, and `message` add messages; `tool`, `tool_choice`, `response_format`, `max_output_tokens`, `temperature`, `reasoning_effort`, and `speed` set controls. `client.complete(request)` returns a `Response` whose `content` is a list of `ContentPart` values, with `text()` and `tool_calls()` helpers, plus `finish_reason`, `usage`, and `cost`.
```rust
use fabro_llm::Request;
use lithos_llm::types::{Message, Role};
let request = Request::builder()
.model("openai/gpt-5.4")
.system("You are a helpful assistant.")
.message(Message::text(Role::User, "What is the capital of France?"))
.temperature(0.0)
.build()?;
let response = client.complete(request).await?;
println!("{}", response.text());
```
There is no tool-execution loop in `fabro-llm`. The agent loop lives in `fabro-agent`, which decides when to run a tool and feeds results back as `Role::Tool` messages.
### Streaming
`client.stream(request)` returns a lithos `ResponseStream`, a `Stream` of `StreamEvent` values. Events are discriminated by `type` on the wire: `started`, `content_block_start`, `text_delta`, `reasoning_delta`, `tool_call_delta`, `content_block_end`, `usage`, `rate_limits`, and `ended`, which carries the complete `Response`.
```rust
use fabro_llm::StreamEvent;
use futures::StreamExt;
let mut stream = client.stream(request).await?;
while let Some(event) = stream.next().await {
match event? {
StreamEvent::TextDelta { text, .. } => print!("{text}"),
StreamEvent::Ended { response } => {
println!("\n[done: {:?}]", response.finish_reason);
}
_ => {}
}
}
```
A turn that ends with `FinishReason::Length` or `FinishReason::Incomplete` is not complete. Tool calls from such a turn arrive in `response.suppressed_tool_calls` and must not be executed. `fabro-agent` treats both as a retryable failure of the turn.
### Structured output
`Client::complete_object` (a lithos method) attaches a JSON Schema as the request's response format and parses the reply into a `StructuredCompletion` with the response and the parsed document:
```rust
use fabro_llm::Request;
use serde_json::json;
let schema = json!({
"type": "object",
"properties": {
"name": { "type": "string" },
"age": { "type": "integer" }
},
"required": ["name", "age"]
});
let request = Request::builder()
.model("claude-sonnet-4.5")
.user("Generate a profile for a fictional character")
.build()?;
let completion = client.complete_object(request, "profile", schema).await?;
println!("Name: {}", completion.object["name"]);
```
### Reasoning
`response.reasoning()` (a lithos method) folds a response's readable reasoning parts into a `ReasoningOutput` with a summary and a trace, whichever channel the provider used. Provider replay data such as signatures and encrypted reasoning never appears in it; `ContentPart::is_replay_material()` marks the parts a conversation keeps for the next request instead.
### Middleware
Middleware is the lithos `Middleware` trait: `handle(&self, call: Call, next: Next)` sees the resolved route and request and returns an `Output` that is either a complete response or a stream. `ClientOptions::standard()` installs lithos's `InlineLocalFiles`, which rewrites local file paths in messages into inline media before dispatch.
### Error handling
Every fallible operation returns `Result<T, fabro_llm::Error>`, the lithos error. `error.kind()` is an `ErrorKind` such as `Authentication`, `RateLimit`, `Server`, `ContextLength`, `ContentFilter`, `Timeout`, `StreamDecode`, or `Cancelled`. `error.data()` is the `ErrorData` snapshot Fabro stores in run events; it reads like `Error`, prints its message, and implements `std::error::Error`.
Both `Error` and `ErrorData` answer the policy questions directly; only the loop-detection signature is Fabro's:
| Function | Description |
|---|---|
| `error.is_retryable()` | Safe to retry with the same provider, from lithos's retry classification |
| `error.failover_eligible()` | Safe to try a different provider |
| `error.is_auth_error()` | The credential was missing or rejected |
| `error.is_cancelled()` | The caller cancelled the call |
| `fabro_llm::failure_signature_hint(&data)` | A stable string for loop and restart detection |
### Retries
The lithos `RetryMiddleware` installed by `ClientOptions::standard()` retries a request until its stream delivers visible output. After visible output the client never replays on its own; `fabro-agent` decides whether to replay a turn using `RetryPolicy::next_delay`, the same decision the middleware uses. Insert a `fabro_llm::RetryListener` into a call's context extensions to be told about each retry the middleware performs.
### Cancellation
Pass a `CallContext` with a cancellation token through `complete_with_context` or `stream_with_context`. Cancelling the token ends the call with `ErrorKind::Cancelled`.
```rust
use fabro_llm::CallContext;
let context = CallContext::new();
let cancel = context.cancellation().clone();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
cancel.cancel();
});
let result = client.complete_with_context(request, context).await;
```
### Probes
`fabro_llm::probe::run_model_test(&client, "provider/model", mode, reasoning_effort, timeout)` sends the lithos model probe: one word in `Basic` mode, a two-step tool exchange in `Deep` mode. `probe_provider_with_api_key` validates an operator-supplied key against a provider's probe model before it is stored.
### Provider adapters
Providers are lithos adapters selected by the catalog `adapter` id: `anthropic`, `openai`, `gemini`, `openai-compatible`, and `bedrock`. A new OpenAI-compatible endpoint needs a catalog entry, not code.
To add a custom transport, implement the lithos `ProviderAdapter` trait and register it with `ClientOptions::with_adapter`. `fabro_llm::gateway::GatewayAdapter` is Fabro's own example: it posts each request to a Fabro server's completions endpoint, which returns lithos `Response` JSON and streams lithos `StreamEvent` JSON verbatim.
```rust
use std::sync::Arc;
use fabro_llm::ClientOptions;
use fabro_llm::gateway::GatewayAdapter;
use lithos_llm::catalog::ProviderId;
let adapter = Arc::new(GatewayAdapter::new(Box::new(my_transport)));
let built = fabro_llm::build_offline_client(
catalog,
ClientOptions::default().with_adapter(ProviderId::new("anthropic"), adapter),
)?;
```