Embacle — LLM Runners
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:
MCP server — connect Claude Desktop, editors, or any MCP client directly:
# stdio (editor integration)
# HTTP (network-accessible)
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
- Supported Runners
- Quick Start
- REST API Server
- Unified Harness Protocol
- MCP Server
- OpenAI API
- HTTP API providers
- Fallback chains
- Copilot Headless
- Vision / Image Support
- Docker
- C FFI Static Library
- Architecture
- Tested With
- License
Install
Homebrew (macOS / Linux) — recommended
This installs two binaries:
embacle-server— OpenAI-compatible REST API + MCP serverembacle-mcp— standalone MCP server for editor integration
Docker
Cargo (library)
[]
= "0.33"
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 |
| 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 PathBuf;
use ;
use ;
async
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
# Specify provider and port
# MCP-only mode via stdio (for editor/client integration)
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
# Call the prompt tool
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
# Default provider
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.
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'stool_callsare 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:
[[]]
= "filesystem"
= "npx"
= ["-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:
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
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
# Or mount it anywhere, including the root
UHP_BASE_PATH=/api/harness
UHP_BASE_PATH=/
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
# Run one
# Stream it instead
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.
&&
EMBACLE_API_KEY=secret &
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)
# HTTP transport (for network-accessible deployments)
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):
OpenAI API (feature flag)
The http-api feature's OpenAiCompatibleProvider talks to the OpenAI API, or to any endpoint that serves the same API:
[]
= { = "0.33", = ["http-api"] }
use ;
use ;
async
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:
[]
= { = "0.33", = ["http-api"] }
use ;
use ;
async
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 Arc;
use ;
let chain = new?
.with_fallthrough
.with_observer;
- 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 anOkwith blank content and no tool call as a failed tier, and clearsrequest.modelbefore the next tier so each provider resolves its own configured model.ResponsePolicy::permissive()(the default) moves on every error and accepts everyOk. - Observe — a
FallbackObserversees every hop:before_attemptcan veto a tier (Attempt::Skip("reason"), consulted only while a later tier exists),on_fallthroughnames the tier passed over and why (Skipped,EmptyCompletion,Error),on_successnames the tier that answered,on_exhaustedcarries 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.
[]
= { = "0.33", = ["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 ;
// Opt out of high-volume per-token text deltas on a Telegram channel.
let filter = allow_all.without;
let sink = new;
let event = run_started;
let _ = sink.emit;
Copilot Headless (feature flag)
Enable the copilot-headless feature for ACP-based communication with SDK-managed tool calling:
[]
= { = "0.33", = ["copilot-headless"] }
use ;
use ;
async
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:
[]
= { = "0.33", = ["copilot-sdk"] }
use ;
use ;
async
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_CONTEXT_LENGTH=32768: the default 4096 silently truncates Copilot's prompt
EMBACLE_E2E_COPILOT_HEADLESS=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 ;
let image = new?;
let request = new;
Server usage (OpenAI multipart content)
Send images via the standard OpenAI multipart content format:
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:
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:
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
# Or pass env vars if the CLI supports them
Running embacle-mcp
Override the entrypoint to run the MCP server instead:
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
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:
# libembacle.a and embacle.h are now available under $(brew --prefix)/lib and $(brew --prefix)/include
Install via script
Build manually
# 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:
;
char* response = ;
/* use response JSON... */
;
;
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 completioncomplete_stream()— streaming completionhealth_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).