Adapters
Add a harness or an editor to omh with one TOML file. A harness is data, not a code change.
Status: built. Four adapters ship — claude, codex, opencode and omp.
Only claude has been driven for real work; the other three pass omh doctor,
which proves their paths and nothing about their behaviour.
Adding a harness
Section titled “Adding a harness”-
Write
~/.omh/adapters/<name>.toml.Declare only what the harness genuinely supports: an absent key means it cannot do that thing. Every field is in Adapter fields below, and the four bundled adapters are worked examples.
-
Check it inside a real sandbox.
Terminal window $ omh doctor --harness <name> -
Fix whatever it says, and run it again.
Step 2 is not optional, and it is not a formality. Adapter facts are unverified claims about external software that ships weekly, and they break silently — a wrong path means the harness starts fine and simply never sees your catalogue. A green unit suite proves omh mounted a path faithfully. It cannot prove anything read it. See Troubleshooting.
The bar for shipping one
Section titled “The bar for shipping one”All four bundled adapters are verified by doctor against a real container,
hooks included on every one. Any fifth adapter inherits the same bar.
- Passing
doctoris not being proven. It means the paths are right, not that the harness has been driven for real work — onlyclaudehas. That distinction is deliberate, is repeated wherever the claim appears, and applies toopencode,ompandcodexin the same words. - The check count is not an adapter fact. It varies with the capabilities your catalogue declares and with whether a login has been captured for that harness.
How each adapter's hooks are checked, and omp's one weaker claim
claude’s and codex’s hooks are configuration files, which doctor expects
to find and to find non-empty. opencode’s and omp’s are generated plugins rather
than config files, so they are checked by node --check inside the sandbox
rather than by existing.
omp carries one claim weaker than the others, and it is written here rather
than left in a comment: its token-probe greps omp usage --json for
accountId, and that field name was read out of oh-my-pi’s source rather than
observed in the output of a logged-in run. doctor reports the probe green only
against an account that actually logged in, so an adapter shipped without one
has had its shape checked and not its answer. The other three adapters have
no equivalent gap, because a token file either holds bytes or does not.
Adding an editor
Section titled “Adding an editor”Same principle, smaller file. Write ~/.omh/editors/<name>.toml:
name = "zed"bin = "zed"args = ["$URL"]| Field | What it says | Default |
|---|---|---|
name |
the word omh sNN attach <editor> takes: letters, digits, - and _ |
required |
bin |
the executable on your host — an editor is not installed in the sandbox | required |
args |
its arguments; $URL becomes ssh://<alias>/work and $ALIAS the session’s SSH alias |
required |
Editors attach from outside over SSH rather than running inside the sandbox, so they have no capabilities to declare. See Editors.
Adapter fields
Section titled “Adapter fields”The claude adapter, shortened:
name = "claude"bin = "claude"version = "2.1.316"install = "npm install -g @anthropic-ai/claude-code@{{version}}"creds = ["$HOME/.claude/", "$HOME/.claude.json"]token = ["$HOME/.claude/.credentials.json"]
[capabilities.rules]path = "/work/CLAUDE.md"also = ["/work/AGENTS.md"]render = "concat"
[capabilities.mcp]path = "/work/.mcp.json" # project-scoped; this harness reads no otherrender = "mcp-json"import = "$REPO/.mcp.json" # host-side, for `omh settings mcp import`verify = "claude mcp list" # what `omh doctor` asks the harness itselfready = "Connected" # what that answer calls a server it loadedThe harness
Section titled “The harness”| Field | What it says | Default |
|---|---|---|
name |
the harness’s name, as omh new, omh account login and omh doctor --harness take it. Letters, digits, - and _ only — it is joined into paths |
required |
bin |
the executable to run inside the sandbox | required |
install |
the command that installs bin into the image; it may interpolate the pin as {{version}} |
required |
version |
the harness version this adapter pins. install must interpolate it |
no pin |
checksums |
SHA-256 sums keyed by uname -m (x86_64, aarch64), for a harness delivered as a release binary rather than an npm package. Each must appear in install, which checks it |
none |
creds |
the paths holding credentials, captured by omh account login and remounted on every launch. A trailing / declares a directory |
none |
token |
the file(s) whose contents prove a login — see below | none |
[token-probe] |
run and ready: ask the harness whether it is logged in, where no file can say |
none |
login |
what you have to do once the harness opens, to log in | none |
mask |
paths strictly inside one of creds’ directories, with no .., that a session could write and the harness reads at its next start as the user’s own — instructions, rules, skills, code modules — so one session could leave them for every repo on the account. A file is hidden behind an empty read-only file; a directory, ending in /, behind an empty read-only directory holding only what omh mounts into it. Never a capability’s path, a [policy] mask, or inside a masked directory. A capability whose path is in the account is masked the same way when the catalogue carries nothing for it. Claude Code’s CLAUDE.md and rules/; Codex’s AGENTS.md, AGENTS.override.md and skills/; omp’s AGENTS.md, RULES.md, SYSTEM.md, hooks/pre/, hooks/post/, tools/ and extensions/ — each measured in tests/e2e |
none |
transcripts |
where the harness writes its transcripts. omh mounts a per-session directory over it, so omh sNN can read what the agent did and what it cost |
none, and omh says not recorded rather than showing an empty session |
[tools] |
how the harness names edit, read, shell and search — see Hooks |
none |
[capabilities.<class>] |
one binding per capability the harness can express | none |
[policy] |
the harness’s own policy file, which omh fills on every launch — see below | none |
creds names a directory rather than the token file because a bind-mounted
file cannot be replaced by rename, and every tool saves its token by writing a
temporary file and renaming it over.
A capability binding
Section titled “A capability binding”| Field | What it says | Default |
|---|---|---|
path |
the guest path the harness reads; $HOME expands per image |
required |
also |
more guest paths pointed at the same content | none |
render |
how omh produces what the harness reads — see Renderers | required |
import |
the host-side path omh import and omh settings mcp import read from: where you keep it, not where the container reads |
none — omh import then needs --from |
verify |
the harness’s own command for listing what it loaded, run by omh doctor inside the sandbox |
not asked |
ready |
the word verify’s output uses for a server the harness took up |
not asked |
flag |
the option the harness is handed path by, for a document it reads only when told to; path is then a file under /etc/. Only claude-settings — see Claude’s settings document |
none: the harness finds path itself |
eject |
with flag, the file under $HOME the harness reads on its own, where omh eject writes the document |
required with flag, refused without |
events, fields, fields-by-tool, inject, refuse |
hooks only — see Hooks |
none |
Claude’s settings document
Section titled “Claude’s settings document”Claude Code reads omh’s settings — its hooks, the approval of omh’s MCP
document and of omh’s own servers’ tools, disableAllHooks: false and
attribution: false — from --settings, never from ~/.claude/settings.json,
which is each session’s own preferences file:
[capabilities.hooks]path = "/etc/omh/claude/settings.json"render = "claude-settings"flag = "--settings"eject = "$HOME/.claude/settings.json"The image puts a wrapper where claude was on PATH, so every claude in the
sandbox is handed the document — omh’s launches, omh doctor’s claude mcp list, one run by hand in an editor’s terminal, a nested claude -p — ahead of
any subcommand, since claude mcp list --settings <f> is an unknown option.
--settings sits above user, project and local settings, so a session’s
disableAllHooks: true or attribution text in the worktree’s
.claude/settings.local.json changes nothing (tests/e2e). Of two
--settings, Claude keeps the last whole, so one you pass — a file,
--settings=<f> or inline JSON — is merged into omh’s instead, with jq’s *:
omh’s keys win, the rest of yours is kept (tests/e2e). One that does not
parse stops the run: omh could not merge your --settings <value> into its own. Calling the real binary by its absolute path skips the wrapper. Where
the document is not there — the image run by something other than omh, or on
sbx, where omh’s entrypoint never runs (#199) — the wrapper runs Claude
handed exactly what it was, as it ran there before.
attribution: false hides the attribution Claude adds to the commits and pull
requests it writes — text it puts before the model at every start, which a
file a session writes could otherwise set. It needs Claude Code 2.1.281 or
later; an older one skips the whole file, hooks included, and a test holds the
pin above it.
[policy]: what a session may load
Section titled “[policy]: what a session may load”[policy]path = "/etc/codex/requirements.toml"format = "codex-requirements"mask = ["$HOME/.codex/cloud-config-bundle-cache.json"]The account directory is writable and shared by every session on it, so
anything a session adds there — an MCP server through codex mcp add, a hook
— would load in every later session (Claude Code’s ~/.claude.json is each
session’s own copy; see copy). A
harness’s policy layer is read from /etc, which omh fills from the launch
with a read-only file, and nothing a session writes there outlives its
container:
| Field | What it says | Default |
|---|---|---|
path |
the guest path the harness reads its policy from: a file under /etc/, absolute, with no .., not under a creds or transcripts path and not a capability’s own path — a literal, never expanded |
required |
format |
codex-requirements or claude-managed |
required |
mask |
guest paths strictly inside one of creds’ directories, with no .., never a capability’s own path, that a session could write and the harness would trust on its next start; each is hidden behind an empty read-only file |
none |
copy |
creds file entries each session gets its own copy of, seeded from the account’s without what names, switches or grants a server, and never written back — see ~/.claude.json is each session’s own. Only a file its format knows how to seed: $HOME/.claude.json, for claude-managed |
none |
The policy admits exactly the MCP servers the launch staged, by command and
arguments compared exactly — and by name too, on Codex. Neither harness
compares env: on Claude Code each session’s own ~/.claude.json (copy
above), and on Codex each session’s own config.toml
([preferences]), keep such an entry
from reaching another session. It is written even when
nothing is staged, because a harness with no allowlist admits anything. On
Codex, what the policy stops in your checkout or account — a repo’s own
.codex/config.toml servers or hooks, an enterprise bundle cache — is named at
launch and by omh doctor. omh eject writes the policy itself under
system/.
- codex —
requirements.toml: the[mcp_servers]allowlist, an entry that holds every plugin’s servers to a list of their own — empty for any plugin not named —allow_managed_hooks_only(omh’s hooks are in the system layer and still run; a repo’s.codex/config.tomlhooks do not, by Codex’s source), and a pinnedchatgpt_base_url. An enterprise account’s cloud bundle cache is masked, so Codex fetches the bundle at every start — and, by Codex’s source, offline it does not start; no enterprise account has been run. The account’s model provider,provider.tomlat the account’s root, is rendered into the system layer beside omh’s hooks, held tomodel_providerand the fields of[model_providers.*]— declared by[provider], not here; see A model provider of your own. Codex uses it from there (tests/e2e: every codex turn’s provider comes from it); a session’s ownconfig.toml, which ranks above the system layer (Codex’s source), holds no provider to override it. - claude — a
managed-settings.ddrop-in:allowManagedMcpServersOnly,allowedMcpServers, anddisableClaudeAiConnectors. claude.ai connectors are switched off in a session (read in the 2.1.300 binary; not measured): one is matched by its URL, and allowlisting that URL would let a session put its own server there. An organisation’s own managed list replaces omh’s (Claude Code’s docs; not measured)..config.json, in the account’s shared~/.claude/, is masked: Claude reads it instead of~/.claude.jsonwhen it is not empty.settings.jsonbeside it, user settings, is each session’s own preferences file; omh’s own settings reach Claude another way: Claude’s settings document.
[preferences]: what a session keeps
Section titled “[preferences]: what a session keeps”[preferences]mount = "link"
[preferences.files]"$HOME/.codex/config.toml" = ["model", "model_reasoning_effort", "tui.theme", …]
[preferences.quiet]"$HOME/.codex/config.toml" = ["notice.hide_rate_limit_model_nudge", …][preferences]mount = "file"
[preferences.files]"$HOME/.claude/settings.json" = ["model", "advisorModel", "modelSettings", …]"$HOME/.claude.json" = ["copyOnSelect", "respectGitignore", …][preferences]mount = "link"
[preferences.files]"$HOME/.omp/agent/config.yml" = ["modelRoles", "defaultThinkingLevel", "theme.dark", …]
[preferences.fallback]"$HOME/.omp/agent/config.yml" = ["$HOME/.omp/agent/config.yaml"][preferences]mount = "dir"root = "$HOME/.local/state/opencode"
[preferences.files]"model.json" = ["recent", "favorite", "variant"]"kv.json" = ["theme", "theme_mode", "thinking_mode", …]A harness’s own config file sits in the account, and a session writes it. The keys named here are the user’s preferences: the account keeps them, at its root where no mount reaches, and each session starts from a copy of them. What a session changes among them reaches the account through the review; everything else it writes there lasts that session — see your preferences.
| Field | What it says | Default |
|---|---|---|
mount |
how a session’s copy reaches the harness. link: the account’s file is a link to the same name under /omh/prefs, which each container mounts from its session’s own copy — for a harness that saves by renaming over the file, which a mounted file refuses, and resolves the link first. file: the session’s copy is mounted on its own where the harness reads it — for a harness that, its rename refused, writes the file in place. dir: the session’s copy of a whole directory is mounted at root — for preferences kept in a directory of the harness’s own state that no account holds, opencode’s; what else the harness keeps there is the session’s |
required |
root |
for dir, and only there: the directory the session’s copy is mounted at, under $HOME, with no .., and neither inside nor around one of creds’ paths |
none |
files |
each file and its keys that are preferences, as dotted keys (tui.theme, notice."a.b"), a table declared whole by its name. A file is TOML, JSON or YAML, by its extension; has no ..; is named unlike every other, since each is kept and copied by its name alone; and is never a [policy] mask entry. For link, it is strictly inside one of creds’ directories, where the link is laid; for file, one of creds’ files or inside one of its directories; for dir, a file name in root. A file preferences file that is a [policy] copy too is one copy: the account file’s rest, with the session’s preferences |
required |
quiet |
keys of the same files the harness writes on its own — a notice dismissed — kept without asking, and named when kept. rm --yes keeps none, and a value whose type changed is kept nowhere. A key whose value names a model is not bookkeeping, and belongs in files |
none |
fallback |
for a link file, the files the harness reads in its place while it is absent, in the order it tries them — omp’s config.yaml for its config.yml. Before the link, the account’s preferences are migrated from the first one there, which then leaves the account; after it, the harness never reads one again, and nothing in one is kept. Each is beside the file it stands in for, TOML, JSON or YAML, and neither that file nor another preferences file |
none |
The rule a key is judged by: a preference never names a program or a
capability, never grants a consent, a trust or a permission, never carries
text the harness puts in front of the model, and never picks where traffic
goes. omh holds a list of what that rules out per harness — for codex
mcp_servers, notify, model_provider, model_providers, profiles,
features, developer_instructions, projects, hooks, approval_policy,
sandbox_mode, notice.hide_full_access_warning and their kin; for claude
hooks, disableAllHooks, permissions, enabledPlugins,
extraKnownMarketplaces, mcpServers, statusLine, apiKeyHelper, env,
attribution, outputStyle, projects and their kin; for omp
extensions, mcp, tools, bash, skills, personality,
contextFiles, auth, providers, enabledProviders, disabledProviders,
modelProviderOrder, retry and their kin; for opencode plugin_enabled
and share_consent — and an adapter
declaring a key at, inside or around one of them is refused when it loads.
Every list is explicit: a key no list names is not kept.
[provider]: the account’s model provider
Section titled “[provider]: the account’s model provider”[provider]format = "omp-models"path = "$HOME/.omp/agent/models.yml"import = "$HOME/.omp/agent/models.yml"drop = [ "$HOME/.omp/agent/models.db", "$HOME/.omp/agent/models.db-wal", "$HOME/.omp/agent/models.db-shm",]A custom model provider is the user’s, and the file a harness keeps it in is one every session on the account can rewrite. The account keeps it instead, in a provider file at its root in the harness’s own schema, and each launch renders it where the session cannot write — see A model provider of your own.
| Field | What it says | Default |
|---|---|---|
format |
the provider file’s schema, and where it is rendered. codex-config: model_provider and [model_providers.*], kept in provider.toml and rendered into Codex’s system layer beside omh’s hooks. omp-models: omp’s models.yml, kept in provider.yml and rendered over path |
required |
import |
the host file, under $HOME, that omh account login --import reads the harness’s own provider from |
required |
path |
for omp-models, the file the harness reads providers from, strictly inside one of creds’ directories, with no ..: the render is mounted over it read-only, {} when the account has none. codex-config takes none |
— |
drop |
files in the account, each strictly inside one of creds’ directories, that can carry a provider’s address into the next session and that the harness rebuilds: removed before each launch |
none |
What omh refuses when it loads an adapter
Section titled “What omh refuses when it loads an adapter”omh enforces each of these, with an error naming the adapter:
- a key it does not know — see Unknown fields are rejected
- a
namethat is anything but letters, digits,-and_ - a
versionthatinstallnever interpolates, or a{{version}}with noversionto fill it - an
installthat runssha256sum -cwhile declaring nochecksums, or a declared suminstallnever checks - an
installthat spans lines: it is oneRUN, and each line after the first would be a Dockerfile instruction of its own tokenandtoken-probetogether- no capabilities at all — it would launch a harness that can see none of your catalogue
hookswith no[tools],[tools]with nohooks,hookswith noevents, and a hook map on any capability buthooks- a
[provider]pathordropentry not strictly inside acredsdirectory, or with a..; anomp-modelswith nopathor acodex-configwith one; animportoutside$HOME - a
[policy]maskentry that is a capability’s own path — two mounts at one path - a
maskentry not strictly inside acredsdirectory, with a.., inside a masked directory, a[policy]mask too, or a capability’s own path - a
[preferences]file outside where itsmountputs it, in a format other than TOML, JSON or YAML, with a.., masked, or named like another; a key that is not a dotted key, declared twice, inside another, or at, inside or around one the harness’s never-list names; afallbackfor a file that is not declared, for afilemount, outside a credential directory, not beside its file, in another format, or that is a preferences file itself; adirmount with noroot, arootfor any other mount, outside the home, with a.., or inside or around a credential path, and adirfile that is not a name - a
flagthat is not a long option (--and letters, digits, dashes), on anything but aclaude-settingsrender, with apathnot under/etc/, or with noeject; anejectoutside$HOME, with a.., or without aflag
One rule is yours to keep: declare verify and ready together. doctor
runs the check only when both are set, and skips half a pair without a word.
Why a pin and a checksum are checked as the adapter loads
A pin can be inert two ways, and both compile and both ship an image nobody
meant: a version the command has no {{version}} to receive installs the
latest release while claiming a pin, and a {{version}} with nothing to fill it
installs a package literally called {{version}}.
A checksum verified but never declared is a check against nothing: the install
would sha256sum -c a file it also wrote, or fail on a sum that is not there.
If the command checks one, the field carries it — which is where omh doctor
reads it too.
The name is checked because it is a path: omh resolves a harness by joining the
word into <dir>/<word>.toml, and keys the credential directory on it. A name
of .. would name a file outside the catalogue, whose install becomes a line
of a host docker build.
Capabilities are optional keys
Section titled “Capabilities are optional keys”The catalogue carries the superset of what any harness might express. An
adapter declares which parts its harness can actually express, and where. The
capability classes are rules, skills, mcp, commands, subagents and
hooks.
An absent key means the harness cannot do that thing. Degradation is a missing map entry, not special-case logic — which is why adding a harness needs no branch anywhere in the codebase.
It is announced once, at launch, rather than silently swallowed:
$ omh new codexcodex on omh/s02 — dropped 0 commands, 0 subagents (unsupported); dropped hooks: graph-first (no `search` tool), graph-read (no `read` tool)Unknown fields are rejected
Section titled “Unknown fields are rejected”Adapters parse with deny_unknown_fields. Without it, a stale adapter parses
cleanly with zero capabilities and every harness silently degrades to
nothing — the worst possible failure for a tool whose entire promise is that
your setup is already there.
token or token-probe, never both
Section titled “token or token-probe, never both”Declare one way to prove a login. omh refuses an adapter that declares both, at load.
| Declare | When | It passes when |
|---|---|---|
token |
the harness writes its login to a file omh can stat | the file holds bytes |
[token-probe] |
the credentials are somewhere omh cannot read — omp keeps them in SQLite | run succeeds inside the sandbox and its output names ready |
[token-probe]run = "omp usage --json" # asked inside the sandbox, where the credentials areready = "accountId" # what that answer says when the login is realomh doctor settles a probe, not omh account login. omh account login tears the container
down before it can ask, so on such a harness it records the account and says the
login is unconfirmed.
Why a file beats a probe, and why declaring both is refused
token is the better answer wherever it applies, because a stat needs no
container and cannot be wrong about what it saw.
Some harnesses keep credentials somewhere omh cannot read. There the only file that could be named is created by the harness starting, so declaring it would report a login for every session that merely opened the tool. Such an adapter declares a probe instead.
The pair is refused at load, not resolved by precedence: an adapter declaring
both leaves omh with two answers to one question and no rule for which wins. It
is the same shape as verify/ready one level up — when the claim is about
software omh did not write, ask that software — and the same rule applies to the
answer: the probe passes only when the command succeeds and its output names
ready, because a harness that errors out will happily print the marker inside
its error text.
Renderers
Section titled “Renderers”| Render | Used by | Effect |
|---|---|---|
dir |
skills, commands, subagents | link the catalogue’s entries by name, mount read-only |
concat |
rules | join the sections into one document, staged and mounted read-only |
mcp-json, codex-toml, opencode-json |
mcp | reshape the canonical server list |
claude-settings |
hooks | reshape the canonical hook list |
codex-hooks |
hooks | write Codex’s [[hooks.<Event>]] tables into its system layer, /etc/codex/config.toml, which Codex runs as managed — with the account’s model provider beside them, when it has one |
opencode-plugin, omp-plugin |
hooks | generate a JavaScript module |
Nothing is written into the worktree. concat, and any path under /work
whichever renderer produced it, is mounted: omh places the mountpoint and binds
over it, so a project’s own file is hidden for the session and returned
untouched.
Every renderer must round-trip through its parser. omh settings mcp import
is the exact inverse of rendering, so a format that renders but parses lossily
means import silently drops fields. That is a test, not a hope.
Why the two plugin renders are separate, and why concat stopped writing to the worktree
Two of the renders emit a program, and they are separate renders rather than
one parameterised by the harness. They agree on the shell bridge — how a hook
body runs, how a failed predicate is reported, how $OMH_* expands — and on
nothing else: opencode registers one object of named handlers and reads a call’s
arguments off output or input depending on the moment, while omp gives each
hook its own pi.on(...) registration and hands every handler an event.
Sharing the generator would mean a match on the harness in every line of it.
concat once wrote into the worktree. That made omh’s staging
indistinguishable from the agent’s work, and carried omh’s rules into users’
pull requests.
verify and ready: asking the harness
Section titled “verify and ready: asking the harness”verify is the harness’s own command for listing what it loaded; ready is the
word its output uses for a server the harness has taken up. omh doctor runs
the one and greps for the other, on the same line as the server’s name.
| Harness | verify |
ready |
What a pass proves |
|---|---|---|---|
claude |
claude mcp list |
Connected |
the server is running |
codex |
codex mcp list |
enabled |
Codex’s parsed config holds the server — the file was read, not that the server starts |
opencode, omp |
— | — | not asked |
That is as much as each harness can say. The pass line names the word, so the two do not read as the same fact.
Both are optional. An adapter that declares neither is simply not asked — the same degradation as any absent key.
Why a name in the list is not enough, and what an unasked path cost
A path is a claim about software omh did not write, and the suite cannot check
it. What leaving it unasked costs is on record: the claude binding pointed
mcp at $HOME/.mcp.json, which nothing reads, and every check omh had stayed
green while no session ever loaded a server.
Matching the name alone is not enough, and that is the point of ready. A
project-scoped document Claude Code has not been told to trust is listed in full
and loaded not at all, so a name-only check passes in exactly the state the
feature is broken in.
Hooks: three maps and a template
Section titled “Hooks: three maps and a template”A hook is authored in omh’s vocabulary and translated when it is staged, so the
adapter is where a harness’s spelling lives — the same rule as every path.
# Adapter-level: hooks match on these, and the Agent Skills standard and# subagent frontmatter both carry harness tool names too. One vocabulary.# No `search`: Claude Code's native builds reach grep and glob through `Bash`.[tools]edit = "Edit|Write|MultiEdit"read = "Read"shell = "Bash"
[capabilities.hooks]path = "/etc/omh/claude/settings.json"render = "claude-settings"flag = "--settings"eject = "$HOME/.claude/settings.json"
[capabilities.hooks.events] # omh's moments → this harness's namessession-start = "SessionStart"turn-end = "Stop"before-tool = "PreToolUse"after-tool = "PostToolUse"
[capabilities.hooks.fields] # where this harness keeps each fieldtool-file = ".tool_input.file_path"tool-command = ".tool_input.command"
[capabilities.hooks.inject] # how it accepts advisory text for the agenttemplate = """jq -nc --arg m {{text}} '{"hookSpecificOutput":{"hookEventName":"{{event}}","additionalContext":$m}}'"""
[capabilities.hooks.refuse] # and how it blocks a call, with a reasontemplate = """jq -nc --arg m {{text}} '{"hookSpecificOutput":{"hookEventName":"{{event}}","permissionDecision":"deny","permissionDecisionReason":$m}}'"""| Key | Maps |
|---|---|
[tools] |
omh’s tool words — edit, read, shell, search — to the harness’s tool names. Adapter-level |
events |
omh’s moments — session-start, turn-end, before-tool, after-tool — to the harness’s names. Required on a hooks binding |
fields |
where the harness keeps tool-file and tool-command, in the renderer’s own language |
fields-by-tool.<tool> |
a field that lives somewhere else on one tool: a complete expression, used verbatim. A tool with no entry falls back to fields |
inject |
the template for advisory text the agent reads; the call goes ahead |
refuse |
the template that blocks a call, with a reason |
In a template, {{text}} receives a shell word and {{event}} the harness’s own
word for the moment — some protocols name the event back in the payload.
An absent entry means this harness has no such thing — the capability map’s
rule, one level down. The hooks wanting it are dropped by name, saying what
they asked for, and the rest still ship: a harness with turn-end and no
before-tool keeps most of them, and a count per capability would report that
as “hooks: 0”.
fields is read in the renderer’s own language. Claude Code’s and Codex’s
are jq paths, because they hand a hook its payload on stdin; opencode’s and
omp’s are property names, because a plugin receives the tool’s arguments as an
object. An adapter says which by its render.
Advising and blocking are two templates, and either may be absent. A harness that can do one and not the other drops the hooks wanting the other by name — never substituting, in either direction.
events is required on a hooks binding, and these maps are refused on any
other capability, where nothing would read them. omh enforces both.
Why there is no single syntax for fields, why advise and block stay apart, and what fields-by-tool is for
The fields map answers “where does this harness keep the file path”, and
there is no single syntax for which that is true — so each render reads it in
the language it emits.
Advising and blocking are one field apart on Claude Code and genuinely different mechanisms elsewhere: on opencode advisory text has no channel before a tool runs, and the only way to speak there is to throw, which blocks.
Without events nothing can be expressed: every hook is dropped, and the
harness receives an empty settings document — indistinguishable from a harness
that declares no hooks, except that this one claimed to have them.
fields-by-tool is the escape hatch fields cannot express. omp’s edit is
the reason it exists: its path is not a property at all but a substring of one
input string, one or more [PATH#TAG] sections. Codex’s edit is the same
problem in another shape — its file is read out of the patch’s first
*** Update File:, *** Add File: or *** Delete File: header. On both, a call
touching several files shows a guard only the first.
Breadth is capped on purpose
Section titled “Breadth is capped on purpose”Adapter breadth is capped until the base set is earned. Four harnesses ship: the
cap moved from two to three for omp (#37, in 0.6.0), and codex came in as the
fourth in 0.10.0 (#107), adopted so that “the second-most-used harness is in”.
Breadth before depth is how distributions die. Ten adapters that each half-work is a worse product than three that are verified, and it is a much worse product to maintain, because every one of them is a standing claim about software you do not control.
See the roadmap for when that cap lifts.
Why omp moved the cap, and the argument a further one has to beat
omp was not adopted for coverage. It was adopted because it is the second
harness to express hooks as a program rather than as configuration, and that
is a fact about the design omh could not learn from one example:
opencode-plugin’s own doc called declarative hook config the norm and a plugin
the exception, and it had exactly one data point. Two independent harnesses
agreeing that hooks are code is what turns
the hook vocabulary from a convenience into
the only place a hook is written once.
A third adapter that had taught omh nothing would not have been worth its
maintenance, whatever it cost to write. codex, the fourth, came in for
coverage, and #107 says so.
- Troubleshooting
What each row ofomh doctormeans, and what to do when one fails. - Configuration
The catalogue an adapter renders from, and what a repository takes from it. - Editors
Open a session in VS Code, Zed, Cursor or Neovim. - Roadmap
When the cap on harnesses lifts.