Architecture

August 1, 2026 · View on GitHub

Hearth is a desktop app around three things a chat window can't give you: a pane where the game runs while the agent edits it, bots that play it and leave evidence, and folders on your disk that are just folders. This page is how those are actually built.

One local server, one window

Electron's main process starts a plain Node HTTP server on 127.0.0.1 with an OS-assigned port, then loads the window at that URL. The server serves the built UI, a small JSON API under /api, and two static mounts. In development the exact same handler runs as a Vite plugin, so the renderer is byte-identical in both modes.

It is one server per app process, not one per folder: a project root is named per request (?project=<abs path>, a body field, or an encoded path segment), and folders you have deliberately opened this run form the jail every file route checks against. Requests are refused unless the Origin and Host are loopback. A request with no Origin at all is allowed, because that is what a CLI or an MCP server looks like.

The two mounts:

  • /game/<key>/<rel>: the folder itself, which is what the game pane's iframe loads. The root is encoded into the path rather than a query parameter so a game's own relative URLs (./main.js, assets/sprite.png) resolve correctly from inside the iframe.
  • /evidence/<key>/<rel>: the folder's .hearth/evidence, so screenshots the probe captured can be shown without copying them anywhere.

One multiplexed socket

Everything live rides a single WebSocket at /api/ws, subscribed to one project via ?project=. Sockets sharing a root share one channel (one journal watcher, one evidence watcher), which is disposed when the last of them leaves.

Frames, by family:

  • chat: chat-send, chat-open, chat-new, chat-cancel, chat-interrupt, chat-approval, chat-input-response, chat-commands-list, chat-handoff-cli up; chat-ready, chat-event, chat-opened, chat-list, chat-providers, chat-commands, and handoff ready/error acknowledgements down.
  • evidence / journal: batched watcher output, broadcast to every socket on the root.
  • pty: pty-start/input/resize/stop up, pty-data/exit/attach/error down. These are per-socket, never broadcast, and a detached terminal lingers for an hour so a reload reattaches to the same shell instead of killing it.
  • export: progress, done, error.

Approvals ride this socket rather than an HTTP round trip because the agent's turn is genuinely blocked until one arrives. Attachment bytes are the notable exception: they stream over a bounded HTTP upload, and only an opaque single-use token rides chat-send.

Chat drivers

One ChatDriver interface, three implementations, selected per turn:

  • agent-sdk: the Anthropic Agent SDK, one long-lived streaming query per conversation, rooted at the folder. The SDK runs the Claude Code CLI, which answers on the account it is signed into; an API key is an option, not a requirement.
  • codex: the open-source Codex CLI spawned as codex app-server, driven over stdio JSON-RPC.
  • stub: nothing usable is installed; it explains how to connect an agent.

A turn that names a different provider, model, or permission mode than the bound session rebinds the driver before the message goes out. Each chat keeps its backend's continuation handle (the Codex thread id, the Claude session id) in the chat index, and every bind passes both, so a rebind, a reload, or an app restart resumes the agent's own session rather than starting a stranger on the transcript. Stop calls the driver's interrupt(), which ends the running turn and keeps the session; teardown is reserved for drivers that cannot interrupt.

Every provider's output is folded into one provider-agnostic event vocabulary (message-delta, reasoning-delta, tool-begin, tool-output-delta, tool-end, file-change, approval-request, approval-resolved, subagent-start/delta/end, plan-update, image, notice, turn-complete, error), so the transcript renderer knows nothing about who answered. Adding a third backend means writing a mapping, not a renderer. The union only ever grows: the older event names are still parsed and upgraded at read time, so a conversation recorded before the vocabulary was extended replays exactly as it did. An approval can resolve as allow, deny, or withdrawn, the last meaning the session ended under the question rather than anyone answering it. Blocking provider questions and MCP elicitations follow the same persistence rule: only their shape and final action are recorded; answers, including secrets, go directly to the waiting provider and never enter the transcript.

Two mapping rules keep the transcript honest about actions neither backend had when the vocabulary was written. A Codex item type the adapter doesn't recognise becomes an ordinary tool row titled with the item's own type rather than nothing at all; and things that are not tool calls (a plan, a generated or viewed image, a compaction notice, a subagent) are mapped to their own event, from both backends, so the app never shows less than the terminal it replaced.

Attachments are streamed into project-scoped staging with size limits, then atomically consumed into .hearth/chats/attachments/<chatId>/ before the turn is queued. Each driver hands its backend a path: localImage / mention input items for Codex, base64 image blocks or an Attached file: line for the Agent SDK.

Drivers are keyed by (root, chatId), so two windows on one conversation share its agent. A detached driver lingers for an hour for reload and sleep/wake reconnection, while transcript snapshot and live writes share one conversation lane so an event is replayed or delivered live exactly once. Every turn and tool event is appended to .hearth/chats/<id>.jsonl as it streams; history survives anything the live driver doesn't.

“Continue in CLI” is an ownership transfer on that same lane. It rejects a running turn, stops and observes closure of the native driver, starts the PTY, and only then acknowledges the renderer. A lease keyed by the chat and exact PTY blocks native sends until that terminal exits, including while its socket is detached. See agents.md.

Dev Team orchestration

A Dev Team conversation keeps its lead on the ordinary chat driver and adds a server-owned state machine beside it. The lead writes and reviews the spec and plan; each build task receives a separate headless ChatDriver using the same provider binding. Hearth therefore schedules Claude and Codex identically without depending on either provider's native multi-agent feature.

The runtime persists state before broadcasting it. Engineer events use the normal chat event vocabulary, but carry an engineer id and are appended to a per-engineer transcript. Opening the conversation replays the durable snapshot and those transcripts on the same serialized lane as live updates, so the client reconstructs the same board without inventing a second event reducer. Dependencies and declared path scopes gate dispatch; the default concurrency is two and the hard maximum is four. See devteam.md for the user-facing lifecycle, files, and v1 limits.

Skills

Skills are folders under ~/.hearth/skills/, each with a SKILL.md (the format Claude Code and Codex both read), with ~/.hearth/skills.json holding the disabled list. They are global to the machine, and /api/skills is therefore the one route in this server that is not scoped to a project. That costs it the project jail every other file route relies on, so the validation carries the whole weight: ids go through a single-segment check, imported paths through a relative-path check, and request bodies through zod before anything touches the disk.

Reaching the two backends takes two different mechanisms, both re-applied on every bind: Codex is given the folder with skills/extraRoots/set on the app-server protocol, and the Agent SDK, which discovers skills from the filesystem around its cwd, gets the enabled ones symlinked into <project>/.claude/skills/, copied where the platform refuses a symlink.

The probe contract

The probe knows nothing about engines, formats, or 2D pixels. It drives one interface, GameUnderTest, whose implementations declare what they can do:

interface GameUnderTest {
  readonly capabilities: ProbeCapabilities;   // input vocabulary + senses
  start(); stop(); step();                    // sample one unit of game time
  setActionDown/Up(); setAxis(); sendPointer();
  listEntities?(); findEntity?(); screenshot?(); navGrid?(); reset?();
}

Two rules are structural, not conventions. Capabilities are declared, never assumed: the optional methods are literally absent unless the matching capability is true, and a check that needs a missing sense is skipped with a reason instead of silently passing. Nothing assumes determinism: the bot's RNG is seeded and replays exactly; the game's response is treated as a distribution.

@hearth/adapter-web is the implementation that ships, and it has two tiers:

  • Zero cooperation. Point it at a directory or a URL. It opens the page in headless Chromium, injects real keyboard and pointer input, captures PNGs, and collects console errors. Senses it can honestly claim: errors, screenshot, and reset by full page reload.
  • Shim. If the page exposes window.__hearthProbe, the adapter upgrades the declared capabilities to match what the shim provides: entities, scene ids, an event stream, a nav grid, a cheap in-page reset (probe-shim.md).

A connector for anything else (an engine, a device) is the same contract implemented differently (connect-your-engine.md).

The evidence bus

.hearth/evidence is the neutral bus between whatever ran the probe and whatever reads it. A sweep started from the CLI or by an agent over MCP writes the same files: an append-only journal.jsonl, a folded report.json per sweep, per-episode JSON, and screenshots.

There is no second progress channel. The journal is the progress, so two readers of one sweep can never disagree about it. It is appended as the sweep runs, keyed on a sequence number rather than a byte offset, so anything following along can re-read it at any point and a half-written trailing line is simply picked up whole next time.

The app does not run sweeps and has no surface for them. It serves the evidence folder over /evidence/ and counts the sweeps under it for the usage read-out; everything else about a sweep belongs to whoever ran it.

Reload, by polling

The game pane refreshes by polling /api/game/status every 1.5 seconds and comparing a timestamp: the newest file mtime under the game's directory, from a bounded walk that skips node_modules, .git, .hearth and friends. When it moves, the iframe URL's cache-buster changes and the pane reloads.

This is deliberately a poll and deliberately a timestamp. The agent writes files through whatever toolchain it likes, so there is no reliable "I finished" signal to subscribe to, and "something changed" is all the pane needs to know.

The harness registry

A folder's registry is the honest answer to "what can this Hearth reach?": the connectors it can talk to. Built-ins are facts about the binary and are never written to disk; anything you register lands in .hearth/harness.json. Every entry carries a status the app assigns: active (wired up and used now), available (registered, nothing consumes it yet), or coming-soon (named, not built).

The file also has a skills array, from when this registry held named slots for what Hearth knew how to do. It is still read and still returned by the route, and nothing in the app shows it any more: skills became real folders with real instructions, described above and in agents.md.