# Omni Chat

Omni Chat is a lightweight, local-first, multi-user AI chat app. The core is a Go server with SQLite storage, and it talks to any OpenAI-compatible endpoint such as LiteLLM, vLLM, llama.cpp, or another compatible local/proxy server.

Tool-capable models can also call a native `web_search` function. Search is provider-based, uses
SearXNG by default, and is disabled unless `CHAT_WEB_SEARCH_URL` is configured. A companion
`fetch_url` tool lets the model fetch and summarize a specific URL (a web page, article, or — via a
capable reader — a YouTube transcript); it is off by default and enabled with `CHAT_WEB_FETCH=true`.
A native `generate_image` tool turns "generate an image of…" requests into pictures rendered inline
in the chat; it is enabled by pointing `CHAT_IMAGE_GEN_URL` at any OpenAI-Images-compatible backend,
such as the bundled Z-Image-Turbo sidecar (see [Image generation](#image-generation)).

The product goal is one Go core shared by both a web app and a Tauri desktop shell. The current repo is at Phase 3: settings and prompt assembly are implemented in the Go core. The desktop shell is not implemented yet; use the web app served by the core.

New chats get an immediate first-message title, then the Go core makes one best-effort pass after
the first answer to replace it with a short summary title. Later turns never auto-rename the chat;
use Chat Details to edit the title manually.

## User manual

Read the [published user and configuration manual](https://geekaholic.gitlab.io/omni-chat/docs/manual.html).
In a downloaded or cloned copy, open [`docs/manual.html`](docs/manual.html) directly in a browser
(double-click it, or run `open docs/manual.html` on macOS or `xdg-open docs/manual.html` on Linux).

For the full product spec, see [docs/prd.html](docs/prd.html). For agent/development rules, see [AGENTS.md](AGENTS.md).

## Installing build dependencies

To build Omni Chat from a clean machine you need the following. All commands go through the
[Makefile](Makefile), so `make` is required as well.

| Tool | Minimum | Why |
| --- | --- | --- |
| Go | 1.25+ | Builds the core (matches `core/go.mod`) **and** compiles the sandbox Go runtime for the web build. |
| Node.js | 20 or 22 LTS | Builds the Svelte/Vite web SPA. |
| npm | bundled with Node | Installs and runs the web build. |
| make | any recent | Runs every documented command. |
| git | any recent | Clones the repo. |

No C compiler is needed — storage uses the pure-Go `github.com/ncruces/go-sqlite3` (no CGO). The
Tauri desktop shell is not implemented yet, so Rust/Tauri are not required.

Building the web app provisions the WebAssembly runtime assets for the code-block **Run** feature: it
compiles the sandbox Go runtime (`tools/gorunner`, emitted to `web/public/runtimes/go-<version>/`, so
the Go toolchain is needed for the web build too) and downloads pinned, checksum-verified Python
(Pyodide) and bash (Wasmer/WASIX) assets (see `web/runtimes.json`) on the first build — network is
needed once for the download; afterwards builds reuse the cached files under `web/public/runtimes/`
and run offline. For a fully offline build, pre-populate `web/public/runtimes/` from a mirror.

You also need an **OpenAI-compatible endpoint** (LiteLLM, vLLM, llama.cpp, etc.) with
`/v1/models` and `/v1/chat/completions` to actually run the app — that is a runtime dependency,
not a build dependency.

### macOS (Homebrew)

```bash
brew install go node make git
```

macOS already ships `make` and `git` with the Xcode Command Line Tools (`xcode-select --install`);
Homebrew simply provides newer versions.

### Ubuntu / Debian

The `apt` packages for Go and Node are often too old, so install those from upstream:

```bash
# make, a compiler toolchain, and git
sudo apt update && sudo apt install -y build-essential git

# Go (official tarball — adjust the version as needed)
curl -fsSL https://go.dev/dl/go1.25.0.linux-amd64.tar.gz | sudo tar -C /usr/local -xz
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.profile && source ~/.profile
# or: sudo snap install go --classic

# Node.js 22 LTS via NodeSource
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
# or use nvm: https://github.com/nvm-sh/nvm
```

### Verify

```bash
go version && node -v && npm -v && make --version
```

Then build everything with `make build` (or run the full gate with `make check`).

## Quick Start

Point it at a running OpenAI-compatible endpoint and start:

```bash
# Ollama defaults to port 11434, LM Studio to 1234, llama.cpp/vLLM commonly to 8000/8080
CHAT_ENDPOINT_URL=http://localhost:11434 make dev
```

`make dev` serves the app on port `8080`, so open <http://127.0.0.1:8080>. On first run, create
the owner account; after that, login is required for protected routes.

Set `CHAT_PORT` to use a different port, `CHAT_API_KEY` if your endpoint needs a token, and
`CHAT_DB_PATH` to choose where the SQLite file lives. See [Configuration](#configuration) for the
full list.

### Add to iPhone Home Screen

When the web app is reachable from your phone, open it in Safari, tap **Share**, then tap
**Add to Home Screen**. iOS uses the bundled Omni app icon (`apple-touch-icon`) and launches the app
in a standalone browser window from that Home Screen icon.

## Docker

Docker is an alternative to installing Go and Node.js locally. The Compose setup pulls the
published multi-platform image from GitLab, publishes the app only on host loopback by default,
and keeps the SQLite database in the Docker-managed `omni-chat-data` volume.

```bash
# Optional: copy this if you want to change ports, endpoint settings, or secrets.
cp .env.example .env

docker compose up -d
docker compose ps
```

To start Omni Chat with the optional self-hosted SearXNG provider:

```bash
printf 'SEARXNG_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env
docker compose -f compose.yaml -f compose.search.yaml up -d
```

The overlay enables SearXNG JSON results, configures Omni Chat to use the internal
`http://searxng:8080` address, and publishes its UI only on
<http://127.0.0.1:8888> by default. Generate the secret once and keep it in the ignored
`.env` file; Compose fails with setup guidance when it is missing. The overlay is not part of the
normal Compose stack.

For a host-run core (`make dev`) instead of the full Compose stack, start just SearXNG and point
the core at its published port:

```bash
printf 'SEARXNG_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env
docker compose -f compose.search-only.yaml up -d
CHAT_WEB_SEARCH_URL=http://localhost:8888 make dev
```

To start Omni Chat with the optional llama.cpp auxiliary-model sidecar (see [Auxiliary
model](#auxiliary-model-chat-titles-memory-jobs-smart-routing)):

```bash
docker compose -f compose.yaml -f compose.aux.yaml up -d
```

The overlay wires `CHAT_AUX_URL` / `CHAT_AUX_MODEL` to an internal `aux-model` service running
`ghcr.io/ggml-org/llama.cpp`, which downloads the configured Hugging Face GGUF (default
[`LiquidAI/LFM2.5-1.2B-Instruct-GGUF`](https://huggingface.co/LiquidAI/LFM2.5-1.2B-Instruct-GGUF),
~731 MiB) into a named volume on first run — allow a moment before the sidecar reports healthy and
Omni Chat starts. This one model handles chat titles, memory jobs, **and** smart-router
classification, so on its own it needs no separate router sidecar. It is stackable with the other
overlays, e.g. `docker compose -f compose.yaml -f compose.search.yaml -f compose.aux.yaml up -d`.

For a host-run core (`make dev`) instead of the full Compose stack, start just the sidecar and
point the core at its published port:

```bash
docker compose -f compose.aux-only.yaml up -d
CHAT_AUX_URL=http://localhost:8091 \
CHAT_AUX_MODEL=lfm2.5-1.2b-instruct make dev
```

To also run the optional dedicated llama.cpp router-classifier sidecar (see [Semantic router
classifier](#semantic-router-classifier)):

```bash
docker compose -f compose.yaml -f compose.aux.yaml -f compose.router.yaml up -d
```

The overlay wires `CHAT_ROUTER_CLASSIFIER_URL` / `CHAT_ROUTER_CLASSIFIER_MODEL` to an internal
`router-classifier` service running `ghcr.io/ggml-org/llama.cpp`, which downloads the configured
Hugging Face GGUF (default `katanemo/Arch-Router-1.5B.gguf`, ~1 GiB) into a named volume on first
run — allow a few minutes before the sidecar reports healthy and Omni Chat starts. When present, this
dedicated classifier **outranks the auxiliary model** for routing (the aux model then does titles and
memory only); omit this overlay and the aux sidecar classifies via the fallback. It is stackable with
the other overlays, e.g.
`docker compose -f compose.yaml -f compose.search.yaml -f compose.aux.yaml -f compose.router.yaml up -d`.

For a host-run core, start just the sidecar and point the core at its published port:

```bash
docker compose -f compose.router-only.yaml up -d
CHAT_ROUTER_CLASSIFIER_URL=http://localhost:8090 \
CHAT_ROUTER_CLASSIFIER_MODEL=arch-router-1.5b make dev
```

To run the `fetch_url` tool through a self-hosted reader sidecar instead of the in-core `direct`
provider (see [Web/URL fetch](#weburl-fetch)):

```bash
docker compose -f compose.yaml -f compose.fetch.yaml up -d
```

The overlay enables the tool (`CHAT_WEB_FETCH=true`), switches it to the `reader` provider, and points
Omni Chat at an internal `reader` service on `http://reader:8081` (its port is also published on
<http://127.0.0.1:3001> for debugging). The service is
[`ghcr.io/jina-ai/reader:oss`](https://github.com/jina-ai/reader), Jina AI's official self-host image
(multi-platform: `linux/amd64` and `linux/arm64`, so it runs unmodified on Apple Silicon) that renders
JavaScript in a bundled headless browser; override `READER_IMAGE`/`READER_VERSION` in `.env` to pin or
replace it. It is a large image and needs more memory than the other sidecars, so allow a moment on
first pull. Stackable with the other overlays, e.g.
`docker compose -f compose.yaml -f compose.search.yaml -f compose.fetch.yaml up -d`.

**When to use it.** The default `direct` provider needs no extra container and handles most static or
server-rendered pages, but it only does a plain HTTP GET: it cannot run JavaScript, is blocked by
anti-bot defenses such as Cloudflare, and cannot read YouTube transcripts. The reader sidecar runs a
real browser, so it renders JS-heavy pages, gets past many anti-bot walls, and returns a YouTube watch
URL's transcript — at the cost of a much larger image, more RAM/CPU, slower fetches (you may want to
raise `CHAT_WEB_FETCH_TIMEOUT`, up to `60s`), and sending target URLs to that container. Prefer
`direct` and switch to the reader overlay only when you need one of those capabilities.

For a host-run core (`make dev`) instead of the full Compose stack, start just the reader and point the
core at its published port:

```bash
docker compose -f compose.fetch-only.yaml up -d
CHAT_WEB_FETCH=true CHAT_WEB_FETCH_PROVIDER=reader \
CHAT_WEB_FETCH_URL=http://localhost:3001 make dev
```

To use the default in-core `direct` provider under the base Compose stack (no sidecar), just add
`CHAT_WEB_FETCH=true` to `.env` — no overlay needed:

```bash
echo 'CHAT_WEB_FETCH=true' >> .env
docker compose up -d
```

### Image generation under Compose

To offer the [`generate_image`](#image-generation) tool from the Compose stack, use the
`compose.imagegen.yaml` overlay. It builds and runs the bundled **Z-Image-Turbo** sidecar
(`docker/imagegen/` — FastAPI + [diffusers](https://github.com/huggingface/diffusers) exposing the
OpenAI Images API) and points Omni Chat at it:

```bash
docker compose -f compose.yaml -f compose.imagegen.yaml up --build -d
```

The sidecar **requires an NVIDIA GPU** (install the
[NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html);
Z-Image-Turbo wants roughly 16 GB of VRAM in bf16) and downloads ~12 GB of weights into the
`imagegen-models` volume on first start — watch `docker compose logs -f imagegen` and expect the
healthcheck to stay `starting` until the model is loaded. Stackable with the other overlays.

**On Jetson/L4T or arm64 CUDA hosts**, the default x86-64 base image won't run. Override
`IMAGEGEN_BASE_IMAGE` with a CUDA-enabled PyTorch image that matches your host's driver stack (torch
is **not** reinstalled by the build — it must come from the base) and set `IMAGEGEN_RUNTIME=nvidia`
so the GPU is exposed. For example, in `.env`:

```bash
# NVIDIA Jetson (aarch64 / L4T) — match your JetPack
IMAGEGEN_BASE_IMAGE=dustynv/pytorch:2.7-r36.4.0
# or arm64 CUDA (DGX Spark / Grace-Hopper)
# IMAGEGEN_BASE_IMAGE=nvcr.io/nvidia/pytorch:25.01-py3
IMAGEGEN_RUNTIME=nvidia
```

On a GPU-less host (for example macOS, where containers cannot use the GPU), run just the sidecar on
a GPU machine and point the core at it:

```bash
# on the GPU box
IMAGEGEN_PUBLISH_ADDRESS=0.0.0.0 docker compose -f compose.imagegen-only.yaml up --build -d
# where the core runs
CHAT_IMAGE_GEN_URL=http://<gpu-host>:8001 make dev   # or add it to .env for Compose
```

**On Apple Silicon**, skip Docker for the sidecar entirely and run it natively so it uses the Mac's
GPU via PyTorch MPS (containers on macOS get no GPU — the compose overlay's NVIDIA reservation
fails outright, and CPU diffusion is unusably slow):

```bash
cd docker/imagegen
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt torch
uvicorn app:app --port 8001          # auto-detects MPS; first run downloads ~12 GB to ~/.cache/huggingface
```

Then point the core at it (`CHAT_IMAGE_GEN_URL=http://localhost:8001 make dev`, or in `.env` for a
Compose-run core use `http://host.docker.internal:8001`). Wait for `model ready` in the sidecar log
before the first request, and note the app-side `CHAT_IMAGE_GEN_TIMEOUT` may need raising while MPS
warms up. When running natively, all the `IMAGEGEN_*` knobs documented in `.env.example` apply as
plain process environment variables (`.env` only feeds Docker Compose) — e.g.
`IMAGEGEN_EDIT_MODEL_ID=Qwen/Qwen-Image-Edit-2509 uvicorn app:app --port 8001`.

Set `IMAGEGEN_API_KEY` on the sidecar and `CHAT_IMAGE_GEN_API_KEY` on the core when that port is
reachable by others. Any other OpenAI-Images-compatible server also works — set
`CHAT_IMAGE_GEN_URL` (and optionally `CHAT_IMAGE_GEN_MODEL`/`CHAT_IMAGE_GEN_API_KEY`) and skip the
sidecar entirely.

### Agent handoff + SFTP under Compose

To offer the [Agent handoff](#agent-handoff-delegate-a-task-to-pi) (`delegate_task`) tool and SFTP
workspace access from the Compose stack, use the `compose.agents.yaml` overlay. The core launches
each approved delegation as a **sibling container** through the host's Docker socket, so the overlay
mounts the socket, adds the workspace tree at an identical path on both sides (the host daemon
resolves the sibling's bind mounts against *host* paths), and publishes the SFTP port. One-time
setup:

```bash
docker build -t omni-agent-pi docker/agent-pi                 # the agent image
sudo mkdir -p /srv/omni-agent-work
sudo chown 10001:10001 /srv/omni-agent-work                   # the core's container uid
cp docs/agents.compose-example.json config/agents.json        # managed mode + docker runtime + sftp
ssh-keygen -t ed25519 -f config/sftp_host_key -N ""           # /config is read-only in-container,
sudo chown 10001 config/sftp_host_key                         # so pre-generate the SFTP host key
```

Then bring the stack up with the overlay (stackable with the others):

```bash
DOCKER_GID=$(getent group docker | cut -d: -f3) \
  docker compose -f compose.yaml -f compose.agents.yaml up --build -d
```

`DOCKER_GID` is the group that owns `/var/run/docker.sock` so the non-root core may use it. Change
the workspace location with `OMNI_AGENT_WORK_DIR` in `.env` — it must match `managed_root` in
`agents.json` exactly, as a literal absolute path (no `~`; the container cannot expand your home).
SFTP publishes on `127.0.0.1:2222` by default; set `OMNI_SFTP_PUBLISH_ADDRESS=0.0.0.0` to reach it
from the LAN. If you change `OMNI_SFTP_PUBLISH_PORT`, also set `"public_port"` in the config's
`sftp` block to the same value so the SFTP address the UI offers to copy points at the published
port, not the container-internal one. The example config uses `"docker_network": "bridge"`, so agent containers reach your
`host.docker.internal` model endpoint through the same host-gateway alias the core uses.

**On macOS (Docker Desktop)** the paths and permissions differ: `/srv` is on the sealed read-only
system volume, so put the workspace under your home instead — `mkdir -p ~/omni-agent-work` (no
`sudo`), then set `OMNI_AGENT_WORK_DIR=/Users/<you>/omni-agent-work` in `.env` and the same path as
`managed_root` in `agents.json`. Skip every `chown` and the `DOCKER_GID` prefix — Desktop's VirtioFS
maps file ownership automatically and its socket needs no extra group.

If you built the published `latest` image before `fetch_url` existed (or before another `CHAT_*`
feature you're testing landed), `docker compose up -d` alone will keep running that stale image.
Rebuild from your checked-out source with `docker compose up --build -d` to pick up local changes.

Open <http://127.0.0.1:8080> and create the owner account. The default endpoint is
`http://host.docker.internal:8000`; change `CHAT_ENDPOINT_URL` in `.env` for a different local
OpenAI-compatible port (for example `11434` for Ollama). Compose passes every variable in `.env`
to the container, so it can also hold `CHAT_API_KEY`, an environment variable referenced by
`api_key_env`, and any supported `CHAT_*` capability overrides. Keep `.env` private; it is ignored
by Git.

The default image is `registry.gitlab.com/geekaholic/omni-chat:latest`, built for both AMD64 and
ARM64. GitLab publishes an immutable full-commit-SHA tag alongside `latest` after every successful
main-branch pipeline. Pin a deployment by setting this in `.env`:

```bash
OMNI_CHAT_IMAGE=registry.gitlab.com/geekaholic/omni-chat:<full-commit-sha>
```

Public registries can be pulled anonymously. If the GitLab project or registry is private, run
`docker login registry.gitlab.com` before pulling. To build from the checked-out source instead of
pulling the published image, run `docker compose up --build -d`.

Container `localhost` refers to the Omni Chat container, not the host. Compose maps
`host.docker.internal` to the host gateway on Docker Desktop and native Linux. On native Linux,
the AI server must listen on an address reachable from Docker's bridge, not only `127.0.0.1`;
bind it to an appropriate host interface and restrict that port with host firewall rules.

### Persistent configuration

Compose mounts the repository's `./config` directory read-only at `/config`. Put persistent
`endpoints.json`, `profiles.json`, and `SOUL.md` files there:

```bash
cp docs/endpoints.docker.example.json config/endpoints.json
cp docs/profiles.example.json config/profiles.json
cp docs/SOUL.example.md config/SOUL.md
# Edit those files, then reload startup configuration:
docker compose restart
```

The Docker endpoint example uses `host.docker.internal`, which is required for an AI server
running on the host. As with native runs, the presence of `config/endpoints.json` takes precedence
over `CHAT_ENDPOINT_URL`. The database remains in the named volume when the image or container is
recreated:

```bash
git pull
docker compose pull
docker compose up -d
```

`docker compose down` preserves the database. `docker compose down -v` permanently removes it.
To expose Omni Chat to other machines intentionally, set `OMNI_CHAT_PUBLISH_ADDRESS=0.0.0.0` in
`.env` and apply appropriate network controls.

For a consistent backup, stop database writes and archive the named volume:

```bash
docker compose stop
mkdir -p backups
docker run --rm \
  -v omni-chat-data:/data:ro \
  -v "$PWD/backups:/backup" \
  alpine:3.22 tar -czf /backup/omni-chat-data.tar.gz -C /data .
docker compose start
```

### Docker without Compose

```bash
docker volume create omni-chat-data
docker run -d --name omni-chat --restart unless-stopped \
  --add-host host.docker.internal:host-gateway \
  -p 127.0.0.1:8080:8080 \
  -e CHAT_ENDPOINT_URL=http://host.docker.internal:8000 \
  -v omni-chat-data:/data \
  -v "$PWD/config:/config:ro" \
  registry.gitlab.com/geekaholic/omni-chat:latest
```

The image runs as a non-root user, includes a health check against `/api/health`, and stores
writable state only under `/data`. To build the image locally instead, run
`docker build -t omni-chat:local .` and replace the final image name with `omni-chat:local`.

## Commands

```bash
make dev          # run the Go core and served web app
make check        # fmt + lint + test + build
make test         # go test ./...
make build-core   # build the Go core binary
make build        # build the core and verify embedded web assets exist
```

`make dev-desktop` is currently a placeholder because the Tauri shell has not landed yet.

## Importing from ChatGPT

Bring your ChatGPT history into Omni Chat with the `core import chatgpt` CLI subcommand. In
ChatGPT, go to **Settings → Data controls → Export data**; a download link arrives by email with a
zip file.

```bash
./.cache/bin/omni-chat-core import chatgpt ~/Downloads/chatgpt-export.zip --user alice
```

`<export>` accepts the export zip as downloaded, an already-unzipped export directory, or a bare
`conversations.json`. A bare JSON file has no attachments to pull from, so the importer prints a
note to stderr and continues without them. Both the classic single-`conversations.json` layout and
the newer sharded layout (`conversations-000.json`, `conversations-001.json`, … plus `.dat` assets
with a `conversation_asset_file_names.json` restoring their original names) are supported.

Flags:

| Flag | Effect |
| --- | --- |
| `--user <username>` | target omni-chat username (required) |
| `--db <path>` | SQLite database path (defaults to `$CHAT_DB_PATH` or `data/app.db`) |
| `--skip-attachments` | opt out of importing images/files referenced by messages |
| `--skip-memories` | opt out of importing ChatGPT memories |
| `--skip-archived` | opt out of importing conversations archived in ChatGPT |
| `--dry-run` | parse the export and print the report without writing anything |

For each conversation the importer takes the **active branch only** — exactly what you last saw in
ChatGPT, not every edited-away alternative. It imports the title, timestamps, user/assistant
messages, reasoning ("thoughts") folded into the following assistant message, images/files as
attachments, and archived state; every imported chat gets a `chatgpt` tag. ChatGPT memories (bio-tool
writes) are harvested into your user-scope memories, deduplicated against what's already there.
Tool-call plumbing (code execution, browsing traces, canvas) is not imported and is counted in the
summary.

Re-running the import against a newer export is incremental: conversations that haven't changed are
skipped, conversations that grew in ChatGPT get only their new messages appended, and conversations
whose active branch changed (an edit that switched branches) are skipped with a warning — existing
messages are never rewritten or deleted. It's safe to import while the server is running (SQLite
WAL). Imported chats have no model/endpoint set, so they pick your default model the next time you
use them.

### Importing under Docker Compose

When Omni Chat runs via `docker compose`, the same binary is the container's entrypoint, so the
import runs as a one-off container that shares the service's database volume and env:

```bash
docker compose run --rm \
  -v "$PWD/chatgpt-export.zip:/import/export.zip:ro" \
  omni-chat import chatgpt /import/export.zip --user alice
```

`docker compose run` reuses the service's `CHAT_DB_PATH=/data/app.db` and the `omni-chat-data`
volume, so no `--db` flag is needed and the import lands in the same database the server uses —
safe to run while the service is up. Bind-mount the export read-only at a fresh path such as
`/import/…` (avoid `/tmp`, which the service config covers with a small tmpfs). All the flags above
work the same way; add `--dry-run` first if you want a report before writing anything. If the image
predates the import feature, rebuild it with `docker compose build`.

## Configuration

Core runtime config:

| Variable | Default | Notes |
| --- | --- | --- |
| `CHAT_BIND` | `127.0.0.1` | Bind address. Use `0.0.0.0` only when intentionally sharing the instance. |
| `CHAT_PORT` | `0` | `0` chooses an ephemeral port and prints it in the `READY` line. |
| `CHAT_DB_PATH` | `core/data/app.db` when run via `make dev` | SQLite database path. |
| `CHAT_ENDPOINT_URL` | unset | OpenAI-compatible endpoint base URL. Required for model list and chat. Accepts a comma-separated list to combine multiple backends (each shares `CHAT_API_KEY`). Ignored when `endpoints.json` is present. |
| `CHAT_API_KEY` | unset | Optional bearer token. Keep secrets in env/keychain, not SQLite plaintext. |
| `CHAT_CONFIG_DIR` | OS config dir | App config directory. Defaults to `~/.config/omni-chat` on macOS/Linux unless Linux `XDG_CONFIG_HOME` is set; Windows uses the user config directory. |
| `CHAT_PROFILES_PATH` | `<config-dir>/profiles.json` | Optional file-specific override for additional or overriding profile definitions loaded at startup. |
| `CHAT_SOUL_PATH` | `<config-dir>/SOUL.md` | Optional file-specific override for the assistant personality Markdown loaded at startup. |
| `CHAT_ENDPOINTS_PATH` | `<config-dir>/endpoints.json` | Optional file-specific override for the multi-backend definitions loaded at startup. See [Multiple backends](#multiple-backends). |
| `CHAT_ROUTER_PATH` | `<config-dir>/router.json` | Optional file-specific override for the Auto (Smart Router) policy loaded at startup. See [Auto (Smart Router)](#auto-smart-router). |
| `CHAT_AGENTS_PATH` | `<config-dir>/agents.json` | Optional file-specific override for the external-agent definitions that enable the `delegate_task` tool. See [Agent handoff](#agent-handoff-delegate-a-task-to-pi). |
| `CHAT_ROUTER_CLASSIFIER_URL` | unset | OpenAI-compatible endpoint of a **dedicated** router-classifier model (e.g. Arch-Router). Unset falls back to the auxiliary model (`CHAT_AUX_*`) when configured, else heuristics only. See [Semantic router classifier](#semantic-router-classifier). |
| `CHAT_ROUTER_CLASSIFIER_MODEL` | unset | Required when the URL is set; the one explicit classifier model id. The router overlay defaults it to `arch-router-1.5b`. |
| `CHAT_ROUTER_CLASSIFIER_FORMAT` | `auto` | Classifier protocol adapter: `auto` (detect `arch-router` in the model id), `arch` (Arch-Router native routes), or `schema` (JSON-schema classification for general instruct models). |
| `CHAT_ROUTER_CLASSIFIER_API_KEY` | unset | Optional bearer token for the classifier endpoint. |
| `CHAT_ROUTER_CLASSIFIER_TIMEOUT` | `3000ms` | Race deadline; accepts `100ms`–`10s`. Requires a Go duration unit, e.g. `3000ms` — a bare number like `3000` fails to parse and is fatal at startup. |
| `CHAT_WEB_SEARCH_PROVIDER` | `searxng` | Provider selected when web search is enabled. |
| `CHAT_WEB_SEARCH_URL` | unset | Provider base URL. Setting it enables the `web_search` tool. |
| `CHAT_WEB_SEARCH_TIMEOUT` | `10s` | Search request timeout; maximum `1m`. |
| `CHAT_WEB_SEARCH_MAX_RESULTS` | `5` | Results returned to the model, from 1 to 10. |
| `CHAT_TOOL_CALLING` | `true` | Set `false` to suppress tools for an environment-backed endpoint. |
| `CHAT_WEB_FETCH` | `false` | Set `true` to enable the `fetch_url` tool. |
| `CHAT_WEB_FETCH_PROVIDER` | `direct` | `direct` (in-core, SSRF-guarded) or `reader` (external reader endpoint). |
| `CHAT_WEB_FETCH_URL` | unset | Reader endpoint base URL; required when the provider is `reader` (e.g. `https://r.jina.ai`). |
| `CHAT_WEB_FETCH_TIMEOUT` | `15s` | Per-fetch deadline, from `1s` to `60s`. |
| `CHAT_WEB_FETCH_MAX_BYTES` | `2097152` | Per-page download cap in bytes before truncation. |
| `CHAT_WEB_FETCH_USER_AGENT` | realistic browser UA | Overrides the User-Agent sent by the `direct` provider. |
| `CHAT_WEB_FETCH_ALLOW_PRIVATE` | `false` | Local dev only: relax the SSRF guard so private/loopback hosts can be fetched. |
| `CHAT_IMAGE_GEN_URL` | unset | OpenAI Images API–compatible backend base URL. Setting it enables the `generate_image` tool. See [Image generation](#image-generation). |
| `CHAT_IMAGE_GEN_PROVIDER` | `openai` | Backend protocol; `openai` (the OpenAI Images API, `POST /v1/images/generations`) is the only v1 provider. |
| `CHAT_IMAGE_GEN_MODEL` | unset | Optional model id passed through to the backend (the bundled sidecar serves one model and ignores it). |
| `CHAT_IMAGE_GEN_API_KEY` | unset | Optional bearer token for the image backend. Env-only, never stored in SQLite. |
| `CHAT_IMAGE_GEN_TIMEOUT` | `120s` | Idle deadline per image, from `10s` to `10m` (Go duration or bare seconds). With a streaming backend every progress event resets it, so only a stalled backend trips it; against a non-streaming backend it is the total deadline. |
| `CHAT_IMAGE_GEN_STREAM` | `true` | Ask the backend for streamed progress + partial previews (OpenAI Images `stream`/`partial_images`). Backends that ignore it degrade gracefully; set `false` to force plain JSON requests. |

Model/request config:

| Variable | Default | Notes |
| --- | --- | --- |
| `CHAT_MAX_TOKENS_FIELD` | `max_tokens` | Set to `max_completion_tokens` for endpoints/models that require it. |
| `CHAT_CONTEXT_WINDOW` | `8192` | Approximate input+output context window used for preflight budgeting. |
| `CHAT_MAX_OUTPUT_TOKENS` | unset | Optional hard cap for generated tokens; model metadata and session overrides are clamped to this. |
| `CHAT_CONTEXT_SAFETY_MARGIN` | `512` | Reserved tokens subtracted from the input budget. |
| `CHAT_RESPONSE_HEADER_TIMEOUT` | `2m` | How long to wait for the endpoint's first response headers. On streaming backends these arrive with the first token, so this is effectively a time-to-first-token limit — raise it (e.g. `5m`) for slow self-hosted routers that cold-load large models, or a turn errors with a `timeout` while the model is still loading. Accepts a Go duration (`5m`, `90s`) or a bare number of seconds (`300`). |
| `CHAT_HONORS_SAMPLING` | `false` | When `true`, Creativity sends `temperature` and `top_p`. |
| `CHAT_EXTRA_BODY` | unset | JSON object merged into the top-level chat request body for static endpoint params. |
| `CHAT_THINKING_ON_BODY` | unset | Advanced adapter override: JSON object merged into the top-level chat request body when the per-chat Thinking switch is on/auto. |
| `CHAT_THINKING_OFF_BODY` | unset | Advanced adapter override: JSON object merged into the top-level chat request body when the per-chat Thinking switch is off. |
| `CHAT_PARSE_THINK_TAGS` | `false` | Advanced adapter override: parse streamed `<think>...</think>` content into the reasoning channel and strip it from the answer. |

Embeddings are optional and, when configured, power **semantic memory** — relevance-ranked recall and meaning-based deduplication (see the Memory section). Memory works without them (recency-based). Document RAG will reuse the same client in a later phase.

| Variable | Default | Notes |
| --- | --- | --- |
| `CHAT_EMBEDDINGS_URL` | unset | OpenAI-compatible embeddings endpoint (`/v1/embeddings`). Unset disables semantic memory. |
| `CHAT_EMBEDDINGS_MODEL` | unset | Embedding model name. Required when the URL is set. |
| `CHAT_EMBEDDINGS_DIM` | unset | Embedding dimension; must match the model. Required when the URL is set. |
| `CHAT_EMBEDDINGS_API_KEY` | unset | Optional bearer token for the embeddings endpoint. |
| `CHAT_EMBEDDINGS_TIMEOUT` | `30s` | Per-request timeout (1s–2m). |

Changing the embedding model or dimension is detected at startup (recorded in `app_meta`); existing memory vectors that no longer match are treated as missing and re-embedded lazily on next access — no manual reindex.

### Auxiliary model (chat titles, memory jobs, smart routing)

One optional small, fast, **non-thinking** model powers Omni Chat's background intelligence. Configure `CHAT_AUX_*` to enable it. Everything it does runs off the response path (never adding turn latency), and it never uses your main chat model, so it won't compete with generation on a single local endpoint. A 1–2B non-reasoning instruct model is ideal — the recommended default is [`LFM2.5-1.2B-Instruct`](https://huggingface.co/LiquidAI/LFM2.5-1.2B-Instruct-GGUF) (~731 MB at Q4_K_M), e.g. `llama-server -hf LiquidAI/LFM2.5-1.2B-Instruct-GGUF:Q4_K_M --port 8091 --alias lfm2.5-1.2b-instruct`; see `.env.example` for smaller/larger alternatives.

| Variable | Default | Notes |
| --- | --- | --- |
| `CHAT_AUX_URL` | unset | OpenAI-compatible chat endpoint for the auxiliary model. Unset disables every aux-powered feature below. |
| `CHAT_AUX_MODEL` | unset | Model name (a small, fast, non-thinking instruct model is ideal). Required when the URL is set. |
| `CHAT_AUX_API_KEY` | unset | Optional bearer token for the endpoint. |
| `CHAT_AUX_ROUTER` | `true` | Set `false` (or `0`) to stop the aux model from serving as the smart-router classifier when no dedicated one is configured. |

> **Migration from `CHAT_MEMORY_*`:** the auxiliary model replaces the former `CHAT_MEMORY_URL`/`CHAT_MEMORY_MODEL`/`CHAT_MEMORY_API_KEY` variables, which are no longer read. Point `CHAT_AUX_URL`/`CHAT_AUX_MODEL` at the same small model to restore the memory jobs — setting `CHAT_MEMORY_*` now only logs a startup warning.

What it powers:

- **Chat titles** — after the first exchange, the aux model replaces the truncated first message with a concise 2–6 word title summarizing the conversation. Without an aux model, the derived truncated title stands.
- **Automatic extraction** quietly saves durable facts you mention. It is conservative: only clearly-stated, lasting facts, skips sensitive data (passwords, card/account numbers, secrets), honors the chat's `save_policy` (`off`/`explicit_only` disable it) and `min_confidence_to_save`, and de-duplicates against what you already have. Everything it saves is inspectable, editable, and deletable in the Memories panel.
- **Rolling chat summaries (automatic context compaction)** keep a long conversation coherent past the model's context window. Once a chat grows long enough, a compact running recap of the earlier messages is maintained in the background — each refresh merges the previous recap with the new turns, so early context is never lost — and when the input budget would otherwise drop old turns, the assistant sees an "Earlier in this conversation…" recap in their place instead of losing the thread. Unlike the other jobs here, this one works **even without an aux model** (summarization then quietly uses the chat's own model between turns) and regardless of the Memory toggle. Your transcript is never altered: when compaction was active on the last response, a subtle divider marks the boundary ("messages above are sent to the model as a summary") with a **View summary** control that shows exactly what the model is told, and the response's info popup gains a "Context: Compacted" row. Editing, regenerating, or deleting a message inside the summarized span simply discards the stale summary and rebuilds it in the background — nothing is ever locked.
- **Cross-chat recall** brings relevant history from your *other* conversations into the current one. When you ask about something you discussed in a previous chat, the assistant is shown a short "From earlier conversations…" recap of the most relevant past chats, so a brand-new chat isn't a blank slate. This needs embeddings configured (it ranks summaries by meaning), only ever draws on your own chats, and respects the per-chat Memory toggle (off = no recall).
- **Consolidation** tidies your accumulated memories over time. An idle-time, per-user pass (rate-limited, in the background) merges duplicates, resolves contradictions (the newer memory wins; the older is retired), and distills recurring patterns into higher-level "insight" memories. Nothing is hard-deleted — retired memories keep a link to what replaced them and can be inspected via `GET /api/memories?include_deleted=true`. Pinned memories are never touched.
- **Smart-router classification** — when you pick "Auto (Smart Router)" and no dedicated classifier is configured, the aux model classifies each turn (code / reasoning / creative / etc.) so routing can pick a fitting model. This runs on the turn path under a short raced deadline (default 3s, falling back to heuristics), separate from the background jobs above. See [Semantic router classifier](#semantic-router-classifier); disable with `CHAT_AUX_ROUTER=0`.

## Message Actions

- **Edit your message** changes an earlier user prompt and regenerates from that point.
- **Edit an assistant response** asks the model to revise that response from a short instruction while the original stays visible. Revision thinking can be expanded during the stream; the answer is replaced with a dissolve effect only after the edit succeeds. The edit instruction is prompt context only and is not saved as a chat message.
- **Regenerate** re-runs an assistant response from the prior user turn.
- **Delete** removes a message from the visible chat and future prompt context with a short undo window.
- **Generation details** opens from the info icon beside an assistant response. Hover or focus it for a quick view, or click to pin it. The panel shows the response's saved profile, resolved model/endpoint, thinking and web-search status, token counts, TTFT, speeds, and total time.

## Sharing & export

The **share** icon at the top of the chat exports the current conversation as a standalone
document. Both outputs come from the same self-contained HTML — no server round-trip, it renders
what's already on screen:

- **Download HTML** saves a single `.html` file (title, messages, rendered markdown, code-run
  outputs, and web-source citations). Images are inlined as data URLs so the page opens fully
  offline; other attachments are listed by filename. Assistant thinking is not included.
- **Save as PDF** opens the same document in a print view; choose your browser's *Save as PDF* to
  keep crisp, selectable text without any extra tooling.
- **Download Markdown** saves a `.md` file with assistant answers kept as Markdown, code-run
  outputs as fenced blocks, and citations as links. Images are referenced by filename (not
  embedded), keeping the file small and portable.

## Attachments

Attach images and documents to a message with the composer's paperclip button, drag-and-drop, or
clipboard paste. Supported: PNG/JPEG/WebP/GIF images and PDF/TXT/MD/HTML documents, up to 20 MB per
file and 8 files per message.

- **Images** are sent to the model as vision content on that turn (OpenAI-compatible `image_url`
  parts, so local VLMs like Qwen2.5-VL behind llama.cpp or Ollama just work). Oversized images are
  downscaled server-side to keep local inference fast; to keep follow-up turns cheap, images are
  only transmitted in full on the turn they are attached. The composer warns when the selected
  model cannot view images; Auto (Smart Router) routes image turns to a vision-capable model.
  Vision support is detected from (strongest first) `model_overrides` in `endpoints.json`, the
  endpoint's own reported modalities (llama.cpp's router exposes `input_modalities` — note a
  llama-server started **without `--mmproj`** reports and is treated as text-only), and a
  built-in catalog (Gemma 3/4, Qwen 3.5/3.6, Qwen-VL, LLaVA, Pixtral, GPT-4o/5, Claude, Gemini,
  …). If a model is misdetected, set `{"model_pattern": "...", "vision": true}` in that
  endpoint's `model_overrides`.
- **Documents** get their text extracted server-side and injected as source context, on the
  attaching turn and on follow-up questions in the same chat (token-budgeted).
- **Scanned PDFs** with no text layer fall back to extracting their embedded page images, which
  ride the same vision path.

Uploads are stored per-user in SQLite; unsent uploads expire after 24 hours.

## Composer Code Blocks

Type three backticks on an otherwise empty composer line to open a monospace code block. Paste or
type code without input-time syntax highlighting; Enter and Shift+Enter both add a new line and
never submit. Use Arrow Down at the end of the block, or click the blank line below it, to continue
with normal text. A populated code block stays structured until all of its contents are deleted or
cut; Backspace dismisses a newly opened empty block. Sent user messages show these sections as
visual code blocks while leaving all other user text literal.

## Code Previews

Assistant code blocks can be copied or saved from their toolbar. The save action opens a Save As
picker when the browser supports it, otherwise it falls back to a normal local download. Named blocks
keep their filename and unnamed blocks use a language-based `snippet.*` name; both include an
ordering suffix such as `main-01.py` or `snippet-02.js`. HTML code blocks can be rendered as
sandboxed previews with inline CSS and JavaScript. Adjacent `css` and `js` blocks are bundled into
the same preview. Dynamically evaluated JavaScript, including `eval()` and `new Function()`, is
allowed inside the isolated preview so interactive apps such as calculators work. HTML previews can
also be popped out into a new browser tab/window so keyboard driven demos such as games can capture
arrow keys without scrolling the chat. The iframe does not receive same-origin access, cannot access
authenticated `/api/*` endpoints, and may load external resources only over HTTPS.

## Running code blocks

JavaScript, TypeScript, Python, Go, and bash code blocks show a **Run** button that executes the
snippet locally, in a sandboxed WebAssembly runtime (QuickJS for JS/TS, Pyodide/CPython for Python, a
Yaegi interpreter for Go, WASIX GNU bash for bash/sh) inside a dedicated Web Worker — nothing is sent
to a server. The sandbox has **no network, no DOM, and no filesystem** access to your machine:
`fetch`, `XMLHttpRequest`, and storage are unavailable, so untrusted AI-generated code can't reach
your session or the network. Each run is capped at **30 seconds** of wall-clock time and **1 MiB** of
output, and nothing runs until you click Run. Output (stdout/stderr, exit code, duration) streams into
a panel below the code; **Stop**, **Re-run**, and **Close** controls manage the run. Runtimes download
once on first use and are then cached (Python is ~6 MB, Go ~8 MB, bash ~4 MB). Python covers the
standard library plus a curated set of bundled offline packages — **numpy and pandas** load
automatically when imported (a one-time ~8 MB download). Anything else fails with an honest
`ModuleNotFoundError` note: packages can't be installed because the sandbox has no network —
`pip`/`micropip` don't work by design. Go runs a standard-library subset via
an interpreter — no third-party modules, and networking/`os-exec`/`syscall`/`unsafe` are withheld as a
security boundary. **Multi-file programs work:** code blocks that carry a filename — either on the
fence (` ```python main.py `) or as a bold/heading line right above the block (`**utils.py**`) — form
a workspace within one answer. Running a named block mounts its named siblings into the sandbox's
in-memory filesystem, so `import utils` or a multi-file Go package just works; the files exist only
for that run and are discarded with it. **Interactive programs work:** a JS/TS program that reads
input with Node `readline` or `process.stdin`, a Python program that calls `input()`, or a bash block
runs in a terminal below the block — type into it and the run keeps going. JS/TS interactivity needs
no special browser support; **Python `input()` and bash need cross-origin isolation** (a
`SharedArrayBuffer`), so they work in **Chrome and Firefox** and degrade to a "not supported" notice
in Safari. bash runs your block as a real script (`bash -c`) with `coreutils` (`ls`, `cat`, and
friends); if the script calls `read`, the terminal stays live for input. There is no `pip`, `python`,
`node`, or package manager in the shell — a `command not found` is followed by a one-line note saying
only bash + coreutils are available. Because bash runs in
WebAssembly without full POSIX signals, a filter reading an *unbounded* stream and piped into another
command (e.g. `tr < /dev/urandom | head -c 16`) produces no output — bound the source first
(`head -c 64 /dev/urandom | …`).

To make cross-origin isolation possible the app sends `Cross-Origin-Opener-Policy: same-origin` and
`Cross-Origin-Embedder-Policy: credentialless` on its own responses; `credentialless` keeps
cross-origin images in chat loading (without credentials).

## Appearance

- **Light / dark theme** toggles from the sun/moon button in the top bar, next to the switch-user button. It defaults to your operating system's preference and remembers your choice per user (stored in the browser), so each account keeps its own theme on that device.

## Settings Behavior

The settings panel starts collapsed by default to give the conversation more room — open it from the gear button on its rail. Your expand/collapse choice for each side panel is remembered per user (stored in the browser), like the theme. Model, Profile, and Thinking stay available in the composer toolbar while it is collapsed. Inside the panel, the Session Instructions section starts collapsed; expand it with the eye icon to view or edit.

The settings panel is intentionally single-purpose:

- **Model** is populated from `/api/models`, persists per chat, and is available from both the settings panel and the compact composer picker. A status LED sits beside each real model inside the composer picker (and next to the selected model on the closed picker): green when the selected backend confirms that model is loaded, amber while it is loading, and neutral when unloaded, sleeping, or unsupported. It refreshes while the app is visible. The composer picker is disabled while a response is streaming; stop first, then switch models and regenerate.
- **Tone** is single-select and only changes `style.tone`. `Default` adds no tone instruction.
- **Response Length** changes the desired output token cap; the backend clamps it to model/context limits and may reduce it to preserve recent chat history.
- **Response budget** in Advanced generation is a session-only override of the output cap (sent as `max_output_tokens`). Empty uses the response length preset, discovered model limits, and env caps.
- **Creativity** changes style prompt text. It sends `temperature` and `top_p` only when `CHAT_HONORS_SAMPLING=true`.
- **Format** changes output-shape prompt text.
- **Thinking** persists per chat. The synchronized switches in the composer toolbar and Settings both update the same state. On (`auto`) asks capable endpoints for thinking; Off disables upstream thinking when Omni Chat knows the model/server adapter, otherwise it suppresses reasoning display/storage for that turn. Completed reasoning panels show how long the leading thinking phase took, measured from the first reasoning token to the first visible answer token. Common Qwen models served by llama.cpp, DeepSeek V4 models served by `ds4.c`, and OMLX models whose chat templates advertise `enable_thinking` are detected automatically. Models with no known reasoning channel or adapter show the switch as unavailable.
- **Session Instructions** appears in a divided section directly below Model. It contains only the editable per-session instructions generated from project/profile/style settings; app safety, hidden realtime context, and SOUL are managed by the core and are not shown in the textarea. Edits are session-only and are not saved to SQLite.
- **Profile** is a collapsible section with an always-visible dropdown. `Default` restores default tone, medium length, balanced creativity, and structured format; Brainstorm Ideas, Draft Content, and Write Code set a session-only persona plus Tone / Response Length / Creativity / Format bundle. Selecting a value expands the Profile controls. Each assistant response records the profile ID and label used for that turn and shows it in the response's Generation details panel. Reopening a chat selects the profile from its newest visible assistant response; if that profile was removed from configuration, the historical label remains visible and the selector falls back to `No profile`. If that response was Auto-routed (label `Auto → <Profile Label>`, or a bare `Auto`), reopening restores the **Auto** profile selection instead of the concrete persona it happened to route to that turn.

The top-right user menu contains **Edit Profile**, **Memory**, **Manage Memories**, and **Sign off**. Edit Profile updates your username, display name, and a small local avatar image. Memory persists per chat: On lets the assistant save durable facts about you and recall the relevant ones on later turns; Off neither reads nor writes memory for that chat (history is still stored). See the Memory section below.

Power users can customize the default assistant personality by creating `<config-dir>/SOUL.md`. The built-in SOUL makes Omni Chat warm, thoughtful, conversational, and lightly playful by default. `CHAT_CONFIG_DIR=/path/to/omni-chat-config` overrides the directory for all app config, and `CHAT_SOUL_PATH=/path/to/SOUL.md` overrides only the soul file. SOUL controls personality and tone only; app safety remains a separate hard-coded invariant that the core always injects before sending upstream. A missing default `SOUL.md` is fine and uses the built-in personality; an explicit `CHAT_SOUL_PATH` must exist and must not be empty.

```bash
mkdir -p ~/.config/omni-chat
cp docs/SOUL.example.md ~/.config/omni-chat/SOUL.md
```

Power users can extend profiles by creating `<config-dir>/profiles.json`, where the default config dir is `~/.config/omni-chat` on macOS and Linux-style systems. `CHAT_PROFILES_PATH=/path/to/profiles.json` overrides only the profiles file.

## Memory

With the **Memory** toggle on, the assistant remembers durable facts, preferences, decisions, and constraints about you across chats — separate from raw message history.

- **Ask it to remember.** Say something like "Remember that I'm vegetarian" and the assistant saves a durable memory; a small chip confirms "Memory saved". In a later chat it recalls the relevant memories automatically. Say "Forget that I'm vegetarian" to remove one.
- **Manage your memories.** Choose **Manage Memories** from the top-right user menu to list everything remembered about you. Filter by scope, edit a memory's text or kind, pin the ones that should always stay in context, add one manually, delete any, and see each memory's provenance (the chat it came from). Memories are private to your account.
- **Scopes.** `user` memories follow you across all your projects; `project` memories apply only within that project's chats. (Shared `workspace` memory arrives with the sharing phase.)
- **Kinds.** `preference`, `fact`, `decision`, `constraint`.

- **Smarter recall (optional).** When an embeddings endpoint is configured (`CHAT_EMBEDDINGS_*`), the assistant recalls memories by *relevance* to what you're currently asking rather than by recency, and saving something you already told it in different words updates the existing memory instead of adding a duplicate. Without embeddings, recall is recency-based and dedup is exact-text — everything still works.

Memories are stored in the local SQLite database and are also available via the `/api/memories` REST endpoints. Automatic extraction from conversations and rolling chat summaries are planned in later memory phases; see the PRD and `docs/superpowers/specs/` for the design.

Built-in profiles load first; matching IDs in the JSON file override built-ins in place, and new IDs append to the list. The welcome page shows at most 8 cards total; the synthetic **✨ Auto** entry (see below) is shown first when present and uses one of those slots. The profile dropdowns still include every configured profile. A missing default `profiles.json` is fine and uses built-ins. An explicit `CHAT_PROFILES_PATH` must exist. Any existing profile file is validated at startup and must use lower snake case IDs plus valid style option keys. `icon` is optional and must name a bundled icon in lower kebab case; unknown names fall back to the generic custom-profile icon in the UI.

A ready-to-copy starter lives at [docs/profiles.example.json](docs/profiles.example.json):

```bash
mkdir -p ~/.config/omni-chat
cp docs/profiles.example.json ~/.config/omni-chat/profiles.json
```

You can also point directly at it for a one-off run:

```bash
CHAT_PROFILES_PATH=docs/profiles.example.json make dev
```

```json
{
  "schema_version": 1,
  "profiles": [
    {
      "id": "review_docs",
      "label": "Review Docs",
      "description": "Review documentation for gaps.",
      "persona_prompt": "Review documentation for missing context and unclear claims.",
      "icon": "file-text",
      "style": {
        "tone": "direct",
        "response_length": "detailed",
        "creativity": "precise",
        "format": "structured"
      }
    }
  ]
}
```

Reasoning models often have endpoint-specific parameters. Omni Chat infers common adapters such as Qwen served by llama.cpp and DeepSeek V4 served by `ds4.c`. For OMLX, it reads the optional `/v1/models/status` metadata and enables the switch whenever `thinking_default` is a boolean; both `true` and `false` indicate that the model's chat template supports `enable_thinking`. This keeps new OMLX model families working without name-based allowlists. For custom endpoints, configure per-endpoint thinking request bodies in `endpoints.json`:

```json
{
  "thinking": {
    "on_body": { "reasoning_effort": "medium" },
    "off_body": { "reasoning_effort": "none" },
    "parse_think_tags": true
  }
}
```

`CHAT_EXTRA_BODY` remains available for static top-level request parameters. Do not use it for per-turn thinking state; the Thinking switch owns that and chooses the inferred/configured adapter body at request time.

Do not force sampling on reasoning models unless your endpoint explicitly supports it.

## Web search

When `CHAT_WEB_SEARCH_URL` is set, Omni Chat advertises a strict `web_search` function to
tool-capable models with automatic tool choice. The Go core validates the call, queries the
configured provider, returns structured title/URL/snippet results to the model, and persists the
results as source cards. Search snippets are treated as untrusted data and are not full-page
content.

SearXNG is the only provider implemented today. Other providers can be added behind the same Go
provider interface. A configured endpoint that does not support OpenAI-compatible tool calls can
set `"tool_calling": false` in `endpoints.json`; `CHAT_TOOL_CALLING=false` provides the equivalent
fallback for environment-backed endpoints. The Include Sources switch remains limited to local
document retrieval as an input behavior and does not enable or disable web search. It also controls
source-card presentation: switching it off hides saved document and web-search cards, while the core
continues to retain real references and report the search outcome in Generation Details. Switching
it back on reveals those cards again.

## Web/URL fetch

When `CHAT_WEB_FETCH=true`, Omni Chat additionally advertises a strict `fetch_url` function so a
tool-capable model can retrieve a specific URL the user gave it (or that appeared in search results),
extract its readable text, and summarize or quote it. The result is persisted as a source card, and
each response snapshots a `web_fetch_status` (`used`/`empty`/`failed`/`not_used`) shown in the
response info control. `web_search` and `fetch_url` share one bounded tool-call budget per turn.

Two providers are available:

- **`direct`** (default) fetches in-core with a realistic browser User-Agent and extracts the main
  content with a readability parser. A built-in SSRF guard refuses private, loopback, link-local, and
  cloud-metadata addresses (checked at dial time, so redirects and DNS rebinding are covered too);
  only `text/html`/`text/plain` responses are extracted and the body is size-capped. It needs no
  external service but may be blocked by JS-heavy or anti-bot sites.
- **`reader`** delegates to an external or self-hosted reader endpoint (`CHAT_WEB_FETCH_URL`, e.g.
  `https://r.jina.ai`) that renders JS and returns clean text. This handles anti-bot sites and, with
  a capable reader, YouTube transcripts. URLs are sent to that endpoint, so point it at a service you
  trust. The `compose.fetch.yaml` overlay (see [Docker](#docker)) is the easy way to run a self-hosted
  reader for this provider.

When the fetch tool is enabled and your message contains a URL, the core fetches it automatically
before the model runs (up to the first two URLs) and hands the model the content, so summarizing a
pasted link works reliably even on models that would otherwise reach for web search. Fetched content
is treated as untrusted: the model is instructed not to follow instructions embedded in a page and to
cite the URL.

Every turn also carries a hidden, system-injected context block (not the editable Session Instructions):
the current date and time in your browser's timezone, plus a short directive. When `web_search` is
available it tells the model to search for current or possibly-changed facts and trust fresh
results over its own memory; when the tool is unavailable it instead tells the model it has no web
access and should flag that its knowledge may be out of date. The wording is kept plain so smaller
local models follow it reliably. The browser sends only its IANA timezone name (e.g.
`America/Chicago`); the server keeps the authoritative clock and falls back to UTC if the name is
missing or invalid.

## Code execution as a tool

Beyond the manual Run button, a tool-capable model can execute code itself: turn on the **Code
Execution** icon toggle in the composer (the `</>` button, next to Include Sources and Thinking — it
outlines orange when active) and the model is
offered a `run_code` function (Python or JavaScript). Ask something like *"sort these numbers and
give me the median — use a tool"* and the model writes a program, your **browser** runs it in the
same sandboxed WASM runtime as the Run button (fresh Web Worker, no network/DOM/filesystem/stdin,
30-second timeout, capped output), and the printed result feeds back into the model's answer — the
code never runs on the server. While it executes, the response shows a "Running Python…" chip that
becomes a collapsed card; expand it to audit the exact code and its stdout/stderr, and the card is
stored with the message so it survives reload. Runs are budgeted (three per response, independent of
the web-tool budget), arguments are validated before anything executes, and if the browser can't
produce a result the model is told execution was unavailable and answers without it. Go and bash are
deliberately not offered to the model (slow first load / browser-gated); they remain Run-button-only.
Enabling the toggle is the explicit opt-in for auto-running model-written code in the sandbox — with
it off (the default), nothing ever runs without your click.

The composer's icon toggles — **Include Sources**, **Code Execution**, **Agent**, and **Thinking** —
apply to the *current chat* (this session). To choose what *new* chats start with, use the **New chat
defaults** switches in the right settings sidebar; those are saved to your account (synced across
devices) and never change a chat you've already started. (Agent handoff is deliberately not a
new-chat default — it's situational and off unless you turn it on for a chat.)

## Agent handoff (delegate a task to Pi)

`run_code` is a sealed sandbox — no filesystem, no network. When a task needs the real thing (edit a
repository, run its test suite, make git commits, longer-running work), a tool-capable model can hand
it to an **external coding agent** running on the machine hosting the core. The first supported agent
is [Pi](https://pi.dev).

This is off unless the operator enables it. Create `<config-dir>/agents.json` (default
`~/.config/omni-chat/agents.json`; `CHAT_AGENTS_PATH` overrides only this file) declaring which
directories agents may touch and which agents exist. A ready-to-copy starter lives at
[docs/agents.example.json](docs/agents.example.json):

```bash
cp docs/agents.example.json ~/.config/omni-chat/agents.json
# or for a one-off run:
CHAT_AGENTS_PATH=docs/agents.example.json make dev
```

```json
{
  "schema_version": 1,
  "workspace_roots": ["~/Work"],
  "agents": [
    { "id": "pi", "label": "Pi", "type": "pi", "command": "pi", "timeout_minutes": 15 }
  ]
}
```

Install Pi first (see [pi.dev](https://pi.dev)) so `command` resolves on `PATH` (or give an absolute
path). `workspace_roots` is the safety boundary: the agent can only ever run in a directory that
resolves under one of these roots.

For a **multi-user server** use *managed* workspaces instead: set `"workspace_mode": "managed"` with
a `"managed_root"` (starter: [docs/agents.server-example.json](docs/agents.server-example.json)). The
core then gives every conversation its own private directory `<managed_root>/<user>/<chat>` — created
on first delegation, reused for follow-ups in the same chat, and invisible to other users. The model
no longer picks a folder; the consent modal shows the assigned one.

Instead of installing the agent on the server, you can run it in a **Docker container**: build the
bundled image and point the agent entry at it —

```bash
docker build -t omni-agent-pi docker/agent-pi
```

```json
{ "id": "pi", "type": "pi", "command": "pi", "runtime": "docker", "image": "omni-agent-pi:latest" }
```

Per delegation the core runs the image with the workspace mounted at `/workspace`, as your own
uid/gid, with all capabilities dropped; the host only needs the `docker` CLI (plus `git` for the
run card's change summary). `docker_network` defaults to `host` so `localhost` model endpoints just
work on Linux; on macOS/Windows Docker Desktop set `"docker_network": "bridge"` — the core then
rewrites `localhost` endpoint URLs to `host.docker.internal` automatically.

**Getting at the files.** With managed workspaces, add `"sftp": { "enabled": true, "addr": ":2222" }`
and the core embeds an SFTP server over the workspace tree. Users sign in with their omni-chat
username and password and see only their own chats' folders (read-write — copy results out, or drop
input files in for the next delegation):

```bash
sftp -P 2222 alice@your-server          # CLI
# VS Code: install the "SSH FS" extension and add sftp://alice@your-server:2222
#   (SSH FS, not Remote-SSH — Remote-SSH must exec a server on the remote and
#   this port is sftp-subsystem-only, no shell/exec)
# FileZilla/Cyberduck: sftp, host your-server, port 2222, password login
```

There is no shell access — only the SFTP subsystem, jailed per user. The server's host key is
generated on first start at `<config-dir>/sftp_host_key`. Each agent-run card in the chat shows a
copy button with a ready-to-paste `sftp://you@host:port/<chat folder>` address; if the externally
reachable port differs from the listen port (e.g. a compose port mapping), set
`"public_port": <port>` in the `sftp` block so the advertised address is right.

Once configured, turn on the **Agent** icon toggle (the robot button) in the composer and ask for
something that needs a real workspace — *"in ~/Work/myproj, add a failing test for the parser and
make it pass — delegate it."* The model writes a self-contained brief and a **consent modal** appears
showing the target folder and the editable brief; nothing runs until you approve, and the agent sees
exactly what you approve. The subagent runs on the same model your chat is using. While it works you
see a live activity card (with a **Stop task** button); when it finishes, a collapsed run card records
the brief, what it did, its summary, and a `git diff --stat` of the changes — stored with the message
so it survives reload. Denying, stopping, or a timeout all leave the model to answer gracefully; the
turn never fails on an agent error. Each turn allows one delegation, and only one agent runs per user
at a time.

Adding support for another agent is a small, documented Go integration — see
[docs/dev/agent-integrations.md](docs/dev/agent-integrations.md).

## Image generation

With an image backend configured (`CHAT_IMAGE_GEN_URL`), a tool-capable model can create pictures:
ask *"Generate an image of a cyberpunk cat riding a skateboard"* and the model calls a
`generate_image` function with a detailed prompt (and an optional shape — square, portrait, or
landscape). The core generates the image server-side against the backend, stores it with the
conversation, and shows an **animated placeholder inline the moment generation starts**; with a
streaming backend (the bundled sidecar, or anything speaking OpenAI's `partial_images` streaming)
the placeholder becomes a **blurred preview of the actual image that sharpens as generation
progresses**, with a live percentage — then the finished image replaces it in place, with the
prompt as a
caption, a **Download** button, and a **Variation** button that prefills the composer with a
variation request you can edit before sending (the model then re-generates with a fresh seed).
Images are private to your account like any attachment, survive reload, and are included in chat
exports. Each response is budgeted to two images; if the backend is down or slow the model is told
generation failed and answers gracefully — the turn never errors on it.

The bundled image server renders **one image at a time** (concurrent diffusions on one GPU are
slower than running back-to-back). When someone else's generation is in flight, yours waits its
turn and the placeholder says *"Waiting for the image server… (N ahead)"* — queued jobs never time
out while visibly waiting. The queue is bounded (`IMAGEGEN_MAX_QUEUE`, default 4 waiters); beyond
that the server reports busy and the assistant tells you to try again shortly.

**Editing and image-to-image.** The model can also transform an existing image with a companion
`edit_image` tool: attach a photo and say *"make this look like a watercolor"*, click the **Edit**
button on any generated image (it prefills the composer with the image's reference), or just say
*"now make it night time"* about the image it produced a moment ago. **This works with any
tool-capable chat model — no vision support required**: the model passes an image *reference* (an
id the app provides in text) plus your instruction, and the image backend does the pixel work. A
vision model can describe the image to itself for richer edit prompts, but it's optional. Edited
results show an "Edited ·" caption and count against the same two-images-per-response budget, and
the placeholder starts from a blurred copy of the source image so you see what's being transformed.

The tools are offered whenever the backend is configured and the model supports tool calling — there
is no per-chat toggle. The backend speaks the **OpenAI Images API** (`POST /v1/images/generations`
and multipart `POST /v1/images/edits`), so you can point `CHAT_IMAGE_GEN_URL` at the bundled
[Z-Image-Turbo sidecar](#image-generation-under-compose) (`docker/imagegen/`, GPU required) or at
any other compatible server. The sidecar's edit quality has two levels: leave `IMAGEGEN_EDIT_MODEL_ID` unset for classic
img2img re-imagining using the already-loaded base model (no extra download — the right default
for most machines), or set it to a dedicated
instruction-edit model for true "change X to Y" edits:
[`Qwen/Qwen-Image-Edit-2509`](https://huggingface.co/Qwen/Qwen-Image-Edit-2509) (Apache-2.0, the
reference open editor — but ~20B, so plan for ~40–60 GB) or
`black-forest-labs/FLUX.1-Kontext-dev` (12B; gated + non-commercial license — accept it on HF and
set `HF_TOKEN`). Tongyi announced a same-family Z-Image-Edit, but no public weights exist as of
July 2026.

**Tuning fallback edits** (no `IMAGEGEN_EDIT_MODEL_ID`): the bundled Z-Image-Turbo base is
guidance-free, so a style prompt only steers weakly and edits keep the composition while lightly
re-imagining it. Two levers make restyles land: raise `IMAGEGEN_EDIT_STRENGTH` (default 0.7)
toward ~0.8 for a stronger change (lower toward 0.6 if the subject drifts), and rely on the full
descriptive prompt the model now writes (e.g. "the same photo as a soft watercolor painting")
rather than a terse instruction. `IMAGEGEN_EDIT_GUIDANCE` (default 1.0) only helps if your model
responds to CFG — the distilled base can burn/over-saturate above 1, so leave it unless you swap
in a CFG-capable model. True "add a hat / change X to Y" edits need a dedicated
`IMAGEGEN_EDIT_MODEL_ID`, which is impractical under ~32 GB of memory.

Generation can take a while on local hardware — raise
`CHAT_IMAGE_GEN_TIMEOUT` if your backend cold-loads the model.

## Multiple backends

To combine models from several OpenAI-compatible backends (e.g. a local vLLM/LiteLLM proxy alongside a hosted provider) in one model picker, create `<config-dir>/endpoints.json` (default `~/.config/omni-chat/endpoints.json`; `CHAT_ENDPOINTS_PATH` overrides only this file). Each entry needs a `name` and `base_url`; an API key is optional and may be given inline as `api_key` or indirected via `api_key_env` (the name of an env var to read). Per-endpoint capability fields mirror the `CHAT_*` model/request settings and apply only to that backend. An optional `model_overrides` array declares per-model capability metadata (matched by a `model_pattern` glob on the model id) — `vision`, `tool_calling`, `params_b`, `vendor`, `family` — that the Auto router uses; overrides win over the built-in model catalog and over id inference.

A ready-to-copy starter lives at [docs/endpoints.example.json](docs/endpoints.example.json):

```bash
cp docs/endpoints.example.json ~/.config/omni-chat/endpoints.json
# or for a one-off run:
CHAT_ENDPOINTS_PATH=docs/endpoints.example.json make dev
```

```json
{
  "schema_version": 1,
  "default_endpoint": "local-litellm",
  "endpoints": [
    { "name": "local-litellm", "base_url": "http://localhost:4000" },
    { "name": "openai", "base_url": "https://api.openai.com", "api_key_env": "OPENAI_API_KEY",
      "max_tokens_field": "max_completion_tokens", "context_window": 128000 }
  ]
}
```

The model dropdown combines every backend's models under a selectable divider per backend (choosing the divider picks that backend's first model). A backend that fails to load at startup is shown as offline and skipped — the others still work. Each chat remembers both its model and backend. Endpoint API keys in this file are never written to SQLite, but note this owner-managed file is the one place a plaintext key may live; prefer `api_key_env` if you'd rather not store the secret in the file. When no `endpoints.json` exists, `CHAT_ENDPOINT_URL` is used and may be a single URL or a comma-separated list.

For local inference servers, `/api/models` also reports a normalized model residency state when the backend exposes one. Omni Chat auto-detects Ollama (`/api/ps`), LM Studio (native v1, with v0 fallback), llama.cpp router mode (`/models`) and single-server mode (`/health`), vLLM (`/health` plus its served model list), and oMLX (`/v1/models/status`). The composer model picker shows this as a small status LED beside each real model (and next to the selected model on the closed picker); the Auto router and backend divider rows carry none. These probes are optional: unsupported or temporarily failing status endpoints leave the LED neutral without removing the model from the picker.

## Auto (Smart Router)

The model picker also offers a virtual **✨ Auto (Smart Router)** entry whenever at least one real model is available. Selecting it classifies each turn (task type, complexity, required capabilities, input size) with fast, deterministic heuristics and routes it to the single best available real model/endpoint, falling back to the default endpoint's first model if nothing else is routable. It works out of the box with a built-in policy — no configuration required — and after a response the composer shows `Auto → <routed-model>`; the response's Generation details panel shows which model handled it and why.

Selection also considers each model's resolved capabilities — vision, tool-calling, parameter size, and vendor/family — drawn from a built-in catalog of common model ids, from id inference, or from a per-endpoint `model_overrides` array in `endpoints.json` (strongest first). Policy selectors can target the derived tags (`vision`, `size_small`/`size_medium`/`size_large`, `reasoning`, `vendor_<v>`/`family_<f>`), and a turn with an image attachment hard-requires a vision-capable model (relaxed only if none is available).

Power users can customize model tagging and per-task preferences with `<config-dir>/router.json` (default `~/.config/omni-chat/router.json`; `CHAT_ROUTER_PATH` overrides only this file). A ready-to-copy starter lives at [docs/router.example.json](docs/router.example.json):

```bash
cp docs/router.example.json ~/.config/omni-chat/router.json
# or for a one-off run:
CHAT_ROUTER_PATH=docs/router.example.json make dev
```

See PRD §11 "Router definitions" for the full schema.

### Semantic router classifier

By default the router classifies each turn with fast, deterministic heuristics only. To classify
turns semantically instead, you have two options:

- **Zero-config:** if the [auxiliary model](#auxiliary-model-chat-titles-memory-jobs-smart-routing)
  (`CHAT_AUX_*`) is configured, it automatically doubles as the classifier via the JSON-schema
  adapter — no extra setup. Disable this role with `CHAT_AUX_ROUTER=0`.
- **Dedicated model:** point the router at a purpose-built classifier with
  `CHAT_ROUTER_CLASSIFIER_URL` and `CHAT_ROUTER_CLASSIFIER_MODEL` (and optionally
  `CHAT_ROUTER_CLASSIFIER_API_KEY` / `CHAT_ROUTER_CLASSIFIER_TIMEOUT` / `CHAT_ROUTER_CLASSIFIER_FORMAT`),
  or add an equivalent `classifier` section to `router.json` (see the commented example in
  [docs/router.example.json](docs/router.example.json)) — env vars override the file per field. A
  dedicated classifier always takes precedence over the auxiliary model.

The default classifier model is [`Arch-Router-1.5B`](https://huggingface.co/katanemo/Arch-Router-1.5B),
a purpose-built router model. The core auto-detects it and speaks its native protocol: it is
handed a set of routes (the task classes, with descriptions you can override via a `routes`
array in `router.json`) and returns the single best-fitting one, which maps to a model through
the usual tag rules and policies. `CHAT_ROUTER_CLASSIFIER_FORMAT` (`auto`/`arch`/`schema`, default
`auto`) forces the adapter — set `schema` to use a general instruct model with the richer
JSON-schema classification instead. Because Arch-Router returns only a route, under it (or with the
classifier disabled) the Auto-profile persona is chosen by a deterministic task-affinity heuristic
over the profile catalog rather than by the classifier; the `schema` classifier gives a richer,
content-aware persona pick.

On every routed turn the classifier races the heuristics against a deadline (default `3000ms`,
sized for a CPU-only sidecar classifying a fresh, full-context turn — see
[docs/router.example.json](docs/router.example.json) to tune it for your hardware).
Whichever finishes first within the deadline wins; a classifier error, timeout, or unset
config falls back to heuristics, so a turn never fails or blocks on it. The persisted
`routing_reason` records which one won, prefixed `semantic: ` or `heuristic: `, and the
per-message stats hover's decision time includes any time spent waiting on the classifier.

To self-host a tiny classifier model with no extra setup, use the `compose.router.yaml` (or
`compose.router-only.yaml` for host-run cores) Compose overlay described above under
[Docker](#docker); it runs `ghcr.io/ggml-org/llama.cpp` with the Arch-Router-1.5B GGUF and
constrained JSON output.

**Third-party model license:** Arch-Router-1.5B is a
[Katanemo model](https://huggingface.co/katanemolabs/Arch-Router-1.5B) published by
DigitalOcean, LLC under the Katanemo Community License, not Omni Chat's own Apache-2.0 license.
Built with DigitalOcean. Commercial use of Arch-Router-1.5B requires a separate license from
DigitalOcean — see the model's license terms on Hugging Face before using it commercially.
Katanemo Models are licensed under the DigitalOcean Community License, Copyright 2026
DigitalOcean, LLC. All Rights Reserved.

### Auto profile

The profile dropdown also offers a synthetic **✨ Auto** entry alongside the built-in and any
custom profiles. `auto` is reserved — it is never a real profile you can define in
`profiles.json`. Picking Auto is an autopilot: it also switches the chat to the **✨ Auto (Smart
Router)** model (when that model is available), so a single choice hands both the backend model
and the per-turn persona to the router. Auto only picks a persona on an Auto (Smart Router) chat.
On such a turn the profile catalog is offered to the router: the `schema` semantic classifier, when
enabled, picks the best-fitting profile from it; otherwise (Arch-Router, or the classifier disabled
or too slow) a deterministic task-affinity heuristic matches the turn's task to a profile whose
description reads like that kind of work. Either way the chosen profile's persona is used and the
response shows `Auto → <Profile Label>` beside the response metadata, exactly like a manually-picked
profile would. When no profile fits (e.g. a trivial greeting) or the chat uses a manually-selected
model, Auto behaves like `No profile` and the response is simply labeled `Auto`. Reopening such a
chat restores the **Auto** profile selection rather than whatever persona it happened to route to on
the last turn.

## Manual Verification

To confirm settings are reaching the model:

1. Start your OpenAI-compatible endpoint.
2. Start Omni Chat with `CHAT_ENDPOINT_URL` and, if needed, `CHAT_MAX_TOKENS_FIELD`.
3. Create/login in the browser.
4. Create or select a chat, choose a model, optionally edit Session Instructions or select a Profile, change Tone / Response Length / Creativity / Format, and send a message.
5. Check your endpoint or proxy logs. The upstream request should include exactly one `system` message, the selected model, and the configured max-token field.

If `CHAT_HONORS_SAMPLING=false`, `temperature` and `top_p` are intentionally omitted even when Creativity changes. If `CHAT_HONORS_SAMPLING=true`, Creativity maps to sampling parameters.

Token usage stats are shown when the backend reports them. The core normalizes OpenAI-compatible `usage` chunks and Ollama final-stream `prompt_eval_count` / `eval_count` metrics into the same message stats. Open the info icon beneath an assistant response for labeled token counts, prefill/generation speed, TTFT, thinking time when present, and total request-to-completion time formatted in seconds, minutes, or hours. Each new assistant message also snapshots its resolved model/endpoint, thinking mode, and whether web search was unavailable, unused, successful, empty, or failed, so later chat-setting changes do not rewrite history.

## Troubleshooting

### SearXNG exits because `server.secret_key` is unchanged

Recent SearXNG releases reject the bundled `ultrasecretkey`. Generate a unique installation secret
and recreate the overlay:

```bash
printf 'SEARXNG_SECRET=%s\n' "$(openssl rand -hex 32)" >> .env
docker compose -f compose.yaml -f compose.search.yaml up -d
```

Do not commit `.env` or place this secret in `settings.yml`.

### Empty model picker with a LAN endpoint on macOS

If `CHAT_ENDPOINT_URL` points to a machine on your local network (e.g.
`http://192.168.x.x:8000` or `http://<hostname>.local:port`) and the model
picker stays empty even though `curl`ing the same `/v1/models` URL works, the
cause is usually **macOS Local Network privacy**. Recent macOS releases block a
process from reaching local-network addresses until you grant it Local Network
access, which surfaces here as a "no route to host" error and an offline
endpoint. Public/internet endpoints are unaffected.

Fix: **System Settings → Privacy & Security → Local Network**, enable the toggle
for the terminal app you launch `make dev` from (Terminal, iTerm2, Ghostty,
VS Code, …), then **quit and reopen that terminal** and start the core again. The
entry only appears after the app has attempted a local-network connection, so run
`make dev` once first if it isn't listed. The grant is tied to the terminal app,
so it survives `go run` rebuilds. If you launch through tmux/screen/ssh, start
from a plain terminal window so the permission is attributed correctly.
