Self-hosted, agent-first knowledge stack combining a document knowledge base (KB) and a temporal fact memory in one system. Open WebUI provides the document KB: ingest curated documents — synced from Google Drive — into access-controlled collections; query by RAG (LLM-grounded answer) or precise text / raw chunk retrieval (no LLM). Graphiti provides the fact memory over Neo4j: facts are time-bound and are invalidated, not deleted, when superseded. Temporal model + motivation: docs/memory.md.
Documents provide grounded answers from a curated reference corpus. Fact memory replaces scattered, untrimmed README, notes, and tracker files across projects with one searchable temporal graph (docs/memory.md). Agents use the stack through the stack-side API Gateway (per-account credentials; identity and role derived server-side), plus a thin zero-dependency CLI and the /kb skill. Browser clients use the Open WebUI web UI.
The document KB also indexes Claude Code's project memory — the per-project auto-memory Claude Code writes under ~/.claude/projects/*/memory/ — into one Open WebUI KB per project. The /kb skill drives this host-side with the caller's own user key (index-projects / retrieve-projects / status-projects); the caller creates and owns each project KB, and the API Gateway is not involved on this surface. See Projects memory indexing in docs/operations.md, and the /kb skill (docs/agents.md).
Components:
- Open WebUI — document knowledge base with vector search, grounded RAG chat, and user/group access control; also the identity provider for the API Gateway.
- Graphiti — temporal fact memory over Neo4j; reached via an internal REST server.
- Neo4j — graph store for Graphiti (internal only).
- API Gateway (
api-gateway) — a custom component in this repo: stack-side authorization, per-account identity and role validation, Graphiti REST bridge, live group discovery, and admin user provisioning. - Caddy — the single public edge (
KB_HOST): fronts Open WebUI at the root (catch-all) and proxies/memory/*,POST /admin/users,POST /index,GET /status,GET /openapi.json,/healthto the API Gateway (method-scoped routes fall through to Open WebUI for other methods, so browser deep-links keep working). - Ollama — external model host (not in the compose stack): serves the
qwen2.5:14bchat LLM,nomic-embed-textembeddings, and thedeepseek-ocrOCR model; runs on the Docker host or a remote/LAN host, reached viaOLLAMA_HOST(Ollama's native client env var).
README:
Sub-documents:
- docs/operations.md — prerequisites, configuration (env vars), Ollama host service, persistent data / RAID, make targets, troubleshooting, full hardening reference.
- docs/ocr.md — markitdown-OCR external extraction engine: per-figure
deepseek-ocrvia native Ollama/api/chat, per-page/per-slide/per-sheet metadata,OCR_ENABLEDconfig flag + scope + limits. - docs/testing.md — integration test suite + matrix.
- docs/agents.md — agent integration per tool (Claude Code, Codex, OpenCode, Pi): install the
kbskill, setKB_HOST+KB_API_KEY, example flows. - docs/memory.md — fact memory (Graphiti): temporal model (time-stamped episodes, time-bound facts, invalidation) + motivation.
┌───────────┐ ┌────────────────────────┐
│ User │ │ Agent (any host) │
│ (browser) │ │ kb.py │
│ │ │ KB_HOST + KB_API_KEY │
└──────┬────┘ └────────────┬───────────┘
└─────────┬───────────────┘
▼ KB_HOST http://<host>:3000
┌────────────────┼─────────────────────────────────────────────────────────────┐
│ │ Docker host │
│ ┌─────────────┴──────────────────────────────────────────────────────────┐ │
│ │Caddy :3000 KB_HOST — single public edge (only published port) │ │
│ │/memory/* /admin/users(POST) /health → api-gateway :8010 │ │
│ │/* catch-all → openwebui :8080 │ │
│ └─────────────┬─────────────────────────────────────┬────────────────────┘ │
│ ▼ ▼ │
│ ┌─────────────┴────────────┐ ┌────────────────────┴────────────────────┐ │
│ │openwebui :8080 │ │api-gateway :8010 (zero-dep stdlib) │ │
│ │owui_net │ │identity+role from KB_API_KEY via OWUI │ │
│ │identity + users │ │owui_net ► openwebui :8080 │ │
│ │+ RAG │ │graph_internal ► graphiti :8000 │ │
│ │ │ │graph_internal ► Neo4j :7474/7687 │ │
│ │ │ └─────────┬──────────────────────┬────────┘ │
│ │ │ ▼ ▼ │
│ │ │ ┌─────────┴─────────┐ ┌────────┴────────┐ │
│ │ │ │graphiti :8000 │ │Neo4j :7474/7687 │ │
│ │ │ │(REST) graph_int │ │group discovery │ │
│ │ │ │bootstrap.py: │ │delete guards │ │
│ │ │ │ Ollama client │ │(graph_int) │ │
│ │ │ │ + nomic embed │ │ │ │
│ └─────────────┬────────────┘ └─────────┬─────────┘ └─────────────────┘ │
│ ▼ ▼ │
│ ┌─────────────┴──────────────────────────┴───────────────────────────────┐ │
│ │Ollama :11434 (host, via host-gateway) │ │
│ │qwen2.5:14b-ctx8192 (chat + extraction, non-reasoning) nomic-embed-text│ │
│ └────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────┘
- A user with a browser and an agent both reach the stack through one URL,
KB_HOST(mandatory, shell-provided —export KB_HOST=http://<host>:3000): Caddy fronts Open WebUI at the root and the API Gateway under/memory/*,POST /admin/users, and/health. One port, one var for agents (mirrorsOLLAMA_HOST). - An agent holds only
KB_API_KEY+KB_HOST(no Graphiti token, no repo files) — it works on any host. Its CLI (kb.py) is a thin client that reads ONLY those two env vars (no.envfiles) and hitsKB_HOST: OWUI REST at/api/*(KBs, retrieve, files, projects memory), API Gateway at/memory/*(facts memory;/memory/ragis direct/operator-only). - API Gateway is the sole bridge to the graph. It resolves the caller's identity + role from
KB_API_KEYvia Open WebUI (tamper-proof), enforces ownership-bounded writes + owner/admin destructive gating, discovers all existing groups live from Neo4j, calls the internal Graphiti REST server, and provisions new KB users for admins. - Graphiti client injection. The
ghcr.io/dkhokhlov/graphiti-restbase image defaults toOpenAIClient(OpenAI Responses API), which Ollama cannot satisfy — entity/fact extraction silently stores nothing.docker/graphiti/bootstrap.pyis baked into thekb-graphitioverlay image (FROM the base + COPY) and run as the command; it overrides the FastAPI dependency to inject the stockOpenAIGenericClient(graphiti_core >= 0.29 defaults tojson_schemastructured outputs, which Ollama enforces server-side; set attemperature=0) +OpenAIEmbedder(nomic-embed-text, 768-dim), so extraction works with Ollama. There is no config switch for this; the injection is required. Seedocker/graphiti/bootstrap.py. - Network split:
graph_internal(neo4j+graphiti+api-gateway),edge(caddy+api-gateway),owui_net(caddy+api-gateway+openwebui). Thegraphitiandneo4jservices are internal-only — no host ports, reachable only through the API Gateway. Open WebUI is internal-only too (fronted by Caddy). KB_HOST_PORT(the Caddy:3000host bind) is derived fromKB_HOSTbymake bootstrap(compose cannot parse a URL, so it reads the port from.env); override it only for the tunnel case (client URL port ≠ bind port). Caddy'sdepends_onusesservice_started(notservice_healthy) so the OWUI root stays reachable even if the gateway is broken.- Ollama is external on the Docker host (reached via host-gateway); not published by this stack. The
openwebui(RAG + chat) andgraphiti(extraction LLM + embedder) services both reach it. - TLS: the Caddyfile is plain HTTP on the port. For a non-local
KB_HOSTyou MUST either front Caddy with an upstream TLS reverse proxy, or switch the:3000site block to a hostname block so Caddy auto-terminates TLS and publishes:443. For a local deployment (http://localhost:3000) no TLS is needed.
Host prerequisites: make, uv (provisions the Python 3.12 .venv for the test suite — every test target runs make ci, which is uv sync from pyproject.toml + uv.lock; install at https://docs.astral.sh/uv/), docker compose (the stack), and rclone (only for make kb-sync, the gdrive sync stage).
Full prerequisites, configuration, and env vars are in docs/operations.md. Core sequence:
Minimum env vars to set (in your shell env — both are commented out in .env.template so the shell value is not clobbered; everything else has a working default or is auto-generated):
OLLAMA_HOST: the only hard blocker.make startfails fast ifOLLAMA_HOSTis unset — compose uses${OLLAMA_HOST:?…}(nohost.docker.internalfallback;make preflightchecks it too).export OLLAMA_HOST=http://<ollama-host>:11434(http://host.docker.internal:11434if Ollama runs on the Docker host). Do NOT uselocalhost/127.0.0.1— the value is used inside the containers, where localhost is the container's own loopback (no Ollama there).KB_HOST: the single public URL agents/clients point at — mandatory (nolocalhostfallback).export KB_HOST=http://<host>:3000;make bootstrappersists it into.envand derivesKB_HOST_PORT(the Caddy host bind) from its port. Usehttps://<host>/ VPN for a remote agent (KB_API_KEYis a bearer — plain HTTP only on a trusted local interface).
One-shot provision — from a fresh checkout, run:
export OLLAMA_HOST=http://<ollama-host>:11434
export KB_HOST=http://<host>:3000
make provision
make provision chains the full first-time setup and leaves the stack running: bootstrap (creates .env/.env.local + the JWT key, OCR_SERVICE_TOKEN, first-user password, ./data dirs; bakes COMPOSE_PROFILES=ocr into .env from OCR_ENABLED) → pull-models (BLOCKING: pulls the base LLM + ctx variant + embedder + deepseek-ocr from Ollama) → start (preflight + docker compose up -d; the ocr sidecar is included via COMPOSE_PROFILES=ocr in .env) → admin-signup (creates admin@<KB_DOMAIN>) → api-keys (admin + read-scoped agent keys; auto-configures OWUI → markitdown-ocr when OCR_ENABLED=true) → config-rag (strict-grounding RAG template + rag.ollama.base_url sync) → kb-bootstrap KB=gdrive (creates the gdrive KB + grants public read; name-based, no GDRIVE_KB_ID). The JWT key, API keys, and Neo4j auth are generated locally; OCR_ENABLED defaults to true (to skip OCR: make clean-all && make provision OCR_ENABLED=false). Test-only credentials for make test are described in docs/testing.md.
PROMPT for Agent
Paste into an agent (Claude Code, etc.); the agent runs the
maketargets (provision creates the 1st useradmin@<domain>; this adds the 2nd). Replace the domain, name, and email:Do make clean-all then provision from scratch with domain: <your.domain>; 2nd user (me) - <your-name>, <you>@<your.domain>; update my KB_API_KEY in ~/.bashrc.
Populate the Google Drive KB (one-time) — after make provision:
make kb-sync # rclone pull the shared drives into ./root/gdrive/ (SYNC-ONLY; does NOT index)
make kb-index KB=gdrive # POST /index?dir=gdrive — reconcile the synced tree into the KB (INDEX-ONLY)
make kb-status KB=gdrive # pretty JSON: completed/pending/processing/failed vs source (no ETA — no daemon)
See Google Drive indexing (manual, via API Gateway). make kb-sync runs rclone; make kb-index KB=gdrive reconciles into the KB; indexing is manual/on-demand (no sidecar).
Add another KB (any local folder tree): drop/symlink/bind-mount it at ./root/<name>/, then make kb-index KB=<name> (creates the KB if missing, then indexes). Each top-level subdir of ./root/ is one KB, named after the subdir. A source=root KB whose ./root/<name>/ dir is later removed shows as class 11 stale_root_kb in make kb-check; PRUNE_KB=1 make kb-check deletes it (timestamped backup first).
Everyday restart (provision is done once): make start. Re-assert after a DB reset: make config-rag (+ make config-ocr). Rotate keys: make api-keys FORCE=1.
(Optional) Close signup after the admin + agent accounts exist: set ENABLE_SIGNUP=false in .env and make restart.
The stack is agent-facing. A role is admin or user (the Open WebUI role field). An account is an Open WebUI account that holds a role; KB_API_KEY is per-account (an Open WebUI API key).
- Identity is tamper-proof. The gateway resolves
(id, email, role)fromKB_API_KEYvia Open WebUIGET /api/v1/auths/. The caller cannot set or influence it — there is noKB_USER_IDenv var, no spoofable header. - Authorization = role + personal-group ownership, both enforced on the stack (not bypassable by a modified CLI):
- Writes go to your own personal group.
addwith no--groupwrites touser:<email>.add --group Gis allowed only ifGis your own personal group; any other group →403. There are no shared write groups — reads are how knowledge is shared across accounts. Graphiti stores the personal group as a charset-safe id (user-<sanitized-email>, e.g.user-agent-local-test);forgetaccepts theuser:<email>form too. - Reads span all groups that have data, discovered live from Neo4j (no roster file).
retrieve,episodes,status,groupsare read-only for everyone. - Destructive ops (
forget,delete-edge,delete-episode) require owning the target group or admin. Admin (role=admin) overrides ownership and is the only role that can create users.
- Writes go to your own personal group.
All endpoints are on one URL, KB_HOST (http://<host>:3000 by default). Caddy routes by path.
| Endpoint | Auth | Use |
|---|---|---|
KB_HOST/ |
session (web UI) | Open WebUI: document upload, chat, admin, users, knowledge bases |
KB_HOST/api/* |
Authorization: Bearer <OWUI API key> |
OWUI REST for agents (files, KBs, projects memory); humans/admins also RAG directly here with an explicit model |
KB_HOST/docs |
none (read-only) | Swagger UI (OWUI; ENV=dev) |
KB_HOST/openapi.json |
none (read-only) | OpenAPI schema (OWUI) |
KB_HOST/memory/* |
Authorization: Bearer <KB_API_KEY> |
API Gateway: memory (whoami, groups, add, retrieve, episodes, status, forget, delete-edge, delete-episode) + RAG chat (POST /memory/rag; the gateway inserts the chat model from OPENWEBUI_MODEL) |
KB_HOST/admin/users |
Authorization: Bearer <KB_API_KEY> (admin, POST) |
API Gateway: create a new KB user (returns temp password + KB_API_KEY); GET falls through to the OWUI SPA |
KB_HOST/health |
none (read-only) | health probe (Caddy → API Gateway → OWUI, aggregated) |
KB_HOST is mandatory (shell-provided, persisted into .env by make bootstrap); KB_HOST_PORT is derived from KB_HOST's port by make bootstrap (the only host-published port). The neo4j (:7474, :7687) and graphiti (:8000 internal) services are not published — reachable only through the API Gateway over graph_internal.
Two sourcing models:
- Operator scripts (
scripts/*.sh, run viamake <target>): source.envthen.env.local(set -a; . ./.env; . ./.env.local; set +a). Precedence is.env.local>.env> shell env — the file wins (location-specific to the repo root).KB_HOSTis mandatory (shell-provided, persisted into.envbymake bootstrap);KB_HOST_PORTis derived fromKB_HOST's port bymake bootstrap(override only for the tunnel case). - The
/kbskill (kb.py): a thin client that reads ONLYKB_HOST+KB_API_KEYfrom the shell env (no.env/.env.localsourcing) so it runs on any host. - Make-time tunables (
make <target> VAR=val) override the file: the script captures the shell value before sourcing and restores it after (seescripts/api-keys.shforKB_DOMAIN/OCR_ENABLED). A target's own args (e.g.EMAIL/NAME/QUERYformake users-*) are not in.env, so they pass through unclobbered.
Full detail: docs/operations.md → Variable precedence.
An admin runs make users-create EMAIL=alice@example.com NAME=Alice — an operator make target (the former in-skill user-create is removed; admin functions are operator-only). It calls the API Gateway POST /admin/users flow below.
- The gateway enforces
role=adminserver-side before any write. A non-admin key →403(not merely a CLI check). OWUI down →503. - Flow (all inside the gateway, one stateless request): generate a strong temp password → create the OWUI user (
POST /api/v1/auths/add, admin key) → sign in as the new user → generate that user'sKB_API_KEYwith the new user's own JWT (POST /api/v1/auths/api_key) → verify the key viaGET /api/v1/auths/resolves to the expected email +role=user. - Returns to the admin only:
email,temp_password,kb_api_key,role,id. The gateway is stateless — it never persists the password or key; they exist only in the one response. The operator must relay them to the new account out-of-band and not store them. - Rollback: if any step after user creation fails, the gateway deletes the partial user (admin
DELETE /api/v1/users/{id}) and returns a clear error. It never reports success on partial provisioning. A duplicate email → deterministic409(no second account). - Prerequisite: the deployed Open WebUI image must expose the provisioning endpoints. The gateway probes
/openapi.jsonat startup and returns501from/admin/usersif the image lacks them.
- A user's uploaded files and knowledge bases are private to that user by default. The KB owner (with
sharing.knowledge) or an admin grants a KB to user groups: Workspace -> Knowledge. - An agent using a user's API key inherits that user's permissions: the user's own files + KBs shared with the user's groups. It cannot see other users' private docs. An admin key bypasses access control — give agents a non-admin user's key, not an admin key.
- To RAG a curated doc set: create a KB -> add docs -> grant it to a group -> put the agent's user in that group -> pass the KB in the
filesfield as{"type":"collection","id":"<kb-id>"}. A top-levelknowledgefield is ignored, andmetadata.knowledgeis discarded server-side — onlyfilesgrounds. The/kbskill retrieves raw chunks via the gatewayPOST /retrieve(pgvector backend; the agent synthesizes the answer itself); the gatewayPOST /memory/ragendpoint is retained for direct/operator one-shot RAG (the gateway inserts the chat model fromOPENWEBUI_MODEL; sendmessages+files, nomodel); humans/admins RAG directly atPOST /api/chat/completionswith an explicitmodel. make config-ragsets a strict-grounding RAG template (admin config, persisted inwebui.db): answer only from the retrieved context; refuse when the answer is absent; do not use outside knowledge or invent names/artifacts. The default template lets the model fall back to its own knowledge, which makes the local 14B chat model confabulate. Re-run after any DB reset/rebuild. Grounding (chunk injection) is the caller's job (filesfield); this template governs what the model does with the chunks. It also syncsrag.ollama.base_urltoOLLAMA_HOST(which OWUI otherwise leaves stale after a host change;make preflightwarns on drift).
The gdrive knowledge base is indexed by the API Gateway (stateless, no sidecar). The gdrive pipeline is split into two stages: make kb-sync runs rclone to sync ./root/gdrive from the shared drives (SYNC-ONLY; does NOT index), then make kb-index KB=gdrive POSTs /index?dir=gdrive to the API Gateway, which walks ./root/gdrive read-only and drives OWUI's native sync/diff protocol to reconcile the tree into the KB. The split makes sync and index independently retryable. Indexing is manual/on-demand only: no daemon, no schedule, no hooks. Prerequisite: a configured + authenticated rclone gdrive remote — one-time rclone config (new gdrive remote, Google Drive storage, browser OAuth login); make kb-sync fail-fasts if no shared drives are visible. Full setup (headless auth, verify, re-auth): see docs/gdrive.md.
The gdrive KB is one instance of the generic source-root pattern: every top-level subdir of ./root/ is one KB, named after the subdir. The gateway mounts ./root read-only at KB_ROOT (/kb-source) and walks /kb-source/<dir> on POST /index?dir=<name>&kb_id=<id>; dir is required and is a single top-dir name (no slash, no wildcard, no within-KB subpath scoping — the KB identity IS the top dir). The gateway is stateless and takes kb_id (it does NOT resolve names); name→id resolution is runtime, in the shell (scripts/kb-bootstrap.sh --resolve, paginated + unique-or-fail). No GDRIVE_KB_ID env is read. A non-gdrive KB has no rclone stage: drop the tree at ./root/<name>/ and run make kb-bootstrap KB=<name> + make kb-index KB=<name>.
KB source attribute (in the description). Each KB carries a structured source attribute as kv appended to its description: root KBs — Indexed from local root/<name>/ via api-gateway | source=root | host=<hostname> | path=<name>; projects-memory KBs — Claude projects memory | source=projects-memory | host=<hostname> | project=<encoded-dir> | repo=<git-repo> | path=<project-dir>. OWUI's REST API cannot write the KB meta JSONB field (KnowledgeForm has no meta), so the attribute lives in the writable description (the prose lead is kept — OWUI re-embeds f'{name}\n\n{description}' into the KB-metadata collection on every write). kb kbs parses the kv and surfaces source/host/path/project/repo per KB; a consumer reads source to interpret path (root-relative for source=root, the absolute project dir for source=projects-memory). New KBs get the kv at create time. make kb-desc-backfill (one-time, idempotent) appends the kv to the descriptions of legacy KBs created before this landed (skips any KB that already has a source= kv); run it on the stack host.
make kb-syncsyncs external sources into./root/(SYNC-ONLY; does NOT index). Today this is gdrive: rclonecopy --files-from --backup-dirsyncs./root/gdrivefrom all shared drives (delta — files removed from Drive are reconciled out of./root/gdriveby the wrapper, and the next/indexdrops them from the KB viasync/cleanup). Deleted/overwritten files are retained in a dated./.gdrive-backup/<UTC-ISO>/dir outside./rootas a recovery net;make clean-backupclears it. Runmake kb-index KB=gdriveafter to index.- Exclude rules: per-drive non-downloadable files and global patterns (e.g.
*.tmp) are read from the gitignored per-directory.kb-ignorechain (gitignore-style:./root/.kb-ignoreholds the globals applied to EVERY KB;./root/gdrive/<drive name>/.kb-ignorescopes to that drive; format documented in docs/operations.md).kb-syncenumerates the remote file list and downloads only the allowed files (rclone copy --files-from); the gateway walk applies the same ancestor chain at index time.!re-includes a file a shallower pattern denied. - Sync report: fail-fasts on any transfer error; writes a per-run report (
0600) to./root/gdrive/.sync-reports/sync-<UTC-ISO>.reportwith a per-driveremote/local/excluded/dupstable, aCOPY/UPDATE/DELETEbreakdown (per file; correct under--backup-dir), a "Files excluded" section (Drive files matching the exclude patterns), a "Duplicates ignored" section (Drive files rclone skipped because another file shares the same path — Drive permits duplicate names), and a "Files not downloaded" section (e.g. admin-protected / download-restricted Drive files, surfaced with their 403 reason). Downloadable files still transfer; exit code is non-zero if any drive had errors. make kb-index KB=<name>runs POST/index?dir=<name>alone (no rclone): reconcile./root/<name>/into its KB, creating the KB if missing (find-or-create viakb-bootstrap.sh: 0 matches → create + grant; 1 → reuse + re-grant; >1 → fail).KB=<name>selects one./rootsubdir; noKB=indexes EVERY top-level non-dot subdir;KB=<glob>(e.g.xgen-*) expands against./root/(Makefile glob — the gateway never sees a wildcard). Default incremental; setINDEX_ALL=1for a full re-index (drain + re-upload every file). SetRETRY_PENDING=1to also re-trigger stalledpendingfiles (delete + re-upload; the default retries onlyfailed). Per-KB client-side in-flight guard + name resolution; iso e2e fixtures live under a separateroot_tests/tree (each indexed by name, not a dot-dir).make kb-bootstrap KB=<name>(one-time, aftermake api-keys; noKB=bootstraps every./rootsubdir) creates the KB named after the./root/<name>/subdir, grants public read (user:*) so every authenticated user can retrieve/RAG it, and prints thekb_id. Resolution is by name (noGDRIVE_KB_IDwritten). Idempotent. Does NOT start a sidecar (there is none).make kb-status KB=<name>reads GET/status?dir=<name>and emits pretty JSON (indent=2):dir,kb_id,source_count(allowlisted file count under the KB root),indexed_count(files withdata.status=completed— extracted, embedded, linked, searchable),pending(in extraction / OCR / GPU, or queued),processing(embedding + linking),failed,failed_files({filename, error}),pending_files({filename, error}), and the per-fileindexed_fileslist. Key order:indexed_filesfirst (the long list scrolls off the top), thenfailed_files/pending_files, then the single-field counts (visible at the bottom). The drain is terminal whenpending+processing=0ANDcompleted+failedcoverssource_count. No ETA (no daemon). (It passes?json=1; the bare/statustext/glyph form still exists for direct curl.)- Indexed file types are the documents-only allowlist hardcoded in
gateway/app.pyDEFAULT_ALLOW(source code is handled by open-codebase-index;.npy, audio/video, images, archives,.svg/.drawioare excluded). Max size isKB_MAX_SIZE(default100mb,.env)./indexdoes incremental SHA-256 diffing against the live KB and fails closed on an empty source (0 files + not?force=1→ 422, nocleanup), so a bad/empty mount cannot mass-delete the KB. - The API Gateway reaches OWUI internally (
openwebui:8080onowui_net) with the admin key in its env (OPENWEBUI_ADMIN_API_KEY, injected from.env.local). The caller'sKB_API_KEYis authorization only (identity via OWUI, role checked for/indexadmin). The gateway is stateless: nohistory.db, no./data/oikb. File bytes flow gateway → OWUI internally (not through Caddy); Caddy carries only the trigger + the results JSON. - Per-file transparency:
/indexreturns{added, modified, deleted, unmodified, retried, errors, ok}whereerrorscarries{filename, status, error}per failed upload/dir-create/re-trigger — the diagnosis surface (no opaque daemon aggregate). The gateway does NOT link files itself:POST /files/queues OWUI's per-upload background task (extract → embed → link), which is the sole linker./indexre-triggersfailedfiles every run (delete + re-upload;RETRY_PENDING=1/?retry_pending=1also re-triggerspending).ok=falsemeans a real upload/extract error (the upload-idempotency + path-aware-dedup patches make duplicate-content 400s not occur).
The markitdown-ocr sidecar is an external extraction engine that OCRs image-bearing documents (PDF/DOCX/PPTX/XLSX) and standalone images via deepseek-ocr on Ollama's native /api/chat, so image-only PDFs and embedded figures/diagrams become searchable instead of orphaning. Full design, scope, and service guards: docs/ocr.md.
- Gating:
OCR_ENABLEDin.env(defaulttrue); compose-profile-gated (COMPOSE_PROFILES=ocr, baked into.envbymake bootstrapfromOCR_ENABLEDand read bydocker composefor every command); no per-type fallback (global + all-or-nothing). - Provisioning: when enabled, the standard chain covers it (
bootstrapgenerates the token + bakesCOMPOSE_PROFILES=ocr,pull-modelspullsdeepseek-ocr,startbuilds + starts the sidecar,api-keyssets the OWUI routing) — no separate step. - Disable:
make clean-all && make provision OCR_ENABLED=false(bakesOCR_ENABLED=false+ an emptyCOMPOSE_PROFILESinto.env; existing OCR'd members keep their content until re-ingested). - Hit metadata: a retrieval hit carries
file_id+page→ the exact original page/slide/sheet.
For per-tool agent integration (skill install, CLI examples), see docs/agents.md.
Measured warm on the GPU host (one 14B ctx-baked model loaded). The first call after idle adds model-load time (~30 s cold). RAG chat and async extraction include the LLM; the other operations do not.
| Operation | Endpoint | Median latency |
|---|---|---|
| add (queue -> 202, episode queued) | POST /memory/add |
~54 ms |
| retrieve facts | POST /memory/retrieve |
~89 ms |
| fetch raw episodes | GET /memory/episodes?max=20 |
~57 ms |
| add -> fact searchable (async extraction, warm model) | add + poll /memory/retrieve |
~9 s |
/memory/retrievereturns facts (entity_edges; fact text +valid_at/invalid_at) via Graphiti RAG: the query is embedded (nomic-embed-text), matched by vector similarity over nodes, then refined by graph traversal. The originaladdtext is not returned./memory/episodesreturns the raw episodes: the originaladdtexts, verbatim, in order. No embedding, no LLM, no graph traversal.
| Operation | Endpoint | Median latency |
|---|---|---|
| raw chunk retrieval | POST /api/v1/retrieval/query/collection |
~64 ms (first call ~328 ms) |
| RAG chat (retrieval + grounded LLM) | POST /api/chat/completions (files) |
~1.5 s |
- Raw retrieval (gateway
POST /retrieve→ OWUI/api/v1/retrieval/query/collection, pgvector backend) returns chunks: the gateway flattens the response into 8-key hits (distance, file, file_id, page, start_index, source, mtime, text), top-k by hybrid (vector + BM25) similarity. No LLM runs. - RAG chat (
/api/chat/completionswithfiles:[{"type":"collection","id":<kb-id>}]) runs retrieval, injects the chunks as context, and returns a grounded LLM answer. Themake config-ragstrict template makes the model answer only from the retrieved chunks and refuse when the answer is absent.
For lockdown defaults, phone-home hardening, container caps, secrets handling, Neo4j auth, and dev-mode docs, see docs/operations.md#hardening-reference.
WEBUI_AUTH=true: every UI and REST call needs a session or a Bearer API key.- First registered user becomes admin. Later signups get
DEFAULT_USER_ROLE=pendingand need admin approval. Close signup withENABLE_SIGNUP=false+make restart. - Knowledge bases are private by default; admins grant access to user groups (Workspace -> Knowledge). RBAC is additive: role + group membership.
- JWTs signed with a generated secret key (gitignored
.env.local;make startrejects an empty/missing key). See docs/operations.md#secrets-handling.
- The
graphitiandneo4jcompose services have no agent-facing auth and are internal-only ongraph_internal. The API Gateway is the sole bridge; Caddy (KB_HOST) fronts it. The Graphiti REST server has no native auth — the gateway is the gate. (Full route set:docker/caddy/Caddyfile.) - Every gateway endpoint (except
/health) requiresAuthorization: Bearer <KB_API_KEY>. No key →401; bad key →401; Open WebUI unreachable →503(fail closed). /healthis ungated on purpose: non-sensitive probe, carries no data or credentials. It also probes Open WebUI somake healthcatches an identity-broken stack rather than reporting healthy while auth is down.KB_API_KEYis a bearer —KB_HOSTMUST be HTTPS or VPN/tunnel for any non-local agent. Plain HTTP is safe only on a trusted local interface.- Rotate Open WebUI API keys on a schedule (
FORCE=1 make api-keys).
| Path | Contents |
|---|---|
compose.yml |
services + networks (graph_internal, edge, owui_net); api-gateway internal env |
gateway/ |
API Gateway source: app.py, authorize.py, graphiti.py, neo4j.py, owui.py, Dockerfile (zero-dependency stdlib, non-root) |
caddy/Caddyfile → docker/caddy/Caddyfile |
public edge KB_HOST; routes /memory/*, POST /admin/users, /health → api-gateway:8010, catch-all → openwebui:8080; baked into the kb-proxy overlay image (FROM caddy:2.11.4-alpine + COPY) |
graphiti/bootstrap.py → docker/graphiti/bootstrap.py |
baked into the kb-graphiti overlay image (FROM ghcr.io/dkhokhlov/graphiti-rest + COPY) and run as the command; injects OpenAIGenericClient + nomic embedder (768) so Ollama extraction works, runs a robust /messages worker, owns the app lifespan |
graphiti/config.yaml → docker/graphiti/config.yaml |
UNUSED — config for the retired MCP image; kept for reference (not mounted, not COPY'd) |
Modelfile_qwen2_5 |
reference Modelfile for the custom ctx-baked GRAPHITI_MODEL (FROM qwen2.5:14b + PARAMETER num_ctx 8192); see docs/operations.md |
scripts/ |
bootstrap.sh, api-keys.sh, preflight.sh, config-rag.sh, gdrive-sync, kb-bootstrap.sh, kb-index.sh, kb-finalize.sh, kb-desc-backfill.sh |
skills/ |
per-tool kb agent skill (claude/ primary; codex/, opencode/, pi/ symlink scripts/ to it) — see docs/agents.md |
tests/ |
test_01..test_12 bash tests + test_runner.py (pytest driver) + 4 native UTs + conftest.py + lib.sh (see docs/testing.md) |
docs/ |
operations.md, ocr.md, gdrive.md, memory.md, testing.md, agents.md, favicon assets |
.env / .env.template |
tracked template — no secrets (ports, tags, models, tunables, OLLAMA_HOST) |
.env.local / .env.local.template |
gitignored secrets + generated keys (chmod 0600) |
Makefile |
targets (see docs/operations.md#make-targets) |
LICENSE |
MIT |
- Embedding dimensions must be 768 for
nomic-embed-text. ChangeEMBEDDER_MODELandEMBEDDER_DIMENSIONStogether if you swap models. The bootstrap readsEMBEDDER_DIMENSIONSto set both the embedder and the vector index dim (Graphiti defaults to 1024, which would reject 768-dim writes). - Graphiti extraction uses
json_schemastructured outputs over Chat Completions. The base image's default client targets the OpenAI Responses API (Ollama cannot satisfy it — extraction silently stores nothing), sodocker/graphiti/bootstrap.py(baked into thekb-graphitioverlay) injects the stockOpenAIGenericClient(>= 0.29 defaults tojson_schema, which Ollama enforces server-side) attemperature=0. With plainjson_objectmode a 14B local model echoes the response schema back as values (Neo4j then rejects the nested MAP);json_schemaprevents that. - Extraction is async:
POST /memory/addreturns200as soon as the episode is queued, but each episode runs several LLM extraction calls before a fact is searchable. With the ctx-baked model (num_ctx=8192, ~20 GB, fits the GPU) a fact is searchable in ~9 s warm (~30 s cold, model load); do not treat a bare200(or even a stored episode) as proof of extraction — poll/memory/retrievefor the probe. - The extraction LLM (
GRAPHITI_MODEL, built fromOLLAMA_MODEL_BASE) must be a non-reasoning model. Graphiti 0.29.3 calls it withmax_tokens=16384; a reasoning model (one with a thinking chain) spends the budget on its thinking and emits nocontent(finish_reason=length) →json.loads('')→ extraction silently stores nothing, even withjson_schemaenforcement.qwen2.5:14b-ctx8192(the defaultGRAPHITI_MODEL) is non-reasoning and tested reliable. Ollama's/v1/chat/completionsdoes not honorthink=false, so suppressing reasoning that way is not an option. - The
/v1endpoint ignoresnum_ctxin the request body, somake pull-modelsbakesOLLAMA_MODEL_CONTEXT(default8192) intoGRAPHITI_MODELvia aPARAMETER num_ctxModelfile (seeModelfile_qwen2_5and docs/operations.md). The remote GPU host (~22.5 GB VRAM) cannot hold the stock 14B at the default 32k context (~53 GB) — it spills to CPU and extraction slows drastically;num_ctx=8192loads ~20 GB and fits.OPENWEBUI_MODEL(chat) andGRAPHITI_MODEL(extraction) are independent; the defaults are equal so one 14B instance loads, but they may differ.make preflightverifies the extraction model exists and itsnum_ctxmatches. - There are no shared write groups. Each account writes to its own personal group (logical
user:<email>, stored by Graphiti asuser-<sanitized-email>e.g.user-agent-local-test); reads span all groups, so cross-account knowledge is shared read-only. - Open WebUI exposes
/docsand/openapi.jsononly inENV=dev(also raises log verbosity — acceptable for an internal/LAN deployment). SEMAPHORE_LIMIT=3bounds graphiti_core extraction concurrency; conservative for one local Ollama. Raise it if the host Ollama has capacity.