Frontends
Three consumers of the same AgentEvent stream, plus a desktop shell around the third. Adding each of them required no engine change, which is the whole point of the split.
Terminal
oharness
A full-screen interface: scrollback, a fixed input box, a status line, and overlays for approvals and the mode picker. Written without a TUI library, so a few things had to be earned rather than assumed:
- CJK width. East Asian characters occupy two columns. Measuring them as one
makes every row below wrap in the wrong place.
- Restoring the terminal. The failure that matters is not a misdrawn frame,
it is exiting without leaving raw mode: the user is left with no echo and no cursor and has to type reset blind. Restoration is wired to every exit path, including uncaught exceptions, and proven by a test that crashes a real process.
- Bracketed paste. Without it, pasting three lines fires three turns.
Piped input, CI and dumb terminals fall through to a line-based loop that behaves the same and stays scriptable.
Artifacts appear here as one line each — title, kind, size, revision. The kinds a terminal can honestly show are shown; a page, an image or an SVG says so and points at the browser frontend instead. That refusal is a declared answer, not an omission: every kind has an entry in a table each frontend must fill in, and a kind added to the engine without one fails a test rather than rendering as a silent blank in one of the three.
Headless
oharness-headless "a task"
echo "a task" | oharness-headless
stdout carries JSONL and nothing else, so it pipes straight into jq. Diagnostics go to stderr. The exit code is 1 if the run failed.
Nobody is present to answer a permission prompt, so anything that would ask is denied and reported as an event. Silently approving would make unattended runs more dangerous than attended ones, which is backwards. Pre-authorise work with rules, or pass --permission-mode.
Browser
oharness-web
Markdown rendering, collapsible tool cards, file diffs, an artifact shelf, session tabs, a project switcher, model and permission pickers, attachments.
Where a session runs is set on the composer, beside the model and the permission mode — the three things this message is about to be sent under. It starts on the project already open, which is the answer almost every time, and the menu offers every directory opened here plus a folder dialog for one that has not been. It is there only until the session says something: a transcript belongs to the tree it was recorded in, so changing the folder before the first message starts the session again in the new one and throws the untouched original away rather than filing it in the sidebar.
Getting rid of a project is two different acts, and they are in two different places because they have nothing in common but the word. Remove from list — the ✕ on a row of the project switcher — forgets a path: nothing recorded is touched, and the project keeps its place in the sidebar for as long as it has sessions there, because the sidebar groups by the tree a transcript was recorded in rather than by that list. What removes a project from the sidebar is deleting its sessions, which is on the project header's menu, asks once, and says what it takes: transcripts, artifacts and the permission trail, here and on any device you sync with. The directory and its files are untouched either way. Deleting the project you are standing in moves the window to another one first.
A session carries its tree with it, and the tabs already open carry theirs — starting work in another project leaves them running, and a tab names its project as soon as two of them disagree. Clicking a transcript from another project in the sidebar does the same: it opens on that project's harness rather than moving everything across. The switcher at the top of the sidebar is still how you say "I am working here now", and the last tree a session was started or reopened in is the one the next run opens.
The composer points a message at things two ways, split by where the thing is.
+ reaches this machine. Attach files or photos and Attach folders open the platform's own dialog on the host the agent runs on, because the browser's file input deliberately withholds the path, which is right for a pasted screenshot and useless for a log the agent could simply open. They are two rows rather than one because the dialogs behind them are two: only macOS has a panel that takes a file and a folder in the same pass, and a row promising both would elsewhere open only one of them. Beside them are the MCP connectors this harness is connected to with their live tool counts, the plugins it loaded, and the upload that does send bytes. A pick becomes a chip, and the server turns the chips into a short block above the prompt: paths are named, not inlined, so the agent reads what it needs with the tools you can watch it use.
Under Connectors is the list of ones on offer — shipped as data in src/mcp/registry.json and layered over by ~/.oharness/connectors.json, so adding an entry to the menu never means editing code. Picking one does both halves: the server is connected now, so its tools are usable in the next turn, and the declaration is written to the user config so it comes back tomorrow. One that fails to start is removed again rather than left as a dead row with a line in a config file behind it.
A remote connector answers the first request with a 401, and that is not a failure — it is the start of a login, so the row says so and offers it instead of a red error. What follows is discovery, all of it, none of it configured: the challenge names the server's protected-resource document, that names an authorization server, its metadata names the endpoints, and a client is registered against it on the spot. The browser opens, the redirect lands on a loopback listener this process holds for the length of the flow, and the tokens go into auth.json under the same 0600 the model providers get. Nothing about that chain is per-vendor, which is the point: a server nobody has added a row for works exactly as well as one that has one.
The list stays short because it is a convenience, not a boundary. What it saves is knowing a URL. The bar for a row is that the server is published by whoever owns the data it exposes, speaks streamable HTTP rather than the older two-channel transport, and is usable after one click or one named variable; npm run verify:connectors asks each remote one who authorizes it and reports what came back, which is what the verified date in the registry means.
Tokens are resolved per request rather than captured when the transport is built — an access token outlives a long session by about an hour, and a header snapshotted at connect is a session that dies quietly in the middle. A refresh that fails is not fatal either: the stale token goes out, the server answers 401, and that path already knows how to ask for a new login.
Each row carries its vendor's mark, and the page does not fetch one. Twelve rows would otherwise be twelve third-party image requests, each telling a vendor that this user opened the menu before connecting anything — in a page whose whole claim is that it talks to nobody. So the server resolves the icon instead: it reads what the vendor's site declares in its own markup, falls back to /favicon.ico, checks that what came back is actually an image rather than a single-page app answering 200 with itself, and keeps the bytes on disk. The page only ever asks this origin. A site with no usable icon costs nothing — the row draws a lettered tile and the connector works exactly the same.
A server that wants a plain API key instead says ${env:NAME} — in a header, an argument, a URL, or a child process's environment. The reference is what gets written to the config file and the value is read from the environment when the server starts, so a config that is shared, synced or committed never contains the key. A variable that is not set is named before the connection is attempted, because "MCP_TOKEN is not set" is a sentence you can act on and whatever the server says about an empty credential is not.
Under Plugins is what this harness loaded, and the list is no longer only what somebody hand-copied into ~/.oharness/plugins. A plugin here is a manifest plus a skills/ and an agents/ folder, and so is a Claude Code plugin — the same idea arrived at twice, in two file layouts. Reading both is about twenty lines and it is the difference between a panel with an ecosystem behind it and a panel that is empty on a machine with twenty plugins already installed on it. Nothing is imported: the directory stays where its installer put it and is read where it lies, so updating it there updates it here.
What is adopted depends on where the plugin came from, and the line is between what the agent reads and what runs on its own. Skills and agents are text, consulted when the agent decides they are relevant. A hook is a command line that fires on a lifecycle event, and an MCP server is a process started at launch. Those two are read and reported — the row says what the plugin carries — but not activated from another program's directory, because the user who installed them there pointed them at that program. A server from one is added the ordinary way, through Connectors, with the confirmation and the login that path already has.
@ reaches the project. It fuzzily searches the tree from the caret and drops the path into the sentence at the point it was meant for — "compare @a with @b" says something two chips above the box cannot. It is also why the + carries no project browser: the files anyone names most are in the project, and @ already names them.
Reaching this port is equivalent to a shell on this machine. The agent behind it writes files and runs commands, so the server binds to loopback and requires a token generated fresh each run and never persisted. The comparison is constant-time: a plain
!==leaks a token a character at a time to anything that can measure the response.
The page is entirely self-contained — no CDN, no external fonts. A page that can drive a shell must not hand a third party script execution in that context. For the same reason the renderer builds DOM nodes and sets textContent; it never assigns innerHTML with content from a model, a tool result or a file.
Artifacts are the one exception, and they are the reason the rule is stated so precisely. An HTML artifact's whole value is the browser executing it, so it cannot go through that renderer — it is served from its own route, into an iframe with sandbox="allow-scripts" and no allow-same-origin, under a Content-Security-Policy whose connect-src 'none' is the load-bearing part: an opaque origin stops a frame reading a reply, not sending a request, and the server on the other end drives an agent that runs shell commands. That route is gated on a credential of its own rather than the session token, because a sandboxed document can read its own URL.
Analytics
Off. Nothing is sent anywhere unless the server is started with a PostHog project key, which is the one thing that turns it on:
| Variable | Effect |
|---|---|
OHARNESS_POSTHOG_KEY | A PostHog project key. Set, the page loads PostHog and reports feature usage; unset, no analytics code reaches the browser at all |
OHARNESS_POSTHOG_HOST | Defaults to https://us.i.posthog.com. EU cloud is https://eu.i.posthog.com; a self-hosted instance is its own origin |
Turning it on accepts the trade the paragraph above refuses, so it is worth knowing exactly what it buys and what it costs. What is sent: an event per action taken — app_opened, panel_opened, prompt_sent, model_switched, permission_answered — with enumerable properties only, such as which panel or which model. What is not sent: prompts, file paths, diffs, transcripts, session titles, or the page's URL. Autocapture is off, so no click is ever recorded by the text on the button; session replay is off; and the URL properties are denied twice over, because the URL carries the token that is equivalent to a shell. Nobody is identified: no person profile is created and identify is never called. src/analytics.ts is short and is the whole of it.
Desktop
cd desktop && npm install && npm start
An Electron window around the web frontend. It starts the server in-process, so there is one process to supervise rather than an orphan holding the port, and it hands the token to the page through a preload rather than the address bar — a URL carrying a token ends up in history, in the window title, and in every screenshot of a bug report.
Electron lives in desktop/, a separate package, so the published library keeps its single runtime dependency. A test enforces that.
Sync
oharness-sync --port 4483 # serve
oharness-sync token alice # issue a token, printed once
The optional half. Nothing here runs unless someone starts it: a harness with no sync block in its config never opens a socket, and the local store is complete on its own. It exists because two machines cannot hand each other a transcript without somewhere to leave it — not because the harness needs a backend.
It is deliberately dumb. It orders opaque blobs and hands them back; it does not parse messages, call models, or know what a tool is. That is what keeps it safe to leave running, and what keeps the message format free to change without a deploy.
TLS is not handled here. Put it behind a reverse proxy that terminates HTTPS: a bearer token over plaintext on an untrusted network is a token anyone on that network now has.