Skip to content

jetkvm

Recipe card from the charly-check plugin (Commands — runtime CLI verbs).

JetKVM — IP-KVM control from a jetkvm: check verb

Section titled “JetKVM — IP-KVM control from a jetkvm: check verb”

jetkvm: is a DECLARATIVE check/control verb — authored as jetkvm: <method> inside a candy/box plan check:/run: step. It is NOT a host charly check subcommand: there is no charly check jetkvm. The verb’s implementation and its WebRTC/HID client dependency live in the out-of-tree candy/plugin-jetkvm plugin module; at check time the host dispatches jetkvm: through the provider registry to that out-of-process plugin, exactly like vnc:/adb:/cdp:.

It drives a JetKVM (https://jetkvm.com) with no browser, over the device’s own control plane: local login (POST /auth/login-local) → the signaling websocket (/webrtc/signaling/client) → the rpc JSON-RPC 2.0 data channel and the binary hidrpc input channel, with the H.264 video track decoded to a PNG by ffmpeg (required on the host).

The method name is the scalar value for a bare-method step (jetkvm: status), or the method: key of the jetkvm: map when the step carries jetkvm-exclusive fields (host:, x:, y:, text:, key:, artifact:, …) — those live INSIDE the jetkvm: map. Only the shared matchers (stdout:, stderr:, exit_status:) and context:/id:/timeout: stay siblings. All jetkvm: steps are deploy-context only (they need a reachable device), so author them with context: [runtime].

- check: the JetKVM is reachable and its control channel answers
context: [runtime]
jetkvm:
method: status
host: jk.example.ts.net
stdout:
- contains: "rpc: true"
- check: a fresh framebuffer is captured as a real PNG
context: [runtime]
jetkvm:
method: screenshot
host: jk.example.ts.net
artifact: /tmp/kvm.png
artifact_min_bytes: 20000

READ-ONLY BY DEFAULT — the device-safety gate

Section titled “READ-ONLY BY DEFAULT — the device-safety gate”

A JetKVM is a physical appliance wired into a real machine’s keyboard, video and power. Unlike a container it is NOT disposable, and a stray keystroke or power action has real consequences. So every mutating method requires allow_control: true; without it the step reports a documented skip naming the gate rather than acting. factory-reset and update are refused even WITH allow_control, because no unattended plan may wipe or reimage a device.

The classification is an allowlist, so a method added later is mutating-by-default and cannot touch a device until it is deliberately classified.

Read-only (no gate): status, screenshot, ocr, version, video-state, usb-state, atx-state, dc-state, virtual-media-state, storage-files, wol-devices, macros, keyboard-layout, timezones, cloud-state, network-state, network-settings, tailscale-status, update-status, devmode-state, ssh-key, tls-state, extensions, public-ip, diagnostics, check-media-url, usb-config.

Mutating (need allow_control: true): key, type, key-combo, macro, click, mouse, move, scroll, drag, install, power, dc-power, reboot, wol, wake-host, virtual-media, usb-device, usb-emulation, set-settings, set-edid, set-video, set-display, set-audio, set-network, set-tailscale, set-devmode, set-ssh-key, set-tls, set-keyboard-layout, set-macros, set-jiggler, set-extension, set-wol-devices, set-log-level, renew-dhcp, rpc.

Never autonomous: factory-reset, update.

wake-host vs wol: wake-host sends the device’s own USB HID wake report (upstream’s wakeHost RPC) and wakes a host that is display-asleep (DPMS); wol sends a magic packet and only helps a host that is powered off. A screenshot whose diagnostics boundary is “zero RTP”/“RTP stalled” (the device sent no media — a host with no HDMI signal) now appends a hint naming these two methods instead of a bare frame-wait timeout.

Screen reading — ocr and artifact_contains_text

Section titled “Screen reading — ocr and artifact_contains_text”

Two wait-for-screen primitives, both running tesseract on the HOST (the plugin process is host-side, so the captured bytes are local — no venue round-trip):

  • ocr (read-only) captures a frame, OCRs it, and asserts text is present (case-insensitive). The method form: no artifact required.
    - check: the installer greeter is showing
    jetkvm: {method: ocr, text: "Press Return to Start Install"}
    context: [runtime]
    eventually: 120s
    retry_interval: 5s
  • artifact_contains_text (on screenshot) runs the SDK’s shared artifact validator over the pulled PNG — the same OCR assertion in screenshot form, composable with artifact_min_bytes / artifact_not_uniform.

Failing to RUN is not failing to MATCH: a missing tesseract (or missing tesseract-data-eng) returns a named error, never a vacuous “not found”.

The configurable console-installer driver — install + kind: jetkvm

Section titled “The configurable console-installer driver — install + kind: jetkvm”

install (mutating) drives a text-console OS installer end to end with nobody at the keyboard: it connects ONCE and walks an ordered steps: recipe, OCR-waiting for each screen’s anchor before sending that screen’s key/type/combo input.

WHY ONE CONNECTION: the verb opens a fresh WebRTC session per dispatch call (0.8–1.6 s connect, a 1.5–6.7 s /webrtc/signaling/client handshake per capture). A ~15-screen wizard as a chain of check: steps pays that per screen; the driver pays it once.

The recipe is DATA, supplied by a kind: jetkvm device entity — the plugin is installer-agnostic (R3), so one plugin drives any text console. Example:

omarchy-kvm:
jetkvm:
installer:
answer_secrets:
password: OMARCHY_KVM_PASSWORD # credential store — never plaintext
answers:
username: omarchy
hostname: omarchy
steps:
- wait_for: Press Return to Start Install
action: key
key: Return
- wait_for: "Username>"
action: type
text: "{{username}}"
# ...
  • wait_for MUST be screen-UNIQUE. A string present on every screen (an OS logo, a window title) passes vacuously and desynchronises the whole drive — measured on Omarchy, whose logo OCRs on every wizard screen.
  • action: is key / type / key-combo; an action-less step is a pure wait.
  • {{name}} placeholders in text: resolve from answers: (literal, single-pass — NOT charly ${VAR} expansion, which does NOT reach plugin_input).
  • answer_secrets: maps a placeholder name to a CREDENTIAL-STORE KEY, resolved at run time, so a password or LUKS passphrase never lands in charly.yml.
  • timeout_sec: bounds each screen’s wait (default 120; the final reboot step wants a large value).

A step can also reference the entity by name (install: {device: omarchy-kvm}), resolved out-of-process over the reverse channel; authored step steps:/answers: win over the entity’s.

Authoring a kind: jetkvm entity needs a load-time anchor. A flat external kind connects only when its plugin candy is in the LOAD-TIME candy scan closure (a project candy’s candy:/require: ref), because kind decode runs at the loader walk’s Boundary — BEFORE any deploy tree exists. A deploy-node add_candy: is too late for a kind (it connects at the deploy/check walk, the VERB path), even though the parse-time prescan recognizes the word. So a project authoring a kind: jetkvm entity must also reference the plugin from a discovered candy (see distro-omarchy’s candy/omarchy-kvm).

The driver is NOT a backup of the machine. A console installer wipes a disk. The machine behind a KVM is never disposable, so an install drive is a DELIBERATE OPERATOR ACTION — not a disposable: true CI bed. Author the read-only OCR pre-flight as a check: and the destructive drive as a separate, operator-run deploy.

rpc invokes a device JSON-RPC method the plugin has not typed yet: author rpc_method: and optionally rpc_params: (a JSON object). It is deliberately MUTATING — it can invoke ANY device method, so an ungated escape hatch would bypass the safety gate — and requires allow_control: true. Even then it refuses the irreversible reimaging methods (factoryReset, tryUpdate, tryUpdateComponents), matching the never-autonomous rule their typed counterparts obey.

Prefer password_secret: (a key in the verb:credential store, resolved peer-to-peer over the reverse channel) or the JETKVM_AUTH_TOKEN / JETKVM_PASSWORD environment variables. An auth_token: may also be authored directly when a session already exists. Never commit a device password to a charly.yml.

host accepts a bare hostname, host:port, or an explicit http(s):// URL; a bare value defaults to the device’s plaintext scheme. Set insecure: true to accept the self-signed certificate a JetKVM ships by default (needed for an https:// device; also required for the signaling websocket).

The device address is resolved in order: the authored host:, then the JETKVM_HOST environment variable, then the deploy venue’s address. Author no host: and set JETKVM_HOST to keep a device-specific hostname out of a committed plan — the same shape JETKVM_AUTH_TOKEN / JETKVM_PASSWORD already give credentials. No device hostname belongs in a repository.

Input methods: move/mouse are always a pure position move — an authored button: is validated but never sent. click/drag press the button, which defaults to left. x/y (and from_x/from_y) are absolute HID pointer coordinates in [0,32767], not desktop pixels; map a desktop pixel (px,py) on a W×H screen with px*32767/(W-1), py*32767/(H-1) (centre of 1920×1080 ≈ 16384,16384). key and key-combo resolve over the common USB HID Keyboard/Keypad usages — letters a–z, digits 0–9, F1–F12, the navigation/editing keys (Enter, Escape, Tab, arrows, Home, End, PageUp, PageDown, Insert, Delete, Backspace), punctuation, and modifier chords like Control_L+Alt_L+Delete; names outside that set fail with unknown key. Uppercase or shifted symbols (A, !) imply Shift.

NOTE on video: do not gate a screenshot on the device’s getVideoState.ready field. Measured on firmware 0.5.9, ready reports whether the device’s native capture PIPELINE is currently running — which is started per WebRTC session and stopped when the last one disconnects — not whether an HDMI signal is present. It reads false with error:no_signal even while screenshots succeed. The verb’s screenshot method is the authority; it opens the session and waits for a frame.

  • A reachable JetKVM device on the network
  • ffmpeg on the HOST running the check (it decodes the H.264 keyframe to PNG); provided by the layer-ffmpeg candy

A JetKVM runs an armv7l (32-bit ARM) uClibc/BusyBox userland with no package manager, so the distro charly packages cannot be installed on it. To put charly on the appliance, use the opencharly/charly-jetkvm tool, which delivers the published release binary + welded plugin set over ssh:

Terminal window
charly-jetkvm install --host root@jk.example.ts.net
charly-jetkvm verify --host root@jk.example.ts.net
charly-jetkvm status --host root@jk.example.ts.net
charly-jetkvm uninstall --host root@jk.example.ts.net

It downloads the release assets on the HOST (gh, TLS-validated) and streams them over ssh into /userdata/charly — the device’s own wget does not validate TLS, so an on-device download would be MITM-exposed. verify asserts charly version equals the requested CalVer and that a baked command word dispatches project-less.

The same tool derives the jetkvm: verb’s environment from the ONE ssh connection — charly-jetkvm env --host root@<device> --export reads the device’s local_auth_token and prints JETKVM_HOST + JETKVM_AUTH_TOKEN, so a device can be driven with no committed hostname and no hand-copied token:

Terminal window
eval "$(charly-jetkvm env --host root@jk.example.ts.net --export)"
charly check run jetkvm-device-readonly # status + screenshot
charly check run jetkvm-input-probe # move/click/drag/key/type + screenshots

Repair a device with no auth token. Upstream JetKVM mints local_auth_token ONLY on a successful login/setup and never re-mints it at startup, so a password-mode device whose token was cleared (logout, config reset, a crash before save) answers HTTP 401 to every authenticated request. charly-jetkvm auth heals exactly that: it writes a fresh uuid into local_auth_token and restarts jetkvm_app — ONLY when the field is empty, NEVER overwriting an existing token. env --heal repairs then prints:

Terminal window
charly-jetkvm auth --host root@jk.example.ts.net # print the token
eval "$(charly-jetkvm env --host root@jk.example.ts.net --export --heal)"

There is deliberately NO charly mcp serve on the device. The appliance has ~199 MB RAM and one core; running the MCP server forked a full CLI model and exhausted memory, hanging the device. The appliance gets charly only for local project-less verbs. Driving the KVM remains the host-side jetkvm: verb above, which talks to the device’s control plane directly and needs no charly on it.

Console terminal session, flow, LUKS, and boot order — see the canonical skill

Section titled “Console terminal session, flow, LUKS, and boot order — see the canonical skill”

Beyond the one-shot installer wizard, the jetkvm: verb exposes the TRANSPORT-NEUTRAL console actions — open-terminal, run-command (with sudo + expect + prompt_anchors), close-terminal, luks-unlock, flow (continuous OCR-until-condition + if/then/else + case/switch + bounded while, with reference-screenshot outcomes and auto-resume), and boot-order (the OS-side efibootmgr). The SAME actions serve the JetKVM (USB HID) and the VM (SPICE).

The mechanism, the authoring surface, the marker-line/echo-trap and OCR-upscale RCAs, the flow vocabulary, and the measured hardware facts are documented ONCE in the canonical /charly-check:console-automation skill — read it before authoring any console session/flow. This skill covers only the JetKVM-specific device surface (above).

  • /charly-check:check — parent router; the jetkvm: verb dispatches out-of-process via candy/plugin-jetkvm.
  • /charly-check:vnc — the sibling desktop-automation verb (RFB for a container/VM display rather than a physical KVM).
  • /charly-check:adb — the sibling out-of-process device verb.
  • /charly-image:layer — layer authoring; the ordered plan: step list is part of every charly.yml.

MUST be invoked when the task involves a JetKVM device, the jetkvm: check verb, IP-KVM remote control (keyboard/video/mouse or power over the network), or capturing a screenshot from a machine via a JetKVM. Invoke this skill BEFORE reading source code or launching Explore agents.