--- name: open-webui-admin description: Programmatic administration of Open WebUI via REST API and direct SQLite DB access — audio config, LLM connections, groups, users, model access grants, and presets. version: 1.0.0 author: Hermes Agent license: MIT platforms: [linux] metadata: hermes: tags: [open-webui, admin, rest-api, sqlite, automation] related_skills: [hermes-api-server, ask-hermes] --- # open-webui-admin — Programmatic Open WebUI Administration ## Overview Administer Open WebUI entirely from the terminal via its REST API and direct SQLite DB access. No browser clicks needed. Covers audio config, LLM connections, group/user management, model access grants, and preset creation. Used when setting up a new Open WebUI instance or adding profiles/users/groups to an existing one. ## When to Use - Setting up Open WebUI for the first time (audio, connections, groups, users, presets) - Adding a new Hermes profile as an LLM connection - Creating groups and assigning users to them - Setting model visibility (access grants) for group-based bank isolation - Debugging why a model isn't visible to a user - Any Open WebUI admin task the operator wants done without clicking through the UI ## Prerequisites - Open WebUI reachable at its HTTP port (default 8080) - Admin credentials (email + password) - SSH access to the Open WebUI host for direct DB fallback (optional but recommended) - `curl`, `python3`, `jq` on the agent host ## Core Workflow ### 1. Get Admin Token ```bash TOKEN=$(curl -s -X POST -H "Content-Type: application/json" \ -d '{"email":"","password":""}' \ http://:8080/api/v1/auths/signin | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])') ``` All subsequent calls use `-H "Authorization: Bearer $TOKEN"`. ### 2. Audio Configuration **Endpoint:** `POST /api/v1/audio/config/update` **Critical pitfall:** The endpoint requires ALL provider fields, even unused ones. Missing fields cause a 422 validation error. Set unused providers to empty strings. **Field names are UPPERCASE** (as of v0.10.2). The API rejects lowercase aliases like `engine`/`model`/`url` with 422 "Field required" on every uppercase field. Use the exact field names returned by `GET /api/v1/audio/config`. Always GET first and mirror that schema. ```bash curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "tts": { "OPENAI_API_BASE_URL": "http://10.0.0.16:8880/v1", "OPENAI_API_KEY": "not-needed", "OPENAI_PARAMS": null, "API_KEY": "not-needed", "ENGINE": "openai", "MODEL": "kokoro", "VOICE": "af_heart", "SPLIT_ON": "punctuation", "AZURE_SPEECH_REGION": "", "AZURE_SPEECH_BASE_URL": "", "AZURE_SPEECH_OUTPUT_FORMAT": "audio-24khz-160kbitrate-mono-mp3", "MISTRAL_API_KEY": "", "MISTRAL_API_BASE_URL": "https://api.mistral.ai/v1" }, "stt": { "OPENAI_API_BASE_URL": "http://10.0.0.42:9000/v1", "OPENAI_API_KEY": "not-needed", "OPENAI_API_REQUEST_FORMAT": "multipart", "ENGINE": "openai", "MODEL": "base", "SUPPORTED_CONTENT_TYPES": [], "ALLOWED_EXTENSIONS": ["mp3","wav","m4a","webm","ogg","flac","mp4","mpga","mpeg"], "WHISPER_MODEL": "base", "DEEPGRAM_API_KEY": "", "AZURE_API_KEY": "", "AZURE_REGION": "", "AZURE_LOCALES": "", "AZURE_BASE_URL": "", "AZURE_MAX_SPEAKERS": "", "MISTRAL_API_KEY": "", "MISTRAL_API_BASE_URL": "https://api.mistral.ai/v1", "MISTRAL_USE_CHAT_COMPLETIONS": false } }' \ http://:8080/api/v1/audio/config/update ``` **STT engine note:** Use `"openai"` (not `"web"`) to route to a local faster-whisper backend. Engine `"web"` uses the browser Web Speech API with no server-side transcription. **Verify:** `GET /api/v1/audio/config` — check that `stt.ENGINE` is `"openai"` and `stt.OPENAI_API_BASE_URL` points at the local backend. For full E2E: `POST /api/v1/audio/transcriptions` with an audio file. ### 3. LLM Connections (OpenAI API) **Endpoint:** `POST /openai/config/update` (NOT `/api/v1/configs/connections` — that endpoint only has `ENABLE_DIRECT_CONNECTIONS` and `ENABLE_BASE_MODELS_CACHE` toggles). ```bash curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "urls": ["http://:8653/v1"], "keys": [""], "prefix_ids": [true] }' \ http://:8080/openai/config/update ``` **Multiple connections:** Add both open1 and openz in one call by extending the arrays: ```json { "urls": ["http://:8653/v1", "http://:8654/v1"], "keys": ["", ""], "prefix_ids": [true, true] } ``` **Verify:** `GET /openai/config` — check `urls` and `keys` arrays. Then `POST /openai/verify` to confirm reachability. ### 4. Sync Models from Connections After adding LLM connections, sync to auto-create model rows: ```bash curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{}' http://:8080/api/v1/models/sync ``` This creates model rows for every model returned by `/v1/models` on each connection. The model `id` will match the model `id` from the gateway (e.g., `Hermes Agent (open1)`). ### 5. Groups **Create:** ```bash curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"name": "Operators", "description": "Operator group for Rob"}' \ http://:8080/api/v1/groups/create ``` Returns the group object with `id`. Capture it. **List:** `GET /api/v1/groups/` **Add user to group:** ```bash curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{"group_id": "", "user_id": ""}' \ http://:8080/api/v1/groups/id//update ``` **Verify:** `GET /api/v1/groups/id//info` — check `member_count`. ### 6. Model Access Grants (CRITICAL — read this section twice) **Endpoint:** `POST /api/v1/models/model/access/update` **THE #1 PITFALL:** The endpoint silently drops grants with wrong field names. It returns HTTP 200 either way. A "success" response with wrong field names means the grants were discarded — the model is effectively public. **Correct field names (verified by direct DB read + API read-back):** - `principal_type` — NOT `type` - `principal_id` — NOT `id` or `group_id` - `permission` — NOT `access` or `read_write` **Correct payload:** ```json { "id": "", "access_grants": [ { "principal_type": "group", "principal_id": "", "permission": "read" } ] } ``` **Valid values:** - `principal_type`: `"group"` or `"user"` - `permission`: `"read"` or `"write"` (there is no `"read_write"` — add a second grant object for write) **Source:** `open_webui/models/access_grants.py` `normalize_access_grants()` function — silently discards any grant dict where `principal_type` is not exactly `"user"` or `"group"`, or `permission` is not exactly `"read"` or `"write"`. **Verification (MANDATORY — do not trust the 200):** 1. API read-back: `GET /api/v1/models/model?id=` → `access_grants` must be a non-empty list 2. Direct DB (if available): `SELECT * FROM access_grant WHERE resource_id = '';` → must return rows ### 7. Presets **Create:** ```bash curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d '{ "id": "hermes-operator", "name": "Hermes-Operator", "base_model_id": "Hermes Agent (open1)", "meta": { "profile_image_url": "/user.png", "description": "Operator persona (Rob) — wraps the open1 Hermes profile.", "capabilities": {"vision": false, "file_upload": true, "web_search": true, "tts": true, "stt": true}, "system": "", "tts": {"voice": "af_heart"} }, "params": {"temperature": 0.7} }' \ http://:8080/api/v1/models/create ``` **Update (full replace):** ```bash # GET the current model, modify fields, POST back curl -s -H "Authorization: Bearer $TOKEN" "http://:8080/api/v1/models/model?id=" -o /tmp/preset.json # Edit /tmp/preset.json (e.g., change meta.tts.voice), then: curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ -d @/tmp/preset.json \ "http://:8080/api/v1/models/model/update?id=" ``` **After creating a preset, immediately set its access grants** (same endpoint as step 6, with `id` = preset id). **CRITICAL — TTS backend switch pitfall:** When changing the global TTS engine (e.g., Kokoro → Orpheus), the global audio config update does NOT cascade to preset-level `meta.tts.voice` overrides. Each preset with a `meta.tts.voice` field must be individually checked and updated to a voice that exists in the new backend. A preset with a stale voice override (e.g., `af_heart` pointing at Orpheus) will silently fail TTS — the API accepts the config but the voice name doesn't exist in the new engine. After any TTS backend switch, always: 1. `GET /api/v1/models/model?id=` and check `meta.tts.voice` 2. Update any preset voices to valid names for the new backend 3. Verify with a read-back after update ### 8. Direct DB Access (Fallback) When the REST API silently fails or you need to verify state, access the SQLite DB directly: ```bash # DB location (Open WebUI 0.10.2, systemd root service): /root/.open-webui/webui.db ``` **Key tables:** - `user` — id, name, email, role, active - `group` — id, name, description, user_id (creator) - `group_member` — id, group_id, user_id - `model` — id, name, user_id, base_model_id, meta (JSON), params (JSON) - `access_grant` — id, resource_type, resource_id, principal_type, principal_id, permission, created_at **Remote DB access via SSH + sudo:** ```bash # Write a one-line askpass script on the remote host: echo '#!/bin/sh' > /tmp/askpass.sh echo 'echo "passw0rd"' >> /tmp/askpass.sh chmod 700 /tmp/askpass.sh # Then use SUDO_ASKPASS for passwordless sudo in scripts: SUDO_ASKPASS=/tmp/askpass.sh sudo -A python3 -c '...' ``` **Orphan cleanup after failed runs:** Deleting a group does NOT cascade-delete `group_member` rows. If a script errors and re-runs, check for orphan rows: ```sql SELECT gm.* FROM group_member gm LEFT JOIN "group" g ON gm.group_id = g.id WHERE g.id IS NULL; ``` ## Bank Isolation Pattern When multiple Hermes profiles serve different users through one Open WebUI instance: 1. Each profile gets its own LLM connection (different port, different API key) 2. Each user group gets its own model access grants — only their profile's base model + preset 3. **Never cross-grant:** Operators group must NOT have access to the Kids' base model, and vice versa 4. Verify isolation: `GET /api/v1/models/model?id=` should return 404 or empty access_grants for the wrong group ## Pitfalls 1. **Audio config requires ALL fields** — missing provider fields → 422. Set unused ones to `""`. 2. **LLM connections use `/openai/config/update`** — NOT `/api/v1/configs/connections`. The latter only has feature toggles. 3. **access_grants silently drop wrong field names** — HTTP 200, grants discarded. Always verify via DB or API read-back. 4. **Group deletion doesn't cascade** — `group_member` rows survive. Clean up manually if scripts re-run. 5. **Model sync creates rows for ALL connections** — including ones you haven't set up yet. That's fine; just don't grant access to them until ready. 6. **User creation endpoint is `POST /api/v1/auths/add`, NOT `/api/v1/users/add`.** The wrong endpoint returns HTTP 405 in Open WebUI 0.10.2. Verified live 2026-07-01 and peer-validated. Required body fields: `name`, `email`, `password`. Optional: `role` (default `"pending"`), `profile_image_url` (default `"/user.png"`). The response includes a `token` field — the new user's JWT, useful for immediate verification of their model-list view without a separate signin. 7. **`/api/v1/users/all` returns a wrapped object, not a bare array.** The response shape is `{"users": [...]}`, not `[...]`. Parse with `d.get("users", [])` before iterating. 8. **`access_grants` in the create body can be silently dropped** — same silent-drop behavior as the access/update endpoint. If you pass `access_grants` in the `POST /api/v1/models/create` body and the field names are wrong, the API returns HTTP 200 but the grants are discarded. Always verify via read-back or DB after creating a model with inline grants. Safer pattern: create without grants, then do a separate `POST /api/v1/models/model/access/update` call, then verify. 9. **Never assume url_idx for multi-connection setups.** When adding a third (or Nth) LLM connection, do not hardcode `GET /openai/models/2`. The URL list order depends on insertion order, which may differ from the expected sequence. Always discover the index first: `GET /openai/config` → enumerate `OPENAI_API_BASE_URLS` → use the discovered index. Peer-validated 2026-07-01 during openg runbook review. 10. **TTS backend switch does NOT cascade to preset voice overrides.** When changing the global TTS engine, each preset's `meta.tts.voice` must be individually checked and updated to a voice that exists in the new backend. A stale voice override silently breaks TTS for that preset. See `references/orpheus-tts-integration.md` for the Orpheus-specific voice names and verification commands. ## Verification Checklist - [ ] Audio config: `GET /api/v1/audio/config` → TTS engine + STT engine match - [ ] LLM connections: `GET /openai/config` → urls/keys arrays correct, `POST /openai/verify` → reachable - [ ] Groups: `GET /api/v1/groups/` → group exists, `GET /api/v1/groups/id//info` → member_count correct - [ ] Model access: `GET /api/v1/models/model?id=` → `access_grants` non-empty AND `SELECT * FROM access_grant WHERE resource_id = ''` returns rows - [ ] Preset: `GET /api/v1/models/model?id=` → `base_model_id` correct, `meta.tts.voice` correct - [ ] E2E: chat completion through the preset returns correct model id and expected behavior