Reference Go implementation of the design described in
oh-my-agentic-coder.md.
omac bridges out-of-sandbox REST/HTTP services into a sandboxed agent-coding
environment through a single Unix-domain-socket facade. Per-skill secrets are
stored in the OS keychain and injected into sidecar processes at start time —
they never reach the sandbox.
# 1. (Linux only) Install system dependencies
# bubblewrap: required by the built-in sandbox
# zenity: needed for the interactive network-access dialog
# libnotify-bin: desktop notifications when a network prompt appears
sudo apt install bubblewrap zenity libnotify-bin # Debian/Ubuntu
sudo dnf install bubblewrap zenity libnotify # Fedora
# macOS uses the built-in Seatbelt framework and native AppleScript dialogs;
# no extra install needed.
# 2. Install omac (pick one), for details see Installation section
brew tap TNG-release/tap && brew install oh-my-agentic-coder # macOS
sudo dpkg -i oh-my-agentic-coder_<version>_linux_<arch>.deb # Debian/Ubuntu
go install github.com/tngtech/oh-my-agentic-coder/cmd/omac@latest # from source
# 3. Verify the setup
omac doctor
# 4. Optional: Register a skill (prompts for secrets → OS keychain)
omac register <skill>
# 5. Launch — default sandbox (Seatbelt/bwrap) + default harness (opencode)
omac startThe built-in sandbox (Seatbelt on macOS, bubblewrap + Landlock on Linux)
is the default — no external sandbox runtime required. To use the nono sandbox
instead, see Running under nono.
omac is harness-agnostic: it launches an inner agentic coder inside the
sandbox and exposes skills to it through a stable OMAC_* / REST contract. The
harness is selected by an optional positional token after start / serve:
omac start # default harness (opencode) — unchanged behavior
omac start opencode # OpenCode
omac start claude # Claude Code
omac serve claude # multi-directory server, Claude Code harnessSupported harnesses (and aliases): opencode (oc), claude-code
(claude, cc). Omitting the token defaults to opencode. An unknown token
is rejected with the list of supported names. Inner arguments that happen to be
barewords go after -- (e.g. omac start claude -- --model sonnet).
Each harness ships a small client-side bridge that wires the agent to omac's control plane (skill activation, the skills manifest, skill base URLs):
| Harness | Bridge location | Mechanism |
|---|---|---|
| OpenCode | .opencode/plugins/ |
OpenCode plugin (omac-multidir.ts) |
| Claude Code | .claude/ (settings + hook) |
SessionStart/SessionEnd hooks |
Skills themselves are harness-agnostic — the same skill works unchanged
under any harness. Adding a new agentic harness means registering one
descriptor in internal/config/harness.go plus shipping its bridge; no
command-dispatch code changes. See CREATING_A_SKILL.md and
docs/MULTI_DIR_DESKTOP.md.
Each harness reads SKILL.md from its own skills directory, and omac
matches that: discovery is scoped to the active harness.
| Harness | Own skills dir (workdir / global) |
|---|---|
| OpenCode | .opencode/skills / ~/.config/opencode/skills |
| Claude Code | .claude/skills / ~/.claude/skills |
| (shared) | .agents/skills / ~/.config/agents/skills |
- The active harness scans its own dir + the shared
.agents/skills, and never the other harness's dir. Soomac start claudeignores skills that live only under.opencode/skills, and vice versa. Put a skill in.agents/skillsto share it across all harnesses. - A skill name can be registered once per harness (each pointing at that harness's dir); registering for one harness does not disturb the other.
- The marketplace
/installdefaults to the active harness's dir (so installed skills land where that harness loads them); passtarget_pathto override (e.g..agents/skillsfor a shared skill).
When a skill name is ambiguous at register time, omac stops and asks you to pick:
omac register slack # if ambiguous, prints the candidates
omac register slack --harness claude # pick the harness
omac register slack --global # pick the user-global one over workdirPre-built binaries and packages are published to GitHub Releases on every tagged version. The release pipeline produces:
oh-my-agentic-coder_<version>_macOS_{x86_64,arm64}.tar.gz— macOS binariesoh-my-agentic-coder_<version>_linux_{x86_64,arm64}.tar.gz— Linux binariesoh-my-agentic-coder_<version>_linux_{x86_64,arm64}.deb— Debian/Ubuntu (apt)oh-my-agentic-coder_<version>_linux_{x86_64,arm64}.pkg.tar.zst— Arch (pacman)oh-my-agentic-coder.rb— Homebrew formula (also bundled in the archive)checksums.txt— SHA-256 sums of every artifact
Releases are auto-published to the TNG-release/homebrew-tap tap.
brew tap TNG-release/tap
brew install oh-my-agentic-coderTo upgrade later:
brew update
brew upgrade oh-my-agentic-coderPre-releases (tags like v1.2.3-rc1) are intentionally not pushed to the
tap; install those from the per-release tarball below.
ARCH=$(dpkg --print-architecture) # amd64 or arm64
curl -L -o omac.deb \
"https://github.com/TNG/oh-my-agentic-coder/releases/latest/download/oh-my-agentic-coder_$(curl -s https://api.github.com/repos/TNG/oh-my-agentic-coder/releases/latest | grep tag_name | cut -d '"' -f4 | sed 's/^v//')_linux_${ARCH/amd64/x86_64}.deb"
sudo dpkg -i omac.debOr, more simply, download the .deb matching your architecture from the
releases page and run
sudo dpkg -i <file>.deb.
ARCH=$(uname -m) # x86_64 or aarch64; map aarch64 -> arm64 in URL
curl -L -O \
"https://github.com/TNG/oh-my-agentic-coder/releases/latest/download/oh-my-agentic-coder_<version>_linux_${ARCH}.pkg.tar.zst"
sudo pacman -U oh-my-agentic-coder_*.pkg.tar.zstEvery release includes checksums.txt:
curl -L -O https://github.com/TNG/oh-my-agentic-coder/releases/latest/download/checksums.txt
sha256sum -c checksums.txt --ignore-missinggo install github.com/tngtech/oh-my-agentic-coder/cmd/omac@latestFor the project layout, build instructions (dev and release), and test
details, see docs/DEVELOP.md.
omac depends on a few system-level packages. omac doctor checks all of
them; this section explains what each one does and what happens when it's
missing.
| Package | Linux | macOS | Purpose |
|---|---|---|---|
bubblewrap (bwrap) |
apt install bubblewrap / dnf install bubblewrap |
built-in (Seatbelt) | Sandboxes the inner process via Linux user namespaces + Landlock. Without it the built-in sandbox cannot start. |
| Secret Service / D-Bus | ships with GNOME/KDE; apt install libsecret-1-0 |
built-in (Keychain) | Stores skill secrets (API keys, tokens) in the OS keychain so they never touch disk. If no Secret Service is running, omac secrets operations will fail. |
| Python 3 (stdlib only) | pre-installed on most distros | pre-installed | Sidecar processes are written against the Python standard library only. No pip packages required. |
When the default sandbox profile's network.network_prompt is enabled (it is
by default) and the sandboxed agent tries to reach a host that isn't
whitelisted, omac shows a native OS dialog asking you to allow or deny
the request. The dialog backend is platform-specific:
| Package | Linux | macOS | Purpose |
|---|---|---|---|
| zenity | apt install zenity / dnf install zenity |
— | GTK dialog for GNOME/XFCE/etc. (first choice on Linux) |
| kdialog | apt install kdialog / dnf install kdialog |
— | Qt dialog for KDE (fallback on Linux) |
| osascript | — | built-in | AppleScript "choose from list" dialog (always available) |
| libnotify-bin / notify-send | apt install libnotify-bin / dnf install libnotify |
built-in (notification center) | Desktop notification alerting you that a dialog is waiting |
If no dialog backend is available (e.g. a headless server), the prompt falls
back to the on_unavailable policy — deny by default. This means every
non-whitelisted network request is silently blocked. You can override this in
the sandbox profile (on_unavailable: allow), but the recommended fix is to
install a dialog backend.
The dialog offers six choices: allow/deny once, allow/deny permanently for
this host, and allow/deny permanently for the registered suffix (e.g.
*.example.com). Permanent decisions are persisted in
default.pages.json next to the sandbox profile.
| Package | Install | Purpose |
|---|---|---|
| opencode | see opencode docs | Default inner harness (omac start) |
| claude (Claude Code CLI) | see Claude Code docs | Alternative harness (omac start claude) |
At least one inner harness must be installed; opencode is the default.
| Package | Purpose |
|---|---|
| nono | Alternative sandbox runtime with credential injection and network profiles (omac start --sandbox nono). See Running under nono. |
| Go | Only needed to build omac from source (go install …). Pre-built binaries have no Go dependency. |
omac uses several configuration files. None are required — compiled-in defaults work out of the box — but you can override them as needed.
oh-my-agentic-coder.yaml controls sandbox profiles and facade tuning.
omac looks for it in two locations (first found wins):
| Layer | Path |
|---|---|
| Workdir-local | <workdir>/.opencode/oh-my-agentic-coder.yaml |
| User-global | ~/.config/omac/config.yaml ($XDG_CONFIG_HOME honored) |
If neither file exists, DefaultLauncherConfig() is used (profile
builtin, 300 s idle timeout, 10 MB max body).
sandbox:
default_profile: builtin # or nono, nono-netprofile, no-sandbox-debug
profiles: { } # override or add profiles; defaults are merged
facade:
idle_timeout_secs: 300
max_body_bytes: 10485760
base_env_passthrough: [PATH, HOME, USER, LANG, LC_ALL, LC_CTYPE, TMPDIR]sidecar.json records which skills are registered (name, directory,
bundle hash, declared secrets). It lives in two layers, merged at
startup with workdir winning on collision:
| Layer | Path |
|---|---|
| Workdir-local | <workdir>/.opencode/sidecar.json |
| User-global | ~/.config/omac/sidecar.json |
Written by omac register / omac deregister; read by omac start,
omac list, omac doctor. Not mounted into the sandbox.
skill-config.yaml stores non-secret per-skill fields (API base URLs,
region names, feature flags — anything safe to commit). Same two-layer
merge as the registry:
| Layer | Path |
|---|---|
| Workdir-local | <workdir>/.opencode/skill-config.yaml |
| User-global | ~/.config/omac/skill-config.yaml |
Written by omac register (prompts for fields) and omac config;
read by omac start to inject field values into sidecar env vars.
Not mounted into the sandbox — resolved values are passed as
environment variables.
The built-in sandbox reads JSON profiles from
~/.config/omac/sandbox-profiles/. On first omac start with the
builtin profile, omac scaffolds default.json from the compiled-in
defaults so you can edit it:
~/.config/omac/sandbox-profiles/
├── default.json # filesystem grants, network mode, protected paths
└── default.pages.json # learned allow/deny decisions (network prompts)
Profile fields: workdir.access (none/read/write/readwrite),
filesystem.allow / .read / .write (path grants, ~ and $VAR
expansion), filesystem.override_deny (punch holes in the
built-in protected-path list), network.mode
(filtered/blocked/open), network.network_prompt, and
environment.allow_vars. See the scaffolded default.json for the
full schema.
Secrets (API keys, tokens) are stored in the OS keychain
(Keychain on macOS, Secret Service / D-Bus on Linux, Credential
Manager on Windows) — never on disk. Managed via omac secrets.
Never reachable inside the sandbox.
The sandbox receives resolved values (env vars, socket paths), not config files. Only these paths from the host are accessible inside the sandbox:
| Path | Access | Source |
|---|---|---|
<workdir> |
read+write | workdir.access: readwrite (default) |
~/.local/share/opencode, ~/.local/state/opencode |
read+write | default profile filesystem.allow |
~/.claude |
read+write | default profile filesystem.allow |
~/.cache, ~/Library/Caches |
read+write | default profile filesystem.allow |
~/go, ~/.rustup, ~/.cargo |
read+write | default profile filesystem.allow |
~/.config/opencode, ~/.opencode/bin |
read-only | default profile filesystem.read |
~/.nvm, ~/.gitconfig, ~/.gitignore_global, ~/.claude.json |
read-only | default profile filesystem.read |
/usr, /bin, /lib, /etc, … |
read-only | platform baseline |
/tmp, $TMPDIR |
read+write | platform baseline + per-session TMPDIR |
Bridge socket ($TMPDIR/omac-<hash>/bridge.sock) |
read+write | --allow-file / --read flags |
Paths in ~/.ssh, ~/.gnupg, ~/.aws, ~/.kube, … |
denied | protected paths (override with filesystem.override_deny) |
# 1. Install a skill with the existing marketplace installer.
# (Skill must declare a `sidecar:` block in its omac.yaml — see the design doc §7.)
scripts/install.sh slack
# 2. Register its sidecar in this workdir. Prompts for every declared secret
# (masked input, stored in the OS keychain; nothing touches disk under .opencode/).
omac register slack
# 3. Inspect the install script (omac never runs it for you).
bash .opencode/skills/slack/install/install.macos.sh
# 4. (Optional) status.
omac doctor
omac list
omac secrets list slack
# 5. Launch the full stack: sidecars → facade (Unix socket) → sandbox → agent.
omac start # default harness (opencode)
# or: omac start claude # launch Claude Code as the inner harness instead
# Inside the sandbox the skill reaches its sidecar via the socket:
# curl --unix-socket "$OMAC_SOCKET" http://x/slack/api/chat.postMessage ...
# 6. Rotate a secret without re-registering.
omac secrets set slack SLACK_BOT_TOKENomac [--workdir <dir>] <subcommand> [flags] [args]
register Locate the skill (workdir-local first, then user-global;
within each layer, .agents/skills ranks above the legacy
.opencode/skills — see CREATING_A_SKILL.md §2 for the
full search order including XDG and legacy fallbacks),
validate meta, prompt for secrets → keychain, prompt for
config fields → skill-config.yaml, surface the install
script path (omac never runs it), add to sidecar.json.
Flags:
--force replace existing registry entry
--reprompt-secrets re-prompt even if secrets exist
--no-secrets skip all secret prompts
--secrets-from <file> KEY=VALUE file instead of prompting
--reprompt-fields re-prompt config fields
--no-fields skip all config-field prompts
--fields-from <file> KEY=VALUE file for fields
deregister Remove from registry. Flags:
--purge-secrets also delete from keychain
--purge-fields also delete from skill-config.yaml
list Show registered skills with mount, secret count, binary status.
secrets <sub> <skill> [name]
list, set, unset, import --from <file>
config <sub> <skill> [args]
show <skill> [--json] resolved config + secret fingerprints
get <skill> <field> one resolved value, suitable for $(...)
start Spawn sidecars → bind socket → exec sandbox runtime. Refuses
to start if any skill is unregistered in any of the search
roots (workdir-local .agents/skills + .opencode/skills,
plus the user-global layers), or if a registered skill's
bundle changed since register, or if a required config
field is unresolvable. Auto-deregisters
(silently) skills whose directory has vanished; secrets +
config persist for safety. Flags:
--sandbox <profile> pick a sandbox profile
--inner <cmd> override inner_cmd
--no-sandbox debug: run inner cmd directly
--keep-running don't stop sidecars on exit
--accept-skill-changes tolerate bundle_hash drift
--verbose lifecycle logging
doctor Sanity checks: config, registry, binaries, secrets, sandbox.
version
| Code | Meaning |
|---|---|
0 |
success |
1 |
generic failure |
2 |
misuse / invalid arguments |
3 |
configuration or metadata invalid |
4 |
prerequisite missing (skill not installed) |
5 |
I/O error |
6 |
sidecar failed health check |
7 |
sandbox exited abnormally |
8 |
keychain access failed |
9 |
required secret refused by user |
Minimal by design:
github.com/zalando/go-keyring— macOS Keychain / Secret Service / Windows Credential Manager abstraction.golang.org/x/term— masked-input password prompt.gopkg.in/yaml.v3—omac.yamlparsing.
Everything else is stdlib.
If you want to build a new skill from scratch — or just get a deeper
walkthrough of the schema, the sidecar contract, and the dev loop — see
CREATING_A_SKILL.md. It covers the on-disk
layout, the full omac.yaml schema, every env var omac sets in the
sidecar and inside the sandbox, secrets best practices, and a
pre-shipping checklist.
A working example skill lives under .opencode/skills/echo-rest/ and is
the reference for how to write a sidecar-backed skill. omac skills are
also valid agentskills.io skills — every
skill ships a SKILL.md (the agentskills.io discovery file the agent
reads via progressive disclosure) and an omac.yaml (omac's
runtime contract for the sidecar process). See
CREATING_A_SKILL.md §3 for the split:
.opencode/skills/echo-rest/
├── SKILL.md agentskills.io frontmatter + Markdown
│ instructions (name, description, when
│ to use, endpoints, env vars)
├── omac.yaml sidecar block + declared secrets + health
├── scripts/
│ └── sidecar.py stdlib-only Python HTTP server (the
│ sidecar entry-point, referenced from
│ omac.yaml's `command:` as
│ `["python3", "scripts/sidecar.py"]`)
└── install/
├── install.macos.sh
└── install.linux.sh
Exposes:
GET /status— health probe (facade waits on this)GET /whoami— returns a sha256 fingerprint of the injected secret (proves injection without leaking the value)POST /echo— echoes back the JSON bodyGET /tick?n=N&gap_ms=MS— streaming Server-Sent Events; proves that the facade streams frame-by-frame instead of buffering
A companion script, demo-client.sh, stands in for the in-sandbox agent and
calls the sidecar through the Unix socket:
export ECHO_API_KEY="demo-key-42" # only needed for env_passthrough
omac register --no-secrets echo-rest # (or without --no-secrets to use the keychain)
omac start --no-sandbox --inner bash -- ./demo-client.shExpected output (abridged) when run in an environment that permits
loopback connect(2):
OMAC_SOCKET = /tmp/omac-<hash>/bridge.sock
OMAC_ECHO_BASE = http://127.0.0.1:<port>/echo
--- GET /echo/status --- {"ok":true,"skill":"echo-rest"}
--- GET /echo/whoami --- {"skill":"echo-rest","secret_present":true,"secret_fingerprint":"sha256:..."}
--- POST /echo/echo --- {"skill":"echo-rest","secret_fingerprint":"sha256:...","you_sent":{"hello":"from sandbox","n":7}}
Three test files exercise the same wiring in Go. Each of them skips cleanly when the environment denies a capability it needs; together they cover the full request matrix in any environment that permits at least one of them.
internal/facade/facade_test.go::TestFacadeEchoLikeRest— in-process upstream reached through the facade over a Unix socket. Covers path rewriting,X-Forwarded-Prefixinjection, JSON round-trip, unknown-mount 404, facade status route, and a 5-frame SSE stream with incremental delivery assertion.internal/facade/integration_test.go::TestEchoRestEndToEnd— spawns the Pythonscripts/sidecar.pyas a real subprocess, routes through the facade's Unix socket, asserts the secret was injected into the sidecar's env and round-trips a POST body, and consumes the/tickSSE stream with the same incremental-delivery check.internal/facade/sse_inmemory_test.go::TestFacadeSSE_InMemory— runs the facade's HTTP handler overnet.Pipe()so no Unix socket is required; the upstream is a loopbackhttptestserver. Exists so that SSE can be verified in environments that permit loopback but not Unix sockets (or vice-versa).
SSE is plain HTTP with a long-running response body in chunked transfer encoding. The facade supports it without any special case because:
- The Go reverse proxy in
internal/facade/facade.gonever reads the response body into memory — it streams throughhttp.ResponseController/Flushercalls. - When the upstream sets
Content-Type: text/event-stream, the facade additionally setsX-Accel-Buffering: noon the response so any downstream client libraries that inspect that header also disable buffering. - No
Content-Lengthis set on an SSE response, so Go encodes it as chunked. EachFlush()on the upstream causes a chunk to be sent on the client socket.
The 60 ms span assertion in the tests (with a 30 ms upstream gap between frames) guards against any future regression that would collapse the stream into a single response write.
omac uses a built-in sandbox by default (Seatbelt on macOS, bubblewrap +
Landlock on Linux). You may want the nono sandbox instead
if you need nono's credential injection, network profiles with interactive
domain prompts, or are migrating from an existing nono setup. Select it with
omac start --sandbox nono (or --sandbox nono-netprofile for domain-filtered
outbound HTTP).
See docs/NONO_SANDBOX.md for the full setup guide,
transport details (TCP vs Unix socket under proxy mode), flag combinations,
and debugging instructions.
See the design doc's "Open questions / future work" section. Notably:
- Headless-Linux file fallback for the keychain.
- WebSocket splice robustness tests (code path exists, untested here).
doctor --fixauto-remediation.OMAC_KEYRING_BACKENDoverride.- Signed skill metadata verification.
Copyright 2026 TNG Technology Consulting GmbH
Licensed under the Apache License, Version 2.0. See LICENSE and NOTICE for details. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0.