Architecture
This page explains how omh is put together — what lives where on disk, how its images are built, and how the runtime backend is kept swappable. Read it before changing any of those, or to understand why Docker is the only runtime verified end to end.
In short
- Everything omh keeps lives in
~/.omh— your catalogue, and per-repo, per-session state — while a repo commits only its settings, memory policy and hooks. → On-disk layout - Images come in two layers, tagged by recipe digest: a base every session
shares, which already meets
sbx’s contract, and a thin per-harness layer. → Images - The backend sits behind a narrow seam: a pure
Planin, an argv out, and one place where an argv becomes a process. → Runtime backends - Backends declare what they can do, and a plan that needs more fails loudly instead of starting an agent that cannot see its own catalogue. → Declared capabilities
sbxis measured, built and opt-in. It is the fix for the agent holding its own token, but its full launch has not been accepted, soautonever picks it. → The spike
On-disk layout
Section titled “On-disk layout”~/.omh/ rules/ skills/ commands/ the catalogue — the only place content lives subagents/ hooks/ mcp.json default.toml what a new repo starts from adapters/*.toml one file per harness (data, not code) editors/*.toml one file per editor base/ the curated base set, versioned creds/<harness>/<account>/ captured logins, one directory per account worktrees/<repo>/<session>/ the agent's working directory shadow/<repo>/<session>.git the repository the sandbox gets, and `.seed` beside it keys/<repo>/ per-repo ed25519 keypair run/<repo>/<session>/<harness>/ staged catalogue, regenerated per launch run/<repo>/<session>/port the loopback port that session's sshd is published on memory/<repo>/ the local note store, mounted into every session of the repo
<repo>/.omh/ settings.toml COMMITTED — settings, and `[omh]` settings.local.toml GITIGNORED — overrides, and MCP env memory.toml COMMITTED — how the note store keys and expires hooks/ COMMITTED — the one content kind a repo may declare<repo>/AGENTS.md the project's own rules, trackedTwo details are load-bearing rather than incidental:
- Staging is keyed by session and harness. Without the harness in that path, launching a second harness overwrites the config the first one has mounted, live.
- Worktrees live outside the repo. Nested, your IDE would index every session’s full copy of the codebase.
Where things live is in Configuration.
Images
Section titled “Images”Two layers: a base every session shares, and a thin per-harness layer that
runs the adapter’s install command.
FROM node:22-trixie-slimRUN apt-get install -y … git ripgrep dtach sudo curl jq socatRUN usermod -l agent -d /home/agent -m node # node:slim holds UID 1000RUN test "$(id -u agent)" = "1000" # assert, do not assumeRUN mkdir -p /work /omh/sock /omh/cache /omh/layersUSER agent# omh/<harness>FROM omh/base:<recipe-digest>USER rootRUN <adapter.install>USER agentThe base satisfies the sbx kit contract on purpose — agent at UID 1000,
passwordless sudo, /home/agent, proxy env forwarding. One image works on
either backend, and an sbx kit becomes a two-line file rather than a port.
Three properties, each with a test:
- The contract is asserted at build time, not assumed. If a future base image moves UID 1000, the build fails there instead of failing mysteriously inside a sandbox weeks later.
- Images end unprivileged. Installing needs root; running must not have it. An image that ends as root hands the agent the sandbox’s own escape hatch.
- Dockerfiles arrive on stdin with an empty build context, so nothing is written to disk and no context is uploaded.
Tags are recipe digests, never :latest
Section titled “Tags are recipe digests, never :latest”Harness layers pin an exact base digest, so a recipe change actually propagates.
What :latest cost: base recipe changes that never shipped
A mutable :latest on the base meant ensure skipped rebuilding it, so base
recipe changes had never shipped — adding socat silently did nothing.
init builds these, because init is not finished until omh new <harness>
works. About 30 seconds on first run, cached after.
A plan must be runnable, not merely well-formed
Section titled “A plan must be runnable, not merely well-formed”The network a plan names has to be created, too — one per session, named like its container, so two sessions of one repo cannot reach each other’s services.
That gap made every real launch die at network omh-<repo> not found (the
per-project network of the time) while every unit test passed — the archetypal
case for omh doctor.
Verified end to end
Section titled “Verified end to end”Inside a real container, with the real mounts:
user: agent uid=1000 home: /home/agent cwd: /worktools: claude=ok dtach=ok git=ok rg=ok node=ok rules # tdd / <repo>/AGENTS.md (your catalogue, then the project's) skills graphify, review-diff (your catalogue) mcp codegraph, filesystem, github, linear, omh-memory, sentry subagents explorer.md hooks rust-test, graph-refresh … (translated into Claude's settings) version 2.1.222 (Claude Code)Every capability class arrives, resolved from the catalogue, in the harness’s own format. That is the thesis demonstrated rather than argued.
Runtime backends
Section titled “Runtime backends”Isolation is not ours to build. It is also not one vendor’s to own.
The backend sits behind a narrow trait. Plan is already a pure
description — a mount list, env, and argv — so a backend is a translation of
that into one process invocation.
Plan (pure: mounts, env, argv, workdir) │ ┌─────┴─────┐ ▼ ▼ Docker Sbx shared microVM: own kernel, own dockerd, kernel egress policy, keychain-backed credential injectionSelection is runtime in settings.toml, and only one value is chosen for
you:
runtime |
What it is | Chosen by auto |
|---|---|---|
docker |
the shared-kernel backend above | yes — auto selects only docker |
podman |
rootless, docker’s CLI | no — an explicit opt-in |
sbx |
Docker Sandboxes, rewritten from the measured spike below | no — an explicit opt-in |
omh doctor’s runtime row names whichever backend is in use. When auto finds
no Docker, its error names the sbx opt-in, and omh doctor as the way to
check it first.
Why not simply adopt sbx
Section titled “Why not simply adopt sbx”It is better on every security axis — hypervisor isolation instead of a
shared kernel, and secrets injected at the egress proxy so a compromised agent
never holds its own token. omh currently mounts a credential volume, which
means the agent can read and exfiltrate it. That is a real flaw and sbx’s
model is the fix.
But a distribution whose opinion cannot be escaped is not trustworthy. Backends stay plural.
Is sbx harness-agnostic?
Section titled “Is sbx harness-agnostic?”Yes — verified, not assumed. Docker’s own docs demonstrate building a kit for Amp, a third-party agent they do not ship. The four named agents are shipped kits, not a constraint. A kit is barely more than an omh adapter:
entrypoint: run: [amp, --dangerously-allow-all]Base image requirements are four: a non-root agent user at UID 1000,
passwordless sudo, /home/agent/ as home, and HTTP proxy env forwarding. omh’s
GUEST_HOME is already /home/agent.
An omh adapter can compile to an sbx kit. Agnosticism expressed once.
The seam
Section titled “The seam”From the sbx FAQ:
“Sandboxes don’t import your complete user-level agent configuration. Hooks, settings, and other files under directories such as
~/.clauderemain on the host.”
Docker drew its boundary exactly where omh’s product begins. They isolate;
they deliberately do not carry your setup in. The two compose: sbx
provides the microVM, omh gets your catalogue inside it.
The seam in the code
Section titled “The seam in the code”runtime::Runtime is pure: a Plan in, an argv out, and nothing in it ever
runs a process. runtime::Backend is the one place an argv becomes a
process. runtime::select returns a Backend, and every command that shells
out to the runtime goes through Backend::output.
Only the things that genuinely need a Child take program() and spawn their
own. There are six, and they are named rather than counted:
- an interactive attach and an interactive login, which hand over the terminal;
- a build fed its Dockerfile on stdin;
- two graph indexes, which are
spawned into the background and never waited for; - and the memory server’s cross-build, a docker-shaped
runof its own whose output is relayed line by line while it builds.
A hand-built Command that waits for an answer is the shape to look for in
review; needing a Child is the only thing that justifies one.
Why they are named: this list once said "two" while the tree held seven
They are named rather than counted because this paragraph said “two” while the tree held seven.
The two that did not belong on that list were plain .output() calls — the
graph drop on omh sNN rm, and the daemon round-trip behind omh doctor’s host
row — and each was Backend::real’s body written out a second time. Writing it
out is what put them beyond Backend::scripted, so neither had a test until
they came back through the seam.
That split is what makes the launch path testable on a machine with no
container runtime. Backend::scripted answers each argv from a table and logs
what was asked, so cmd::session’s launch decisions — join the running
container, refuse to touch one the daemon will not describe, refuse to restart
one with a live harness inside, clear a stopped one before run --name — each
have a unit test, where before the seam they had a manual check against Docker
or nothing at all.
Declared capabilities, and honest unknowns
Section titled “Declared capabilities, and honest unknowns”Backends differ in ways that break a naive plan, so each declares what it can do rather than failing mysteriously.
| Capability | Docker | sbx |
Consequence if absent |
|---|---|---|---|
| bind-mount a single file | yes | no (measured 0.39.0) — a workspace must be a directory | staging must write into a workspace dir + symlink, not mount single files |
| choose the guest path | yes | no (measured) — a workspace mounts at its host path | the /work convention breaks; stage + symlink from inside |
| SSH attach for IDE | yes (sshd in image) | no native SSH; exec is the entry, and a published loopback port reaches a service inside (measured) |
an sshd in omh’s template on a published port should work; else exec (#109) |
The unknowns are not hand-waved. A Plan is validated against the selected
backend’s declared capabilities and fails loudly if it needs something the
backend lacks. Running that validation against sbx’s conservative
capabilities, before staging existed, was instructive — every single mount omh
made was rejected. As it printed then, before omh’s errors said omh::
Error: the selected runtime cannot honour this plan: /work would have to mount at its host path /Users/…/worktrees/s01 /home/agent/.claude/skills would have to mount at its host path … /home/agent/.mcp.json is a single-file mount …That is the real blast radius. If sbx genuinely forces host-path mounts,
the staging model needs rework rather than a tweak: staged content would have to
be written into the workspace and symlinked from inside, not mounted onto chosen
guest paths.
Better to know that from a failing validation than from an agent that starts fine and cannot see its own catalogue.
Resolving it was planned as a one-afternoon spike — build an opencode kit, try a
single-file mount, try attaching an IDE. The spike below answered it: sbx
does force host-path mounts and directory-only workspaces, so staging was
reworked into stage-and-symlink, and Plan::validate_for skips the native-mount
refusal for a backend that stages. What still gates whether sbx becomes the
default or stays opt-in hardening is the full launch. See
roadmap.
Spike: sbx 0.39.0, measured 2026-09-06
Section titled “Spike: sbx 0.39.0, measured 2026-09-06”Built and tested; not yet accepted. Verified live against sbx 0.39.0: the
OMH_LINKS entrypoint symlinks /work and a root-owned path (through the sudo
fallback) and reads the host content through them; docker save | sbx template load lists the image and the parser recognises it.
The remaining acceptance is a full omh new/omh doctor launch, which
rebuilds the base image once (the entrypoint changed). auto still never
selects sbx — it stays an explicit opt-in behind the login and account gate.
Implemented (2026-09-06). All four mismatches the spike found are now built and tested:
- Delivery is
provide/provide_to_sbxinimage: build with docker,docker save -oto a tar,sbx template loadit, idempotent throughSbx::template_hasreadingsbx template ls --json. The measured store row is registry-prefixed (docker.io/omh/base), which the parser matches on a path boundary. - No labels — the stamp is recorded in
runs/<id>/stamp.jsonat create and read back bystamp_recorded;Runtime::carries_labelsis the seam, and the reuse semantics (attach on no drift, restart on drift) are identical to the label path. The fuller option won over recreate-each-launch, so a live sbx agent is not killed on the nextresume. - Stage-and-symlink — every guest path travels in
OMH_LINKS(guest hostper line) and the entrypoint symlinks each,sudofor a root-owned parent.sbx_stagingturns mounts into host-path workspaces: a file mount stages its parent directory, a docker named volume (the graph cache) is dropped because sbx home persists across stop/run, and a workspace nested in another is dropped as redundant.Plan::validate_forskips the native-mount refusal for a staging backend. - Running check and
remove/networkare the runtime’s own:running_namesparses the JSON by status,remove_argsisrm … --force, andensure_networkis a no-op because sbx isolates in a microVM.
The measurements behind it, in the order they were taken:
Where the spike started: the docker sandbox plugin is gone
The section above was written against the docker sandbox Desktop plugin, which
is removed (docker sandbox now prints a migration notice). Docker
Sandboxes ships as a standalone CLI, sbx, installed with
brew trust docker/tap && brew install docker/tap/sbx.
This spike measured sbx v0.39.0 on macOS 26 (Apple Silicon). What runs a
sandbox needs sbx login (interactive Docker OAuth) and sbx daemon start;
the CLI surface below was read without a login, and the runtime behaviours it
raises are marked as still needing one.
Measured from the CLI, without a login — and the model it suggested
Measured from the CLI (no login required):
- Agent-aware, not command-aware.
sbx create AGENT PATH [PATH...]andsbx run AGENT …take a named agent —claude,codex,copilot,cursor,gemini,opencode,shell, and others — not an arbitrary argv.shellis the bare sandbox omh would build on. - Workspaces are positional,
:rofor read-only, and extra workspaces are extra arguments (sbx create claude . /docs:ro). The guest path is not chosen — this confirmsfree_guest_paths: false. -t, --template IMAGEruns a chosen container image, andsbx template save|load|ls|rmmanages them (sbx template loadreads a tar). This is the enabler the old design lacked: omh can hand sbx its own base image — the one that already carries sshd, the memory server and the graph cache — rather than generating a kit. It removes most of the kit machinery the plan sketched.-p, --publish [[HOST_IP:]HOST_PORT:]SANDBOX_PORT[/PROTOCOL]oncreate/run, andsbx ports SANDBOX --publish …on a running one. Loopback by default (127.0.0.1), ephemeral host port when omitted — the same loopback-only posture omh’s Dockerup_argskeep by hand.sbx exec [flags] SANDBOX COMMANDmirrorsdocker execexactly:-it,-d,-u root,-e/--env. omh’sexec_argsmaps onto it with only the program name changed.-e/--envand--env-filecarry environment in, soOMH_SESSIONandOMH_GRAPH_PROJECTride along;-m/--memoryand--cpusare the limits omh’ssandbox_memory/sandbox_cpusalready express.- Egress and credentials are first-class.
--deny-network,--profileandsbx policygovern egress;sbx secret set|importstores service secrets (github/anthropic/openai) that a proxy injects into API requests so the secret never enters the sandbox filesystem — the exact “a compromised agent never holds its own token” property this section wants, delivered by sbx rather than built by omh. Registry secrets (sbx secret set --registry) pull a private--templateimage without the credential entering the sandbox. --cloneruns the agent on an in-container git clone of the host repo (mounted read-only), with the agent’s commits reachable through asandbox-<name>git remote on the host. This is a second, sbx-native answer to the isolation omh gets from a worktree plus harvest, and worth weighing against omh’s own model rather than layering on top of it.- Lifecycle verbs are all present:
ls,stop,rm,prune,cp,reset,diagnose.sbx diagnoseruns without a login and confirmed virtualization support and the daemon socket path.
The candidate omh-on-sbx model, under these answers:
sbx template loadomh’s base-image tar once, thensbx create shell -t omh/base:<tag> -p <port>:22 -e OMH_SESSION=sNN <worktree>— omh’s own image, its own sshd, the worktree at its host path.- Credentials for an API-key login go through
sbx secret set; an OAuth login, which is a file, rides a read-only host-path workspace as the weaker option, documented as such. - Egress is a
sbx policy/--deny-networkset seeded from the harness’s known hosts plus asandbox_egresssetting. - The catalogue — skills, MCP, rules — is staged into the worktree and symlinked from inside at startup, because the guest path cannot be chosen.
Measured with a login, sbx 0.39.0 (2026-09-06)
- A workspace mounts at its exact host path —
sbx create shell <dir>puts the files at<dir>inside the sandbox,pwdand all. The guest path is not chosen (free_guest_paths: false). - A workspace must be a directory — a single-file path is refused with
“workspace path exists but is not a directory” (
file_mounts: false). So omh’s per-file mounts (rules,.mcp.json) cannot be workspaces; they must be staged into a directory and symlinked from inside. - The default sandbox already presents
agentat UID 1000, passwordless sudo, and/home/agent— omh’s base-image contract, met out of the box. - A published loopback port reaches a service inside.
sbx ports NAME --publish 8099bound127.0.0.1:<ephemeral>, and a listener on 8099 inside answered on the host — the same loopback bridge omh’s Dockerup_argsbuild, so publishing sshd’s 22 works the same way. - The home directory persists across
stop/run— a file written to~/survived a stop and the next exec’s auto-start. -e/--envcarries in —OMH_SESSION=s01read back inside.--template <image>runs a custom image as-is — plaindebiancame up as root with no sudo, so sbx does not add the agent/sudo layer; the image provides it. omh’s base does, so it is a valid template: the omh-on-sbx model issbx create shell -t omh/base:<tag> -p <port>:22 <worktree>.- First-run prerequisites, beyond install:
sbx login(a Docker account),sbx daemon start, andsbx policy init <allow-all|balanced|deny-all>before the firstcreate. These are whatomh doctorshould detect and hand the user the exact next command for, rather than letting sbx’s own errors surface.
What remained at that point was the end-to-end: build omh’s base,
sbx template load it, and run omh doctor --harness claude with
runtime = "sbx" — the acceptance test. The design was no longer guessed;
Sbx in runtime.rs still needed its argv rewritten to
create/exec/ports and Plan::validate taught the stage-and-symlink model,
and until that shipped auto would never select it.
Why the rewrite was architectural: four mismatches, each verified with a throwaway image
The rewrite is architectural, not an argv swap (measured 2026-09-06).
Trying to map omh’s Docker-shaped Sbx onto the real CLI surfaced four
mismatches that are the actual work, each verified end to end with a throwaway
image loaded into sbx:
- The image reaches sbx by
docker save | sbx template load. omh buildsomh/base:<tag>in Docker; sbx runs in its own runtime and takes an image with-t.sbx template load <tar>loaded a locally-built image, andsbx create shell -t <it>ran its ENTRYPOINT — the stub wrote its marker andsleep infinitywas PID 1, so an sshd started before that exec would persist. So omh’s launch gains a save-and-load step beforecreate. - sbx has no labels.
create/runtake no--label, so omh’sPlan::labelsdrift-and-reuse — read back withcontainer_stampto decide attach-vs-restart — has no equivalent. A first cut recreates the sandbox each launch (rmthencreate); a fuller one records the plan stamp underruns/<id>and compares there. - The workspace is at its host path, and
/workmust be a symlink.-e OMH_WORKTREE=<host path>carries the path in (env carrying is confirmed), and the image entrypoint doesln -s "$OMH_WORKTREE" /work. The catalogue’s single-file and chosen-guest-path mounts (rules,.mcp.json, skills) cannot be mounts at all — they stage into the worktree (or a second:roworkspace) and are symlinked from the entrypoint, which is thestage-and-symlinkmodelPlan::validatemust express for a backend withfile_mounts: false, free_guest_paths: false. - The running check is
sbx ls --json, filtered by status.sbx ls -qlists all sandboxes, running or stopped, so it cannot stand in for Docker’sps --format {{.Names}}(running only) — a stopped sandbox would read as up.--jsonreturns{"sandboxes":[…]}with astatus; the sbx path filtersstatus == "running".sbx rm --force,sbx stopmap cleanly;sbx exec [-it] NAME ARGVis the harness entry.
So the Sbx rewrite touches runtime.rs (argv + a JSON running-check), the
base image (the symlink entrypoint, one rebuild), image (save-and-load
delivery), container/Plan::validate (stage-and-symlink, and reuse without
labels), and its acceptance is a real launch: omh doctor --harness claude with
runtime = "sbx". The design is fully measured; none of it is guessed.
- Sessions
What runs in the container, and what the agent inside it can reach. - The
sbxruntime
Setting it up as an opt-in, and what it changes. - Risks
The credential and egress gapssbxwould close. - Roadmap
What decides whethersbxbecomes the default.