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:
- Server — the orchestrator and persistence authority. A FastAPI app that owns the database, routes requests, and tracks who is online.
- 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.
- Runner — a per-machine subprocess the host spawns. It owns local tool execution (filesystem, terminals, MCP) and forwards LLM work to harness subprocesses.
- Inner / harness — one subprocess per underlying agent. A thin FastAPI
"harness" wraps an
Executorthat actually drives Claude/Codex/Cursor. - 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:
- The conversation is a server-side row plus an append-only item log; the
client never holds canonical state (
omnigent/db/db_models.py:236,:452). - Routing is conversation-pinned: a session sticks to one runner via
conversations.runner_id, set race-safe on first dispatch (omnigent/runner/routing.py:88,omnigent/stores/conversation_store/__init__.py:788). - If no runner is online, the server asks the host to spawn one, then the
runner dials back — the server never connects out to the agent machine
(
omnigent/host/connect.py:771,omnigent/runner/_entry.py:847). - A "turn" is one DBOS workflow ("task"/"response"); execution state lives in
DBOS, identity/relationship columns in the
taskstable (omnigent/server/DBSPEC.md). - Output streams to the client through
omnigent/runtime/session_stream.py, an in-process pub/sub with no buffer — events emitted before a subscriber connects are dropped; reconnects replay from the item log instead.
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
- The runner is a WebSocket client: it calls
serve_tunnel(), dialswss://server/v1/runners/{id}/tunnel, and sends ahelloframe advertising which harnesses it supports (omnigent/runner/_entry.py:917,omnigent/server/routes/runner_tunnel.py:267). - Server-side, an outbound HTTP request to a runner is encoded as
request/request.bodyframes, pushed onto the runner's queue, and the response is reassembled fromresponse.head/response.body/response.endframes. The transport is a customhttpxbackend so route handlers callclient.post(...)as if the runner were a normal HTTP service. - Frame kinds include
ws.open/ws.frame/ws.close, which multiplex a nested WebSocket — that is how a browser xterm.js terminal attaches to the runner's tmux pane through the same tunnel (omnigent/server/_runner_ws_tunnel.py). - The host tunnel carries control frames, not HTTP:
host.hello,host.launch_runner,host.runner_exited, plus filesystem andhost.create_worktreeoperations (omnigent/host/frames.py:35). Runners carry their own HTTP tunnels; hosts only manage lifecycle.
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 happen — TextChunk,
ReasoningChunk, ToolCallRequest, TurnComplete, ExecutorError
(omnigent/inner/executor.py:96–261). 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:541–587):
| 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:
- A harness (
omnigent/inner/claude_native_harness.py:22) is a thin FastAPI app factory. It instantiates anExecutor, hands it toExecutorAdapter, and exposes HTTP/SSE. It is just the process boundary the runner talks to. - An executor (
omnigent/inner/claude_native_executor.py:32) implementsrun_turnand owns per-session backend state (subprocess handles, SDK clients, tmux panes).
Native vs. SDK
The deeper split is how the underlying agent is driven:
- Native (
omnigent/inner/claude_native_executor.py,omnigent/inner/codex_native_executor.py,omnigent/inner/cursor_native_executor.py, …) — the agent's own TUI/server is already running in the session terminal. Each turn injects a prompt into it and lets the agent's native transcript stream the output. The executor reportssupports_streaming() → False(omnigent/inner/claude_native_executor.py:64) because the forwarder, not the executor, emits text. Mid-turn steering is supported by injecting another message (omnigent/inner/claude_native_executor.py:72). - SDK / subprocess (
omnigent/inner/claude_sdk_executor.py:1055,omnigent/inner/codex_executor.py) — the executor launches and drives the agent headlessly via its SDK orapp-server, parsing its native event stream intoExecutorEvents.
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
- Items are append-only and position-ordered. Each append allocates a
gapless
positionfrom the conversation'snext_positioncounter in the same transaction — strict per-conversation ordering without a global sequence (omnigent/stores/conversation_store/__init__.py:394,omnigent/server/DBSPEC.md). - A single
dataJSON blob with atypediscriminator holds messages, function calls, outputs, reasoning, compaction summaries, and more — new item types need no migration (omnigent/db/db_models.py:452, mirrors the OpenAI Responses item model). - Execution state lives in DBOS, not the table. A "task"/"response" is a
DBOS workflow; the
tasksrow only stores identity and the steeringinbox_closedflag.TaskStore.get()reassembles the full entity from both. An invariant (row ⇔ workflow, both or neither) is enforced by a compensating transaction and a startup reaper (omnigent/server/DBSPEC.md).
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:54–203):
| 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
- The
omnigent.wrapperlabel is the dispatch signal: it names which native wrapper (claude,codex,cursor, …) to relaunch (omnigent/resume_dispatch.py:201). Labels live in a separate table so they survive context compaction (omnigent/db/db_models.py:507). - On a new device, the wrapper fetches the conversation and its items from the
server, then either clones the agent's on-disk native transcript (keyed by
external_session_id) or rebuilds it from the Omnigent item log. Fork labels (omnigent.fork.source_external_session_id,omnigent.fork.carry_history) drive the transcript rebuild. - The CLI works against either a remote server (
--server) or a local SQLite store at~/.omnigent/chat.db(omnigent/resume_dispatch.py:312), so the same resume contract covers solo-local and team-cloud deployments.
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:
- bubblewrap mount namespace (
omnigent/inner/bwrap_sandbox.py): read-only/usr,/bin,/lib; minimal allow-listed/etc; fresh/proc,/dev,/tmp; cwd read-only unlesswrite_pathsflips it;$HOMEnever mounted; dotfiles masked unless allow-listed. - seccomp (
omnigent/inner/_seccomp.py): a Kubernetes-RuntimeDefault-derived denylist plus argument-filteredclone(CLONE_NEW*)and asocket()family allow-list (AF_UNIX/INET/INET6 only), registered across native + compat ABIs so legacy syscall paths can't bypass it. - secretless credential proxy (
omnigent/inner/credential_proxy.py): real secrets stay in the parent; the sandbox makes credential-free requests and an egress MITM proxy injects the real token on the way out. An opt-in placeholder mode (oa_cred_<nonce>) serves clients that refuse to start without a token, with a cross-host replay guard.
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.