fabro/docs/public/reference/sdk.mdx
Bryan Helmkamp 18a3c4741e
Run agent stages, Ask Fabro, and fabro exec on pebble's CodingAgent
Replace fabro's hand-written agent loop with pebble's `CodingAgent` and
delete the `fabro-agent` crate.

Workflow: `PebbleBackend` builds one agent per stage over `RunSandbox`,
binds the stage's hooks as tool middleware, the interviewer as the
human-input provider, and a durable `EventSink` that writes every agent
event through the run event log before the agent goes on. Full-fidelity
threads continue across stages through `export`/`resume_from_export`.
Model failover takes the session record after the failed prompt and
continues it on the next route with `ResumeMode::UseModel`, so no tool
effect repeats. The steering hub targets pebble's control handle, with
a steering lease holding completion open while a human is paired.

Events: `EventBody::Agent` carries pebble's `CodingAgentEvent` envelope;
the per-variant bodies, the transcript projection, and the fabro-only
context-window, tool-summary, and skill types are gone in favor of
pebble's. The OpenAPI schemas, generated Rust and TypeScript clients,
and web readers follow.

Ask Fabro: the session runs a `CodingAgent` under a read-only permission
policy and a system prompt transform. Its conversation lives in a new
`run_session_records` table and resumes on the recorded model with the
event cursor advanced past the run log.

`fabro exec` builds the same agent over a local sandbox with pebble's
permission middleware and an interactive approval service.

The catalog fills in `metadata.agent.profile` for operator providers
that declare none, so pebble's lookup is the one resolution path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:19:15 -06:00

468 lines
25 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:
- **`pebble-coding-agent`** — the coding agent Fabro runs its agent stages, Ask Fabro sessions, hook evaluators, and `fabro exec` on. Use it with `fabro-sandbox` when you want 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 can be used independently of Fabro's workflow engine.
## Agent (`pebble-coding-agent` over `fabro-sandbox`)
Fabro does not ship its own agent loop. Its agent stages run pebble's `CodingAgent`, and `fabro-sandbox`'s `RunSandbox` is the `Environment` the agent's tools act through: the local filesystem, a Docker container, or a cloud sandbox. The agent loop streams model responses, executes tool calls (`shell`, `read_file`, `write_file`, `edit_file`, `apply_patch`, `glob`, `grep`, `web_fetch`, `web_search`, subagents), feeds results back, and repeats until the model answers or a limit is hit.
```toml title="Cargo.toml"
[dependencies]
fabro-auth = { git = "https://github.com/fabro-sh/fabro" }
fabro-llm = { git = "https://github.com/fabro-sh/fabro" }
fabro-sandbox = { git = "https://github.com/fabro-sh/fabro" }
pebble-coding-agent = { git = "https://github.com/lithoscomputer/pebble" }
tokio = { version = "1", features = ["full"] }
```
Pin `pebble-coding-agent` to the revision Fabro's workspace `Cargo.toml` pins; `RunSandbox` implements that revision's `Environment` contract.
### Quick start
```rust
use std::path::PathBuf;
use std::sync::Arc;
use fabro_auth::VaultCredentialSource;
use fabro_llm::ClientOptions;
use fabro_sandbox::local_sandbox;
use pebble_coding_agent::environment::Environment;
use pebble_coding_agent::events::CodingEvent;
use pebble_coding_agent::tools::PermissionLevel;
use pebble_coding_agent::{CodingAgent, ShutdownReason};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let catalog = fabro_llm::default_catalog();
let client = fabro_llm::build_client(
catalog,
Arc::new(VaultCredentialSource::environment_only()),
ClientOptions::standard(),
)
.await?
.client;
let sandbox: Arc<dyn Environment> = Arc::new(local_sandbox(PathBuf::from(".")).await?);
let mut agent = CodingAgent::builder(client, sandbox)
.model("anthropic/claude-sonnet-4.5")
.permission_level(PermissionLevel::Full)
.build()
.await?;
// Subscribe to events before sending input
let mut events = agent.subscribe();
tokio::spawn(async move {
while let Ok(event) = events.recv().await {
if let CodingEvent::TextDelta { delta } = &event.event {
print!("{delta}");
}
}
});
let report = agent.prompt("List the files in this directory").await;
agent.shutdown(ShutdownReason::Completed).await?;
report.result?;
Ok(())
}
```
### CodingAgent
`CodingAgent` is the core type. `CodingAgent::builder(client, environment)` takes the lithos client and the environment; the builder picks the model (`provider/model`), the permission level, tool middleware, application tools, a human-input provider, a system prompt transform, an event sink, options, and subagent limits. `build()` initializes the agent: it probes the environment, loads memory files and skills, and assembles the system prompt.
**Lifecycle methods:**
| Method | Description |
|---|---|
| `prompt(input).await` | Runs one user prompt and every queued follow-up to completion. Returns a `PromptReport` with the result, token usage, cost, and timing. |
| `prompt_with_cancellation(input, &token).await` | The same, ending early when the token fires. The agent stays reusable. |
| `continue_prompt_with_cancellation(&token).await` | Continues an unfinished prompt on the history as it stands, such as after a model failover. |
| `shutdown(reason).await` | Ends the agent, emits `SessionEnded`, and flushes events. |
| `control_handle()` | A cloneable handle for steering, interrupting, and aborting from another task. |
**Inspection:**
| Method | Description |
|---|---|
| `history()` | The conversation as `History` (a sequence of `Message` values). |
| `snapshot()` | The agent's identity, route, tools, memory, and skills at the last committed event. |
| `subscribe()` | A broadcast receiver for `CodingAgentEvent` values. |
| `to_record()` | The durable `SessionRecord`, restored with `CodingAgent::resume`. |
**Steering** goes through the control handle: `queue_steering(message)` injects guidance at the next turn boundary, `steer_now(message)` interrupts the round first, `interrupt()` parks the prompt until a steer arrives, and `queue_follow_up(message)` queues another user turn.
### CodingAgentOptions
Set with the builder's `.options(...)`. Key settings with their defaults:
| Setter | Default | Description |
|---|---|---|
| `with_reasoning_effort` / `with_speed` | `None` | Request controls for the model. |
| `with_max_tokens` | catalog default | The most tokens the model may produce per turn. |
| `with_loop_detection` | `true` | Stop a session that is repeating itself. |
| `with_context_compaction` | `true` | Summarize old turns when approaching the context window limit. |
| `with_compaction_threshold_percent` | `80` | Context window usage that triggers compaction. |
| `with_wall_clock_timeout` | `None` | Hard timeout for a prompt. Reported as `InterruptReason::WallClockTimeout`. |
| `with_max_turns` | unlimited | The most model turns one prompt may use. |
| `with_memory_files` | none | Files loaded into the system prompt as memory (Fabro passes `AGENTS.md` and the profile's own file). |
| `with_skill_dirs` | none | Directories searched for `SKILL.md` files. |
Subagents are enabled with `.subagents(SubagentOptions::enabled())`; `SubagentLimits` bounds how many child sessions may be open at once.
### 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.
}
```
`RunSandbox` also implements pebble's `Environment` trait, so an `Arc<RunSandbox>` is what a `CodingAgent` is built over. The mapping lives in `fabro_sandbox::environment` and is checked against pebble's environment contract suite.
`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
Pebble picks the harness profile (system prompt, tool vocabulary, and capability defaults) from the catalog: `metadata.agent.profile` on the model, else on the provider. The `AgentProfileKind` values are `anthropic`, `claude-5`, `openai`, `gemini`, `kimi`, `gpt56`, and `gpt6`. Every lithos built-in provider declares its profile; `fabro_llm::build_catalog` fills in the profile implied by the adapter for an operator-defined provider that declares none, and `fabro_llm::catalog::agent_profile(catalog, provider, model)` reports the resolved profile.
### Events
All operations emit `CodingAgentEvent` values (a `CodingEvent` plus session ids, a sequence number, and a timestamp) through a tokio broadcast channel. Subscribe before calling `prompt()`. For a complete durable record install an `EventSink` with the builder; the broadcast channel is bounded and can lag.
```rust
let mut rx = agent.subscribe();
tokio::spawn(async move {
while let Ok(event) = rx.recv().await {
match event.event {
CodingEvent::TextDelta { delta } => print!("{delta}"),
CodingEvent::ToolCallStarted { tool_name, .. } => {
println!("[calling {tool_name}]");
}
CodingEvent::ToolCallCompleted { tool_name, is_error, .. } => {
println!("[{tool_name} done, error={is_error}]");
}
CodingEvent::LoopDetected => println!("[loop detected]"),
CodingEvent::CompactionCompleted { .. } => println!("[context compacted]"),
_ => {}
}
}
});
```
Key `CodingEvent` 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 `ErrorData` occurred. |
| `LoopDetected` | The agent is repeating itself. |
| `CompactionStarted` / `CompactionCompleted` | Context window compaction. |
| `SubAgentSpawned` / `SubAgentCompleted` | Sub-agent lifecycle. |
| `SteeringInjected` / `RoundInterrupted` | Steering and interrupts. |
Fabro stores every one of these as an `agent.*` run event whose properties are the `CodingAgentEvent` envelope; `fabro_types::coding_event_name` maps a variant to its run event name.
### Tool middleware
Implement pebble's `ToolMiddleware` to intercept tool calls for approval, logging, or transformation, and install it with the builder's `.tool_middleware(...)`. Fabro's `fabro_hooks::WorkflowToolHookCallback` is one: it runs the workflow's `pre_tool_use` hooks before each call and the `post_tool_use` hooks after.
```rust
use async_trait::async_trait;
use pebble_agent::{ToolCallNext, ToolCallRequest, ToolErrorKind, ToolMiddleware, ToolOutcome, ToolSystemError};
struct MyHooks;
#[async_trait]
impl ToolMiddleware for MyHooks {
async fn call(
&self,
request: ToolCallRequest,
next: ToolCallNext<'_>,
) -> Result<ToolOutcome, ToolSystemError> {
if request.call().name == "shell" {
return Ok(ToolOutcome::failure(ToolErrorKind::Denied, "shell is not allowed"));
}
next.run(request).await
}
}
```
For permission gating, `PermissionMiddleware::new(policy)` hides tools a `ToolPermissionPolicy` denies and routes the rest through an optional `ToolApprovalService`; `PermissionLevelPolicy::new(level)` is the read-only, read-write, full ladder `fabro exec --permissions` uses.
### Error handling
`PromptReport::result` is `Result<PromptOutput, pebble_coding_agent::Error>`:
| Variant | Description |
|---|---|
| `Llm(lithos_llm::Error)` | An error from the LLM provider. `llm_source()` reaches it from any variant that wraps one. |
| `SessionClosed` | A prompt was sent to a closed agent. |
| `InvalidState(String)` | The agent is in an unexpected state. |
| `ToolExecution(String)` | A tool execution failed in a way that stops the prompt. |
| `Interrupted(InterruptReason)` | The prompt was cancelled, timed out, or used every allowed turn. |
| `EventSink(EventSinkError)` | The durable event sink refused an event; the recorded stream is untrustworthy. |
---
## 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 `pebble-coding-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. The coding 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; the coding 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),
)?;
```