Personal AI Router is an Electron desktop application backed by the modular Go
services in the sibling services/ directory of this repository. The renderer
never connects directly to backend processes. Electron main owns process
lifecycle and exposes a typed preload bridge to the UI.
flowchart LR
subgraph Electron
UI["React renderer"]
Preload["Preload bridge"]
Main["Electron main"]
Supervisor["ModularSupervisor"]
end
Broker["nvpair-ui-broker"]
Workers["Broker-owned workers"]
UI -->|"window.pairApi"| Preload
Preload <-->|"service-bridge IPC"| Main
Main --> Supervisor
Supervisor <-->|"stdio JSON-RPC"| Broker
Broker --> Workers
nvpair-ui-broker is the only backend binary Electron spawns directly. The
broker supervises every runtime worker and relays their JSON-RPC methods and
notifications. In development binaries live in cli-bin/; packaged builds load
them from process.resourcesPath/cli-bin.
The canonical runtime inventory is
src/shared/constants/modular-binaries.ts:
| Binary | Owner | Purpose |
|---|---|---|
nvpair-ui-broker |
Electron | Worker supervision and control-plane relay |
ollama-proxy |
Broker | Ollama-compatible proxy and cluster routing |
lmstudio-proxy |
Broker, optional | LM Studio OpenAI-compatible proxy |
nvpair-node-scanner |
Broker | LAN discovery and announcement |
nvpair-node-info |
Broker | Node metadata and telemetry endpoint |
nvpair-workload-manager |
Broker, optional | Workload replication |
nvpair-cluster-manager |
Broker, optional | Cluster identity, pairing, and membership |
nvpair-node-settings |
Broker | Persisted node identity and settings |
nvpair-manual-nodes |
Broker, optional | User-managed discovery entries |
nvpair-engine-manager |
Broker, optional | Engine lifecycle and model operations |
nvpair-errors |
Broker, optional | Error registry and peer synchronization |
nvpair-job-scheduler |
Broker, optional | Node-wide routing priority |
nvpair-tui is bundled as a standalone terminal client; it is not supervised by
Electron or the broker. It spawns and owns its own nvpair-ui-broker, so it runs
over SSH with no display. It is what the nvpair command on PATH resolves to on
every platform — see Terminal use.
The renderer uses window.pairApi. Its logical invoke and push contracts are
declared in src/shared/types/ws-channels.ts. Preload forwards them through the
service-bridge:invoke and service-bridge:push IPC envelope.
Electron main handles logical requests in
src/electron/service-bridge/empty-handlers.ts. Backend notifications update
ModularBridgeState, which emits typed push events to preload.
See Frontend API Reference for the channel surface.
window.windowApi is reserved for Electron-native operations:
- window and tray management;
- clipboard access;
- service lifecycle and log access;
- updater commands and status;
- first-run settings.
These channels are declared in src/shared/types/ipc-channels.ts and handled
with safeHandle().
Personal AI Router has no Electron-backed console client. The terminal interface
is the bundled Go nvpair-tui, which owns its own broker and worker tree and is
independent of the desktop app.
On packaged builds the app generates an nvpair launcher on PATH that execs
resources/cli-bin/nvpair-tui (src/electron/nvpair-command.ts): a .cmd
under <userData>\bin on Windows, with that directory appended to the per-user
PATH, and a shell script in ~/.local/bin or /usr/local/bin otherwise. The
Debian package installs the same wrapper at /usr/bin/nvpair. The desktop entry
launches the GUI directly from /opt and does not go through the wrapper.
Because the TUI starts its own broker, do not run it alongside the desktop app — the two process trees compete for the same engines and proxy ports.
JsonRpcSubprocess provides newline-delimited JSON-RPC over stdio. The
supervisor passes worker paths and the configured log level to the broker,
subscribes to broker relays after app:ready, and converts backend responses
into stable UI contracts.
Electron reports the service connected after broker app:ready. The
broker-owned Ollama and LM Studio proxies remain asynchronous capabilities; a
late or failed proxy does not misreport the broker startup as failed. If
app:ready does not arrive within the startup deadline, Overview opens Settings
Service, surfaces the failure, and leaves restart and log actions available instead of loading indefinitely. A late
app:readynotification restores connected state.
A service that is not running at all — stopped from Settings, or exited on its
own — is reported only by the Electron connector, since a dead broker sends
nothing over the bridge. Overview and the tray therefore treat
connectorStatus === 'disconnected' as its own state and render a stopped
notice with a Start control, rather than the spinner that would otherwise wait
on a service nobody is starting. When the connector reaches connected again,
the renderer re-reads every domain snapshot, because the restarted broker builds
a fresh state projection.
The only direct backend HTTP polling in Electron is /v1/node-info: Electron
polls healthy local and discovered nodes every two seconds because rich
telemetry is not part of the broker JSON-RPC surface. Consecutive failures back
off to 4, 8, 16, then 30 seconds; the first success or a changed endpoint
restores the normal cadence.
Personal AI Router uses commands down and events up:
- A renderer action invokes a typed service command.
- Electron dispatches it through the broker.
- Backend notifications update
ModularBridgeState. - Push events update renderer stores.
Renderer stores fetch domain snapshots at startup and then subscribe to push events. Engine lifecycle and model commands do not return replacement state. The centralized pending-actions store is the only optimistic renderer state for engine operations.
Important push domains include:
nodes:upsert/nodes:remove;engines:state-changed;engines:progress-changed/engines:progress-cleared;metrics:update;workloads:upsert/workloads:remove;errors:update;discovery:nodes-changed;connection:cluster-identity;cluster:invite-received;cluster:pending-invites-changed;state:request-refresh.
nvpair-node-scanner owns LAN discovery. Personal AI Router consumes the broker's
AvailableNode snapshots and does not construct or interpret the backend's
discovery service names.
Personal AI Router keys every node by the backend's stable per-host UUID (AvailableNode.id /
hostUuid), not the hostname. That same UUID keys workload attribution
(originatedFrom/scheduledOn), errors (nodeId), proxy routing (Node.ID),
/v1/node-info, and cluster membership (ClusterNode.nodeUuid), and it is the
value of selfId (the cluster-manager nodeUuid, resolved as soon as the broker
is ready). The hostname is retained only as the display name. This lets one
merge collapse the broker- and proxy-reported views of a node into a single
entry and makes cross-domain correlation exact. AvailableNode also carries the
trusted and clustered flags; Personal AI Router renders an already-clustered node as
non-invitable.
Cluster admission, identity, trusted certificates, membership, and every
node-to-node channel are owned by nvpair-cluster-manager and the other
backend workers. Personal AI Router implements no cluster cryptography, no certificate or key
handling, and no transport security. It only relays cluster:* / nodes:*
requests to the broker and renders the resulting notifications (invite status,
membership). An outbound invite can be canceled through cluster:cancel-invite,
which invalidates the PIN backend-side and notifies the joiner; a rejected
invite surfaces its reason (e.g. already-clustered). Pairing uses a low-entropy
convenience PIN; UI copy must not present it as a strong authenticator. See the
repository SECURITY.md.
Engine lifecycle and model operations flow through the broker's engine:*
relay to nvpair-engine-manager. The renderer identifies engines with the
closed EngineType union and narrows external strings with isEngineType().
The Ollama and LM Studio proxies are cluster-aware. For model-bearing inference,
each proxy first keeps only nodes whose per-engine discovery inventory advertises
the requested model. Empty and non-matching inventories are excluded; an empty
owner set returns a local 502. Routing precedence within the eligible set is:
- a user-selected manual node;
- the priority list emitted by
nvpair-job-scheduler; - the proxy's deterministic default ordering.
The scheduler combines total pending (queued and running) workload across both
engines with a smoothed 0–3 pressure derived from the busiest GPU. Missing,
invalid, or older-than-10-second telemetry has neutral pressure. It emits the
order, pending count, and pressure, reranking on meaningful workload, discovery,
or pressure changes with a periodic reconciliation fallback. Each proxy chooses
with pending + gpuPressure + localReservations, so concurrent bursts spread
across eligible owners before workload feedback arrives. An upstream 404
remains retryable among advertised owners because an inventory snapshot can
become stale. Electron's richer renderer metrics are display-only; Electron does
not select or pin proxy routes.
An NVPAIR-launched engine binds to loopback and is never directly LAN-reachable.
Peers reach it only through the node's proxy over a cluster-mTLS ingress, so
discovery advertises the promoted proxy port for ol/lm rather than the
engine's private port. This transport security is backend-owned; Electron only
reflects the advertised proxy port and reads a remote engine's real port from
engine:remote-get-installed facts.
The model hub is Electron-main functionality in src/electron/model-hub/:
- Ollama models come from a locked, committed list
(
src/electron/model-hub/ollama-models.json) bundled into the main process — there is no runtime Ollama scraping. Devs regenerate the list withnpm run scrape:ollama-models(scripts/scrape-ollama-models.ts) and commit it when Ollama's catalog changes; - LM Studio models come from the curated
lmstudio-communitycatalog, still fetched live from Hugging Face and cached for six hours. The cache is warmed when the Overview renderer reports ready, not when the service connects, so a slow or hanging catalog fetch cannot compete with the window's first paint; - model pulls still run through
nvpair-engine-manager.
The Inference Demo sends a fixed sixty-second burst of synthetic inference traffic through the local proxies so job activity is visible on Overview. It is the one place Electron launches a non-broker executable.
- The schedule is built and owned by Electron main
(
src/electron/inference-demo.tsandsrc/electron/inference-demo-schedule.ts). - Each scheduled request spawns the bundled
inference-dispatcherclient, a standalone Go HTTP client that knows nothing about the broker, JSON-RPC, or discovery. Its source isscripts/inference-dispatcherat the monorepo root and it ships inresources/tools, outside the servicescli-bininventory. See Inference dispatcher. - Requests are addressed to a proxy port reported by the broker, never to an engine's own port, so the backend places them exactly as it would place any third-party client's traffic. PAIR makes no routing decision.
- Progress is broadcast on the
demo:stateElectron push channel (IpcPushChannelMap), not the service push bus. State is node-local and is never synchronized across the cluster. - Prompts and responses are never surfaced or logged; child processes run with
stdio: 'ignore'and with theINFERENCE_DISPATCHER_*environment namespace stripped so an inherited variable cannot redirect output to disk.
Backend-owned ports are reported at runtime where available. Constants that
cannot yet be reported are centralized in
src/shared/constants/modular-runtime.ts.
- Ollama-compatible clients use the proxy port reported by the broker.
- Cluster pairing currently uses port
14321. - Node telemetry is read from
/v1/node-infoat each discovered node's advertised port.
Inference prompts, messages, chunks, and response bodies must not be logged.
npm run build:modular-binaries compiles the sibling services/ tree for the
selected platform and architecture. The cli-bin/manifest.json it writes records
the source path and fingerprint, the product and component versions, the target,
and per-file hashes. The build rejects unexpected files in cli-bin/.
Electron Builder produces:
- Windows NSIS installers;
- Linux
.debpackages; - macOS
.dmginstallers and.zipupdate payloads.
npm run build:tools compiles the inference-dispatcher client into tools/,
which is packaged as a separate extraResources directory with its own manifest
and packaging assertion.
The macOS build also compiles the SMAppService privileged helper used to
configure Application Firewall rules. Firewall membership comes from
needsFirewallAccess in MODULAR_RUNTIME_BINARIES, and
npm run service-contracts:check verifies the Swift helper matches it. See
macOS Privileged Helper.
Personal AI Router and the Go services share one current per-user root:
Nvidia Corporation/Personal AI Router.
On first launch after the rename, Electron data under
NVIDIA Corporation/PAIR is merged into the shared directory without
overwriting backend files already present there.
Uninstalling keeps user data by default or requires an explicit removal action. The Electron updater cache is separate from the shared root.
PAIR exposes a canonical, append-only wipe inventory in the repo-root scripts
scripts/wipe-app-data.sh (Linux and macOS) and scripts/wipe-app-data.ps1
(Windows).
They list every PAIR-owned path (current and historical layouts) and are never
shrunk — when storage moves, both old and new locations stay listed so upgrades
from any prior version remain recoverable. No Node runtime is required.
| Path | Behavior |
|---|---|
| Settings → Service → Reset app data | Confirms stop → wipe; packaged also relaunches into first-run; unpackaged quits and prompts to restart |
scripts/wipe-app-data.sh / scripts/wipe-app-data.cmd |
Clean-only manual entrypoints; require confirmation; do not relaunch |
Packaged builds ship the same scripts under resources/scripts/ so the app invokes
the identical inventory. Platform uninstall scripts should stay aligned with these
files (see comments in scripts/wipe-app-data.* and
desktop/scripts/build/installer.nsh).
The in-app path never deletes while Electron is alive, otherwise Chromium flushes
session and cache files back into the directories just removed. Instead Electron
stops the service tree, spawns the script detached with --wait-pid=<electron pid>,
and exits. The script waits for the process to disappear, then wipes. Packaged
builds also pass --relaunch=<app executable> so the script starts the app after
deletes finish. Unpackaged (electron-vite dev) builds omit relaunch and the UI
tells the developer to run npm start again — quitting Electron tears down the Vite
renderer server, so auto-relaunch would come up with nothing to load.
Third-party model libraries (~/.ollama, ~/.lmstudio) and the application install
tree are never removed.
- UI invoke/push contract:
src/shared/types/ws-channels.ts - Electron IPC contract:
src/shared/types/ipc-channels.ts - Runtime binary inventory:
src/shared/constants/modular-binaries.ts - Runtime defaults:
src/shared/constants/modular-runtime.ts - Backend API extraction:
docs/services-api.md - Services backend integration:
docs/services-backend.md - Current capability status:
docs/services-parity.md