Sign inSign up

terrakernel/odxdocs

By terrakernel

•Updated 16 days ago

Your team's Odoo SOPs, checked, published to ODXBrain and served to AI agents by exact version

Image
0

90

terrakernel/odxdocs repository overview

⁠odxdocs

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.


⁠One image, three jobs

JobRuns asWhat it does
servea long-running service (the default command)Hands the agent an SOP's procedure by id and version
publisha one-off commandChecks the SOPs, self-tests them against ODXBrain, writes the snapshot, tells ODXBrain to reload
guia one-off command, while editingA 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).


⁠Requirements

  • linux/amd64.
  • A running ODXBrain for publish, test and the GUI. serve doesn't need it.
  • Docker Compose, for the setup below. Plain docker run works too.

⁠Quick start with Docker Compose

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.


⁠Writing an SOP

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.
  • There's no version field. The version is the first 16 hex characters of the file's SHA-256, so every edit gets a new version automatically. Git keeps the history.

⁠Publishing

docker compose run --rm publish does five things, in order:

  1. Checks every file. One invalid file stops the publish, and every problem is listed at once.

  2. Self-tests against ODXBrain's own model: each intent, held out, must find its own SOP.

    ResultMeaningBlocks the publish?
    PASSevery intent finds its SOP firstno
    WARNevery intent finds it in the top 3, but another SOP comes firstno
    FAILan intent puts its SOP outside the top 3, so an agent asking that way won't see ityes

    There's deliberately no flag to publish a failing set. Reword the intent to say what makes this procedure different.

  3. Keeps every new version in the store, so serve can hand it out.

  4. Writes the snapshot atomically.

  5. 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.

⁠The label: which commit is live

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.


⁠The GUI

docker compose run --rm gui, then open http://127.0.0.1:8091/⁠. It shows:

  • A library of SOPs, each marked Live, Changed or New against what ODXBrain is serving.
  • An editor with live validation, Try it (ask a question, see what the agent would get) and the self-test on the unsaved draft.
  • Publish, through exactly the same steps as the command line.

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.


⁠Serving SOPs to the agent

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..."}
  • There's no "latest". The agent follows the version it resolved. A publish between its question and its fetch can't swap the procedure under it.
  • Every version ever published stays available, so any run can be replayed against exactly the procedure the agent followed.
  • Responses are Cache-Control: immutable, because a version never changes.
  • A stored file that no longer matches its hash is refused (500), never served.
  • 404 for a version never published, 401 without the key. GET /health never needs the key.

⁠Reference

⁠Mounts
PathHoldsUsed by
/storeevery published SOP version. Back it up: it's what lets old runs be replayedserve (read-only), publish, gui
/sopsthe snapshot directory, the same folder ODXBrain mounts at /sopspublish, gui
/sop-filesthe SOP Markdown, ideally a git checkoutpublish (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.

⁠Environment
VariableUsed byMeaning
ODXDOCS_API_KEYserveThe 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_KEYpublish, test, guiODXBrain's key, sent to ODXBrain

Keys come from the environment only, never from flags, which show in ps.

⁠Running as your user

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.

⁠Commands
CommandNotes
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 DIROffline
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.


⁠Without Docker

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.


⁠Security

  • Plain HTTP inside your network, never TLS, never the internet. odxdocs talks only to ODXBrain, and the image build fails if a TLS library ever enters its dependencies.
  • Static and distroless: no shell, no libc, no OpenSSL, no package manager.
  • 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.
  • Hardening works: serve runs with --read-only --cap-drop ALL --security-opt no-new-privileges.
  • Every stored procedure is verified against its hash before it's served.

⁠Tags

TagMeaning
0.1.0A release. Pin this in production
latestThe newest release

⁠Troubleshooting

SymptomCause
refusing to serve SOP bodies on 0.0.0.0 without a keyODXDOCS_API_KEY isn't set for serve
Permission denied writing /sops or /storepublish or the GUI isn't running as the folders' owner. Set ODX_UID/ODX_GID
A FAIL in the self-testAn intent doesn't lead to its own SOP. Reword it to say what makes this procedure different
Publish says 409 snapshot_conflictThe snapshot ODXBrain read isn't the one just written. Publish again
The GUI starts but the browser can't reach itHost networking isn't available. On Docker Desktop, enable it under Settings → Resources → Network. On a remote server, use the SSH tunnel
/info shows no labelThe image has no git. Set ODX_SOP_LABEL (see The label)

⁠License

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.

Tag summary

Content type

Image

Digest

sha256:baa32f86b…

Size

2.5 MB

Last updated

16 days ago

docker pull terrakernel/odxdocs