embacle 0.38.0

LLM runner library — 12 AI CLI tools, ACP, and HTTP APIs (Anthropic, Gemini, Cohere, Groq, OpenRouter, OpenAI-compatible) as pluggable LLM providers with fallback chains, agent loop, guardrails, and cost tracking
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
# Embacle — LLM Runners

[![crates.io](https://img.shields.io/crates/v/embacle.svg)](https://crates.io/crates/embacle)
[![docs.rs](https://docs.rs/embacle/badge.svg)](https://docs.rs/embacle)
[![CI](https://github.com/dravr-ai/dravr-embacle/actions/workflows/ci.yml/badge.svg)](https://github.com/dravr-ai/dravr-embacle/actions/workflows/ci.yml)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](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>).