embacle 0.31.0

LLM runner library — 12 AI CLI tools, ACP, and HTTP APIs (Gemini, Cohere, Groq, OpenRouter, OpenAI-compatible) as pluggable LLM providers with fallback chains, agent loop, guardrails, and cost tracking
Documentation

Embacle — LLM Runners

crates.io docs.rs CI License

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.

Run It

Embacle ships as two ready-to-run servers — no code required:

OpenAI-compatible HTTP server — drop-in replacement for any OpenAI client:

embacle-server --provider copilot --port 3000
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:

# 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 and MCP Server for full details.

Table of Contents

Install

Homebrew (macOS / Linux) — recommended

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

docker pull ghcr.io/dravr-ai/embacle:latest
docker run -p 3000:3000 ghcr.io/dravr-ai/embacle --provider copilot

Cargo (library)

[dependencies]
embacle = "0.31"

Supported Runners

CLI Runners (subprocess-based)

Runner Binary Features
Claude Code claude JSON output, streaming, system prompts, session resume
GitHub Copilot copilot Text parsing, streaming
Cursor Agent cursor-agent JSON output, streaming, MCP approval
OpenCode opencode JSON events, session management
Gemini CLI gemini JSON/stream-JSON output, streaming, session resume
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, session resume via task IDs
Continue CLI cn JSON output, single-shot completions
Warp oz NDJSON output, conversation resume
Kiro CLI kiro-cli ANSI-stripped text output, auto model selection
Kilo Code kilo NDJSON output, streaming, token tracking, 500+ models via Kilo Gateway

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
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

Built on the standalone 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:

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. 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

# 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, 25 endpoints under a configurable base

MCP Streamable HTTP

The server also speaks MCP 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 server (get_provider, set_provider, get_model, set_model, get_multiplex_provider, set_multiplex_provider, prompt).

# 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.

# 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.

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 client (default mcp-tools feature) — in embacle.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:

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.

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 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.

# 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

# 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.

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.

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. Connect any MCP-compatible client (Claude Desktop, editors, custom agents) to use all embacle providers.

Usage

# 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):

{
  "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:

[dependencies]
embacle = { version = "0.31", features = ["http-api"] }
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:

[dependencies]
embacle = { version = "0.31", features = ["http-api"] }
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
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, 429 → RateLimit carrying the vendor's wait, 400/422 → InvalidRequest, anything else → ExternalService. Retries in place on 429/502/503 and on connect/timeout transport errors follow HttpRetryConfig (3 retries, 500 ms initial, 5 s cap). Cohere's "no valid response generated" 400/422 is the one vendor exception: a provider fault, so a fallback chain moves on. Every provider's usage is priced by embacle::pricing, keyed on its name().

Fallback chains

FallbackProvider asks its tiers in order and returns the first accepted answer. Three policies sit between one tier and the next:

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.

AG-UI Progress Events (feature flag)

Enable the agui feature to expose the AG-UI protocol event vocabulary — the canonical types agents use to broadcast run / step / tool-call / text progress to user-facing clients.

[dependencies]
embacle = { version = "0.31", 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's /api/agui/runs/{run_id}/stream and dravr-canot's messaging adapters).

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:

[dependencies]
embacle = { version = "0.31", features = ["copilot-headless"] }
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:

[dependencies]
embacle = { version = "0.31", features = ["copilot-sdk"] }
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 the ACP runner's child inherits them. This is how CI runs the live Copilot tests — the real binaries, a model on the runner, no account:

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

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:

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:

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:

FROM ghcr.io/dravr-ai/embacle
USER root
RUN npm install -g @anthropic-ai/claude-code
USER embacle

Build and run:

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:

# 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:

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

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:

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

./scripts/install-ffi.sh                        # → /usr/local
./scripts/install-ffi.sh --prefix $HOME/.local  # → custom prefix
./scripts/install-ffi.sh --uninstall            # remove

Build manually

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:

.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:

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)
            │   ├── 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.

Tested With

Embacle has been tested with mirroir.dev, an MCP server for AI-powered iPhone automation.

License

Licensed under the Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0).