CodeDocs Vault

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:

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-bufferpaste-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):

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:

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/listconfig/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/completedfunction_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:

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 NativePermissionDecisionLiteral["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_promptPOST /session/{id}/prompt_async, abortPOST /session/{id}/abort, events → SSE, forkPOST /session/{id}/fork, reply_permissionPOST /permission/{id}/reply.

So Omnigent normalizes via two seams, by mechanism:

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/evaluate and 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_hook module already governs both — which is precisely the two agents Swisscheese targets.


9. Pitfalls to learn from