Your team's Odoo SOPs, checked, published to ODXBrain and served to AI agents by exact version
90
The doc manager for ODXBrain: your team's Odoo procedures, checked, published and served to an AI agent.
Your standard operating procedures (SOPs) live as Markdown files in a git repository. odxdocs
validates them, tests that the agent will actually find each one, publishes them to ODXBrain
without a restart, and serves the agent the exact version it was told to follow.
SOP files (git) ──publish──▶ ODXBrain "which SOP fits this task?" → vendor-payment @ 528c02d3…
odxdocs serve GET /v1/sops/vendor-payment/528c02d3… → the procedure
Small and quiet for vulnerability scanners. One static binary on distroless/static: no libc,
no OpenSSL, no shell, no package manager. 2.6 MB compressed, running as a non-root user.
| Job | Runs as | What it does |
|---|---|---|
serve | a long-running service (the default command) | Hands the agent an SOP's procedure by id and version |
publish | a one-off command | Checks the SOPs, self-tests them against ODXBrain, writes the snapshot, tells ODXBrain to reload |
gui | a one-off command, while editing | A browser editor with live validation, "Try it" and Publish, on 127.0.0.1 only |
Also available: check (validate the files, offline), test (the self-test alone) and build
(write the snapshot without testing or reloading).
publish, test and the GUI. serve doesn't need it.docker run works too.ODXBrain and odxdocs serve share a private network. publish and the GUI run when you need them.
The full file, with ODXBrain's service, is on the
terrakernel/odxbrain page. These are the
odxdocs parts:
services:
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
.env beside it, chmod 600:
ODXBRAIN_DOCS_KEY=... # one of ODXBrain's keys; publish and the GUI send it
ODXDOCS_API_KEY=... # the key the agent sends to odxdocs serve
ODX_UID=1000 # `id -u` of the user who owns the folders
ODX_GID=1000 # `id -g`
docker compose up -d # ODXBrain + odxdocs serve
docker compose run --rm publish # check, self-test, publish, reload
docker compose run --rm gui # then open http://127.0.0.1:8091/
The very first time, write the snapshot offline before starting the stack. ODXBrain refuses to
start on a snapshot that doesn't exist yet, and build needs no ODXBrain:
docker compose run --rm --no-deps publish build /sop-files --out /sops/snapshot.json --store /store
docker compose up -d
After that, every publish reloads ODXBrain live.
One Markdown file per SOP, named after its id, with TOML front matter between +++ lines:
+++
id = "vendor-payment"
title = "Register a vendor payment"
intents = [
"pay an open vendor bill",
"register a payment to a supplier",
"settle what we owe a vendor",
]
[applies_to]
modules = ["account"]
+++
## Business rules (Accountant)
- Pay only bills that are posted and approved.
## Technical notes (Developer)
- Use account.payment.register on the posted bill.
intents are how the agent will ask for this SOP: short English task descriptions, not
questions a person would type. At least two, because the self-test holds one out at a time.applies_to restricts where the SOP applies: odoo_min, odoo_max, edition, modules
(a localization is a module, e.g. l10n_id) and companies. Leave out what doesn't restrict.
A misspelled key is an error, never ignored.docker compose run --rm publish does five things, in order:
Checks every file. One invalid file stops the publish, and every problem is listed at once.
Self-tests against ODXBrain's own model: each intent, held out, must find its own SOP.
| Result | Meaning | Blocks the publish? |
|---|---|---|
| PASS | every intent finds its SOP first | no |
| WARN | every intent finds it in the top 3, but another SOP comes first | no |
| FAIL | an intent puts its SOP outside the top 3, so an agent asking that way won't see it | yes |
There's deliberately no flag to publish a failing set. Reword the intent to say what makes this procedure different.
Keeps every new version in the store, so serve can hand it out.
Writes the snapshot atomically.
Reloads ODXBrain and checks that it reports the same content back.
Generations count up automatically. Publishing unchanged content is a no-op. To undo, use git: check out the earlier commit and publish again.
An empty folder is refused, because publishing one by accident would remove every SOP the agent
relies on. Pass --allow-empty to do it on purpose.
ODXBrain's /info shows a label for the SOPs it's serving. On a host with git, odxdocs sets it to
git:<commit>, plus +dirty for uncommitted changes. The image has no git, so pass the label
in from the host:
ODX_SOP_LABEL="git:$(git -C sop-files rev-parse --short HEAD)$(git -C sop-files status --porcelain -- . | grep -q . && echo +dirty)" \
docker compose run --rm publish
Left unset, the snapshot has no label, and publishing still works. Labels are at most 256 bytes.
docker compose run --rm gui, then open http://127.0.0.1:8091/. It shows:
It has no sign-in, so it only ever listens on 127.0.0.1. No flag changes that. It runs on
host networking so that 127.0.0.1 is the host's own loopback. On a remote server, reach it through
an SSH tunnel:
ssh -L 8091:127.0.0.1:8091 your-server # then open http://127.0.0.1:8091/ on your machine
It refuses requests whose Host header isn't 127.0.0.1, localhost or [::1], and cross-origin
writes, so a web page in the same browser can't drive it.
Start it when you're editing and stop it afterwards. It's a tool, not a service.
GET /v1/sops/{id}/{version}
x-api-key: <ODXDOCS_API_KEY>
id and version exactly as ODXBrain's /v1/sops/resolve returned them.
{"id":"vendor-payment","version":"528c02d3eeef01f2",
"title":"Register a vendor payment","body":"\n## Business rules (Accountant)\n..."}
Cache-Control: immutable, because a version never changes.500), never served.404 for a version never published, 401 without the key. GET /health never needs the key.| Path | Holds | Used by |
|---|---|---|
/store | every published SOP version. Back it up: it's what lets old runs be replayed | serve (read-only), publish, gui |
/sops | the snapshot directory, the same folder ODXBrain mounts at /sops | publish, gui |
/sop-files | the SOP Markdown, ideally a git checkout | publish (read-only), gui (read-write) |
Mount the snapshot directory, not the file. A publish replaces the file by renaming, and a mount of the file itself keeps showing ODXBrain the old one.
| Variable | Used by | Meaning |
|---|---|---|
ODXDOCS_API_KEY | serve | The x-api-key clients must send. Required on any address but loopback, so in a container. Colon-separate several to rotate: new:old |
ODXDOCS_BRAIN_KEY | publish, test, gui | ODXBrain's key, sent to ODXBrain |
Keys come from the environment only, never from flags, which show in ps.
The image's own user is nonroot (65532). serve only reads, so that's fine. publish and the
GUI write files, so run them as the user who owns the host folders (user: in compose,
--user "$(id -u):$(id -g)" with docker run). Then the files stay editable, and backups see the
right owner.
| Command | Notes |
|---|---|
serve --store DIR [--host H] [--port 8090] | The default command, with --host 0.0.0.0 |
publish DIR --out FILE --store DIR [--brain URL] [--label L] [--allow-empty] | |
gui DIR --out FILE --store DIR [--brain URL] [--port 8091] | Loopback only |
check DIR | Offline |
test DIR [--brain URL] | The self-test alone. Exits 1 on a FAIL, handy in CI |
build DIR --out FILE --store DIR [--label L] [--allow-empty] | Writes the snapshot without testing or reloading |
--brain defaults to http://127.0.0.1:8080.
The same binary ships as a tarball, odxdocs-0.1.0-x86_64-linux-musl.tar.gz, with a SHA-256 file.
It's byte-identical to the one in this image. It's fully static, so it runs on any x86-64 Linux
whatever its libc. It suits SOP authors running check and test in CI or on a laptop, and there
it uses the host's git for the label automatically.
odxdocs talks only to
ODXBrain, and the image build fails if a TLS library ever enters its dependencies.serve requires a key on any non-loopback address and won't start without one. The GUI
has no network mode at all: loopback only, in the code.serve runs with --read-only --cap-drop ALL --security-opt no-new-privileges.| Tag | Meaning |
|---|---|
0.1.0 | A release. Pin this in production |
latest | The newest release |
| Symptom | Cause |
|---|---|
refusing to serve SOP bodies on 0.0.0.0 without a key | ODXDOCS_API_KEY isn't set for serve |
Permission denied writing /sops or /store | publish or the GUI isn't running as the folders' owner. Set ODX_UID/ODX_GID |
| A FAIL in the self-test | An intent doesn't lead to its own SOP. Reword it to say what makes this procedure different |
Publish says 409 snapshot_conflict | The snapshot ODXBrain read isn't the one just written. Publish again |
| The GUI starts but the browser can't reach it | Host networking isn't available. On Docker Desktop, enable it under Settings → Resources → Network. On a remote server, use the SSH tunnel |
/info shows no label | The image has no git. Set ODX_SOP_LABEL (see The label) |
Proprietary. Copyright © 2026 TerraKernel Pte Ltd. All rights reserved. Use requires a written
agreement with TerraKernel. The binary is built from open-source Rust crates under MIT, Apache-2.0
and similar licences. Their texts are in /usr/share/doc/odxdocs/THIRD-PARTY-NOTICES.md in the
image, and in the tarball.
Content type
Image
Digest
sha256:baa32f86b…
Size
2.5 MB
Last updated
16 days ago
docker pull terrakernel/odxdocs