Skip to content

Host protocol ​

The Go API (Run) is for a Go host. tuimark host (0.3b) is for everyone else: it runs a document in the terminal exactly as Run() would, while a parent process in any language supplies the data and answers the actions, over a small JSON line protocol. The child lays out, paints, and decides everything; the parent only speaks the protocol.

This is different from shelling out to dump/validate/play: those are one-shot batch calls. tuimark host is interactive — it holds the terminal open for a whole session, the way Run() does.

When to reach for it ​

  • You want a Tuimark view in front of a program written in TypeScript, Python, Rust, or anything else, without a second host runtime for that language. (The SPEC only ever recognizes one host implementation, the Go one; a parent over this protocol is not a second host — it lays out and paints nothing itself.)
  • You want a thin process boundary between "what the terminal shows" and "what has the data and the business logic" — the parent can be the same process that already talks to your database or your API.

If your host is already Go, use Run directly; it is simpler and has no protocol to speak.

Starting it ​

sh
tuimark host FILE [--data FILE.json] [--theme dark|light] [--reply-timeout 5s]
  • --data binds a JSON file as the store, exactly as dump --data does; the parent typically overwrites and extends it with set/batch messages once the session is running.
  • --theme is applied as Set("@theme", …) before the first frame; TUIMARK_THEME still wins over it, as it does everywhere else Run() reads the environment.
  • --reply-timeout (default 5s, from 100ms to 1m) bounds how long a host action waits for the parent's reply before the session moves on.

The transport is POSIX only: on Windows, host is a usage error, because passing extra file descriptors to a child process needs os/exec's ExtraFiles (Unix) or an equivalent Windows has no counterpart for here.

Transport: fd 0-2 and fd 3/4 ​

The child inherits the terminal unchanged, on file descriptors 0, 1, and 2 — it reads keys and the mouse from stdin and writes frames to stdout exactly as Run() does. The protocol never touches those three.

The parent passes two more descriptors when it spawns the child:

  • fd 3, which the child reads: parent → child.
  • fd 4, which the child writes: child → parent.

Every message is one UTF-8 JSON object per line (\n-terminated), with a type member. tuimark host checks that fd 3 and fd 4 are pipes or sockets, open for reading and writing respectively, before it loads the document or touches the terminal; a parent that passes nothing there gets a usage error, not a hang.

In Go, pass the two ends in exec.Cmd.ExtraFiles. In Bun (see the excerpt below), stdio: ["inherit", "inherit", "inherit", "pipe", "pipe"] gives the parent proc.stdio[3]/proc.stdio[4] as the raw descriptor numbers.

Messages ​

Parent → child (fd 3) ​

typeMembersEffect
set, bindpath, value, optional idSet(path, value). bind is an alias of set.
batchwrites: [{"path","value"}…], optional idBatch, all or nothing
getid, pathGet(path)
replyseq, optional quit: true, optional error: "msg"answers the event numbered seq

A message that carries an id (a number or string you choose) gets an ack back; one without id gets nothing on success, and an error on failure, so no failure is silent. That includes a reply: one with an id that answers the waiting event gets {"type":"ack","id":…,"ok":true} before the loop resumes (so it comes before the exit a quit reply leads to), and every error about a reply (late, for an unknown seq, or malformed) carries the reply's id when it had one, next to its seq. A seq is any JSON integer (1, 1.0, and 1e0 all answer event 1); a seq echoed in an error is written exactly as you sent it. Messages are applied in the order they arrive from one reader, so writes sent before a reply are applied before that reply is processed — the frame drawn after the handler returns shows them.

Child → parent (fd 4) ​

typeMembersWhen
readyprotocol, version, cols, rows, theme, catalog, diagnosticsonce, always first
eventseq, action, source, keys, valuea host action fired
ackid, ok, optional error, found, valueanswers a message that had an id
errorerror, optional id, optional seqa malformed message, a failed write without id, a reply timeout, or a late/unknown reply
exitreason (quit, eof, signal, error), optional signal, optional erroronce, always last

ready is the handshake: protocol is 1 (check it — a parent must stop on a value it does not know), version is the bare runtime version, cols/rows/theme are the session's starting size and resolved theme, catalog lists every action the document names (the same shape as Catalog()), and diagnostics holds the document's static problems.

Every non-built-in action in the catalog becomes an event: the child writes it with a fresh seq (1, 2, … per session) and waits for a reply with that seq, up to --reply-timeout. While it waits, keys keep queuing (dispatched after the handler returns) and set/batch/get keep being served, so the parent can read and write state before replying. {"type":"reply","seq":N} alone resumes the loop; "quit": true ends the session with reason quit; "error": "msg" ends it with reason error (which wins when both are present). A reply that never arrives becomes an error on fd 4 and the loop moves on — the parent lost that one event, but the session keeps going.

When the session ends, exit is the last message: reason is "quit", "eof" (the terminal's input ended, or the parent closed fd 3), "signal" (with signal: "SIGTERM", "SIGHUP", or "SIGINT"), or "error" (with error). The process exits 0 for quit/eof, 1 for error, and 128 + n for signal.

Backpressure. The child writes fd 4 through a queue of at most 1024 messages. A parent that stops reading, or a write that fails, ends the session with reason error — so a parent that stops reading holds the loop for at most the reply timeout, and the terminal is always restored, never held hostage by a stuck pipe.

From TypeScript ​

For a Bun program, use @thelacanians/tuimark (0.4) instead of speaking the protocol yourself. It follows every rule below, correlates the acks, and turns each event into a call to your handler:

ts
import { spawnHost } from "@thelacanians/tuimark";

const ui = await spawnHost("view.tui", { data: "sample.json" });
let count = 0; // the parent owns the model; the view only shows it
ui.on("inc", async () => {
  await ui.set("count", ++count); // applied before the reply
});
process.exit((await ui.exited).code);

examples/host-ts/parent.ts in the repository is a fuller version of this program, with its error handling.

A parent without the client ​

In another language, you speak the protocol yourself. This Bun excerpt does it by hand, so the transport is visible. It drives examples/host-ts/view.tui, a document with one action, inc, and answers it by writing the new count back to the store:

ts
import { closeSync } from "node:fs";

const proc = Bun.spawn({
  cmd: ["tuimark", "host", "view.tui", "--data", "sample.json"],
  stdio: ["inherit", "inherit", "inherit", "pipe", "pipe"],
});

const toChild = Bun.file(proc.stdio[3] as number).writer();
const send = (msg: object) => {
  toChild.write(`${JSON.stringify(msg)}\n`);
  void toChild.flush();
};

let count = 0; // the parent owns the model; the view only shows it

async function readMessages() {
  const reader = Bun.file(proc.stdio[4] as number).stream().getReader();
  const decoder = new TextDecoder();
  let buf = "";
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    buf += decoder.decode(value, { stream: true });
    for (let nl = buf.indexOf("\n"); nl >= 0; nl = buf.indexOf("\n")) {
      const msg = JSON.parse(buf.slice(0, nl));
      buf = buf.slice(nl + 1);
      if (msg.type === "event" && msg.action === "inc") {
        count++;
        send({ type: "set", path: "count", value: count }); // applied before the reply below
      }
      if (msg.type === "event") send({ type: "reply", seq: msg.seq }); // every event gets a reply
    }
  }
}

for (const sig of ["SIGTERM", "SIGHUP"] as const) {
  // after a grace, and only if the child is still running (see the rules below)
  process.on(sig, () => setTimeout(() => proc.exitCode === null && proc.kill(sig), 1500));
}

await readMessages();
const code = await proc.exited;
closeSync(proc.stdio[3] as number); // FileSink.end() leaves the descriptor open
closeSync(proc.stdio[4] as number); // and so does reading fd 4 to its end
process.exit(code);

fs.readSync/fs.createReadStream fail on the raw socket-pair descriptors Bun hands back for fd 3/4 (EAGAIN), and fs.createWriteStream("", { fd }) breaks on a write of about 8 KB or more. Read fd 4 with Bun.file(fd).stream() and write fd 3 with Bun.file(fd).writer() (a FileSink, flushed after each message). Closing the descriptor with closeSync gives the child end of file on fd 3; FileSink.end() does not close it. Close fd 4 too once you have read it to the end, or each session leaks a descriptor.

Rules for parents ​

Follow these and a parent behaves correctly under every ending the protocol has:

  • Never touch the terminal. While the child runs, do not read stdin (process.stdin in Bun/Node reads keys the child is trying to read) and do not write stdout — the child owns fds 0 and 1 completely. A parent with its own full-screen UI unmounts it first and remounts it only after exit.
  • Forward SIGTERM and SIGHUP to the child, wait for its exit, and exit with its code. A parent that dies on the signal instead would orphan the child. Forward after a short grace (1.5 seconds), and only if the child is still running: a signal sent to the whole process group (closing the terminal window, systemd's KillMode=control-group) already reached the child, a second signal is fatal to it, and the child would die without writing exit. Forward at once if the same signal arrives again during the wait.
  • End the child with SIGTERM, then SIGKILL after 2 seconds if it has not exited — never SIGKILL first. Run()'s own signal handling restores the terminal within a 1-second grace period; killing it outright skips that and can leave the terminal in raw mode.
  • Check protocol in ready, and treat a child that exits without ever sending exit as an error — a well-behaved child always sends it last.

Testing a parent without a terminal ​

You don't need a pty to test how your parent reacts to events: tuimark play FILE --format json prints the same event records ({step, action, source, keys, value} minus step) that the host protocol's event messages carry. Write your parent's event handler against that shape, and test it directly; save the pty-and-real-child testing for one or two end-to-end checks.

See also ​

  • TypeScript client — @thelacanians/tuimark, the protocol from Bun.
  • Go API — Run(), for a Go host that doesn't need a second process.
  • MCP server — for driving the authoring loop (validate/dump/play) from an agent, not running a live session.

Released under the MIT License.