This document records the current integration status between Personal AI Router
and the services/ backend. It is a present-state reference, not a release
history.
- Backend versions:
services/versions.json(rendered indocs/services-api.md) - Method-level contract:
docs/services-api.md - Exceptions:
docs/service-contract-exceptions.json - Integration runbook:
docs/services-backend.md
| Domain | Status | Current state |
|---|---|---|
| Process supervision | Complete | Electron starts only nvpair-ui-broker; the broker supervises all workers |
| Discovery | Complete | Broker discovery snapshots drive available nodes and node state |
| Node telemetry | Integrated with direct poll | Electron polls advertised /v1/node-info (plain HTTP); remote OS and some remote telemetry are backend-limited |
| Manual nodes | Complete with local persistence | Broker owns probing and proxy registration; Electron persists entries for replay |
| Ollama routing | Complete | Broker relay and backend scheduler drive proxy routing |
| LM Studio routing | Complete | Parallel broker relay and scheduler path |
| Local engine lifecycle | Complete | Install, start, stop, uninstall, update, and port configuration |
| Remote engine lifecycle | Partial | Remote install, start, stop, status, and model pull are supported |
| Engine models | Partial | Core list, pull, load, unload, and supported delete actions are wired |
| Engine version metadata | Backend-limited | The backend status surface does not provide a complete version/install-owner/update contract |
| Errors | Complete | Broker-relayed registry with peer synchronization |
| Workloads | Complete after subscription | Local and peer workload events feed one Electron catalog |
| Cluster pairing | Complete | PIN pairing, identity, membership, leave, and removal |
| Cluster transport security | Backend-owned | Node-to-node transport security, including the proxies' cluster-mTLS inference ingress, is entirely backend; Personal AI Router implements none |
| Settings | Partial | Cluster identity settings are used; inert settings are not surfaced |
| Model catalog search | Electron-owned | Curated Ollama and LM Studio catalogs are fetched in Electron main |
nvpair-ui-broker is the sole Electron child. It owns:
ollama-proxy;lmstudio-proxy;nvpair-node-scanner;nvpair-node-info;nvpair-manual-nodes;nvpair-workload-manager;nvpair-cluster-manager;nvpair-node-settings;nvpair-engine-manager;nvpair-errors;nvpair-job-scheduler.
Worker crashes are handled by the broker. Electron reports a broker crash and allows the user to restart the service.
Personal AI Router consumes discovery:get-nodes and discovery:nodes-changed from the
broker. The bridge:
- keys every node by the backend's stable per-host UUID (
AvailableNode.hostUuid), the same identity workloads (originatedFrom/scheduledOn), errors (nodeId), proxy routing (Node.ID),/v1/node-info(hostUuid), and cluster membership (ClusterNode.nodeUuid) all use; the hostname is display only; - preserves the backend's canonical reachable address;
- reflects the discovery
trusted(locally pinned peer) andclustered(belongs to some cluster) flags, and usesclusteredto mark an already-clustered discovered node non-invitable.trustedis a display annotation the scanner re-derives whenever the cluster-manager reports a pin-set change, and again on its periodic sweep of each peer's reported membership; it is not the routing gate. Each proxy answers "do I hold a pin for this peer?" from its own mesh when it resolves candidates, so PAIR must never treattrustedas authoritative for whether work can reach a node; - merges per-engine proxy presence;
- carries flat and per-engine remote model inventories;
- emits node and discovery pushes to the renderer.
selfId is the cluster-manager's nodeUuid (from cluster:get-node-id), minted
at startup and resolved as soon as the broker is ready, so self is identified by
the same UUID key rather than a hostname guess.
The broker discovery surface does not include full dynamic telemetry, so
Electron polls /v1/node-info for CPU, memory, GPU, and VRAM values.
Two limitations follow from the current discovery and node-info contract:
- Remote telemetry. The poll is plain HTTP. A remote node that does not
answer
/v1/node-infoover plain HTTP contributes no live CPU/GPU/VRAM telemetry; Personal AI Router still shows its discovery-level data. This resolves when the backend exposes a plaintext metrics path for such nodes. - Remote OS. The contract does not report a remote node's operating system. The local node's OS is known from the running process; remote nodes fall back to a placeholder until the backend reports OS on discovery or node-info.
Manual nodes use the broker's node/add, node/remove, and nodes/list
surface. Electron persists user entries and replays them after broker startup so
they survive worker restarts.
Both text-engine proxies are broker-owned and cluster-aware:
ollama-proxyserves the Ollama-compatible surface;lmstudio-proxyserves the LM Studio/OpenAI-compatible surface.
Routing precedence is manual selection, scheduler priority, then deterministic proxy ordering. Personal AI Router leaves proxies in automatic mode.
nvpair-job-scheduler combines total queued and running workload across both
engines with a smoothed 0–3 GPU-pressure signal. The backend scanner and manual
node worker provide maximum-GPU utilization, while invalid, missing, or
older-than-10-second samples receive neutral pressure. The scheduler emits order,
pending count, and pressure; the broker forwards each schedule:priority
snapshot to the matching proxy through node/set-priority. Each proxy adds
local reservations, so its estimate is
pending + gpuPressure + localReservations during concurrent bursts.
Current limitation: pressure does not represent GPU capability or available VRAM, and a multi-GPU node is represented by its busiest device rather than engine-to-device affinity.
An NVPAIR-launched engine binds to loopback only; it is never directly
LAN-reachable. Each node's proxy exposes two personalities on one listener: a
loopback-only plaintext path for local clients, and a LAN ingress gated by
cluster mTLS that forwards trusted-peer requests to the loopback engine. Because
the engine port is private, discovery advertises the promoted proxy port for
ol/lm, and the peer's real engine port is knowable only from authoritative
engine:remote-get-installed facts.
Personal AI Router consequences (all reflection, no security implementation):
- The proxy
node/*presence port is the peer's promoted proxy port. Personal AI Router surfaces it asEngineStatusData.proxyPortand never labels it as the engine port; a remote engine's port comes only fromecfacts. - Because both peers must speak the mTLS channel, mixed-version clusters cannot run inference across the version boundary. Local use and the shared nearby-model list are unaffected.
nvpair-engine-manager is authoritative for installed, running, healthy, and
port state.
Personal AI Router supports local:
- install and uninstall;
- start and stop;
- managed update;
- engine and proxy port changes;
- desired-state restoration across app restarts;
- engine and model progress.
Before shutdown, Personal AI Router calls engine:prepare-shutdown. This stops managed engine
processes without changing the persisted desired state; the broker restores
enabled engines on the next launch. The broker also self-initiates
engine:prepare-shutdown before tearing down its workers and waits for each
worker to exit without force-killing the worker, so engines are not orphaned
during teardown. Stopping a managed engine itself sends one stop signal and
waits for it to exit with no timeout: SIGTERM to the process group on Unix
(never escalated to SIGKILL) and taskkill /T /F on Windows (its windowless
engines cannot receive a graceful close).
engine:stop records the OFF intent even when it returns an error. The backend
reclaims an orphaned managed engine left on its own port, and declines only a
genuinely foreign listener (with an actionable error). Personal AI Router treats the saved
desired state as authoritative and surfaces a stop error as guidance, not as a
sign the OFF choice was discarded.
For ease of use, Personal AI Router issues starts in two additional cases and
keeps no auto-start list of its own: every install sends engine:install with
start: true, so a successful install (or managed update) starts the engine;
and on the first app open Personal AI Router starts every already-installed local
engine once. Both paths rely on the backend recording desired-enabled as a side
effect of start, so nvpair-engine-manager remains the single owner of
desired-state persistence and restoration.
Personal AI Router synthesizes one centralized pending-action state while waiting for terminal backend notifications. It never treats optimistic state as authoritative.
Remote cluster support includes status, install, start, stop, model pull, and
remote model load, unload (eject), and delete via the ec surface. Uninstall,
update, and port changes remain local-only.
Local model state comes from engine-manager actions. Remote model state comes from node-scanner enrichment and is attributed per engine.
Personal AI Router uses:
list_models;pull_model;- Ollama
run_model,unload_model(keep_alive: 0), anddelete_model; - LM Studio
load_model,unload_model, anddelete_model(remove_path).
Both engines expose Load, Eject, and Delete in the model manager when the backend action exists. Keep-alive / expiry controls remain unsupported.
LM Studio's delete_model declares restart_after, so the engine manager
restarts a running LM Studio once the files are removed — its /v1/models is
served from an index built at startup and it exposes no rescan operation, so
clients would otherwise keep being offered a deleted model. Because that makes
Delete interrupt inference, EngineCapabilities['lm-studio'].restartsOnModelDelete
tells ModelManager.tsx to confirm the deletion first (ConfirmModal). The
restart is entirely backend-owned: PAIR sends the same deleteModel command as
for any other engine and never issues engine:restart itself, so the bundled
nvpair terminal UI and a remote peer's deletion get the same behavior.
This is LM Studio only. Ollama reflects a deletion immediately, so its
manifest omits restart_after and its capability entry omits
restartsOnModelDelete: no bounce, no confirmation, no interrupted inference.
Those two facts have to stay in step across a Go manifest and a TypeScript
constant, which nothing in either type system enforces — so
tests/modular/delete-model-restart.test.ts reads the shipped manifests and
asserts the pair agrees, and TestBundledManifestsRestartOnlyLMStudio guards the
same thing from the Go side.
Several model-action timeouts have to be ordered correctly. For a
restart-backed delete, the reply is withheld until the engine is ready again
(LM Studio's ready.timeout_s alone is 60s), so
MODULAR_MODEL_ACTION_TIMEOUT_MS anchors the delete-specific observers. Ollama
Load uses a separate 10/11-minute response-header budget:
| Budget | Value | Why |
|---|---|---|
MODULAR_MODEL_ACTION_TIMEOUT_MS (modular-runtime.ts) |
120s | The RPC must outlast a full stop + readiness-probed start. |
RESTART_DELETE_TIMEOUT_MS (pending-actions.store.ts) |
180s | The optimistic-spinner safety net must outlast the RPC, or it expires mid-flight and drops the spinner while the delete is still running. |
engineResponseHeaderTimeout (executor.go) |
30s | Downloads and ordinary local HTTP actions retain a prompt response-header bound; probe contexts stay shorter. |
ollamaLoadResponseHeaderTimeout (executor.go) |
10min | Only Ollama's local run_model action gets the cold-load allowance. |
OLLAMA_LOAD_PENDING_TIMEOUT_MS (pending-actions.store.ts) |
12min | Ollama's Load control stays locked beyond the remote path's 11-minute header budget, while backend success or failure still clears it immediately. |
PENDING_TIMEOUT_MS |
60s | Unchanged for every other command, including an Ollama delete. |
remoteReadyResponseHeaderTimeout (remoteclient.go) |
11min | Remote start/delete responses can wait on engine readiness, and a cold Ollama model load can withhold headers for minutes, so those calls use the readiness-sized client. Other model actions retain the ordinary 30s budget. |
The net is a backstop, not the mechanism: a delete now ends the spinner on real
backend truth either way. On success the refreshed list_models drops the model;
on failure the reported error carries nodeId + engineType + modelName, which
is what lets the store attribute it and clear that row.
Local Ollama Load also observes its eventual JSON-RPC rejection and reports the
same model context; the resulting attributed errors:update clears Loading…
without waiting for the 12-minute net.
A failed restart is reported as Deleted <model>, but <engine> failed to restart: …, never as a failed delete — the files really are gone, and telling
the user otherwise invites a retry that hits model … not found on disk. The
bounce itself raises no crash or unhealthy alert: markStopped cancels the
health loop and clears unhealthy/exited, and doStart clears them again, so
the engine simply shows as stopped and then running.
A local pull_model streams live download progress: the engine-manager routes
engine:action{pull_model} through its streaming pull path and emits
engine:pull-progress ({ engine, op, stage, percent, message }) — the local
counterpart of engine:remote-progress. Personal AI Router consumes it in
applyLocalEngineProgress (modular-supervisor.ts → modular-state.ts),
backfilling the dispatched model (the frame carries none) and advancing the
optimistic pull entry's percent in place, so a local pull shows "Pulling · N%"
to completion just like a remote pull. The awaited action response owns
completion (clearing the entry and refreshing the model list); a CLI-driven pull
(LM Studio) emits a single pulling marker and degrades to the indeterminate
spinner.
engine:models (and the em GET /v1/models surface) returns the flat model
union, the per-engine breakdown (modelsByEngine), and the per-engine set of
models loaded in memory (loadedByEngine). The engine-manager watches each
running engine's loaded set and pushes engine:models-changed
({ engine, models }, the full engine:models shape) on explicit load/unload,
LM Studio JIT auto-load, and TTL/idle eviction. Discovery carries
loadedByEngine onto each node's AvailableNode so remote cards reflect
residency too.
Personal AI Router consumes both: parseBrokerNode records loadedByEngine on every node
(local via loopback self-enrichment, remote via the peer's enrichment), and
applyLocalLoadedModels (modular-supervisor.ts → modular-state.ts) applies
the engine:models-changed snapshot to the local node immediately. Model rows
are stamped ModelItem.status: 'loaded' when a name is in that engine's loaded
set, which drives the loaded dot, disables Load for a resident model, and
gates Eject to loaded models only (ModelRow.tsx). The optimistic
load/eject pending-action clears on the resulting model patch instead of its
safety-net timeout (pending-actions.store.ts). Loaded state carries no
sizeVram/expiresAt — the backend delivers the simpler loadedByEngine
name-set, not structured details.
The model hub is intentionally outside the backend: Electron main fetches curated catalogs and sends selected pull-ready IDs to the engine manager.
nvpair-errors is broker-owned and is the authoritative error registry. Personal AI Router:
- fetches
errors:get-initial; - consumes full
errors:updatesnapshots; - forwards clear and report actions through the broker;
- uses engine operation metadata to clear matching pending actions;
- renders supported retry hints.
Peer error synchronization and authentication are backend-owned.
Personal AI Router subscribes to the broker workload stream and maintains an Electron-local catalog keyed by workload origin and ID.
scheduledOn identifies the execution node. Workloads that have not yet been
scheduled are not attributed to a node.
The broker backs workloads with a durable, order-independent store and answers
workloads:get-initial with an authoritative baseline (current plus
recently-terminal jobs), and the workload-manager backfills a joining node with
peers' in-flight work and periodically re-syncs. This makes cluster job counts
converge across restarts and network hiccups on the backend side. Personal AI Router's
workloads:get-initial handler fetches that broker baseline (falling back to its
own subscription-built catalog if the call fails), and the supervisor seeds the
catalog from it right after workloads:subscribe, so a freshly started or
restarted app immediately shows cluster-wide in-flight jobs. The renderer store
subscribes before fetching the baseline so a transition during init is never
dropped.
nvpair-cluster-manager owns:
- node crypto identity;
- interactive six-digit PIN pairing;
- trusted certificate pins;
- membership snapshots;
- cluster leave and member removal;
- trust endorsements, roster reconciliation, and durable admission-bound removal proofs.
Personal AI Router creates a cluster automatically when the first outbound invite requires one. A failed invite can abandon that unused solo cluster. The UI displays the inviter's PIN and requires the invitee to enter it.
The cluster-manager emits cluster:invite-received exactly once per inbound
invite (always pending) and exposes no list-pending-invites RPC. To give the
receiver a durable, recoverable set of pending invites, Electron main accumulates
them (ModularState), prunes them as they resolve — via membership
(nodes:changed), the local accept/decline result, the receiver-side
cluster:invite-canceled and cluster:invite-expired pushes (both consumed
directly so the PIN prompt dismisses at once when the inviter cancels or the
invite times out), and a per-invite cluster:invite-status sweep (pruning
terminal or evicted sessions). The authoritative set is served by
cluster:get-initial (pendingInvites) and broadcast on every change via
cluster:pending-invites-changed.
The cluster-manager expires unanswered invites after a TTL (default 5m) on
both sides: it expires its own outbound invite and, on the receiver,
expires the inbound invite and signals the inviter for immediate teardown. It
emits cluster:invite-expired on the receiver — which Personal AI Router
consumes to prune the inbound invite — and on the inviter, alongside
cluster:invite-declined. Personal AI Router reconciles the outbound side
through the cluster:invite-status poll in useInvitePairing and dissolves any
solo cluster it auto-created only to back the invite via
cluster:abandon-if-solo, so cluster:invite-declined stays an inviter-side
latency optimization it does not consume (recorded in
docs/service-contract-exceptions.json). Because the backend owns receiver-side
expiry, Personal AI Router keeps no client-side inbound TTL; the
cluster:invite-status sweep is the sole backstop for a missed push.
Personal AI Router treats the canceled and rejected invite states as terminal non-paired
outcomes (coerced by parseInvite, which also carries the backend reason). The
outbound Cancel action calls the cluster:cancel-invite RPC, so canceling an
in-flight invite invalidates the PIN backend-side and best-effort notifies the
joiner to drop its prompt (the receiver reflects this via the already-consumed
cluster:invite-canceled push). A rejected result surfaces its reason
distinctly — already-clustered renders as "That node is already in a cluster" —
and Personal AI Router pre-empts the invite entirely by disabling an already-clustered
discovered node.
A wrong PIN is a terminal, non-retryable failure: the invitation ends on both
sides and the user must request a fresh one. The cluster-manager mirrors the
failure — the joiner fails the completion with reason: 'incorrect-pin', clears
its pending-inbound member (nodes:changed), and best-effort notifies the
inviter over the pairing channel (phase: "fail"), which flips its outbound
invite to failed (same reason), tears down the EAP session (invalidating the
PIN), and pushes cluster:invite-failed. Personal AI Router consumes this end to end: the
inviter surfaces the "Incorrect PIN" error from the cluster:invite-status poll
returning failed with reason: 'incorrect-pin' (rendered by
InvitePairingPanel), and cluster:invite-failed is consumed
(modular-supervisor.ts) to prune any matching inbound invite. The joiner's
pending-inbound row clears via nodes:changed, and both surfaces normalize
-32001 / "invite session evicted" to friendly copy (cluster-invite-error.ts)
as a backstop. Other failed causes (peer unreachable, malformed) carry an empty
reason, so Personal AI Router falls back to generic "Pairing failed" copy.
The PIN is a convenience pairing code and should not be described as a strong authenticator.
Cluster identity, pairing, trust, membership, and all node-to-node transport security are owned entirely by the backend. Personal AI Router implements none of it: it does not read private keys, build TLS contexts, or authenticate peers. This includes cross-node inference: the proxies terminate the cluster-mTLS ingress and gate it on cluster pins, and NVPAIR-launched engines bind to loopback so they are reachable off-box only through that ingress.
The one Personal AI Router-facing constraint is that trust begins with a low-entropy human PIN, so UI copy must not present the PIN as a strong authenticator.
Personal AI Router uses node settings for persisted cluster identity and friendly name synchronization.
Backend settings with no active behavior are intentionally not exposed in the UI. A setting should be surfaced only when a backend component consumes it and its effect is observable.
The following responsibilities remain in Personal AI Router because the backend does not provide an equivalent client-facing contract:
| Responsibility | Location |
|---|---|
Poll node telemetry over /v1/node-info |
node-info-poller.ts |
| Persist and replay manual node entries | manual-nodes-store.ts, modular-supervisor.ts |
| Bridge the local node into engine proxies | modular-supervisor.ts |
| Present optimistic engine transition state | pending-actions.store.ts, bridge state |
| Serve the model hub (Ollama committed list, LM Studio live) | src/electron/model-hub/ |
| Accumulate and reconcile receiver-side pending invites | modular-state.ts, modular-supervisor.ts |
| Mirror backend-coupled runtime defaults not yet reported | modular-runtime.ts |
| Collapse a superseded node row before the scanner proves it | modular-state.ts, modular-runtime.ts |
These are current integration boundaries. Do not add compatibility shims or duplicate backend services around them.
The last row is a display-side collapse, not a directory decision. The scanner
treats a matching address, hostname, and lastSeen gap as grounds to ask the
machine who it is, and evicts only on a node-info identity mismatch; the bridge
has no such proof and hides the row on the suspicion alone. It must never be
extended into anything a consumer reads as an eviction.
The generated docs/services-api.md is authoritative for methods handled by
the backend but not called by Personal AI Router. Typical optional opportunities include:
- engine manifest description;
- engine log and error-ring views;
- scheduler status and interval controls;
- proxy selected-node observability;
- explicit unsubscribe calls for process-lifetime subscriptions.
Unused methods are not automatically product gaps. Adopt them only when they serve a current Personal AI Router feature.
On every backend update:
- run
npm run service-contracts; - resolve missing notifications or document why they are internal;
- verify meaningful payload fields are consumed;
- update this present-state status;
- run
npm run service-contracts:write; - run
npm run service-contracts:checkandnpm run typecheck.
Do not append release diaries or prior-state narratives. Git history is the record of how this integration changed.