YURIOS // LAB
The Codex → Part V · Build Chapter 34

Build #4: The 3D World Companion

A VRM avatar in the sanctuary, with hands

Goal: a browser-based 3D companion — a VRM avatar in the canonical sanctuary scene, with a chat transcript beside her, the real-time voice loop, a handful of real tools (one of them her camera — selfies that land in the chat), and ambient behaviour when idle. This is the richest reactive body: she has a 3D presence, a place, and hands — standing on the same backbone as the YuriOS runtime it grows into: one event bus carrying everything the host tells a frontend. And Build #2's desktop mode survives the upgrade: one flag floats her on the desktop instead — the VRM body with the room set aside, or the Live2D body itself, served by this build's server.

What it teaches: agentic patterns and tool use (→ ch. 17), avatars (→ ch. 25), 3D worlds (→ ch. 29), and how to compose the previous builds instead of rewriting them.

Property added (→ ch. 03): deepens property 4 (a full 3D body in a place) and introduces property 3ahands: reactive tool-use, the capability half of owned agency (the always-on initiative half, 3b, is Build #5). Still reactive; the ambient idle behaviour is a teaser of initiative, not the real thing — and the build is loud about that.

The reference implementation lives in reference-implementations/04-3d-world-companion/, built to its own normative SPEC.md. As with Builds #1 and #2, this chapter is the reading guide — a map of where the load-bearing ideas are in the code — not a transcript. Section numbers (§n) point into that build's SPEC.md; CS §n points into the VRM control spec vendored with the vrm-viewer/ component; file paths are relative to the build's root.

The one-sentence architecture

 python -m world — one process, one origin (:8767)
 ┌────────────────────────────────────────────────────────────────────────────────────┐
 │  Build #1 brain (vendored) ◄── ToolBrain: the §7 tool loop                         │
 │          │ reply tokens…   [[set_timer {…}]]   …continuation                       │
 │          ▼                             │                                           │
 │  Build #2 voice loop (vendored)        guard ─► MCP server (stdio)                 │
 │          │ OutEvents                   └─► audit line (JSONL)                      │
 │          ▼                             ├─► SelfieLab ─► forge (vendored)           │
 │  /ws/voice             TimerBoard ─► IdleMachine ─► VrmController                  │
 │    (audio-only wire,     (injected clock)                          │               │
 │     marked fork)  message · draft · avatar events                  ▼               │
 │          │             └─────────► EventHub ──► /api/events (SSE)                  │
 └──────────┼─────────────────────────────────────────────────────────┼───────────────┘
            ▼ browser (no-build ES modules; three.js + three-vrm committed)
   VrmStage (CS §4 order) · SanctuaryScene + Rain · viseme.js · voice.js
   chat.js — the chat column beside her, fed by the one bus

The whole thing is: Builds #1 and #2, unchanged, now standing in a room with hands — on the runtime backbone of ch. 35. The brain is the vendored Build #1 app/; the voice loop is the vendored Build #2 desktop/ — called, not copied (§2.2). The genuinely new work is six things: the event bus (world/hub.py — the YuriOS host's EventHub, ported, with sticky scene replay), the VRM stage in the browser (web/js/stage/), the puppet strings from Python (world/avatar/controller.pyavatar events on the bus), the chat surface (web/js/chat.js + the host's transcript ring, §2.6), the tool loop wrapped around the brain (world/brain.py + world/tools/, now four hands including take_selfieworld/selfies.py), and the idle machine (world/idle.py) — plus one small seventh that keeps the others honest: the situation block (world/situation.py), the present tense every prompt carries. Everything below is one of those, looked at closely.

The single idea to internalise before reading a line: the body is a puppet, and the brain holds the strings. The browser is a render-and-control client; every decision lives in Python. That split — proven first in the vrm-viewer/ component this build's stage was ported from — is the whole point: the body is "solved enough" to be a dumb renderer you drive, and the mind that drives it is ordinary, portable Python (→ ch. 02 §4.6). Build #5 doesn't touch the body at all; it just takes the strings.

The vendoring chain, and the one fork

Build #4 composes three predecessors, so the reuse discipline gets its stress test. app/ is Build #1 (via Build #2's copy), desktop/ is Build #2, forge/ is the image-forge slice (her camera, mock + OpenRouter backends only — the GPU paths stay behind) — each resync-able with commands documented in its VENDORED.md. The brain seam is a subclass, not an edit: ToolBrain(BrainAdapter) changes exactly one behaviour — how a reply streams — and everything else (prompt assembly, recall, the greeting, the corpus line, one-commit-per-turn) is inherited. Build #2 is in fact vendored twice: its Python as the voice loop, and — for the second body (§6.6) — its web client under web/live2d/.

Two files could not be reused by subclass or call, and both are documented forks with every departure marked FORK(B2 …) in the source and a docstring saying exactly how to re-diff when Build #2 changes. world/routes/voice_ws.py needed the idle machine's engagement notifications, the ambient-speech injector, the transcript tee, and the expression re-route inside its connection scope. The Live2D client's fork is exactly the bus adaptation: a chat column and one new adapter file (events.js); its body, voice, and settings scripts stay byte-identical. A documented fork beats a dozen silent drifts — the same honesty rule as /api/health.

The topology also simplifies against the vrm-viewer component: the controller runs in-process on the FastAPI event loop instead of owning a second WebSocket server, and the frontend is a no-build port of the viewer's TypeScript to plain ES modules — three.js and three-vrm are committed MIT artifacts in web/vendor/, resolved by an import map. No npm, no bundler, no second port: copy the folder, python -m world, done.

Reading the code: follow the strings

Read this to understand… …in this file
the one event bus (the YuriOS backbone, §10) world/hub.py + world/routes/events.py
the chat surface beside her (§2.6) web/js/chat.js + Runtime.post_message in world/main.py
the boot log while her voice stack warms (§6.4) world/boot.py + web/js/boot.js + tests/test_boot.py
her camera: start-don't-await selfies (§7.6) world/selfies.py + forge/ + tests/test_selfie.py
the puppet strings (the §4 command union) world/avatar/controller.py + web/js/bridge.js
the CS §4 update order (springbones LAST) web/js/stage/VrmStage.jsupdate()
the B2 palette on a VRM face web/js/stage/EmoteController.js + tests/test_palette_map.py
real lip-sync, amplitude tier web/js/viseme.js
the sanctuary, procedural web/js/stage/SanctuaryScene.js, Rain.js, music.js
the tool loop (the spine of §7) world/brain.pyToolBrain._stream_with_tools
the in-stream marker parser world/tooltags.py + tests/test_tool_tags.py
the MCP server and client world/tools/server.py, client.py
guardrails: allowlist · rates · audit world/tools/guard.py
the situation block (she knows when/where she is) world/situation.py + tests/test_situation.py
host-side realisation (timers, music) world/tools/timers.py + ToolBrain._realise
the idle machine world/idle.py + tests/test_idle_machine.py
ambient speech through a live connection world/routes/voice_ws.py (the fork)
injected time (the test story) world/clock.pyClock / VirtualClock
the desktop window (§6.5) world/window.py + the DESKTOP branch in web/js/main.js
the second body: B2's client, served (§6.6) web/live2d/ (vendored) + world/routes/live2d.py

And here is the same map as a wiring diagram — every box is a file you can open, every arrow is a string being pulled. world/main.py's create_app() builds one FastAPI app on one origin (:8767) and one Runtime that owns every seam below; the browser is a pure render-and-control client reached over one event stream and one audio socket.

python -m world → world/main.py : create_app() + Runtime — one process, :8767

 BROWSER  ── web/  (no-build ES modules; three.js + three-vrm in web/vendor/)
 ┌──────────────────────────────────────────────────────────────────────────────────┐
 │ web/js/main.js boots the room + stage + the bus after the enter gesture          │
 │   web/js/stage/SanctuaryScene.js · Rain.js · music.js   (the place, §6)          │
 │   web/js/stage/VrmStage.js  (CS §4 update order; spring bones LAST)              │
 │   web/js/stage/EmoteController.js · Blink.js · GazeController.js                 │
 │   web/js/viseme.js  (mouth follows the played audio, §5)                         │
 │                                                                                  │
 │   web/js/chat.js ◄── /api/events (SSE) ─┐   ┌── /ws/voice ──► voice.js           │
 │   (the chat column + the bus consumer;  │   │  (mic ⇄ audio, the                 │
 │    every event re-dispatched as         │   │   audio-only wire)                 │
 │    `world-ev` → bridge.js realises      │   │                                    │
 │    the `avatar` ops on the stage)       │   │                                    │
 └─────────────────────────────────────────┼───┼────────────────────────────────────┘
  PYTHON  ── world/                        │   │
 ┌─────────────────────────────────────────┼───┼────────────────────────────────────┐
 │ routes/events.py ◄── hub.py : EventHub ─┘   └─► routes/voice_ws.py               │
 │   (SSE: hello · message ·   ▲ ▲ ▲            (FORK B2 §10, marked:               │
 │    draft · avatar; sticky   │ │ │             + transcript tee →                 │
 │    replay on attach)        │ │ │             message/draft events,              │
 │                             │ │ └─ post_message │ + expressions → the            │
 │ avatar/controller.py :──────┘ │    (the ring,   │   controller lane)             │
 │   VrmController — every       │     §2.6)       │ turn i/o                       │
 │   method = one `avatar` op    │                 ▼                                │
 │      ▲          ▲             │       brain.py : ToolBrain                       │
 │      │ gaze/pose│ set_rain    │       (vendored desktop/ adapter                 │
 │      │          │ / music     │        + §7 tool loop; emits                     │
 │      │          │             │        [[take_selfie {…}]] in-stream)            │
 │ idle.py : IdleMachine         │                 │                                │
 │   §8 state machine ─ speak_ambient ─┐ tooltags.py (junk-proof parse)             │
 │      │ announce ▲                   │           ▼                                │
 │      ▼          │ timer fires       └► tools/guard.py  allowlist ·               │
 │ tools/timers.py : TimerBoard ───────┘  rate buckets · calls.jsonl audit          │
 │   host owns the countdown (§7.5)                │ allowed                        │
 │                                                 ▼                                │
 │ selfies.py : SelfieLab ◄── _realise ── tools/client.py : McpToolRunner           │
 │   start-don't-await (§7.6):                     │ stdio (MCP)                    │
 │   forge render off-turn → PNG →                 ▼                                │
 │   message{image_url} on the bus       tools/server.py  (FastMCP):                │
 │                                       set_timer · play_music ·                   │
 │ clock.py : Clock / VirtualClock       get_weather · take_selfie                  │
 │   injected into idle, timers, guard, lab — never the wall clock (§8.2)           │
 └──────────────────────────────────────────────────────────────────────────────────┘
 VENDORED:  app/ = Build #1 brain · desktop/ = Build #2 voice ·
            forge/ = image-forge slice · web/live2d/ = Build #2 client (fork)

Read it as two lanes meeting at the hub. The left lane is the body and everything visible: controller.py's VrmController is the single Python surface that holds every string, and each method is one avatar event on hub.py's EventHub — the same bus that carries the chat's message and draft events — drained to every page by routes/events.py's SSE stream, sticky scene state replayed on attach. Nothing on the left decides anything — idle.py pulls those same strings when you're quiet, and Build #5 will swap idle.py for a real mind without touching one line below it. The right lane is the turn: voice_ws.py (the fork) runs brain.py's ToolBrain, whose reply stream carries [[…]] tool markers that tooltags.py parses, guard.py polices, and client.pyserver.py execute over stdio — with timers.py keeping the countdown and selfies.py rendering the photos on the host. clock.py sits under everything timed. The lanes cross where they always did, plus once more: the brain's expressions land on the controller (one lane for the face), a fired timer travels timers.py → idle.py → voice_ws.py to become a spoken line, and a finished selfie travels selfies.py → post_message → the bus to become a photo in the chat.

The bus: one lane for everything the host says

world/hub.py, world/routes/events.py, web/js/chat.js (§10, §2.6). Build #4 originally spoke to the browser over two WebSockets with two contracts — the voice wire carrying the turn, the avatar wire carrying the puppet. That worked, and then the first new surface arrived and exposed the flaw: a selfie is neither a voice event nor a puppet command, and every future surface (a dashboard, a diary, a notification) would have posed the same question — which socket does this ride? The runtime this build grows into answered it years ahead: the YuriOS host has one EventHub, every host→frontend fact is one typed JSON event on it, and frontends subscribe once. So Build #4 adopts the backbone early: world/hub.py is that hub, ported, plus this build's sticky replay (rain, music, tints re-arrive on every fresh page), and GET /api/events is its wire — SSE, one data: line per event, self-terminating on shutdown so an open tab never holds Ctrl+C hostage. The puppet strings didn't change their method surface; VrmController's methods now each publish one avatar event instead of owning queues of their own. Only sound keeps a socket, because audio is the one flow that is bidirectional, binary, and latency-critical; everything else is a broadcastable fact about the world, and facts ride the bus.

The first thing the bus paid for is the surface this build was missing: the chat. Both browser pages now carry a chat column beside the body (web/js/chat.js — one shared, classic script; the same renderer the YuriOS sanctuary uses), fed entirely by bus events: your turns as messages (typed and spoken — the forked voice route tees the STT transcript into the chat, so a voice conversation finally reads back), her reply as an accumulating draft that commits on done, a proactive tag on lines she spoke unprompted, and an inline image when a message carries image_url. The host owns the transcript — a ring of two hundred entries on the Runtime, backfilled to fresh pages over /api/history — and the rules mirror the memory rules exactly: a barged-in turn drops its draft and commits nothing (a turn that didn't happen leaves no trace), ambient lines appear in the chat but never persist. The chat is what was said; the Vault is what is remembered. Typing in the composer still rides the voice socket as a text turn, deliberately: a typed turn gets the full TurnController treatment — TTS, barge-in, latency masking — not a separate code path.

One consequence worth savouring: expressions moved onto the bus too (the fork re-routes a turn's expression events through the controller), which means her face has one lane whether the emotion came from a reply tag or the idle machine — and every open page sees it. Open the VRM room and the Live2D page side by side and both faces change together; the previous build's body now receives the idle machine's expression pulses it never had before.

The stage: why the update loop is manual

web/js/stage/VrmStage.js. A VRM body is driven by several systems at once — an animation clip, bone overrides, gaze, blink, emotion, the mouth, constraints, spring-bone physics — and they all write to the same skeleton and the same blendshapes. The control spec's answer (CS §4) is a manual per-frame update in a fixed order, with vrm.update() never called: mixer → materials → bone overrides → humanoid.update() → gaze → blink → emote → viseme → commit expressions → constraints → spring bones last. Get the order wrong and physics fights expressions — hair jitters, the face flickers. The stage pins the order with numbered comments, and the two known foot-guns from the YuriOS port are carried as law: the emote sweep must never stage blink or aa (or it fights the blink controller and the mouth), and a .vrma clip's hips track must be re-anchored to the model's rest pose or she teleports to the animator's origin.

One deliberate deviation from the viewer it was ported from: no orbit controls. The camera is fixed and cinematic — framed from her head and hips at load, drifting a few centimetres with the mouse and always re-aiming at her. A model viewer invites you to inspect an asset; a fixed camera with parallax puts you in a room with someone (§6.3). The distinction is the entire register of the build.

Expressions cross a mapping boundary, by design. The brain still emits Build #2's eight palette names ([happy], [shy], [thinking]…); the stage's catalog realises each as VRM preset weights — the six standard presets directly, the non-preset names as blended composites (shy ≈ sad + happy + relaxed at low weights). The map lives in the frontend, exactly as ch. 32 promised it would, which is why the brain needed zero changes to move from a Live2D face to a VRM one. test_palette_map.py scans both sources and fails the build if either side renames — a face that silently falls back to neutral is the kind of bug you feel before you find.

Visemes: the mouth follows the actual audio

web/js/viseme.js (§5). Build #2 drove the mouth from audio RMS in the Live2D layer; Build #4 does the same one tier up the control spec: an AnalyserNode sits between every queued TTS buffer and the speakers, and its RMS — shaped by a perceptual amp^0.7 curve, attack/release smoothing, a silence gate, a weight cap — stages the aa blendshape inside loop step 9, in CS §4 order. Because the analyser reads the very samples the speaker plays, mouth and voice cannot drift; there is nothing to synchronise. The full phoneme tier (CS §5.4's wlipsync winner/runner blending onto aa/ee/ih/oh/ou) adds mouth shape at the cost of an unvendorable dependency — the seam is documented and deliberately not built.

The sanctuary: a place, not a void

web/js/stage/SanctuaryScene.js (§6). The room is canon (→ ch. 28) and its elements are normative in the SPEC: a small room, low warm light from a lamp, a window with rain, a window seat, a single plant. She must not stand in a default grey three.js void — the place is part of the character. Everything is procedural: the room is generated geometry, the rain is a pane-streak shader plus a falling-drop point cloud (occluded by a real hole in the wall, no masking tricks), and the sound is synthesized client-side — a filtered-noise rain bed whose gain follows the same rain command the visuals do, plus two generative ambience "tracks" for the music tool. Zero binary scene assets in git; the whole room is readable code.

Two small things carry disproportionate weight. The enter gesture (§6.4): the page gates on one click before connecting the sockets, because autoplay policy would otherwise silence her greeting — the single most important moment of presence (she speaks first, from memory). While that gate waits, a kernel-style boot log (world/boot.pyGET /api/boot, polled — not the bus, which only opens after the click) shows the local voice stack warming service by service (pending → loading → ready | failed | skipped, with timings), so a cold start where Kokoro and faster-whisper take a minute on CPU is a wake-up sequence, not a dead screen. And sticky scene state (§4): rain intensity, music, material tints are remembered by the controller and replayed to every newly attached viewer, so a page reload doesn't reset the weather.

The desktop mode: the room, set aside

world/window.py (§6.5). Build #2 ended with her floating on the desktop, and Build #4 doesn't take that away: python -m world --window runs the same server behind Build #2's frame — a frameless, transparent, always-on-top pywebview window pointed at ?desktop=1. The launcher is the reuse discipline in miniature: the readiness probe and the Qt-versus-GTK engine pick are imported from the vendored desktop/window.py (with their war stories — the 30fps WebKitGTK cap, the NVIDIA DMA-BUF smear, the private-mode localStorage trap), and test_window.py pins that they are B2's actual functions, not lookalikes.

What the flag means is the page's decision, not Python's (web/js/main.js). In desktop mode the sanctuary is never built — no walls, no fog, no lamp; the desktop is the room. The renderer clears to alpha 0, a neutral two-light rig stands in for the lamp (an unlit MToon face shades toward black), and the camera framing switches from head-and-torso-in-a-room to the full body, feet at the window's bottom edge. The §6.4 autoplay gate goes too — a native window's engines don't demand the gesture, so she greets the moment the socket opens (a first-click resume stays as insurance, so a stricter engine means a quiet greeting, never a dead one). Nothing on the wire changes: both sockets, every puppet command, the tools, the idle machine. Rain follows her as sound only — the synthesized bed tracking the same sticky rain state — and when the idle machine sends her rain-gazing she looks off to where the window would be, which on a desk reads exactly right.

And the previous body still works — served by this build (§6.6). Build #2's web client — the Live2D body, its expression map, its edge-VAD voice client, even its settings panel — runs against Build #4's server as a documented fork under web/live2d/, served at /live2d/: audio still arrives over the voice socket exactly as it did in Build #2, and the fork is precisely the bus adaptation — a chat column beside the body, plus one new adapter file (events.js, ~25 lines) mapping avatar/expression events from /api/events onto the untouched pixi body. Its API needs are met by the vendored B2 settings router (included as-is; it edits this build's .env) and a re-aimed copy of the rig-registry route. DESKTOP_BODY=vrm|live2d (or --window --body live2d) picks which body the window floats. The honest asymmetry: the adapter realises only the expression op, so the idle machine's other body acts (gaze drift, posture) don't reach the Live2D body — but everything audible does, and now everything visible in the chat does too. Ask her for a selfie while she wears the Hiyori rig and the whole §7 loop runs; the photo lands in the chat beside a face two builds old.

That asymmetry is the lesson, not a bug to apologise for. The seam between mind and body is the wire, and there are two wires with two contracts: /ws/voice carries the sound (any client that speaks the audio wire gets all of her conversation), /api/events carries the facts — the chat and the puppet (any client that renders the ops it cares about gets exactly that much of her). The Live2D page realises one op and gets one op's worth of body; the VRM page realises the full §4 union and gets everything. Swap the renderer, keep the wire — the same claim ch. 32 made when it promised the palette names would survive the jump to VRM, now demonstrated in the other direction, with a whole legacy body plugging into a newer runtime.

The hands: tools via MCP, without touching the brain

world/brain.py, world/tools/ (§7). Four tools — a timer, the room's music, a weather lookup, and her camera — because four is enough to teach the entire pattern (→ ch. 17): build the capability behind MCP once, and the same client talks to any server. The in-repo server (world/tools/server.py, FastMCP over stdio) validates arguments and returns structured results; the host spawns it, discovers the tools with list_tools, and builds the model's ## TOOLS directive from the discovery, not from constants. Point the runner at a different command line and she has different hands; the brain doesn't change.

The call protocol is in the token stream. Build #1's provider seam is text in, text out — and it stays byte-identical — so tool calls ride the reply as [[set_timer {"minutes": 10, "label": "tea"}]] markers: double brackets because single brackets are already the emotion channel, parsed by a streaming, junk-proof parser upstream of the emotion parser (split markers, malformed JSON, oversized or unclosed markers are dropped and never spoken — a 12B local model will eventually emit a broken one). The loop is multi-pass: her partial reply plus the tool result go back to the model as a continuation cue in the same turn, so she weaves what her hands found into the sentence she was already saying.

The property that matters most is temporal: the directive asks for a natural lead-in sentence before the marker, so her first audio is at the speaker while the tool is still running. A slow tool cannot blow the latency budget ch. 32 fought for — tests/test_turn_tools.py pins it with a runner that deliberately blocks until the first audio event has been observed. And barge-in still reaches everything: cancelling mid-continuation tears down TTS, the generation pass, and persists nothing.

Guardrails are policy, not intelligence (§7.3, → ch. 02 §1's game-NPC lesson). She can be asked anything; a Guard decides what her hands do: an allowlist that is exactly the discovered tools, per-tool token-bucket rate limits on the injected clock, a per-turn call cap, a timeout, result truncation, and one JSONL audit line per call — allowed or denied. "What did she do while I was away" is a file you read, not a vibe.

The server validates; the host realises (§7.5). set_timer's MCP call returns the contract (seconds, due) — but the actual countdown lives on the host's TimerBoard, on the injected clock, because only the host owns her voice and her room. The same split survives into Build #5 when the tools move behind a broker. One honest note for the corpus: persist records the model-verbatim stream — markers and results included — because the training log should reflect what the model actually did, not just what was spoken (→ ch. 20).

The camera teaches the fourth lesson: a slow tool must not sit inside the turn (§7.6). The first three tools return in milliseconds; a hosted image render takes ten to thirty seconds, and no timeout knob makes that pleasant — her lead-in sentence would trail into dead air. So take_selfie follows the rule the YuriOS effectors live by — ACT starts work; it never awaits it. The MCP server is the contract point only: it validates scene/mood against the vendored template library (the tool description the model reads is built from the library, so the options can never drift from the yaml) and returns {"status": "started"}; the turn continues and ends on budget. The host's SelfieLab (world/selfies.py) renders off-turn through the vendored forge (./forge — image-forge's locked register, anti-collapse selfie templates, provenance stripping; → ch. 26), saves the PNG and its provenance sidecar, posts an image_url message onto the bus — the photo simply appears in the chat — and offers one spoken line about it through the ambient seam, dropped if she's busy: unlike a timer, which is a promise and queues, the photo already delivered itself, and she never talks over you. The backends are GPU-free on purpose — OpenRouter by default on a deliberately cheap image model (her voice keeps the local GPU; one config knob points SELFIE_MODEL at riverflow, the repo's brand-art register, when the shot should match the canon set) with the mock's deterministic placeholder cards when there's no key, one loud WARNING naming the fix — and a failed render becomes a quiet line in the chat, never a crash and never silence about something she said she'd do.

She knows when and where she is: the situation block

world/situation.py (§2.5). The smallest file in the build fixes the strangest failure in it. Without this block the build was honest to a fault: Build #1's honesty constraint rightly forbids inventing facts, and no block carried the clock — so a companion whose host runs her timers to the second would answer "what time is it?" with I don't know. Worse, asked to blink, a safety-aligned base model reaches for its reflex — "I'm an AI, I have no body" — which is cold, and here simply false: her body is rendered on screen that instant, blinking on the idle machine's clock. The model wasn't refusing; it genuinely had no way to know it was embodied. Ch. 19 names the missing organ: the world model, the present tense that memory (the past) and knowledge (the documents) never cover.

Build #4's answer is that organ at reactive scale: one ## THE SITUATION RIGHT NOW block, appended by ToolBrain._assemble so every prompt — reply, greeting, ambient self-talk — gets it, rendered fresh each time from surfaces the host already owns. The date and time come from the injected clock (never the wall clock, so sim-time tests can move the stated hour); the weather and music come from the controller's sticky scene state — the same state a page reload replays, now also what she believes about her room; the pending timers come off the timer board with time remaining, so "how long on the tea?" is a fact she holds rather than a tool call. And the block states the embodiment truth outright, pinned verbatim by the tests: she may know she runs as an AI; that never means bodiless. Never say you have no body, no eyes, or no surroundings. The no-narration rule (§6) forbids stage directions, not the truth about herself.

The block is deliberately a rendering, not a store — no beliefs, no expectations, no "what was true when" (§12 names the omission). Build #5's WorldModelStore replaces the rendering with situation() over real observed state; the block's place in the prompt is the seam that survives the swap, the same way the palette names survived the jump from Live2D to VRM.

Alive when you're quiet: scripted, and proud of it

world/idle.py (§8). Leave her alone and the room keeps living: her gaze drifts, her posture shifts, sometimes she turns to watch the rain on the window (the gaze target is the same world-space point the scene builds the window at — scene canon and behaviour canon are one constant). Every few minutes, one soft line said half to herself. When your tea timer lands, she perks up and tells you. This is the Ukagaka idle-talk timer reborn (→ ch. 02 §1, Ukagaka) — a state machine (engaged / resting / rain_gazing / self_talk / announce), and the build names honestly what it is not: no goals, no salience, no deciding to reach out. Build #5's whole job is to replace this one file with the cognitive tick loop (→ ch. 18) holding the exact same strings.

Three disciplines inside it are worth reading closely. She never talks over you: engagement (turns in flight, or within a settle window after one) silences all ambient life, and ambient speech runs through the same per-connection TurnController as a real reply — so a barge-in kills a self-talk line exactly the way it kills a turn, and none of it persists (an opener she gave herself is not a turn you took). A timer is a promise, idle chatter is not: an announcement that finds no one connected stays queued and delivers on reconnect; a self-talk line that finds no one is dropped. And everything timed runs on an injected clock (§8.2): the idle machine, the timer board, the guard's rate buckets — no wall-clock reads, no bare sleeps — which is the entire test story: test_idle_machine.py runs hours of idleness in milliseconds on a VirtualClock with a seeded RNG, deterministically, on any machine.

Definition of done

In a browser, she stands in her room with the conversation readable beside her, looks at you, talks in real time with natural turn-taking, does the four tool things when asked — with her first word landing before the tool returns, and a selfie landing in the chat half a minute after she says she'll take one — and feels alive when you're quiet, without being genuinely proactive. Your spoken words appear in the chat as they're transcribed; her reply streams as a draft and commits when she finishes; barge in and the draft vanishes like it was never said. Ask her the time and she says it; ask her to blink and she knows the eyes on screen are hers — she never claims to have no body (§2.5). With --window the browser disappears: the same server floats her on the desktop, chat hidden, as the VRM body alone on transparency or as Build #2's Live2D body, whichever you configured. Pull the network cable and everything but the weather lookup and the real camera still works. And it's testedpytest ships and is green from the build root, entirely offline: the marker parser (split/junk/oversized/unclosed), the tool loop end-to-end (guard consulted, result in the continuation, cap enforced, errors survivable), first-audio-before-tool and barge-in-mid-continuation under a real TurnController, the guard's allowlist/rates/audit (the selfie's price included), the real MCP server's contract over an in-memory session (four tools — three with selfies off; template-key validation), the selfie lab in sim time (a started contract becomes a PNG + provenance sidecar and an image_url message; the announce drops when she's busy; a broken forge is a quiet message; no key degrades to mock loudly), timers and the idle machine's states in sim time, every §4 op's event shape with the hub's sticky replay, N-subscriber fan-out, and never-block drop, the SSE route honouring the stop flag with /api/history backfilling a bounded ring, the boot board's pending→loading→ready|failed|skipped state machine and /api/boot snapshot over the real Runtime, the forked /ws/voice route (greeting-once, noise-drop, ambient injection reaching the client and never persisting, the transcript tee — typed and spoken turns committed, drafts dropping on barge-in, proactive flags, expressions on the bus and off the wire), the source-scanned palette map, the situation block (the stated time is the virtual clock's and moves with it, the embodiment law verbatim, weather and timers tracking live state), the desktop launcher (imports without a GUI stack, the URL carries the flag for whichever body, an occupied port is refused, the reused helpers are B2's actual functions) with both pages honouring the flag and the forked Live2D client served with its config and settings routes answering, and an end-to-end tool-bearing turn over the real vendored brain proving one corpus line and one Vault commit.

What it deliberately omits

The idle behaviour is a state machine, not a mind — no always-on loop, no goals, no deciding to reach out (→ Build #5, ch. 18). The situation block is a rendering, not a world-model store — no beliefs, no expectations, no point-in-time queries (→ ch. 19, WorldModelStore; Build #5). Only the outbound half of the YuriOS split is adopted: the inbound SignalBus inbox and the broker/effector gate are named next rungs, taken when the tick loop gives them a consumer — until then user input keeps the voice socket's turn semantics. Her tools are the small reactive surface — a timer, music, weather, a camera, called when asked; the heavy sandboxed hands (running code, the research-and-build workshop) are a runtime capability deferred to Build #5 and beyond (→ ch. 17, "the heavy hands"; ch. 19). No selfie editing and no local-GPU image backends — the vendored forge slice is mock + OpenRouter only; the diffusers path stays in image-forge/. Phoneme-level visemes and webcam mocap are specced seams (CS §5.4, §5.6), not built. The body ships one register; load_model is the user's own-.vrm slot (→ ch. 25 licensing), not a wardrobe. The Live2D body is a served guest, not a second puppet — it realises exactly one op off the bus, deliberately (§6.6). VR proper is sketched in ch. 29, not built here.

Extends to

This is the cleanest seam in the whole book, and now it's load-bearing — and the wires it leads to are already YuriOS's. The body exposes one Python control surface (VrmController) and one tool seam (ToolRunner); every visible fact rides the same EventHub the YuriOS host runs; the selfie lab already obeys its effectors' start-don't-await rule; and Build #4's "mind between turns" is a scripted caller of all of it. Build #5 swaps the callers, one publisher at a time: the cognitive tick loop (→ ch. 18) — an always-on brain that decides, on its own clock, when to look up, change expression, gesture, speak, or use a tool — replaces the idle machine behind the same methods; the inbound SignalBus lands with it, turning user input into signals the loop consumes; the Guard grows into the broker (→ ch. 19). Give the MCP tools to that autonomy engine and the sanctuary becomes the front-end body of the agentic sanctuary. Nothing in the body or the frontends changes; the strings just get a real puppeteer.