Sign inSign up

terrakernel/odxbrain

By terrakernel

•Updated 16 days ago

Ask in plain language, get back the Odoo models that hold the answer. Offline, on your own servers

Image
0

57

terrakernel/odxbrain repository overview

⁠ODXBrain

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:

  • Which Odoo model holds this data? One question in, three ranked model names out, filtered to what your Odoo version and edition actually have.
  • How do we do this here? A task in, the matching standard operating procedure (SOP) out, from the SOPs your team has approved. SOPs are optional and managed with 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.


⁠What's in the image, and what isn't

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 provideWhat it isWhere it comes from
The model file bge-m3-f16.ggufThe embedding model, 1.16 GBDownload it from terrakernel/odxbrain-bge-m3-gguf⁠ on Hugging Face (MIT), below
The corpus packThe Odoo model knowledge, about 210 KB, versioned separately from the imageRelease assets of terrakernel/odxbrain-corpus⁠ (CC0)
An SOP snapshot (optional)Your approved SOPsWritten 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.


⁠Requirements

  • linux/amd64 only. The CPU must be x86-64-v3, which means AVX2 (Intel Haswell, AMD Excavator or newer). The image won't start on an older CPU.
  • About 2 GB of RAM for the container (1.97 GB measured in use).
  • 2 CPU cores is the measured baseline, at about 40 ms a query.

⁠Quick start

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.


⁠Adding SOPs

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
  • Mount the directory, not the file. 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.
  • ODXBrain refuses to start if --sops names a file that doesn't exist. Write the first snapshot offline with odxdocs build (the odxdocs page shows how), then start ODXBrain.
  • ODXBrain returns SOP ids and versions, never the procedure text. The agent fetches the text from 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.


⁠Docker Compose: ODXBrain with odxdocs

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.


⁠Reference

⁠Mounts
PathHoldsMode
/modelsbge-m3-f16.ggufread-only
/packthe corpus packread-only
/sopsthe directory holding snapshot.jsonread-only

None of them has a default: a missing mount fails at startup with "file not found".

⁠Environment
VariableMeaning
ODXBRAIN_API_KEYRequired 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.

⁠Flags
FlagDefaultMeaning
--model PATHnone, requiredThe GGUF model file
--model-id NAMEthe file nameThe model's identity, echoed in every response. Use bge-m3
--pack DIRnoneThe corpus pack. Without it /v1/resolve answers 503
--sops PATHnoneThe SOP snapshot. Enables /v1/sops/resolve and /v1/sops/reload
--host ADDR127.0.0.1Use 0.0.0.0 in a container. Any address but loopback requires a key
--port N8080
--threads Nthe container's CPU limitThe 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 PATHnoneA JSON file whose keys mirror the flags (model, pack, threads, api_keys, …). An unknown key is an error
--truncateoffCut over-long inputs instead of rejecting them
--devicesList the compute backends that loaded, then exit
--versionPrint the version and exit

Precedence: defaults, then --config, then ODXBRAIN_API_KEY, then flags.

⁠Endpoints
EndpointKeyWhat it does
POST /v1/resolveyesA question in, ranked Odoo model names out
POST /v1/sops/resolveyesA task in, ranked SOP ids and versions out
POST /v1/sops/reloadyesRe-read the SOP snapshot. odxdocs publish calls it
POST /v1/embeddingsyesOpenAI-compatible embeddings from the same model
GET /infoyesModel, corpus version and SOP generation being served
GET /healthnoLiveness: 200 while the process is up
GET /readynoReadiness: 200 when /v1/resolve can answer, 503 when it can't
⁠Request fields for /v1/resolve
FieldRequiredMeaning
queryyesThe question, in any supported language
odoo_versionnoOdoo's server_serie, e.g. "18.0" or "saas~17.4"
editionno"community" or "enterprise"
knoHow 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":"…"}}.


⁠Health checks

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.


⁠Security

  • No outbound network code. An HTTP server, no HTTP client, no TLS client library. Verified by a test against the exact binary in this image, and no curl or wget in the image.
  • A key is mandatory on any address but loopback; ODXBrain won't start without one. The key is a shared secret compared in constant time. Put a proxy such as ODXProxy in front for user identities and scopes.
  • Runs as UID 10001 with read-only mounts. It also runs with a read-only root filesystem, all capabilities dropped and no-new-privileges.
  • It never holds Odoo credentials. Requests carry a question and optional version facts, never an Odoo URL or API key.
  • A model mismatch is refused at startup. Every response names the model and dimensions it was computed with, and a pack built for another model doesn't load.
  • Base image: Red Hat UBI 10 micro, which gets Red Hat security errata.

⁠Tags

TagMeaning
0.1.0A release. Pin this in production
latestThe 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.


⁠Troubleshooting

SymptomCause
refusing to serve on 0.0.0.0 without an api keyODXBRAIN_API_KEY isn't set in the container
Exits at start, mentioning the pack or modelA mount is missing, or the files aren't readable by UID 10001
Pack refused for model or dimension mismatchThe 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_conflictThe 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 DesktopDocker 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

⁠License

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.

Tag summary

Content type

Image

Digest

sha256:d5d584666…

Size

23.8 MB

Last updated

16 days ago

docker pull terrakernel/odxbrain