Reports › Contract docs › api-v1

IO diagnostics — server API v1

2026-10-22, lane F (host + tray). The only interface the web UI and the tree proposals may rely on. Served by io_diagnostics/server.py (stdlib http.server, default http://127.0.0.1:8812/). Tests: tests/test_host_api.py checks every endpoint against the shapes below; tests/test_server_act.py the one write.

Every endpoint is GET and returns JSON (Content-Type: application/json, Cache-Control: no-store), except the page's writes: POST /api/v1/act, the Utilities panel (see "Actions from the page" below), and POST /api/v1/expect | forget | retire, the Expected setup card (host-side writes of the person's rig file, never a device; "Editing the expected setup" below), and POST /api/v1/settings, the View options (the history window, written to the person's settings file; "Settings" below). Any other method or path returns 405. Agents never call POST /api/v1/act: they dry-run with io-diagnostics act --dry-run; a real action runs only from a person's click on the page (or io-diagnostics act typed by a person at a TTY). The record contract underneath is contract/record.py (SCHEMA = "diagnostics-record/0", RECORD_VERSION = "0.2"); docs/diagnostics-record-v0.md explains every rule this API applies.

uv run --project ~/io-diagnostics python ~/io-diagnostics/io_diagnostics/cli.py serve --fixture F2
Endpoint What a page uses it for Poll
GET /api/v1/state Everything a page draws every tick: clock, chip, the whole tree (nested), the last 6 events 1 s
GET /api/v1/node?path=<entity path> The detail panel of one row: its checks, values, measurements, actions with gate state, faults, events on select, then 1-2 s
GET /api/v1/activity[?limit=N][&types=usb,driver][&fold=1&...] Activity log (all events, newest first, typed), faults, link-health rows 2-5 s
GET /api/v1/events[?types=...] Server-Sent Events: each new or changed activity event (a provisional plug, then the same id confirmed) and a tick per change stream
GET /api/v1/records The raw v0 records the host holds (entities, statuses with received, events, faults) rarely
GET /api/v1/plugins Plugin registry: loaded plugins.d/*.toml, matched devices, last run per device rarely
GET /api/v1/health {"ok": true, ...} liveness —
GET /api/v1/history?path=<entity path>[&status=<name>][&window=<duration>] One entity's statuses over the window: level spans (stale and no data included) and every measurement value on select, then 2-5 s
GET /api/v1/history/bar?path=<entity path>&bins=N[&window=<duration>] The health bar of an entity and of each child row: the worst level held per bin 2-5 s
GET /api/v1/settings, POST /api/v1/settings The app-wide settings (the history window); the POST is the page's View options ONLY on open / on change
GET /api/v1/runs (and /runs/<id>, /runs/<id>/stream, /runs/<id>/cast; POST /runs/<id>/input, /signal, /resize, page-only) Every real utility is a run on a terminal: the list, one run, its live output (SSE), its recording, and the page's typing and Stop. The whole contract is docs/runs-v1.md stream / on open
POST /api/v1/act The Utilities panel ONLY: a dry run, then a person's confirmed run with the dry run's token on click
POST /api/v1/expect, /forget, /retire The Expected setup card ONLY: one edit of the rig file after a person's confirm on click
GET /, /<file> The page in io_diagnostics/web/ —

Errors: an unknown path → 404 {"error": "..."}; an exception while building a response → 500 {"error": "<Type>: <message>"} (the server keeps serving).

Conventions

GET /api/v1/state

{
  "api": "io-diagnostics/1",
  "schema": "diagnostics-record/0",
  "record_version": "0.2",
  "clock": {"now": 1761095510.0, "iso": "2026-10-21T18:11:50-07:00", "time": "18:11:50",
            "mode": "fixture", "utc_offset_s": -25200,
            "rank": ["ok", "warn", "unknown", "stale", "error"], "stale_factor": 3.0, "stale_floor_s": 5.0},
  "fixture": {"name": "F1", "label": "18:11 · plug", "story": "XXXXXXXX settled on 1-5 ...", "clock": 1761095510.0,
              "frames": [{"name": "F1", "label": "18:11 · plug"}, {"name": "F2", "label": "18:25 · bus busy"},
                         {"name": "F3", "label": "19:02 · after restart"}]},
  "host": {"name": "rack-pc", "sources": [{"name": "io_facts.usb", "state": "used", "note": "12 devices"},
                                          {"name": "io_facts.pci", "state": "skipped", "note": "ModuleNotFoundError: ..."}]},
  "summary": {"level": "error", "counts": {"error": 2, "warn": 3},
              "attention": [{"path": "host/usb1/port-1-5/usb-1-5/can", "name": "XXXXXXXX CAN bus",
                             "kind": "bus", "icon": "bus", "level": "error",
                             "message": "error-passive · no ACK: no powered node on this bus · 0 of 4 answer",
                             "device": {"path": "host/usb1/port-1-5/usb-1-5", "name": "fdcanusb XXXXXXXX"}}],
              "entities": 41, "devices": 10},
  "chip": {"label": "IO 5 ⚠", "label_guide": "IO 00 ⚠", "icon": "attention", "status": "error", "title": "IO diagnostics",
           "menu": [{"text": "5 need you · 10 devices"}, {"sep": true}, "..."]},
  "tree": {"...": "node, see below; children nested"},
  "recent": [{"...": "event, see Activity"}],
  "expected_setup": {"file": "~/.config/io-diagnostics/expected.toml", "editable": true, "note": "",
                     "rigs": [{"name": "1-DOF test rack", "path": "host/rig-1-dof-test-rack", "devices": 4}],
                     "retired": [{"name": "fdcanusb XXXXXXXX", "hardware_id": "usb:0483:5740:XXXXXXXX", "serial": "",
                                  "date": "2026-10-23", "reason": "frames never reached the bus (2026-10-22) ...",
                                  "path": null}]},
  "runs": {"active": 0, "total": 0, "items": []}
}

The node (one tree row)

{
  "path": "host/usb1/port-1-3/usb-1-3",
  "name": "fdcanusb XXXXXXXX",
  "kind": "device",
  "icon": "adapter",
  "parent": "host/usb1/port-1-3",
  "edge": "via",
  "depth": 3,
  "level": "ok",
  "message": "adapter OK (answers can status)",
  "own": {"level": "ok", "message": "adapter OK (answers can status)"},
  "rollup": {"level": "error", "origin": "host/usb1/port-1-3/usb-1-3/can/15/driver",
             "origin_name": "moteus-c1 driver", "message": "moteus-c1 driver: fault 33 (motor driver) on bridge enable",
             "counts": {"error": 1}, "knock_on": []},
  "hint": {"level": "error", "origin": "host/usb1/port-1-3/usb-1-3/can/15/driver",
           "message": "moteus-c1 driver: fault 33 (motor driver) on bridge enable"},
  "knock_on_of": null,
  "stale": false,
  "connected": true,
  "hardware_id": "usb:0483:5740:XXXXXXXX",
  "identity": {"manufacturer": "mjbots", "model": "fdcanusb", "serial_number": "XXXXXXXX"},
  "owner": "moteus_servo",
  "id_text": "XXXXXXXX",
  "last_received": 1761095510.0, "age_s": 0.0, "expected_period_s": null, "stale_after_s": null,
  "checks": 2, "actions": 1, "faults": 0, "collapsed": false,
  "children": []
}
Field Meaning
path, name The entity key (Conventions: changes on replug; key selections on it) and the name to show. A plugin may rename its device's OS row ("fdcanusb" → "D405 ·6036"); the name is rendered as authored.
kind What the entity is (open string; the record contract's vocabulary plus group): host; bus (an OS bus from io_facts.skeleton: a USB root hub "USB bus 1", a PCI root bus; or a plugin's bus, e.g. a CAN bus); group (on-board / parentless nodes of one source, e.g. "On-board serial ports", or "USB" when the fallback reader is used); port (a USB port, added by the host between a device and its hub or root bus); device (every OS hardware or function node: a USB device, a hub, a PCI function, a tty, a video node, a NIC); plugin kinds node, part, stream; and rig (a rig row of the expected setup, first under the host).
icon The icon TYPE to draw (closed vocabulary; unknown → draw device): host, group, usb, pci, net, tty, video, port, hub, adapter, bus, servo, chip, motor, camera, stream, webcam, hid, device. A plugin may hint it; otherwise the host derives it from kind, bus and USB class.
parent, edge The ONE parent edge (contract Entity): "via" = connectivity (port, hub, adapter, bus it is reached through), "part_of" = composition (a node's driver / motor; a camera's streams). Draw them differently if you like; both nest in children. null for the root.
depth 0 at host.
level, message What the row shows (contract summarize → Summary.level / .message). An entity with records of its own is a black box: this is ITS self-report (the worst effective level of its own checks). A pure grouping node (own.level = null: the bus groups, and the host while no plugin diagnoses it) shows its rollup instead. The host never overrides a self-report. With the computer plugin the host row has records of its own (the Computer's checks, its USB/PCIe links line): its level is then the Computer's own report, judged as a leaf beside its children, so a full disk never explains a dead CAN bus (tree.HOST_SELF).
own The self-report alone; own.level = null means a pure grouping node.
rollup Advisory "worst below" (Redfish HealthRollup): the worst independent cause at or below this row, where it is, its counts per level, and knock_on = [{"path", "cause"}] problems collapsed into an ancestor's failure. level = "ok" when everything below reports OK; null when nothing below reports.
hint Set when rollup.level is worse than level (rank order): the "a child is in ERROR" hint a row draws beside its own status. null otherwise. Example: the spider self-reports WARN with an ERROR hint from leg 3.
knock_on_of This row's own problem is explained by another row: a failed ancestor (one dead bus is one cause), or, for a composite whose checks name their members in caused_by (record v0.1 addendum), the root cause of the member it names (the rig row "missing: CAN 15" → CAN 15's row; a spider at ERROR → its failed leg). Draw it subdued; it is not counted in summary. null when the row is an independent cause or OK.
stale own.level is "stale": the host has not received this entity's periodic record for max(3 × expected_period_s, 5 s) on its own receive clock.
connected false = not connected → render fully greyed: an empty USB port, or an entity whose latest record says mode: "offline" (a deliberate goodbye).
hardware_id, identity Repeatable identifier (usb:<vid>:<pid>:<serial> for USB) and the descriptive manufacturer / model / serial_number / hw_version / sw_version / model_id (any subset; parts carry none). model_id (record v0.1) is the robotparts part id, e.g. "mjbots-moteus-c1", a plain string a page may link to robotparts.dev.
owner Who registered the entity and would run its actions: a plugin name, or io_facts.usb / io_diagnostics for OS rows.
id_text Short text for the id column: a device's serial (a plugin may replace it, e.g. the RealSense SDK serial), a port's link speed or empty, a bus's / group's / the host's physical device count.
last_received, age_s When the row was last heard from: the newest received among its PERIODIC own records (the ones STALE is judged on), else among all its own records (null = no records); age on the host clock.
expected_period_s, stale_after_s Shortest declared period among the row's own periodic records and its STALE threshold (null = event-driven: never stale).
checks, actions, faults Counts of own status records, registered actions, and the host's open faults (pending or confirmed) on this row; details via /api/v1/node.
collapsed true for a group a page starts closed: host/recently-seen ("Recently seen": USB devices this host saw a plugin diagnose that are neither in the expected setup nor in a plugin's describe.expected, unplugged within the last day; each an OK presence row, never a warning or a fault). false everywhere else.
children Child nodes, ordered: ports and groups by name, plugin entities in the order the plugin sent them.

GET /api/v1/node?path=<entity path>

{
  "node": {"...": "the node as in /state, with children replaced by a list of child paths"},
  "ancestors": [{"path": "host", "name": "rack-pc", "edge": null}, {"path": "host/usb1", "name": "USB bus 1 · 480 Mb/s", "edge": "via"}],
  "checks": [
    {"name": "CAN 15: comms", "level": "ok", "effective_level": "stale", "stale": true,
     "message": "answers · 36.5 V · stopped", "code": "", "mode": null, "source": "moteus_servo (rack_inventory, real)",
     "hardware_id": "moteus 22fb62f0 · XXXXXXXXXXXXXXXX", "stamp": 1761096101.0, "received": 1761096101.0,
     "received_iso": "2026-10-21T18:21:41-07:00", "age_s": 199.0, "expected_period_s": 180.0, "stale_after_s": 540.0,
     "uptime_s": null, "caused_by": [],
     "values": [{"key": "voltage", "value": "36.5 V"}],
     "measurements": [{"key": "voltage", "value": 36.5, "unit": "V", "text": "36.5 V", "level": "ok",
                       "warn_low": null, "warn_high": null, "error_low": null, "error_high": null, "stamp": null}]}
  ],
  "actions": [
    {"name": "reset", "title": "Reset…", "description": "...", "danger": "interrupts", "scope": "node", "kind": "changes",
     "confirm": "Reboot CAN 15 (serial ABoA...)? ...",
     "preconditions": [{"check": "port_free", "text": "Port not held by another process", "state": "fail",
                        "detail": "port held by 640623 tview"}],
     "state": "blocked", "reason": "blocked: port held by 640623 tview", "executable": false, "owner": "moteus_servo"}
  ],
  "faults": [{"...": "fault, see Activity"}],
  "events": [{"...": "event for this path or below, newest first, at most 20"}],
  "entity": {"schema": "diagnostics-record/0", "record": "entity", "path": "...", "...": "contract to_dict(Entity)"},
  "setup": {"role": "device", "file": "~/.config/io-diagnostics/expected.toml", "note": "", "editable": true,
            "rigs": ["1-DOF test rack"], "rig": "1-DOF test rack", "expected": true, "retired": null,
            "nodes_expected": ["can/1", "can/14", "can/15"], "node_notes": {"can/15": "being replaced"},
            "nodes_here": ["can/1", "can/14"],
            "can": {"expect": true, "forget": false, "retire": true},
            "confirm": {"expect": "Expect fdcanusb XXXXXXXX in 1-DOF test rack with can/1, can/14 (can/15 no longer expected)?",
                        "retire": "Retire fdcanusb XXXXXXXX? It leaves 1-DOF test rack. Plugged in again, ..."},
            "needs_rig": false}
}

GET /api/v1/activity[?limit=N][&types=<type>,...][&fold=1&channel=&path=&scope=&severity=]

{
  "clock": {"...": "as in /state"},
  "events": [
    {"stamp": 1761095502.0, "iso": "2026-10-21T18:11:42-07:00", "time": "18:11:42", "code": "Plugged",
     "kind": "plugged", "type": "usb", "id": "usb:1-7:21:add", "provisional": false, "severity": "info",
     "message": "fdcanusb XXXXXXXX plugged on 1-5 after 4 flaps on 1-7",
     "path": "host/usb1/port-1-5", "name": "Port 1-5", "hardware_id": "usb:0483:5740:XXXXXXXX", "port": "1-5",
     "source": "io_facts.journal", "seq": 12, "args": {"port": "1-5", "flaps": 4, "merged": ["usb:1-7:21:add", "usb:1-7:21:remove"]},
     "channel": "activity", "count": 1, "last_stamp": null, "last_iso": null, "last_time": "", "received": 1761095503.4,
     "received_iso": "2026-10-21T18:11:43-07:00", "badges": [], "members": []}
  ],
  "faults": [
    {"path": "host/usb1/port-1-3/usb-1-3/can/15/driver", "code": "MoteusFault.33", "severity": "error",
     "status": "confirmed", "active": true, "occurrence_count": 1, "first_occurred": 1761090847.0,
     "first_occurred_iso": "...", "last_occurred": 1761090847.0, "last_occurred_iso": "...", "last_passed": null,
     "last_passed_iso": null, "message": "fault 33 (motor driver) on bridge enable", "hardware_id": "...",
     "name": "CAN 15 driver"}
  ],
  "link_health": [
    {"edge": "Port 1-7", "path": "host/usb1/port-1-7", "kind": "USB", "metric": "enumerations / hour", "value": "4",
     "level": "warn", "source": "journalctl -k (boot enumeration excluded)"}
  ]
}
type codes from args beside port
usb Plugging, Unplugging (provisional), Plugged, Unplugged, ReEnumerated (dropped and came back), Flapped (enumerated, dropped before settling 3 s, never came back) udev at once; the kernel log (io_facts.journal's coalescer) confirms product, identity, flaps, flap_ports, gap_s, merged (the raw enumerations it folds); provisional: product, serial, devnum
link LinkSpeed (info: the speed each settled enumeration linked at), LinkDowngraded (warn: a USB 3 device, sysfs version ≥ 3, linked below 5 Gb/s, or one that linked at USB 3 earlier this boot) the kernel's new <speed> USB device line, sysfs speed_mbps, identity; usb_version or before_mbps
usb_error UsbDescriptorReadError, UsbNotAcceptingAddress, UsbReset, UsbConfigFailed (warn); UsbOverCurrent, UsbEnumerationFailed, UsbCannotEnable, UsbResetFailed (error); UsbPortDisabled ("disabled by hub (EMI?)", warn) the kernel log (hub.c's words, io_facts.kernel_log); a burst on one port within 5 s is one event count, kernel (the kernel's line)
driver DriverBound, DriverUnbound: "cdc_acm bound on 1-1 → /dev/ttyACM1", "uvcvideo bound on 1-1 → /dev/video0, /dev/video1", "uvcvideo unbound from 1-1 (/dev/video0 gone)" udev (interface bind / unbind, tty / video4linux / net nodes) and the kernel log (cdc_acm, Found UVC, USB serial converters); one event per enumeration and driver driver, nodes
network NetCarrierUp (info), NetCarrierDown (warn) sysfs carrier, compared on every refresh interface, carrier_changes
pcie PcieAerError (warn when Corrected, error when Uncorrected / Fatal) the kernel log (AER:, PCIe Bus Error); one per burst device (PCI address), driver, count, kernel
level LevelChanged: ": ok → error (Code: message)", stale included the history store (each status's effective level) status, from, to, status_code
fault FaultConfirmed, FaultHealed, FaultCleared the host's FaultManager fault, occurrence
boot Booted, ShutDown the kernel log, journalctl --list-boots devices
run RunStarted, RunEnded (2026-10-24): one pair per real utility run, linked by args.run_id; RunEnded is warn when stopped or timed out, error when failed (docs/runs-v1.md section 5) the host (io_diagnostics.runs) run_id, action, node, plugin, state, exit_code
plugin anything a plugin sends, e.g. NoAck, PortHeld, and a probing driver's Found, Probed, ProbeBlocked (docs/probe-on-connect.md) the plugin as sent

The Activity log model: fold=1 (record v0.2, 2026-10-24; docs/activity-log-v1.md)

fold=1 answers the Activity view's model instead of the raw event list: the same events, folded into the rows a person reads. It is a pure function of the stored events (io_diagnostics/logview.py), so a restart gives the same rows.

{
  "clock": {"...": "as in /state"},
  "events": [
    {"stamp": 1761095504.0, "iso": "2026-10-21T18:11:44-07:00", "time": "18:11:44", "code": "BusErrorPassive",
     "kind": "bus error passive", "type": "plugin", "id": "moteus_servo#(email removed)", "provisional": false,
     "severity": "warn", "message": "XXXXXXXX bus error-passive: nobody ACKs its frames", "path": "host/usb1/port-1-5/usb-1-5/can",
     "name": "CAN bus", "hardware_id": "", "port": "", "source": "moteus_servo", "seq": 1, "args": {"serial": "XXXXXXXX"},
     "channel": "activity", "count": 1, "last_stamp": null, "last_iso": null, "last_time": "", "received": 1761095504.6,
     "received_iso": "2026-10-21T18:11:44-07:00",
     "badges": [{"id": "fault:FaultConfirmed:host/usb1/port-1-5/usb-1-5/can|NoAck|1761095504.000", "code": "FaultConfirmed", "label": "fault confirmed", "stamp": 1761095504.0,
                 "severity": "error", "fault": "NoAck", "occurrence": 1, "state": "confirmed", "ended": null,
                 "message": "NoAck confirmed on host/usb1/port-1-5/usb-1-5/can: error-passive", "time": "18:11:44", "ended_time": ""}],
     "members": [{"id": "fault:FaultConfirmed:host/usb1/port-1-5/usb-1-5/can|NoAck|1761095504.000", "code": "FaultConfirmed", "severity": "error", "message": "NoAck confirmed",
                  "path": "host/usb1/port-1-5/usb-1-5/can", "name": "CAN bus", "stamp": 1761095504.0, "time": "18:11:44",
                  "args": {"fault": "NoAck", "occurrence": 1}}]}
  ],
  "log": {"channel": "activity", "path": "", "name": "", "scope": "children", "severity": "debug", "matching": 17, "shown": 17,
          "persisted": true, "note": ""}
}

GET /api/v1/events[?types=<type>,...] (Server-Sent Events)

A text/event-stream (Cache-Control: no-store) for as long as the client stays: the page's new EventSource("/api/v1/events"), same-origin and read-only (no CORS header, so another origin cannot read it). types filters as on /activity (unknown → 400 before the stream starts); it takes the same fold, channel, path, scope and severity, and then streams ROWS: a row is sent again when its count, last_stamp, badges, members or a run's args.state changed, and a row can disappear (absorbed by another), so a page re-reads /activity?fold=1 on a tick.

retry: 2000
event: hello
data: {"api": "io-diagnostics/1", "revision": 41, "now": 1792790150.2, "latest": "usb:1-1:12:add"}

id: usb:1-1:13:add
event: activity
data: {"...": "one event, exactly as in /api/v1/activity", "provisional": true, "message": "plugging in: fdcanusb XXXXXXXX on 1-1…"}

event: tick
data: {"revision": 42, "reason": "udev", "now": 1792790150.9}

GET /api/v1/records

{"clock": {"...": "..."}, "entities": [{"schema": "diagnostics-record/0", "record": "entity", "...": "..."}],
 "statuses": [{"record": "status", "received": 1761095510.0, "...": "..."}],
 "events": [{"record": "event", "...": "..."}], "faults": [{"record": "fault", "...": "..."}]}

The host's current memory as contract to_dict records with absolute paths: what io-diagnostics once --json prints under records, what the interop bridge (contract.record.to_diagnostic_status) projects for stock viewers, and what the rack session runner can read.

GET /api/v1/plugins

{
  "directory": "~/io-diagnostics/plugins.d",
  "problems": ["broken.toml: command must be a non-empty list of strings"],
  "plugins": [
    {"name": "moteus_servo", "file": ".../plugins.d/moteus_servo.toml", "command": ["..."], "diagnose_args": [],
     "cwd": "...", "timeout_s": 20.0, "act_timeout_s": 900.0, "period_s": 30.0, "enabled": true, "replay": false,
     "exclusive": "rack-bus", "kind": "driver", "attach": null,
     "description": "moteus boards",
     "matches": ["usb:0483:5740"],
     "describe": {"name": "moteus_servo", "version": "0.3.0", "schema": "diagnostics-record/0"},
     "jobs": [{"device_id": "usb:1-3", "device_path": "host/usb1/port-1-3/usb-1-3",
               "last_run": {"started": 1761095500.0, "duration_s": 1.2, "outcome": "ok", "returncode": 0,
                            "accepted": 14, "rejected": 0, "problems": [], "rejected_lines": [],
                            "message": "ran in 1.2 s · 14 records"}, "next_due_s": 28.8, "follow": null}]}
  ]
}

outcome is ok, malformed (some lines rejected), crashed, timeout or not_started; the same outcome is the host check on the device row. follow is null unless the plugin's describe declares follow (docs/build-plan-v1.md "Plugin boundary"): its job then runs no diagnose (last_run stays null, next_due_s null) and follow is {"state": "running" | "restarting" | "starting", "pid", "since", "first_started", "restarts", "lines", "records", "rejected", "period_s", "retry_at", "exit", "stderr"} (times on the host clock; exit how the last process ended, stderr its last stderr line). The device row's <plugin>: plugin run check then says "following since 14:20:01 (pid 4242) · every 1 s · 3612 records" (OK, re-said once per period, so a silent follower goes STALE), or ERROR PluginCrashed / PluginNotStarted "follow exited 1: ... · restarting in 4 s". diagnose_args is what the declaration appends after diagnose --device <id> (e.g. a driver's --passive). describe is null until the plugin's describe verb has run (once per enabled plugin, all in parallel, at the host's first refresh, before the first match); a v0.1 plugin may state "record_version": "0.1" and its expected devices there (see "Expected, not connected"). exclusive is the declaration's group ("" = none): the host never runs two jobs (diagnose or act) of one group at once, across plugins and across one plugin's devices; a due job whose group is busy waits its turn (oldest due first) and is never failed for it. Both rack drivers declare "rack-bus", because each takes the rack bus lock with flock LOCK_NB and would otherwise fail its sibling with BusLockHeld. next_due_s stays 0.0 while a job waits for its group.

The on-connect probe (additive, 2026-10-23; contract: docs/probe-on-connect.md): a plugin whose describe has "probe": {"timeout_s": 30} (or true = its own timeout_s) gets <command> probe --device <id> [diagnose_args] ONCE PER CONNECTION in place of that connection's first diagnose: when its device is first matched (plugged in, or present when the host starts) and again when its kernel device number changes (re-enumeration). The periodic diagnose (period_s) or follow starts period_s (follow: its period) after the probe; a probe never runs on the period. A plugin without probe keeps diagnose-on-connect. The probe's records are applied exactly like diagnose's; its last_run and outcome are the same, and it honours exclusive. The host derives NO device-specific event (no found / lost from diffing entities): the driver writes its own Found / Probed / ProbeBlocked events, and they appear in /api/v1/activity as they are, type plugin, source the plugin's name when the event names none. - The device row's <plugin>: plugin run check reads probed 15:12:31 · 3.1 s (OK), or while blocked UNKNOWN with the holder's code (PortBusy / BusLockHeld) and the driver's ProbeBlocked sentence (the host words it itself, "tview has the port (pid 78183) · RobotDx probes again when it closes", when the driver sent none), plus the value blocked_by. - Blocked = a status of the probe's records with code PortBusy or BusLockHeld. Its holders are read from the status's port_holders / holders / lock_holders value, else from the sentence after "held by", as "pid program" entries separated by ", " (the program name is derived from /proc/<pid>/cmdline when the plugin says only python3). The host then probes again 0.5 s after the last holder pid has exited (it reads /proc/<pid>/stat about once a second, no subprocess; a zombie or a reused pid counts as gone: measured 1.0-1.3 s from exit to the probe starting) and, for holders it cannot watch, on a backoff of 10, 20, 40 s ... never beyond period_s. A blocked probe never turns into the periodic diagnose; a probe that crashed or timed out is not repeated (its row says so, and the periodic diagnose goes on).

Monitoring Plugins checks (kind "check", round 3: "CLI as the seam"; plugins.d/README.md "Checks"): any command speaking the Monitoring Plugins API is a diagnostic, no code needed. It has no matches; attach names its one entity ("host", a hardware_id, or "rig:<rig name>"). Its one job has device_id "check:<name>", device_path = the entity its result is attached to now, and last_run in the same shape (outcome ok | timeout | not_started; returncode = the exit code; accepted 1). Its result is ONE check on that row, named after the check, source "io_diagnostics.check":

Actions: io-diagnostics act (a person at a terminal)

uv run --project ~/io-diagnostics io-diagnostics act --path host/usb1/port-1-3/usb-1-3 --action check_comms --dry-run

act --path <entity path> --action <name> [--node N] [--new-id N] [--confirm TOKEN] [--dry-run] (also --plugins, --enable, --fixture, --machine as for once) finds the job whose device row is at or above path, looks the action up on the entity (after a diagnose) or else in the plugin's describe actions, and runs the plugin's <command> act --device <id> --action <name> [--node N] [--new-id N] [--confirm TOKEN] [--dry-run] inside its exclusive group. --new-id is set_id's new bus id (act --action set_id --node 15 --new-id 7); the host passes it through and the plugin checks it (moteus: 1-126, not the board's own id, no board answering it yet; its confirm token names it). Host function: Host.act(path, action, node=, confirm=, dry_run=, human=, new_id=).

The rule: only a person runs an action; agents can't click. - With stdin not a TTY (an agent, a pipe, a cron job) the host refuses everything except --dry-run (refused: "NotInteractive"). A dry run runs nothing: the plugin prints what it would run. - An action whose danger is not query needs --confirm TOKEN (refused: "ConfirmRequired", with the action's confirm text). The host passes the token through; the plugin checks it, since only the owner knows what it confirms (moteus: the token its ConfirmationRequired event names; RealSense: the camera's serial). - Refused before anything runs: NoSuchEntity, NoPlugin, ActionNotRegistered.

It prints one JSON object and exits 0 (ran, plugin exit 0), 2 (refused: nothing ran) or 1 (the plugin crashed or hung):

{"path": "host/usb1/port-1-3/usb-1-3", "action": "reboot", "node": 15, "new_id": null, "dry_run": true, "plugin": "moteus_servo",
 "device_id": "usb:1-3", "title": "Reboot…", "danger": "interrupts", "registered_by": "describe",
 "argv": ["uv", "...", "act", "--device", "usb:1-3", "--action", "reboot", "--node", "15", "--dry-run"],
 "refused": null, "outcome": "ok", "returncode": 0, "duration_s": 0.4,
 "records": [{"record": "action-result", "command": ["..."], "...": "the plugin's lines, verbatim"}],
 "action_result": {"record": "action-result", "action": "reboot", "dry_run": true, "command_kind": "argv",
                   "command": ["..."], "preconditions": [], "confirm_required": true, "message": "..."},
 "rejected": [], "stderr_tail": "", "message": "ran in 0.4 s · 1 lines"}

outcome is refused, ok, crashed, timeout or not_started; records are the plugin's JSON lines as it wrote them (the host does not reinterpret an action's result); action_result is the first record: "action-result" line (null if none) with the vocabulary's fields (action, title, dry_run, danger, path, hardware_id, command_kind argv | sdk, command, preconditions [{check, text, state, detail}], confirm_required, confirm_token, confirm_given, source, stamp, message), and its message becomes the result's; registered_by is entity or describe. The plugin's act is bounded by its declaration's act_timeout_s (default 900 s; a firmware flash or a calibration outlasts any diagnose), never by timeout_s.

Actions from the page: POST /api/v1/act

The Utilities panel's one write (io_diagnostics/server.py). Never call it from an agent, a script or another tool: an agent proves a button with io-diagnostics act --dry-run, and only a person clicks. Every click on the page is a dry run first:

{"path": "host/usb1/port-1-3/usb-1-3/can/15", "action": "calibrate", "node": 15, "dry_run": true}

The answer carries the Host.act(..., dry_run=True) result (above: argv, action_result with the plugin's command, steps, preconditions, confirm, and its own token) and, when the plugin answered (outcome ok), the page's token for exactly that path / action / node / new id:

{"dry_run": true, "result": {"path": "host/usb1/port-1-3/usb-1-3/can/15", "action": "calibrate", "outcome": "ok"}, "token": "c-3TW1BFkuLa5-UsYKpa3OMp", "window_s": 300.0}

The run repeats the request with that token and without dry_run:

{"path": "host/usb1/port-1-3/usb-1-3/can/15", "action": "calibrate", "node": 15, "token": "c-3TW1BFkuLa5-UsYKpa3OMp"}

set_id also names the board's new bus id as new_id (an integer; omit it or send null for every other action). The server passes it to the plugin as --new-id and binds the token to it, so the run must repeat it: a token issued for CAN 15 → CAN 7 cannot run CAN 15 → CAN 9 (TokenMismatch).

{"path": "host/usb1/port-1-3/usb-1-3/can/15", "action": "set_id", "node": 15, "new_id": 7, "dry_run": true}

and answers {"dry_run": false, "result": {...}} with the Host.act result of the real run (records are the plugin's lines: ActionCompleted, ConfirmationRequired, ActionRefused, ...). Since 2026-10-24 a real run is a RUN (docs/runs-v1.md): the plugin runs on a pseudo-terminal, the answer carries result.run_id, and "detach": true in the request answers 202 {"dry_run": false, "run": {...}} at once so the page can show the live output. A dry run on a bus whose exclusive group a run is holding is refused Busy (nothing waits for minutes). The server passes the PLUGIN's token from the dry run (action_result.token, or confirm_token) as --confirm; the plugin still checks it against the device. It runs a real action only when ALL of these hold, else 403 {"refused": "<Code>", "error": "<plain reason>"} and nothing reaches the plugin:

Code Refused when
NotFromPage the X-IO-Diagnostics: ui header is missing (the page's own fetch sets it)
NotSameOrigin Sec-Fetch-Site is not same-origin (a browser sets it; no page can forge it), or Origin is another site
NotJson the body is not Content-Type: application/json (forces a CORS preflight this server never answers)
NoDryRun no token, a token this server never issued, or one already used (tokens are single use)
TokenExpired the dry run is older than window_s (300 s: a moteus token is good for at least one 5-minute window)
TokenMismatch the token was issued for another path, action, node or new id

A malformed body (node or new_id not an integer or null, ...) is 400 BadRequest. The page's rule on top (web/app.js): a query runs with one more click; any other danger only from a confirm modal that repeats the danger; moves only with a ticked box that states the physical precondition (e.g. "CAN 15's joint is free to spin") and a button labelled with the action. set_id's row holds a "New id (1-126)" number box: its dry run stays disabled until the box holds a whole number 1-126 other than the board's own id, its confirm modal repeats "CAN 15 → CAN 7", and typing another id closes a dry run shown for the old one. Agents must not replay this flow: the headers are what a browser sends, and a command-line client can forge them, which is why the rule is written here and not only enforced.

Editing the expected setup: POST /api/v1/expect, /forget, /retire

The Expected setup card's three buttons (docs/expected-setup.md). Each rewrites the person's rig file (~/.config/io-diagnostics/expected.toml, atomically) and nothing else: no device is touched, so there is no dry run and no token. The page asks one plain confirm first, showing setup.confirm[op] from /api/v1/node. The same same-origin rule as /api/v1/act applies (NotFromPage, NotSameOrigin, NotJson: 403), and agents never call them (the CLI has io-diagnostics expect | forget | retire).

{"path": "host/usb1/port-1-5/usb-1-5", "rig": null, "reason": ""}

The answer is 200 {"ok": true, "op", "path", "refused": null, "message": "done: ...", "file"}, or 400 {"ok": false, "refused": "<Code>", "message": "<plain reason>", ...} (NotADevice, NotHere, NotAbsent, ReasonRequired, RigRequired, ManifestUnavailable when the file does not read or this host has none, NotWritten), or 404 when no row has that path.

GET /api/v1/health

{"ok": true, "api": "io-diagnostics/1", "mode": "live" | "fixture", "uptime_s": 12.3}

GET /api/v1/history?path=<entity path>[&status=<name>][&window=<duration>]

The host keeps a rolling history per (entity path, status name), in memory (io_diagnostics/history.py): the level transitions and every measurement value, each on its own receive stamp (received, the host's clock). What it is fed: every NEW evaluation a plugin run brings (a replay re-sending its recording is one report), the host's own plugin run checks, and, on change, the OS checks (presence, port link, PCIe link, expected-absent) and the per-view lines (rig rows, another adapter, the Computer's USB/PCIe links). A check its source stops computing (an unplugged device's, a removed job's) has no data from then on.

{
  "path": "host/usb9/port-9-1/usb-9-1/can",
  "name": "DEAD CAN bus",
  "window_s": 900.0,
  "from": 130.0,
  "to": 1030.0,
  "statuses": [
    {"name": "DEAD CAN bus: bus", "expected_period_s": 2, "stale_after_s": 6.0,
     "levels": [{"from": 130.0, "to": 1000.0, "level": null, "code": "", "message": ""},
                {"from": 1000.0, "to": 1010.0, "level": "ok", "code": "", "message": "error-active"},
                {"from": 1010.0, "to": 1012.0, "level": "stale", "code": "", "message": "no update for 6 s (last: error-active)"},
                {"from": 1012.0, "to": 1022.0, "level": "error", "code": "NoAck", "message": "error-passive: nobody ACKs"},
                {"from": 1022.0, "to": 1030.0, "level": "stale", "code": "", "message": "no update for 6 s (last: error-passive: nobody ACKs)"}],
     "measurements": [{"key": "link speed", "unit": "Mbit/s", "warn_low": null, "warn_high": null, "error_low": null,
                       "error_high": null, "points": [[1000.0, 12], [1004.0, 12]]}]}
  ]
}

GET /api/v1/history/bar?path=<entity path>&bins=N[&window=<duration>]

The health bar (rqt_robot_monitor's timeline) of one entity and of each of its child rows, over the same window.

{
  "path": "host/usb9/port-9-1/usb-9-1",
  "window_s": 60.0,
  "from": 970.0,
  "to": 1030.0,
  "bins": 10,
  "bin_s": 6.0,
  "rows": [
    {"path": "host/usb9/port-9-1/usb-9-1", "name": "fdcanusb DEAD", "depth": 0, "basis": "own", "statuses": 3,
     "bar": [null, null, null, null, null, "ok", "stale", "ok", "stale", "stale"]},
    {"path": "host/usb9/port-9-1/usb-9-1/can", "name": "DEAD CAN bus", "depth": 1, "basis": "own", "statuses": 1,
     "bar": [null, null, null, null, null, "ok", "stale", "error", "error", "stale"]}
  ]
}

Settings: GET /api/v1/settings, POST /api/v1/settings

{"history_window_s": 900.0, "source": "default", "file": "~/.config/io-diagnostics/settings.toml", "note": "",
 "limits": {"history_window_s": {"min": 60.0, "max": 86400.0, "default": 900.0}}}

What a page must NOT assume