fabro/docs/public/reference/sdk.mdx
Bryan Helmkamp 2e8d6b8a3d
Merge origin/main into the sandbox-driver adoption
Both sides rewrote the same crates. This branch replaced fabro's sandbox
layer with the sandbox driver: one RunSandbox, no Sandbox trait, driver
events consumed directly, MockSandbox over the driver's doubles. Main
replaced fabro's LLM layer with lithos-llm: fabro-model deleted, the
catalog and provider ids from lithos, credentials through the lithos
CredentialProvider, clients built with build_client.

Every conflict was one of those two renames meeting in an import list or
a signature, so the rule was mechanical: sandbox names resolve to this
branch, LLM names to main. Where main's newer code still used the old
sandbox API — new session tests over Arc::new(MockSandbox), the SDK
example's LocalSandbox, test fakes typed as Arc<dyn Sandbox> — it is
ported to RunSandbox and the mock helper. Where this branch still used
fabro-model or Client::from_source, main's replacement stands. One
combined future in the CLI runner crossed clippy's size budget and is
boxed at its call.

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

527 lines
24 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, Session, SessionOptions, local_sandbox};
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(local_sandbox(PathBuf::from(".")).await?);
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<RunSandbox>,
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
`RunSandbox` is where tools execute: the local filesystem, a Docker container,
or a cloud sandbox. It is one concrete type over a
[sandbox-driver](https://github.com/lithoscomputer/sandbox-driver) sandbox,
and every tool operation goes through it. Paths resolve against the run's
working directory, commands run as Bash with fabro's timeout and stop policy,
and output is drained even when the retained copy is capped.
```rust
impl RunSandbox {
pub async fn read_file_bytes(&self, path: &str) -> Result<Vec<u8>>;
pub async fn read_file_text(&self, path: &str) -> Result<String>;
pub async fn read_file(&self, path: &str, offset: Option<usize>, limit: Option<usize>) -> Result<String>;
pub async fn write_file(&self, path: &str, content: &str) -> Result<()>;
pub async fn delete_file(&self, path: &str) -> Result<()>;
pub async fn file_exists(&self, path: &str) -> Result<bool>;
pub async fn list_directory(&self, path: &str, depth: Option<usize>) -> Result<Vec<DirEntry>>;
pub 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>;
pub async fn grep(&self, pattern: &str, path: &str, options: &GrepOptions) -> Result<Vec<GrepMatch>>;
pub async fn walk_files(&self, base: &str, relative_start: &str, options: &WalkOptions) -> Result<Vec<SandboxFile>>;
pub async fn glob(&self, pattern: &str, path: Option<&str>) -> Result<Vec<String>>;
pub async fn initialize(&self) -> Result<()>;
pub async fn cleanup(&self) -> Result<()>;
pub fn working_directory(&self) -> &str;
pub fn platform(&self) -> &str;
pub fn os_version(&self) -> String;
// ... plus git setup and push, credentials refresh, preview URLs, and access commands.
}
```
`DirEntry`, `GrepMatch`, `GrepOptions`, and `WalkOptions` are the driver's own
types, re-exported from `fabro_sandbox`.
**Constructors:**
| Function | Description |
|---|---|
| `local_sandbox(directory)` | Executes directly on the local filesystem through the sandbox driver Host provider. |
| `provider_sandbox(kind, ...)` | Runs on any sandbox driver provider by kind: the bundled `docker` and `daytona` providers in process, or a configured plugin. |
**Testing:** `fabro_sandbox::test_support::MockSandbox` (behind the
`test-support` feature) describes a scripted sandbox by its fields — seeded
files, the result every command returns, the platform — and hands out the
`RunSandbox` with `.sandbox()`. Afterwards it reads back what the code did:
`captured_commands()`, `written_files()`, `deleted_files()`, and so on.
### 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: &RunSandbox, ...) -> 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),
)?;
```