Architecture
August 9, 2026 · View on GitHub
How a running coding-agent session becomes a moving sprite in the office.
This file is the single source for pixtuoid's architecture overview. It renders on the website at
/architectureand on GitHub (the diagram below is native Mermaid).CLAUDE.md(the agent guide) links here; per-crate "sharp edges" live in each Rust crate'sSHARP-EDGES.md(indexed from its nestedCLAUDE.md; the smaller guides keep theirs inline).
The shape of it
pixtuoid is a Cargo workspace of five crates wired as a strict producer → reducer → renderer pipeline:
pixtuoid-core— the headless library. It has no terminal dependencies (noratatui, nocrossterm); terminal-specific code lives downstream in the binary's thin painters, which render through the engine's seam (pixtuoid_scene::floor::render_floor/pixel_painter::render_to_rgb_buffer). Owns sources, the reducer + scene state, the sprite format, and the grid/walkable vocabulary (the sim geometry — layout/physics/pose — lives inpixtuoid-scene; only the coherence-boundwalkable.rsstays).pixtuoid-scene— the backend-agnostic render + simulation engine: the office world itself (render_to_rgb_buffer, layout geometry, walk physics, pose/motion/pathfinding, the theme model, pets, chitchat, the embedded default pack). It is terminal- AND window-free by crate boundary (noratatui/crossterm/winit/softbufferin itsCargo.toml— compiler-enforced, not just a lint). Depends onpixtuoid-core.pixtuoid— the binary:clapCLI,tokioruntime wiring, and two of the three thin painters over the engine — the TUI renderer (ratatui+crossterm) and thefloatingdesktop window (winit+softbuffer). Depends onpixtuoid-scene.pixtuoid-web— the third painter: a publish-excludedwasm-bindgencrate that renders the same engine into a browser<canvas>(the site's live-office hero). Depends onpixtuoid-scenewith default features off — core'snativefeature (the async source runtime:tokio/notify, the watchers and probes) is disabled, leaving the pure decode/reducer core that compiles towasm32-unknown-unknown. A scripted event loop drives the real reducer; the built artifact is committed undersite/public/wasm/(just gen-wasm).pixtuoid-hook— a tiny shim Claude Code invokes per hook event. It depends on no other crate; it reads stdin JSON, forwards it over a local IPC endpoint — a Unix socket on macOS/Linux, a named pipe on Windows (selected inpixtuoid-hook/src/transport.rs) — and always exits 0 so it can never block your agent.
Dependency direction is one-way: pixtuoid-core ← pixtuoid-scene ← {pixtuoid, pixtuoid-web}. The
engine's render seam (render_floor / render_to_rgb_buffer in pixtuoid-scene)
is the inversion point that keeps the core terminal-free — the same pixel pass
drives the terminal, the desktop window, and a browser <canvas>.
A Source is one of two classes (source/registry.rs's SourceKind):
- an Agent — a transcript- or hook-bearing coding CLI (Claude Code, Codex,
Cursor, …) that produces
AgentEvents →SceneState::agents→ a desk sprite; or - a Daemon — a long-running gateway with no transcript and no desk that
produces
DaemonPresenceUpdates →SceneState::daemons→ one presence-gated wandering mascot per running INSTANCE (OpenClaw supports several isolated gateways per host, keyed on the resolved port), each mascot's motion encoding that instance's liveness.
The OpenClaw gateway is the first daemon — it ambles the office floor as a
lobster (idle), shuttles when a turn is in flight (busy), turns a sickly red
when its model backend is failing (degraded), and walks out when it goes down.
The two classes share the socket and the registry but never the reducer: the
daemon lane below is deliberately AgentId-free.
Data flow
flowchart TB
accTitle: pixtuoid data flow
accDescr: A hook or transcript event flows from a coding agent (Claude Code, Codex) through the pixtuoid-hook shim into pixtuoid-core, where a HookRouter demuxes agent payloads to the Transport-tagged reducer and SceneState.agents. A daemon gateway (OpenClaw) shares the same shim and socket but is routed instead to apply_presence and SceneState.daemons over a sibling channel that bypasses the reducer. The pixtuoid TUI renderer then paints the whole scene through the pixtuoid-scene engine's terminal-agnostic pixel pass and a half-block flush.
CC["Claude Code / Codex<br/>(agent source)"]
OC["OpenClaw gateway<br/>(daemon source)"]
subgraph hook["pixtuoid-hook (shim)"]
SH["enrich + forward<br/>200ms timeout · exit 0"]
end
subgraph core["pixtuoid-core (headless)"]
L["HookRouter<br/>(shared socket: Unix / named pipe)"]
D["decode_hook_payload"]
J["JsonlWatcher · walk_jsonl"]
R["Reducer::apply<br/>(Transport-tagged)"]
AP["apply_presence<br/>(AgentId-free · bypasses Reducer)"]
S["SceneState<br/>agents + daemons"]
L -->|agent payload| D
L -.->|"daemon payload<br/>is_daemon()"| AP
D -->|"(Hook, AgentEvent)"| R
J -->|"(Jsonl, AgentEvent)"| R
R --> S
AP -.->|"PresenceMsg{source, delta}<br/>sibling channel"| S
R -.->|"scope tree:<br/>cascade ↓ · liveness ↑"| R
end
subgraph bin["pixtuoid (binary · TUI)"]
W["watch<Arc<SceneState>>"]
TR["TuiRenderer"]
FL["flush · ½-block cells"]
end
subgraph scene["pixtuoid-scene (engine)"]
PX["render_to_rgb_buffer<br/>(desks + mascots)"]
end
W --> TR --> PX --> FL
CC -->|hook event| SH
OC -->|hook event| SH
SH --> L
CC -.->|writes transcript JSONL| J
S -. Arc per mutation .-> W
Walking the pipeline (real symbols):
- Ingest. Claude Code fires a hook → the
pixtuoid-hookshim (enrich_payloadstamps_pixtuoid_source, a 200 ms watchdog-bounded send, exit 0) →HookSocketListeneron a Unix socket (a named pipe on Windows) →decode_hook_payloadturns the JSON into one or moreAgentEvents — tool/permission payloads are preceded by anIdentityevent the reducer uses to register live-but-invisible sessions with real identity (mid-attach). In parallel,JsonlWatcher→walk_jsonltails each agent's transcript file (with a first-sight gate so historical/ended sessions don't resurrect) and decodes lines via a per-source decoder (decode_cc_line/decode_codex_line). - One channel. Every source multiplexes onto a single
mpsc::Sender<(Transport, AgentEvent)>(buffer 256). TheTransport(Hook|Jsonl) tag is load-bearing: the reducer uses it for hook-wins dedup so a hook and its transcript echo don't double-count. - Reduce.
reducer_taskdrains the channel intoReducer::apply, which updates aSceneState, runs garbage-collection/stale sweeps on a 1 Hz tick, and delegates single-slot transitions to the FSM. After every change it publishes a freshArc<SceneState>on awatchchannel. - Render.
TuiRenderer(in the binary) borrows the latest scene (O(1), no lock) and paints it throughpixtuoid_scene::pixel_painter::render_to_rgb_buffer— a terminal-agnostic pixel pass that lives in the engine crate — thenflush_buffer_to_termcompresses pairs of pixel rows into half-block (▀) terminal cells.
The daemon lane (the OpenClaw gateway). A daemon source creates no
AgentSlot and writes no transcript, so it skips the whole agent pipeline. The
HookRouter at the shared socket reads each payload's source: an agent's
goes to decode_hook_payload; a daemon's (is_daemon()) is decoded by the
source's own presence_decoder into DaemonPresenceUpdates and pushed onto a
sibling channel as PresenceMsg { key, delta } — where the key is a
DaemonInstanceKey { source, instance }, so N concurrent instances of one daemon
route to distinct entries (invariant #2 — NOT the one AgentEvent channel). The
reducer task merges those via apply_presence — which is AgentId-free and
never touches Reducer::apply — into SceneState::daemons. The render pass then
draws one mascot per live daemon INSTANCE, its motion encoding that instance's
DaemonState (Idle / Busy / Degraded / Down).
A daemon has no per-session pid, so silence is its abrupt-down signal (a TTL
sweep, decayed per instance), while each gateway's own process pid is armed for
instant ExitWatch.
Seams & invariants
These are load-bearing — see CLAUDE.md and the nested guides before changing them.
- The
Sourcetrait is the only seam for adding a transcript-bearing agent CLI (Codex, Copilot CLI, Antigravity, …). Per-source format knowledge lives in that source's own decoder functions (injected intoJsonlWatcheras fn pointers), not in a shared decoder. Hook-only CLIs (Reasonix, opencode, Cursor CLI, CodeWhale, Hermes, Kimi Code CLI — none exposes a transcript pixtuoid watches; for Kimi and Cursor CLI one DOES exist but is deliberately unwatched, seecrates/pixtuoid-core/CLAUDE.md) are the documented exception: noSourceimpl and no runtime wiring; their registry rows settranscript: Noneand supply a custom hook decoder, and each ships an installTargetinstead (bound via the in-TUI Sources panel). - A
Sourceis anAgentor aDaemon(SourceKind). A daemon (the OpenClaw gateway is the first) earns a presence-gated wandering mascot per running instance, not a desk: its deltas ride a sibling channel (PresenceMsg { key: DaemonInstanceKey, delta }, invariant #2 — NOT the oneAgentEventchannel) and merge viaapply_presence, neverReducer::apply(which isAgentId-pure). TheHookRouterdemux and the daemon-sweep loop both dispatch on this enum, so a second daemon is one registryDaemonrow + one mascot arm + one badge arm — nohandle_connedit and no new reducer arm. - Cross-source facts live in ONE registry row (
source/registry.rs, internal): each CLI'sSourceDescriptorcarries its label prefix, JSONL decoder, hook keying (transcript_pathvssession_id, plus an optional source-specific hook decoder for events the shared arms can't express — Codex's subagent hooks, Reasonix's whole alien envelope), and capability flags. The reducer derives lifecycle policy from those flags — e.g. the short idle reaper is!has_exit_signal && resurrects_on_prompt, which today holds only for Codex (no exit signal of any kind, but a swept session walks back in on the next prompt) — instead of matching CLI names. - Events flow through ONE tagged channel. Producers tag their own events; the
reducer never hardcodes
Transport::Hook— it reads the producer's tag. pixtuoid-corehas no terminal dependencies. Anything terminal-specific lives in a thin painter over the engine's render seam (render_floor/render_to_rgb_buffer), never in the core or the scene engine.- The hook shim must never block the agent — always exit 0, and the 200 ms send bound is enforced by a watchdog thread that hard-exits the process, on both platforms.
- Subagent supervision is a scope tree (
state/scope.rs): exit cascades down (a parent'sSessionEndreaps its subtree), liveness flows up (a working subagent keeps its ancestors fresh), and permission-blocked subagents are exempt from the stale sweep. - The walkable mask is the ground footprint only — a top-down view, so a sprite can be visually taller/wider than the tile its base occupies.
Where to go next
- Configure it:
docs/CONFIGURATION.md· live/config - Contribute:
CONTRIBUTING.md - Agent/contributor detail: the workspace
CLAUDE.md+ the nested per-crateCLAUDE.mdfiles.