# Embacle — LLM Runners
[](https://crates.io/crates/embacle)
[](https://docs.rs/embacle)
[](https://github.com/dravr-ai/dravr-embacle/actions/workflows/ci.yml)
[](LICENSE.md)
Standalone Rust library that wraps 12 AI CLI tools, SDKs, and a browser-driven web UI as pluggable LLM providers, with vision/image support.
Instead of integrating with LLM APIs directly (which require API keys, SDKs, and managing auth), **Embacle** delegates to tools the user has already installed and authenticated — getting model upgrades, auth management, and protocol handling for free. For GitHub Copilot, an optional headless mode communicates via the ACP (Agent Client Protocol) for SDK-managed tool calling.
The same "use what you've already logged into" idea extends to the browser: an optional **web-UI provider** drives the Claude.ai web app through a headless Chrome session and a one-time login — no API key, on your existing Claude.ai account — and is exposed over both the REST API and MCP. See [docs/web-ui.md](docs/web-ui.md).
## Run It
Embacle ships as two ready-to-run servers — no code required:
**OpenAI-compatible HTTP server** — drop-in replacement for any OpenAI client:
```bash
embacle-server --provider copilot --port 3000
```
```bash
curl http://localhost:3000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model": "copilot", "messages": [{"role": "user", "content": "hello"}]}'
```
**MCP server** — connect Claude Desktop, editors, or any MCP client directly:
```bash
# stdio (editor integration)
embacle-mcp --provider copilot
# HTTP (network-accessible)
embacle-mcp --transport http --port 3000 --provider copilot
```
Both modes support all 12 CLI runners, streaming, model routing, and vision. See [REST API Server](#rest-api-server-embacle-server) and [MCP Server](#mcp-server-embacle-mcp) for full details.
## Table of Contents
- [Install](#install)
- [Supported Runners](#supported-runners)
- [Quick Start](#quick-start)
- [REST API Server](#rest-api-server-embacle-server)
- [Unified Harness Protocol](#unified-harness-protocol-uhp)
- [MCP Server](#mcp-server-embacle-mcp)
- [OpenAI API](#openai-api-feature-flag)
- [HTTP API providers](#http-api-providers-feature-flag)
- [Fallback chains](#fallback-chains)
- [Copilot Headless](#copilot-headless-feature-flag)
- [Vision / Image Support](#vision--image-support)
- [Docker](#docker)
- [C FFI Static Library](#c-ffi-static-library)
- [Architecture](#architecture)
- [Tested With](#tested-with)
- [License](#license)
## Install
### Homebrew (macOS / Linux) — recommended
```bash
brew tap dravr-ai/tap
brew install embacle
```
This installs two binaries:
- **`embacle-server`** — OpenAI-compatible REST API + MCP server
- **`embacle-mcp`** — standalone MCP server for editor integration
### Docker
```bash
docker pull ghcr.io/dravr-ai/embacle:latest
docker run -p 3000:3000 ghcr.io/dravr-ai/embacle --provider copilot
```
### Cargo (library)
```toml
[dependencies]
embacle = "0.38"
```
## Supported Runners
### CLI Runners (subprocess-based)
| Runner | Binary | Features |
|--------|--------|----------|
| Claude Code | `claude` | JSON output, streaming, system prompts, unsaved sessions with no auto-memory, `CLAUDE.md` or project settings from the shared cwd |
| GitHub Copilot | `copilot` | Text parsing, streaming |
| Cursor Agent | `cursor-agent` | JSON output, streaming, MCP approval |
| OpenCode | `opencode` | JSON events |
| Gemini CLI | `gemini` | JSON/stream-JSON output, streaming |
| Codex CLI | `codex` | JSONL output, streaming, sandboxed exec mode |
| Goose CLI | `goose` | JSON/stream-JSON output, streaming, no-session mode |
| Cline CLI | `cline` | NDJSON output, streaming |
| Continue CLI | `cn` | JSON output, single-shot completions |
| Warp | `oz` | NDJSON output |
| Kiro CLI | `kiro-cli` | ANSI-stripped text output, auto model selection |
| Kilo Code | `kilo` | NDJSON output, streaming, token tracking, 500+ models via Kilo Gateway |
Every CLI runner is stateless: each `complete()` / `complete_stream()` call starts a fresh CLI session and never resumes an earlier one. A request carries its whole conversation, and one runner instance typically serves every caller in a process, so resuming a previous call's session would put one caller's conversation into another caller's context.
### HTTP API Runners (feature-flagged)
| Runner | Feature Flag | Features |
|--------|-------------|----------|
| OpenAI API | `http-api` | `OpenAiCompatibleProvider` on `OPENAI_API_*`: the OpenAI API or any endpoint serving it, streaming, tool calling, vision, model discovery |
| Anthropic API | `http-api` | Messages API (Claude Haiku / Sonnet / Opus 5.5), streaming, tool use, vision, prompt-cache breakpoints, cache read/write usage, a key pool across organizations |
| Google Gemini | `http-api` | Generative Language API, streaming, function calling, cache-read usage, retries thinking-only answers |
| Cohere | `http-api` | v2 chat API (Command A / Command R), typed SSE envelope, tool calling, billed-units usage |
| Groq | `http-api` | OpenAI-shaped chat on LPU inference, streaming, tool calling, 429 wait parsing |
| OpenRouter | `http-api` | One key for 200+ upstream models, ranking headers, cached and reasoning token usage |
| OpenAI-compatible (local) | `http-api` | Ollama, vLLM, LocalAI or any self-hosted endpoint, named after the endpoint it targets |
### ACP Runners (persistent connection)
| Runner | Feature Flag | Features |
|--------|-------------|----------|
| GitHub Copilot Headless | `copilot-headless` | NDJSON/JSON-RPC via `copilot --acp`, SDK-managed tool calling, streaming |
### Browser Web UI Runners (feature-flagged)
| Runner | Feature Flag | Features |
|--------|-------------|----------|
| Claude (web) | `web-ui` | Drives the Claude.ai web UI via a headless browser + persistent login, streaming, no API key — see [docs/web-ui.md](docs/web-ui.md) |
Built on the standalone [`dravr-browser`](https://github.com/dravr-ai/dravr-browser)
crate. Exposed over REST + MCP by `embacle-server` (the `web-ui` feature is on by
default there). Driving a consumer web UI is generally against the provider's
ToS — intended for your own account; off by default in the core library.
## Quick Start
Use a CLI runner:
```rust
use std::path::PathBuf;
use embacle::{ClaudeCodeRunner, RunnerConfig};
use embacle::types::{ChatMessage, ChatRequest, LlmProvider};
#[tokio::main]
async fn main() -> Result<(), embacle::types::RunnerError> {
let config = RunnerConfig::new(PathBuf::from("claude"));
let runner = ClaudeCodeRunner::new(config);
let request = ChatRequest::new(vec![
ChatMessage::user("What is the capital of France?"),
]);
let response = runner.complete(&request).await?;
println!("{}", response.content);
Ok(())
}
```
## REST API Server (`embacle-server`)
A unified HTTP server that proxies requests to embacle runners, speaking three protocols at once: the OpenAI chat completions API, MCP, and the [Unified Harness Protocol](#unified-harness-protocol-uhp). Any client that speaks one of them can use it without modification, and the three are served side by side rather than in place of one another. Supports `--transport stdio` for MCP-only mode (editor integration).
### Usage
```bash
# Start with default provider (copilot) on localhost:3000
embacle-server
# Specify provider and port
embacle-server --provider claude_code --port 8080 --host 0.0.0.0
# MCP-only mode via stdio (for editor/client integration)
embacle-server --transport stdio --provider copilot
```
### Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/v1/chat/completions` | Chat completion (streaming and non-streaming) |
| `GET` | `/v1/models` | List available providers and models |
| `GET` | `/health` | Per-provider readiness check |
| `POST` | `/mcp` | MCP Streamable HTTP (JSON-RPC 2.0) |
| — | `/uhp/v1/*` | [Unified Harness Protocol](#unified-harness-protocol-uhp), 25 endpoints under a configurable base |
### MCP Streamable HTTP
The server also speaks [MCP](https://modelcontextprotocol.io/) at `POST /mcp`, accepting JSON-RPC 2.0 requests. Any MCP-compatible client can connect over HTTP instead of stdio. It exposes the same tool registry as the standalone [`embacle-mcp`](#mcp-tools) server (`get_provider`, `set_provider`, `get_model`, `set_model`, `get_multiplex_provider`, `set_multiplex_provider`, `prompt`).
```bash
# MCP initialize handshake
curl http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl"}}}'
# Call the prompt tool
curl http://localhost:3000/mcp \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"prompt","arguments":{"messages":[{"role":"user","content":"hello"}]}}}'
```
Add `Accept: text/event-stream` to receive SSE-wrapped responses instead of plain JSON.
### Model Routing
The `model` field determines which provider handles the request. Use a `provider:model` prefix to target a specific runner, or pass a bare model name to use the server's default provider.
```bash
# Explicit provider
curl http://localhost:3000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model": "claude:opus", "messages": [{"role": "user", "content": "hello"}]}'
# Default provider
curl http://localhost:3000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model": "gpt-5.4", "messages": [{"role": "user", "content": "hello"}]}'
```
### Multiplex
Pass an array of models to fan out the same prompt to multiple providers concurrently. Each provider runs in its own task; failures in one don't affect others.
```bash
curl http://localhost:3000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model": ["copilot:gpt-4o", "claude:opus"], "messages": [{"role": "user", "content": "hello"}]}'
```
The response uses `object: "chat.completion.multiplex"` with per-provider results and timing.
Streaming is not supported for multiplex requests.
### SSE Streaming
Set `"stream": true` for Server-Sent Events output in OpenAI streaming format (`data: {json}\n\n` with `data: [DONE]` terminator).
### Tool Calling
`/v1/chat/completions` accepts OpenAI-style `tools` and `tool_choice` and returns `tool_calls`. The `tool_execution` field selects who runs the tools:
- **`"client"`** (default) — the model's `tool_calls` are returned for you to execute and resubmit (standard OpenAI flow). Works for every runner: providers with native function calling use it directly; others use a text-based `<tool_call>` simulation.
- **`"server"`** — the server runs an autonomous agent loop, executing tool calls against its own configured MCP tool servers and returning the final answer.
When streaming (`"stream": true`) with tools, tool calls are emitted incrementally as OpenAI `tool_calls` deltas.
#### Server-side execution (MCP)
Configure downstream MCP tool servers — connected via the official [`rmcp`](https://crates.io/crates/rmcp) client (default `mcp-tools` feature) — in `embacle.toml`:
```toml
[[mcp_servers]]
name = "filesystem"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
```
…or via the `EMBACLE_MCP_SERVERS` environment variable (a JSON array of the same objects). Then let the server run the tools:
```bash
curl http://localhost:3000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{"model": "copilot", "tool_execution": "server",
"messages": [{"role": "user", "content": "What files are in /tmp?"}]}'
```
The server discovers each configured server's tools on startup. If the request also supplies `tools`, server-side execution is restricted to the named subset.
### Authentication
Optional. Set `EMBACLE_API_KEY` to require bearer token auth on all endpoints. When unset, all requests are allowed through (localhost development mode). The env var is read per-request, so key rotation doesn't require a restart.
```bash
EMBACLE_API_KEY=my-secret embacle-server
curl http://localhost:3000/v1/models -H "Authorization: Bearer my-secret"
```
## Unified Harness Protocol (UHP)
`embacle-server` speaks the [Unified Harness Protocol](https://unifiedharnessprotocol.org)
`2026-08-11` — an open HTTP contract for driving agent harnesses behind one API. It scores
**64/64 on the published conformance suite at class `full`**: no failures, no skips.
```
64/64 passed · 0 failed · 0 skipped · 0 errored
CONFORMANT — UHP 2026-08-11 (full)
```
embacle already runs twelve CLI harnesses behind one trait, which is exactly what UHP calls a
*runner*: a server that puts existing harnesses behind the contract and advertises them as its
catalog. UHP serves that catalogue over a standard wire format, so anything that speaks the
protocol can drive Claude Code, Codex, Gemini CLI or any other supported harness through this
server without knowing which one it is talking to.
The UHP surface is built for clients outside this repository: embacle is public, and anyone may run
`embacle-server` as their UHP runner. No dravr service calls it — dravr-platform uses embacle as a
library — so its contract is kept for those external users.
### It sits beside the OpenAI API, not instead of it
Both surfaces define `GET /v1/models` and the bodies are incompatible — the OpenAI API answers a
flat `{"object":"list","data":[…]}`, UHP requires `{"backends":{"<id>":{"default":…,"models":[…]}}}`.
So UHP mounts under its own base path and **nothing on `/v1` changes**. Existing OpenAI clients are
unaffected.
The protocol expects this: its published `servers:` block lists a base of `/api/harness`, and the
conformance suite takes a `--base-url`.
```bash
# Default base is /uhp
embacle-server --port 3000
# Or mount it anywhere, including the root
UHP_BASE_PATH=/api/harness embacle-server --port 3000
UHP_BASE_PATH=/ embacle-server --port 3000
```
### What it serves
| Area | Endpoints |
|------|-----------|
| Discovery | `GET /v1/uhp` (unauthenticated), `GET /v1/harnesses`, `GET /v1/models`, `GET /v1/harnesses/{id}/models` |
| Tasks | `POST /v1/responses`, `GET`/`DELETE /v1/responses/{id}`, `POST /v1/responses/{id}/cancel` |
| Sessions | `GET /v1/sessions`, `GET`/`DELETE /v1/sessions/{id}`, `GET /v1/sessions/{id}/turns` |
| Sharing | `POST`/`GET`/`DELETE /v1/sessions/{id}/share`, and the public `GET /share/{id}` view |
| Files | `GET /v1/sessions/{id}/files`, `GET /v1/containers/{id}/files/{id}/content` |
| Management | `POST`/`PUT`/`DELETE /v1/harnesses`, `GET /v1/harnesses/{id}/skills/{name}/files` |
Paths are relative to `UHP_BASE_PATH`.
### Running a task
```bash
# Discovery needs no credential — a client must be able to learn what this is
# before deciding what to present
curl http://localhost:3000/uhp/v1/uhp
# Run one
curl http://localhost:3000/uhp/v1/responses \
-H "Authorization: Bearer $EMBACLE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"input": "Reply with exactly: ok", "stream": false}'
# Stream it instead
curl -N http://localhost:3000/uhp/v1/responses \
-H "Authorization: Bearer $EMBACLE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"input": "Count to three", "stream": true}'
```
A task runs in its own session working folder, so files the harness writes are captured as session
artifacts and downloaded with `X-Content-Type-Options: nosniff` — an artifact is content a model was
steered into producing, and must never render as a page on this origin. Deleting a session removes
that folder, so an artifact never outlives what produced it.
A harness you configure over `POST /v1/harnesses` runs tasks like a discovered one, with its skills,
MCP servers and disabled tools applied to the CLI underneath; a setting that CLI has no way to apply
refuses the task with `unsupported_harness_setting` instead of being dropped.
Full endpoint table, authentication rules, artifact handling and harness configuration are in
[docs/uhp.md](docs/uhp.md).
### Verifying conformance yourself
The suite is Apache-2.0 and ships inside the reference implementation. It runs real agent tasks by
design, so a full pass spends real model calls and several minutes.
```bash
git clone --depth 1 https://github.com/HarnessRouter/harnessrouter.git
python3 -m venv venv && ./venv/bin/pip install -e harnessrouter/protocol/conformance
EMBACLE_API_KEY=secret embacle-server --port 3000 &
./venv/bin/uhp-conformance --base-url http://127.0.0.1:3000/uhp \
--api-key secret --class full
```
## MCP Server (`embacle-mcp`)
A library and standalone binary that exposes embacle runners via the [Model Context Protocol](https://modelcontextprotocol.io/). Connect any MCP-compatible client (Claude Desktop, editors, custom agents) to use all embacle providers.
### Usage
```bash
# Stdio transport (default — for editor/client integration)
embacle-mcp --provider copilot
# HTTP transport (for network-accessible deployments)
embacle-mcp --transport http --host 0.0.0.0 --port 3000 --provider claude_code
```
### MCP Tools
| Tool | Description |
|------|-------------|
| `get_provider` | Get active LLM provider and list available providers |
| `set_provider` | Switch the active provider (`claude_code`, `copilot`, `copilot_headless`, `cursor_agent`, `opencode`, `gemini_cli`, `codex_cli`, `goose_cli`, `cline_cli`, `continue_cli`, `warp_cli`, `kiro_cli`, `kilo_cli`) |
| `get_model` | Get current model and list available models for the active provider |
| `set_model` | Set the model for subsequent requests (pass null to reset to default) |
| `get_multiplex_provider` | Get providers configured for multiplex dispatch |
| `set_multiplex_provider` | Configure providers for fan-out mode |
| `prompt` | Send chat messages to the active provider, or multiplex to all configured providers |
### Client Configuration
Add to your MCP client config (e.g. Claude Desktop `claude_desktop_config.json`):
```json
{
"mcpServers": {
"embacle": {
"command": "embacle-mcp",
"args": ["--provider", "copilot"]
}
}
}
```
## OpenAI API (feature flag)
The `http-api` feature's `OpenAiCompatibleProvider` talks to the OpenAI API, or to any endpoint that serves the same API:
```toml
[dependencies]
embacle = { version = "0.38", features = ["http-api"] }
```
```rust
use embacle::{OpenAiCompatibleConfig, OpenAiCompatibleProvider};
use embacle::types::{ChatMessage, ChatRequest, LlmProvider};
#[tokio::main]
async fn main() -> Result<(), embacle::types::RunnerError> {
// Reads OPENAI_API_BASE_URL (requests go to its /v1), OPENAI_API_KEY,
// OPENAI_API_MODEL and OPENAI_API_TIMEOUT_SECS from env
let config = OpenAiCompatibleConfig::openai_api_from_env();
let provider = OpenAiCompatibleProvider::new(config)?
.with_discovered_models()
.await;
let request = ChatRequest::new(vec![
ChatMessage::user("What is the capital of France?"),
]);
let response = provider.complete(&request).await?;
println!("{}", response.content);
Ok(())
}
```
It reports `openai_api`, the price-table key its usage bills under, and sends `top_p`, stop sequences, `response_format` and images, which it advertises. `with_discovered_models` publishes the endpoint's own `GET /v1/models` list. To inject a shared HTTP client (e.g. from a connection pool), use `OpenAiCompatibleProvider::with_client(config, client)`.
| Variable | Default | Description |
|----------|---------|-------------|
| `OPENAI_API_BASE_URL` | `https://api.openai.com/v1` | API base URL |
| `OPENAI_API_KEY` | *(none)* | Bearer token for authentication |
| `OPENAI_API_MODEL` | `gpt-5.4` | Default model for completions |
| `OPENAI_API_TIMEOUT_SECS` | `300` | HTTP request timeout |
## HTTP API providers (feature flag)
Enable the `http-api` feature for providers that call a vendor's HTTP API directly. Each one implements `LlmProvider`, accepts a caller-owned `reqwest::Client` through `with_client`, and carries tool calls on `ChatRequest::tools` / `ChatResponse::tool_calls`:
```toml
[dependencies]
embacle = { version = "0.38", features = ["http-api"] }
```
```rust
use embacle::http_api::{GeminiConfig, GeminiProvider};
use embacle::types::{ChatMessage, ChatRequest, LlmProvider};
#[tokio::main]
async fn main() -> Result<(), embacle::types::RunnerError> {
let provider = GeminiProvider::new(GeminiConfig::from_env()?)?;
let request = ChatRequest::new(vec![ChatMessage::user("What is the capital of France?")]);
let response = provider.complete(&request).await?;
println!("{}", response.content);
Ok(())
}
```
Each `XConfig::from_env()` reads its own variables; `XConfig::new(api_key)` and the `with_model` / `with_timeout` / `with_retry` builders set them programmatically.
| Provider | `name()` | Required | Optional |
|----------|----------|----------|----------|
| `AnthropicProvider` | `anthropic` | `ANTHROPIC_API_KEY` | `ANTHROPIC_API_KEY_2`, `ANTHROPIC_API_KEY_3`, … (pool), `ANTHROPIC_DEFAULT_MODEL` (`claude-haiku-5-5`), `ANTHROPIC_BASE_URL` (`https://api.anthropic.com`), `ANTHROPIC_MAX_TOKENS` (`16000`), `ANTHROPIC_EFFORT` (`low`…`max`), `ANTHROPIC_THINKING` (`adaptive` / `disabled`), `ANTHROPIC_MAX_RETRIES`, `ANTHROPIC_INITIAL_RETRY_DELAY_MS`, `ANTHROPIC_MAX_RETRY_DELAY_MS` |
| `GeminiProvider` | `gemini` | `GEMINI_API_KEY` | `GEMINI_DEFAULT_MODEL` (`gemini-flash-lite-latest`), `GEMINI_MAX_RETRIES`, `GEMINI_INITIAL_RETRY_DELAY_MS`, `GEMINI_MAX_RETRY_DELAY_MS` |
| `CohereProvider` | `cohere` | `COHERE_API_KEY` | `COHERE_DEFAULT_MODEL` (`command-a-03-2025`), `COHERE_MAX_RETRIES`, `COHERE_INITIAL_RETRY_DELAY_MS`, `COHERE_MAX_RETRY_DELAY_MS` |
| `GroqProvider` | `groq` | `GROQ_API_KEY` | `GROQ_DEFAULT_MODEL` (`llama-3.3-70b-versatile`), `GROQ_MAX_RETRIES`, `GROQ_INITIAL_RETRY_DELAY_MS`, `GROQ_MAX_RETRY_DELAY_MS` |
| `OpenRouterProvider` | `openrouter` | `OPENROUTER_API_KEY` | `OPENROUTER_DEFAULT_MODEL` (`meta-llama/llama-3.3-70b-instruct`), `OPENROUTER_SITE_URL`, `OPENROUTER_APP_TITLE`, `OPENROUTER_MAX_RETRIES`, `OPENROUTER_INITIAL_RETRY_DELAY_MS`, `OPENROUTER_MAX_RETRY_DELAY_MS` |
| `OpenAiCompatibleProvider` | `ollama` / `vllm` / `localai` / `local` | — | `LOCAL_LLM_BASE_URL` (`http://localhost:11434/v1`), `LOCAL_LLM_MODEL` (`qwen2.5:14b-instruct`), `LOCAL_LLM_API_KEY` |
Every provider maps HTTP statuses the same way (`embacle::http_api::client::map_http_error`): 401/403 → `AuthFailure`, 408/504 → `Timeout`, 402 (prepaid credit exhausted) → `RateLimit` with a `credit balance exhausted` message, 429 → `RateLimit` carrying the vendor's wait, 400/422 → `InvalidRequest`, anything else → `ExternalService`. A 402 is never retried in place. Retries in place on 429/502/503/529 and on connect/timeout transport errors follow `HttpRetryConfig` (3 retries, 500 ms initial, 5 s cap, exponential backoff — the vendor's stated wait is reported in the message, not slept on). Vendor exceptions: Cohere's "no valid response generated" 400/422 is a provider fault, so a fallback chain moves on; Anthropic's 413 and "prompt is too long" 400 are `ContextLength`, its 404 is `ModelUnavailable`, its 429 message carries the `retry-after` header's wait, and every Anthropic `RateLimit` (429 or 402) leaves the tier on the first refusal with no in-place retry, so a fallback chain cools it down and a pool's next account answers at once. A 529 overload is retried in place on each Anthropic account. Every provider's usage is priced by `embacle::pricing`, keyed on its `name()`.
The Anthropic provider leaves `thinking` unset by default so the model's adaptive default applies (lower `ANTHROPIC_EFFORT` to think less), and gates a configured `thinking` / `effort` per model so a mode the requested model refuses is left off rather than sent as a 400. A tool continuation replays text and `tool_use` blocks without the earlier signed thinking blocks: the API accepts it, though Anthropic's contract is to pass them back, so the model re-derives its reasoning after each tool result. A `tool_use` or `tool_result` without its partner travels as text. It sends `temperature` / `top_p` only to legacy models that accept them (the 5.5 family refuses them), downgrades a forced `tool_choice` to `auto` on the models that refuse it, and places three cache breakpoints: the last tool, the last system block, and the automatic conversation tail. A `stop_reason: "refusal"` discards any partial output and is returned empty with `finish_reason: "refusal"` — an empty completion, which `ResponsePolicy::strict()` moves past; a stream cannot recall the deltas it sent, so a streaming consumer checks the final chunk's reason.
### HTTP credential pools
`embacle::pool::provider_pool` turns already-built providers — one per API key — into ordered tiers for a `FallbackProvider`: account 1 keeps the provider's name, account N is `<name>#N`, a response keeps the model id the account ran, and every account prices as its family. `AnthropicConfig::pool_from_env()` reads `ANTHROPIC_API_KEY`, `ANTHROPIC_API_KEY_2`, … up to the first unset or blank key, one per Anthropic organization with its own prepaid credit:
```rust
use embacle::fallback::FallbackProvider;
use embacle::http_api::{AnthropicConfig, AnthropicProvider};
use embacle::pool::provider_pool;
use embacle::types::LlmProvider;
fn chain() -> Result<FallbackProvider, embacle::types::RunnerError> {
let client = reqwest::Client::new();
let accounts = AnthropicConfig::pool_from_env()?
.into_iter()
.map(|config| {
Box::new(AnthropicProvider::with_client(config, client.clone())) as Box<dyn LlmProvider>
})
.collect();
FallbackProvider::new(provider_pool(accounts)?)
}
```
A spent account answers 402, which is a `RateLimit`: the chain cools it down and the next account serves. `cli_pool` does the same for CLI runners, each account's token riding as explicit child environment.
## Fallback chains
`FallbackProvider` asks its tiers in order and returns the first accepted answer. Three policies sit between one tier and the next:
```rust
use std::sync::Arc;
use embacle::fallback::{FallbackProvider, ResponsePolicy, FallbackObserver, Tier, Attempt};
let chain = FallbackProvider::new(vec![primary, secondary, tertiary])?
.with_fallthrough(ResponsePolicy::strict())
.with_observer(Arc::new(MyBreaker::default()));
```
- **Retry in place** — `with_retry(RetryConfig)` re-asks the same tier with backoff while the error is transient (`ErrorKind::is_transient`).
- **Fall through** — `ResponsePolicy::strict()` moves to the next tier only on a provider fault (`ErrorKind::is_provider_fault`: timeout, upstream failure, auth failure, missing binary, unavailable model, internal fault); a malformed request, a guardrail refusal or a quota refusal propagates so the caller sees the real diagnostic. It also treats an `Ok` with blank content and no tool call as a failed tier, and clears `request.model` before the next tier so each provider resolves its own configured model. `ResponsePolicy::permissive()` (the default) moves on every error and accepts every `Ok`.
- **Observe** — a `FallbackObserver` sees every hop: `before_attempt` can veto a tier (`Attempt::Skip("reason")`, consulted only while a later tier exists), `on_fallthrough` names the tier passed over and why (`Skipped`, `EmptyCompletion`, `Error`), `on_success` names the tier that answered, `on_exhausted` carries the last error. Callbacks run synchronously on the calling task, so the caller's tracing span is live inside them — the seam for a circuit breaker that lives outside the chain.
`health_check` is `Ok(true)` as soon as any tier is healthy; otherwise the last tier's outcome is returned verbatim. `RouterProvider` is the deliberate sibling: it routes proactively on quota readings, where the chain reacts to outcomes, and the two compose.
`RouterProvider` keeps its readings and penalties in a `QuotaStore`. The default, `InMemoryQuotaStore`, is per-process; routers in one process can share it through an `Arc`. Several processes spending one account's budget share `RedisQuotaStore` (feature `quota-redis`), keyed by account, so a reading or a quota refusal one instance sees steers every instance's next turn:
```rust
let store = RedisQuotaStore::connect(&redis_url, "claude-max-team")
.await?
.with_backend_keys(["claude_code", "copilot_headless"]);
let router = RouterProvider::new(backends, Box::new(PreferInOrder))?.with_store(Box::new(store));
```
Each Redis operation is bounded by `OPERATION_TIMEOUT` (500 ms); when Redis is unreachable the store answers from this process's own view, which is exactly the in-memory default.
## AG-UI Progress Events (feature flag)
Enable the `agui` feature to expose the
[AG-UI protocol](https://github.com/ag-ui-protocol/ag-ui) event vocabulary —
the canonical types agents use to broadcast run / step / tool-call / text
progress to user-facing clients.
```toml
[dependencies]
embacle = { version = "0.38", features = ["agui"] }
```
The module is deliberately transport-agnostic: it ships the event enum
(`AgUiEvent`), the filter (`AgUiEventFilter`), and the emitter trait
(`AgUiEmitter`) plus a `NoopEmitter` default. HTTP routing, SSE framing,
and pipeline wiring live in downstream crates that know their runtime
(e.g. [`dravr-platform`](https://github.com/dravr-ai/dravr-platform)'s
`/api/agui/runs/{run_id}/stream` and [`dravr-canot`](https://github.com/dravr-ai/dravr-canot)'s
messaging adapters).
```rust
use embacle::agui::{AgUiEvent, AgUiEventFilter, AgUiEventKind, AgUiEmitter, NoopEmitter};
// Opt out of high-volume per-token text deltas on a Telegram channel.
let filter = AgUiEventFilter::allow_all().without(AgUiEventKind::TextMessageContent);
let sink = NoopEmitter::new(filter);
let event = AgUiEvent::run_started("run_abc", Some("thread_xyz"));
let _ = sink.emit(&event);
```
## Copilot Headless (feature flag)
Enable the `copilot-headless` feature for ACP-based communication with SDK-managed tool calling:
```toml
[dependencies]
embacle = { version = "0.38", features = ["copilot-headless"] }
```
```rust
use embacle::{CopilotHeadlessRunner, CopilotHeadlessConfig};
use embacle::types::{ChatMessage, ChatRequest, LlmProvider};
#[tokio::main]
async fn main() -> Result<(), embacle::types::RunnerError> {
// Reads COPILOT_HEADLESS_MODEL, COPILOT_GITHUB_TOKEN, etc. from env
let runner = CopilotHeadlessRunner::from_env();
let request = ChatRequest::new(vec![
ChatMessage::user("Explain Rust ownership"),
]);
let response = runner.complete(&request).await?;
println!("{}", response.content);
Ok(())
}
```
The headless runner keeps a small pool of warm `copilot --acp` subprocesses across calls and communicates via NDJSON-framed JSON-RPC. The CLI strips ACP's `session/new` `systemPrompt` field, so the system prompt is inlined at the top of the prompt text — the only delivery path this transport has. Conversation history from prior turns is serialized into a `<conversation-history>` block in the prompt text for multi-turn continuity. The `max_tokens` field from `ChatRequest` is forwarded to ACP's `session/prompt` as `maxTokens`.
Configuration via environment variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `COPILOT_CLI_PATH` | auto-detect | Override path to copilot binary |
| `COPILOT_HEADLESS_WORKING_DIR` | scratch dir under the system temp dir | Directory the subprocess runs in and the `cwd` of every session; copilot loads `.mcp.json`, agent files and custom instructions from it. Unset, a scratch directory rather than the host's cwd |
| `COPILOT_HEADLESS_MODEL` | top entry of ranked catalog (see `copilot_models::CATALOG`) | Default model for completions |
| `COPILOT_GITHUB_TOKEN` | stored OAuth | GitHub auth token (falls back to `GH_TOKEN`, `GITHUB_TOKEN`) |
| `COPILOT_HEADLESS_MAX_HISTORY_TURNS` | `20` | Max conversation history turns in prompt (0 disables) |
| `COPILOT_HEADLESS_PERMISSION_POLICY` | deny | `auto_approve` lets the subprocess run its own tools (shell, git, file edits); anything else denies |
| `COPILOT_HEADLESS_MCP_TOOL_CALLING` | `false` | Advertise `SDK_TOOL_CALLING`: the caller passes `mcp_servers` per request and copilot calls those tools natively |
## Copilot SDK (feature flag)
Enable the `copilot-sdk` feature to reach the same GitHub Copilot runtime through GitHub's own Rust SDK instead of the `copilot --acp` adapter:
```toml
[dependencies]
embacle = { version = "0.38", features = ["copilot-sdk"] }
```
```rust
use embacle::{CopilotSdkRunner, HeadlessTurnProvider};
use embacle::types::{ChatMessage, ChatRequest};
#[tokio::main]
async fn main() -> Result<(), embacle::types::RunnerError> {
// Reads COPILOT_RUNTIME_PATH, COPILOT_SDK_MODEL, COPILOT_GITHUB_TOKEN, etc. from env
let runner = CopilotSdkRunner::from_env();
let request = ChatRequest::new(vec![
ChatMessage::system("You are Dravr's coach."),
ChatMessage::user("Explain Rust ownership"),
]);
let turn = runner.converse(&request).await?;
println!("{} (served by {})", turn.content, turn.model);
Ok(())
}
```
`copilot --acp` is `app.js` over `runtime.node`; the SDK runner drops the JS adapter and spawns the ~400 KB Rust `copilot-runtime` wrapper (`--server --stdio`) that loads the same `runtime.node` — no Node process anywhere on this path. It opens one session per turn (the host owns the conversation, so prior turns are rendered into a `<conversation-history>` block exactly as the headless runner does), delivers the system prompt through the runtime's own system slot in `replace` mode, and reads the turn off the runtime's event stream. What that stream reports and ACP's does not: the model that actually served (`assistant.usage.model`), cache read/write token counts per call, and every tool execution with its name, arguments and result (`ObservedToolCall::{name, arguments, result}`). A model id the runtime's catalogue does not know fails with `ErrorKind::ModelUnavailable` instead of being silently served by another model; the prompt timeout aborts the session instead of killing a process.
Both Copilot runners implement `HeadlessTurnProvider` (`converse` / `converse_stream`), so a host names the trait, not a transport.
The runtime pair is a file on disk, never a payload in the binary. Point `COPILOT_RUNTIME_PATH` at the `copilot-runtime` wrapper with `runtime.node` adjacent — the pair ships inside the `github-copilot-<version>-<platform>.tgz` release asset of `github/copilot-cli` (`prebuilds/<platform>/`), and the Copilot CLI extracts the same pair into its package cache on first run. The SDK's build script would otherwise download and embed the whole CLI (~163 MB); this workspace's `.cargo/config.toml` sets `COPILOT_SKIP_CLI_DOWNLOAD=1`, and a consumer building with this feature sets the same variable in its own build environment.
Configuration via environment variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `COPILOT_RUNTIME_PATH` | SDK resolves `COPILOT_CLI_PATH` | Path to the `copilot-runtime` wrapper; `runtime.node` must sit next to it. No `PATH` scan |
| `COPILOT_SDK_HOME` | per-process dir under the system temp dir | The runtime's home (`COPILOT_HOME`): session state, credential store, logs |
| `COPILOT_SDK_MODEL` | top entry of ranked catalog (see `copilot_models::CATALOG`) | Default model for completions |
| `COPILOT_GITHUB_TOKEN` | stored login | GitHub auth token (falls back to `GH_TOKEN`, `GITHUB_TOKEN`) |
| `COPILOT_PROVIDER_BASE_URL` | unset | Route sessions to this model provider (OpenAI-compatible, Azure, Anthropic, or local such as Ollama at `http://localhost:11434/v1`) instead of GitHub Copilot. No GitHub login is needed, and the Copilot catalogue check is skipped: the endpoint refuses a model it does not serve |
| `COPILOT_PROVIDER_TYPE` | `openai` | `openai`, `azure` or `anthropic` |
| `COPILOT_PROVIDER_API_KEY` | unset | API key for the provider; local endpoints take none |
| `COPILOT_PROVIDER_MAX_PROMPT_TOKENS` | runtime default | Prompt-token ceiling for a model the runtime has no limits for |
| `COPILOT_SDK_PERMISSION_POLICY` | deny | `auto_approve` lets the runtime run its own tools; anything else denies |
| `COPILOT_SDK_MAX_HISTORY_TURNS` | `20` | Max conversation history turns in prompt (0 disables) |
| `COPILOT_SDK_MCP_TOOL_CALLING` | `false` | Advertise `SDK_TOOL_CALLING`: the caller passes `mcp_servers` per request and the runtime calls those tools natively |
| `EMBACLE_SDK_PROMPT_TIMEOUT_SECS` | `300` | Bound on one turn, prompt sent to session idle; the session is aborted on expiry |
| `EMBACLE_SDK_SESSION_TIMEOUT_SECS` | `60` | Bound on starting the runtime and opening a session |
The `COPILOT_PROVIDER_*` names are the Copilot CLI's own, so one environment routes both Copilot runners: `copilot --acp` reads them itself (add `COPILOT_OFFLINE=true` to cut every other GitHub call), and both runners pass them to their child — which otherwise inherits only `HOME`, `PATH`, `TERM`, `USER`, `LANG`, the GitHub token variables, the proxy and CA variables, and the CLI's own package-cache, home, XDG and `TMPDIR` variables (`copilot_common::runtime_env_keys`), never the rest of the host's environment. This is how CI runs the live Copilot tests — the real binaries, a model on the runner, no account:
```bash
ollama serve & # OLLAMA_CONTEXT_LENGTH=32768: the default 4096 silently truncates Copilot's prompt
export COPILOT_OFFLINE=true COPILOT_PROVIDER_BASE_URL=http://localhost:11434/v1
export COPILOT_HEADLESS_MODEL=qwen2.5:3b COPILOT_SDK_MODEL=qwen2.5:3b
EMBACLE_E2E_COPILOT_HEADLESS=1 cargo test --features copilot-headless --test e2e -- headless:: --test-threads=1
```
## Vision / Image Support
Embacle supports sending images alongside text prompts via the `ImagePart` type. Images are base64-encoded and tagged with a MIME type (PNG, JPEG, WebP, GIF).
### Which providers support vision?
| Provider | Vision | How |
|----------|--------|-----|
| Copilot Headless (ACP) | Native | Images sent as ACP `image` content blocks |
| Copilot SDK | Native | Images written to the turn's scratch directory and attached as files |
| OpenAI API | Native | Images sent as `image_url` parts with `data:` URIs |
| C FFI | Native | Images forwarded to copilot headless via `image_url` content |
| All 12 CLI runners | Tempfile | Images decoded to temp files, file paths injected into prompt |
CLI runners materialize base64 images to a temp directory and append `[Attached images]` with file paths to the user message. The temp directory is kept alive until the subprocess finishes.
### Library usage
```rust
use embacle::types::{ChatMessage, ChatRequest, ImagePart};
let image = ImagePart::new(base64_data, "image/png")?;
let request = ChatRequest::new(vec![
ChatMessage::user_with_images("What do you see?", vec![image]),
]);
```
### Server usage (OpenAI multipart content)
Send images via the standard OpenAI multipart content format:
```bash
curl http://localhost:3000/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "copilot_headless",
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": "What do you see in this image?"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBOR..."}}
]
}]
}'
```
Plain string messages continue to work unchanged. All providers accept images — native providers send them directly, CLI runners materialize them to temp files.
## Docker
Pull the image from GitHub Container Registry:
```bash
docker pull ghcr.io/dravr-ai/embacle:latest
```
The image includes `embacle-server` and `embacle-mcp` with Node.js pre-installed for adding CLI backends.
### Adding a CLI Backend
The base image doesn't include CLI tools. Install them in a derived image:
```dockerfile
FROM ghcr.io/dravr-ai/embacle
USER root
RUN npm install -g @anthropic-ai/claude-code
USER embacle
```
Build and run:
```bash
docker build -t my-embacle .
docker run -p 3000:3000 my-embacle --provider claude_code
```
### Auth and Configuration
CLI tools store auth tokens in their config directories. Mount them from the host, or set provider-specific env vars:
```bash
# Mount Claude Code auth from host
docker run -p 3000:3000 \
-v ~/.claude:/home/embacle/.claude:ro \
my-embacle --provider claude_code
# Or pass env vars if the CLI supports them
docker run -p 3000:3000 \
-e GITHUB_TOKEN=ghp_... \
-e EMBACLE_API_KEY=my-secret \
my-embacle --provider copilot
```
### Running embacle-mcp
Override the entrypoint to run the MCP server instead:
```bash
docker run --entrypoint embacle-mcp ghcr.io/dravr-ai/embacle --provider copilot
```
## C FFI Static Library
Embacle provides a C FFI static library (`libembacle.a`) that exposes copilot chat completion to any language that can call C functions — Swift, Objective-C, Python, Go, Ruby, and more. The FFI surface is 4 functions: init, chat completion, free string, and shutdown.
### Install via Homebrew
```bash
brew tap dravr-ai/tap
brew install embacle-ffi
```
This builds from source (requires Rust) and installs `libembacle.a` and `embacle.h` to Homebrew's prefix. The formula is published automatically with each release.
For CI environments:
```bash
brew tap dravr-ai/tap
brew install embacle-ffi
# libembacle.a and embacle.h are now available under $(brew --prefix)/lib and $(brew --prefix)/include
```
### Install via script
```bash
./scripts/install-ffi.sh # → /usr/local
./scripts/install-ffi.sh --prefix $HOME/.local # → custom prefix
./scripts/install-ffi.sh --uninstall # remove
```
### Build manually
```bash
cargo build --release --features ffi
# Output: target/release/libembacle.a
# Header: include/embacle.h
```
### Swift / SPM example
For Swift Package Manager, add a `systemLibrary` target in your `Package.swift` with a modulemap that links `embacle`:
```swift
.systemLibrary(name: "CEmbacle")
```
With a `module.modulemap`:
```
module CEmbacle {
header "embacle.h"
link "embacle"
export *
}
```
The FFI accepts OpenAI-compatible JSON — the same format as the REST API:
```c
embacle_init();
char* response = embacle_chat_completion(
"{\"messages\":[{\"role\":\"user\",\"content\":\"hello\"}]}",
60 /* timeout seconds */
);
/* use response JSON... */
embacle_free_string(response);
embacle_shutdown();
```
Vision payloads work via multipart content with `image_url` data URIs.
## Architecture
```
Your Application
└── embacle (this library)
│
├── CLI Runners (subprocess per request)
│ ├── ClaudeCodeRunner → spawns `claude -p "prompt" --output-format json`
│ ├── CopilotRunner → spawns `copilot -p "prompt"`
│ ├── CursorAgentRunner → spawns `cursor-agent -p "prompt" --output-format json`
│ ├── OpenCodeRunner → spawns `opencode run "prompt" --format json`
│ ├── GeminiCliRunner → spawns `gemini -p "prompt" -o json -y`
│ ├── CodexCliRunner → spawns `codex exec "prompt" --json --full-auto`
│ ├── GooseCliRunner → spawns `goose run --quiet --no-session`
│ ├── ClineCliRunner → spawns `cline task --json --act --yolo`
│ ├── ContinueCliRunner → spawns `cn -p --format json`
│ ├── WarpCliRunner → spawns `oz agent run --prompt "..." --output-format json`
│ ├── KiroCliRunner → spawns `kiro-cli send "prompt"`
│ └── KiloCliRunner → spawns `kilo run --auto --format json`
│
├── HTTP API Runners (behind feature flags)
│ ├── AnthropicProvider → Anthropic Messages API
│ ├── GeminiProvider → Google Generative Language API
│ ├── CohereProvider → Cohere v2 chat
│ ├── GroqProvider → Groq (OpenAI-shaped)
│ ├── OpenRouterProvider → OpenRouter gateway
│ └── OpenAiCompatibleProvider → the OpenAI API / Ollama / vLLM / LocalAI / any compatible endpoint
│
├── ACP Runners (persistent connection, behind feature flag)
│ └── CopilotHeadlessRunner → NDJSON/JSON-RPC to `copilot --acp`
│
├── Browser Web UI Runners (headless browser, behind feature flag)
│ └── WebUiRunner → drives the Claude.ai web UI via dravr-browser
│
├── Provider Decorators (composable wrappers)
│ ├── FallbackProvider → ordered chain: retry in place, fall through on provider fault, observe every hop
│ ├── MetricsProvider → latency, token, and cost tracking (priced by embacle::pricing)
│ ├── QualityGateProvider → response validation with retry
│ ├── GuardrailProvider → pluggable pre/post request validation
│ └── CacheProvider → response caching with TTL and capacity
│
├── Agent Loop
│ └── AgentExecutor → multi-turn tool calling with configurable max turns
│
├── Structured Output
│ └── request_structured_output() → schema-validated JSON extraction with retry
│
├── MCP Tool Bridge
│ └── McpToolBridge → MCP tool definitions ↔ text-based tool loop
│
├── MCP Server (library + binary crate, powered by dravr-tronc)
│ └── embacle-mcp → JSON-RPC 2.0 over stdio or HTTP/SSE
│
├── Unified REST API + MCP Server (binary crate, powered by dravr-tronc)
│ └── embacle-server → OpenAI-compatible HTTP, MCP Streamable HTTP, SSE streaming, multiplex
│
└── Tool Simulation (text-based tool calling for CLI runners)
└── execute_with_text_tools() → catalog injection, XML parsing, tool loop
```
All runners implement the same `LlmProvider` trait:
- **`complete()`** — single-shot completion
- **`complete_stream()`** — streaming completion
- **`health_check()`** — verify the runner is available and authenticated
For detailed API docs — fallback chains, structured output, agent loop, metrics, quality gates, tool simulation, and more — see [docs.rs/embacle](https://docs.rs/embacle).
## Tested With
Embacle has been tested with [mirroir.dev](https://github.com/jfarcand/mirroir-mcp), an MCP server for AI-powered iPhone automation.
## License
Licensed under the Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or <http://www.apache.org/licenses/LICENSE-2.0>).