CodeDocs Vault

Architecture

Omnigent is a meta-harness: it does not write its own agent loop. Instead it drives other people's coding agents — Claude Code, Codex, Cursor, Goose, Copilot, Hermes, and a dozen more — behind one server, one conversation store, and one set of tools, policies, and credentials. The headline feature is that a session is a server-side object, so it follows you across devices: start Claude Code on your laptop, resume it from a phone or a cloud sandbox, and the transcript, workspace, and native session id come with you.

Five layers stack from cloud to kernel:

  1. Server — the orchestrator and persistence authority. A FastAPI app that owns the database, routes requests, and tracks who is online.
  2. Host — a daemon on a machine that can run agents (your laptop, or a provisioned cloud sandbox). It dials the server and launches runners on demand.
  3. Runner — a per-machine subprocess the host spawns. It owns local tool execution (filesystem, terminals, MCP) and forwards LLM work to harness subprocesses.
  4. Inner / harness — one subprocess per underlying agent. A thin FastAPI "harness" wraps an Executor that actually drives Claude/Codex/Cursor.
  5. Native bridge + sandbox — the seam to an agent's own TUI/server, and the bubblewrap/seccomp jail the agent runs inside.

The three middle layers (server, host, runner) are stitched together by one WebSocket tunnel each, always dialed outbound from host/runner to server, so the agent machine never needs an inbound port.


1. Component Topology

flowchart TB
    subgraph CLIENTS["Clients"]
        CLI["omnigent CLI<br/>run / resume / claude / codex<br/>omnigent/resume_dispatch.py:39"]
        Web["Web SPA<br/>SSE stream + xterm.js"]
    end

    subgraph SERVER["Server process (FastAPI)"]
        App["create_app()<br/>omnigent/server/app.py:966"]
        Sessions["sessions router<br/>omnigent/server/routes/sessions.py:12886"]
        RunTun["runner tunnel WS<br/>/v1/runners/{id}/tunnel<br/>omnigent/server/routes/runner_tunnel.py:267"]
        HostTun["host tunnel WS<br/>/v1/hosts/{id}/tunnel"]
        Router["RunnerRouter<br/>omnigent/runner/routing.py:67"]
        Reg["TunnelRegistry<br/>omnigent/runner/transports/ws_tunnel/registry.py:195"]
        Pres["omnigent/server/presence.py<br/>who-is-viewing"]
        Stream["omnigent/runtime/session_stream.py<br/>in-proc pub/sub SSE"]
        DB[("Postgres / SQLite<br/>+ DBOS workflows<br/>omnigent/db/db_models.py")]
    end

    subgraph HOST["Host daemon (agent machine)"]
        HConn["omnigent/host/connect.py<br/>host._daemon_entry"]
        Worktree["omnigent/host/git_worktree.py"]
    end

    subgraph RUNNER["Runner subprocess"]
        REntry["serve_tunnel()<br/>omnigent/runner/_entry.py:847"]
        RApp["runner FastAPI app<br/>omnigent/runner/_entry.py:644"]
        Dispatch["omnigent/runner/tool_dispatch.py<br/>local vs relay"]
        MCP["RunnerMcpManager<br/>stdio MCP pool"]
        Term["TerminalRegistry<br/>tmux"]
    end

    subgraph INNER["Harness subprocess (one per agent)"]
        Harn["omnigent/inner/claude_native_harness.py<br/>ExecutorAdapter"]
        Exec["Executor.run_turn()<br/>omnigent/inner/executor.py:524"]
        Bridge["native bridge<br/>NativeServerTransport"]
        Bwrap["bwrap + seccomp<br/>omnigent/inner/bwrap_sandbox.py"]
    end

    Agent[["Underlying agent<br/>Claude Code / Codex /<br/>Cursor / Goose / …"]]

    CLI -->|HTTP /v1| Sessions
    Web -->|SSE / WS| Sessions
    Sessions --> Router
    Router --> Reg
    Sessions --> DB
    Sessions --> Pres
    Stream --> Web
    Router -- "tunneled HTTP" --> Reg
    REntry -. "outbound WS dial" .-> RunTun
    HConn -. "outbound WS dial" .-> HostTun
    HostTun -- "host.launch_runner" --> HConn
    HConn -->|spawn subprocess| REntry
    Reg <-- "request/response frames" --> REntry
    RApp --> Dispatch
    Dispatch --> MCP & Term
    Dispatch -->|harness HTTP| Harn
    Harn --> Exec
    Exec --> Bridge
    Bridge --> Agent
    Exec --> Bwrap
    Bwrap --> Agent

2. Request Flow — Send a Message, Get a Turn

What happens between a user typing a message and the underlying agent producing output?

sequenceDiagram
    actor User
    participant CLI as CLI / Web
    participant Srv as Server (sessions route)
    participant DB as ConversationStore + DBOS
    participant Router as RunnerRouter
    participant Host as Host daemon
    participant Run as Runner
    participant Harn as Harness (Executor)
    participant Agent as Claude / Codex TUI

    User->>CLI: message in conv_abc123
    CLI->>Srv: POST /v1/sessions/{id} (input)
    Srv->>DB: append user item (position++)
    Srv->>DB: start DBOS workflow (task = response)
    Srv->>Router: client_for_conversation(conv, harness)
    Router->>DB: read conversations.runner_id
    alt no runner pinned
        Srv->>Host: host.launch_runner frame
        Host->>Run: spawn python -m omnigent.runner._entry
        Run-->>Srv: outbound WS dial + hello (harnesses)
        Srv->>DB: set_runner_id (race-safe once)
    end
    Router->>Run: tunneled HTTP /v1/sessions/{id}/... 
    Run->>Harn: HTTP run_turn (harness subprocess)
    Harn->>Agent: inject prompt (native) or SDK call
    loop streaming
        Agent-->>Harn: text / tool calls / reasoning
        Harn-->>Run: ExecutorEvent (SSE)
        Run-->>Srv: response.* frames over tunnel
        Srv->>DB: append assistant / tool items
        Srv-->>CLI: session_stream SSE delta
    end
    Harn-->>Run: TurnComplete (usage)
    Srv->>DB: finalize task (DBOS result)

What that shows:


3. The Tunnel — How Three Processes Become One

The server↔host and server↔runner links are the load-bearing idea. Both are outbound WebSockets dialed by the agent machine, multiplexing HTTP (and nested WebSockets) over a small frame protocol.

flowchart LR
    subgraph S["Server"]
        R["RunnerRouter<br/>routing.py:88"]
        T["WSTunnelTransport<br/>(httpx backend)"]
        Reg["TunnelRegistry<br/>registry.py:195"]
    end
    subgraph N["Runner"]
        Serve["serve_tunnel()<br/>omnigent/runner/transports/ws_tunnel/serve.py:230"]
        ASGI["dispatch_via_asgi()<br/>omnigent/runner/transports/ws_tunnel/serve.py:102"]
        App["runner FastAPI"]
    end
    R --> T --> Reg
    Reg -- "request / request.body" --> Serve
    Serve --> ASGI --> App
    App -- "response.head / .body / .end" --> Serve
    Serve --> Reg

Why the agent machine never listens Outbound-only tunnels mean a laptop behind NAT, a corporate firewall, or an ephemeral cloud sandbox can all host agents with zero inbound networking. The server is the only thing that needs a public address.

For your project — Swisscheese: This is the topology Swisscheese needs for fan-out. The server pins each conversation to a runner and can spawn many runners across many hosts, all dialing back over one tunnel apiece. A writer agent on host A and three reviewer agents on hosts B/C/D are just four conversations with four runner_id bindings, fanned out by RunnerRouter and reassembled server-side.


4. The Executor — Where the Agent Loop Actually Lives

Omnigent's "agent loop" is delegated. The framework's only loop contract is a single async-generator method on Executor (omnigent/inner/executor.py:518):

async def run_turn(messages, tools, system_prompt, config) -> AsyncIterator[ExecutorEvent]

One call drives one turn and yields events as they happenTextChunk, ReasoningChunk, ToolCallRequest, TurnComplete, ExecutorError (omnigent/inner/executor.py:96261). There is no while loop, no graph, no event-bus in the Omnigent core; the shape is a streaming async generator per turn, and the real multi-step agent loop runs inside the wrapped agent.

Capability flags let the framework adapt to each backend (omnigent/inner/executor.py:541587):

Flag Meaning
supports_streaming() Backend emits TextChunk deltas vs. one final blob
handles_tools_internally() Agent runs its own tool loop; framework must not re-execute ToolCallRequest
supports_live_message_queue() enqueue_session_message() works mid-turn (steering)
supports_stepwise_internal_turns() Loop can pause/resume between internal turns

Harness vs. Executor

Each agent ships as a pair:

Native vs. SDK

The deeper split is how the underlying agent is driven:

For native-server agents (OpenCode, Codex-native), a shared NativeServerHarness (omnigent/native_server_harness.py:45) sits over a protocol- agnostic NativeServerTransport (omnigent/native_server_transport.py:117). The runner boots the agent's server + an event forwarder; the harness only injects prompts (send_prompt), aborts (abort), and resumes sessions (create_or_resume_session). The transport hides whether the wire is WS JSON-RPC or HTTP+SSE.

Supported pairs span both modes: claude, codex, cursor, goose, hermes, kimi, kiro, pi, qwen, antigravity (native + SDK), plus copilot, opencode, and the OpenAI Agents SDK — roughly 20+ harnesses, registered in omnigent/native_coding_agents.py.

For your project — Swisscheese: The native/SDK split is exactly Swisscheese's "drive the agent's own harness vs. run it headless" tension. Native mode gives you a real, resumable Claude Code / Codex session a human can take over in a terminal; SDK mode gives you clean event streams for an automated review pipeline. Omnigent keeps both behind one Executor contract, so the orchestrator code is agnostic to which it gets.


5. Persistence — Why Sessions Follow You

There is no separate event table: the conversation_items table is the event log (omnigent/db/db_models.py:452, omnigent/server/DBSPEC.md).

flowchart TB
    Conv["conversations<br/>id, runner_id, host_id,<br/>workspace, git_branch,<br/>external_session_id,<br/>next_position"]
    Items["conversation_items<br/>position-ordered,<br/>type + JSON data blob"]
    Labels["conversation_labels<br/>(survives compaction)"]
    Tasks["tasks (responses)<br/>= DBOS workflow_uuid"]
    DBOS[("DBOS workflows<br/>status, output, usage")]
    Conv --> Items
    Conv --> Labels
    Conv --> Tasks
    Tasks --> DBOS

The portability mechanism

What makes a session device-portable is a handful of columns on the conversations row plus server-side item replay (omnigent/entities/conversation.py:54203):

Column Role
runner_id Hard affinity to a live runner; cleared on switch/close
host_id Machine that should (re)launch the runner
workspace Immutable absolute path the runner cds into
git_branch Worktree branch; gates worktree cleanup on delete
external_session_id The underlying agent's own native session uuid

The resume flow (omnigent/resume_dispatch.py:39):

sequenceDiagram
    actor User
    participant CLI as omnigent resume conv_abc
    participant Srv as Server
    participant Wrap as Native wrapper
    participant Run as Runner (new device)
    User->>CLI: resume conv_abc123
    CLI->>Srv: GET /v1/sessions/conv_abc123
    Srv-->>CLI: labels.omnigent.wrapper = "claude"
    CLI->>Wrap: run_claude_native(server, session_id)
    Wrap->>Srv: fetch conversation + items + external_session_id
    Srv->>Run: launch runner on this device
    Run->>Run: clone native transcript / replay items
    Run-->>User: re-attached session, full context

Presence and liveness

Because state is server-side, multiple people (or devices) can watch one session. omnigent/server/presence.py keeps a who-is-viewing registry scoped to a session tree and broadcasts a Google-Docs-style viewer list over the SSE stream (omnigent/server/presence.py:131, :198). Liveness is a batch query joining conversations (runner/host ids) against the tunnel registry and a host freshness gate (HOST_LIVENESS_TTL_S = 90, omnigent/stores/host_store.py:35), so the UI can show "runner online / host online" dots per session.

For your project — EU AI Act compliance platform: The item log is an audit trail by construction: every user message, tool call, tool output, reasoning block, and policy modification is an append-only, position-ordered, timestamped row that survives compaction (the summary is just another item; labels live in their own table). For an Article 12 record-keeping obligation, "what did the agent do, in what order, on whose behalf" is a single ordered SELECT over conversation_items, with created_by distinguishing human from agent actions.


6. Security Boundary — Sandbox and Credentials

The underlying agent runs untrusted-ish code, so the inner layer jails it:

Omnigent declares the sandbox per agent via OSEnvSandboxSpec (omnigent/inner/datamodel.py:462): read/write paths, network policy, dotfile masking, and seccomp baseline + custom rules.

For your projects — Swisscheese & the EU AI Act platform: For Swisscheese this is per-agent isolation — a reviewer agent can read a diff without write access to the writer's tree. For the AI Act platform, the egress proxy is the natural enforcement point for Article 14 human-oversight and data- governance controls: every outbound call is mediated, so credential use and network egress are loggable and policy-gated centrally rather than trusted to the agent.


7. Key Architectural Decisions

Delegate the agent loop; own everything around it

Omnigent's core has no reasoning loop. Executor.run_turn is a per-turn async generator, and the multi-step loop lives inside Claude Code / Codex / Cursor. This is the whole bet: instead of competing with those agents, Omnigent wraps them and sells the infrastructure — persistence, multi-device resume, tools, policy, credentials, sandboxing — uniformly across all of them.

Outbound-only tunnels, conversation-pinned routing

Hosts and runners always dial the server; the server pins each conversation to one runner. The price is hard affinity — a session is tied to one runner while it is live, and reconnect/relaunch logic must reconcile when a host drops. The payoff is firewall-free agent machines and a clean fan-out story.

Items table as the event log; DBOS for execution

Conversation history is an append-only, position-ordered, JSON-discriminated table; volatile execution state is offloaded to DBOS workflows. This keeps the relational schema tiny and stable (new item types need no migration) while durability/retry/idempotency of a "turn" is DBOS's problem, not the schema's.

Native vs. SDK as a runtime choice, not a code fork

The same Executor interface backs both "drive the agent's own TUI" and "run it headless," selected per agent and per session. Orchestration code stays agnostic; only the executor implementation differs.

State on the server, not the client

Clients are thin: the SSE stream is unbuffered pub/sub, and any dropped delta is recovered by replaying the durable item log on reconnect. This is what lets a session be observed by many viewers and resumed on any device — at the cost of making the server a hard dependency for every turn.


8. Failure Modes & Observability

Failure Handling Where
No runner online for a conversation Server sends host.launch_runner; host spawns runner; runner dials back and registers omnigent/host/connect.py:708, omnigent/runner/_entry.py:847
Runner dies mid-session Host watcher reports host.runner_exited with log tail; affinity cleared/replaced omnigent/host/frames.py:39, omnigent/stores/conversation_store/__init__.py:788
Host goes offline Liveness gate (updated_at older than 90 s) marks host stale; session shows offline omnigent/stores/host_store.py:35,:435
SSE delta dropped (no subscriber) Pub/sub has no buffer; client recovers by replaying the item log on reconnect omnigent/runtime/session_stream.py
Turn crashes after task row written Compensating transaction deletes orphan; startup reaper cleans row⇔workflow mismatches omnigent/server/DBSPEC.md
Agent process compromised bubblewrap + seccomp jail; secretless egress proxy; $HOME never mounted omnigent/inner/bwrap_sandbox.py, omnigent/inner/credential_proxy.py
Cold resume on a new device Native wrapper relaunches the agent TUI; transcript restored from external_session_id or rebuilt from items omnigent/_native_resume_hint.py:60, omnigent/resume_dispatch.py:201

Observability is built from the same persistence: per-conversation cost/usage rolls up into session_usage on the conversation row, per-user daily spend into user_daily_cost for cost-control policies, and presence/liveness drive the live UI. Every turn's token usage arrives on TurnComplete.usage (omnigent/inner/executor.py:150) and is folded into the durable totals.