openapi: 3.1.0 info: title: Vitals local API version: 0.1.0 summary: The LAN HTTP API served by a running Vitals desktop. description: | Vitals is a Windows system monitor. When the user turns on **Remote access** in Settings, the desktop app starts a plain-HTTP server on the LAN (default port `7331`, bound on `0.0.0.0`) so a phone, a Prometheus scraper or a shell script can read the same numbers the desktop shows. ## Off by default, LAN only Nothing binds a socket until the user opts in. There is no TLS: a self-signed certificate would make every phone show a warning and break the install-to-home-screen flow, buying a dialog instead of security. The threat model is a home or office network, and the server exists to stop a device that never scanned the QR code from reading your process list — not to survive an attacker who can already read your traffic. ## The local API: the same routes on `127.0.0.1:7330`, no token needed Independently of Remote access, a running desktop serves these routes on the loopback interface only, so the `vitals` CLI and local scripts can attach without pairing. A caller connecting from `127.0.0.1` is granted `read` scope with no token — never `control`, because loopback is shared by every account on the machine. The listener is bound to loopback, is never advertised over mDNS, and the grant is decided on the connecting address — a request over the LAN still needs a token. If port 7330 is taken the OS picks another; the app writes `%LOCALAPPDATA%\Vitals\local-api.json` as `{"port":N,"pid":P,"version":"x.y.z"}` on start and removes it on exit. See `README.md` in this directory. ## Authentication: a bearer token from QR pairing Every route except `GET /api/v1/health`, `POST /api/v1/pair` (and the static mobile web app) requires a token. The desktop mints one per pairing — 32 bytes from the OS CSPRNG, base64url without padding, so always 43 characters — and shows it once as a QR code. The QR encodes `http://
:/mobile.html#t=`; the token lives in the URL **fragment**, which browsers never send to a server, so it cannot land in a request line or an access log. A device without a camera — a TV — pairs with a **six-digit code** instead: the desktop shows one for five minutes and the device exchanges it at `POST /api/v1/pair` for an ordinary token, found by browsing `_vitals._tcp.local.` over mDNS. Present it either way: - `Authorization: Bearer ` — preferred. - `?token=` — for `EventSource` and `WebSocket`, which cannot set headers in a browser. A query string can end up in a log; the server writes no access log, and the alternative is that the phone cannot stream at all. ## Scopes: read versus control A token has one of two scopes. `read` sees everything and is the default for a new pairing. `control` can also end, suspend, resume and re-prioritise processes; it is never the default, because a phone left on a desk with a control token is a remote kill switch for the machine. A read token calling `POST /api/v1/control` gets `403` with `{"kind":"forbidden"}`. ## A wrong token and a missing token return identical 401s — this is deliberate Both cases return the same status, the same `WWW-Authenticate` header and the same body. Telling a caller "that token exists but is wrong" is a free oracle. Tokens are compared in constant time against every token in the set, so timing cannot reveal list position either. To tell "wrong address" from "wrong token", call `/api/v1/health` first: it needs no token. ## A field that is null was not measured. It is never zero-when-unknown. Every optional number in this document is `null` when the machine cannot report it: a GPU whose driver hides VRAM has `memoryUsed: null`, not `0`. An unmeasured reading and a genuinely-zero reading are different facts, and the desktop renders the first as an em dash. Clients must preserve the distinction. The Prometheus endpoint follows the same rule by **omitting** the series rather than emitting zero. ## Static files Any path not listed below falls through to the bundled mobile web app (`/` serves `/mobile.html`). Those files are unauthenticated on purpose: anyone with the installer already has them, and the page cannot show anything until it has a token. A path containing `..` returns `400`; a server built without the web app returns `404` for everything unlisted. license: name: MIT identifier: MIT servers: - url: http://{address}:{port} description: The machine running Vitals, on the local network. variables: address: default: 192.168.1.20 description: The LAN IPv4 address shown in Settings → Remote access. port: default: '7331' description: Default `7331`; configurable in Settings. tags: - name: health description: The one unauthenticated read. - name: pairing description: Getting a token without scanning a QR code. - name: metrics description: Reading the machine. - name: control description: Acting on processes. Needs the `control` scope. security: - bearer: [] - queryToken: [] paths: /api/v1/health: get: tags: [health] summary: Is something listening, and which frame shape does it speak? description: | Deliberately outside the auth layer so a client can tell "wrong address" from "wrong token". Exposes only the version and the fact that a server is there. operationId: health security: [] responses: '200': description: A Vitals server is listening. content: application/json: schema: $ref: '#/components/schemas/Health' /api/v1/pair: post: tags: [pairing] summary: Exchange a six-digit pairing code for a bearer token. description: | For a TV or any device that cannot scan the QR code. The user presses **Pair a TV** in Settings → Remote access and the desktop shows six digits for five minutes; the device finds the PC over mDNS and sends them here. The answer is an ordinary token, identical in every way to one from a QR pairing, with the scope chosen when the code was shown. Outside the auth layer, because a caller without a token is exactly who needs it. The code carries its own limits instead: - at most **one** code is active per server — showing a new one replaces the old; - **five** wrong codes in total, from any caller, burn it; the right code is refused afterwards and the user must show a new one; - it expires after **five minutes** and works **once**; - codes are uniform over `000000`–`999999` from the OS CSPRNG and compared in constant time. **Every refusal is byte-identical** — no code active, expired, wrong or burnt all answer the same `403` body. Telling them apart would tell a guesser how its budget stands. A body that is not a well-formed request is `400` and does **not** spend an attempt, so a client bug cannot burn the user's code. operationId: pair security: [] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PairRequest' example: { code: '482913', label: Living room TV } responses: '200': description: Paired. The token is shown here once and never again. content: application/json: schema: $ref: '#/components/schemas/PairReply' '400': description: Not valid JSON, no `code`, or a `code` that is not exactly six ASCII digits. No attempt was spent. content: application/json: schema: $ref: '#/components/schemas/ApiError' example: { error: code must be exactly six digits } '403': description: | The code was not accepted. Identical whether none is active, it expired, it is wrong, or it was burnt by wrong attempts. content: application/json: schema: $ref: '#/components/schemas/ApiError' example: { error: invalid pairing code } '404': description: This server does not offer pairing codes (a build or host without the feature). content: application/json: schema: $ref: '#/components/schemas/ApiError' /api/v1/snapshot: get: tags: [metrics] summary: The most recent keyframe. description: | The last **keyframe**, not the last frame: a one-shot caller asking "what is running" must get the whole process list, not the handful that happened to change since the previous tick. The `payload.kind` is therefore always `keyframe` here. operationId: snapshot responses: '200': description: The newest keyframe. content: application/json: schema: $ref: '#/components/schemas/Frame' '204': description: | Nothing has been sampled yet. An empty status rather than an empty object, so a client can tell "not sampling yet" from "sampling, and everything is zero". '401': $ref: '#/components/responses/Unauthorised' /api/v1/summary: get: tags: [metrics] summary: The machine now and its busiest processes, in about 3 KB. description: | For clients that cannot afford a keyframe (about 250 KB): a watch, a home-screen widget, a tile. `system` is complete; `top` is the busiest processes by CPU, ties broken by private memory so an idle machine does not shuffle. `processCount` counts every process. Polling this, or `/sensors`, keeps the desktop sampling at no less than 1 Hz for 45 seconds, even with its window hidden. operationId: summary parameters: - name: top in: query required: false description: How many processes. Default 5; values above 25 are clamped to 25. schema: type: integer minimum: 0 responses: '200': description: The summary. content: application/json: schema: $ref: '#/components/schemas/Summary' '204': description: Nothing has been sampled yet. '401': $ref: '#/components/responses/Unauthorised' /api/v1/history: get: tags: [metrics] summary: Machine-wide history from the desktop's store. description: | One row per point at whatever resolution the store keeps for that span: seconds for the last hour, then five-minute and hourly rollups. An empty array means history recording is off, or the host keeps none (`vitals serve`). operationId: history parameters: - name: seconds in: query required: false description: Span to cover, ending now. Default 3600; clamped to 60–604800. schema: type: integer minimum: 0 responses: '200': description: Oldest first. content: application/json: schema: type: array items: $ref: '#/components/schemas/MachineSample' '401': $ref: '#/components/responses/Unauthorised' /api/v1/sensors: get: tags: [metrics] summary: Every sensor reading the host can make now. description: | Thermal zones, CPU package and cores, fans, GPU, drives, batteries — the same list the desktop's Devices screen shows. Cached by the host for five seconds. An empty array means nothing is measurable, never that everything reads zero. operationId: sensors responses: '200': description: The readings. content: application/json: schema: type: array items: $ref: '#/components/schemas/SensorLine' '401': $ref: '#/components/responses/Unauthorised' /api/v1/host: get: tags: [metrics] summary: Static facts about the machine. operationId: host responses: '200': description: Hostname, OS, CPU model, core topology and so on. content: application/json: schema: $ref: '#/components/schemas/HostInfo' '204': description: The host has not been probed yet. '401': $ref: '#/components/responses/Unauthorised' /api/v1/alerts: get: tags: [metrics] summary: What is currently wrong, in one sentence each. description: | The alert engine's active list, most serious first. Alerts are **sustained** (a condition must hold for fifteen seconds before it is raised) and **sticky** (it clears only after ten seconds of being false), so a poller at any interval sees a stable list rather than a reading flickering at a threshold. `title` and `cause` are i18n keys, not prose — render them in the user's language. `values` carries the numbers, already rounded. An empty array is the healthy answer, not `204`: "nothing wrong" is a value a client shows as a green tick. operationId: alerts responses: '200': description: The active list. `[]` when nothing is wrong. content: application/json: schema: type: array items: $ref: '#/components/schemas/Alert' examples: healthy: value: [] diskFailing: value: - kind: diskHealth severity: critical subject: 'C:' title: alert.diskHealth.title cause: alert.diskHealth.cause values: { disk: 'C:' } route: storage sinceSample: 1842 '401': $ref: '#/components/responses/Unauthorised' /api/v1/stream: get: tags: [metrics] summary: Frames as server-sent events. description: | A `text/event-stream` in which every event's `data:` line is one `Frame` as JSON. The first event is the current keyframe (so a client connecting between ticks renders immediately); every subsequent event is whatever the sampler produced, usually a `delta`. A keep-alive comment is sent every 15 seconds. A client that falls behind has frames dropped for it rather than queued — live metrics want the current numbers, not a backlog. `X-Accel-Buffering: no` is set so a reverse proxy does not buffer the stream. Irrelevant on a LAN, harmless to set. Browsers cannot set headers on an `EventSource`, so pass the token as `?token=`. operationId: stream responses: '200': description: An open event stream. Each event's data is a `Frame`. headers: X-Accel-Buffering: schema: type: string const: 'no' description: Tells proxies not to buffer the stream. content: text/event-stream: schema: $ref: '#/components/schemas/Frame' example: | data: {"seq":42,"timestampMs":1757500000000,"elapsedMs":1003,"payload":{"kind":"delta","system":{...},"changed":[...],"exited":[1234]}} '401': $ref: '#/components/responses/Unauthorised' /api/v1/ws: get: tags: [metrics, control] summary: WebSocket — frames out, control commands in. description: | OpenAPI cannot describe a WebSocket, so this entry documents the upgrade and the message protocol in prose. **Upgrade.** A standard `GET` with `Upgrade: websocket`. The token goes in `Authorization: Bearer …` or, from a browser, in `?token=`. Auth is checked before the upgrade, so a bad token gets `401`, never a socket that immediately closes. **Server → client.** Text messages, each one `Frame` as JSON — the current keyframe first, then every frame the sampler produces. Frames a slow client misses are skipped, not queued. **Client → server.** Text messages, each one `ControlRequest` as JSON. The server replies with exactly one text message per request: a `ControlReply` — `{"ok":true}` on success, or `{"ok":false, "error":{…}}` where `error` is a `ControlError`. A message that is not a valid `ControlRequest` gets `{"ok":false,"error":{"kind": "unsupported","message":"could not read the request: …"}}`; a request from a `read`-scope token gets `{"kind":"forbidden"}`. Binary and ping frames are ignored. Unlike the HTTP control route, the socket does not map errors to status codes — the `kind` field is the only signal. operationId: websocket responses: '101': description: Switching protocols. The message protocol above applies from here. '400': description: Not a valid WebSocket handshake (missing or malformed `Connection`, `Upgrade`, `Sec-WebSocket-Key` or `Sec-WebSocket-Version`). '401': $ref: '#/components/responses/Unauthorised' '426': description: The connection cannot be upgraded (HTTP/2 or later). /api/v1/control: post: tags: [control] summary: Act on a process over plain HTTP. description: | The same control surface as the WebSocket, for clients that do not want a socket — a shell script with `curl`, Home Assistant. Requires the `control` scope. Every action names a `ProcessKey`, never a bare PID: a client that took a second to tap "End" must not kill whatever recycled the PID in the meantime. Take the key from a `Process` in a frame; do not construct one. The scope check runs before the body is parsed, so a malformed request from a read-only token is answered `403`, never with a body error. operationId: control requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ControlRequest' examples: terminate: value: { action: terminate, key: { pid: 4242, startTime: 133724800000000000 } } setPriority: value: action: set-priority key: { pid: 4242, startTime: 133724800000000000 } priority: below-normal setEfficiencyMode: value: action: set-efficiency-mode key: { pid: 4242, startTime: 133724800000000000 } enabled: true responses: '204': description: Applied. '401': $ref: '#/components/responses/Unauthorised' '403': description: | Refused. Either the token is `read`-only (`kind: forbidden`) or the operating system refused the operation (`kind: access-denied`) — the process may be protected, or elevation might help. The scope check runs **before** the body is parsed, so a read-only token gets this answer even when the request is malformed. The other order let a caller probe the schema by watching 422 turn into 403. content: application/json: schema: $ref: '#/components/schemas/ControlError' example: { kind: forbidden } '404': description: The process is gone, or its start time no longer matches (the PID was recycled). content: application/json: schema: $ref: '#/components/schemas/ControlError' example: { kind: not-found } '422': description: | The body is not a `ControlRequest` — invalid JSON, an unknown `action`, or a missing `key`. Only reachable with a token that already has the `control` scope. content: application/json: schema: $ref: '#/components/schemas/ControlError' example: { kind: unsupported, message: 'could not read the request: …' } '500': description: The host failed for a reason it can only describe in prose. content: application/json: schema: $ref: '#/components/schemas/ControlError' example: { kind: internal, message: '…' } '501': description: This host has no way to do this — no controller, a priority it does not know, or a Windows build without efficiency mode. content: application/json: schema: $ref: '#/components/schemas/ControlError' example: { kind: unsupported, message: 'this host cannot act on processes' } /metrics: get: tags: [metrics] summary: Prometheus text exposition. description: | Prometheus exposition format 0.0.4. System-wide gauges come from the newest frame; the per-process gauges rank the last keyframe and keep the top 20 by CPU, then private memory — one series per process on a 600-process machine would be more than a small Prometheus wants. A metric that cannot be measured is **omitted**, never zero: a GPU with no engine counters has no `vitals_gpu_percent` series, and a desktop with no battery has no `vitals_battery_percent`. Use `absent()` for absence. The full metric list is in `docs/api/README.md`. Prometheus sends `Authorization: Bearer` from `bearer_token` / `bearer_token_file` in the scrape config. operationId: metrics responses: '200': description: The exposition body. content: text/plain; version=0.0.4; charset=utf-8: schema: type: string example: | # HELP vitals_cpu_percent Total CPU utilisation across all cores. # TYPE vitals_cpu_percent gauge vitals_cpu_percent 12.5 # HELP vitals_memory_bytes Physical memory, by state. # TYPE vitals_memory_bytes gauge vitals_memory_bytes{state="total"} 34359738368 vitals_memory_bytes{state="used"} 17179869184 '204': description: Nothing has been sampled yet. '401': $ref: '#/components/responses/Unauthorised' components: securitySchemes: bearer: type: http scheme: bearer description: | `Authorization: Bearer `. The token is 43 characters of base64url (`A–Z a–z 0–9 - _`, no padding), shown once at pairing. queryToken: type: apiKey in: query name: token description: | The same token as a `?token=` query parameter, for `EventSource` and `WebSocket` clients that cannot set headers. Prefer the header wherever you can. responses: Unauthorised: description: | No valid token. **Identical whether the token is missing or wrong** — distinguishing them would tell a guesser it had found a real token. Call `/api/v1/health` to confirm the address is right. headers: WWW-Authenticate: schema: type: string const: Bearer realm="vitals" content: application/json: schema: $ref: '#/components/schemas/ApiError' example: { error: a bearer token is required } schemas: # ── Envelopes ────────────────────────────────────────────────────── Health: type: object required: [ok, version, modelVersion] properties: ok: type: boolean const: true version: type: string description: The Vitals release, e.g. `0.1.0`. modelVersion: type: integer minimum: 0 description: | The frame-shape version the server speaks, so a client can refuse to parse a `Frame` shape it does not know rather than rendering garbage. ApiError: type: object required: [error] properties: error: type: string PairRequest: type: object required: [code] properties: code: type: string pattern: '^[0-9]{6}$' description: The six digits on the desktop, leading zeros kept, no spaces. label: type: string description: | What the device is called in the paired-devices list. Trimmed and cut to 64 characters; missing or blank becomes `TV`. PairReply: type: object required: [token, scope] properties: token: type: string pattern: '^[A-Za-z0-9_-]{43}$' description: A bearer token, exactly like one from a QR pairing. Store it; it is not shown again. scope: type: string enum: [read, control] Frame: type: object description: | One sampling tick, as delivered to the desktop UI and to every remote client. The `payload.kind` discriminator says whether this is a full `keyframe` or a `delta` against the previous frame. required: [seq, timestampMs, elapsedMs, payload] properties: seq: type: integer minimum: 0 description: | Monotonically increasing frame counter. Use gaps in this sequence to detect dropped frames rather than guessing from timestamps, which are subject to clock adjustment. timestampMs: type: integer description: Milliseconds since the Unix epoch, for display and correlation only. elapsedMs: type: integer description: | Actual elapsed time since the previous frame. Rate calculations must divide by this, not by the nominal interval: under load the sampler runs late, and assuming a perfect 1000 ms is how monitors report throughput spikes that never happened. payload: $ref: '#/components/schemas/FramePayload' FramePayload: description: | What a frame carries. Most frames are deltas: sending 2000 full process records at 1 Hz is ~2 MB/s of serialisation for data that barely changes. A periodic keyframe bounds the damage if one is lost. `system` is complete in **both** kinds. oneOf: - $ref: '#/components/schemas/Keyframe' - $ref: '#/components/schemas/Delta' discriminator: propertyName: kind mapping: keyframe: '#/components/schemas/Keyframe' delta: '#/components/schemas/Delta' Keyframe: type: object required: [kind, system, processes] properties: kind: type: string const: keyframe system: $ref: '#/components/schemas/SystemMetrics' processes: type: array description: Every process on the machine. items: $ref: '#/components/schemas/Process' Delta: type: object required: [kind, system, changed, exited] properties: kind: type: string const: delta system: $ref: '#/components/schemas/SystemMetrics' changed: type: array description: Processes that appeared or whose values changed. items: $ref: '#/components/schemas/Process' exited: type: array description: Processes that exited, by PID. Apply exits before changes so a recycled PID survives. items: $ref: '#/components/schemas/Pid' # ── Units ────────────────────────────────────────────────────────── # Newtypes in Rust, plain numbers on the wire. Named so a reader can see # the unit without reading every description. Percent: type: number description: 0–100. Bytes: type: integer minimum: 0 BytesPerSec: type: integer minimum: 0 description: Bytes per second, already divided by the frame's real `elapsedMs`. Hertz: type: integer minimum: 0 Celsius: type: number Watts: type: number Pid: type: integer minimum: 0 description: A process identifier. Unsafe to act on alone — see `ProcessKey`. ThrottleReason: type: string enum: [thermal, powerLimit, currentLimit, voltageDrop, powerPolicy, unknown] # ── System ───────────────────────────────────────────────────────── SystemMetrics: type: object description: Everything sampled about the machine in one tick. required: [cpu, memory, disks, networks, gpus, powerDraw, battery] properties: cpu: $ref: '#/components/schemas/CpuMetrics' memory: $ref: '#/components/schemas/MemoryMetrics' disks: type: array items: $ref: '#/components/schemas/DiskMetrics' networks: type: array items: $ref: '#/components/schemas/NetworkMetrics' gpus: type: array items: $ref: '#/components/schemas/GpuMetrics' powerDraw: description: Total system power draw where measurable (laptops, some desktops). oneOf: - $ref: '#/components/schemas/Watts' - type: 'null' battery: oneOf: - $ref: '#/components/schemas/BatteryMetrics' - type: 'null' CpuMetrics: type: object required: - total - perCore - kernel - effectiveClock - maxClock - temperature - power - throttled - processCount - threadCount - handleCount - uptimeSecs - contextSwitches - interrupts properties: total: $ref: '#/components/schemas/Percent' perCore: type: array description: Per logical processor. items: $ref: '#/components/schemas/Percent' kernel: description: Kernel-mode share of `total`. $ref: '#/components/schemas/Percent' effectiveClock: description: | Current effective clock, averaged across active cores. "Effective" rather than requested: modern CPUs park and boost constantly, and the nominal base clock says nothing about what is happening now. oneOf: - $ref: '#/components/schemas/Hertz' - type: 'null' maxClock: oneOf: - $ref: '#/components/schemas/Hertz' - type: 'null' temperature: oneOf: - $ref: '#/components/schemas/Celsius' - type: 'null' power: oneOf: - $ref: '#/components/schemas/Watts' - type: 'null' throttled: description: Non-null when the CPU is held below its capability by heat or power limits — the headline signal for "why is my PC slow". oneOf: - $ref: '#/components/schemas/ThrottleReason' - type: 'null' processCount: type: integer minimum: 0 threadCount: type: integer minimum: 0 handleCount: type: [integer, 'null'] minimum: 0 uptimeSecs: type: integer minimum: 0 contextSwitches: type: [number, 'null'] description: Context switches per second. interrupts: type: [number, 'null'] description: Interrupts per second. MemoryMetrics: type: object required: - total - used - available - cached - pagedPool - nonPagedPool - committed - commitLimit - swapTotal - swapUsed - hardwareReserved - pageFaultsPerSec - speed - slotsUsed - slotsTotal - formFactor properties: total: $ref: '#/components/schemas/Bytes' used: description: In use by processes and the kernel — the number users care about. $ref: '#/components/schemas/Bytes' available: $ref: '#/components/schemas/Bytes' cached: description: Cached file data. Counted as available, because it is. $ref: '#/components/schemas/Bytes' pagedPool: description: Kernel memory that can be paged out. $ref: '#/components/schemas/Bytes' nonPagedPool: description: Kernel memory that cannot. $ref: '#/components/schemas/Bytes' committed: $ref: '#/components/schemas/Bytes' commitLimit: $ref: '#/components/schemas/Bytes' swapTotal: $ref: '#/components/schemas/Bytes' swapUsed: description: | Page file actually occupied. Null where the platform cannot report it cheaply — on Windows it needs WMI or a performance counter, both too slow for the per-tick path. Zero would be a lie: an idle page file and an unmeasured one are different facts. oneOf: - $ref: '#/components/schemas/Bytes' - type: 'null' hardwareReserved: description: Hardware-reserved, invisible to the OS. $ref: '#/components/schemas/Bytes' pageFaultsPerSec: type: [number, 'null'] description: | Page faults per second — the real indicator of memory pressure. A machine at 90% RAM with no faulting is fine; one at 60% that is thrashing is not. speed: oneOf: - $ref: '#/components/schemas/Hertz' - type: 'null' slotsUsed: type: [integer, 'null'] minimum: 0 slotsTotal: type: [integer, 'null'] minimum: 0 formFactor: type: [string, 'null'] DiskKind: type: string enum: [hdd, ssd, nvme, removable, network, optical, unknown] DiskHealth: type: object description: SMART overall health, when readable. required: [lifeRemaining, powerOnHours, totalWritten, reallocatedSectors, failing] properties: lifeRemaining: oneOf: - $ref: '#/components/schemas/Percent' - type: 'null' powerOnHours: type: [integer, 'null'] minimum: 0 totalWritten: oneOf: - $ref: '#/components/schemas/Bytes' - type: 'null' reallocatedSectors: type: [integer, 'null'] minimum: 0 failing: type: boolean DiskMetrics: type: object required: - id - name - model - mount - kind - total - free - read - write - activeTime - responseMs - queueDepth - temperature - health properties: id: type: integer minimum: 0 description: Stable within one server run; use it to correlate across frames. name: type: string model: type: [string, 'null'] mount: type: [string, 'null'] description: '`C:`, `/dev/nvme0n1`, etc.' kind: $ref: '#/components/schemas/DiskKind' total: $ref: '#/components/schemas/Bytes' free: $ref: '#/components/schemas/Bytes' read: $ref: '#/components/schemas/BytesPerSec' write: $ref: '#/components/schemas/BytesPerSec' activeTime: description: Share of time the disk had at least one IO outstanding. $ref: '#/components/schemas/Percent' responseMs: type: [number, 'null'] description: Average response time in milliseconds — correlates with a machine feeling slow far more than throughput does. queueDepth: type: [number, 'null'] temperature: oneOf: - $ref: '#/components/schemas/Celsius' - type: 'null' health: oneOf: - $ref: '#/components/schemas/DiskHealth' - type: 'null' NetworkKind: type: string enum: [ethernet, wiFi, cellular, bluetooth, loopback, virtual, vpn, unknown] NetworkMetrics: type: object required: - id - name - adapter - kind - rx - tx - rxTotal - txTotal - linkSpeed - ipv4 - ipv6 - mac - connected - signal - ssid - errorsPerSec properties: id: type: integer minimum: 0 name: type: string adapter: type: [string, 'null'] kind: $ref: '#/components/schemas/NetworkKind' rx: $ref: '#/components/schemas/BytesPerSec' tx: $ref: '#/components/schemas/BytesPerSec' rxTotal: $ref: '#/components/schemas/Bytes' txTotal: $ref: '#/components/schemas/Bytes' linkSpeed: type: [integer, 'null'] minimum: 0 description: Bits per second. ipv4: type: [string, 'null'] ipv6: type: [string, 'null'] mac: type: [string, 'null'] connected: type: boolean signal: description: Wi-Fi signal strength. oneOf: - $ref: '#/components/schemas/Percent' - type: 'null' ssid: type: [string, 'null'] errorsPerSec: type: [number, 'null'] description: Dropped packets per second — the first sign of a failing cable or saturated link. GpuVendor: type: string enum: [nvidia, amd, intel, apple, qualcomm, unknown] GpuEngine: type: object required: [name, utilization] properties: name: type: string description: '`3D`, `Copy`, `Video Decode`, `Compute`…' utilization: $ref: '#/components/schemas/Percent' GpuMetrics: type: object required: - id - name - vendor - engines - utilization - memoryUsed - memoryTotal - sharedMemoryUsed - coreClock - memoryClock - temperature - hotspotTemperature - power - powerLimit - fanPercent - fanRpm - throttled - driverVersion properties: id: type: integer minimum: 0 name: type: string vendor: $ref: '#/components/schemas/GpuVendor' engines: type: array description: | Per-engine utilisation. A single "GPU %" is close to meaningless: a machine can sit at 5% overall while the video-decode engine is pinned. items: $ref: '#/components/schemas/GpuEngine' utilization: description: | Highest utilisation across engines. Null when the adapter exposes no engine counters at all — a virtual display, a render-only device. Those are unknown, not idle; the previous non-optional type made them all read 0%. oneOf: - $ref: '#/components/schemas/Percent' - type: 'null' memoryUsed: oneOf: - $ref: '#/components/schemas/Bytes' - type: 'null' memoryTotal: oneOf: - $ref: '#/components/schemas/Bytes' - type: 'null' sharedMemoryUsed: oneOf: - $ref: '#/components/schemas/Bytes' - type: 'null' coreClock: oneOf: - $ref: '#/components/schemas/Hertz' - type: 'null' memoryClock: oneOf: - $ref: '#/components/schemas/Hertz' - type: 'null' temperature: oneOf: - $ref: '#/components/schemas/Celsius' - type: 'null' hotspotTemperature: oneOf: - $ref: '#/components/schemas/Celsius' - type: 'null' power: oneOf: - $ref: '#/components/schemas/Watts' - type: 'null' powerLimit: oneOf: - $ref: '#/components/schemas/Watts' - type: 'null' fanPercent: oneOf: - $ref: '#/components/schemas/Percent' - type: 'null' fanRpm: type: [integer, 'null'] minimum: 0 throttled: oneOf: - $ref: '#/components/schemas/ThrottleReason' - type: 'null' driverVersion: type: [string, 'null'] BatteryMetrics: type: object required: [charge, charging, timeRemainingSecs, power, health, cycleCount, temperature] properties: charge: $ref: '#/components/schemas/Percent' charging: type: boolean timeRemainingSecs: type: [integer, 'null'] minimum: 0 description: Estimated seconds remaining, when the OS will say. power: description: Current draw; negative while charging. oneOf: - $ref: '#/components/schemas/Watts' - type: 'null' health: description: Full-charge capacity as a share of design capacity — battery wear. oneOf: - $ref: '#/components/schemas/Percent' - type: 'null' cycleCount: type: [integer, 'null'] minimum: 0 temperature: oneOf: - $ref: '#/components/schemas/Celsius' - type: 'null' # ── Processes ────────────────────────────────────────────────────── ProcessKey: type: object description: | An unambiguous reference to a running process. **`startTime` is required because PIDs are reused.** Windows recycles them aggressively: a `TerminateProcess` against a PID captured a second ago can kill an unrelated process that just inherited the number. Pairing the PID with the creation timestamp closes that race, and every control action requires the pair, never a bare PID. Copy the key from a `Process`; never construct one. required: [pid, startTime] properties: pid: $ref: '#/components/schemas/Pid' startTime: type: integer description: | Process creation time, in 100 ns intervals since the platform epoch. Opaque: only ever compared for equality, never interpreted. ProcessKind: type: string enum: [app, background, service, system, containerized] ProcessState: type: string enum: [running, suspended, waiting, notResponding, zombie] IntegrityLevel: type: string enum: [untrusted, low, medium, high, system, protected] ProtectionLevel: type: string enum: [none, light, full] Process: type: object description: | A snapshot of one process at one instant. Deliberately flat: the process table holds thousands of these per frame, so anything that needs a heap walk per process (command line, full path, icon) is not here. required: - key - parent - name - kind - state - flags - integrity - protection - cpu - memoryPrivate - memoryWorkingSet - diskRead - diskWrite - netRx - netTx - gpu - gpuMemory - threadCount - handleCount - user - uptimeSecs properties: key: description: Race-free identity. Prefer this over `key.pid` for any action. $ref: '#/components/schemas/ProcessKey' parent: oneOf: - $ref: '#/components/schemas/Pid' - type: 'null' name: type: string description: Executable name only (`chrome.exe`), not the full path. kind: $ref: '#/components/schemas/ProcessKind' state: $ref: '#/components/schemas/ProcessState' flags: type: integer minimum: 0 description: A bitfield of process attributes. Opaque to remote clients. integrity: oneOf: - $ref: '#/components/schemas/IntegrityLevel' - type: 'null' protection: $ref: '#/components/schemas/ProtectionLevel' cpu: description: Share of total machine CPU, not of a single core. $ref: '#/components/schemas/Percent' memoryPrivate: description: Private bytes — memory freed if the process exited. The honest "how much RAM is this using". $ref: '#/components/schemas/Bytes' memoryWorkingSet: description: Resident working set, including shared pages. $ref: '#/components/schemas/Bytes' diskRead: $ref: '#/components/schemas/BytesPerSec' diskWrite: $ref: '#/components/schemas/BytesPerSec' netRx: description: | Per-process network throughput, only when an ETW session is running. Windows exposes no per-process network counters without a kernel trace the unelevated app cannot start. Null, never zero: a zero would render as "0 B/s" against every process on the machine. oneOf: - $ref: '#/components/schemas/BytesPerSec' - type: 'null' netTx: oneOf: - $ref: '#/components/schemas/BytesPerSec' - type: 'null' gpu: oneOf: - $ref: '#/components/schemas/Percent' - type: 'null' gpuMemory: oneOf: - $ref: '#/components/schemas/Bytes' - type: 'null' threadCount: type: integer minimum: 0 handleCount: type: [integer, 'null'] minimum: 0 user: type: [string, 'null'] description: Owning user; null when the server lacks rights to query the token. uptimeSecs: type: integer minimum: 0 # ── Host ─────────────────────────────────────────────────────────── # ── Alerts ───────────────────────────────────────────────────────── AlertKind: type: string enum: - cpuSustained - cpuThrottled - memoryPressure - memoryCommit - diskSaturated - diskLatency - diskSpace - diskHealth - gpuThrottled - thermalCpu - networkErrors - batteryLow - batteryHealth Severity: type: string enum: [info, warning, critical] AlertRoute: type: string description: Where in the app the user should go to act on this. enum: [performance, processes, storage, network, devices] Alert: type: object required: [kind, severity, subject, title, cause, values, route, sinceSample] properties: kind: $ref: '#/components/schemas/AlertKind' severity: $ref: '#/components/schemas/Severity' subject: type: string description: >- The thing the alert is about — a disk mount, a GPU name, an adapter. Empty for machine-wide alerts. Part of the alert's identity: two failing disks are two alerts. title: type: string description: i18n key, `dashboard.alert..title`. cause: type: string description: i18n key for the "why", `dashboard.alert..`. values: type: object additionalProperties: oneOf: - type: number - type: string description: Interpolation values, already rounded for display. route: oneOf: - $ref: '#/components/schemas/AlertRoute' - type: 'null' sinceSample: type: integer description: >- Sample index at which the alert was raised. Subtract from the current sample count (one per second at the default rate) for "for 4 minutes". CoreClass: type: string enum: [performance, efficiency, lowPower, standard] Summary: type: object required: [seq, timestampMs, system, top, processCount] properties: seq: type: integer timestampMs: type: integer system: $ref: '#/components/schemas/SystemMetrics' top: type: array items: $ref: '#/components/schemas/Process' processCount: type: integer MachineSample: type: object required: [ ts, cpuPercent, cpuKernelPercent, memoryUsed, memoryTotal, diskReadBps, diskWriteBps, netRxBps, netTxBps, ] properties: ts: type: integer description: Unix time in seconds. cpuPercent: type: number cpuKernelPercent: type: number memoryUsed: type: integer memoryTotal: type: integer diskReadBps: type: integer diskWriteBps: type: integer netRxBps: type: integer netTxBps: type: integer gpuPercent: type: [number, 'null'] cpuTempC: type: [number, 'null'] powerDrawW: type: [number, 'null'] SensorLine: type: object required: [key, label, value, unit, source, quality] properties: key: type: string description: Stable across requests, e.g. `cpu.package`. label: type: string description: English label from the backend. value: type: number unit: type: string enum: [temperature, power, voltage, fanSpeed, charge, percent] source: type: string enum: [ acpiThermalZone, batteryMiniport, systemPowerStatus, vendorLibrary, kernelDriver, storageDevice, ] quality: type: string enum: [measured, derived, nameplate] HostInfo: type: object required: - hostname - osName - osVersion - kernelVersion - architecture - cpuModel - cpuVendor - physicalCores - logicalCores - coreTopology - totalMemory - bootTimeMs - isVirtualMachine - motherboard - biosVersion properties: hostname: type: string osName: type: string osVersion: type: string kernelVersion: type: string architecture: type: string cpuModel: type: string cpuVendor: type: string physicalCores: type: integer minimum: 0 logicalCores: type: integer minimum: 0 coreTopology: description: | One entry per logical core, on hybrid designs (Intel P/E, ARM big.LITTLE). Null when the CPU is homogeneous or the topology is unknown. An E-core at 100% and a P-core at 100% are not the same event. oneOf: - type: array items: $ref: '#/components/schemas/CoreClass' - type: 'null' totalMemory: $ref: '#/components/schemas/Bytes' bootTimeMs: type: integer description: Milliseconds since the Unix epoch. isVirtualMachine: type: boolean motherboard: type: [string, 'null'] biosVersion: type: [string, 'null'] # ── Control ──────────────────────────────────────────────────────── ControlPriority: type: string description: The same string set the desktop's context menu uses. enum: [idle, below-normal, normal, above-normal, high, realtime] ControlRequest: description: | A command for the host. Tagged by `action`, kebab-case. Every variant carries a full `ProcessKey`, never a bare PID. oneOf: - $ref: '#/components/schemas/TerminateRequest' - $ref: '#/components/schemas/SuspendRequest' - $ref: '#/components/schemas/ResumeRequest' - $ref: '#/components/schemas/SetPriorityRequest' - $ref: '#/components/schemas/SetEfficiencyModeRequest' discriminator: propertyName: action mapping: terminate: '#/components/schemas/TerminateRequest' suspend: '#/components/schemas/SuspendRequest' resume: '#/components/schemas/ResumeRequest' set-priority: '#/components/schemas/SetPriorityRequest' set-efficiency-mode: '#/components/schemas/SetEfficiencyModeRequest' TerminateRequest: type: object required: [action, key] properties: action: type: string const: terminate key: $ref: '#/components/schemas/ProcessKey' SuspendRequest: type: object required: [action, key] properties: action: type: string const: suspend key: $ref: '#/components/schemas/ProcessKey' ResumeRequest: type: object required: [action, key] properties: action: type: string const: resume key: $ref: '#/components/schemas/ProcessKey' SetPriorityRequest: type: object required: [action, key, priority] properties: action: type: string const: set-priority key: $ref: '#/components/schemas/ProcessKey' priority: $ref: '#/components/schemas/ControlPriority' SetEfficiencyModeRequest: type: object description: | Turn Windows 11 efficiency mode (EcoQoS plus low priority) on or off for one process — what Task Manager's leaf icon does. `enabled: false` restores normal scheduling. The `enabled` flag is required; a missing value is a `422`, never a default. There is no read-back over HTTP: whether a process is currently in efficiency mode is a desktop-only detail, like its handles and modules, because answering it means opening the process on every poll. required: [action, key, enabled] properties: action: type: string const: set-efficiency-mode key: $ref: '#/components/schemas/ProcessKey' enabled: type: boolean ControlError: description: | Why a control request was refused. Tagged by `kind`, kebab-case. Over HTTP the same value also selects the status code: `forbidden` and `access-denied` → 403, `not-found` → 404, `unsupported` → 501, `internal` → 500. oneOf: - type: object description: The token can read but not act. required: [kind] properties: kind: type: string const: forbidden - type: object description: The process is gone, or the key no longer matches (PID recycled). required: [kind] properties: kind: type: string const: not-found - type: object description: The operating system refused. Elevation might help, or the process may be protected. required: [kind] properties: kind: type: string const: access-denied - type: object description: The host has no way to do this (no backend, unknown priority…). required: [kind, message] properties: kind: type: string const: unsupported message: type: string - type: object required: [kind, message] properties: kind: type: string const: internal message: type: string discriminator: propertyName: kind ControlReply: type: object description: The WebSocket reply to a `ControlRequest`. Over HTTP the equivalent is the status code. required: [ok] properties: ok: type: boolean error: description: Present only when `ok` is false. $ref: '#/components/schemas/ControlError'