A harness never draws anything. It emits events and accepts commands, both declared in src/protocol.ts. A view folds the events into state and sends commands back. Because the harness only ever sees that stream, the same program runs in a terminal, a desktop window and a browser, and the same React view serves the last two.
harness ── events ──▶ bridge ──▶ HarnessProvider ── reduce ──▶ AppState ──▶ your view
◀─ commands ─ ◀────────────────────────── useSend ───────────The three parts
| Part | File | What it is |
|---|---|---|
| The protocol | src/protocol.ts |
WorkflowEvent — what the harness says; Command — what a view may ask. Yours, and node-free, so a view imports it without the harness. |
| The fold | src/ui/state.ts |
reduce(state, event) → AppState: pure, immutable, node-free. Every surface imports this one function. |
| The view | src/ui/App.tsx (desktop, web), src/ui/cli.tsx (terminal) |
Renders AppState and sends commands. Holds no truth, never calls the model, never reads the wire directly. |
Mount it
Each surface's entry is the same few lines over the bridge that surface has — IPC on the desktop, a WebSocket in the browser — already installed as window.harness:
import { createRoot } from "react-dom/client";
import { HarnessProvider } from "@lloyal-labs/ui";
import { HarnessApp } from "../../src/ui/App.js";
import { initialState, reduce } from "../../src/ui/state.js";
createRoot(document.getElementById("root")!).render(
<HarnessProvider bridge={window.harness} initialState={initialState} reduce={reduce}>
<HarnessApp surface="web" />
</HarnessProvider>,
);The provider owns the fold: it seeds from the bridge's snapshot, holds events until that lands, and re-seeds when the stream starts over — a reconnected socket, or a desktop engine replaced. It also puts the platform's own screens, such as the first-run installer, in front of your view.
Read and send
import { useAvailability, useProjection, useRecover, useSend } from "@lloyal-labs/ui";
export function HarnessApp({ surface }: { surface: string }) {
const state = useProjection<AppState, AppState>((s) => s);
const availability = useAvailability();
const send = useSend<Command>();
const recover = useRecover();
const submit = (query: string) => send({ type: "submit_query", query });
// …render state.answer, state.roster, state.library …
}| Hook | Gives you |
|---|---|
useProjection(select) |
A derivation of the folded state, memoized per fold |
useSend<Command>() |
A function that sends a command to the harness |
useAvailability() |
Whether the harness can take work now: connecting, queued, warming, ready, ended or lost. Prefer it to useConnection(), which is only the transport's half of the answer. |
useRecover() |
Ask the placement for a working harness again — a new connection in a browser, a new engine on the desktop — or null where the bridge cannot |
useHarness() |
The bridge itself, for what the hooks do not cover |
queued and warming are real states on a shared host: a reader may be waiting for a seat before their session starts. See Serve to many users.
Fold agent activity
What each agent is doing is the platform's to fold, so the model's own markup never reaches your state:
import { emptyRoster, foldAgents } from "@lloyal-labs/ui/fold";
export function reduce(s: AppState, ev: WorkflowEvent): AppState {
if (isAgentEvent(ev)) { // the scaffold's own type guard over WorkflowEvent
const roster = foldAgents(s.roster, ev, {
spawn: (spawn) => spawnDecision(s, spawn), // which agents get a timeline, and which task each is
terminal: RIG_REPORT.tool, // the call that ends a turn is not a timeline row
terminalField: RIG_REPORT.field, // the report streams live from this argument
});
return { ...s, roster };
}
// …your own events: ready, query, answer, library …
}For streaming markdown — an answer arriving token by token — splitStreaming from @lloyal-labs/ui/prose renders the finished blocks once and re-renders only the tail. It is a rendering fix for a rendering cost: never reach for a parser.
Add an event or a command
- Add it to the union in
src/protocol.ts. - For an event:
yield* wire.send(...)it from the harness, and handle it inreduce. - For a command: add a handler in
src/harness/article.ts(the loop dispatches bytype), andsendit from the view.
A command no handler takes is reported on the wire as Nothing in this app handles "…", and the run is left alone.
Related
- Where a harness runs — the surfaces and placements behind the bridge.
- Serve to many users — the host that browsers connect to.