> ## Documentation Index
> Fetch the complete documentation index at: https://docs.webrayn.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Chat — one prompt, an agent, webrain's own tools

> Run a single Copilot agent turn on your own model key, with webrain's MCP tools attached. Provider table, flags, exit codes, troubleshooting.

# Chat

```bash theme={null}
webrain chat "scrape news.ycombinator.com and summarise the top 5 posts"
```

One command. The model runs on **your** key (BYOK), and the agent's tools *are
webrain itself*: the session spawns `webrain mcp` over stdio, so the model gets
the same 18 `webrain_*` tools an MCP client would, named `webrain-webrain_*`.
Nothing is reimplemented for chat — the child inherits your `CDP_URL`, vault and
engine cache, so it drives whatever browser the CLI would.

## Requirements

* A release binary from this repository (`webrain-linux-x86_64`,
  `webrain-macos-aarch64`, `webrain-windows-x86_64.exe`, …). The `chat` feature
  is built into every published target, the two static **musl** builds included —
  those vendor OpenSSL 3 and compile it with `musl-gcc`, so the result stays a
  static binary.
* Building from source needs **Rust 1.94+** and `--features chat`:

```bash theme={null}
cargo build --release --features chat
```

Expect a large binary: the feature embeds the Copilot CLI runtime
(\~135 MB per platform, downloaded and SHA-256 verified at build time).

### What each build target carries

| Target | `pdfium` | `chat` | Why |
| - | - | - | - |
| `x86_64-unknown-linux-gnu` | yes | yes | links the distro's OpenSSL 3 |
| `aarch64-unknown-linux-gnu` | yes | yes | same, cross-linked |
| `x86_64-unknown-linux-musl` | yes | yes | static: **vendors** OpenSSL 3 (`openssl` `vendored`, built by `musl-gcc`) |
| `aarch64-unknown-linux-musl` | yes | yes | same, cross-linked |
| `aarch64-apple-darwin` | yes | yes | TLS via Security.framework |
| `x86_64-apple-darwin` | yes | yes | same |
| `x86_64-pc-windows-msvc` | yes | yes | TLS via schannel |
| `aarch64-pc-windows-msvc` | yes | yes | same |

CI compiles `pdfium,chat` on Linux for every PR, and on Linux, macOS, Windows
**and static musl** for every push to `main` — the four shapes a release tag would
otherwise be the first to try.

The musl rows are the reason this table exists at all. `chat` reaches
`native-tls` through the SDK's `network-forwarding`, which is `openssl-sys` on
Linux; a static target has no OpenSSL to link, so `webrain-cli` declares
`openssl` with the `vendored` feature for `target_env = "musl"` only and the
build compiles OpenSSL 3 from source with `musl-gcc`. The SDK itself supports the
platform: its build script fetches `copilot-linuxmusl-x64`, not a glibc
package. Building it needs `musl-tools`, `perl`, `make`, `pkg-config` and
`libssl-dev` (the last one for the build-host copy of `openssl-sys`), and about
8 GB of RAM to compile the SDK crate — build with `-j 1` if your machine is
tighter than that.

The container image is glibc (Debian) and takes features as a build arg:

```bash theme={null}
docker build -f docker/Dockerfile --build-arg FEATURES=pdfium,chat -t webrain .
```

## Quick start

```bash theme={null}
export OPENAI_API_KEY=sk-…          # or any row below
webrain chat "what does https://example.com say?" --json
```

See what *would* be used before spending a token — this works in every build,
with or without the feature, and never prints the key:

```bash theme={null}
webrain chat --print-config
```

## Provider resolution

First match wins:

1. `--provider`, `--model`, `--base-url`, `--key-from`.
2. `WEBRAIN_CHAT_PROVIDER` / `_MODEL` / `_BASE_URL` / `_API_KEY`.
3. The ambient key chain, in this order:

| Key (aliases) | `base_url` | default model |
| - | - | - |
| `OPENROUTER_API_KEY` | `https://openrouter.ai/api/v1` | `anthropic/claude-sonnet-4.5` |
| `OPENAI_API_KEY` | `https://api.openai.com/v1` | `gpt-5` |
| `ANTHROPIC_API_KEY` / `CLAUDE_API_KEY` | `https://api.anthropic.com/v1` | `claude-sonnet-4-5` |
| `GEMINI_API_KEY` / `GOOGLE_API_KEY` | `https://generativelanguage.googleapis.com/v1beta/openai` | `gemini-2.5-pro` |
| `FIREWORKS_API_KEY` | `https://api.fireworks.ai/inference/v1` | `accounts/fireworks/models/qwen3-235b-a22b` |
| `DASHSCOPE_API_KEY` / `QWEN_API_KEY` | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` | `qwen3-max` |
| `GROQ_API_KEY` | `https://api.groq.com/openai/v1` | `qwen/qwen3.8-27b` |
| `DEEPSEEK_API_KEY` | `https://api.deepseek.com` | `deepseek-flash` (`--model deepseek-v4-pro` for the larger one) |

`--provider` accepts any alias stem: `openrouter`, `openai`, `anthropic`,
`claude`, `gemini`, `google`, `fireworks`, `dashscope`, `qwen`, `groq`,
`deepseek`. A named provider uses **only its own** variables — naming `groq`
while `OPENAI_API_KEY` is set is an error, not a silent mix-up.

### A local server needs no key

Any OpenAI-compatible endpoint works. Point the base url at it and a key is
optional:

```bash theme={null}
WEBRAIN_CHAT_BASE_URL=http://127.0.0.1:11434/v1 webrain chat "hello"
```

## Flags

| Flag | Meaning |
| - | - |
| `--provider P` | pick a row (see above) |
| `--model ID` | override the row's model id |
| `--key-from VAR` | read the key from `VAR` instead of the row's own name |
| `--base-url URL` | override the endpoint (also the keyless-local path) |
| `--max-output-tokens N` | cap the reply (default **4096**, see below) |
| `--timeout SECS` | per-turn wall clock, default 300 |
| `--search` | turn on the provider's own web search, where it has one (see below) |
| `--yes` | approve **every** tool call |
| `--serve [--port N]` | same session, in a browser: the binary serves its own page on `127.0.0.1:8787` (see [In a browser](#in-a-browser)) |
| `--json` | emit `{provider, model, base_url, answer, tools, usage, exit_code}` on stdout |
| `--no-stream` | print only the final answer instead of token deltas |
| `--print-config` | resolve, print, exit — no key, no network, no runtime |

## What it cost

The turn ends with three lines on stderr, so the answer on stdout stays
pipeable:

```
· model · 8412 in / 312 out          ← one line per model call, as it happens
tokens  8412 in / 312 out · 42.2 tok/s
model   deepseek-flash · 1 request · 7.4 s API
cost    n/a (BYOK: your provider bills the key)
```

Numbers come from the runtime's own accounting
(`session.usage.getMetrics`), not from counting characters: `input_tokens` /
`output_tokens` per model, request count, and the time actually spent in model
API calls — which is what the `tok/s` figure divides by. `--json` carries the
same numbers as `usage: {input_tokens, output_tokens, requests, api_ms,
tokens_per_sec, nano_aiu}`.

Two honest limits:

* **Cost is `n/a` on a BYOK turn.** `nano_aiu` is GitHub's billing unit and the
  runtime reports 0 because your key pays the provider, not GitHub. Piping
  through OpenRouter removes even that (OpenRouter meters its own credits), so
  the AIU line only means something with GitHub-authenticated models.
* **Tokens are per model call, not per tool call.** A tool call consumes no
  tokens by itself; each agent step that follows one does. That is why the live
  line reads `· model ·` next to the `· tool ·` lines rather than on them.

## `--search`: live web results, per provider

The vendors do not sell the same thing, so the flag does whatever the provider
actually supports instead of pretending they are interchangeable:

| Provider | What `--search` does | Whose search |
| - | - | - |
| `openrouter` | rewrites the model to its `:online` slug: the `web` plugin uses the vendor's **native** search for Anthropic, Google, OpenAI, Perplexity and xAI models, Exa for everything else. Results arrive as citations. | the model's own |
| `deepseek` | webrain calls DeepSeek's **Search API** (`POST https://api.deepseek.com/v1/tools/search`, same key) *before* the turn and prepends the top 5 titles, urls and snippets with an instruction to cite them | DeepSeek |
| `openai` | webrain calls the **Responses API** (`POST /v1/responses`) with `tools: [{"type":"web_search"}]` and prepends the returned answer, which carries `url_citation` annotations | OpenAI |
| `anthropic` / `claude` | webrain calls the **Messages API** with the `web_search_20250305` server tool (`max_uses: 3`) and prepends the answer plus the urls in its `web_search_tool_result` blocks | Anthropic |
| `gemini` / `google` | webrain calls the **Interactions API** (`POST /v1beta/interactions`) with `tools: [{"type":"google_search"}]` and prepends the grounded answer with its `url_citation` annotations | Google |
| `groq`, `fireworks`, `dashscope`, local servers | webrain searches with its **own engines** (duckduckgo + bing over pooled HTTP, no browser) and prepends the same citation block | webrain's SERP |

Why the split: this CLI cannot inject a request field into the model call — the
Copilot SDK forwards a fixed provider config, not arbitrary body parameters — so
the search it can switch on is one the vendor exposes as a *model variant*
(OpenRouter) or as an *endpoint it can call itself* (DeepSeek, OpenAI, Anthropic,
Gemini, and ours). The vendor row means the vendor's model plans the queries and
writes the grounded answer; the fourth column is whose index found the pages.

Two things that follow from that shape:

* **The vendor call is a second request, billed by the vendor.** OpenAI, Anthropic
  and Google charge per search on top of the turn (Gemini 3+ bills per query the
  model decides to run, with 5,000 free per month). Their fees do not appear in
  webrain's closing cost block — that block reports the runtime's token totals,
  which only cover the turn.
* **A vendor search that fails falls back to ours, never to a dead turn.** A
  rejected key, a model that cannot use the tool, or an empty answer prints the
  reason and grounds the turn on webrain's own results instead.

Every branch prints one line on stderr naming the source and what it found:

```
· search · 5 results (auto)
· search · 5 results (deepseek)
· search · 1840 chars (openai web_search)
· search · 2310 chars (anthropic web_search)
· search · 1690 chars (gemini google_search)
```

```bash theme={null}
webrain chat "what changed in Rust 1.95" --provider deepseek --search
webrain chat "what changed in Rust 1.95" --provider openai --search
webrain chat "what changed in Rust 1.95" --provider anthropic --search
webrain chat "what changed in Rust 1.95" --provider gemini --search
webrain chat "what changed in Rust 1.95" --provider openrouter --search
webrain chat "what changed in Rust 1.95" --provider groq --search
```

`--search` adds one search per turn. The agent can also call `webrain_search` /
`webrain_serp` itself, mid-turn, as many times as the question needs — that is
what it does without the flag.

## In a browser

```bash theme={null}
webrain chat --serve                 # http://127.0.0.1:8787/
webrain chat --serve --port 9000     # anywhere else on loopback
```

The same session, driven from a page instead of a pipe: transcript on the left,
a tool card per `webrain_*` call, a token meter in the header, and a modal for
every permission request. The page is compiled into the binary (`include_str!`),
so there is no build step and no second process, and it is the only UI that
opens — nothing is fetched from a CDN.

Four routes, all on loopback:

| Route | What it does |
| - | - |
| `GET /` | the page |
| `GET /events` | SSE, one `data: {"type":…,"data":{…}}` frame per runtime event |
| `POST /turn` | `{"prompt":"…"}` → `202`, or `409` while a turn is running |
| `POST /permission` | `{"id":"…","allow":true}` — answers a pending modal |

The event **names are the runtime's own** (`assistant.message_delta`,
`tool.execution_start`, `session.usage_info`, `model.call_failure`, …) rather
than a translated dialect: the page maps each one to an element, and a name it
does not know is dropped instead of breaking the stream. One event is ours:
`webrain.permission`, carrying the request id the modal answers.

Two things that matter more than the layout:

* **Nothing is invented.** If the runtime did not emit it, the pane stays empty.
  The composer re-enables on `assistant.idle` — the only signal a turn is over.
* **The modal is the security boundary.** Approving in the page is the same
  authority as `--yes`, so it shows the exact request (the type of action, the
  command or url) and waits. No answer within 120 s is a **deny**, and the turn
  says so rather than hanging. `--serve` does not imply `--yes`.

`--serve` is loopback-only on purpose: a listener that can approve a shell
command must not be reachable from the LAN. Put a reverse proxy in front of it
and add authentication if you need it elsewhere — the binary will not do that
for you.

## Permissions

Without `--yes` the session approves reads, navigation and MCP tool calls, and
**denies** shell commands, file writes, and any permission category the SDK does
not model (an unrecognised category is not an invitation). `--yes` installs an
approve-all handler — say it out loud when you mean it, because the agent can
then drive a logged-in browser.

The key is used in-process to configure the runtime and is never written to
stdout, stderr, or a log. `--print-config`, `--json` and every error path print
`<redacted>` plus the *name* of the variable that supplied it.

## Exit codes

| Code | Meaning |
| - | - |
| 0 | the turn completed |
| 1 | the turn ran and failed (provider error, timeout, refused) |
| 2 | could not start: no provider configured, a bad flag, or a build without `chat` |

## When a turn fails with no answer

The SDK reports `No response was returned. Send your message again to retry.`
whenever the provider produced nothing usable, which can also mean the model
does not support tool calling. See what the runtime actually emitted:

```bash theme={null}
WEBRAIN_CHAT_DEBUG=1 webrain chat "hi" --provider groq
```

Every raw event is mirrored to stderr — including the provider's own error
payload, which is not in the SDK's error string.

If the model answers but never calls a tool, it is usually a model that does not
support tool calling; pick another row (`--provider openai`) rather than
retrying.

### "The prompt was rejected as too long"

That message is the provider refusing the request, not a webrain limit, and both
real causes are quotas — neither is the model's context window:

* **Tokens per minute.** A tool-enabled turn is \~8.4k tokens before your
  question: 18 tool schemas (\~8.0k) plus a short system prompt (\~0.3k). Groq's
  free tier allows 7000 input tokens/minute, so it rejects that outright
  (`413`, `Requested 18102 ... Limit 7000` once retries are counted). Use a paid
  tier or a provider with room.
* **Output budget against balance.** The runtime asks for a 64k-token
  completion by default, and some providers reserve the requested maximum
  against your balance: OpenRouter answered *"you requested up to 64000 tokens,
  but can only afford 171"*. webrain therefore asks for **4096** by default and
  `--max-output-tokens` only lowers it further.

Both are visible only in the raw stream (see the `WEBRAIN_CHAT_DEBUG` example
above).

## Isolation from your own Copilot install

A BYOK chat owns its configuration. The client starts in the SDK's
**`ClientMode::Empty`** — the mode Microsoft documents for "CLI-like ambient
behaviour is unsafe" — which requires an explicit state directory instead of the
implicit `~/.copilot` fallback, disables optional features unless asked for, and
keeps the OS keychain out of the runtime's reach.

So none of your own Copilot setup applies: no ambient MCP servers, no user
skills, no instructions file, no cross-session store, no memory writes, no
session telemetry. This is not politeness — measured on a real turn, inheriting
the machine's config loaded \~20 unrelated MCP servers (\~480 tools,
`toolDefinitionsTokens: 137019` against a `tokenLimit: 128000`) and the turn died
before it reached the provider.

Point the state directory elsewhere with `WEBRAIN_CHAT_HOME` (default:
`webrain-copilot-home` under the system temp directory).

What is *not* removable: the runtime's built-in `github-mcp-server` (5 tools)
still loads, and the tool list is filtered separately with the SDK's `ToolSet`
so the model can only call `webrain-*`. Measured, `toolDefinitionsTokens` stays
**8080** either way — the 18 `webrain_*` schemas are the floor, not the
stragglers.

## What chat does not do

No interactive REPL and no `--session` resume in this version: one prompt, one
turn, one exit code. Everything here is additive to `webrain mcp`, which keeps
its stdout contract — chat's own logs go to stderr for that reason.

No GUI either. The runtime already emits everything a conversation window needs
(streamed deltas, tool calls, permission requests, context and cost meters) and
the CLI flattens all of it into stderr lines; [ADR-004](https://docs.webrayn.dev/adr/0004-chat-gui-surface)
maps each event to the element it should become and specifies `webrain chat --serve` as the smallest interface that keeps the binary self-contained —
including which SDK handler slots a UI has to fill, because today they are
policy, not a person.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.