Native Bridges
Omnigent's signature move: it does not reimplement coding agents — it
wraps the real ones. omnigent claude boots the genuine Claude Code
TUI in your terminal; omnigent codex boots the real codex CLI. A
family of "native bridge" modules then makes that vendor process behave
like an Omnigent session: web-UI messages get injected into it, its
transcript gets mirrored back into the web conversation, and every tool
call it makes is gated by Omnigent's policy engine.
Eleven agents are wrapped this way — Claude, Codex, Cursor, Goose, Qwen,
Kimi, Hermes, Pi, Kiro, Antigravity, OpenCode
(omnigent/native_coding_agents.py:148-160). Each has a *_native*
file family. The hard part is that these eleven agents speak eleven
different protocols. This doc is about how Omnigent makes them
interchangeable.
For Swisscheese. This is the doc. Swisscheese wants to orchestrate Claude Code and Codex as underlying agents. Omnigent has already solved "drive the real CLI, mirror its output, and inject a deployment-wide policy gate into each tool call" — for both, with one shared policy seam. Read the Claude/Codex worked examples as a reference implementation.
1. The common shape of a bridge
Every wrapped agent gets the same module family. Not all files exist for every agent, but the roles are constant:
| Module | Role |
|---|---|
*_native.py |
The CLI entrypoint (omnigent claude). Launches the vendor TUI, prepares the bridge dir, attaches the terminal. |
*_native_bridge.py |
The filesystem rendezvous: bridge-dir layout, state files, spawn-env, secrets/token. The "shared memory" between the runner and the harness turn. |
*_native_forwarder.py |
TUI → web. Mirrors the vendor's transcript back into the Omnigent conversation (streaming deltas, tool calls, token usage). |
*_native_hook.py / *_native_permissions.py |
The policy gate. Intercepts each tool call and routes it to Omnigent's policy engine for allow/deny. |
*_native_state.py |
The per-session state dataclass persisted to state.json (session id, thread id, socket path, active turn). |
*_native_app_server.py / *_rpc.py / *_http_transport.py |
The wire driver when the agent exposes an RPC/HTTP server instead of a hook system. |
Three identity files tie the family together:
omnigent/native_coding_agents.py— one frozenNativeCodingAgentdescriptor per agent (key, display name, harness id, wrapper label, terminal name). The registry the rest of the codebase looks agents up in.omnigent/harness_aliases.py— canonicalizes user-facing spellings (native-codex→codex-native,kimi-code→kimi) and defines theNATIVE_HARNESSESfrozenset (omnigent/harness_aliases.py:53-84) that marks a harness as "drives a vendor TUI."omnigent/_wrapper_labels.py— theomnigent.wrapper/omnigent.ui="terminal"labels stamped on the conversation row so the Web UI renders terminal-first.
A bridge directory is the rendezvous point. For Claude it lives under
/tmp/omnigent-<uid>/claude-native/<sha256(bridge_id)[:32]>/, owned 0o700
and namespaced by uid so a co-tenant can't read the bearer token or
symlink-redirect the tree (omnigent/claude_native_bridge.py:73-94). For Codex
it's ~/.omnigent/codex-native/<sha256(bridge_id)[:32]>/
(omnigent/codex_native_bridge.py:33, 64-69).
2. Worked example A — Claude (hooks + tmux + MCP)
Claude Code has no RPC server you can drive, so Omnigent rendezvouses with a running Claude through three simultaneous channels.
Channel 1: input via tmux send-keys
Web-UI messages are typed into the same tmux pane the user is attached
to; Claude treats them as ordinary keyboard input. The runner advertises
the pane's private socket + target in tmux.json
(advertise_tmux_socket, omnigent/claude_native_bridge.py:2287), and the
harness delivers a message with inject_user_message
(omnigent/claude_native_bridge.py:2322). The delivery detail matters: the prompt
is pushed as one bracketed paste via tmux load-buffer → paste-buffer,
not send-keys argv, because tmux caps a single send-keys at ~1KB; a
file-buffer paste survives a multi-KB prompt
(omnigent/claude_native_bridge.py:2387-2432). Enter is a separate call after the
input box renders.
Claude's experimental Channels MCP was the original input path but is blocked at the org-policy layer, so the bridge falls back to typing into the terminal (
omnigent/claude_native_bridge.py:21-25).
Channel 2: tools via an MCP stdio server
Omnigent advertises its own tools to Claude by registering an MCP
server in Claude's config. build_mcp_config
(omnigent/claude_native_bridge.py:968-996) writes an mcpServers entry whose
command is python -m omnigent.claude_native_bridge serve-mcp --bridge-dir <dir>. Claude launches that as a child process and speaks
MCP over stdio. The server advertises workspace sys_os_* tools outside
an active turn, and relays active-turn Omnigent tools read from
tool_relay.json (omnigent/claude_native_bridge.py:14-16). So the underlying
agent's tools are exposed via plain MCP tool schemas — Omnigent never
parses XML or invents a format here; it speaks the agent's native MCP.
Channel 3: governance via command hooks
build_hook_settings (omnigent/claude_native_bridge.py:999-1068) registers
Claude command hooks that shell out to python -m omnigent.claude_native_hook --bridge-dir <dir> (run with -I isolated
mode so a stray omnigent/ in the cwd can't shadow the install,
omnigent/claude_native_bridge.py:1028-1035). Registered events:
SessionStart, Stop, StopFailure, plus PreToolUse /
PostToolUse / UserPromptSubmit for policy, plus a MessageDisplay
hook pointing at a stdlib-only appender
(claude_native_message_display_hook) that writes each streamed
assistant chunk to message_deltas.jsonl for the forwarder to tail
(omnigent/claude_native_bridge.py:1047-1064).
Two distinct gates ride the hook system, and keeping them separate is the
subtle part (omnigent/claude_native_hook.py):
- Policy gate (
_main_evaluate_policy,omnigent/claude_native_hook.py:717) — POSTs to/v1/sessions/{id}/policies/evaluate, returns a hard deny when the deployment's policy says no. - Consent gate (
_main_permission_request,omnigent/claude_native_hook.py:576) — forwards Claude'sPermissionRequestto the Omnigent server so the user's approval card renders in the web UI; long-polls for the verdict with reattach-on-reconnect so a dropped connection doesn't spawn a second card (_post_hook_with_reattach,omnigent/claude_native_hook.py:509).
Forwarder: Claude → web
forward_claude_transcript_to_session
(omnigent/claude_native_forwarder.py:589) polls at 0.25s and tails two files:
Claude's own JSONL transcript (~/.claude/projects/.../session.jsonl)
and the bridge's message_deltas.jsonl. Transcript rows become
external_conversation_item events; deltas become
response.output_text.delta SSE. A _DeltaOrderingState
(omnigent/claude_native_forwarder.py:94) reconciles the two independent tails so
deltas never arrive after their "done." Sub-agents under
.../subagents/agent-*.jsonl are watched and minted as child Omnigent
conversations.
3. Worked example B — Codex (app-server over a socket)
Codex is the opposite design: it does expose a server, so Omnigent drives it as an RPC client.
Spawn + isolation
omnigent/codex_native_app_server.py:560-578 spawns the real binary as
codex app-server --listen <unix://…sock or ws://127.0.0.1:port>, with
each session pinned to a private CODEX_HOME
(proc_env = {..., "CODEX_HOME": str(self.codex_home)},
omnigent/codex_native_app_server.py:568). The private home is seeded from the
user's config so per-session MCP servers and hooks can be injected
without mutating the user's real Codex setup.
The RPC client
CodexAppServerClient (omnigent/codex_native_app_server.py:257-443) is a
JSON-RPC 2.0 client that connects over a Unix socket
(websockets.unix_connect) or a loopback websocket
(omnigent/codex_native_app_server.py:296-307). It exposes:
request(method, params)(:345) — call + await a replynotify(method, params)(:376) — one-wayrespond(request_id, result)(:392) — answer a server-to-client request (Codex can call back, e.g.mcpServer/elicitation/request)iter_events()(:413) — stream notifications
User turns are injected with the turn/start request
(omnigent/codex_native.py:2206-2211):
{"method": "turn/start",
"params": {"threadId": "<thread>",
"input": [{"type": "text", "text": "<prompt>"}]}}Before the first turn the bridge waits for the TUI to emit a
thread/started notification (wait_for_thread_started,
omnigent/codex_native.py:2171-2189), then binds that thread id to the Omnigent
session.
Governance: same hook seam, different trust dance
Codex also has a command-hook system, so its PreToolUse /
PostToolUse / UserPromptSubmit gate (omnigent/codex_native_hook.py) shares
the exact same implementation as Claude's (see §5). Codex spawns
python -m omnigent.codex_native_hook evaluate-policy --bridge-dir <dir>,
pipes the hook payload on stdin, reads a verdict on stdout
(omnigent/codex_native_hook.py:62-183). One Codex-specific wrinkle: a freshly
written hook is untrusted and Codex silently skips untrusted hooks —
which for a security gate is a fail-open. So at startup the bridge runs
Codex's own hooks/list → config/batchWrite trust flow and verifies
the hook is trusted before the session goes live (_trust_policy_hooks,
omnigent/codex_native_app_server.py:601). A trust failure degrades the
session to "no enforcement" with a surfaced reason rather than silently
running ungoverned.
Elicitation
When Codex needs a human (a tool wants approval), it sends a
server-to-client mcpServer/elicitation/request. The bridge mints a
deterministic elicitation id — elicit_codex_<sha256(session, method, request_id)[:32]> (omnigent/codex_native_elicitation.py:24-53) — so a
later serverRequest/resolved notification can clear the exact web card
even if another client answered first.
Forwarder
omnigent/codex_native_forwarder.py subscribes to app-server notifications and
mirrors them: item/agentMessage/delta → text deltas (coalesced),
item/completed → function_call + function_call_output,
thread/tokenUsage/updated → token counts. State persists in
CodexNativeBridgeState (omnigent/codex_native_bridge.py:47-69): session_id,
socket_path, thread_id, codex_home, active_turn_id.
4. The two interception families
Strip away the per-agent detail and there are exactly two ways Omnigent gets inside a coding agent.
Family A — type into a terminal, tail a file. No RPC server exists,
so the bridge simulates keyboard input via tmux send-keys (bracketed
paste) and mirrors output by tailing the vendor's transcript store. This
covers Claude, Cursor, Goose, Hermes, Pi, Kiro, Kimi. The transcript
store varies wildly — Claude/Kiro use JSONL files, Goose uses a SQLite
DB at ~/.local/share/goose/sessions/sessions.db, Hermes a single
SQLite state.db — so each gets its own forwarder. Qwen is a refinement:
instead of fake keystrokes it drives a built-in remote-control protocol
through two files the TUI watches (--input-file for JSONL commands,
--json-file for events) — its docstring contrasts itself explicitly
with the keystroke approach (omnigent/qwen_native_bridge.py:1-13).
Family B — drive a real server. The agent exposes an RPC/HTTP
surface and Omnigent connects as a client. Codex = JSON-RPC over a Unix
socket / websocket; OpenCode = HTTP + SSE; Antigravity = connect-RPC over
an ephemeral discovered port (omnigent/antigravity_native_rpc.py, turn delivery
via SendUserCascadeMessage). For this family there is a genuine shared
abstraction — see §6.
5. The common interface, part 1 — the policy gate
The cleverest normalization is that two completely different agents
(Claude with hooks, Codex with an app-server) share one policy gate
implementation. omnigent/native_policy_hook.py is harness-neutral
glue between a vendor's hook payload and Omnigent's policy server. Both
Claude Code and Codex use the same hook field names
(hook_event_name, tool_name, tool_input, tool_output), so one
module serves both (omnigent/native_policy_hook.py:1-19).
The flow, identical for every hook-based agent:
vendor hook subprocess (stdin payload)
│
▼ hook_payload_to_evaluation_request() omnigent/native_policy_hook.py:62
proto EvaluationRequest { PHASE_TOOL_CALL | PHASE_TOOL_RESULT | PHASE_REQUEST }
│
▼ POST /v1/sessions/{id}/policies/evaluate (post_evaluate_with_retry :290)
EvaluationResponse { POLICY_ACTION_ALLOW | DENY | ASK }
│
▼ evaluation_response_to_hook_output() omnigent/native_policy_hook.py:142
vendor hook output (stdout) { permissionDecision | decision:"block" | additionalContext }
The mapping (omnigent/native_policy_hook.py:142-244) encodes the design rules:
PreToolUse+DENY→permissionDecision: "deny".ASKis resolved server-side now (URL-based elicitation parks the gate); a strayASKat the hook fails closed to deny, never the olddefer— becausedeferhanded control back to Claude'spermission_mode, whichacceptEdits/bypassPermissionswould auto-approve, reopening the bypass.PreToolUse+ALLOW→None("no opinion"). Crucially it does not emit"allow". Emitting allow would suppress the agent's own permission prompt, collapsing two independent gates (the deployment's policy gate and the user's consent gate) into one. The policy layer may block; it must not silence consent.UserPromptSubmit+DENY→ top-level{"decision": "block"}, dropping the prompt before the model sees it. For native sessions this hook is the sole request-phase gate (the server-level input check is bypassed for native message events,omnigent/native_policy_hook.py:46-51).PostToolUseis observational — aDENYonly surfaces asadditionalContextwarning text; the tool already ran.
Failure is phase-aware (fail_closed_hook_output,
omnigent/native_policy_hook.py:247-287): once a session is known governed, an
unreachable/garbled policy verdict makes PreToolUse fail closed
(deny — this hook is the only enforcement point for native tools), while
UserPromptSubmit and PostToolUse fail open. Omnigent MCP tools
(mcp__omnigent__*) are skipped here because the MCP relay path already
evaluated them — skipping avoids double-counting, but connector tools
(mcp__github__*) still pass through (omnigent/native_policy_hook.py:104-109).
Agents without a hook system (OpenCode, Cursor, Goose, Hermes) reach
the same /policies/evaluate endpoint through a *_native_permissions.py
mirror: it intercepts a permission request from the agent's event
stream / pane / store, normalizes it into the same policy-evaluation
input shape, awaits the verdict, and replies through the agent's native
channel — e.g. OpenCode's permission.v2.asked SSE event maps to a
POST /permission/{id}/reply of once/always/reject
(omnigent/opencode_native_permissions.py:1-12, 151-204).
6. The common interface, part 2 — the transport protocol
For Family-B (server-driven) agents, omnigent/native_server_transport.py
defines a @runtime_checkable Protocol, NativeServerTransport
(omnigent/native_server_transport.py:116-170), with a fixed method set:
start_server / stop_server # process lifecycle
create_or_resume_session # session id
send_prompt(session_id, prompt) # inject a web turn
abort(session_id) # interrupt
events(session_id) # async event stream
list_history / fork # history + branching
reply_permission(decision) # answer a permission request
build_tui_attach_command # terminal takeover
Everything crossing the boundary is normalized: a NativePrompt going
in, a NativeEvent coming out, and a NativePermissionDecision —
Literal["allow_once", "allow_always", "reject"]
(omnigent/native_server_transport.py:101-113) — for approvals. One executor,
NativeServerHarness (omnigent/native_server_harness.py), drives any transport
through these methods only; it is "deliberately thin and
transport-agnostic — the same orchestration drives both codex-native (WS
JSON-RPC) and opencode-native (HTTP + SSE)"
(omnigent/native_server_harness.py:6-9). run_turn resolves the session,
builds a prompt, calls transport.send_prompt, and yields
TurnComplete — streaming is left to the forwarder
(omnigent/native_server_harness.py), exactly mirroring Codex's
injection/completion split.
OpenCode is the clean second implementation
(omnigent/opencode_http_transport.py:128-285): send_prompt → POST /session/{id}/prompt_async, abort → POST /session/{id}/abort,
events → SSE, fork → POST /session/{id}/fork, reply_permission →
POST /permission/{id}/reply.
So Omnigent normalizes via two seams, by mechanism:
- hook-based agents converge at
omnigent/native_policy_hook.py+ the/policies/evaluateendpoint; - server-based agents converge at the
NativeServerTransportprotocol +NativeServerHarness.
Both end at the same place — the Omnigent server's policy engine and session event stream.
7. Agent → mechanism → quirks
| Agent | Input mechanism | Output (forwarder) | Permission path | Notable quirk |
|---|---|---|---|---|
| Claude | tmux bracketed paste + MCP stdio server | tail session.jsonl + message_deltas.jsonl |
command hook → /policies/evaluate plus separate PermissionRequest consent gate |
File-buffer paste (not send-keys argv) to survive multi-KB prompts; two independent gates kept separate |
| Codex | turn/start JSON-RPC over Unix socket / WS |
subscribe to app-server notifications | command hook (shared with Claude) | Private CODEX_HOME; must run hooks/list+config/batchWrite trust flow or hook fails open; deterministic elicitation ids |
| OpenCode | POST /session/{id}/prompt_async (HTTP) |
SSE events() |
permission.v2.asked SSE → POST /permission/{id}/reply |
Cleanest NativeServerTransport impl; XDG-isolated per session |
| Cursor | tmux paste + MCP config | tail transcript (store.db poll) |
store.db pending-call detect → server hook → y/Esc keystroke |
Largest permissions module (omnigent/cursor_native_permissions.py, 764 lines) — must scrape pending tool calls |
| Goose | tmux bracketed paste | tail SQLite sessions.db |
pane-scrape (cliclack) → server hook → arrow-key nav | Uses the user's own ~/.config/goose/config.yaml; no MCP injected |
| Qwen | dual file: --input-file JSONL commands |
--json-file NDJSON events |
confirmation_response written to input file |
"ACP-piped" — built-in remote-control protocol, not fake keystrokes |
| Kimi | tmux paste + Anthropic-style [[hooks]] in config.toml |
tail transcript | command hook (Claude-compatible) → /policies/evaluate |
Kimi reuses Claude Code's hook shape almost verbatim |
| Hermes | tmux bracketed paste | tail single SQLite state.db |
pane-scrape (prompt_toolkit panel) → server hook → digit keystroke | Uses the user's own ~/.hermes config |
| Pi | filesystem inbox/outbox via VSCode extension JS | outbox tail | credentials module (omnigent/pi_native_credentials.py) |
Bridges through a JS extension, not a terminal |
| Kiro | tmux send-keys (literal char chunking) | tail ~/.kiro/sessions/cli JSONL |
session forwarder | Char-chunked keystrokes under an allowlisted env |
| Antigravity | connect-RPC SendUserCascadeMessage |
RPC trajectory-step reader | RPC reader/interactions | Discovers an ephemeral TLS port + port-first ownership probe |
8. The cleverest trick (insight-card candidate)
The policy gate emits "no opinion" on ALLOW so the user's consent
prompt still fires. When a deployment policy allows a tool call,
evaluation_response_to_hook_output returns None instead of "allow"
(omnigent/native_policy_hook.py:160-167, 209-225). That one decision keeps two
independent gates from collapsing into one: the deployment's policy
gate (may DENY/ASK) and the user's consent gate (the agent's own
permission prompt, routed to the web UI). Emitting "allow" would
auto-approve the tool and suppress the human's card — letting an org
policy silently override individual consent. The policy layer is allowed
to be stricter than the user, never more permissive. For an
audit-traceable GRC product this is exactly the right default.
For an AI-Act platform. This separation — deployment policy vs. human consent as orthogonal gates, with the policy layer able to block but never to silence consent — is a clean primitive for Article 14 (human oversight). Each gate is independently logged at
/policies/evaluateand at the consent endpoint.
For Swisscheese. The runaway lesson: don't reimplement the agent's tool loop. Drive the real CLI, let the agent keep its own permission prompt, and inject your governance as a thin pre-tool hook (Claude) or a trusted policy hook (Codex). One
native_policy_hookmodule already governs both — which is precisely the two agents Swisscheese targets.
9. Pitfalls to learn from
- Hook-trust is a silent fail-open. Codex skips untrusted hooks
silently; without the explicit
_trust_policy_hookshandshake (omnigent/codex_native_app_server.py:601) a session would run with zero enforcement and no error. Any hook-injected governance needs a fail-loud "is my hook actually registered and trusted?" check at startup. - Family-A agents have no clean API. Tailing SQLite DBs and scraping TUI panes (Goose, Hermes, Cursor) is brittle: a vendor UI redraw or schema bump breaks the forwarder/permission mirror. The per-agent forwarder count (one per transcript format) is the tax for wrapping closed CLIs.
- tmux send-keys size cap. The bracketed-paste-via-load-buffer
workaround (
omnigent/claude_native_bridge.py:2387) exists only because raw send-keys silently truncates ~1KB+ prompts — an easy footgun for anyone driving a TUI by keystroke injection. - Two-tail ordering. Claude's forwarder reconciles deltas
(
message_deltas.jsonl) against the transcript (session.jsonl) with_DeltaOrderingState; mirroring two independent append-only sources without a sequencer would race "done" ahead of its own deltas.