The host harness, tools/vios_harness.py, connects your Mac to the board. It is the only program that opens the board's USB serial port. Everything else on the Mac reaches the board through it: the human CLI (tools/vios_cli.py), MCP clients such as Claude, and the on-board acceptance suite. VIOS runs fine without it. You need the harness only when you want to drive the board from the Mac.
How it fits together
MCP client (Claude) tools/vios_cli.py acceptance scripts
| stdio | TCP | TCP
| (via nc) | |
+-------------+-------+--------------------+
| newline-delimited JSON-RPC on 127.0.0.1:8765
tools/vios_harness.py (one process, one lock)
| COBS/CRC frames, 115200 8N1
/dev/cu.usbserial-XXX (CH340 on the board)
|
ESP32 USB multiplexer -> eZ80 Forth REPL, supervisor, events
- One serial owner. The harness opens the port once and keeps it open. All tool calls go through one lock, so requests from the CLI and from Claude never interleave on the wire. Do not open the port with anything else while the harness runs. That includes a serial terminal,
esptool,tools/vios_firmware_ab.pyandtools/vios_sd_refresh.py. The port is opened exclusively, so a second opener fails anyway. - Opening the port resets the board. On this board, opening the USB serial device resets it. The first
connectcan therefore take a full boot (allow up to 120 s), and every harness restart is also a board restart. Unsaved RAM work is lost when that happens. - Two front ends. With
--tcp 127.0.0.1:8765the harness serves newline-delimited JSON-RPC (one JSON object per line) on loopback TCP. Several clients can connect at once and share the board. Without--tcpit speaks MCP over its own stdin/stdout, and only that one client can use it. - Loopback only, no authentication. The TCP server refuses any bind address other than
127.0.0.1. Anyone who can open a socket on your Mac can drive the board, so do not forward this port. - The protocol. It is JSON-RPC 2.0 with MCP lifecycle version
2025-11-25. On TCP the transport is a plain line protocol, not MCP Streamable HTTP. Frames are limited to 1 MiB. - JSON stays on the Mac. The board never parses JSON. Over USB the harness and the ESP32 exchange compact binary frames: console, control, events, host-file requests and link requests each have their own channel. See Architecture.
Starting the harness
Find the board's port first, then start the harness against it:
ls /dev/cu.usbserial-*
lsof /dev/cu.usbserial-210 # must print nothing: nobody owns the port
lsof -nP -iTCP:8765 -sTCP:LISTEN # must print nothing: no harness running
.venv/bin/python tools/vios_harness.py --backend resident \
--port /dev/cu.usbserial-210 --tcp 127.0.0.1:8765
The harness prints VIOS /host/ serves /Users/you/VIOS-host and VIOS TCP listening on 127.0.0.1:8765 on stderr. It does not touch the board until a client calls connect.
Always pass --port. The default (/dev/cu.usbserial-224130) is a suffix from an earlier USB setup. The suffix changes with the USB socket or hub you use, so check ls /dev/cu.usbserial-* each time.
| Flag | Default | Meaning |
|---|---|---|
--backend resident |
ram |
Use the installed VIOS firmware. Always pass it. The ram default is the historical bring-up bridge that uploads a temporary RAM image. |
--port DEV |
/dev/cu.usbserial-224130 |
Serial device of the board. |
--tcp 127.0.0.1:PORT |
stdio | Serve loopback TCP instead of MCP stdio. Only 127.0.0.1 is accepted. |
--host-root DIR |
~/VIOS-host |
Mac directory the board sees as /host/. Pass --host-root "" to switch the service off. |
--image FILE |
build/vios-resident.bin |
CPU image used only by the explicit deploy tool. |
--log FILE |
docs/bring-up/vios-harness.jsonl |
Raw serial evidence log (every byte, timestamped). Git ignores this file. |
--fast-console |
off | Historical RAM-bridge option. Resident source always uses atomic framed lines. |
Stop the harness with Ctrl+C. When TCP clients disconnect, the harness and the board session keep running.
Connecting Claude (MCP)
The simplest arrangement is one TCP harness shared by you and Claude. Claude's MCP stdio is bridged to the running harness with nc. The bridge opens no serial port of its own:
{
"mcpServers": {
"vios": {
"command": "/usr/bin/nc",
"args": ["127.0.0.1", "8765"]
}
}
}
If no human CLI is needed, the harness can be the MCP server itself. Stop any TCP harness first, because two owners cannot share the port. Use absolute paths:
{
"mcpServers": {
"vios": {
"command": "/path/to/agon-light-2/.venv/bin/python",
"args": ["/path/to/agon-light-2/tools/vios_harness.py",
"--backend", "resident", "--port", "/dev/cu.usbserial-210"]
}
}
}
The client's first board call must be connect. Listing tools does not open the port.
A raw TCP session looks like this, one JSON object per line:
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"me","version":"1"}}}
{"jsonrpc":"2.0","method":"notifications/initialized"}
{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"connect","arguments":{}}}
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"evaluate","arguments":{"source":": SQUARE DUP * ;\n7 SQUARE . CR"}}}
Each tool result is a JSON document carried in one text content item. isError is true when the tool failed or the Forth code raised an error. Malformed requests or arguments get JSON-RPC errors. The schemas returned by tools/list are the authority for argument limits.
The CLI
tools/vios_cli.py is a plain terminal console that attaches to the TCP harness:
.venv/bin/python tools/vios_cli.py # harness on 127.0.0.1:8765
.venv/bin/python tools/vios_cli.py --port 8765 --timeout 30
Its --port is the TCP port of the harness (default 8765), not the serial device. The CLI has no serial code at all. The other flags are --timeout and --rpc-timeout. --timeout (default 15 s, at most 300) is how long one Forth line may run before the CLI reports it as pending. --rpc-timeout (default 130 s) is how long the CLI waits for any harness reply, and it covers a full board boot.
On start the CLI calls connect and prints VIOS console — :help for commands. Then you type Forth lines. Each line is at most 235 printable ASCII bytes, and tabs become spaces. The prompt shows the session state:
| Prompt | Meaning |
|---|---|
vios> |
Idle. Type Forth. |
compile> |
A colon definition is unfinished. Finish it with ; or cancel with :abort. |
pending> |
The last line is still running (for example waiting in KEY). Use :input and :await. |
uncertain> |
A request failed in a way that leaves the result unknown. Use :await or :recover. |
Tab completes words from the live dictionary. Lines that start with : are CLI commands:
| Command | What it does |
|---|---|
:help [NAME] |
Without a name, list these commands. With a name, explain a word or command from the live system (kind, help text or SEE source). |
:complete PFX |
List dictionary words starting with PFX. Tab does the same at the prompt. |
:graph |
Module activation order, dependency edges with their CRCs, and dependents. |
:events |
Console text you did not request: lines typed on the Agon keyboard with their results, and console-task output. |
:ls [/PATH] |
List an SD directory (8.3 names, sizes, / for directories). Defaults to /. |
:put LOCAL /PATH |
Upload a local ASCII text file to an SD path (8.3 names). |
:get /PATH [LOCAL] |
Print an SD file, or save it to a new local file (an existing file is never overwritten). |
:describe KIND [ID] |
Typed descriptor: word NAME, module NAME, task N, handle N or error. |
:why |
Explain the last runtime error: message, failing word and offset, callers, and the data stack as it was before unwinding. |
:trace WORD [N] |
Single-step WORD for up to N instructions (default 200) and print each step. |
:status |
Show dictionary and VM resource counters. |
:words |
List all words. |
:wb ACTION |
Workbench open, close, status, view or redraw. |
:snapshot [FILE] |
Summarize live, dirty, staged and persisted state. With FILE, also save the full JSON record (the file must not exist). |
:await |
Keep waiting for a pending line without sending new source. |
:resync |
Recover a lost reply frame at an idle prompt. It never resends your request, so the outcome of that request stays unknown. |
:input HEX |
Send 1 to 16 bytes to a pending line, for example :input 41 for a KEY that waits for A. Hex 1D is reserved. Follow it with :await. |
:abort |
Cancel an unfinished definition at the idle prompt. |
:reboot |
Restart the firmware from a synchronized idle prompt. |
:recover |
Force a supervisor restart even when the session is pending or uncertain. Unsaved RAM work is lost (see below). |
:quit |
Disconnect this client. The harness and the board keep running. |
Two rules keep the CLI honest:
- It never retries. If a request fails halfway, the CLI says so and leaves the result marked uncertain. It never sends the request again.
- Pending execution blocks new source.
KEYgets only the bytes you send with:input.
:recover differs with and without the ZDI wire. With the wire (dev boot), the supervisor halts the eZ80 and reloads the image embedded in the ESP firmware, so all RAM state is gone. Without the wire (untethered boot), the ESP cannot reset the eZ80. :recover then restarts only the ESP32: the resident keeps running with its RAM state, reports itself healthy again when the ESP comes back, and the harness reattaches. If the eZ80 itself is wedged, stop and restart the harness (opening the port resets the whole board) or press the board's reset button. See Troubleshooting.
MCP tool reference
The harness registers 72 tools. Tools marked wire need the ZDI recovery links (a dev boot). On an untethered board the ESP cannot reach the eZ80's debug port.
Session and recovery
| Tool | Description |
|---|---|
connect |
Attach to the running resident firmware. Nothing is uploaded. Waits up to 120 s for BOOT READY. Safe to call twice. |
reboot |
Restart the firmware and reattach on the same port. force=true abandons pending or unknown execution and loses unsaved RAM state. Optional timeout. |
deploy |
wire. Replace CPU RAM with the --image file and verify every byte. A development and recovery operation; normal attachment never needs it. |
resync |
Recover a lost reply frame at a quiet idle prompt without rebooting. Never replays the request; its outcome is reported as unknown. |
abort |
Cancel input or an unfinished definition at the idle REPL. A program stuck in KEY needs console_input or reboot(force=true). |
debug_registers |
wire. Briefly pause the CPU to read its registers, then restore the previous run state. |
memory_read |
wire. Read 1 to 65536 bytes of RAM in 0x040000..0x0BFFFF, CRC-checked. |
memory_write |
wire. Write 1 to 256 bytes below 0x0AF000 and verify by readback. A trusted systems operation: it can break running code. |
resume |
wire. Resume the existing CPU PC after a debug pause. Does not reboot. |
Running Forth
| Tool | Description |
|---|---|
evaluate |
Evaluate ASCII Forth lines (up to 65536 characters per call, 235 bytes per line). Definitions, stacks, BASE and the engine persist across calls; multi-line definitions work. Optional timeout (default 15 s). |
await_result |
Wait for a timed-out evaluation to finish, without sending any source. |
console_input |
Send 1 to 16 bytes (hex) to a pending evaluation, for example the key a KEY waits for. |
events |
Return and clear console text the host did not request: lines typed on the Agon keyboard with their results, and console-task output. |
benchmark |
Run the built-in validated hardware benchmarks (up to 120 s). |
Inspection
| Tool | Description |
|---|---|
words |
List the resident dictionary. |
inspect_word |
SEE source and word metadata (kind, address) for one word. |
stacks |
The data stack, as .S shows it. |
resources |
Dictionary capacity and observed VM resource counters. |
complete |
Complete a word, outer command or compiler word from the live dictionary (prefix, optional limit). |
help |
Explain an outer command or compiler word, or show a live word's kind, metadata and source. |
describe |
Typed descriptor (kind, identity, generation) for one word, module, task, handle, or the last error. |
last_error |
The last foreground runtime failure captured before unwinding: message, failing word and offset, caller frames, top stack cells, depths, engine, BASE. Includes stale=true if the dictionary changed since. |
trace |
Single-step one word (one instruction per dispatch, any engine) and return each step: word, offset, depths, top of stack. The word's side effects happen as usual. |
tasks |
The four background task slots: state, word, slices, executed steps, stack depths and peaks, output bytes and drops, error. |
task_output |
Drain one background task's output ring (id 1 to 4). |
handles |
Open handles (SD files, pipes, /dev/null, /dev/cons, /dev/tasks) with owner, direction, byte counts and the last I/O error. |
snapshot |
Read-only structured record of live state: modules with exact sources, draft, stacks, BASE, engine and boot slot, plus a live/dirty/staged/persisted summary. Fails closed on a torn read. |
Modules and persistence
| Tool | Description |
|---|---|
modules |
List module versions, CRC32 values (decimal) and pinned dependencies. |
module_source |
Exact retained module source plus a host-computed SHA-256 (previous=true for the retained older generation). |
module_stage |
Validate and stage exact module source with pinned dependencies and an optional test word. Does not activate. |
module_activate |
Activate the staged module after validating it again. |
module_discard |
Discard the staged module. |
module_rollback |
Restore a module's retained previous generation. |
module_save |
Save a module package to SD, by default at its content-addressed path under /VIOS. |
module_load |
Load a saved package from SD, then stage and activate it. |
module_graph |
Dependency edges with CRC satisfaction, reverse dependents and activation order. |
develop |
One explicit cycle: pin named live dependencies, stage (validate and test), activate, optionally save. Reports each step and stops at the first failure without retrying. |
load |
Evaluate a raw Forth source file from SD (or from /host/). |
boot_status |
Confirmed, candidate and current boot slots and the trial state. |
boot_manifest |
Read-only: what a power cycle would load. Lists module name, version, source CRC32 and package health in the confirmed and candidate boot bundles on SD. |
boot_trial |
Write all active modules into the inactive boot bundle and mark it pending. Does not reboot. |
boot_confirm |
Confirm the currently running trial after validation. |
boot_revert |
Select the confirmed or previous boot slot for the next boot. Does not reboot. |
SD card
| Tool | Description |
|---|---|
sd_list |
List an SD directory: 8.3 names, sizes, directory flags. |
sd_put |
Upload text or hex bytes to an SD file through a handle (create or replace; not power-fail atomic). |
sd_get |
Download an SD file of up to 64 KiB as text and hex, with its SHA-256. |
Workbench (text editor)
These are the headless equivalents of the workbench keys. Edits are checked against the draft revision so that a stale client cannot overwrite newer work.
| Tool | Description |
|---|---|
wb_status |
Draft revision and workbench state. |
wb_open |
Open the workbench, keeping any existing draft. |
wb_close |
Close the workbench and keep its draft. |
wb_browse |
Browse MODULES or WORDS, optionally filtering words by module. |
wb_select |
Select a module by name or a word by catalog index. |
wb_edit |
Load a module's exact active source into the draft. A dirty draft is replaced only with discard=true. |
wb_new |
Start a new module draft (name and version). A dirty draft is replaced only with discard=true. |
wb_patch |
Apply one atomic, revision-checked patch of at most 80 ASCII bytes (offset, remove, source). |
wb_undo |
Restore the one source and cursor undo snapshot. |
wb_metadata |
Set the draft's version and optional test word, keeping pinned dependencies. |
wb_stage |
Validate and test the exact draft without activating it. |
wb_activate |
Activate the staged draft after revision and catalog checks. |
wb_save |
Save the active draft to SD. |
wb_source |
Read the exact draft text in bounded chunks with revision checks. |
wb_view |
Read rows of the 80-column workbench screen model with text and packed attributes. |
wb_key |
Inject a semantic workbench key (ASCII, or 256 to 267 for special keys) with modifier bits. |
wb_redraw |
Invalidate and recompose the workbench screen. |
wb_flush |
Send changed screen spans in bounded batches and return the flush counters. |
wb_render_stats |
Cumulative renderer counters and 288 kHz timer ticks (reset=true zeroes them). |
ESP32 side (display, keyboard, audio, link)
These read the ESP32 directly over the control channel. The eZ80 keeps running and is not involved.
| Tool | Description |
|---|---|
esp_stats |
ESP counters: glyphs drawn, USB console bytes and writes, time the VDP parser was blocked, parser time, frame and vblank timing, and the USB link counters link_requests, link_resent and link_gone. reset=true zeroes them after reading. |
esp_console |
The exact text of the console as the ESP renders it (rows 0 to 57 plus status row 58), read without disturbing the screen. |
esp_gui |
Display server status line (windows, frames, errors, style). With vk, ascii and mods it presses and releases one key at the window manager. With pixels it returns framebuffer colour indices at up to 64 points. |
esp_inject |
Type 1 to 32 characters at the PS/2 boundary, as if on the Agon keyboard (printable, BS, TAB, CR, ESC). The resulting lines appear in events. |
esp_audio |
Audio engine state, read without the CPU: busy voices, master volume, notes started, last tune error, per-voice frequency, wave and position, sample rate and free heap. |
esp_probe |
Key-to-glyph latency probe. With char, arm it for one printable character; without, report n/min/median/p95/max in microseconds. |
esp_g0 |
GUI memory and timing probe (gate G0): heap after a mode change, blitter benchmark, scanline map and tear test. A diagnostic that draws on screen and then restores the console. |
Sharing files with the board: /host/
While the harness runs with the resident backend, any path that starts with /host/ on the board refers to a file under --host-root on your Mac (default ~/VIOS-host, created if missing). You can edit on the Mac and run on the Agon:
LOAD /host/app.fth
OPEN, READ, WRITE and CLOSE work on /host/ paths too, like SD paths (see Handles, Pipes and Devices). The service has these limits:
- Paths are relative to the host root. Absolute paths,
..and symlinks that leave the root are refused. - At most four host files are open at a time.
- Each board request carries at most 240 bytes and waits about one second for the Mac's reply.
/host/exists only while the harness is running and idle enough to answer. Without the harness the board reports an error for/host/paths. Everything else keeps working.
Snapshots and resync
snapshot(CLI:snapshot FILE) records what is live, dirty, staged and persisted: every module with its exact source, the editor draft, the stacks,BASE, the engine and the boot slot. Take one before anything risky, such as a reboot, a firmware install or an experiment. Thepersistedflag means the module's exact name and CRC32 are in the confirmed boot bundle on SD, so a power cycle brings it back. RAM-only definitions are not persisted.resync(CLI:resync) re-establishes framing when a reply was lost. One example is a byte from the physical keyboard merging into a request. It works only at a quiet idle prompt and never resends the request, because that request may or may not have run.
The USB link itself already recovers lost frames: the ESP numbers every frame it sends and resends frames on request (see Architecture). The harness also repeats one read-only tool once after a resync: snapshot, tasks, handles, last_error, sd_list, sd_get, boot_status, boot_manifest, esp_stats or esp_console. Such results carry wire_loss_recovered: true. Mutating tools are never repeated.
The on-board acceptance suite
tools/vios_board_suite.py runs the acceptance checks on the real board through the TCP harness on 127.0.0.1:8765. Start the harness first (as above), with the board booted into the GUI. The suite has no serial-port option of its own.
.venv/bin/python tools/vios_board_suite.py build/suite-$(date +%Y%m%d)
.venv/bin/python tools/vios_board_suite.py build/suite-gui --only gui,gui_tools
.venv/bin/python tools/vios_board_suite.py build/suite-soak --soak-minutes 30
Each check writes its own JSON report into the output directory. The runner writes suite.json with one status per check and exits non-zero if any check failed. Checks that need an idle task table pause and later restart a running AUTORUN task.
| Check | Script | What it covers |
|---|---|---|
gui |
vios_gui_accept.py |
Display server: a decorated window is opened, moved, raised and closed (pixels checked); a Ctrl+S accelerator becomes a COMMAND event; an input and a button widget; no protocol errors; frame work and vblank start within budget. Uses window and surface 3 only. |
gui_tools |
vios_gui_tools_accept.py |
The GUI tools via injected keys: browser (Ctrl+Shift+B), workspace (Ctrl+Shift+W), inspector (Ctrl+Shift+I), manager (Ctrl+Shift+M, an action is cancelled with Esc); text read-back speed; frame budgets. |
console |
vios_console_accept.py |
The full-screen text console, read back from the framebuffer with CHAR@; editor and console round trips. Switches with Ctrl+Alt+F1 and back. |
audio |
vios_audio_accept.py |
TONE, stored tunes, tune errors with slot and offset, looping tunes and STOP, BEEP acknowledgement, VOLUME, ESP heap. Ends with a short melody for you to listen to. |
core_words |
vios_core_words_accept.py |
The DIRECT, TOKEN and C reference engines agree on a set of core-word cases. |
tasks |
vios_tasks_accept.py |
Background tasks, preemption and console latency. |
pipeline |
vios_pipeline_accept.py |
A producer's output feeding a consumer's KEY through a pipe. |
handles |
vios_handles_accept.py |
Handles, pipes and devices; writes and removes /VIOS/IOTEST.TXT. |
tools |
vios_tools_accept.py |
Source maps, the SD file service and native TYPE; writes and removes /VIOS/SVCTEST.TXT. |
incremental |
vios_incremental_accept.py |
Workbench compositor regressions. Replaces the current draft, so it runs only with --with-incremental. Save your draft first. |
None of the default checks changes the boot slots, saved modules or the workbench draft. They define temporary RAM words, so take a snapshot first if your RAM session matters. --soak-minutes N adds tools/vios_soak.py afterwards. The soak repeats REPL arithmetic, a snapshot, a 1000-byte pipeline, an editor round trip and, every tenth iteration, an SD round trip, and it stops at the first failure. On firmware main-51 all nine default checks pass.
A few older drivers open the serial port themselves, so stop the harness before you run them. Each takes --port:
tools/vios_resident_acceptance.py. Itsbootphase changes the SD boot slots and refuses a card with a confirmed user bundle.tools/vios_resident_recovery.py --exercise-resets. This deliberately discards RAM state.tools/vios_coldboot_capture.py. This records cold boots without sending anything.