---
name: open-webui
description: Configure Open WebUI including admin setup, API endpoints, env vars, and feature flags
category: devops
---

# Open WebUI Setup

## API Endpoints

### Auth (signup/login)
- **Signup:** `POST /api/v1/auths/signup` (note: `/auths` with 's', not `/auth`)
- **Body:** JSON with `name`, `email`, `password`, `profile_image_url`
- **Example:**
```bash
curl -s -X POST http://localhost:3081/api/v1/auths/signup \
  -H "Content-Type: application/json" \
  -d '{"name":"Admin","email":"admin@localhost","password":"Admin123!@#","profile_image_url":"/user.png"}'
```

### Models
- `GET /api/v1/models` — requires auth (returns "Not authenticated" without token)
- Models are pulled from Ollama/Llama-swap via `OLLAMA_BASE_URL` env var

### Config
- Config router prefix: `/api/v1/configs`
- Key endpoints: `/connections`, `/models`, `/tool_servers`, `/code_execution`
- Settings are primarily controlled via **environment variables**, not API PATCH

## Environment Variables (key ones)

### LLM Backend
| Variable | Purpose | Recommended |
|---|---|---|
| `OLLAMA_BASE_URL` | Ollama protocol endpoint | **Leave empty** when using llama-swap (OpenAI protocol). Empty = disable Ollama polling (would 404). |
| `OPENAI_API_BASE_URL` | OpenAI-compatible backend | `http://127.0.0.1:9292/v1` (host networking) or `http://host.docker.internal:9292/v1` (bridge) — **trailing `/v1` REQUIRED.** Open WebUI strips the path prefix before making requests (calls `/models` not `/v1/models`). |
| `OPENAI_API_KEY` | Satisfies Open WebUI's auth check | `sk-no-...ired` — must be non-empty even though llama-swap doesn't validate it |
| `HERMES_API_KEY` | Hermes Agent gateway key | Set to the key from `~/.hermes/config.yaml` under `api_server.key` |
| `HERMES_API_URL` | Hermes Agent API URL | `http://host.docker.internal:18789` |
| `AIOHTTP_CLIENT_TIMEOUT` | **CRITICAL for stuck models.** Timeout for chat completions. | `300` (5 min). Default `None` = WUI hangs forever if model stalls. |
| `AIOHTTP_CLIENT_TIMEOUT_MODEL_LIST` | Timeout for model list fetching | `10` (seconds) |
| `AIOHTTP_POOL_CONNECTIONS` | Max total outbound connections | `10` (prevents request pileup on slow models) |
| `AIOHTTP_POOL_CONNECTIONS_PER_HOST` | Max concurrent requests to same upstream | `2` (one active + one queued) |

### Web Search (SearXNG)
| Variable | Purpose | Recommended |
|---|---|---|
| `ENABLE_WEB_SEARCH` | Enable web search | `true` |
| `WEB_SEARCH_ENGINE` | Search engine | `searxng` |
| `SEARXNG_QUERY_URL` | SearXNG endpoint | `http://127.0.0.1:8080/search?q=<query>&format=json` (host networking) or `http://host.docker.internal:8080/...` (bridge) |
| `SEARXNG_LANGUAGE` | Search language | `en-US` for English results, `all` for multi-language |
| `WEB_SEARCH_RESULT_COUNT` | Results per query | `10` (more context = better answers) |
| `ENABLE_RAG_LOCAL_WEB_FETCH` | AI reads full page content (ChatGPT-style browsing) | `true` |
| `ENABLE_SEARCH_QUERY_GENERATION` | AI generates optimized search queries | `true` (default) |
| `WEB_FETCH_MAX_CONTENT_LENGTH` | Max chars to fetch per page | `10000` |
| `BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL` | Inject search results directly into model context (skip vector DB pipeline). Works with BOTH forced and agentic search. | `true` |

**Two approaches for web search behavior:**

1. **Agentic/tool-based (recommended):** Model decides when to search via builtin tools. Patches `tools.py` and `middleware.py` — see "Agentic Web Search (Model Decides When to Search)" section below.
2. **Always-on forced injection:** Every message gets search results injected — see "Always-On Web Search" section below.

Pick ONE approach, not both. Agentic is better for ChatGPT-like behavior (search only when asked).

**ChatGPT-like web search:** Set `ENABLE_RAG_LOCAL_WEB_FETCH=true` so the model can not just search but also fetch and read full page content, mirroring ChatGPT's browsing capability.

### Agentic Web Search (Model Decides When to Search)

The user wants web search to work like ChatGPT — the model automatically searches when you say "search the web" or "do deep research", but doesn't search on every message. This requires tool-based (agentic) web search, not forced RAG injection.

**How it works:**
- The `search_web` tool is injected as a builtin tool that the model can call via native function calling
- The model DECIDES when to call it based on the user's intent
- Regular chat messages don't trigger a search — only when the user asks for information that needs a web lookup

**Requirements:**
1. Model must support OpenAI function/tool calling (llama.cpp models with `--tool-call` flag, Hermes/Qwen models, etc.)
2. `function_calling: 'native'` must be set in the model params (default: not set)
3. The web_search builtin tool must be available (removed the `features.web_search` frontend gate)

**Setup — patch two Python files and mount as volumes:**

#### 1. middleware.py — default function_calling to 'native'

```bash
docker cp open-webui:/app/backend/open_webui/utils/middleware.py /tmp/middleware.py
```

Add after the `open_webui_params` dict (around line 2080):
```python
    # Default to native function calling for tool-using models
    params.setdefault('function_calling', 'native')
```

Mount:
```yaml
volumes:
  - /tmp/middleware.py:/app/backend/open_webui/utils/middleware.py:ro
```

#### 2. tools.py — remove features.web_search gate

```bash
docker cp open-webui:/app/backend/open_webui/utils/tools.py /tmp/tools.py
```

Change the web_search builtin tool condition from:
```python
    if (
        is_builtin_tool_enabled('web_search')
        and getattr(request.app.state.config, 'ENABLE_WEB_SEARCH', False)
        and get_model_capability('web_search')
        and features.get('web_search')          # ← remove this line
        and await has_user_permission('web_search')
    ):
        builtin_functions.extend([search_web, fetch_url])
```

To:
```python
    if (
        is_builtin_tool_enabled('web_search')
        and getattr(request.app.state.config, 'ENABLE_WEB_SEARCH', False)
        and get_model_capability('web_search')
        and await has_user_permission('web_search')
    ):
        builtin_functions.extend([search_web, fetch_url])
```

Mount:
```yaml
volumes:
  - /tmp/tools.py:/app/backend/open_webui/utils/tools.py:ro
```

**Result:** The model has the `search_web` + `fetch_url` tools available and calls them only when appropriate. The 🌐 globe toggle is irrelevant — tools are always available but only used when the model decides.

**Verification:** Ask "what's the weather?" (no search) then "search the web for latest AI news" (should trigger search). Check `docker logs open-webui 2>&1 | grep search_web` to confirm tool calls.

**Caveats:**
- Patches survive container restarts but NOT image updates (mounted volumes shadow bundled files)
- Maintain patch files at a stable path (e.g., `~/.hermes/overrides/open-webui/`)
- Local llama.cpp models may not support tool calling — test with a tool-calling query first

### ⚠️ Web Search Pitfall: Results Reach API but Not the Model

**Symptom:** The web search API returns results (testable via `/api/v1/retrieval/process/web/search`), search results are embedded and stored in the vector DB (visible in logs: "added N items to collection web-search-..."), but the model responds as if it has no web access ("I don't have access to real-time information").

**Root cause #1 — `BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL=false` (default):** The search results go through the embedding/retrieval pipeline (vector DB) where they may fail silently — the RAG query might not match the stored embeddings, or the retrieval step returns empty. **Fix:** Set `BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL=true` to inject results directly into the model's context as plain text, bypassing the vector DB entirely. This is the most reliable approach.

**Root cause #2 — Web search toggle not enabled in chat:** The user must click the 🌐 globe icon in the chat input to enable web search for each conversation. `ENABLE_WEB_SEARCH=true` globally enables the FEATURE, not the per-chat toggle.

### Always-On Web Search (Forced, Every Message)

**⚠️ Alternative preferred:** See "Agentic Web Search (Model Decides When to Search)" above for a ChatGPT-like approach where the model decides when to search. Only use this always-on approach if you truly want search on every message.

To force web search ON for every chat without requiring the user to click the 🌐 icon, patch the backend middleware to inject `features['web_search'] = True` before the chat handler processes the request.

**Patch approach:**

1. Copy the middleware file out of the container:
```bash
docker cp open-webui:/app/backend/open_webui/utils/middleware.py /tmp/middleware.py
```

2. Apply the one-line patch (after `features = form_data.pop('features', None) or {}`):
```python
features = form_data.pop('features', None) or {}
# Force web_search on for all chats — removes need for user to click globe icon
features['web_search'] = True
```

3. Mount the patched file as a read-only volume:
```yaml
volumes:
  - /tmp/middleware.py:/app/backend/open_webui/utils/middleware.py:ro
```

4. Restart the container.

**Caveats:**
- The patch survives container restarts but NOT image updates (the mounted volume shadows the bundled file)
- Every chat message will trigger a web search — this increases latency and token usage
- Set `BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL=true` alongside this to ensure results reach the model
- For production, maintain the patch file at a stable path (e.g., `~/.hermes/overrides/open-webui/middleware.py`)

**Verification:** Send a message without clicking the 🌐 icon — the model should reference search results in its response. Check `docker logs open-webui 2>&1 | grep 'save_docs_to_vector_db'` to confirm search is triggered.

**Verification flow:**
1. Check `BYPASS_WEB_SEARCH_EMBEDDING_AND_RETRIEVAL` is `true` via `/api/v1/retrieval/config`
2. Test the search API directly: `POST /api/v1/retrieval/process/web/search` with `{"queries":["test"],"count":5}` → should return `items` with titles, links, snippets
3. If the API returns results but the model doesn't use them, the bypass flag is the fix
4. Check the logs: `docker logs open-webui 2>&1 | grep -E 'save_docs_to_vector_db|web.search'` — search results being saved doesn't mean they reach the model

**Verify SearXNG works standalone:**
```bash
curl -s "http://127.0.0.1:8080/search?q=test&format=json" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'Results: {len(d.get(\"results\",[]))}')"
```

**Verify web search through Open WebUI API:**
```bash
TOKEN=$(curl -s -X POST http://127.0.0.1:3081/api/v1/auths/signin -H 'Content-Type: application/json' -d '{"email":"admin@localhost","password":"<password>"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
curl -s -X POST http://127.0.0.1:3081/api/v1/retrieval/process/web/search \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"queries":["test query"],"count":5}' \
  | python3 -c "import sys,json; d=json.load(sys.stdin); [print(f'  {r[\"title\"]}') for r in d.get('items',[])]"
```

### Feature Flags
| Variable | Purpose | Recommended |
|---|---|---|
| `ENABLE_API_KEYS` | Enable API key generation | `true` |
| `ENABLE_CODE_INTERPRETER` | Enable code execution | `true` |
| `ENABLE_IMAGE_GENERATION` | Enable image generation | `true` |
| `ENABLE_MEMORIES` | Enable memories feature | `true` |
| `ENABLE_NOTES` | Enable notes | `true` |
| `ENABLE_FOLDERS` | Enable chat folders | `true` |
| `ENABLE_CHANNELS` | Enable channels (disable for single-user) | `false` |
| `ENABLE_AUTOMATIONS` | Enable automations | `false` |
| `ENABLE_COMMUNITY_SHARING` | Disable for privacy | `false` |
| `ENABLE_SIGNUP` | Allow new user signup (disable after admin) | `false` |
| `ENABLE_LOGIN_FORM` | Show login form | `true` |
| `ENABLE_ADMIN_CHAT_ACCESS` | Admin can view any chat | `true` |
| `ENABLE_ADMIN_EXPORT` | Admin can export data | `true` |
| `ENABLE_DIRECT_CONNECTIONS` | Direct OpenAI API connections | `true` |
| `ENABLE_BASE_MODELS_CACHE` | Cache model list | `true` |
| `ENABLE_ASYNC_EMBEDDING` | Async embedding loading | `true` |

### Security
| Variable | Purpose | Recommended |
|---|---|---|
| `CORS_ALLOW_ORIGIN` | CORS origin — **critical for streaming responses from LAN/Tailscale** | `*` on trusted networks (LAN + Tailscale). Restrict to domain only behind public HTTPS proxy. |
| `WEBUI_SECRET_KEY` | Session signing key | Auto-generated if unset; set for persistence |
| `WEBUI_SESSION_COOKIE_SAME_SITE` | CSRF protection | `lax` (default) or `strict` |
| `WEBUI_SESSION_COOKIE_SECURE` | Require HTTPS for cookies | `true` behind HTTPS proxy |
| `FORWARDED_ALLOW_IPS` | Trusted proxy IPs | `*` behind nginx, or restrict to proxy IP |
| `SCARF_NO_ANALYTICS` | Disable telemetry | `true` |
| `DO_NOT_TRACK` | DNT header | `true` |
| `ANONYMIZED_TELEMETRY` | Disable anonymized telemetry | `false` |

## Docker Networking

### Port Mapping
- Container internal port: **8080**
- Bridge network mapping: `127.0.0.1:3081:8080` (localhost-only by default)
- **Change to `0.0.0.0:3081:8080`** in docker-compose.yml if you need to access Open WebUI from other devices (phone, laptop) on the same LAN or Tailscale. This is safe on a trusted LAN.
- To update: edit the compose file, then `docker compose up -d --force-recreate` (Docker doesn't allow port binding changes on a running container).
- **Never use `network_mode: host` if SearXNG is on port 8080** — both will conflict

### Reaching Host Services from Container

**Option A — Bridge Gateway IP (most portable):**
```bash
# Find the gateway
docker inspect openwebui_default --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}'
# → 172.19.0.1
# Use in env vars:
OLLAMA_BASE_URL=http://172.19.0.1:9292
SEARXNG_QUERY_URL=http://172.19.0.1:8080/search?q=<query>&format=json
```

**Option B — host.docker.internal (requires extra_hosts):**
```yaml
services:
  open-webui:
    extra_hosts:
      - "host.docker.internal:host-gateway"
```
Then use `http://host.docker.internal:9292` etc. Verify it resolves:
```bash
docker exec open-webui getent hosts host.docker.internal
```

**⚠️ host-gateway may resolve to the WRONG bridge.** On some Docker setups (Compose v2 with custom bridge networks), `host-gateway` resolves to the default docker0 bridge (`172.17.0.1`) instead of the compose network gateway (`172.19.0.1`). Services on the host reachable via the compose gateway won't respond on the default bridge. **Fix:** hardcode the compose gateway IP directly:

```yaml
extra_hosts:
  - "host.docker.internal:172.19.0.1"
```

Find your compose network gateway: `docker inspect <network-name> --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}'`

### Docker → Host Connectivity: When Bridge Fails

If the container cannot reach host services (llama-swap, SearXNG, etc.) even with the correct gateway IP — connection timeouts despite DNS resolving correctly — the Docker bridge's iptables/NAT rules may be blocking forwarded traffic.

**Diagnosis:** `docker exec open-webui python3 -c "import urllib.request; r=urllib.request.urlopen('http://<gateway>:9292/v1/models', timeout=5)"` → `URLError: timed out` (even though `socket.gethostbyname('host.docker.internal')` returns the correct IP).

**Solution — `network_mode: host` (best for single-service deployment):**

Switch to host networking so the container shares the host's network stack directly. No port mapping, no bridge, no extra_hosts needed. Localhost works for everything.

```yaml
services:
  open-webui:
    image: ghcr.io/open-webui/open-webui:latest
    container_name: open-webui
    restart: unless-stopped
    network_mode: host        # ← replaces ports: + extra_hosts: + networks:
    volumes:
      - open-webui-data:/app/backend/data
    environment:
      - PORT=3081             # ← must change from 8080 to avoid conflict with SearXNG/bare metal
      - OPENAI_API_BASE_URL=http://127.0.0.1:9292/v1
      - SEARXNG_QUERY_URL=http://127.0.0.1:8080/search?q=<query>&format=json
      - TERMINAL_SERVER_CONNECTIONS=[...]
      # Remove HERMES_API_URL/HERMES_API_KEY when not using Hermes Agent
      # All other env vars use 127.0.0.1 directly — no host.docker.internal needed
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:3081/health"]
```

**Why `PORT=3081`:** The default Open WebUI port is 8080 (inside the container). With host networking, the container's port 8080 competes with SearXNG (which also runs on 8080). Changing to 3081 avoids this.

**⚠️ Security warning:** With host networking, the container binds its internal port (e.g., 3081) to `0.0.0.0` on the host, making it reachable from the internet. Tailscale's `ts-input` iptables chain accepts all traffic, so nothing blocks it. **Prefer bridge networking with `127.0.0.1:PORT:PORT` mapping** when the Open WebUI is served through a nginx reverse proxy. Only use host networking when direct internet access is required.

**Caveat:** With host networking, the container has full access to all host ports. This is acceptable on a dedicated server with trusted services. On a multi-tenant machine, prefer bridge networking with proper isolation.

**Result:** All internal service calls (llama-swap, SearXNG, Hermes, Open Terminal) just use `http://127.0.0.1:<port>` — fully reliable, no network stack dependence.

When **UFW is active** with default `deny (routed)`, Docker containers on compose bridge networks cannot reach services on the host (llama-swap :9292, SearXNG :8080). The container resolves `host.docker.internal` correctly, but packets get dropped by UFW's FORWARD chain.

**Diagnosis:** `docker exec open-webui curl -v --connect-timeout 5 http://host.docker.internal:9292/v1/models` → connection timeout after `Trying <IP>:9292...`

**Fix — allow incoming on the bridge interface (NOT route forward):**
```bash
# Find the bridge interface
ip addr show | grep "br-"
# → br-02af8b721340

# Allow traffic from bridge to host ports
sudo ufw allow in on br-02af8b721340 to any port 9292 proto tcp
sudo ufw allow in on br-02af8b721340 to any port 8080 proto tcp
```

**⚠️ Bridge interfaces CHANGE every time `docker compose down` + `up`** — Docker creates a new bridge with a random suffix. Interface-name-based rules break on container recreate. **More stable alternative — subnet-based rules:**
```bash
# Allow entire Docker compose subnet range
sudo ufw allow proto tcp from 172.18.0.0/16 to any port 9292
sudo ufw allow proto tcp from 172.18.0.0/16 to any port 18789
sudo ufw allow proto tcp from 172.19.0.0/16 to any port 9292
sudo ufw allow proto tcp from 172.19.0.0/16 to any port 18789
sudo ufw allow proto tcp from 172.20.0.0/16 to any port 9292
sudo ufw allow proto tcp from 172.20.0.0/16 to any port 18789
# etc. for each /16 subnet Docker might assign
```

The `in on br-*` rule goes through the INPUT chain, not FORWARD, so `deny (routed)` does not apply. Subnet-based rules also go through INPUT. Repeat for each service port the container needs to reach on the host.

**Verification:** Re-run the curl from inside the container — should get a JSON model list response.

## Nginx Reverse Proxy (LAN Access)

Nginx serves on port 80 and proxies to Open WebUI. Tailscale Funnel owns port 443 (scraper) — **never configure Nginx on port 443**.

**Key nginx config features:**
- `proxy_pass http://127.0.0.1:3081` — WebUI on port 3081
- WebSocket upgrade headers for streaming (`proxy_set_header Upgrade $http_upgrade`)
- `proxy_read_timeout 86400s` — long timeout for streaming responses
- `client_max_body_size 50m` — file uploads
- Security headers: X-Frame-Options, X-Content-Type-Options, Referrer-Policy
- `listen 80 default_server` — catches all hostnames for LAN access

**Verification:**
```bash
curl -s http://127.0.0.1/ -o /dev/null -w "Nginx: %{http_code}\n"
```

## ⚠️ Docker Volume: Bind Mount over Named Volume

By default, Docker compose uses a named volume (`open-webui-data`) stored under `/var/lib/docker/volumes/` with root ownership. This causes two problems:
- **Host-invisible writes:** The container writes data (6.9MB+ webui.db) but the host-visible path stays 0 bytes — only the container's overlay shows the real file.
- **Permission drift:** UID/GID mismatch between container process (UID 1000) and host volume (root) can cause write failures on container restart/rebuild.

**Fix — switch to a bind mount:**

```yaml
services:
  open-webui:
    volumes:
      - /home/user/openwebui-data:/app/backend/data
    # instead of:
    #   - open-webui-data:/app/backend/data
```

```bash
mkdir -p /home/user/openwebui-data
# Copy existing data while container is running:
docker cp open-webui:/app/backend/data/. /home/user/openwebui-data/
# Update docker-compose.yml, then:
docker stop open-webui && docker rm open-webui
docker compose up -d
```

After switching: DB at `/home/user/openwebui-data/webui.db` is directly accessible, survives rebuilds.

---

## ⚠️ Model Tool-Calling Requirement

**Open WebUI tools only work when the model supports OpenAI function/tool calling.** Local models served via llama.cpp/llama-swap (gemma, Qwen, etc.) do NOT support tool calling — they accept the `tools` parameter in the API but silently ignore it and respond normally. This means:

- Custom tools created via Admin Panel → Tools are **dead code** when chatting with local models
- The Hermes Agent Bridge Tool reference below is **non-functional** with local vanilla/capped llama.cpp models
- The `ENABLE_CODE_EXECUTION=true` flag enables the built-in sandboxed Python interpreter (not tool calling)

**IMPORTANT: Set `function_calling: false` for local models.** In the Open WebUI config (SQLite `config` table), disable `function_calling` under `model_capabilities`:

```python
config['openai']['model_capabilities']['function_calling'] = False
```

Without this, Open WebUI may attempt to inject tool calls into requests sent to llama.cpp models — the model silently ignores them but wastes context window and adds latency.

**To use Open WebUI tools, you need a model with native function calling:**
- Frontier cloud models (GPT-4+, Claude 3+, Gemini 1.5+) support it
- For local models, verify via the API first: pass `tools` in a request and check for `tool_calls` in the response

Without tool calling, Open WebUI falls back to **prompt-based tool invocation** which is unreliable and typically doesn't trigger tools at all.

---

## Custom Tools

Open WebUI Tools are Python modules that run server-side and are exposed to the LLM as OpenAI function-calling tools. **Only works with models that support native function calling (see above).**

### Tool Format

Each tool is a Python file with async functions. Every public function (no leading `_`) becomes a callable tool. Functions can accept `__user__` and `__request__` for context injection:

```python
"""
Tool description shown in UI.
"""

import json
import urllib.request
from typing import Optional


async def my_tool(
    query: str,
    limit: Optional[int] = 10,
    __user__: Optional[dict] = None,
) -> str:
    """
    Description for the LLM — what this tool does.

    Args:
        query: Search query
        limit: Max results (default: 10)

    Returns:
        Results as formatted text
    """
    # Tool logic here
    return "result"
```

### Installation Methods

**Method 1 — Admin UI:** Admin Panel → Tools → "+" button → paste code → save.

**Method 2 — Direct DB insert (scripted):**
```python
import sqlite3, json, time

ADMIN_USER_ID = "<user-uuid>"  # from user table
now = int(time.time())

conn = sqlite3.connect("webui.db")
cur = conn.cursor()
cur.execute(
    """INSERT INTO tool (id, user_id, name, content, specs, meta, created_at, updated_at)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
    (
        "tool-id",
        ADMIN_USER_ID,
        "Tool Name",
        open("/path/to/tool.py").read(),
        "[]",
        json.dumps({"description": "what it does", "manifest": {}}),
        now,
        now,
    )
)
conn.commit()
conn.close()
```

After DB insert: `docker restart open-webui` for the tool to be discovered.

### Environment Variables for Tools

Tools access env vars via `os.environ.get()`. Add them to the docker-compose environment section:

```yaml
environment:
  - MY_TOOL_API_KEY=actual-key-value
  - MY_TOOL_API_URL=http://host.docker.internal:9999
```

### Hermes Agent Bridge Tool

**⚠️ LIMITATION: Non-functional with local llama.cpp models** — they don't support OpenAI function/tool calling. Only works with models that have native function calling (GPT-4+, Claude 3+, etc.).

See `references/hermes-bridge-tool.md` for the full tool source and install instructions.

**Preferred alternative:** Add Hermes Agent as a second OpenAI provider (see "Multiple OpenAI Providers" below) — it shows as a selectable chat model instead of a tool.

### Terminal Tool

The built-in `ENABLE_CODE_EXECUTION=true` runs Python in a sandbox. For a full shell, deploy the official Open Terminal container (see Open Terminal section below).

## Open Terminal (Official)

Open Terminal connects a real computing environment to Open WebUI — the AI can run shell commands, browse/edit files, clone repos, and iterate without leaving the chat.

## Deployment — Docker Compose (Isolated Container)

Add as a service alongside Open WebUI, sharing a custom network:

```yaml
services:
  open-webui:
    # ... your existing config ...
    networks:
      - openwebui-net

  open-terminal:
    image: ghcr.io/open-webui/open-terminal
    container_name: open-terminal
    restart: unless-stopped
    volumes:
      - open-terminal-data:/home/user
    environment:
      - OPEN_TERMINAL_API_KEY=your-secret-key
    networks:
      - openwebui-net

volumes:
  open-terminal-data:

networks:
  openwebui-net:
    driver: bridge
```

**⚠️ Container = isolated filesystem.** The AI gets `/home/user` (a Docker volume), not the host's real files. Use **Bare Metal** below for full host access.

## Deployment — Bare Metal (Real Debian Host)

**The AI works directly on your machine — your real files, tools, and environment.**

### 1. Generate an API Key
```bash
python3 -c "import secrets; print(secrets.token_urlsafe(32))"
```

### 2. Install and Start via systemd
Install `open-terminal` via `uvx` (no permanent pip install needed):

```ini
# /etc/systemd/system/open-terminal.service
[Unit]
Description=Open Terminal - bare metal shell API for Open WebUI
After=network.target

[Service]
ExecStart=/home/<user>/.local/bin/uvx open-terminal run --host 0.0.0.0 --port 8000 --api-key <your-api-key>
User=<your-username>
Restart=unless-stopped
WorkingDirectory=/home/<user>

[Install]
WantedBy=multi-user.target
```

```bash
sudo systemctl daemon-reload
sudo systemctl enable open-terminal
sudo systemctl start open-terminal
```

### 3. Configure UFW for Docker → Host Access
Add subnet-based rules (stable across compose recreates, unlike bridge-interface names):

```bash
sudo ufw allow proto tcp from 172.18.0.0/16 to any port 8000
sudo ufw allow proto tcp from 172.19.0.0/16 to any port 8000
sudo ufw allow proto tcp from 172.20.0.0/16 to any port 8000
sudo ufw allow proto tcp from 172.21.0.0/16 to any port 8000
# Repeat 9292, 18789, etc. for other host services
```

### 4. Connect via Environment Variable

```yaml
# docker-compose.yml — on open-webui service
environment:
  - TERMINAL_SERVER_CONNECTIONS=[{"id":"debian-terminal","name":"Debian Terminal","url":"http://host.docker.internal:8000","key":"<your-api-key>","auth_type":"bearer","server_type":"terminal"}]
```

### ⚠️ TERMINAL_SERVER_CONNECTIONS Caching

**This env var is only applied on first initialization.** If you recreate the container or change the URL/key, the old cached config persists in the WebUI database.

To update an already-cached terminal connection:

```bash
curl -s -X POST http://localhost:3081/api/v1/configs/terminal_servers \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    | TERMINAL_SERVER_CONNECTIONS | ~ |
      "id": "debian-terminal",
      "name": "Debian Terminal",
      "url": "http://host.docker.internal:8000",
      "key": "<your-api-key>",
      "auth_type": "bearer",
      "server_type": "terminal",
      "enabled": true
    }]
  }'
```

The user-facing terminal list is at `GET /api/v1/terminals/` (returns id, url, name).

## Auto-Configuration via Env Var (first-run only)

```yaml
environment:
  - TERMINAL_SERVER_CONNECTIONS=[{"id":"debian-terminal","name":"Debian Terminal","url":"http://open-terminal:8000","key":"your-secret-key","auth_type":"bearer","server_type":"terminal"}]
```

The terminal connection appears automatically on **first container start** — no Admin Panel steps needed. On subsequent restarts the env var is ignored (cached config in DB takes priority).

## Manual Configuration (alternative)

Admin Panel → Settings → Integrations → **Open Terminal** section (NOT under "External Tools" or "Tool Servers"):
- URL: `http://open-terminal:8000` (compose) or `http://host.docker.internal:8000` (bare metal / separate containers)
- API Key: the `OPEN_TERMINAL_API_KEY` value
- Auth Type: Bearer

## Model Requirements

The AI needs a model with **native function calling** to invoke terminal tools. With local llama.cpp models:
- Terminal **will NOT auto-invoke** — the AI can't call tools
- Open WebUI falls back to prompt-based invocation (unreliable)
- For full terminal functionality, use a frontier model or a Hermes-series model with proper tool-calling support

### Without Tool Calling (fallback)

Even without tool calling, the terminal is still accessible manually from the chat input area: click the ☁ cloud icon → select the terminal under "System" → the AI can use it for code execution if the model supports it.

### API Endpoints

Open Terminal provides:
- `/execute` — run shell commands, get output
- `/execute/{process_id}/status` — check running processes
- `/execute/{process_id}/input` — send stdin to running processes
- `/files/list` — list directory contents
- `/files/read` — read file contents
- `/files/write` — write/create files
- `/files/replace` — replace text in files
- `/health` — health check

### Verification

```bash
# From Open WebUI container
docker exec open-webui curl -s http://open-terminal:8000/health
# → {"status":"ok"}
```

---

## Multiple OpenAI Providers

Open WebUI supports multiple OpenAI-compatible backends simultaneously. Each provider contributes its models to the model list.

### Configuration via API

The OpenAI provider config is at `/openai/config` (GET) and `/openai/config/update` (POST). The router prefix is `/openai` (NOT `/api/v1/openai`).

```bash
# Get current config
curl -s http://localhost:3081/openai/config -H "Authorization: Bearer <token>"

# Update with multiple providers
curl -s -X POST http://localhost:3081/openai/config/update \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "ENABLE_OPENAI_API": true,
    "OPENAI_API_BASE_URLS": [
      "http://host.docker.internal:9292/v1",
      "http://host.docker.internal:18789"
    ],
    "OPENAI_API_KEYS": [
      "sk-no-key-required",
      "<hermes-api-key>"
    ],
    "OPENAI_API_CONFIGS": {
      "0": {"name": "llama-swap", "model_ids": [], "api": "openai", "keys": []},
      "1": {"name": "Hermes Agent", "model_ids": ["hermes-agent"], "api": "openai", "keys": []}
    }
  }'
```

### URL Pitfall: The `/v1` Suffix

Open WebUI appends `/models` to the base URL when fetching model lists. So if your base URL is `http://host:18789` and the upstream expects `/v1/models`, the resulting call will be to `http://host:18789/models` (wrong) instead of `http://host:18789/v1/models` (correct).

**Fix:** Include `/v1` in the base URL: `http://host.docker.internal:18789/v1`

### Key/URL Alignment

`OPENAI_API_BASE_URLS` and `OPENAI_API_KEYS` are parallel arrays — index N in URLs pairs with index N in Keys. If lengths mismatch, Open WebUI pads or truncates keys to match URLs.

### Model Filtering via `model_ids`

Set `model_ids` to `[]` (empty) to auto-discover all models from the provider. Set to a specific list (e.g. `["hermes-agent"]`) to only show those models. This is useful for API gateways that expose many irrelevant models.

**⚠️ Myth: deleting the `model` table + restart auto-populates it.** Open WebUI does NOT re-populate the `model` table from the OpenAI endpoint on container restart alone. The `get_all_models()` call on startup refreshes only the in-memory model list. The `model` table remains at 0 rows until you either:
- Call `/openai/config/update` via API (which triggers re-population), OR
- Manually INSERT rows into the `model` table (see script below), OR
- Navigate to Admin Panel → Settings → Connections — the UI triggers a re-sync

Always manually re-insert models after a DELETE FROM model. See the registration script below.

**⚠️ The `model-` prefix causes "Model not found" in v0.9.x.** When inserting into the `model` table, using `f"model-{base_id}"` as the `id` column (with the `model-` prefix) is the tradition, but in Open WebUI v0.9.x the chat completion endpoint sometimes looks up by bare name (`gemma-26b-200k`), not the prefixed ID (`model-gemma-26b-200k`). This returns "Model not found" — the model exists in the table but the lookup key doesn't match.

**Fix:** Use bare model names (no `model-` prefix) as the model `id`:

```python
# Instead of:
mid = f"model-{base_id}"
conn.execute("INSERT INTO model (id, user_id, base_model_id, ...) VALUES (?, ?, ?, ...)",
    (mid, user_id, base_id, ...))

# Use the bare name directly:
conn.execute("INSERT INTO model (id, user_id, base_model_id, ...) VALUES (?, ?, ?, ...)",
    (base_id, user_id, base_id, ...))
```

After the fix, matches looking up by `base_model_id`:
curl -s http://127.0.0.1:9292/v1/models | python3 -c "import sys,json; [print(m['id']) for m in json.load(sys.stdin)['data']]"

### `model` Table Registration (Separate from `config` Table)

Open WebUI has **two** separate caches for model lists:

1. **`config` table** — the `api_configs` JSON blob (provider-level model ID list)
2. **`model` table** — per-model registry entries with metadata, `user_id`, `is_active` flag

The `model` table is **not** auto-populated. New models from llama-swap won't appear in the model dropdown until an INSERT is done. Symptoms: model is available at the API level (`curl :9292/v1/models` shows it) but invisible in the Open WebUI UI.

**Fix — register new models in the `model` table:**

```python
import sqlite3, time

conn = sqlite3.connect('/app/backend/data/webui.db')
USER_ID = '<admin-uuid>'  # from user table
NOW = int(time.time())

new_models = [
    ('some-model-name', 'Display Name'),
]

for base_id, display_name in new_models:
    cur = conn.execute("SELECT id FROM model WHERE base_model_id = ?", (base_id,))
    if cur.fetchone():
        print(f"Already exists: {base_id}")
        continue
    # Use bare base_id as model id — avoids "model not found" in v0.9.x
    # (no `model-` prefix). The prefix variant works in later versions but
    # breaks chat completion lookups in v0.9.x.
    mid = base_id
    conn.execute(
        "INSERT INTO model (id, user_id, base_model_id, name, meta, params, created_at, updated_at, is_active) "
        "VALUES (?, ?, ?, ?, '{}', '{}', ?, ?, 1)",
        (mid, USER_ID, base_id, base_id, NOW, NOW)
    )
    print(f"Added: {base_id}")

conn.commit()
conn.close()
```

Run inside the container: `docker cp script.py open-webui:/tmp/ && docker exec open-webui python3 /tmp/script.py`

Then restart: `docker restart open-webui`

**⚠️ Must also do the `config` table + `/openai/config/update` sync** (see next section). Both caches must be updated for the model to appear in the dropdown.

## Syncing Model IDs After Fleet Changes

When models are added to or removed from llama-swap, the Open WebUI model ID list gets stale — phantom model names appear in the dropdown that return 404, and new models are invisible.

**Symptom:** User sees `gemma-12b` or `qwen3-coder-next` in the model dropdown but these models don't exist in llama-swap anymore. Or `gpt-oss-20b` is available but not selectable.

**⚠️ CRITICAL: Two separate caches must be synced.** Open WebUI stores model IDs in TWO places:
1. **`config` table** (SQLite) — the `api_configs` JSON blob
2. **`/openai/config` API endpoint** — a separate in-memory cache that the UI reads from

Updating only the DB config is NOT enough. The `/openai/config/update` POST must also be called, then the container restarted.

**Fix — sync both caches:**

```bash
# 1. Get the actual models from llama-swap
# Use gateway IP if host.docker.internal doesn't resolve inside container:
# docker inspect <network> --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}'
ACTUAL=$(curl -s http://127.0.0.1:9292/v1/models | python3 -c "
import sys, json
data = json.load(sys.stdin)
models = [m['id'] for m in data.get('data', [])]
print(json.dumps(models))
")
echo "Actual models: $ACTUAL"

# 2. Update DB config
docker exec open-webui python3 -c "
import sqlite3, json
conn = sqlite3.connect('/app/backend/data/webui.db')
row = conn.execute('SELECT id, data FROM config LIMIT 1').fetchone()
config = json.loads(row[1])
actual_models = $ACTUAL
config['openai']['api_configs']['0']['model_ids'] = actual_models
conn.execute('UPDATE config SET data = ?, updated_at = datetime(\"now\") WHERE id = ?',
    (json.dumps(config), row[0]))
conn.commit()
conn.close()
print('DB config synced:', actual_models)
"

# 3. Update /openai/config API (in-memory cache)
TOKEN=$(curl -s -X POST http://127.0.0.1:3081/api/v1/auths/signin \
  -H 'Content-Type: application/json' \
  -d '{"email":"<email>","password":"<password>"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")

curl -s -X POST http://127.0.0.1:3081/openai/config/update \
  -H "Authorization: Bearer *** \
  -H "Content-Type: application/json" \
  -d "{
    \"ENABLE_OPENAI_API\": true,
    \"OPENAI_API_BASE_URLS\": [\"http://host.docker.internal:9292/v1\"],
    \"OPENAI_API_KEYS\": [\"sk-no-...ired\"],
    \"OPENAI_API_CONFIGS\": {
      \"0\": {
        \"name\": \"llama-swap\",
        \"model_ids\": $ACTUAL,
        \"api\": \"openai\",
        \"keys\": []
      }
    }
  }"

# 4. Restart container to clear stale model list cache
docker restart open-webui
```

**When to run this:** After every model addition/removal from llama-swap. The env vars (`OPENAI_API_BASE_URL` etc.) just define the API endpoint — they do NOT trigger model discovery in the DB.

**Also clean orphaned `model` table entries** — the separate `model` table can accumulate stale rows:
```bash
docker exec open-webui python3 -c "
import sqlite3
conn = sqlite3.connect('/app/backend/data/webui.db')
conn.execute(\"DELETE FROM model WHERE base_model_id NOT IN ('qwen36-35b-mtp','gemma-26b-200k','nemotron-term-14b','glm-4.7-flash','gpt-oss-20b')\")
conn.commit()
print('Cleaned orphaned model entries')
conn.close()
"
```

---

## Known Issue: Streaming Responses Not Rendered (Socket.IO CORS)

In some Open WebUI builds (including `02dc3e6`, mid-2026), streaming SSE responses arrive successfully (HTTP 200, valid chunks, no JS errors) but the Svelte UI never renders the content. Symptoms: assistant bubble shows with model name label but empty content, chat not saved to DB, hard refresh (`Ctrl+Shift+R`) loads the response from DB.

**Root cause (most common):** `CORS_ALLOW_ORIGIN` is set to `http://localhost:3081` but the user accesses the WebUI from a LAN IP (`192.168.1.x`) or Tailscale IP (`100.x.x.x`). Socket.IO's WebSocket handshake rejects connections from origins not in the allowlist, so the `events` (chat content updates) never reach the frontend. The HTTP streaming response arrives fine but the Svelte store never updates.

**Fix:** Set `CORS_ALLOW_ORIGIN=*` in the container env vars. This is safe on a trusted LAN/Tailscale network.

**Verification:** Check the container logs for Socket.IO origin rejection:
```bash
docker logs open-webui 2>&1 | grep -i 'not an accepted origin'
# → http://192.168.1.50:3081 is not an accepted origin.
```

**Quick test:** Send a message, then check the DB:
```bash
docker exec open-webui python3 -c "import sqlite3,json; print(json.loads((sqlite3.connect('/app/backend/data/webui.db').execute('SELECT chat FROM chat ORDER BY id DESC LIMIT 1').fetchone()[0])).get('messages',[]))"
```
If only the user message is saved, the streaming response was received but never persisted via Socket.IO events.

**Debugging checklist (when streaming doesn't render):**
1. `docker logs open-webui 2>&1 | grep -i 'accepted origin'` — Socket.IO CORS rejection?
2. `docker exec open-webui env | grep CORS_ALLOW_ORIGIN` — is it `*` or the access IP?
3. Enable Socket.IO/Engine.IO logging: `-e WEBSOCKET_SERVER_LOGGING=true`
4. Non-streaming requests (`"stream": false`) always work as a fallback. See `references/db-config-management.md` → "Known Issues" section for more debugging approaches.

## Recreating the Container (Data Migration)

### Switching from Named Volume to Bind Mount

```bash
mkdir -p /home/user/openwebui-data
# Copy data while container is running (the named volume path is root-only):
docker cp open-webui:/app/backend/data/. /home/user/openwebui-data/
# Update docker-compose.yml volume line, then:
docker stop open-webui && docker rm open-webui
docker compose up -d
```

### DB Migration Between Open WebUI Versions

When upgrading across versions (especially v0.9.x → newer), the SQLite schema may change how datetime fields are stored. The old version stored `updated_at` as integer timestamps while the new version expects ISO format strings (or vice versa). Symptoms: container crash loop with `TypeError: fromisoformat: argument must be str` or `ValidationError: Input should be a valid integer`.

**Fix — convert datetime fields before the first startup with the new image:**

```bash
# Check and fix all datetime-typed columns
python3 -c "
import sqlite3, datetime

conn = sqlite3.connect('/path/to/webui.db')
c = conn.cursor()
tables = c.execute(\"SELECT name FROM sqlite_master WHERE type='table'\").fetchall()
fixed = 0

for t in tables:
    name = t[0]
    info = c.execute(f'PRAGMA table_info(\"{name}\")').fetchall()
    for col in info:
        col_name = col[1]
        if 'time' in col_name.lower() or 'date' in col_name.lower():
            rows = c.execute(f'SELECT rowid, \"{col_name}\" FROM \"{name}\"').fetchall()
            for row in rows:
                if isinstance(row[1], int) and row[1] > 1000000000:
                    # Integer timestamp -> ISO string (for most columns)
                    iso = datetime.datetime.fromtimestamp(row[1]).isoformat()
                    c.execute(f'UPDATE \"{name}\" SET \"{col_name}\" = ? WHERE rowid = ?', (iso, row[0]))
                    fixed += 1
                elif isinstance(row[1], str) and '20' in row[1]:
                    # Try ISO string -> integer (for UserModel updated_at etc.)
                    try:
                        dt = datetime.datetime.fromisoformat(row[1].replace('Z', '+00:00'))
                        c.execute(f'UPDATE \"{name}\" SET \"{col_name}\" = ? WHERE rowid = ?', (int(dt.timestamp()), row[0]))
                        fixed += 1
                    except:
                        pass

conn.commit()
print(f'Fixed {fixed} datetime fields')
conn.close()
"
```

**Note:** Which direction (string→int or int→string) depends on the version. Check the error message: `fromisoformat: argument must be str` means convert ints to strings; `Input should be a valid integer` means convert strings to ints. If in doubt, the bidirectional conversion above handles both.

---

## 🛠 Maintenance Tasks

### Resetting Admin Password

If password is lost, use the inline approach (no docker cp needed):

```bash
docker exec open-webui python3 -c "
import sqlite3, bcrypt
conn = sqlite3.connect('/app/backend/data/webui.db')
pw = bcrypt.hashpw(b'newpassword', bcrypt.gensalt()).decode()
conn.execute('UPDATE auth SET password = ? WHERE email = ?', (pw, 'admin@localhost'))
conn.commit()
print('Password reset')
conn.close()
"
```

Alternative (copy DB out, edit, copy back):
```bash
HASH=$(docker exec open-webui python3 -c "
import bcrypt
print(bcrypt.hashpw(b'newpassword', bcrypt.gensalt()).decode())
")

docker cp open-webui:/app/backend/data/webui.db /tmp/webui.db
python3 -c "
import sqlite3
conn = sqlite3.connect('/tmp/webui.db')
conn.execute('UPDATE auth SET password = ? WHERE email = ?', ('$HASH', 'admin@localhost'))
conn.commit()
"
docker cp /tmp/webui.db open-webui:/app/backend/data/webui.db
docker restart open-webui
```
## Debugging Failed Model Responses

When a model returns empty content or fails to complete in Open WebUI, use this diagnostic flow:

### 1. Check llama-swap Server Logs
```bash
journalctl -u llama-swap --no-pager -n 100 2>/dev/null | grep -E "disconnect|no valid JSON|connection refused|502|error"
```

Look for:
- `recovered from upstream disconnection during streaming` → llama-server child process crashed mid-stream
- `no valid JSON data found in stream` → empty/corrupt response from upstream
- `connection refused` → server process died (port dead)
- `5m0.xxx` duration → AIOHTTP_CLIENT_TIMEOUT hit

### 2. Check Open WebUI Chat Message DB

```bash
docker exec open-webui python3 -c "
import sqlite3, json
conn = sqlite3.connect('/app/backend/data/webui.db')
# Find failed messages for a specific model
cur = conn.execute('''
    SELECT id, chat_id, role, content, output, model_id, error, done 
    FROM chat_message 
    WHERE model_id LIKE '%<model-name>%'
    ORDER BY id DESC LIMIT 10
''')
for r in cur.fetchall():
    role = r[2]
    content = (r[3] or '')[:150]
    error = r[6]
    done = r[7]
    print(f'role={role} done={done} error={bool(error)}')
    if error: print(f'  ERROR: {error[:200]}')
    if content: print(f'  content: {content}')
    print()
conn.close()
"
```

**Key indicators:**
- `error={"content": ""}` → response was empty (server crash or timeout)
- `done=0` → stream never completed (cut off mid-response)
- `error=null` but empty content → need to check server logs for streaming failure

### 3. Reproduce with Direct API

```bash
# Non-streaming (reliable)
curl -s http://localhost:9292/v1/chat/completions ... | python3 -c "..."

# Streaming (to reproduce the issue)
curl -s http://localhost:9292/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{"model":"<model>","messages":[{"role":"user","content":"test"}],"max_tokens":256,"stream":true}' \
  > /tmp/stream-result.txt
grep -c "data:" /tmp/stream-result.txt
grep -c "reasoning_content" /tmp/stream-result.txt
grep -c '"content"' /tmp/stream-result.txt
```

If non-streaming works but streaming fails → likely an SSE streaming issue or server crash under load.

### 4. Check Request Duration

In llama-swap logs, look for the request duration at the end of each request line:
```
POST /v1/chat/completions HTTP/1.1 200 971 "..." 1.43s    ← normal
POST /v1/chat/completions HTTP/1.1 200 5141180 "..." 5m0.459s  ← timed out
```

If duration is exactly ~300s (5m), the `AIOHTTP_CLIENT_TIMEOUT=300` was hit. The model took too long to respond.

### Known: Upstream Disconnection During Streaming

When Open WebUI sees empty content but the model responds fine via direct API:

1. The llama-server process is **crashing mid-generation** (OOM, segfault, or CUDA error)
2. llama-swap catches the broken pipe and reports `recovered from upstream disconnection during streaming`
3. Open WebUI receives the partial proxy error and saves it as `error: {"content": ""}`
4. The server restarts automatically via llama-swap's health check

**Most common cause on 11 GB RTX 2080 Ti: KV cache OOM** when context length (`-c`) is too large for the model's VRAM budget. See `local-model-fleet-management` skill → "Dense Reasoning Models: KV Cache OOM Masquerading as Tag Issue".

### References

| File | Content |
|------|--------|
| [`references/hermes-bridge-tool.md`](./references/hermes-bridge-tool.md) | Hermes Agent bridge tool code |
| [`references/docker-gateway-ufw.md`](./references/docker-gateway-ufw.md) | Gateway IP changes on recreate, UFW subnet rules, TERMINAL_SERVER_CONNECTIONS caching fix |
| [`references/db-config-management.md`](./references/db-config-management.md) | Direct SQLite config manipulation: add/remove providers, set model lists, fix host.docker.internal |
| [`references/agentic-web-search-patches.md`](./references/agentic-web-search-patches.md) | Python patches for tool-based web search (model decides when to search) |
| [`references/model-not-found-troubleshooting.md`](./references/model-not-found-troubleshooting.md) | Diagnostic flow for "Model not found" errors on chat completion — prefix mismatch, stale caches, Docker networking |
| [`references/reasoning-model-empty-responses.md`](./references/reasoning-model-empty-responses.md) | Dense reasoning models returning empty content in Open WebUI — `--reasoning off` crash, endless reasoning_content events |

1. Start the container with feature flags set in compose env vars
2. Create admin user (first user auto-becomes admin):
```bash
curl -s -X POST http://localhost:3081/api/v1/auths/signup \
  -H "Content-Type: application/json" \
  -d '{"name":"Admin","email":"admin@localhost","password":"Admin123!@#","profile_image_url":"/user.png"}'
```
3. Disable `ENABLE_SIGNUP` after admin created
4. Access WebUI at `http://<host>:3081` (direct) or `http://<host>` (via nginx)

**Note:** The auth endpoint is `/api/v1/auths/` (with 's'), not `/api/v1/auth/`. The form body is JSON (`application/json`), not form-urlencoded.
