Ask in plain language, get back the Odoo models that hold the answer. Offline, on your own servers
57
Ask in plain language, get back the Odoo models that hold the answer. Offline, on your own servers.
"which invoice is not paid yet" → account.move, account.payment, account.payment.term
ODXBrain answers two questions for an AI agent working on Odoo, and answers them with ranked identifiers, never generated text:
terrakernel/odxdocs.Questions can be in any language the model reads. Accuracy is measured in English, German, Greek, Spanish, French, Polish, Russian and Chinese. It needs no GPU and no internet connection, and answers a query in about 40 ms on two CPU cores.
Air-gapped by design. ODXBrain is an HTTP server with no HTTP client in it, which a test
re-proves on every build. The image has no curl or wget either. Nothing in it can call out,
so it runs on networks with no route to the internet.
The image is the engine only: the odxbrain binary and the libraries it loads, CPU only,
running as a non-root user (UID 10001) on Red Hat UBI 10 micro.
It does not contain the model or the corpus pack. You mount them from the host:
| You provide | What it is | Where it comes from |
|---|---|---|
The model file bge-m3-f16.gguf | The embedding model, 1.16 GB | Download it from terrakernel/odxbrain-bge-m3-gguf on Hugging Face (MIT), below |
| The corpus pack | The Odoo model knowledge, about 210 KB, versioned separately from the image | Release assets of terrakernel/odxbrain-corpus (CC0) |
| An SOP snapshot (optional) | Your approved SOPs | Written by odxdocs publish |
Keeping the data out of the image means a pack update never needs a new image, and an image update never re-downloads 1.16 GB. It also suits an air-gapped site: download once on a connected machine, check the hashes, then copy the files across.
1. Download the model file. ODXBrain needs one exact file: BAAI/bge-m3 converted to F16
GGUF with the llama.cpp version ODXBrain was built and measured with. We publish it on Hugging
Face as terrakernel/odxbrain-bge-m3-gguf,
MIT-licensed like bge-m3 itself. Download it once, on any machine with internet access:
pip install -U huggingface_hub # provides the `hf` command
hf download terrakernel/odxbrain-bge-m3-gguf bge-m3-f16.gguf \
--revision 582d13afe777ea05e979a07890d6b3e6ae8dd975 --local-dir .
The --revision pins the exact commit, so the download can't change under you. The file is
1.16 GB.
2. Check it. It must be exactly this file:
$ sha256sum bge-m3-f16.gguf
1257ef7bb76105793f89307b02d6b9c29a444d629f9ff0ad56ee023222fa105d bge-m3-f16.gguf
It's 1,157,671,008 bytes. Don't use another bge-m3 GGUF from Hugging Face instead. None of the popular ones is this file; their sizes alone differ. ODXBrain refuses a pack built for a different model, but it can't tell a different file under the same name: another quantisation or conversion gives answers whose accuracy nobody has measured, with no error.
You don't have to trust our upload: the conversion is reproducible byte for byte, and the model page has the recipe that rebuilds this exact file from BAAI's original weights.
3. Get the corpus pack for your ODXBrain version from the
odxbrain-corpus releases, check it
against its .sha256, and unpack it:
sha256sum -c odxbrain-pack-2026.09.4-bge-m3.tar.gz.sha256
mkdir -p /srv/odxbrain/pack
tar -xzf odxbrain-pack-2026.09.4-bge-m3.tar.gz -C /srv/odxbrain/pack --strip-components=1
4. Put the files in place, in folders the container can read (it runs as UID 10001, so world-readable is enough):
/srv/odxbrain/
├── models/bge-m3-f16.gguf
└── pack/ manifest.json, vectors.f32, payloads.jsonl
5. Run it. Inside a container ODXBrain listens on 0.0.0.0, so an API key is required.
It refuses to start without one.
export ODXBRAIN_API_KEY="$(openssl rand -hex 32)" # or from your secret store
docker run -d --name odxbrain --restart unless-stopped \
--read-only --cap-drop ALL --security-opt no-new-privileges \
-e ODXBRAIN_API_KEY \
-v /srv/odxbrain/models:/models:ro \
-v /srv/odxbrain/pack:/pack:ro \
-p 127.0.0.1:8080:8080 --cpus 2 \
terrakernel/odxbrain:0.1.0 \
--model /models/bge-m3-f16.gguf --model-id bge-m3 --pack /pack \
--host 0.0.0.0 --port 8080
Arguments after the image name replace the default command, so every flag is repeated. The
--read-only, --cap-drop and no-new-privileges hardening is optional, and ODXBrain runs under
it: it needs no writable filesystem and no capabilities.
6. Ask it something.
curl -s http://127.0.0.1:8080/v1/resolve \
-H "x-api-key: $ODXBRAIN_API_KEY" -H 'Content-Type: application/json' \
-d '{"query":"which invoice is not paid yet","odoo_version":"18.0","edition":"community"}'
{"candidates":[{"model":"account.move","score":0.513},
{"model":"account.payment","score":0.492},
{"model":"account.payment.term","score":0.462}],
"corpus_version":"2026.09.4","dim":1024,"model":"bge-m3","usage":{"prompt_tokens":10}}
Use all three candidates. The right model is first about two times in three and in the top three more than nine times in ten. The misses are close neighbours, so let your agent's LLM choose from the three; don't take the first and drop the rest.
Start ODXBrain with --sops pointing at a snapshot, and POST /v1/sops/resolve answers "how do we
do this here?" with SOP ids. The snapshot is written by odxdocs publish, which also tells
ODXBrain to reload it without a restart.
-v /srv/odxbrain/sops:/sops:ro \
... --sops /sops/snapshot.json
odxdocs publish replaces the snapshot by renaming a new
file over it. A bind mount of the file itself keeps showing the container the old one.--sops names a file that doesn't exist. Write the first snapshot
offline with odxdocs build (the odxdocs page shows how), then start ODXBrain.odxdocs serve, by the exact version it was given.{"candidates":[{"id":"vendor-payment","version":"528c02d3eeef01f2",
"title":"Register a vendor payment","score":0.737}],
"excluded":[{"id":"vendor-bill-from-po","reason":"Odoo Enterprise only"},
{"id":"expense-reimbursement","reason":"needs module hr_expense"}],
"model":"bge-m3","dim":1024,"sops":{"generation":8,"root":"4ac91d64…"},"usage":{"prompt_tokens":6}}
When the answer contains "match": "none", no SOP is a confident match: the agent should stop and
ask a person, not run the nearest procedure.
ODXBrain and odxdocs serve on one private network, with publish and the GUI as one-off
commands. ODXProxy, if you use it, joins the same network and calls http://odxbrain:8080 with
its own key. The terrakernel/odxproxy page
has the file with all three.
Put the folders and a .env file (chmod 600) beside compose.yaml:
ODXBRAIN_DOCS_KEY=... # odxdocs publish and the GUI send it to ODXBrain
ODXBRAIN_PROXY_KEY=... # optional: ODXProxy sends it to ODXBrain
ODXDOCS_API_KEY=... # the agent sends it to odxdocs serve
ODX_UID=1000 # `id -u` of the user who owns the folders
ODX_GID=1000 # `id -g`
services:
odxbrain:
image: terrakernel/odxbrain:0.1.0
command: ["--model", "/models/bge-m3-f16.gguf", "--model-id", "bge-m3",
"--pack", "/pack", "--sops", "/sops/snapshot.json",
"--host", "0.0.0.0", "--port", "8080"]
environment:
# odxdocs' key, plus ODXProxy's when it is set: ODXBrain accepts either
ODXBRAIN_API_KEY: "${ODXBRAIN_DOCS_KEY:?set ODXBRAIN_DOCS_KEY in .env}${ODXBRAIN_PROXY_KEY:+:${ODXBRAIN_PROXY_KEY}}"
volumes:
- ./models:/models:ro
- ./pack:/pack:ro
- ./sops:/sops:ro
cpus: 2
expose: ["8080"]
ports:
- "127.0.0.1:8080:8080" # only for the odxdocs GUI, which runs on host networking
restart: unless-stopped
networks: [odx]
odxdocs:
image: terrakernel/odxdocs:0.1.0
environment:
ODXDOCS_API_KEY: ${ODXDOCS_API_KEY:?set ODXDOCS_API_KEY in .env}
volumes:
- ./store:/store:ro
expose: ["8090"]
restart: unless-stopped
networks: [odx]
publish:
image: terrakernel/odxdocs:0.1.0
profiles: [tools]
user: "${ODX_UID:?}:${ODX_GID:?}"
command: ["publish", "/sop-files", "--out", "/sops/snapshot.json", "--store", "/store",
"--brain", "http://odxbrain:8080", "--label", "${ODX_SOP_LABEL:-}"]
environment:
ODXDOCS_BRAIN_KEY: ${ODXBRAIN_DOCS_KEY:?}
volumes:
- ./sop-files:/sop-files:ro
- ./sops:/sops
- ./store:/store
depends_on: [odxbrain]
networks: [odx]
gui:
image: terrakernel/odxdocs:0.1.0
profiles: [tools]
network_mode: host
user: "${ODX_UID:?}:${ODX_GID:?}"
command: ["gui", "/sop-files", "--out", "/sops/snapshot.json", "--store", "/store",
"--brain", "http://127.0.0.1:8080"]
environment:
ODXDOCS_BRAIN_KEY: ${ODXBRAIN_DOCS_KEY:?}
volumes:
- ./sop-files:/sop-files
- ./sops:/sops
- ./store:/store
networks:
odx: {}
# first time only: write the first SOP snapshot, which ODXBrain needs to start
docker compose run --rm --no-deps publish build /sop-files --out /sops/snapshot.json --store /store
docker compose up -d # ODXBrain + odxdocs serve
docker compose run --rm publish # check, self-test, publish, reload
docker compose run --rm gui # SOP editor on http://127.0.0.1:8091/
The terrakernel/odxdocs page covers publishing,
labels and the GUI.
| Path | Holds | Mode |
|---|---|---|
/models | bge-m3-f16.gguf | read-only |
/pack | the corpus pack | read-only |
/sops | the directory holding snapshot.json | read-only |
None of them has a default: a missing mount fails at startup with "file not found".
| Variable | Meaning |
|---|---|
ODXBRAIN_API_KEY | Required in a container. The x-api-key value clients must send. Colon-separate several to give each caller its own key, or to rotate one with no gap: new:old |
Pass the key with -e, never as --api-key, which shows in ps and docker inspect.
| Flag | Default | Meaning |
|---|---|---|
--model PATH | none, required | The GGUF model file |
--model-id NAME | the file name | The model's identity, echoed in every response. Use bge-m3 |
--pack DIR | none | The corpus pack. Without it /v1/resolve answers 503 |
--sops PATH | none | The SOP snapshot. Enables /v1/sops/resolve and /v1/sops/reload |
--host ADDR | 127.0.0.1 | Use 0.0.0.0 in a container. Any address but loopback requires a key |
--port N | 8080 | |
--threads N | the container's CPU limit | The default is the physical cores the container may use, less any efficiency cores, capped at --cpus. The startup line shows what it chose. Set it only to go lower |
--config PATH | none | A JSON file whose keys mirror the flags (model, pack, threads, api_keys, …). An unknown key is an error |
--truncate | off | Cut over-long inputs instead of rejecting them |
--devices | List the compute backends that loaded, then exit | |
--version | Print the version and exit |
Precedence: defaults, then --config, then ODXBRAIN_API_KEY, then flags.
| Endpoint | Key | What it does |
|---|---|---|
POST /v1/resolve | yes | A question in, ranked Odoo model names out |
POST /v1/sops/resolve | yes | A task in, ranked SOP ids and versions out |
POST /v1/sops/reload | yes | Re-read the SOP snapshot. odxdocs publish calls it |
POST /v1/embeddings | yes | OpenAI-compatible embeddings from the same model |
GET /info | yes | Model, corpus version and SOP generation being served |
GET /health | no | Liveness: 200 while the process is up |
GET /ready | no | Readiness: 200 when /v1/resolve can answer, 503 when it can't |
/v1/resolve| Field | Required | Meaning |
|---|---|---|
query | yes | The question, in any supported language |
odoo_version | no | Odoo's server_serie, e.g. "18.0" or "saas~17.4" |
edition | no | "community" or "enterprise" |
k | no | How many candidates, default 3 |
Leaving out odoo_version or edition filters nothing. ODXBrain never guesses a version,
because a wrong guess silently drops correct answers. Send them whenever you know them; the
response says "filtered": false when you sent neither.
Errors share one shape: {"error":{"message":"…","type":"…"}}.
The image has no HEALTHCHECK. Docker runs health checks inside the container, so they need an
HTTP client, and this image deliberately has none. Probe from outside instead: your orchestrator,
a load balancer or ODXProxy. Use GET /ready for readiness and GET /health for liveness. Neither
needs the key.
curl or wget in the image.no-new-privileges.| Tag | Meaning |
|---|---|
0.1.0 | A release. Pin this in production |
latest | The newest release |
An image update doesn't change the corpus pack or the model. A new pack ships separately and loads into the same image.
| Symptom | Cause |
|---|---|
refusing to serve on 0.0.0.0 without an api key | ODXBRAIN_API_KEY isn't set in the container |
| Exits at start, mentioning the pack or model | A mount is missing, or the files aren't readable by UID 10001 |
| Pack refused for model or dimension mismatch | The pack was built for another model, or --model-id isn't bge-m3 |
| Every query slow | --threads was set higher than the CPU limit. Leave it out and the default follows --cpus |
/v1/sops/reload answers 409 snapshot_conflict | The snapshot on disk isn't the one the publish wrote. Publish again. On Docker Desktop, publish from the odxdocs container, not from the host |
An empty /pack on Docker Desktop | Docker Desktop shares only some host paths ($HOME by default). An unshared path mounts as an empty directory. Production Linux hosts don't have this limit |
Proprietary. Copyright © 2026 TerraKernel Pte Ltd. All rights reserved. Use requires a written
agreement with TerraKernel. The image includes third-party components under their own licences
(llama.cpp/ggml, FAISS, cpp-httplib and nlohmann/json, all MIT), listed in
/opt/odxbrain/THIRD-PARTY-NOTICES.md. The bge-m3 model is MIT-licensed by BAAI.
Content type
Image
Digest
sha256:d5d584666…
Size
23.8 MB
Last updated
16 days ago
docker pull terrakernel/odxbrain