HOMUNCULUS CORE Documentation ◂ Walkthrough GitHub ↗
Start Here
Overview Getting Started FAQ & Troubleshooting
Using the Bridge
Widgets & Layout Computer Core BRIDGE Tab OSINT Tab HOME Tab DATA Tab ARCHIVE Tab CRYPTO Tab
Deploy & Operate
Deployment Configuration Reference Security Upgrading
Development
Architecture WebSocket Protocol Widget Development Backend Modules Screener Engine Contributing & Testing

Homunculus Core

A self-hosted command interface for your home, computers, and markets — powered by Claude. One console over live system telemetry, home automation, open-source intelligence, a confirm-first trading desk, and a permanent event archive, running on hardware you own and reachable only over a private mesh.

Homunculus Core is a hybrid client–server system: one standalone Node backend does all the work — telemetry, the Claude-powered Computer Core, a real terminal, Home Assistant, OSINT watchers, crypto market tooling, and a persistent event archive — and serves a React bridge UI that any browser, phone, or the Electron desktop shell can connect to over a single WebSocket.

Start Here

Getting StartedInstall, mint a Claude subscription token, first launch, and verifying every tab lights up. FAQ & TroubleshootingBlank windows, WebSocket failures, expired tokens, and other common snags.

Using the Bridge

Widgets & LayoutRearrange, resize, and move widgets between tabs; how layout persists server-side. Computer CoreThe Claude-powered assistant: sessions, agents, intents, scheduling, and the audit log. BRIDGE TabSystem vitals, the Computer Core chat, a real terminal, and Home Assistant tiles. OSINT TabSituational watchers on a 3D globe, with geofences and escalations. HOME TabThe full Home Assistant surface — devices, grouping, sectors, and routines. DATA TabDataset browsing and inspection. ARCHIVE TabThe persistent event spool, with optional Postgres capture. CRYPTO TabMarket view, screeners, the strategy runner, alerts, and the audit log.

Deploy & Operate

DeploymentElectron dev, browser dev, and Docker production — including Windows + Tailscale. Configuration ReferenceEvery environment variable and setting in one table. SecurityThreat model, WebSocket gating, secrets storage, and the terminal risk surface. UpgradingUpdating safely, state migrations, and rollback.

Development

ArchitectureProcess boundaries, data flow, and the decisions behind them. WebSocket ProtocolThe message types that are the app's real API. Widget DevelopmentBuild a new panel: registration, layout, and backend subscriptions. Backend ModulesConventions in server/ and how a subsystem plugs into the process. Screener EngineThe Python engine and its contract with the Node side. Contributing & TestingDev setup, test suites, and conventions.
Getting Started →

Getting Started

From a fresh clone to a lit-up bridge: install, mint the Claude token, launch, and verify every tab.

1. Requirements

  • Node 20 LTS. The repo pins it in .nvmrc — run nvm use in the checkout. No native-build toolchain is needed: the terminal uses @homebridge/node-pty-prebuilt-multiarch, which ships prebuilt binaries.
  • A Claude Pro/Max subscription and the claude CLI (npm install -g @anthropic-ai/claude-code) — used once, to mint the Computer Core token.

2. Install

git clone <your-homunculus-remote> homunculus-core
cd homunculus-core
nvm use
npm install

3. Mint the Computer Core token

The Computer Core chat runs on your Claude subscription via the Agent SDK — no per-token API billing. It needs a long-lived subscription token, generated by the claude CLI on any machine where you are logged in:

claude setup-token        # prints sk-ant-oat01-...

Copy .env.example to .env and paste the token where the backend reads it:

CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
# HOMUNCULUS_MODEL=sonnet        # optional model override (sonnet | opus | haiku | full id)
# HOMUNCULUS_TOKEN=...           # required once reachable beyond localhost
Note The token is just a string — it works fine minted on one machine and pasted into .env on another. Every other key in .env.example is optional and documented inline there.

4. Launch

Three ways to run the same system. All of them start the one Node backend (server/) that does the work; the desktop shell and the browser are thin clients on the same WebSocket.

Desktop dev (Electron + backend, hot reload)

npm run dev

This runs two processes together (via concurrently): the backend on port 8787 (npm run dev:server, a tsx watch of server/index.ts) and the Electron shell (npm run dev:app, electron-vite dev).

Browser dev

With npm run dev running, open http://localhost:5173 — the Vite dev server serves the renderer while the API stays on the standalone server at :8787.

Production build

npm run build:web
npm run start

The backend now serves the built web UI itself — open http://localhost:8787. Health check: http://localhost:8787/healthz should say ok.

Note On first launch a first-run wizard appears (it is tracked server-side in data/setup.json, so completing or skipping it once covers every client). Docker and Tailscale deployment are covered on the Deployment page.

5. Verify: does each tab light up?

The stock layout has six tabs. Some are fully alive out of the box; others stay in a live-only or empty state until you add the relevant keys to .env and restart the backend.

TabHealthy looks likeExtra config needed?
BRIDGE System Vitals streaming CPU/memory/network, a working PTY terminal, the Computer Core chat answering, plus Home Assistant tiles and an Open Trades widget in the right rail. The header ticker shows CORE LOAD / MEM / NET updating. Chat needs CLAUDE_CODE_OAUTH_TOKEN. The HA / Laundry / Litter Robot tiles need HA_URL + HA_TOKEN; Open Trades needs Gemini keys. Vitals and terminal work with nothing set.
OSINT The 3D globe renders with open-source watchers; geofences and escalations feed the archive. Works with defaults. OSINT_AISSTREAM_KEY and other OSINT_* vars (see server/osint.ts) enable/tune specific watchers.
HOME Home Assistant dashboard: devices, grouping, routines. Yes — HA_URL and HA_TOKEN (a long-lived access token minted from an admin HA account). The ./scripts/setup-home-assistant.sh / .ps1 wizard sets both up.
DATA Dataset browsing and inspection. Live-only without persistence. Set DATABASE_URL (and POSTGRES_PASSWORD for the bundled Docker Postgres) to enable history capture.
ARCHIVE The persistent event spool: proactive alerts, OSINT escalations, geofence breaches. Works out of the box; DATABASE_URL adds optional Postgres capture.
CRYPTO Market view, screeners, strategy runner, and audit log; the header shows a CRYPTO P&L ticker (reads FLAT with no open positions). Read-only market data works without keys. Portfolio and trading need GEMINI_API_KEY + GEMINI_API_SECRET. CMC_API_KEY (free, from coinmarketcap.com/api) is optional — it cross-checks the screener volume gate against cross-exchange volume; without it the gate falls back to Gemini-only.
Warning Before exposing the backend beyond localhost (e.g. over Tailscale), set HOMUNCULUS_TOKEN in .env. The server refuses remote requests outright without one, and remote clients must pass it as ?token=... in the URL.
← OverviewFAQ & Troubleshooting →

FAQ & Troubleshooting

The failure modes people actually hit, and what each one means.

Desktop app

The desktop app shows "NO BACKEND AT http://localhost:8787" (or a blank/error page)

The Electron shell is a client, not the whole thing — it is a window onto the backend and does not contain or start one. When nothing answers at its target URL, it shows a waiting screen and retries every 3 seconds, connecting by itself the moment the backend is up (see electron/main.ts). Fix: start a backend first —

npm run start          # from a checkout
# or
docker compose up

To point the app at a backend on another machine, launch it with HOMUNCULUS_URL=http://your-host:8787. (If you see Chromium's raw ERR_CONNECTION_REFUSED page instead of the waiting screen, you are on a build older than the waiting-screen fix — update.)

macOS says "Homunculus is damaged" or "unidentified developer"

Builds from this repo are not notarized, so Gatekeeper blocks downloaded copies. That warning is about the absent Apple signature, not the contents. Clear the quarantine flag after installing:

xattr -dr com.apple.quarantine /Applications/Homunculus.app

To produce notarized builds yourself you need a paid Apple Developer account with a Developer ID Application certificate, then NOTARIZE=1 npm run dist:mac with APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, and APPLE_TEAM_ID exported.

Connection & auth

The WebSocket won't connect from my phone / another machine

Two gates, in order:

  1. HOMUNCULUS_TOKEN must be set on the server. If it isn't, the server fails closed: every non-localhost request is refused with 503 ("HOMUNCULUS_TOKEN is not configured — remote access is refused until it is set"). That is the safe failure, but it also means a broken phone view.
  2. The client must present the token — as ?token=... in the URL (or the x-homunculus-token header). It gates the WebSocket upgrade and the sensitive REST routes (crypto). Bookmark the full URL, e.g. http://<tailscale-ip>:8787/?token=<HOMUNCULUS_TOKEN>.

Also confirm both devices show as connected in Tailscale, and note the Docker container publishes to 127.0.0.1:8787 only by default — put it on the tailnet deliberately (tailscale serve, or bind the tailnet IP in docker-compose.yml).

The Computer Core says 401 / invalid credentials, or won't answer at all

CLAUDE_CODE_OAUTH_TOKEN is missing or expired. Re-mint it and restart:

claude setup-token     # prints a fresh sk-ant-oat01-... token

Put it in .env (or the container env) where the backend reads it — it is a backend key, not a client one. Under Docker, run .\scripts\homunculus.ps1 rebuild (Windows) or restart the compose stack so the new value is picked up.

The embedded terminal prints "[ Terminal blocked ] HOMUNCULUS_TOKEN required to open a shell."

Opening a shell from a remote client requires HOMUNCULUS_TOKEN, same as the other sensitive routes. Set it in .env and include it in the URL you're connecting from. It fails closed by design: an unset token refuses remote shells rather than allowing them.

Docker

System Vitals and the terminal show Linux, not my Windows/macOS host

Expected under Docker. The container is a small Linux VM, so telemetry and the embedded terminal reflect the container, not the host (see WINDOWS.md). Everything else — Computer Core, Crypto, OSINT, Home Assistant — works fully. If host-level metrics matter, see the commented host-scope options in docker-compose.yml, or run the backend natively with npm run start instead.

docker compose aborts with "env file not found"

Compose reads .env; create one before the first up:

cp .env.example .env    # then fill in CLAUDE_CODE_OAUTH_TOKEN and HOMUNCULUS_TOKEN
docker compose up --build

My data disappeared after a rebuild

You are likely on an old docker-compose.yml without the volumes: block — pull the latest. State lives in named volumes (homunculus-data → /app/data, homunculus-private → /app/private, homunculus-pg for Postgres), so rebuild/git pull won't wipe it.

Home Assistant

HOME tab mostly works, but the DEVICES tab fails with 401

HA_TOKEN must be a long-lived access token created from an admin Home Assistant account. A token inherits the admin status of whoever made it, and a non-admin token fails in a genuinely confusing way: reading state, controlling lights, and running scenes all work perfectly — only the DEVICES tab breaks, because Home Assistant gates config flows (adding/removing integrations) on admin and answers 401. Mint a new token from an admin profile (HA → your profile → Security), update HA_TOKEN in .env, and restart. The ./scripts/setup-home-assistant.sh / .ps1 wizard verifies the token against /api/config and /api/states before writing anything.

Terminal & ports

The terminal won't start / node-pty problems

The terminal uses @homebridge/node-pty-prebuilt-multiarch, which ships prebuilt binaries — under plain Node 20 (the npm run start / Docker path) no native build is needed. Check you are actually on Node 20 LTS (nvm use, per .nvmrc): a different major version can miss the prebuilds. If the module was compiled against the wrong ABI for the Electron dev shell, rebuild it:

npm run rebuild        # electron-rebuild -f -w @homebridge/node-pty-prebuilt-multiarch

Also note the Terminal widget is a singleton — a second instance would fight the first over its PTY session id, so the widget picker refuses to place another.

Port 8787 (or 5173) is already in use

The backend defaults to 0.0.0.0:8787; both halves are overridable in .env:

# HOMUNCULUS_HOST=0.0.0.0
# HOMUNCULUS_PORT=8787

If you change the port, point clients at the new one (browser URL, and HOMUNCULUS_URL for the desktop app). Port 5173 is the Vite dev server used only by npm run dev; kill the stale dev process holding it, or just use the production path (npm run build:web && npm run start) which serves everything from :8787.

Other

The DATA / ARCHIVE tabs show live data but keep no history

History capture is optional Postgres. Leave DATABASE_URL blank and the tabs run live-only. To enable with the bundled Postgres service: set POSTGRES_PASSWORD (compose refuses to start the database without one — there is deliberately no default), set DATABASE_URL to match, then docker compose --profile history up -d.

The layout got mangled — how do I get the stock arrangement back?

The server sanitises every layout it loads or receives, so corruption degrades to defaults rather than a blank app (a warning like [layout] unreadable layout.json, using defaults appears in the backend log). To reset deliberately, click RESET LAYOUT TO DEFAULTS in ⚙ Settings (or delete data/layout.json and restart) — resetLayout() in server/layout.ts drops back to the shipped arrangement.

Note Under the Windows/Docker setup, .\scripts\homunculus.ps1 logs tails the backend log and .\scripts\homunculus.ps1 status shows container state — the first stop for anything not covered above.
← Getting StartedWidgets & Layout →

Widgets & Layout

Every tab is a 12-column grid of widgets — rearrange, resize, move them between tabs, and the arrangement persists on the server so every client agrees.

Tabs are grids of widgets

The tab bar and the contents of every tab are data, not code. src/App.tsx renders whatever the layout config says: which tabs exist, in what order, which are enabled, which one opens on launch, and which widgets sit where inside each one. A "widget" is any panel from src/panels/* registered in src/widgets/registry.tsx.

Placement is on a 12-column grid (GRID_COLS in shared/layout.ts); vertical position and height are in row units. Each placement records an instance id, the registry widget key, and its x/y/w/h. The big whole-tab dashboards (OSINT, HOME, DATA, ARCHIVE, CRYPTO) are widgets too — they just default to a full-width, full-height placement, so a stock install looks like fixed pages.

Rearranging

Edit mode (drag placement)

  • Click ⠿ EDIT LAYOUT in the header. Drag a widget's header to move it, drag the corner to resize.
  • Move a widget to another tab by dragging: while dragging, drop it onto a tab chip in the tab bar — the target chip highlights, and the widget relocates to that tab.
  • Click ✓ DONE to leave edit mode.

SETTINGS → WIDGETS (add, remove, shift)

Open ⚙ Settings. The settings dialog has four sections: TABS, WIDGETS, KEYS, and SYNC.

  • WIDGETS lists what is placed on each tab and a picker of every available widget, grouped by category. From here you can add a widget to the selected tab, remove one, or move one to a different tab via a dropdown. Singleton widgets already placed somewhere show as "Already placed — this panel can only exist once".
  • TABS controls tab order (▲▼), enable/disable, the ★ launch tab, and adding/removing custom tabs. The six shipped tabs are builtin: they can be disabled and reordered but not deleted, and the UI never lets you disable the last enabled tab.

Server-side persistence

The layout lives on the server, not in localStorage — so the Electron shell, the local browser, and a phone over Tailscale all show the same dashboard.

  • shared/layout.ts — the model (LayoutConfig, TabConfig, WidgetPlacement), the stock defaultLayout(), and sanitizeLayout(), which normalises anything read off disk or POSTed by a client: unknown fields are dropped, bad numbers clamped, and a layout with zero tabs falls back to the stock one rather than blanking the app.
  • server/layout.ts — the file-backed store. It reads once at startup and writes through on every mutation to data/layout.json (the directory is overridable with HOMUNCULUS_DATA_DIR). A corrupt layout.json does not brick the UI — the server logs a warning and falls back to defaults. resetLayout() drops back to the shipped arrangement, and the first-run-wizard flag is stored alongside in data/setup.json.
Note Because a malformed client POST is sanitised into a valid layout, the worst a bad edit can do is degrade to the stock arrangement — never persist garbage.

Widget catalog

The full registry, from src/widgets/registry.tsx. Adding an entry there is the only step needed to make a new panel placeable — it then shows up in SETTINGS → WIDGETS. Singleton widgets can exist only once across all tabs (a second Terminal instance would fight the first over its PTY session id).

Widget idLabelCategoryNotes
system.vitalsSystem VitalsCORELive telemetry snapshot
core.terminalTerminalCORESingleton — real PTY
core.computerComputer CoreCORESingleton — Claude chat
home.assistantHome AssistantHOMECompact HA tile
home.laundryLaundryHOMEReads HA entities
home.litterLitter RobotHOMEReads HA entities
home.colonyColonyHOMEReads HA entities
home.ambientAmbientHOMEReads HA entities
crypto.opentradesOpen TradesCRYPTOOpen crypto positions
dash.homeHOME dashboardDASHBOARDSingleton, whole-tab
dash.osintOSINT dashboardDASHBOARDSingleton, whole-tab
dash.dataDATA dashboardDASHBOARDSingleton, whole-tab
dash.archiveARCHIVE dashboardDASHBOARDSingleton, whole-tab
dash.cryptoCRYPTO dashboardDASHBOARDSingleton, whole-tab
misc.networkNetwork StatusMISCPlaceholder — "uplink module pending"
misc.trafficNetwork TrafficMISCPlaceholder — "graph pending"

The stock arrangement

defaultLayout() in shared/layout.ts ships BRIDGE as a composed 2 / 7 / 3-column tab — System Vitals rail, Terminal over Computer Core in the middle, and a right rail of Home Assistant, Open Trades, Laundry, and Litter Robot tiles — followed by OSINT, HOME, DATA, ARCHIVE, and CRYPTO as full-bleed dashboard widgets. BRIDGE is the default launch tab.

← FAQ & TroubleshootingComputer Core →

Computer Core

The Claude-powered ship's computer: how it runs, what it is allowed to touch, the agents that work alongside it, and the audit log that records everything they do.

Where and how it runs

The Computer Core is driven by the Claude Agent SDK (@anthropic-ai/claude-agent-sdk) inside the backend Node process — deliberately not Electron's main process, which crashed when this was tried (see the README's architecture section). Because it lives in the backend, the same assistant serves the desktop shell, a browser tab, and a phone over Tailscale.

It authenticates with your Claude subscription, not a per-token billed API key. You mint a long-lived token once with claude setup-token and put it where the backend reads it:

CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...   # required (from an admin account)
# HOMUNCULUS_MODEL=sonnet                  # optional model override

HOMUNCULUS_MODEL is the server-wide model override: when set, it is passed as the model option on every Computer Core turn and every proactive check, and it is the default for agent runs (individual agents can pin their own model). When unset, the sessions use the Claude CLI's default, and the chat widget reports the model as local session. Forcing the subscription path is deliberate — ANTHROPIC_API_KEY is on the never-forwarded list (below), so a billed key in the server env cannot leak into these sessions.

Session lifecycle

Each WebSocket connection gets its own ChatSession (server/chat.ts). The first turn starts a fresh Agent SDK session; the server captures the SDK's session_id and passes it back as resume on every later turn, so the conversation keeps its context for as long as the connection lives. If a turn fails, the stored session id is dropped on purpose: a corrupt or expired id would otherwise make every subsequent turn resume the same broken conversation, so the next turn starts fresh instead — the conversation's context is the price of getting the feature back.

Every turn is wrapped in the system persona (the Star Trek ship's-computer voice that addresses you as "Captain") and prefixed with a live context block: a compact PC telemetry summary (CPU, memory, storage, network, uptime, top processes) and a home-state summary from the latest Home Assistant snapshot. State questions are answered from that data without any tool call.

What it can do — and what it cannot

The Computer Core chat runs with allowedTools: [] — no SDK tools at all. It cannot read files, run shell commands, or browse. Its only lever on the world is the exec block: it embeds <exec>{"type":"routine","name":"goodnight"}</exec> or <exec>{"type":"ha","entityId":"switch.voltaire_charger_switch","service":"switch.turn_on","data":{}}</exec> in its reply. The server strips those blocks out of the text, executes them in order through executeRoutine / executeHaCommand (server/routines.ts), and appends a ✓/✗ result line to the chat. The model asks; the server acts.

The environment allowlist (agentEnv)

Every Agent SDK child this server starts — the Computer Core chat, the proactive monitor, fleet agents, strategy skill runs — gets its environment from agentEnv() (server/agentEnv.ts), which is an allowlist, not a denylist. Nothing reaches a child process unless it is named there:

  • System keys — PATH, HOME, TEMP, and the rest of what a process needs to run at all.
  • App config — HOMUNCULUS_MODEL and HOMUNCULUS_PORT only.
  • The subscription token — CLAUDE_CODE_OAUTH_TOKEN.

A NEVER_FORWARDED list states the intent explicitly: GEMINI_API_KEY/GEMINI_API_SECRET (the exchange keys), HA_TOKEN/HA_URL, DATABASE_URL, HOMUNCULUS_TOKEN/HOMUNCULUS_ADMIN_TOKEN, CMC_API_KEY, the OSINT feed keys, and ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN. A new credential added to .env is therefore safe by default — no Claude session sees it until someone deliberately adds it to the allowlist.

The process registry

Every live Claude session is registered in server/claudeProcesses.ts under a kind — core-chat, proactive, agent, agent-chat, agent-handoff, or skill (shared/claude.ts). The registry makes the sessions enumerable and stoppable: stopping one aborts the SDK's AbortController, which closes stdin and gives the child a ~2-second grace window rather than a hard kill, and the stop itself is written to the audit log as a claude.stop entry with who stopped what and how long it had run.

The proactive monitor

A background sibling of the chat (ProactiveMonitor in server/chat.ts). It watches Home Assistant state transitions — a washer or dryer cycle reaching end, car charging stopping, a litter-robot fault code, the waste drawer crossing 80% — then waits out a 45-second debounce and asks Claude one self-contained question over the home-state snapshot. If the model answers anything other than SILENT, the text is broadcast to every client as a CORE ALERT toast (and into the ARCHIVE spool), rate-limited to at most one alert per 5 minutes. These checks deliberately never resume a session: each one is handed a full snapshot, so a persistent conversation would only grow context without bound.

The agents subsystem

"Agent" means two related things in Homunculus, and both are built on the same principle: authority lives in server code, never in the prompt.

Fleet agents (the INTELLIGENCE desk)

A fleet agent (shared/agents.ts, server/agents.ts) is an employee you hire in the CRYPTO tab's INTELLIGENCE section. You write its mandate — free text describing its job — and the server turns that into a headless Agent SDK session with live portfolio context. Each agent has:

SettingMeaning
autonomyadvisory (may only talk — every trade proposal is refused), propose (trades land in your confirm queue), or auto (executes directly up to its cap).
maxUsdPer-trade notional ceiling, itself capped by a global AGENT_MAX_USD_CEILING of $250. Auto spend is additionally bounded by a rolling 24-hour budget of 10× the per-trade cap, tracked in a time-trimmed spend ledger the agent cannot flush.
modelOptional per-agent model pin (Opus / Sonnet / Haiku / Fable ids, or empty for the server default, i.e. HOMUNCULUS_MODEL).
intervalMinutes, eventsWhat wakes it: a timer, and/or portfolio events — new signal, order fill, drawdown past drawdownPct, new proposal, @mention.
cooldownMinutes, idleStanddownMinutesMinimum gap between automatic runs, and how long an idle agent keeps its resumed chat session before writing a handoff note and standing down.

How a run happens. Triggers are a manual RUN, the interval, portfolio events, market alerts that name the agent (wakeAgentId), a blocker being answered, or a manager assignment. Only one agent session runs at a time process-wide (MAX_CONCURRENT_RUNS = 1); when several are eligible, pickRunOrder (shared/agentScheduling.ts) decides who gets the slot by what the wake is for — manual first (priority 120), then answers (100), assignments (90), alerts (70), market events (60), interval (40), mentions (20), standdown (5) — with ties broken by who has waited longest. A run is force-failed and aborted after 10 minutes and capped at 60 turns.

How authority is enforced. An agent asks for a trade by POSTing to /api/crypto/agents/:id/propose, authenticated by a per-agent propose key injected only into that agent's own prompt (compared in constant time). The server — never the agent — rules each proposal refused, staged, or executed against the autonomy dial, the per-trade cap, the global ceiling, and the rolling budget. The mandate is untrusted input: it can claim any authorization it likes and still be refused at advisory.

The HOME agent uplink: manifests, intents, plans

For the house, an agent (or you, typing a sentence) operates through a compile-then-execute pipeline — nothing fires directly from text.

  • Manifest (shared/agentManifest.ts) — the contract: every action this app is willing to expose, derived from the live Home Assistant entity list, so it describes your house. Each action has a stable id (<entityId>:<verb>, e.g. light.den:set_brightness), a payload schema, and a guardrail tier: read, write (routine and reversible — an agent may do it alone, logged), or confirm (a human must approve that specific op). Unlocking a lock and every cover movement are confirm-tier; domains absent from the table (sensors, trackers, …) have no actions at all — omission is the primary defence.
  • Intent (server/agentIntent.ts) — the compiler. Free text ("movie mode, but keep the reading lamp on and lock up") or structured ops from an LLM compile into a numbered plan against the manifest. The compiler is deterministic keyword matching, never invents an entity, and flags its inferences (closing an open garage door because you said "lock up") as notes on confirm-tier ops. Fragments it cannot compile are reported in unmatched, never silently dropped.
  • Plan (shared/agentPlan.ts) — the inspectable artifact: numbered ops with entity, service, payload, and tier. Execution re-validates every op against the manifest (the plan round-trips through the client, so it is untrusted by the time it comes back), supports dry runs, holds confirm-tier ops until their specific number is approved, and keeps going past a failed op so "lock both doors" still locks the second when the first errors. Every executed op is audited as ha.agent.execute.
  • Scheduling (shared/agentScheduling.ts) — used by the fleet, as described above: trigger priority, then staleness, then id, so the run order never depends on map iteration order.

The audit log

server/auditLog.ts keeps an append-only, hash-chained record of every state mutation, in two places at once:

  • data/audit/audit-YYYY-MM.jsonl — the write-ahead log, written first and synchronously, one JSON line per entry. Files rotate monthly, but the chain and the seq counter continue across rotation. This file is never rewritten or trimmed; a correction is a new entry referencing the old one, never an edit.
  • Postgres audit_log table (when DATABASE_URL is set) — the queryable mirror, guarded by BEFORE UPDATE OR DELETE triggers that make it refuse changes outright. Entries stream in behind the file and backfill on reconnect; without a database the log is simply file-only.

Each entry records a seq, timestamp, actor (carried implicitly through AsyncLocalStorage — the operator on an HTTP request, agent:<id> inside an agent run, system otherwise), origin, action, resource, a human-readable summary, optional before/after state, and the sha256 of the previous entry. Editing or deleting any line breaks every hash after it, and GET /api/audit/verify re-derives the whole chain and cross-checks every Postgres row against the file — tampering has to succeed in both independent copies to go unnoticed, including rows inserted into the table that no file ever contained.

Actions recorded include, among others: agent.create / agent.update / agent.remove (with the exact before → after diff when an autonomy dial or cap is widened), agent.run.start, agent.trade.executed / .staged / .refused, ha.agent.execute for every uplink op, and claude.stop when someone kills a live session. The CRYPTO tab's AUDIT view reads this log.

Danger Treat the Claude sessions this server starts as having your hands. They run with permissionMode: 'bypassPermissions' — no interactive permission prompts — and while the Computer Core chat itself has no tools, fleet agents get an unrestricted shell on the backend host, with their prompts assembled partly from text other agents wrote and from live market and home-state strings: a prompt-injection surface pointed at a shell. An assistant with terminal and file access can read and delete files the server user can, exfiltrate anything in its environment, and call any local API. The mitigations described on this page are what stand between that and your keys: the agentEnv allowlist keeps exchange keys, HA_TOKEN, and admin tokens out of every session; trade authority is enforced server-side with hard caps; house actions go through the tiered manifest; and everything lands in the tamper-evident audit log. Do not weaken these — in particular, never add a credential to the agentEnv allowlist casually, and keep HOMUNCULUS_TOKEN set on any backend reachable beyond localhost.
← Widgets & LayoutBRIDGE Tab →

BRIDGE Tab

The default tab: live system vitals, the Computer Core chat, a real embedded terminal, and Home Assistant tiles, arranged on the standard widget grid.

What is on the tab

BRIDGE is the tab the bridge opens on (defaultTab: 'BRIDGE' in shared/layout.ts). Its stock layout is a 2 / 7 / 3 column split on the 12-column grid:

WidgetRegistry idDefault position
System Vitalssystem.vitalsLeft rail, full height
Terminalcore.terminalCenter, top half
Computer Corecore.computerCenter, bottom half
Home Assistanthome.assistantRight rail
Open Tradescrypto.opentradesRight rail
Laundryhome.laundryRight rail
Litter Robothome.litterRight rail

Like every tab, this is only the starting arrangement — widgets can be moved, resized, or swapped for others from SETTINGS → WIDGETS, and the layout persists server-side so the desktop shell and any browser see the same grid. Terminal and Computer Core are singleton widgets: a second Terminal instance would fight the first over its PTY session id, so the picker allows at most one of each.

System Vitals

The left rail renders the live telemetry stream. On the backend (server/telemetry.ts), a single collector samples the machine through the systeminformation package on a 2-second interval, and the interval only runs while at least one client is subscribed. Every snapshot is collected once and fanned out to all connected clients; a newly connected client is primed immediately with the latest snapshot.

Each TelemetrySnapshot (typed in shared/telemetry.ts) carries:

  • CPU — aggregate load, per-core load with a rolling 24-sample history (48 seconds at the 2s cadence), clock speed in GHz, and CPU temperature (current and max) where the platform exposes sensors.
  • Memory — active/total bytes, percent used, and swap percent.
  • Storage — used/total/percent of the largest filesystem.
  • Network — receive and transmit rates in Mb/s summed across interfaces, plus lifetime byte totals.
  • Processes — total task count and the top 6 processes by CPU.
  • Uptime — seconds since boot.

The widget (src/panels/SystemVitals.tsx) shows up to 8 CPU cores as sparklines with live percentages, RAM and SWAP meters, TEMP / MAX / TASKS stats, and a scrolling Top Processes table (pid, name, CPU%, with anything at 50%+ highlighted in amber). It is read-only — there are no controls.

Computer Core chat

The conversational interface to the Claude-powered assistant. The full assistant is documented on the Computer Core page; the widget itself (src/panels/ComputerCore.tsx) exposes:

  • Transcript — turns labeled COMMAND (you), COMPUTER (the assistant), and CORE ALERT (proactive messages the backend broadcasts on its own, e.g. an appliance finishing). Streaming turns show a blinking cursor.
  • Quick commands — one-tap buttons for Status sweep, Home state report, Goodnight routine, and Away mode.
  • Input line and EXEC button — Enter or EXEC submits; both are disabled while a turn is in flight.
  • Status readout — STANDING BY, WORKING, or NO SESSION. When no local Claude session is configured, the input is disabled with the placeholder Set CLAUDE_CODE_OAUTH_TOKEN in .env to engage….
  • Model line — the active model (the HOMUNCULUS_MODEL override, or local session when unset).

On the wire this is the chat channel: the client sends a turn with a client-generated id, and the server streams back delta messages followed by done (or error) correlated to that id (shared/chat.ts).

Terminal

A real shell, not an emulation of one. The frontend (src/panels/Terminal.tsx) is an xterm.js terminal with 1000 lines of scrollback that auto-fits its widget cell and re-fits on resize; the backend (server/terminal.ts) spawns a genuine PTY per terminal via @homebridge/node-pty-prebuilt-multiarch.

  • Which shell: on Windows, COMSPEC or powershell.exe; elsewhere, $SHELL or /bin/bash. It starts in the server user's home directory with TERM=xterm-256color.
  • Coalesced output: PTY output is buffered and flushed roughly once per frame — every 16 ms or whenever 64 KB accumulates — so a torrent of output becomes about one WebSocket message per frame instead of thousands of tiny ones (the eDEX-UI RAM problem the comment in the file names).
  • Protocol: the term channel carries start (with cols/rows), input, resize, kill, data, and exit messages (shared/terminal.ts). Closing the widget kills its PTY; disconnecting kills all PTYs for that connection.
  • Graceful degradation: if the native PTY module is missing, only the terminal goes offline — it prints [ Terminal offline ] with instructions to run npm run rebuild, and the rest of the server keeps working.
Warning The shell runs on the backend host, as the user the backend runs as. In Docker that means it is the container's shell, not your host machine's — by default telemetry and the terminal both reflect the container. See the commented host-scope options in docker-compose.yml if you want to monitor or shell the host instead. Anyone who can reach the bridge UI can type into this shell, which is one reason remote access requires HOMUNCULUS_TOKEN.

Home Assistant tiles

The right rail is Home Assistant at a glance. All of these render from the HA snapshot the backend polls over the HA REST API (HA_URL + HA_TOKEN); when HA is not configured they show a setup hint or hide themselves entirely.

Home Assistant (climate)

src/panels/HomeAssistant.tsx shows a LINKED / OFFLINE / CONNECTING status, an OPEN ↗ link to the HA web UI (with the hostname rewritten when you're browsing remotely, so the link still points at the machine running HA), and a card per climate entity: name, HVAC action badge (heating in crimson, cooling in blue, idle dimmed), current temperature, target (single or low–high range), and relative humidity. Clicking a card opens a thermostat control — the default variant is a dual-handle range slider — which sends climate.set_temperature and climate.set_hvac_mode service calls back through the WebSocket.

Laundry

Animated washer and dryer tiles built from HA sensors (sensor.washer_current_status, remaining/total time, power switches). Each shows a progress ring, the current phase (washing, rinsing, spinning, drying, cooling), time remaining, and a "Complete" state with when it finished. Controls send real HA service calls: power via switch.turn_on/turn_off and cycle operations via select.select_option.

Litter Robot

Status tile for a Litter-Robot exposed through HA: litter level and waste-drawer percentages (color-shifting as they run low or fill up), dock/cleaning state, cat-detected state with the pet's weight, and hopper status.

Colony and Ambient

Two further HOME-category tiles that can be placed on BRIDGE: Colony is a per-cat table of litter-box visits today and last known weight; Ambient shows the current weather condition and temperature, the next sunset or sunrise, and the HA backup manager's state. Both are read-only.

Note The laundry, litter, colony, and ambient tiles key off specific entity ids from the author's own HA install (e.g. sensor.washer_current_status, vacuum.r2peepoo_litter_box, weather.forecast_home). If your HA does not expose those entities, the tiles render nothing — the generic climate widget and the HOME tab work with any HA instance.

Open Trades

A compact CRYPTO-category widget included in the stock BRIDGE layout, showing open positions from the crypto subsystem. The full trading surface lives on the CRYPTO tab.

← Computer CoreOSINT Tab →

OSINT Tab

Open-source situational watchers — seismic, military air traffic, maritime AIS, space weather, cyber threat feeds, service outages, and the Pentagon Pizza Index — fronted by a 3D globe with an armable home-perimeter geofence.

How it works

The OSINT hub (server/osint.ts) is a backend cron: it polls public internet sources on independent timers, normalizes each into a typed snapshot (shared/osint.ts), persists key state to disk so the UI has data instantly on reconnect and across restarts, and fans a combined snapshot out to every connected client over the osint WebSocket channel. Because all the watching happens server-side, escalation alerts fire even when no UI client is connected.

Data sources

WatcherSourceDefault cadence
Pentagon Pizza Index (PizzINT)Busyness readings for pizzerias near the Pentagon, from public "Popular Times"-style data via a PizzINT edge function5 min (OSINT_POLL_MS)
Seismic WatchUSGS earthquake GeoJSON feed (M2.5+, past day)3 min (OSINT_SEISMIC_MS)
SkywatchMilitary ADS-B aircraft from adsb.fi (/api/v2/mil)60 s (OSINT_AIRCRAFT_MS)
Geomagnetic / SolarNOAA SWPC planetary K-index + OVATION aurora forecast5 min (OSINT_GEOMAG_MS)
Cyber ThreatCISA Known Exploited Vulnerabilities (KEV) catalog + abuse.ch Feodo Tracker botnet C2 blocklist60 min (OSINT_CYBER_MS)
Service OutagesOfficial Atlassian Statuspage feeds (/api/v2/summary.json) for 12 default services: Discord, GitHub, Cloudflare, OpenAI, Anthropic, DigitalOcean, Reddit, Zoom, Twilio, Datadog, Atlassian, Coinbase2 min (OSINT_OUTAGE_MS)
IP WatchYour public WAN address, from keyless IP-echo endpoints (ipify → ifconfig.co → ipinfo.io, tried in order)5 min (OSINT_IPWATCH_MS)
Vessel AISaisstream.io position reports over a persistent websocket — dormant until OSINT_AISSTREAM_KEY is setStreaming, flushed to clients every 5 s (OSINT_VESSEL_FLUSH_MS)

When a poll fails, the affected source is flagged cache (or error) with the error message, and the rest keep serving — the masthead's source pills (QUAKE, AIR, …) show each feed's origin as green (live), amber (cache), or crimson (error). A source with no key shows as OFFLINE with instructions (the vessel card tells you to get a free key at aisstream.io and set OSINT_AISSTREAM_KEY in .env).

The 3D globe

The tab is fronted by a globe.gl/three.js globe (src/panels/OsintGlobe.tsx) styled to the bridge's phosphor aesthetic, code-split so the heavy three.js bundle only loads on this tab. Each dataset is a toggleable overlay layer, with a toggle bar showing live counts:

  • SEISMIC — USGS quakes as pulsing rings plus clickable core points
  • SKYWATCH — military aircraft as points; emergency squawks render in crimson
  • VESSELS — AIS ships as blue points
  • CYBER — botnet C2 servers as crimson points, placed at their country's centroid (server/country-centroids.ts) since the feed carries country codes, not coordinates
  • AURORA — the NOAA OVATION aurora oval as a heatmap (cells below 25% probability are dropped, capped at 700 cells)

Interactivity: click any point to fly to it and open a HUD, an auto-track mode follows priority events, a home marker shows your pinned location, and a live view-center readout tracks where you are looking. Payloads are bounded — at most 300 aircraft and 800 vessels are shipped per tick.

Side cards

Alongside the globe (src/panels/OsintDashboard.tsx), collapsible cards summarize each feed: a Kp-index gauge with G-scale badge and sparkline; the seven strongest recent quakes (with tsunami flags); airborne military aircraft sorted emergency-first; the Pentagon Pizza Index gauge with DEFCON posture, anomaly count, and per-venue busyness bars; the KEV catalog total with recently exploited CVEs (ransomware-linked ones flagged) and active C2 servers; service outage status per tracked service; your public IP with click-to-copy and how long it has been stable; and the geofence card described below.

Geofence (home perimeter watch)

A radius around a pinned home location. To set it up: click SET HOME in the masthead, then click the globe to pin home (stored in the browser's localStorage). Choose a radius from the stepped presets — 25, 50, 100, 150, 250, 500, or 1000 km (default 150) — and arm the perimeter.

The armed config is pushed to the server (a geofence message on the osint channel) where it is persisted and enforced hub-side: on every poll, the hub checks each geo-bearing feed — quakes, aircraft, vessels — against the perimeter with a great-circle distance test, so the fence stays armed even with no UI client connected. Fresh crossings are recorded as breaches (the card lists the most recent, with kind icon, label, distance, and age; the server retains up to 40) and fire a proactive alert. Details that keep it sane:

  • Priming — the first check per feed kind after arming (or after a server restart that reloads an armed fence from disk) seeds the inside-set silently, so arming never alerts on what was already inside the perimeter.
  • Re-arming — an event that leaves the perimeter and re-enters counts as a fresh breach.
  • Cooldown — repeat breach alerts are suppressed for 5 minutes; the breach list still records everything.

Alert levels and escalation rules

Each watcher carries its own severity scale, defined in shared/osint.ts:

ScaleLevelsDerivation
Pizza DEFCON5 ALL QUIET · 4 ELEVATED · 3 ACTIVE · 2 HIGH ALERT · 1 SURGEFrom anomaly count and average deviation over baseline (e.g. 6+ anomalies or 80%+ average deviation → DEFCON 1)
Geomagnetic G-scaleQUIET · G1 MINOR · G2 MODERATE · G3 STRONG · G4 SEVERE · G5 EXTREMEMapped from the planetary Kp index (Kp 5 → G1 … Kp 9 → G5)
Outage levelnone · minor · major · criticalStatuspage's own indicator ("maintenance" is treated as minor)

Escalations fire proactive alerts — voice-style "Captain — …" messages broadcast to every client as toasts and archived, each with a warn or critical severity:

  • PizzINT anomaly — anomaly count rises or DEFCON drops (30-minute cooldown); critical at DEFCON 2 or below.
  • Seismic event — any quake of magnitude 6.0+, once per quake id, noting a raised tsunami flag. Critical.
  • Emergency squawk — an aircraft squawking 7500/7600/7700 or flagged emergency, once per airframe while airborne. Critical.
  • Geomagnetic storm — the G-scale crossing from quiet into storm (G1+). Warn.
  • Exploited CVE — a new ransomware-linked vulnerability added to the CISA KEV catalog (never on first load). Warn.
  • Service outage — a tracked service freshly escalating into major or critical (never on the first prime). Critical when critical, otherwise warn.
  • Public IP changed — the WAN address changed (confirmed on two consecutive polls to guard against a flaky echo source), with a reminder to update locked-down API allowlists. Warn.
  • Perimeter breach — a fresh geofence crossing, leading with the nearest event. Critical.

Configuration

Everything is optional — the tab works out of the box with the default keyless sources. Environment variables, read by the backend:

VariablePurpose
OSINT_AISSTREAM_KEYFree aisstream.io API key; enables the vessel feed
OSINT_AIS_BBOXJSON bounding boxes limiting the AIS subscription (default: the whole globe)
OSINT_OUTAGE_SERVICESOverride the watched status pages: "slug|Name|host,…"
OSINT_PIZZA_URL / OSINT_PIZZA_KEYOverride the PizzINT endpoint/key if it rotates
OSINT_SEISMIC_URL, OSINT_AIRCRAFT_URL, OSINT_KEV_URL, OSINT_FEODO_URL, OSINT_AIS_URLOverride individual source URLs
OSINT_POLL_MS, OSINT_SEISMIC_MS, OSINT_AIRCRAFT_MS, OSINT_GEOMAG_MS, OSINT_CYBER_MS, OSINT_OUTAGE_MS, OSINT_IPWATCH_MS, OSINT_VESSEL_FLUSH_MSPer-watcher poll/flush cadences (ms)
HOMUNCULUS_DATA_DIRWhere osint-store.json lives (default ./data)

In the UI, the user configures: which globe layers are visible, auto-track on/off, the home pin, and the geofence radius and armed state (home and geofence settings persist in localStorage and the geofence is mirrored server-side). A REFRESH button in the masthead forces an immediate re-poll of every polled source.

Note — persistence The hub persists the pizza snapshot and its trend history (288 points), the Kp history (96 points), the geofence config, and the last known public IP to data/osint-store.json, so the tab has data the moment it reconnects and the perimeter survives restarts. Restored data is honestly labelled cache until the first fresh poll lands.
← BRIDGE TabHOME Tab →

HOME Tab (Home Assistant)

The HOME tab ("Domicile Operations") is a full Home Assistant surface: live entity state, room-by-room sectors, device onboarding, named routines, and server-side monitoring of the connected things in your house.

Connecting to Home Assistant

The backend (server/homeassistant.ts) polls your Home Assistant instance over its REST API and fans updates out to every connected client over the WebSocket. Two settings are required, read from the backend's environment (.env for local dev, container env for Docker):

VariableWhat it is
HA_URLBase URL of your Home Assistant instance, e.g. http://ha.local:8123 (trailing slash is stripped)
HA_TOKENA long-lived access token minted in the HA UI — from an admin account (see the warning below)
HA_POLL_MSOptional state-poll interval in milliseconds. Default 10000 (10 s)

Every poll fetches /api/states with a request timeout kept just under the poll interval, so a hung request is abandoned before the next tick. The configured temperature unit is read once from /api/config and cached. Failure handling is deliberate: a single failed poll holds the last good snapshot and flags it stale: true (the house has not gone away because one request timed out); only after 3 consecutive failures does the hub declare Home Assistant offline. When the tab has no data at all it shows HOME ASSISTANT OFFLINE — CHECK HA_URL + HA_TOKEN IN .ENV.

The setup wizard

A setup wizard stands Home Assistant up on a new machine (if needed), verifies the token, and writes both keys into .env:

# macOS / Linux
./scripts/setup-home-assistant.sh

# Windows
.\scripts\setup-home-assistant.ps1

It asks how HA should be set up and branches accordingly:

  • docker — runs Home Assistant Container locally. On Linux it uses host networking so mDNS/SSDP device discovery works; Docker Desktop on Windows/macOS cannot, so discovery there is limited to IP/cloud integrations.
  • supervised / vm — HA OS or Supervised already runs on this machine (or a Pi/VM you flashed); the wizard skips installation and only wires things up.
  • attach — HA runs elsewhere; just point at its URL.

The script never handles your HA password. It prints the steps to mint a long-lived token in the HA UI, reads the token with echo off, then probes /api/config and /api/states before writing anything. Flags and env vars are accepted for unattended runs:

HA_TOKEN=... ./scripts/setup-home-assistant.sh --mode attach --url http://ha.local:8123 --non-interactive

Restart Homunculus afterwards to pick up the new configuration.

Warning — HA_TOKEN must come from an admin account Home Assistant's config-entries API (the surface behind the DEVICES view — listing, adding, and removing integrations, and seeing what HA discovered on the network) is not merely token-gated like /api/states: it additionally requires the token's user to be an admin, and answers 401 when it is not. A long-lived token inherits the admin status of the account that created it, so a token minted from a non-admin user will read entity state perfectly and then fail only on device management — a confusing failure that Homunculus surfaces with an explicit message ("config flows require an ADMIN user"). The implication cuts both ways: an admin token can reconfigure the whole house, so keep it on the machine running the backend, gate remote access with HOMUNCULUS_TOKEN, and never commit .env.

How entities are discovered and grouped

Domain filter

Not every entity is shipped to the UI. A fixed set of relevant domains (RELEVANT_DOMAINS in server/homeassistant.ts) keeps the snapshot lean and — more importantly — bounds what the tab can ever act on: a domain dropped here does not exist as far as the tab is concerned. The set is:

climate, sensor, binary_sensor, switch, lock, cover, select, number, button,
vacuum, media_player, weather, device_tracker, update, person, sun, todo,
light, scene, script, automation, fan, input_boolean,
alarm_control_panel, camera, humidifier, water_heater

Each poll produces a snapshot carrying three views of the same data (shared/homeassistant.ts):

  • climate — typed climate states for the thermostat panels (current/target temps, humidity, HVAC action).
  • entities — a flat, generic list of every relevant entity (id, domain, friendly name, state, unit, device class, attributes, last-changed).
  • devices — the same entities grouped into logical devices by id/name pattern match (e.g. Voltaire, R2PEEPOO, Washer, Dryer, Thermostat, Colony, Backup — first match wins; edit DEVICE_DEFS to suit your house).

Area registry

The REST API cannot see rooms: /api/states returns states and attributes, while area membership lives in HA's area/device/entity registries, which are exposed only over HA's websocket API. So server/haAreaRegistry.ts runs exactly one websocket conversation — authenticate, ask for the three registry lists, disconnect — and mirrors HA's own resolution rule: an entity belongs to the area set on the entity itself, otherwise it inherits the area of its device. Registries change roughly never, so the result is cached and refreshed hourly rather than polled. If the fetch fails, areas simply stay unknown (areas: null) and every entity shows up as unassigned — honest, rather than guessing rooms from entity names.

Sectors

A sector (shared/haSectors.ts) is one HA area plus a summary of what is happening in it: lights on / lights total, temperature, humidity, summed power draw across the sector's power sensors, and human-readable alert conditions (an open cover reads … OPEN, an unlocked lock … UNLOCKED, a moisture sensor … WET, a smoke sensor … SMOKE). Entities with no known area collect into a single UNASSIGNED sector rather than being dropped — nothing in the house becomes unreachable just because it was never filed into a room. Sector ids are URL-safe slugs of the area name (Living Room → living-room), which is also how routes and agents address them.

What the HOME tab UI shows

The tab (src/panels/HomeDashboard.tsx) has five sub-views under one masthead, and the current view lives in the URL rather than component state (shared/homeRoute.ts), so every state is a shareable deep link and an address an agent can navigate to:

#/home/overview
#/home/sectors/living-room
#/home/registry?domain=sensor&q=temp
#/home/overview?uplink=open
ViewWhat it shows
OVERVIEWThe dashboard: a scene strip, a sector strip, thermostat + ambient + litter-robot tiles, a full-width laundry bay, perimeter/power/media tiles, and the colony row. The masthead tallies subsystems and alerts.
SECTORSRoom-by-room view built from the sector summaries above, with per-sector entity lists and controls.
DEVICESIntegration management in three bands: devices HA discovered on the network by itself, integrations already configured (with reload/remove), and a picker for adding one by name. See "Adding devices" below.
REGISTRYThe escape hatch: every relevant entity, filterable by domain, free text, state, and sector, sortable by last-changed, name, id, or state (shared/haRegistry.ts). A device that never earns a bespoke tile is still one query away here.
AUTOMATAScenes, scripts, and automations, with the controls that belong to those domains: activate a scene, run a script, enable/disable/trigger an automation. Automations are the only things in the house that act on their own, so their enabled state is spelled out.

Pressing ⌘K / Ctrl+K anywhere in the tab opens the uplink palette: type an instruction, get back a numbered plan of the actual HA service calls it compiled to, and see what ran. Nothing fires from text directly — confirm-tier operations come back HELD with a button. The palette is a window onto the exact interface an autonomous agent uses, so what an agent can do is what the operator can see it doing.

Entity controls in the UI send generic commands over the ha WebSocket channel — any domain.service with arbitrary data — and the server answers with a command_ack message. The channel's subscribe message streams update messages carrying each new snapshot.

Adding devices (config flows)

Adding an integration is a multi-step conversation with HA: HA hands back a form step, you fill it in, the answers go back, and eventually a config entry is created. server/haConfigFlow.ts and shared/haConfigFlow.ts drive this through HTTP routes on the backend (GET /api/ha/integrations, GET /api/ha/entries, GET /api/ha/discovered, POST /api/ha/flow, and GET|POST|DELETE /api/ha/flow/:flowId). HA's form schemas are normalized into renderable fields; entity/device pickers and other exotic selectors are honestly labelled unsupported rather than guessed at.

Because these forms are where API keys and passwords get typed, credential fields are masked in the UI, and everything written to the append-only audit log is redacted — failing closed, so a field the schema does not describe is redacted rather than kept. Each flow start, step, and abort leaves an audit record.

Note — device management is operator-only, by construction None of the config-flow routes are reachable from the agent uplink. The uplink executes HA service calls from its manifest, and a config flow is not a service — it is a different API these routes are the only path to. Adding an integration means typing credentials and granting new software access to the house, which is a decision for the person, not for something compiling intent out of free text.

Routines

A routine (server/routines.ts) is a named sequence of HA service calls executed in order. Routines are defined in code — edit the ROUTINES map — and the Computer Core can describe and execute them on request. They can also be triggered directly over HTTP: POST /api/routine/:name (token-gated), with GET /api/routines listing what is available. The shipped defaults:

KeyWhat it does
goodnightThermostat to 68°F, then a litter-box cleaning cycle
awayThermostat to energy-saving 78°F
homeThermostat to 72°F
charge_voltaireOpens the charge-port door, starts charging
stop_chargingStops charging
clean_litterImmediate litter-box cleaning cycle
Note The entity IDs in the shipped routines are best-guess defaults (climate.main_thermostat, vacuum.r2peepoo_litter_box, …). Verify them against your own instance in HA → Developer Tools → States and edit server/routines.ts to match.

What the Computer Core may command

Commands the model itself decides to issue from free-form chat go through executeHaCommand, which enforces a domain allowlist in code: climate, switch, vacuum, cover, fan, light, media_player, number, select, button. Security-relevant domains — locks, alarm panels — and anything else off the list are refused outright, and the check is applied to the service domain (the thing that actually executes) as well as the entity domain, with any model-supplied entity_id override stripped from the payload. Your own HOME tab controls are not restricted by this list — the operator is entitled to control anything in their own house.

Homewatch (device monitoring)

server/homewatch.ts watches entity-state transitions on the server and emits toast events for the connected things in the home. Every event goes through broadcastProactive, which fires a toast on every client and writes a durable ARCHIVE record; these events carry chatLog: false, so device chatter never spams the Computer Core conversation. Because it runs server-side, the watching continues even with no UI open. What it watches:

  • Washer / dryer — cycle started (leaves an idle state) and cycle complete (reaches end while running), from sensor.washer_current_status / sensor.dryer_current_status.
  • Litter robot waste drawer — crossing 80% fires a warn toast ("waste drawer full"); crossing 95% fires a critical one ("will stop cleaning until emptied").
  • Tesla (Voltaire) charging — charging started (with battery % and limit) and charging complete.
  • Litter robot faults — a transition into a fault status code (df1, df2, dfs, sdf, br, offline) fires a critical "needs attention" toast.

The first snapshot after startup primes silently — homewatch never alerts on the state the house was already in.

← OSINT TabDATA Tab →

DATA Tab

The metrics warehouse — time-series history for system telemetry and Home Assistant numerics.

The DATA tab is the metrics warehouse — a time-series browser over everything the bridge has recorded about itself and your house. Pick a source, pick a range, get a phosphor line chart with statistics. It is backed by HistoryHub (server/history.ts) and reached over the /api/history/* REST endpoints rather than the WebSocket.

Warning The DATA tab needs DATABASE_URL. History is written to Postgres, and with no database configured there is nothing to chart — the rest of the app works normally, but this tab has no data to show. See Configuration for enabling the bundled Postgres service.

What gets recorded

HistoryHub tees from the telemetry hub and the Home Assistant hub into Postgres. Writes are fire-and-forget — errors are logged, never thrown — so a database hiccup can never affect the live UI.

System telemetry metrics

Seven numeric series, whitelisted by column name (which also prevents SQL injection in the dynamic column selection):

KeyLabelUnit
cpu_loadCPU LOAD%
cpu_temp_cCPU TEMP°C
mem_pctMEMORY%
swap_pctSWAP%
rx_mbpsNET RXMb/s
tx_mbpsNET TXMb/s
storage_pctSTORAGE%

Home Assistant entities

Any Home Assistant entity with a numeric state is recorded too — temperatures, humidity, power draw, battery levels. The entity browser lists what has actually been captured (GET /api/history/entities), so the list grows as your house reports.

Note Only numerics are stored. An entity whose state is on/off or a text string has nothing to plot and will not appear in the browser.

Using the tab

Pick a source

A source is either a telemetry metric or a Home Assistant entity id. The chart re-fetches whenever you change it.

Pick a range

Five presets, each with its own point limit so a long range stays responsive:

RangeWindowPoint limit
1H1 hour1200
6H6 hours1500
24H1 day2000
7D1 week3000
30D30 days5000

Read the chart

The series renders as a phosphor line chart. Percentage metrics are drawn against a fixed 0–100 axis; unbounded ones (temperature, throughput) autoscale. Four stat tiles summarise the visible window: min, max, avg, and now.

LIVE

The LIVE toggle short-polls the tail of the series every 4 seconds, so a chart left open keeps advancing. Turn it off when you are reading a historical window and do not want the view moving under you.

Endpoints behind the tab

RouteReturns
GET /api/history/telemetryPoints for one telemetry metric over a time range.
GET /api/history/haPoints for one Home Assistant entity over a time range.
GET /api/history/entitiesThe list of HA entity ids that have recorded numeric history.

All three are token-gated like the rest of the REST surface — see the protocol reference.

Placing it elsewhere

The tab body is the dash.data widget ("DATA dashboard"). Like the other dashboards it is a singleton but can be moved, resized, or stacked next to other widgets on any tab from SETTINGS → WIDGETS. See Widgets & Layout.

Related

  • ARCHIVE tab — discrete events rather than continuous series. DATA answers "what was the CPU doing at 3am"; ARCHIVE answers "what happened at 3am".
  • BRIDGE tab — the live view of the same telemetry.
← HOME TabARCHIVE Tab →

ARCHIVE Tab

The ship's log — a persisted, filterable console of every notable event the bridge emits.

The ARCHIVE tab is the ship's log: a persisted, reverse-chronological, colour-coded console of every notable event the system emits — proactive alerts, OSINT escalations, geofence breaches, device events, system messages. It is backed by the ArchiveHub (server/archive.ts) over the archive WebSocket channel.

What lands in the archive

The hub subscribes to every proactive broadcast the bridge makes (addProactiveListener) and records it. That means anything that would toast at you, or speak up unprompted in the Computer Core, is captured here — the archive is the durable record of the same stream.

Each event carries:

FieldMeaning
idUnique event id.
tsEpoch milliseconds when it was recorded.
sourceOriginating subsystem — drives the source filter and the colour of the source chip.
severityinfo, notice, warn, or critical.
titleShort summary line.
bodyThe full event text.

Sources

OSINT, HOME, COMPUTER, CRYPTO, FINANCE, SYSTEM.

Severities

SeverityRankColour
info0dim
notice1primary
warn2amber
critical3crimson
Note An emitter that attaches no ProactiveMeta gets classified SYSTEM / notice, with a title derived from the message text — the hub strips a leading "Captain —" and takes everything up to the first sentence-ending punctuation, capped at 80 characters.

Using the console

Events render newest-first. Three filters narrow the view and compose with each other:

  • Source — restrict to one or more subsystems.
  • Minimum severity — set to warn to hide routine chatter and see only what needed attention.
  • Free-text search — matches against event text.

Storage and retention

The archive has two tiers, and which you get depends on whether Postgres is configured.

Always: the on-disk ring

The hub keeps a bounded in-memory ring persisted to data/archive-events.json, so history survives a restart with no database at all.

BoundValueMeaning
RING1000Events retained in memory and on disk. Older events roll off.
SNAPSHOT300Events sent to a client when it subscribes.

With DATABASE_URL: durable history

The hub writes through to Postgres for durable, unbounded history. The ring stays as the fast local copy; the database is what survives losing the working directory.

Warning Without Postgres you keep the most recent 1000 events and nothing more. If the archive is meant to be a record you can go back to months later, configure DATABASE_URL.

Live behaviour

On subscribe a client receives an archive:snapshot of the recent slice, then one archive:event push per new event. See the protocol reference for the exact message shapes.

The archive and the Computer Core

Most events are also injected into the Computer Core conversation, so the assistant knows what the ship has been logging and can answer questions about it. Emitters can opt out by setting chatLog: false in their metadata — device and crypto events do exactly that, to avoid flooding the chat with routine state changes. Those events still toast and still archive; they just do not enter the conversation.

Backing it up

# The on-disk ring
cp data/archive-events.json archive-events.bak.json

# Durable history, if you run Postgres
docker compose exec db pg_dump -U homunculus homunculus > homunculus.sql

See Upgrading for the full backup and rollback procedure.

Related

  • DATA tab — continuous time-series rather than discrete events.
  • Security — the audit log (data/audit/*.jsonl) is a separate, append-only record of privileged actions, distinct from this event archive.
← DATA TabCRYPTO Tab →

CRYPTO Tab

Market view, charts and indicators, saved screeners, the headless strategy runner, indicator alerts, and the hash-chained audit log — a full trading desk backed by the Gemini exchange.

Danger — this trades real money, and none of it is financial advice Nothing in this tab, its signals, screeners, strategies, or alerts is investment advice. More importantly: the strategy runner is not a simulator. Strategy skills propose trades into a confirm-first queue, and when you confirm a proposal — or when the optional auto-execute switch is enabled — the server places real orders on the Gemini exchange with your GEMINI_API_KEY / GEMINI_API_SECRET (placeOrder() in server/crypto.ts, posting to Gemini's /v1/order/new). Confirm-first is the default (confirmFirst is ON in the GLOBAL strategy settings), auto-execute ships disabled with $100 per-strategy USD caps, and safe mode auto-arms protective stops — but the executing path is live capital, not paper. Do not point this at an account holding money you cannot afford to lose.

Layout

The CRYPTO tab is one full-width dashboard widget (dash.crypto, rendered by src/panels/CryptoDashboard.tsx) split into sections: OVERVIEW, MARKET, SCREENERS, TRADES, INTELLIGENCE, SETTINGS, and AUDIT. The server side is server/crypto.ts (the cryptoHub), with satellite modules for CMC volume (server/cmc.ts), alerts (server/cryptoAlerts.ts), strategy runs (server/strategyRunner.ts), strategy tuning (server/cryptoStrategySettings.ts), screeners (server/screenerStore.ts, server/screenerApi.ts, server/screenerRunner.ts), and the audit log (server/auditLog.ts).

Market view and data sources

Primary market data comes from Gemini's public REST API (no key needed):

  • /v1/symbols — the tracked universe, filtered to canonical USD-quoted pairs. GUSD/RLUSD-quoted duplicates and crosses are dropped at the source, and symbols Gemini refuses to trade for this account (403 RestrictedSymbol) are learned at order time, persisted to data/crypto/restricted-symbols.json, and never proposed again.
  • /v2/ticker/<symbol> — 24h open/high/low/close, bid/ask, and the derived 24h change.
  • /v2/candles/<symbol>/<tf> — up to 500 candles per symbol and timeframe. Timeframes are 1m, 5m, 15m, 1hr, 4hr, 1day; Gemini has no native 4hr feed, so 4hr bars are synthesized locally from the 1hr feed on UTC-aligned buckets (aggregateTo4h). The candle cache is persisted to data/crypto/candle-cache.json so restarts skip re-seeding.

CoinMarketCap is a secondary, cross-exchange signal (server/cmc.ts), not the price feed. Gemini is a thin venue, so a coin can be breaking out market-wide while Gemini's own 4hr volume stays flat; CMC's aggregated 24h volume change is fetched to override that false negative in the volume gate, and it also supplies market cap and 24h dollar volume to the screeners. Configuration:

  • CMC_API_KEY in .env — read server-side only and never sent to the client. Without it, CMC-backed reads are simply absent and dependent gates degrade gracefully.
  • Responses are cached for 3 minutes and all watched symbols are batched into one quotes/latest call, to stay inside the free tier's ~333 calls/day credit budget.
  • When several listings share a ticker (wrapped variants), the entry with the lowest cmc_rank — the coin traders actually mean — wins.

Charts and indicators

The MARKET section (src/panels/market/: MarketSection.tsx, TradingChart.tsx, CompareChart.tsx, IndicatorModal.tsx, AlertModal.tsx) draws candle charts with overlays computed by shared/indicators.ts — the same module the alert engine evaluates, so an alert can never disagree with the line it was set against. Available indicator math includes:

  • Moving averages: SMA, EMA (SMA-seeded)
  • Momentum: RSI-14 (Wilder-smoothed), MACD (12/26/9), Stochastic, CCI, MFI
  • Volatility and bands: Bollinger Bands (20, 2σ, population variance), ATR, Keltner Channels, Bollinger/Keltner squeeze
  • Trend: ADX (+DI/−DI), Parabolic SAR, Supertrend (10, 3)
  • Volume and levels: OBV, VWAP, volume ratio vs 20-bar average, pivot levels, Fibonacci retracement

The signal engine in server/crypto.ts combines these into multi-timeframe day/swing readings — RSI-14, MACD, Bollinger, MA20/50/200, OBV, VWAP, ADX-14, Ichimoku Cloud, and Fibonacci retracement across SHORT-TERM (15m/1hr) and MEDIUM-TERM (1day/1hr) categories — each with a direction (BUY/SELL/HOLD), an entry quality, and a confluence count.

Screeners

A screener is a saved question about the market, pure data defined in shared/screener.ts: a name, a timeframe (15m, 1hr, 4hr, 1day, 1week), a universe (ALL USD pairs or HELD positions only), and eleven gates in three groups:

GroupGates
MARKETmarket cap, 24h volume (both from CMC — degrade to ANY when unconfigured), 24h change
TECHNICALRSI range, EMA 50 trend, EMA 200 trend, MACD cross, BB width
PATTERNcandle-pattern whitelist (20 named patterns), pattern freshness in bars, relative volume

Definitions live in data/crypto/screeners.json (server/screenerStore.ts); three starters (DIP HUNTER, OVERSOLD BLUE-CHIPS, BREAKOUT WATCH) are seeded on first boot. Running one assembles a job — the newest 400 candles per symbol, tickers, CMC market caps and volumes, held symbols — and pipes it to the deterministic Python engine in engine/ (see the Screener Engine page). Results come back as a ranked candidate table plus an elimination funnel showing which gate killed how many symbols. The HTTP surface is /api/crypto/screeners (server/screenerApi.ts); an unsaved draft can re-screen live, but the saved id in the URL always wins so a draft can never report itself as another screener's results.

Screeners are not strategies A strategy executes — it sizes bids, places legs, manages exits. A screener only asks a question. The single bridge between them runs one way: creating a screener can copy a strategy's gate snapshot (SNIPER, TRAPLINE, OVERSOLD RSI, FAST CASH, FIRECRACKER presets in server/screenerStore.ts) as a starting point, and from that moment the copy is independent. Editing a screener can never retune a live strategy, and nothing in the screener path can place an order.

The strategy runner

Strategies are Claude Code skills — prompt documents under .claude/commands/ — run headlessly through the Agent SDK on your Claude subscription (no per-token API billing), exactly like the Computer Core chat. server/strategyRunner.ts defines the selectable set:

  • crypto-strategy — 4hr Bollinger Band swing system (BTC ladder + BB/volume alt swings)
  • btc-ladder — trend-gated BTC accumulation ladder, measured in BTC
  • fast-cash — 5m candle-pattern scalp, small fixed size
  • oversold — 1hr RSI < 30 mean reversion
  • crypto-candles — the 5m pattern scanner that feeds fast-cash (stages nothing itself)
  • firecracker — whole-market RSI/candle scalp, tiny fixed size
  • sniper — precision candle swing on a historically-proven composite
  • reaper, trapline — additional selectable skills

Mechanics worth knowing:

  • One run at a time. A manual RUN cannot collide with the hourly scheduled routine; external routines signal liveness via heartbeat pings and are presumed dead after 6 minutes of silence.
  • Bounded. Each in-process run has a 20-minute wall clock and an 80-turn ceiling, so a wedged session cannot block every future run.
  • The enabled strategy is persisted to data/crypto/enabled-strategy.json; the UI segmented control writes it and the headless routine reads it.
  • Every run is attributed. Runs execute inside withActor('skill:<id>'), so everything the skill changes lands in the audit log under the skill's name, and each run is mirrored into the durable timeline (agent_runs) shown in INTELLIGENCE → timeline.

Strategy settings

server/cryptoStrategySettings.ts stores each strategy's tuning knobs as data (StrategyDefinition[] in data/crypto/strategy-settings.json), editable from the tab's admin panel without touching prompt text — the skills read the resolved values over HTTP at the start of each run. A _global pseudo-strategy holds shared assumptions (round-trip fee, dust floor, exchange order minimum, liquidity floors, and the confirmFirst toggle); a strategy overrides a global value simply by declaring a field with the same key. New strategies can be created from the "+ NEW STRATEGY" form; the five original built-ins are seeded and their schemas reconciled on upgrade while your tuned values survive. Every settings change is written to the audit log with before/after values, so "who moved rsiMax to 35 — me or sniper?" stays answerable months later.

What actually executes

Verified against server/crypto.ts:

  • Skills propose; the server stages. A strategy run stages steps into a confirm-first plan (stageProposal → confirmProposal in the AutoPlanner). Staged steps can be edited, approved, or denied per step; denied trades are never sent to the exchange.
  • Confirmation places real orders. On confirm, the server signs authenticated requests with GEMINI_API_KEY / GEMINI_API_SECRET and posts to Gemini's /v1/order/new — entries, exit legs, protective stops, and bracket adjustments included.
  • Auto-execute exists and is off by default. data/crypto/auto-execute.json holds { enabled: false, btcLadderMaxUsd: 100, altMaxUsd: 100, perStrategy: {} }: a master switch plus per-strategy USD caps that bound what may auto-confirm without human review.
  • Safe mode is on by default. Eligible resting SELL orders are auto-armed with a default 5% stop trigger and 0.1% exit offset unless explicitly disarmed per order.
  • Trade lifecycle events toast to the UI and archive to the ship's log (source CRYPTO) without spamming the Computer Core chat.

Alerts

Indicator alerts (server/cryptoAlerts.ts, persisted to data/crypto/alerts.json) are evaluated on the server, inside the hub's 30-second refresh loop — they keep firing with the app closed. Conditions read from shared/indicators.ts, and each alert fires at most once per bar of its chosen timeframe. Sources and conditions include:

  • price — crossed above/below a level, or a bar's open→close move past a percent
  • rsi — crossed above/below a value, or exited the 30–70 band
  • ema-cross (EMA 9/21), sma-cross (golden/death cross of SMA 50/200)
  • macd — bull/bear cross or histogram flip
  • bollinger — close broke the upper/lower band, or a Bollinger/Keltner squeeze
  • stoch — %K/%D cross while oversold/overbought
  • volume — spike or fade vs the 20-bar average; atr; adx — trend starts/fades, ±DI cross; vwap cross; supertrend flip
  • signal — the signal engine's direction flipped, entry quality reached MEDIUM/HIGH, or confluence reached a target

An alert's action is notify (toast + archive) or stage-buy / stage-sell, which stages a confirm-first proposal for a configurable USD size (default $20) — one of the few paths that reaches the confirm queue with no human in the loop, so each such fire gets its own audit entry. An alert may also name an agent to wake when it fires, independent of the action. Creators are capped at ALERT_MAX_PER_CREATOR alerts, and an agent denied trading authority cannot arm a trade-staging alert — the autonomy dial means the same thing whether the agent acts now or arranges to act later.

The audit log

Every state mutation — trades, settings changes, alert arms, screener edits — lands in an append-only, hash-chained record (server/auditLog.ts), kept in two places at once:

  • data/audit/audit-YYYY-MM.jsonl — the write-ahead log, one JSON line per entry, written synchronously first. Files rotate monthly but the sha256 chain and seq counter continue across rotation. These files are never rewritten or trimmed; a correction is a new entry, never an edit.
  • Postgres audit_log (when DATABASE_URL is set) — the indexed, queryable system of record, guarded by BEFORE UPDATE OR DELETE and BEFORE TRUNCATE triggers that make it append-only regardless of privilege. Rows stream in behind the file and backfill on reconnect.

Each entry carries an actor (operator, system, skill:<id>, agent:<id> — threaded implicitly via AsyncLocalStorage), an action, a resource, a summary, and optional before/after state. GET /api/audit/verify re-derives the whole chain and cross-checks every Postgres row against the files: an edited line, a deleted line, a torn write, or a row inserted directly into the table all show up as a named break. The AUDIT section of the tab browses the log with actor/resource/action filters, newest first.

Configuration checklist GEMINI_API_KEY / GEMINI_API_SECRET (required for balances, orders, and anything that trades), CMC_API_KEY (optional — cross-exchange volume and market caps; screener MKT CAP and VOL 24H gates degrade to ANY without it), CLAUDE_CODE_OAUTH_TOKEN (required for strategy runs), HOMUNCULUS_MODEL (optional model override), DATABASE_URL (optional — Postgres mirrors for state, history, and the audit log). All are read from the backend's .env and never sent to the client.
← ARCHIVE TabDeployment →

Deployment

Every way to run the Homunculus backend — desktop dev, browser dev, bare production, Docker, and remote access over Tailscale — in one place.

The shape of every deployment

There is exactly one thing to deploy: the Node backend in server/. It serves the built React UI over HTTP and multiplexes everything else — telemetry, the Computer Core chat, the terminal, Home Assistant, OSINT, crypto — over one WebSocket per client. The Electron desktop shell, a phone browser, a laptop browser, and a watch-adjacent shortcut are all just clients of that same WebSocket; none of them contains any backend logic. Choosing a deployment mode is choosing where that one Node process runs and who can reach it.

Requirements Node 20 LTS (.nvmrc pins 20; run nvm use). No native-build toolchain is needed on a normal install — node-pty ships prebuilt binaries. The Computer Core additionally needs CLAUDE_CODE_OAUTH_TOKEN in .env (generate with claude setup-token).

Mode A — Desktop development

npm install
npm run dev

npm run dev runs two processes concurrently (see package.json): dev:server (tsx watch server/index.ts, the backend on port 8787 with hot reload) and dev:app (electron-vite dev, the Electron shell). The shell is a thin window that connects to the backend as a client — it starts nothing itself.

Mode B — Browser development

With npm run dev running, open http://localhost:5173 — the Vite dev server with hot module reload. The backend explicitly allows the dev renderer origins http://localhost:5173 and http://127.0.0.1:5173 through its Origin gate (see DEV_ORIGINS in server/index.ts), so cross-origin fetches and the WebSocket handshake from the dev UI work without extra configuration.

Mode C — Production (bare Node)

npm run build:web    # vite build --config vite.web.config.ts  →  out/renderer
npm run start        # tsx server/index.ts

Then open http://localhost:8787. The server reads .env from the working directory at startup (import 'dotenv/config' is the first line of server/index.ts) and serves the built UI from out/renderer by default (override with HOMUNCULUS_WEB_DIR). Port and bind address come from HOMUNCULUS_PORT (default 8787) and HOMUNCULUS_HOST (default 0.0.0.0).

Warning The default bind is 0.0.0.0 — on a bare-Node install the port is reachable from your LAN unless a firewall says otherwise. Set HOMUNCULUS_TOKEN before the machine is reachable beyond localhost; without it the server refuses remote requests outright (503), which is safe but means a broken phone view. See Security.

Mode D — Docker / docker-compose

cp .env.example .env    # fill in CLAUDE_CODE_OAUTH_TOKEN and HOMUNCULUS_TOKEN
docker compose up --build

Compose reads .env via env_file, so the file must exist first — without it docker compose aborts with "env file not found".

What the Dockerfile builds

  • Two stages, both on node:20-bookworm-slim. The builder installs the toolchain (python3 make g++) for node-pty's native fallback, runs npm ci, builds the web UI (npm run build:web), then npm prune --omit=dev. The runtime stage copies only node_modules, out/, server/, shared/, engine/, package.json, and tsconfig.json.
  • Runs unprivileged. USER node — the terminal channel hands out a shell inside this container, and that shell (or any RCE) should not land as root.
  • Baked-in env: HOMUNCULUS_HOST=0.0.0.0, HOMUNCULUS_PORT=8787, HOMUNCULUS_WEB_DIR=/app/out/renderer, NODE_ENV=production. EXPOSE 8787.
  • Healthcheck every 30s against /healthz using Node's own fetch — it catches a wedged (not just crashed) process, which matters because the take-profit monitor lives in this process.
  • Entrypoint: npx tsx server/index.ts.

What docker-compose adds

  • Port: 127.0.0.1:8787:8787 — loopback-only by default. A bare 8787:8787 would publish the terminal, finance and trading API to every device on the LAN.
  • Volumes: homunculus-data:/app/data and homunculus-private:/app/private persist all on-disk state (crypto trades/plans, OSINT cache, archive spool) across rebuilds. Without them every docker compose up --build wipes your data.
  • Env: everything in .env passes through via env_file; the compose file additionally documents CLAUDE_CODE_OAUTH_TOKEN (required), HOMUNCULUS_TOKEN (strongly recommended once reachable beyond localhost), and HOMUNCULUS_MODEL (optional).
  • Optional Postgres: a postgres:16-alpine service (db, volume homunculus-pg) behind the history profile: docker compose --profile history up -d. Compose refuses to start it until POSTGRES_PASSWORD is set in .env — there is deliberately no default. Point DATABASE_URL at postgres://homunculus:<password>@db:5432/homunculus.
  • Restart policy: restart: unless-stopped — but note this only revives the container when the Docker daemon itself is running.
Note Inside Docker, the System Vitals panel and the embedded terminal reflect the container (a small Linux environment), not the host. Everything else — Computer Core, Crypto, OSINT, Home Assistant — works fully. If host-level telemetry matters to you, run bare Node (Mode C) instead.

Mode E — Windows + Docker Desktop + Tailscale (remote access)

This is the WINDOWS.md recipe: run the backend in Docker Desktop (WSL2) on a Windows PC and reach it privately from your phone, watch, or any browser over Tailscale. Condensed:

  1. Install: Docker Desktop (WSL2 backend), Git for Windows, Tailscale (signed into the same account as your phone), and — once, to mint a token — the claude CLI.
  2. Get the code and configure: git clone, Copy-Item .env.example .env, then fill in CLAUDE_CODE_OAUTH_TOKEN (claude setup-token) and HOMUNCULUS_TOKEN (.\scripts\homunculus.ps1 token generates a strong one).
  3. Start: .\scripts\homunculus.ps1 up — builds the image, starts the container in the background, prints your local and Tailscale URLs. Verify at http://localhost:8787 and http://localhost:8787/healthz.
  4. Reach it remotely: .\scripts\homunculus.ps1 url prints something like http://your-pc.tailnet-name.ts.net:8787?token=.... Open that on the phone (Tailscale app signed in). The ?token= is required off the home PC — bookmark the full URL.
  5. Keep it running 24/7: .\scripts\homunculus.ps1 uptime configures never-sleep/never-hibernate, lid-close-does-nothing, and Docker Desktop auto-start on login. If you run take-profit orders this is not optional: the stop-loss is a real resting order on the exchange, but the take-profit is enforced by the backend's monitor loop — if the PC sleeps or Docker stops, take-profits stop being watched.

Helper commands: logs, status, down, rebuild (after git pull), url, and up -History to include the Postgres service.

Warning Under Docker Desktop on Windows the "host" is a WSL2 VM, so vitals and the terminal show the container's Linux environment, not Windows. This is expected. Also: Tailscale keeps you off the public internet, but it does not protect your LAN — if you change the compose port mapping from 127.0.0.1:8787:8787 to a bare 8787:8787, anything on your home Wi-Fi can reach the port. Set HOMUNCULUS_TOKEN before you do that.

Phone, watch, browser — all the same client

There is no separate mobile deployment. The phone browser loads the same web UI from the same backend and speaks the same WebSocket; it just passes ?token=<HOMUNCULUS_TOKEN> in the URL because it is not localhost. The Apple Watch has no general browser — realistic options are a phone-side shortcut/complication that opens the URL. The packaged Electron desktop app is also just a client: it looks for http://localhost:8787 (override with HOMUNCULUS_URL), shows a waiting screen if no backend is up, and connects by itself when one appears.

← CRYPTO TabConfiguration Reference →

Configuration Reference

Every environment variable the backend reads, where configuration is loaded from, and where persistent state lives on disk.

Homunculus reads its configuration from a single .env file in the repository root, loaded by the backend at startup via dotenv/config (see server/index.ts). The same file is passed into the container by docker-compose through env_file. Copy .env.example to .env and fill in what you need — every variable below is optional except where noted.

Note .env is gitignored and dockerignored. Nothing in it is ever sent to a client: the KEYS panel reports only presence and a last-4 fingerprint.

Computer Core

VariableDefaultEffect
CLAUDE_CODE_OAUTH_TOKEN—Required for the Computer Core. Drives your Claude Pro/Max subscription rather than per-token API billing. Mint it on a machine with the claude CLI logged in: claude setup-token.
HOMUNCULUS_MODELSDK defaultModel override for the Computer Core and every agent session — sonnet, opus, haiku, or a full model id.
ANTHROPIC_API_KEY—Deliberately not used. It appears only in NEVER_FORWARDED (server/agentEnv.ts) to guarantee it is stripped from agent child processes, forcing the local-subscription path.

Network and access control

VariableDefaultEffect
HOMUNCULUS_PORT8787HTTP + WebSocket listen port.
HOMUNCULUS_HOST0.0.0.0Bind address. The default is correct for Docker; leave it unset.
HOMUNCULUS_TOKEN—Required once reachable beyond localhost. Gates the WebSocket upgrade and the sensitive REST routes. Remote clients pass it as ?token=… or an x-homunculus-token header. Unset means remote requests are refused with 503 — an unconfigured gate is a closed gate.
HOMUNCULUS_ADMIN_TOKEN—Admin secret for audit-log management (POST /api/audit/annotate). Header-only, never waived for localhost. Unset keeps those routes closed at 503; audit recording continues regardless.
HOMUNCULUS_URL—Backend URL used by helper tooling to reach the API.
HOMUNCULUS_WEB_DIR./out/rendererDirectory of built web assets the backend serves.
HOMUNCULUS_DATA_DIR./dataRoot for all persisted state — layout, archive spool, OSINT store, crypto state.
HOMUNCULUS_DEVTOOLS—Opens Electron DevTools on launch.

History, archive, and audit (Postgres)

VariableDefaultEffect
DATABASE_URL—Postgres connection string. Unset means live-only: the DATA and ARCHIVE tabs work but nothing persists to a database. Also backs the append-only audit_log table.
POSTGRES_PASSWORD—Password for the bundled compose Postgres service. Compose refuses to start without it — there is deliberately no fallback default. Must match the password inside DATABASE_URL.
# Enable durable history with the bundled database
POSTGRES_PASSWORD=choose-something-long
DATABASE_URL=postgres://homunculus:choose-something-long@db:5432/homunculus

docker compose --profile history up -d
Note Even without DATABASE_URL, the audit log still records to data/audit/*.jsonl and the archive still keeps its on-disk ring of 1000 events.

Crypto

VariableDefaultEffect
GEMINI_API_KEY—Gemini exchange key. Read-only market data works without it; portfolio and trading do not.
GEMINI_API_SECRET—Gemini exchange secret. Paired with the key above.
CMC_API_KEY—CoinMarketCap key used to cross-check the volume gate against aggregated cross-exchange volume. Without it, the volume gate falls back to Gemini-only data.
Danger GEMINI_API_KEY and GEMINI_API_SECRET can spend the portfolio directly. They are on the NEVER_FORWARDED list so no agent child process ever inherits them — see Security.

Home Assistant

VariableDefaultEffect
HA_URL—Base URL of your Home Assistant instance.
HA_TOKEN—Long-lived access token (HA → profile → Security).
HA_POLL_MS10000State poll interval in milliseconds.
Warning Create HA_TOKEN from an admin account. A token inherits the admin status of whoever made it, and a non-admin token fails confusingly: reading state, controlling lights and running scenes all work, and only the DEVICES tab breaks — Home Assistant gates config flows on admin and answers 401.

Screener engine

VariableDefaultEffect
SCREENER_PYTHONauto-probedPins the Python interpreter for the screening engine. Normally leave unset: the server probes at startup (the py launcher first on Windows, python3 elsewhere) and skips the Microsoft Store alias stub that only prints "Python was not found". Set it to pin a venv or one of several installs.

OSINT watchers

All OSINT variables have working defaults; set them only to tune feeds or supply an optional key. See server/osint.ts for the authoritative list.

VariableDefaultEffect
OSINT_AISSTREAM_KEY—aisstream.io API key. Without it the vessel feed stays idle.
OSINT_AIS_URLwss://stream.aisstream.io/v0/streamAIS stream endpoint.
OSINT_AIS_BBOX—JSON bounding box limiting the AIS subscription.
OSINT_VESSEL_FLUSH_MS5000How often buffered vessel positions are flushed to clients.
OSINT_AIRCRAFT_URLhttps://opendata.adsb.fi/api/v2/milMilitary aircraft feed.
OSINT_AIRCRAFT_MS60000Aircraft poll interval.
OSINT_SEISMIC_URLUSGS feedEarthquake feed URL.
OSINT_SEISMIC_MS180000Seismic poll interval.
OSINT_GEOMAG_MS300000Geomagnetic poll interval.
OSINT_KEV_URLCISA KEV catalogKnown-Exploited-Vulnerabilities feed.
OSINT_FEODO_URLFeodo trackerBotnet C2 tracker feed.
OSINT_CYBER_MS3600000Cyber-feed poll interval (one hour).
OSINT_OUTAGE_SERVICESbuilt-in listServices watched for outages.
OSINT_OUTAGE_MS120000Outage poll interval.
OSINT_IPWATCH_MS300000IP-watch poll interval.
OSINT_PIZZA_URLbuilt-inPizzINT venue-activity source.
OSINT_PIZZA_KEY—Key for the above, if the source needs one.
OSINT_POLL_MS300000PizzINT poll interval.

Where state lives on disk

Everything persistent lands under HOMUNCULUS_DATA_DIR (default data/), written through server/stateStore.ts. Back up this directory before upgrades.

FileContents
layout.jsonTab and widget layout, shared by every client.
setup.jsonFirst-run wizard progress.
archive-events.jsonThe on-disk event ring for the ARCHIVE tab (1000 events).
osint-store.jsonOSINT watcher state and geofence config.
sync.jsonSync configuration.
alerts.jsonCrypto alert definitions.
trades.json, closed-trades.json, pending.jsonTrade records — open, closed, and pending.
cost-basis.json, portfolio-baseline.json, portfolio-history.jsonPortfolio accounting.
active-bracket.json, active-plans.json, plan-report.json, plan-reports/Strategy bracket and plan state.
safe-mode.json, safe-mode-optout.json, restricted-symbols.json, auto-execute.json, loop-mode.jsonTrading guardrails and execution mode.
strategy-interval.json, strategy-intervals.json, btc-ladder-cycles.json, candle-cache.jsonStrategy scheduling and cached market data.
audit/*.jsonlAppend-only audit log.
crypto/office/library/Documents written by agents — a .json artifact plus a human-readable .md mirror.

Runtime key entry (the KEYS panel)

Keys can also be entered at runtime from SETTINGS → KEYS in the Electron desktop app. Those values live in an encrypted-at-rest vault owned by the Electron shell (electron/vault.ts, backed by Electron safeStorage → macOS Keychain, Windows DPAPI, or libsecret) and are pushed into the backend memory on connect. The backend never writes them to disk.

  • A backend restart drops the vault; the Electron shell re-pushes on reconnect.
  • A headless or Docker backend with no Electron client reads everything from .env, and the KEYS panel is read-only there.
  • The unlock endpoint is localhost-only, so a phone or browser session across Tailscale cannot write keys.
← DeploymentSecurity →

Security

Threat model, the two token gates, credential isolation for agent processes, and a hardening checklist.

Homunculus is not a read-only dashboard. It runs a real shell, holds exchange credentials that can spend money, holds a Home Assistant admin token that can unlock a house, and runs Claude agent sessions with permissionMode: 'bypassPermissions'. Treat the backend as a privileged process and read this page before exposing it to anything wider than localhost.

Danger Anyone who can reach the backend and pass its token gets a shell on the machine, the ability to stage trades, and control of every Home Assistant entity. There is no per-user model, no roles, and no per-action confirmation at the transport layer. The token is the security boundary.

The two gates

The backend enforces two separate secrets, with deliberately different rules (server/index.ts).

HOMUNCULUS_TOKEN — the remote-access gate

Gates both the WebSocket upgrade and the sensitive REST routes. The logic in requireToken():

  • Localhost bypasses it. Requests that isLocalReq() judges local are admitted without a token, so the desktop app and the operator's own machine work unconfigured.
  • An unconfigured gate is a closed gate. If HOMUNCULUS_TOKEN is empty, remote callers get 503 with "HOMUNCULUS_TOKEN is not configured — remote access is refused until it is set". This is a hardening fix: the earlier behaviour returned true when the token was empty, which meant "no token configured" silently equalled "no authentication at all" for every remote caller — on a LAN or a tailnet that served finance data, trade staging and the agent fleet to anyone who could reach the port.
  • Two ways to present it: a ?token=… query parameter or an x-homunculus-token header.
  • Compared in constant time via constantTimeEquals(). A wrong token gets 401.

HOMUNCULUS_ADMIN_TOKEN — the audit-management gate

Guards audit-log management (POST /api/audit/annotate) with three deliberate differences from the gate above:

  • No localhost bypass. Agents and skills run on this machine too, so "local" is not a trust boundary for the record that exists to catch them.
  • Header only. A query-string secret leaks into shell history and proxy logs.
  • Unset means 503, not open. Audit recording never depends on this token; only management of the record does.
Warning Keep HOMUNCULUS_ADMIN_TOKEN somewhere the agents cannot read. Its entire purpose is to stop a compromised or confused agent from editing the log that would reveal what it did.

Credential isolation for agent processes

Every Claude Agent SDK session the server starts — fleet agents, the strategy runner, the Computer Core chat, the proactive monitor — runs with permissionMode: 'bypassPermissions'. Fleet agents additionally get unrestricted Bash and a prompt assembled partly from text other agents wrote (library documents, board threads, journals, manager-file instructions) plus live market and home-state strings. That is a prompt-injection surface pointed at a shell.

server/agentEnv.ts answers this with an allowlist, not a denylist. Nothing reaches a child process unless it is named explicitly:

  • System keys — PATH, HOME, SHELL, temp dirs, and the Windows equivalents (SystemRoot, PATHEXT, COMSPEC…) a child needs to spawn anything at all.
  • App keys — only HOMUNCULUS_MODEL and HOMUNCULUS_PORT. Neither is a credential.
  • CLAUDE_CODE_OAUTH_TOKEN — the subscription token the session needs to run.

Everything else is stripped. A second list, NEVER_FORWARDED, names the credentials explicitly even though the allowlist would already block them — so the intent survives a future edit that loosens the allowlist:

GEMINI_API_KEY, GEMINI_API_SECRET   // spend the portfolio directly
HA_TOKEN, HA_URL                    // unlock the house
CMC_API_KEY
DATABASE_URL                        // the system of record
HOMUNCULUS_TOKEN, HOMUNCULUS_ADMIN_TOKEN
OSINT_AISSTREAM_KEY, OSINT_PIZZA_KEY
ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN

The reasoning is worth internalising: the trade-authority gate in agents.ts::propose is meaningless if a child can simply sign its own Gemini request. A denylist silently re-opens the hole the day someone adds a new credential — which is exactly how six credentials ended up being forwarded before this was fixed. With an allowlist, new credentials are safe by default and granting one is a one-line reviewable decision.

Secret storage

The secret store (server/secrets.ts) never persists anything. The backend is a plain Node process with no OS keychain and may be a headless container, so:

  • Values live in memory only, injected at runtime by the Electron shell, which owns the encrypted-at-rest vault (electron/vault.ts → Electron safeStorage → macOS Keychain, Windows DPAPI, or libsecret).
  • Nothing unencrypted touches disk, and no secret value is ever sent to a client — status() reports presence, source, and a last-4 fingerprint only.
  • The unlock endpoint is localhost-only. A key must never cross the Tailscale boundary, so the phone or browser view cannot write keys; it sees the KEYS panel read-only.
  • A backend restart drops the vault; the Electron shell re-pushes on connect. A headless backend falls back to .env.

Risk surfaces, ranked

1. The embedded terminal

The Terminal widget is a real PTY (server/terminal.ts, node-pty) running as the backend user. It is the single largest risk surface: anyone who reaches an authenticated session has shell access, and the shell can read .env — every credential the allowlist works to keep away from agent children.

The backend takes one extra precaution here: the terminal channel re-checks the token even on a local socket. The upgrade handler waives the token for localhost, which is sensible for the dashboard itself but not for a channel that hands out a shell carrying the process environment. So opening a PTY always costs the real secret once one is configured, regardless of where the socket dialled in from.

Danger In Docker the terminal is the container's shell, which bounds the blast radius. Running the backend natively on your desktop means the terminal is your shell with your permissions.

2. Trading credentials

GEMINI_API_KEY / GEMINI_API_SECRET can place orders. Issue exchange keys with the narrowest permissions that make the tab useful to you — read-only keys give market data and portfolio views with no ability to trade at all.

3. The Home Assistant admin token

HA_TOKEN must come from an admin account for the DEVICES tab to work, which means the token can do anything an HA admin can: add and remove integrations, control every entity, unlock locks, disarm alarms. Its blast radius is your entire home, not just the widgets you placed.

4. Prompt injection into agent sessions

Agent prompts include text written by other agents and live external data (market strings, home state, OSINT feeds). Content arriving through those channels is data, not instruction — but a model with Bash can be talked into treating it as instruction. The env allowlist is the mitigation that assumes this will sometimes fail.

Deployment posture

PostureAssessment
Localhost onlySafest. No token needed; nothing is reachable off the machine.
Tailscale (recommended for remote)Good. Traffic is encrypted and device-authenticated by the tailnet; HOMUNCULUS_TOKEN is a second factor. This is what the Windows deployment guide assumes.
LAN-exposed portRisky. Plain HTTP/WS — the token crosses the wire in the clear and any device on the network can attempt access.
Public internet port-forwardDo not. There is no TLS, no rate limiting, and no account model in front of a shell.

Hardening checklist

  • Set a long random HOMUNCULUS_TOKEN before the backend is reachable off the machine.
  • Set HOMUNCULUS_ADMIN_TOKEN to a different long random string, stored where agents cannot read it.
  • Reach remote clients over Tailscale rather than a forwarded port. Never expose the port to the public internet.
  • Run the backend in Docker if you want the terminal and any agent shell bounded to a container.
  • Issue read-only Gemini keys unless you specifically intend to trade from the bridge.
  • Keep .env out of version control (it is gitignored and dockerignored by default — keep it that way).
  • Rotate CLAUDE_CODE_OAUTH_TOKEN, exchange keys, and HA_TOKEN if a client device is lost.
  • Review data/audit/*.jsonl after any unexpected agent behaviour; it records regardless of whether the admin token is configured.
  • Back up data/ before upgrades — see Upgrading.
← Configuration ReferenceUpgrading →

Upgrading

Backing up state, pulling a new version, what migrates automatically, and how to roll back.

Homunculus has no in-app updater. Upgrading means pulling the new code and restarting the backend — which is straightforward, because almost all state is JSON under data/ and the migration machinery that exists runs automatically at boot.

Before you upgrade: back up

Everything the app remembers lives in two places:

  • data/ — layout, archive spool, OSINT state, every crypto ledger, the audit log. See the state file table for what each file holds.
  • Postgres, if DATABASE_URL is set. This is the authoritative copy; the JSON files are a synchronously-written local replica.
# Snapshot the data directory
cp -r data data.bak-$(date +%Y%m%d)

# Snapshot Postgres, if you run it
docker compose exec db pg_dump -U homunculus homunculus > homunculus-$(date +%Y%m%d).sql
Warning If you run the backend in Docker without a volume mounted at the data directory, data/ lives inside the container and a rebuild destroys it. Check your compose volumes before rebuilding.

Upgrading a source checkout

git pull
npm install          # dependencies may have changed
npm run typecheck    # optional but fast — catches a broken checkout early
npm test             # vitest + the Python engine tests

Then rebuild whichever surfaces you use:

npm run build:web    # the browser UI served by the backend
npm run build        # the Electron desktop shell
npm run start        # restart the backend
Note If the terminal stops working after a Node or Electron version change, rebuild the native PTY binding: npm run rebuild.

Upgrading a Docker deployment

git pull
docker compose build
docker compose up -d

# with the bundled Postgres
docker compose --profile history up -d

Confirm what you are actually running afterwards — the backend serves its build identity:

curl -s localhost:8787/api/version

That returns the semver from package.json, the short git commit, and an ISO build timestamp (shared/version.ts). Outside a git checkout the commit degrades to unknown rather than failing.

What migrates automatically

Layout

LayoutConfig carries a version field (shared/layout.ts, currently LAYOUT_VERSION = 1) that is bumped when the shape changes so server/layout.ts can migrate an older file forward. Layout loading also runs sanitizeLayout(), which drops placements referring to widgets that no longer exist — so a widget removed in a new version degrades to a missing tile rather than a broken tab.

State store

server/stateStore.ts runs migrate(), migrateLedgers(), and migrateRuns() at startup. A JSON file with no corresponding Postgres row is imported on first boot — that is the one-time migration of existing state, and it is why no separate migration script exists. reconcile() runs on every boot and reports any drift between the database and the local files.

Screener jobs

Both the Node side and the Python engine stamp schemaVersion (SCREENER_SCHEMA_VERSION, currently 1). The engine refuses a job whose version it does not implement rather than guessing at a gate it might read wrong. If you upgrade one side and not the other, screeners fail loudly instead of returning quietly wrong results.

What does not migrate

  • Most data/*.json files carry no version field. Layout is the versioned exception. If a release changes the shape of, say, a trade ledger, the file is read as-is — read the CHANGELOG before upgrading across a release that touches crypto state.
  • Nothing downgrades. There is no reverse migration. A newer layout file read by older code is not guaranteed to be understood.
  • Secrets do not migrate and do not need to: the Electron vault is keyed by the OS keychain and the backend holds nothing on disk.
Note Read CHANGELOG.md before every upgrade. It is the only place a breaking state change would be announced.

Rolling back

Because there is no reverse migration, a rollback is: restore code, then restore state.

# 1. Stop the backend
# 2. Go back to the previous commit or tag
git log --oneline -10
git checkout <previous-tag-or-commit>
npm install

# 3. Restore the data you snapshotted
rm -rf data && cp -r data.bak-YYYYMMDD data
psql "$DATABASE_URL" < homunculus-YYYYMMDD.sql   # if you use Postgres

# 4. Rebuild and restart
npm run build:web && npm run start
Danger Restoring a crypto state snapshot rewinds the app view of your positions, but not the exchange. Open orders and fills that happened after the snapshot are real and will not be in the restored ledgers. Reconcile against the exchange before letting the strategy runner act on restored state.

Releasing (maintainers)

Version bumps go through npm, which stages the changelog automatically:

npm run release:patch    # npm version patch -m "release: v%s"
npm run release:minor
npm run release:major

The version lifecycle script runs git add CHANGELOG.md, so changelog edits land in the release commit. Desktop artifacts are built with npm run dist (or npm run dist:mac, which enables notarization when NOTARIZE is set).

← SecurityArchitecture →

Architecture

One Node backend does all the work; every UI — browser, phone, Electron — is a thin client on the same WebSocket.

The shape of the system

Homunculus Core is a hybrid client–server system. A single Node process (started from server/index.ts) owns every subsystem: telemetry, the Computer Core chat, the PTY terminal, Home Assistant, OSINT watchers, the archive spool, the crypto engine, and persistence. It serves the built React UI over HTTP and multiplexes live data over one WebSocket per client. Clients render; they never compute.

┌─ Backend: one Node process (server/index.ts, containerizable) ──────────────┐
│                                                                             │
│  HTTP :8787 ── serves out/renderer (the built React UI) + /api/* routes     │
│  WS   :8787 ── one socket per client, channels keyed by `ch`                │
│                                                                             │
│  ├─ telemetryHub      systeminformation snapshots      (server/telemetry.ts)│
│  ├─ ChatSession       Computer Core — Claude Agent SDK (server/chat.ts)     │
│  ├─ TerminalManager   real PTY, node-pty prebuilt      (server/terminal.ts) │
│  ├─ haHub             Home Assistant REST poller       (server/homeassistant.ts)
│  ├─ osintHub          situational watchers + geofence  (server/osint.ts)    │
│  ├─ archiveHub        persistent event spool           (server/archive.ts)  │
│  ├─ cryptoHub         market data, trades, autoPlanner (server/crypto.ts)   │
│  ├─ screenerRunner ───┐ builds jobs for the engine     (server/screenerRunner.ts)
│  ├─ stateStore        Postgres + data/*.json replica   (server/stateStore.ts)
│  └─ auditLog          append-only hash-chained record  (server/auditLog.ts) │
│                       │                                                     │
│              child process, per job                                         │
│                       ▼                                                     │
│        ┌─ Python screener engine ─┐                                         │
│        │  engine/screener_engine.py │  stdin: job JSON → stdout: result JSON│
│        └───────────────────────────┘  no keys, no network, cannot trade     │
└──────────────────────┬──────────────────────────────────────────────────────┘
                       │ WebSocket + HTTP (over Tailscale for remote)
          ┌────────────┼──────────────────┐
     Browser        iPhone           Electron shell (electron/main.ts)
     localhost:8787 tailnet + token  a window onto the same web UI
              └── src/ React bridge, one codebase, transport-agnostic ──┘

The backend: one process, many hubs

server/index.ts is the composition root. It creates the HTTP server, mounts the /api/* REST routes, upgrades /ws connections, and wires each subsystem in. The subsystems follow a shared shape: a singleton "hub" (telemetryHub, haHub, osintHub, archiveHub, cryptoHub) that polls or watches its source and exposes subscribe(handler) returning an unsubscribe function. Per WebSocket connection, the server subscribes on demand and forwards snapshots down the socket; on close it unsubscribes and disposes any per-connection state (each connection gets its own ChatSession and TerminalManager).

Background work runs process-wide, not per client: proactiveMonitor.start(), osintHub.start(), homeWatcher.start(), cryptoHub.start(), agentFleet.startWatching(), and historyHub.start() all begin at boot, so history capture and alerting continue with no UI connected.

Transport: one WebSocket, plus REST

All streaming data — telemetry ticks, chat deltas, terminal output, HA/OSINT snapshots, archive events — travels on a single multiplexed WebSocket whose JSON messages are keyed by a ch (channel) field. The message unions live in shared/protocol.ts (ClientMsg / ServerMsg) and are the wire contract for both directions. Request/response work that doesn't stream — layout persistence, crypto orders, history queries, the audit log — goes over plain HTTP /api/* routes on the same origin. The WebSocket Protocol page documents both surfaces.

The web UI and window.homunculus

The React bridge in src/ is built (by vite.web.config.ts or the electron-vite renderer build — same artifact) and served by the backend from out/renderer. Before rendering, src/main.tsx calls installTransport() from src/transport.ts, which opens the WebSocket and installs a window.homunculus object of the HomunculusApi shape defined in shared/api.ts. Panels only ever talk to that object — onTelemetry, sendChat, termStart, onArchive, onDisconnect, and the rest — so they are transport-agnostic: the same panel code runs in a browser tab, on a phone over Tailscale, or inside the Electron window.

The transport also owns resilience: capped exponential backoff with jitter on reconnect, re-issuing channel subscriptions when the socket reopens, a bounded send queue (50 messages, 30-second TTL so a stale chat turn never fires half an hour late), and synthetic connection events so hooks like useChat can unlatch when a socket dies mid-stream.

The Electron shell is deliberately thin

electron/main.ts creates one BrowserWindow and loads the web UI from a URL — http://localhost:8787 in production, the electron-vite dev server in dev, or HOMUNCULUS_URL for a remote backend. It contains no telemetry, chat, or terminal code. Its only privileged feature is the OS-keychain key vault, exposed through a sandboxed preload (contextIsolation: true, nodeIntegration: false, sandbox: true) and disabled entirely against remote backends so credentials never cross the wire. When no backend is running it shows an inlined waiting page (a data: URL, so it depends on nothing the backend would have served) and retries every 3 seconds.

Design decision: the Computer Core runs in plain Node The README records this explicitly: the backend "runs in Docker. The Computer Core runs here in plain Node (not Electron's main process, which crashed)." An earlier design hosted the Claude Agent SDK session inside Electron's main process; it crashed, and the fix became the architecture — all real work moved to a standalone Node process that Electron merely connects to like any other client. The shared type headers in shared/chat.ts and shared/terminal.ts still say "main <-> renderer" from that era; the payloads are unchanged, only the transport moved.

The Python engine is a child process

The crypto screeners are evaluated by a separate Python program, engine/screener_engine.py. server/screenerRunner.ts builds a job (a pure, unit-testable function) and pipes it to the engine over stdin, reading the result from stdout — one short-lived child process per run, killed after a 30-second timeout if stuck. The comments in that file record why: a process boundary means a screener that runs long or crashes outright cannot take the trading server down, and the engine "has no keys, no network, and no path back into the order code" — it receives prices and returns opinions. The engine is deterministic (the job carries its own timestamp), and engine/tests/test_parity.py holds its indicator math to within 1e-9 of the TypeScript implementation in shared/indicators.ts.

Persistence: Postgres with a file replica

server/stateStore.ts gives every hub synchronous readJson/writeJson calls keyed by paths under data/, while mirroring every value into a Postgres app_state table when DATABASE_URL is set. Postgres is the durable system of record; the JSON files are a synchronously written local replica that lets hubs boot before any connection exists and keeps the app fully functional with no database at all. A reconcile() pass at startup imports files with no row, restores rows with no file, and reports any divergence. The append-only audit log (server/auditLog.ts) follows the same dual-write shape and hash-chains its entries; the chain is verified at every boot.

Other decisions worth recording

  • Layout lives on the server, not localStorage (server/layout.ts), so the desktop shell and a browser over Tailscale render the same dashboard.
  • Fail-closed auth. An unset HOMUNCULUS_TOKEN refuses remote callers (503) rather than waiving authentication; localhost is waived, but opening a terminal PTY always requires the real token once one is configured, even locally over the socket. A present-but-foreign Origin header is rejected before any token check, closing the same-machine-browser hole.
  • Crash guards. The process holds live exchange orders in memory, so unhandledRejection is logged (to console and the audit log) rather than fatal, uncaughtException exits non-zero for a supervisor to restart, and SIGTERM/SIGINT flush the audit queue and state store before exit.
  • node-pty ships prebuilt (@homebridge/node-pty-prebuilt-multiarch), so no native build toolchain is needed under Node 20.

Repository layout

PathWhat lives there
server/The Node backend: index.ts (HTTP + WS + routing) and one module per subsystem, each with a colocated .test.ts
shared/Types shared by server and clients — protocol.ts, api.ts, and per-domain type modules (telemetry.ts, crypto.ts, layout.ts, …)
src/The React bridge: App.tsx, transport.ts, panels/, widgets/registry.tsx, components/, hooks/, lib/
electron/The thin desktop shell (main.ts, preload.ts, vault.ts)
engine/The Python screener engine and its stdlib-unittest suite
← UpgradingWebSocket Protocol →

WebSocket Protocol

The message contract between the backend and any client — enough to build a third-party one.

One WebSocket at /ws multiplexes telemetry, the Computer Core chat, the terminal, Home Assistant, OSINT, and the archive. Everything else — crypto, history, layout, routines, secrets, audit — is REST under /api/. The authoritative definitions are shared/protocol.ts (the wire types) and shared/api.ts (the client-facing shape), which is exactly what the shipped web client consumes.

Connecting

The endpoint is /ws on the backend host and port (default 8787). The reference client resolves it like this (src/transport.ts):

  • Same-origin by default: ws:// or wss:// matching the page protocol.
  • In Vite dev the page is on 5173 while the backend is on 8787, so the client redirects to ws://<hostname>:8787/ws.
  • A token, when present, is appended as a token query parameter.
ws://localhost:8787/ws
wss://homunculus.your-tailnet.ts.net/ws?token=<HOMUNCULUS_TOKEN>

Authentication

There is no in-band handshake — the gate is applied at the HTTP upgrade. Requests judged local are admitted unconditionally. Remote requests must present HOMUNCULUS_TOKEN as ?token=… or an x-homunculus-token header, compared in constant time. If the server has no token configured, remote upgrades are refused with 503; a wrong token gets 401. See Security.

Envelope

Every frame is JSON with two required fields: ch (the channel) and type (the message within that channel). All other fields are per-message. There is no request id, no envelope wrapper, and no batching — a message is the whole frame.

{ "ch": "telemetry", "type": "subscribe" }
{ "ch": "chat", "type": "send", "id": "turn-7", "text": "status report" }

Subscription channels follow one pattern: send subscribe once, then receive pushes until the socket closes. Subscriptions do not survive a reconnect — the client re-sends them on open.

Client → server

telemetry

MessagePayloadMeaning
subscribe—Begin receiving telemetry:update pushes.

chat — the Computer Core

MessagePayloadMeaning
status—Request the current ChatStatus; answered by chat:status.
sendid: string, text: stringStart a turn. The id is client-chosen and correlates every subsequent delta, done, or error for this turn.

term — the embedded terminal

MessagePayloadMeaning
startid, cols, rowsOpen a PTY session under a client-chosen id.
inputid, dataWrite keystrokes to the PTY.
resizeid, cols, rowsResize the PTY.
killidTerminate the session.

ha — Home Assistant

MessagePayloadMeaning
subscribe—Begin receiving ha:update snapshots.
commandentityId, service, dataCall a Home Assistant service against an entity. Acknowledged by ha:command_ack.

osint

MessagePayloadMeaning
subscribe—Begin receiving osint:update snapshots.
refresh—Force an out-of-cycle poll of the watchers.
geofenceconfig: GeofenceConfigPush the armed perimeter to the hub, which enforces it server-side.

archive

MessagePayloadMeaning
subscribe—Receive an archive:snapshot of recent events, then live archive:event pushes.

Server → client

ChannelMessagePayloadMeaning
telemetryupdatesnapshot: TelemetrySnapshotPeriodic host vitals.
chatstatusstatus: ChatStatusWhether the Computer Core is configured and ready.
chatdeltaid, delta: stringA streamed fragment of the assistant reply for that turn.
chatdoneid, stopReason: string | nullThe turn is complete.
chaterrorid, messageThe turn failed. No done follows.
chatproactiveid, text, meta?: ProactiveMetaAn unsolicited message from the bridge — an OSINT escalation, a device event, an alert. Also what feeds the archive.
termdataid, data: stringPTY output, coalesced server-side.
termexitid, exitCode: numberThe session ended.
haupdatesnapshot: HaSnapshotCurrent entity states.
hacommand_ackok: boolean, error?: stringResult of a service call.
osintupdatesnapshot: OsintSnapshotCurrent watcher state — contacts, escalations, geofence status.
archivesnapshotsnapshot: ArchiveSnapshotThe most recent slice on subscribe — up to 300 events, newest first.
archiveeventevent: ArchiveEventOne newly recorded event.

ProactiveMeta

Attached to a proactive message so the archive can classify it. When omitted the hub falls back to SYSTEM / notice with a derived title.

FieldTypeMeaning
sourceOSINT | HOME | COMPUTER | CRYPTO | FINANCE | SYSTEMOriginating subsystem; drives the source filter.
severityinfo | notice | warn | criticalSeverity, low to high; drives colour and the severity filter.
titlestring?Event title. Derived from the text when absent.
iconstring?Tabler icon name for the toast, e.g. ti-wash.
substring?Toast subtitle. Not stored in the archive body.
chatLogboolean?Defaults true. When false the event toasts and archives but is not injected into the Computer Core conversation — device and crypto events set this false to avoid spamming the chat.

Client behaviour worth copying

The reference transport handles three things a naive client gets wrong:

  • Re-subscribe on reconnect. Subscriptions are per-socket. The client tracks which channels it subscribed to and replays them in onopen.
  • Bounded, expiring send queue. Messages sent while the socket is down are queued to a maximum of 50 entries with a 30-second TTL. The TTL is a safety property, not tidiness: a chat turn typed during an outage should not start a Claude session — one that can stage trades — half an hour later, unprompted.
  • Surface disconnects to consumers. A socket dying mid-stream means an in-flight chat turn or terminal session will never receive its terminating message. onDisconnect / onReconnect exist so consumers stop waiting for a done that is not coming.

Reconnect uses exponential backoff from 1s to 30s, reset on a successful connection.

The REST surface

Everything not on the socket is REST. All routes are subject to the same token gate; audit management additionally requires the admin token.

AreaRepresentative routes
Build identityGET /api/version
LayoutGET/POST /api/layout, POST /api/layout/reset
Setup & state/api/setup, /api/state
Secrets/api/secrets, /api/secrets/unlock (localhost only)
History (DATA tab)/api/history/telemetry, /api/history/ha, /api/history/entities
Home Assistant config flow/api/ha/integrations, /api/ha/discovered, /api/ha/entries/:entry, /api/ha/flow/:flow
Routines/api/routines, /api/routine/:name
Proactive/api/proactive/say, /api/proactive/trigger
Agents/api/agent/manifest, /api/agent/intent, /api/claude/running, /api/claude/stop-all
Audit/api/audit, /api/audit/files, /api/audit/verify, /api/audit/annotate (admin token)
Sync/api/sync/manifest, /api/sync/file, /api/sync/config, /api/sync/run
Crypto/api/crypto/snapshot, /api/crypto/positions, /api/crypto/candles/…, /api/crypto/screeners, /api/crypto/strategy/*, /api/crypto/bracket/*, /api/crypto/autoplan/*, /api/crypto/trade/…, /api/crypto/order/…
Warning The crypto routes include order placement and trade staging. A third-party client holding HOMUNCULUS_TOKEN can spend real money through them.

Minimal client

const ws = new WebSocket('ws://localhost:8787/ws')

ws.onopen = () => {
  ws.send(JSON.stringify({ ch: 'telemetry', type: 'subscribe' }))
  ws.send(JSON.stringify({ ch: 'archive', type: 'subscribe' }))
}

ws.onmessage = (e) => {
  const msg = JSON.parse(e.data)
  if (msg.ch === 'telemetry' && msg.type === 'update') {
    console.log('vitals', msg.snapshot)
  }
  if (msg.ch === 'archive' && msg.type === 'event') {
    console.log(msg.event.severity, msg.event.title)
  }
}

Because shared/protocol.ts is a discriminated union on ch + type, a TypeScript client that imports ClientMsg and ServerMsg gets exhaustiveness checking for free.

← ArchitectureWidget Development →

Widget Development

How to add a panel to the bridge: the registry, the grid, data subscriptions, and the theme tokens.

A widget is a zero-prop React component plus one entry in a registry. src/widgets/registry.tsx is the only file you must touch to make a new panel placeable — add an entry and it appears in SETTINGS → WIDGETS, draggable onto any tab.

How the pieces fit

  • src/panels/ — the component itself. One file per panel.
  • src/widgets/registry.tsx — the catalogue: id, label, category, default and minimum grid footprint, and a render() thunk.
  • src/components/WidgetGrid.tsx — the 12-column grid that places, moves, resizes, and compacts them.
  • src/hooks/ — subscription hooks over window.homunculus.
  • src/lib/ — typed REST clients for the /api/ surface.

Why widgets take no props

Panels differ in what they need — a telemetry snapshot, HA entities, crypto positions, nothing at all. Rather than plumb props through the grid, each registry entry renders from useWidgetContext(). From the grid point of view every widget is a zero-prop component, so placement stays generic.

export interface WidgetContextValue {
  telemetry: TelemetrySnapshot | null
  haEntities: HaEntity[]
  sendHaCmd: (entityId: string, service: string, data: Record<string, unknown>) => void
  crypto: ReturnType<typeof useCryptoPositions>
}

The provider is installed once, at the top of App.tsx. If your panel needs data that is not in the context, subscribe to it yourself with a hook rather than widening the context — see below.

The registry entry

export interface WidgetDef {
  id: string
  label: string
  /** Grouping in the widget picker. */
  category: 'CORE' | 'HOME' | 'CRYPTO' | 'DASHBOARD' | 'MISC'
  /** Default grid footprint when dropped fresh (12-col grid, ROW_H units). */
  defaultW: number
  defaultH: number
  minW: number
  minH: number
  /** Some panels are singletons — a second Terminal instance would fight the
   *  first over its PTY session id. */
  singleton?: boolean
  render: () => JSX.Element | null
}
FieldGuidance
idDotted namespace — system.vitals, core.terminal, dash.crypto. This id is written into data/layout.json, so changing it orphans existing placements.
categoryPicker grouping, rendered in the order CORE, DASHBOARD, HOME, CRYPTO, MISC.
defaultW / defaultHFootprint when dropped fresh. Width is in columns out of 12; height is in row units. Small tiles are typically 2×4; a full-bleed dashboard is 12×24.
minW / minHResize floor. Set it where your panel stops being readable, not at 1×1.
singletonSet it when two instances would conflict over shared state — the Terminal fights over its PTY session id, and each whole-tab dashboard owns its own subscriptions.

Grid behaviour worth knowing

The grid is 12 columns with drag-to-move, drag-to-resize, and vertical compaction. There is no third-party grid dependency.

  • Rows are elastic while the layout still fits. If the tallest widget bottom edge is within FIT_ROWS (24), row height stretches so content fills the pane exactly — that is what makes a single full-width dashboard look identical to the hardcoded full-bleed body it replaced. Past that, the grid falls back to a fixed row height and scrolls, so a genuinely tall dashboard is not squashed.
  • Compaction pulls everything as far up as it will go, preserving left-to-right order.
  • Nothing is draggable until edit mode is on. A stray mousedown on a live trading panel must never reflow the dashboard.

Getting data

Streaming data — hooks over the WebSocket

Every live channel already has a hook in src/hooks/: useTelemetry, useHomeAssistant, useOsint, useArchive, useChat, useCryptoPositions, useLayout, useSecrets, useProactiveToasts, useHomeRoute, useTheme. They all follow the same shape — subscribe in an effect, return the unsubscribe:

export function useTelemetry(): TelemetrySnapshot | null {
  const [snapshot, setSnapshot] = useState<TelemetrySnapshot | null>(null)

  useEffect(() => {
    // Guard for running the renderer outside Electron (e.g. plain browser).
    if (!window.homunculus) return
    const unsubscribe = window.homunculus.onTelemetry(setSnapshot)
    return unsubscribe
  }, [])

  return snapshot
}
Warning Always guard on window.homunculus and always return the unsubscribe. The transport reconnects with backoff, and a leaked listener survives the reconnect and double-fires.

Request/response data — the lib clients

Anything on the REST surface goes through a typed client in src/lib/ — api.ts, cryptoApi.ts, agentApi.ts, agentsApi.ts, devicesApi.ts, blockersApi.ts. Add your call there rather than calling fetch from a panel: the clients carry the token handling and have colocated tests.

Adding a new channel

If your widget needs data that does not exist yet, the change spans the stack — add the message to shared/protocol.ts and shared/api.ts, implement it in a server module, expose it in src/transport.ts, then write the hook. See Backend Modules and the protocol reference.

Styling

All colour, typography, and chrome come from CSS custom properties defined in src/styles/global.css. Never hardcode a colour. A literal hex breaks the moment someone switches theme.

Themes

Three themes ship, selected by useTheme() and applied as a data-theme attribute on the root, persisted to localStorage:

IdLabelCharacter
devDEV ◈The default — phosphor green on near-black, crimson reserved for alerts.
prismPRISM ◇Iridescent light-field over deep indigo-black, with animated rainbow sweeps on decorative edges. These docs use it.

Because every theme redefines the same token names, a panel written against the tokens themes itself for free.

Tokens you will reach for

TokenUse
--bg, --bg-panel, --bg-elev, --bg-meterBackgrounds, increasing elevation.
--green, --green-soft, --green-dimPrimary accent, body text, muted text. Named for the default theme; each theme redefines them (in PRISM the primary is cyan).
--crimson, --amber, --cool-colorAlert, warning, and cool-status accents.
--border, --border-strong, --glow-green, --glow-textEdges and glows.
--font-mono, --font-display, --font-wordmarkBody, panel labels, and the logo. --font-wordmark is a hairline face — never below ~18px or for running text.
--ind-fast, --ind-slow, --ind-band, --ind-rsi, --ind-costChart indicator palette, kept distinct from candle colours.

Structural classes

panel-label for the header strip, holo / holo-h / holo-v / holo-bar for holographic tiles and meters, muted and alert for de-emphasised and alarming text. Under PRISM the holo-* classes and panel-label pick up the animated iridescent sweep automatically.

A worked example

A tile showing uptime from the telemetry snapshot.

1. The panel — src/panels/UptimeTile.tsx:

import { useWidgetContext } from '../widgets/registry'

export function UptimeTile(): JSX.Element {
  const { telemetry } = useWidgetContext()
  if (!telemetry) return <div className="panel-label muted">UPTIME · —</div>

  const hours = Math.floor(telemetry.uptimeSec / 3600)
  return (
    <div className="holo">
      <div className="panel-label">UPTIME</div>
      <div style={{ color: 'var(--green)', fontFamily: 'var(--font-display)' }}>
        {hours}h
      </div>
    </div>
  )
}

2. The registry entry — in src/widgets/registry.tsx:

import { UptimeTile } from '../panels/UptimeTile'

def({
  id: 'system.uptime', label: 'Uptime', category: 'CORE',
  defaultW: 2, defaultH: 4, minW: 2, minH: 3,
  render: () => <UptimeTile />,
}),

That is the whole change. The widget now appears in SETTINGS → WIDGETS under CORE and can be dropped on any tab. Verify the field you read actually exists on TelemetrySnapshot in shared/telemetry.ts — the type is the contract.

Checklist

  • Component takes no props and reads from useWidgetContext() or its own hook.
  • Registry entry added with a stable dotted id.
  • minW / minH set where the panel stops being readable.
  • singleton: true if two instances would conflict.
  • No hardcoded colours — tokens only.
  • Renders sensibly with null or empty data; the socket may not have delivered yet.
  • Every subscription returns its unsubscribe.
  • npm run typecheck passes.
← WebSocket ProtocolBackend Modules →

Backend Modules

Conventions in server/, the hub and persistence patterns, and how to add a subsystem end to end.

The backend is one Node process. Each subsystem is a module in server/ with a colocated test file, and server/index.ts is the only place they are wired together: it owns the HTTP server, the WebSocket server, the REST routing table, and the startup sequence.

Conventions

  • One module per subsystem, named for what it does: telemetry.ts, terminal.ts, chat.ts, homeassistant.ts, osint.ts, archive.ts, crypto.ts.
  • Tests sit beside the code — osint.ts and osint.test.ts in the same directory. Nearly every module in server/ has one.
  • Most modules export a singleton hub (telemetryHub, haHub, osintHub, archiveHub, cryptoHub, historyHub) rather than a class the caller instantiates. Per-connection things are classes instead: ChatSession and TerminalManager are constructed once per WebSocket with that socket send function.
  • shared/ is the type contract. A module that speaks to the client defines its shapes in shared/ so the server and the React code compile against the same definitions. Pure logic that both sides need — indicator math, layout sanitisation, screener rules, alert classification — lives there too, with its own tests.
  • Long comments explain decisions, not mechanics. The house style is a header block stating why the module looks the way it does. Follow it: the reasoning is the part that gets lost.

The hub pattern

A hub owns some external resource — a poller, a subscription, a device connection — and fans updates out to listeners. The shape is consistent:

  • start() begins whatever polling or connecting it does, called from the startup sequence in index.ts.
  • Listeners subscribe and receive a snapshot plus subsequent updates; subscribing returns an unsubscribe function.
  • Reference counting matters. TelemetryHub only stops collecting once its listener count hits zero — a subscriber that is never removed pins an expensive collect loop forever. This has bitten before: HistoryHub.enabled once reported !!DATABASE_URL, which stayed true even when the connection failed, so index.ts installed a permanent subscriber feeding a dead sink and pinned the 2-second telemetry loop with no UI attached. If your hub exposes an enabled flag, make it mean "actually working", not "configured".

Persistence

Every hub persists the same way: read a JSON file at import time, rewrite the whole file on every change. That pattern is load-bearing and deeply synchronous — cryptoHub reads its trades while constructing a snapshot, agentFleet reads at module scope — so it could not simply be made async.

server/stateStore.ts resolves this by changing where the data lives, not how the code reads it. readJson / writeJson are drop-in replacements for the readFileSync / writeFileSync pair, keyed by the same file path:

postgres: app_state(key, value)   durable system of record
data/**/*.json                    local replica, written synchronously
  • Postgres is authoritative when configured: durable, queryable, backed up as one unit, and it survives losing the working directory.
  • The files remain as a synchronously-written local replica — which is what lets a hub boot before any connection exists, and what keeps the app fully functional with DATABASE_URL unset.
  • Both copies are written on every change, so neither drifts. reconcile() runs at startup and reports it if they ever do.
  • The key is derived from the path under data/ — data/crypto/trades.json → crypto/trades.json — so no hub has to invent or remember a name.

Use stateStore for anything a restart must not lose. The same discipline governs server/auditLog.ts, for the same reason: a write that only reaches a database that happens to be down is a write that did not happen.

How index.ts wires it together

HTTP and REST

index.ts serves the built web UI from HOMUNCULUS_WEB_DIR and routes /api/ requests. Sensitive routes call requireToken(req, res) first, which returns false having already written the error response — so the handler just returns. Audit management calls requireAdminToken instead. Larger areas delegate to their own handler module; the screener API is a good example, routed through handleScreenerRequest rather than inlined.

WebSocket

On each connection index.ts builds a send function that JSON-encodes to the socket if it is still open, constructs a ChatSession and a TerminalManager bound to it, registers a proactive listener, and holds unsubscribe handles for every hub subscription so they can all be torn down on close.

Messages are dispatched on the ch + type discriminated union from shared/protocol.ts. Malformed JSON is dropped silently.

Warning The terminal channel re-checks the token itself. The upgrade handler waives the token for a local socket — sensible for the dashboard, but not for a channel that hands out a shell carrying the process environment. Opening a PTY always costs the real secret once one is configured, regardless of where the socket dialled in from. Preserve that check if you touch the connection handler.

Startup sequence

Order matters. stateStore.start() and auditLog.start() come first — they run migrations and reconciliation that other modules depend on. Then the hubs start: proactiveMonitor, osintHub, homeWatcher, cryptoHub, historyHub, and archiveHub once history has settled.

Adding a subsystem end to end

1. Define the contract in shared/. A new file with your snapshot and payload types, plus any pure logic and its test.

2. Add the messages to shared/protocol.ts. Extend ClientMsg and ServerMsg with your channel. Follow the subscribe/update convention:

| { ch: 'weather'; type: 'subscribe' }
| { ch: 'weather'; type: 'update'; snapshot: WeatherSnapshot }

3. Add the client-facing method to shared/api.ts so panels stay transport-agnostic:

onWeather(handler: (snapshot: WeatherSnapshot) => void): () => void

4. Write server/weather.ts — a hub with start(), a listener set, and stateStore persistence if it needs any. Write server/weather.test.ts alongside it.

5. Wire it in server/index.ts — import the hub, start it in the startup sequence, handle subscribe in the connection handler, and hold the unsubscribe for teardown.

6. Expose it in src/transport.ts, implementing the HomunculusApi method. Track the subscription so it is replayed on reconnect.

7. Add the hook and the widget — src/hooks/useWeather.ts, then a panel and a registry entry. See Widget Development.

8. Add configuration to .env.example and to the configuration reference. If it introduces a credential, add it to SECRET_SPECS in shared/secrets.ts and to NEVER_FORWARDED in server/agentEnv.ts.

Danger Any new credential must be added to NEVER_FORWARDED. The allowlist in agentEnv.ts already blocks unknown variables, but the explicit list is what states the intent to a future reader — and what protects you if someone later loosens the allowlist. See Security.

Module map

AreaModules
Coreindex.ts, telemetry.ts, terminal.ts, chat.ts, stateStore.ts, secrets.ts, auditLog.ts, format.ts
Claude / agentsagents.ts, agentIntent.ts, agentEnv.ts, claudeProcesses.ts, claudeResult.ts
Home Assistanthomeassistant.ts, haConfigFlow.ts, haAreaRegistry.ts, homewatch.ts, routines.ts
OSINTosint.ts, country-centroids.ts
Data & archivehistory.ts, archive.ts, layout.ts, sync.ts
Cryptocrypto.ts, cmc.ts, cryptoAlerts.ts, cryptoStrategySettings.ts, strategyRunner.ts, screenerApi.ts, screenerRunner.ts, screenerStore.ts
Officeoffice.ts, library.ts, blockers.ts, managerFile.ts
← Widget DevelopmentScreener Engine →

Screener Engine

The deterministic Python screening engine, its contract with the Node server, and how to extend it.

The screening engine is deterministic Python that answers one question: given a screener definition and a pile of candles, which symbols pass — and for the ones that do not, which gate stopped them. There is no model here, no network call, and no clock read. The job carries its own timestamp, so the same job always produces the same bytes.

Note Nothing here can trade. The engine receives prices and returns opinions. It has no keys, no network, and no path back into the order code.

Why a separate process

Screening is deterministic arithmetic over candles — no model, no network, no judgement. Python already hosts the repository quantitative work, and a process boundary means a screener that runs long or crashes outright cannot take the trading server down with it. The cost is a serialization round-trip, which is why the payload is trimmed to exactly one base feed per scan.

The wire contract

The engine is a CLI: a job on stdin, a result on stdout.

echo '<job json>' | python3 engine/screener_engine.py    # → result json
python3 engine/screener_engine.py --contract             # → wire contract

The contract lives in shared/screener.ts. Both sides stamp schemaVersion (SCREENER_SCHEMA_VERSION, currently 1), and the engine refuses a job whose version it does not implement rather than guessing at a gate it might read wrong.

How Node calls it

server/screenerRunner.ts is the bridge, split deliberately in two:

  • buildScreenerJob() — assembles the job. Pure, synchronous, and a function only of its inputs (which candles, which market caps, which symbols are held), so the interesting half is unit-testable without a subprocess.
  • runScreenerEngine() — pipes the job to engine/screener_engine.py and reads the result. Everything that can fail in boring operational ways (no interpreter, a hung process, a corrupt pipe) lives here, and is testable with a three-line fake engine.
ConstantValueWhy
BARS_PER_SYMBOL400Covers an EMA-200 on the native timeframe with room to spare while keeping a 142-symbol payload to a few megabytes. A derived timeframe sees fewer bars and its longest averages may legitimately report "not enough data" — which the engine says out loud rather than quietly approximating.
DEFAULT_TIMEOUT_MS30000A full-universe scan measures in low single-digit seconds; past this it is stuck, not slow.
MAX_OUTPUT_BYTES64 MBNode defaults to 1 MB, which a 142-symbol result overruns immediately.

Interpreter discovery

The server probes for a real Python at startup — the py launcher first on Windows, python3 elsewhere — and skips the Microsoft Store alias stub that only prints "Python was not found". Set SCREENER_PYTHON to pin a specific interpreter, such as a venv.

Layout

FileWhat it holds
indicators.pyRSI / EMA / MACD / Bollinger — a port of shared/indicators.ts.
rollup.py4hr and 1week bars, derived from the feeds Gemini serves.
patterns.pyThe 20 candle patterns a screener may whitelist.
screen.pyGate evaluation, fit scoring, ranking, the funnel.
screener_engine.pyThe CLI — stdin job in, stdout result out.

Gates

A screener is a set of gates. Evaluation order is user-visible and matters twice: the funnel eliminates in it (cheap market compares before expensive candle walks), and results sort by it.

marketCap → volume24h → change24h
  → rsi → ema50 → ema200 → macd → bbWidth
  → pattern → freshness → relVolume

Three rules shape the evaluation:

  1. The blocking gate is the first failure in gate order, not the worst one. The rail reads top to bottom, so "blocked by market cap" means the user first filter — not whichever gate failed hardest.
  2. Missing data is not the same as failing. A gate marked optional steps aside and flags itself degraded when its feed is absent. Every other gate fails when its input cannot be computed: a symbol with nine candles genuinely cannot be screened on RSI, and quietly admitting it would be a lie about what was filtered.
  3. Fit is mostly "how many gates did you clear", with a minority weight on "how comfortably". Two symbols that both pass everything should not tie, and the tiebreak that matters to a trader is margin — deeper into the range, fresher pattern, more room before the bound.

Optional-data gates

marketCap and volume24h both come from CoinMarketCap. Volume is deliberately the CMC cross-exchange aggregate rather than the Gemini book — one thin venue does not represent the market. A missing read degrades the gate to ANY instead of failing the symbol, so an unconfigured CMC_API_KEY does not make the market look empty.

Indicators

indicators.py exposes the series helpers (closes_of, highs_of, lows_of, volumes_of, last_value, last_pair, crossed_above, crossed_below) and the indicators themselves: sma, ema, rsi, macd, bollinger, bb_width_pct, rel_volume, pct_change.

Warning These are a port of shared/indicators.ts, not an independent implementation. If you change the math on one side you must change it on the other, or the chart draws one RSI while the screener filters on another.

Patterns

patterns.py detects 20 candle patterns, including hammer, hanging_man, inverted_hammer, shooting_star, the doji family (doji, dragonfly_doji, gravestone_doji, long_legged_doji), bullish_engulfing / bearish_engulfing, the harami family, piercing_line, dark_cloud_cover, morning_star, evening_star, and three_black_crows. Detection is trend-aware — trend_before() establishes context so a hammer in a downtrend is not confused with a hanging man in an uptrend.

Timeframe rollup

Gemini serves 1m/5m/15m/30m/1hr/6hr/1day. A screener may run on 4hr or 1week, so rollup.py builds those from the 1hr and 1day feeds — the same derivation server/crypto.ts already does for the 4hr chart, ported so the screener and the chart bucket identically.

  • A bar timestamp is its bucket boundary, not the timestamp of the first input bar that landed in it. That is what makes the rollup stable: the same candle always falls in the same bucket regardless of where the fetched window starts, so two scans an hour apart agree about what "the last 4hr bar" was.
  • The newest bucket is deliberately kept even when partial — it is the forming bar, which is precisely the one a live screen is reading.

Tests

npm run test:engine
# or directly:
python3 -m unittest discover -t . -s engine

Stdlib unittest, no dependencies — the same rule as the rest of the Python here. npm test runs the TypeScript suite and then this one.

TestCovers
test_indicators.pyThe indicator math.
test_patterns.pyPattern detection and trend context.
test_rollup.pyBucketing and the partial newest bar.
test_gates.py, test_screen.pyGate evaluation, blocking-gate selection, fit scoring, the funnel.
test_cli.pyThe stdin/stdout contract, including that the Python gate order equals the TypeScript one.
test_parity.pyThe one worth understanding — see below.

The parity test

test_parity.py recomputes the numbers shared/indicators.ts produced from real candles and fails on any disagreement past 1e-9. Without it the two implementations drift silently. If you change the math on either side, regenerate the fixture and let the diff show you what moved:

npm run fixtures:parity

That runs engine/tools/gen-parity-fixture.ts and rewrites engine/fixtures/parity.json.

Adding an indicator or a pattern

  1. Add it on the TypeScript side first (shared/indicators.ts) if the chart needs it too — that file is the reference implementation.
  2. Port it to engine/indicators.py or add the detector to engine/patterns.py, matching the TypeScript output exactly.
  3. If it becomes a gate, add it to GATE_ORDER in both shared/screener.ts and engine/screen.py — the CLI test asserts the two lists are equal — plus a label in GATE_LABELS, and classify it: range gate, trend gate, or optional-data gate.
  4. Bump SCREENER_SCHEMA_VERSION on both sides if the job or result shape changed.
  5. Regenerate the parity fixture and run npm test.

Related

  • CRYPTO tab — where screeners are defined and run.
  • Configuration — SCREENER_PYTHON and CMC_API_KEY.
← Backend ModulesContributing & Testing →

Contributing & Testing

Dev setup, every npm script, what the test suites cover, and the conventions to match.

Homunculus is TypeScript end to end — one Node backend, a React renderer, a thin Electron shell, shared types between them — plus a small dependency-free Python engine for screening. This page covers getting a checkout running, the scripts, and what the tests do and do not cover.

Setup

nvm use            # Node 20 LTS, pinned in .nvmrc
npm install
npm run dev        # backend on :8787 + the Electron shell

No Xcode or native-build dance is needed: node-pty runs under Node with prebuilt binaries. If the terminal stops working after a Node or Electron upgrade, rebuild the binding with npm run rebuild.

For the Python engine you need any Python 3 on PATH. The server probes for one at startup; SCREENER_PYTHON pins a specific interpreter if you have several.

Scripts

ScriptWhat it does
npm run devRuns dev:server and dev:app together under concurrently, with the backend labelled green and the app cyan.
npm run dev:serverBackend alone with reload — tsx watch server/index.ts.
npm run dev:appElectron renderer alone — electron-vite dev.
npm run start / npm run serverBackend without watch. What production runs.
npm run buildBuilds the Electron app (electron-vite build).
npm run build:webBuilds the browser UI the backend serves.
npm run typechecktsc --noEmit over the whole project. Fast; run it before every commit.
npm testThe full suite: vitest, then the Python engine tests.
npm run test:tsVitest only.
npm run test:watchVitest in watch mode.
npm run test:coverageVitest with v8 coverage, text and HTML reporters.
npm run test:engineThe Python engine tests, via scripts/run-engine-tests.mjs.
npm run fixtures:parityRegenerates engine/fixtures/parity.json from the TypeScript indicators.
npm run rebuildRebuilds the native PTY binding for the current Electron ABI.
npm run dist / dist:macBuilds distributable desktop artifacts. dist:mac enables notarization when NOTARIZE is set.
npm run iconsRegenerates app icons.
npm run release:patch|minor|majorVersion bump, tagged release: vX.Y.Z. The version lifecycle hook stages CHANGELOG.md into the release commit.

Code style

There is no linter config in the repo; the compiler is the enforcement. tsconfig.json is strict and then some:

SettingConsequence
strict: trueFull strict mode, including strictNullChecks.
noUnusedLocals, noUnusedParametersDead bindings are build errors, not warnings.
isolatedModulesType-only imports must say import type.
forceConsistentCasingInFileNamesCase mistakes fail on Linux and Windows alike, not just in CI.
target: ES2022, module: ESNextESM throughout. Node built-ins are imported with the node: prefix in newer modules.
paths: { "@/*": ["src/*"] }@/ aliases src/ in the renderer; vitest mirrors the alias.

Beyond the compiler, follow the house conventions visible in any module:

  • Comment the decision, not the mechanics. Modules open with a header block explaining why they look the way they do — what was tried, what broke, what constraint forced the shape. That reasoning is the part that gets lost; the code already says what it does.
  • Section dividers use the box-drawing style (// ── Name ─────).
  • Types that cross the server/client boundary live in shared/, never duplicated.
  • No hardcoded colours in the renderer — CSS custom properties only.

Testing

What is covered

Vitest runs in a Node environment over **/*.test.ts(x), with roughly 71 test files: about 31 in server/, 20 in shared/, and 20 in src/ (mostly src/lib/). Coverage is measured over shared/**, server/**, and src/lib/** — deliberately the logic layers, not the components.

The pattern that makes this work is separating judgement from I/O. screenerRunner.ts is the clearest example: buildScreenerJob() is pure and synchronous, so the interesting half is unit-testable with no subprocess, while runScreenerEngine() holds everything that fails in boring operational ways and is tested against a three-line fake engine. Aim for that split in new code.

npm run test:ts                      # everything
npx vitest run server/osint.test.ts  # one file
npm run test:watch                   # watch mode

The Python engine

npm run test:engine
python3 -m unittest discover -t . -s engine   # equivalent

Stdlib unittest, no dependencies. test_parity.py is the load-bearing one — it fails if the Python and TypeScript indicator implementations disagree past 1e-9. See Screener Engine.

What has no automated coverage

Warning These areas are verified by hand. Changes touching them deserve extra care in review.
  • React components and panels. Tests cover src/lib/, not rendering. There is no component test harness.
  • The Electron shell. Window lifecycle, the preload bridge, and the vault are untested.
  • WebSocket integration. Modules are tested in isolation; nothing exercises a real socket end to end.
  • Live external integrations. Home Assistant, Gemini, CoinMarketCap, and the OSINT feeds are not contract-tested against the real services.

Manual verification checklist

  1. npm run typecheck and npm test both clean.
  2. npm run dev, then confirm every tab you touched still renders and populates.
  3. Open the browser view at localhost:5173 too — the renderer runs in both, and window.homunculus guards are easy to get wrong.
  4. If you touched layout or widgets: move a widget between tabs, reload, and confirm it persisted.
  5. If you touched the transport: kill the backend with the UI open, restart it, and confirm the client reconnects and re-subscribes.
  6. If you touched the terminal: open a PTY, resize it, and kill it.
  7. If you touched anything credential-adjacent: confirm agentEnv() still strips it.

Branches and commits

Work happens on feature branches named feat/<topic> and merges to main. Commit subjects are written as imperative statements of intent, not conventional-commit prefixes:

Expand the HOME tab into a full Home Assistant surface
Add a Home Assistant setup wizard for new machines
Note that HA_TOKEN must come from an admin account
Bring in the production hardening

Match that voice: say what the change does to the product. Release commits are the exception, formatted release: vX.Y.Z by the npm version scripts.

Before opening a pull request

  • npm run typecheck passes.
  • npm test passes, including the engine tests.
  • New logic has a colocated .test.ts.
  • New configuration is in .env.example and in the configuration reference.
  • New credentials are in SECRET_SPECS and NEVER_FORWARDED.
  • CHANGELOG.md updated if the change is user-visible.
  • Docs updated if you changed the protocol, configuration, or a tab surface.
← Screener Engine
Homunculus Core — self-hosted command interface