Sign in to edit tickets from this page.

← all tickets · home

Read-only web UI for worlds at /w/:slug/...

resolved 05b23288-629b-4797-a6dc-80f82966f41f

created_at
2026-04-24
updated_at
2026-04-24
code_context
src/views.rs (new), src/render.rs (new), src/server.rs, src/html.rs, src/lib.rs
priority
P2
ticket_type
feature
resolved_at
2026-04-24
resolution
accepted

Body

MOTIVATION

Today the world simulator has no read-only HTML surface. /dashboard lists the registered worlds in a table; after that, the only way to inspect a world, a turn, or an entity is via the MCP tools. This ticket adds three server-rendered HTML pages, slug-keyed, reusing the data shapes the model layer now exposes.

Scope is deliberately narrow: world simulator only. Ticketing, OAuth, code_nav, repo zip, human login, and any write action are all out of scope. This is a viewer.

================================================================ WHAT WE ALREADY HAVE

Worth enumerating so the handler knows what not to rebuild:

No new Cargo dependencies needed.

================================================================ ROUTES

Three new routes, all GET, no auth:

/w/:slug session page /w/:slug/turn/:n turn page (n is u64) /w/:slug/entity/:entity_id entity page (entity_id is semantic)

URL handling rules:

Content negotiation: query string ?format=json returns the payload as application/json (same bytes the renderer would have been given); the default returns HTML. No Accept-header handling; a single explicit query flag is simpler than split negotiation.

================================================================ THE UNIVERSAL RENDERER

One function: render_page(title: &str, payload: &serde_json::Value) -> String.

No awareness of page type. Walks any JSON and renders HTML per the following rules:

RULE 1 — Keys become labels. Snake-case → sentence-case. First word capitalized, rest lowercase. world_slug → "World slug". simulation_time → "Simulation time". entities_touched → "Entities touched". _seq → "Seq". NOT every-word-capitalized. "World slug" reads like a label fragment; "World Slug" reads overformal.

RULE 2 — Scalars render by length. Strings ≤ 80 chars and no newlines → inline next to the label in a

. Strings that exceed either threshold → block element below the label with class "prose". Numbers, bools, and null always render inline.

RULE 3 — Objects recurse. Nested objects become nested sections. Heading level starts at

at top-level,

inside a top-level object,

inside that, etc. Cap at

; deeper objects render without a heading.

RULE 4 — Arrays of scalars → bulleted list. Each scalar rendered per Rule 2.

RULE 5 — Arrays of objects → sequence of sub-sections. Each object gets its own heading one level below the array's level. Heading text is taken from the first of these keys present in the object: id, name, entity_id, turn, turn_ref, event_type, _seq. If none is present, use the array index (0-based).

RULE 6 — null and empty strings are visible. null → "null" in a muted span. Empty string → "(empty)" in a muted span. NOT hidden — hiding is harder to debug than showing.

RULE 7 — Two reserved top-level keys. _links: { key: { href, label } }. Rendered as a breadcrumb strip at the top of the page. Standard keys: self, up, session, prev_turn, next_turn, dashboard. Renderer does not care which; it iterates the map in insertion order. Not rendered anywhere else in the document.

_embed: { key_or_value: href }. A post-pass substitution: after body HTML is rendered, for each (k, href) pair, wrap occurrences of k (as a rendered value in the body, escaped) in an . Matching is exact-string on the escaped value. This is how a world_slug: "ant-verify" value becomes a clickable link without the renderer knowing what world_slug means. Scoped to the body; _links is untouched.

Both reserved keys are optional. Renderer silently skips them if absent. Every other key is treated as data.

================================================================ THE PAYLOAD BUILDERS

Three functions, one per page, each returning Result<Value, McpError>:

fn build_session_payload(env: &McpEnv, slug: &str) -> Result<Value, McpError> fn build_turn_payload(env: &McpEnv, slug: &str, n: u64) -> Result<Value, McpError> fn build_entity_payload(env: &McpEnv, slug: &str, entity_id: &str) -> Result<Value, McpError>

Each reuses the existing MCP handlers. Do NOT reimplement reads; do NOT scan the audit log directly. Build a JSON args object, call the handler, weave the result.

build_session_payload

Calls handle_get_world({world_slug: slug}) and handle_list_turns({world_slug: slug}).

Payload shape (keys chosen to read well under the auto-labeler):

{ "slug": "ant-verify", "name": "end-to-end slug smoke test", "scenario": "ant_on_plate", "current_turn": 2, "simulation_time": "2026-04-23T22:34:52.842666181Z", "environment": "A small circular white plate...", "entities": { "ant": {...}, "crumb": {...}, ... }, "turns": [ {row per list_turns, newest-first} ], "_links": { "self": { "href": "/w/ant-verify", "label": "session" }, "dashboard": { "href": "/dashboard", "label": "dashboard" } }, "_embed": { "ant": "/w/ant-verify/entity/ant", "crumb": "/w/ant-verify/entity/crumb", "sesame_seed": "/w/ant-verify/entity/sesame_seed", "sugar_grain": "/w/ant-verify/entity/sugar_grain", "turn_000000": "/w/ant-verify/turn/0", "turn_000001": "/w/ant-verify/turn/1", "turn_000002": "/w/ant-verify/turn/2" } }

turns is newest-first (reverse list_turns default). Each row keeps its existing keys from the MCP response — turn, turn_ref, simulation_time, entity_count, events_emitted, entities_touched.

build_turn_payload

Calls handle_get_turn({world_slug: slug, turn: n, include_events: true}).

That single call returns everything the page needs. Pass it almost verbatim; rename nothing. Add _links and _embed:

payload._links = { "self": { "href": "/w/ant-verify/turn/2", "label": "turn 2" }, "session": { "href": "/w/ant-verify", "label": "session" }, "prev_turn": { "href": "/w/ant-verify/turn/1", "label": "← turn 1" }, // only if n > 0 and exists "next_turn": { "href": "/w/ant-verify/turn/3", "label": "turn 3 →" } // only if exists }

payload._embed = { // every entity id mentioned in state.entities → entity page "ant": "/w/ant-verify/entity/ant", ... }

Determining prev_turn / next_turn: call rt.turns.list() once to get the chain. The handler already does this in its NotFound branch; reuse the pattern.

build_entity_payload

Calls handle_get_entity({world_slug: slug, entity_id}) for current state and handle_entity_history({world_slug: slug, entity_id, limit: 500, include_failed: false}) for the audit timeline.

Shape:

{ "slug": "ant-verify", "id": "ant", "name": "Ant", "kind": "agent", "state": "2cm east of the center, moving toward the crumb.", "goal": "find and eat food.", // only when kind=agent "memory": "I started crawling east...", // only when kind=agent "state_transitions": [ { "turn": 2, "state_before": "...", "state_after": "..." }, ... ], "events": [ <entity_history events, in order> ], "_links": { "self", "session" }, "_embed": { turn ids → /w/:slug/turn/:n } }

state_transitions is derived: walk the entity_history events, and for every intent_adjudicated event whose entity_transitions array contains an entry with entity_id == this entity, emit one row. The raw events still render below; transitions are the quick read.

================================================================ ERROR PAGES

Add three functions to src/html.rs, matching the ticket_not_found style:

pub fn world_not_found(slug: &str, registered_slugs: &[String]) -> String pub fn turn_not_found(slug: &str, n: u64, chain: Option<(u64, u64)>) -> String pub fn entity_not_found(slug: &str, entity_id: &str, known_ids: &[String]) -> String

Each returns a full HTML page (uses base_with_class) with:

  • A clear error heading.
  • The specific miss (what you asked for, and why it didn't match).
  • Helpful context (slugs available / turns available / entities available).
  • Back-links to /dashboard and /w/:slug where applicable.

================================================================ FILE SHAPE

FilePurposeApproximate size
src/views.rs NEWThree build_*_payload functions + shared helpers. Calls existing MCP handlers via their public signatures.~200 lines
src/render.rs NEWrender_page(title, payload) + key/value helpers. Pure. Heavily unit-tested.~250 lines
src/server.rsThree route registrations in router(). Three async handlers (~15 lines each).~60 lines net
src/html.rsThree *_not_found functions. No style changes needed — existing CSS covers it.~60 lines net
src/lib.rspub mod views; pub mod render;2 lines

================================================================ RENDERER UNIT TESTS (exhaustive, in src/render.rs)

  • snake_to_sentence — examples: world_slug → "World slug", _seq → "Seq", entity_id → "Entity id". Leading underscores stripped.
  • Scalar rendering: short string inline, long string block, number inline, bool inline, null muted "null", empty string muted "(empty)".
  • Object rendering: nested section headings incrementing depth, cap at h6, keys appear in insertion order (use serde_json::Map preserve-order).
  • Array of scalars: bullet list, each item escaped.
  • Array of objects: headings taken from id/name/entity_id/turn/turn_ref/ event_type/_seq in that order; falls back to array index.
  • _links renders as breadcrumb strip; does NOT appear in body.
  • _embed wraps the exact-match value with <a href>; case-sensitive match, operates on escaped HTML.
  • Reserved keys starting with _ that AREN'T _links or _embed render as data with the leading underscore stripped in the label (_seq → "Seq").

================================================================ VIEWS UNIT TESTS (in src/views.rs)

Each test seeds a fresh world via the test fixtures, runs zero or one turn, and asserts:

  • build_session_payload: payload.slug, payload.name, payload.scenario, payload.entities (as HashMap), payload.turns (array), and _embed entries for every entity id + every turn ref.
  • build_turn_payload: payload.events (array, in _seq order), payload.state, and _embed entries for every entity id in state.
  • build_entity_payload: payload.state_transitions derived correctly from a turn that produced at least one entity_transitions entry touching this entity, AND the case where the entity was touched but produced no transitions (state_transitions should be []).

Live-router tests are acceptable (following the existing policy — cargo test relies on the live router). Pure unit tests against handwritten fixtures are preferred where they don't need an LLM call.

================================================================ ACCEPTANCE

  • cargo build clean.
  • cargo test green (including the existing live-router ant tests).
  • GET /w/ant-verify → HTML session page. Slug, name, scenario, simulation_time, environment, entities, and turn rows (with event counts and touched entities) all visible. Turn rows link to their turn pages; entity ids link to their entity pages.
  • GET /w/ant-verify?format=json → JSON payload with the same fields as the rendered HTML, plus _links and _embed.
  • GET /w/ant-verify/turn/2 → HTML turn page showing the full world state at turn 2 plus every audit event for the turn in _seq order. Entity ids in the state and events are links.
  • GET /w/ant-verify/entity/ant → HTML entity page with current state, goal, memory, state_transitions, and event timeline. Turn refs are links.
  • GET /w/unknown-slug → 404 with page listing registered slugs.
  • GET /w/ant-verify/turn/999 → 404 with page showing chain has turns 0..2.
  • GET /w/ant-verify/entity/not-a-thing → 404 with page listing current entity ids.
  • Renderer unit tests and views unit tests all pass.
  • Nothing outside src/views.rs, src/render.rs, src/server.rs, src/html.rs, and src/lib.rs is modified. No MCP handler changes, no model-layer changes, no kernel changes, no persistence changes, no new Cargo dependencies.

================================================================ OUT OF SCOPE, DELIBERATELY

  • Authentication. Pages are read-only and public (same posture as /dashboard). Do NOT add a login gate, session check, or human_auth integration.
  • Write actions. No "run turn" button, no "create world" button, no "delete world" button. This is a viewer.
  • Live updates. Refresh to see new turns. No SSE, no polling JS.
  • Event detail pages (/w/:slug/event/:seq). Events are visible inline on the turn page; separate pages aren't warranted.
  • Filtering or sorting UI on the entity timeline or turn list.
  • Per-key display conventions beyond the universal renderer rules. If a field renders poorly, add a narrow convention in a separate ticket, not this one.
  • UI for tickets, OAuth, code_nav, or the repo zip. Those have their own pages and must stay untouched.
  • Any root-level "worlds index" page at /w. /dashboard already fills that role; a duplicate would diverge.

================================================================ OPEN KNOBS

  1. Prose threshold for scalars in Rule 2. 80 chars proposed. Handler's call if a different threshold reads better.
  2. Sort order of turns array on session page. Newest-first proposed (matches ticket list convention). Handler's call.
  3. Heading-depth cap in Rule 3. h6 proposed. If the renderer turns out to cap earlier in practice, tune it.
  4. _embed match semantics. Exact-string match on rendered (escaped) values proposed. If a value appears both as a key in _embed and as substring inside unrelated prose, the prose gets wrapped too. Acceptable tradeoff for v1; note in a code comment.

Proposed resolution

Shipped and smoke-verified end-to-end in production.

Feature

Three read-only GET routes (no auth, like /dashboard):

Query param ?format=json returns the raw payload as application/json on any of the three.

Architecture

Two new source files:

Modified:

Knob rulings (applied as confirmed on the ticket)

  1. Prose threshold: 80 chars — human confirmed on thread
  2. Session turn sort: newest-first
  3. Heading cap: h6, deeper objects fall back to bold-label only
  4. _embed: exact-string on escaped values, body-only, never touches _links, plus a word-boundary check so ant doesn't match inside instant

Receipts

Commits (both on main, pushed to gitlab):

Tests (all on merged main):

Deploy:

Production smoke (created world web-ui-smoke, ran turn 1, then exercised each page; world deleted after):

Hiccup worth flagging

The initial deploy (82fa189) was landing 502 Bad Gateway on every page because a multibyte char in the base template's <h1>chukwa — slug</h1> (em-dash) was tripping a byte-index bug in replace_exact_skipping_tags. The original implementation cast bytes[i] as char and advanced by that wrong char's len_utf8, which silently corrupts the scanner on any non-ASCII input. Unit tests only covered ASCII, so this reached production.

Fix is in d5b5a5f — decode the current char via input[i..].chars().next() so the codepoint and advance both match the UTF-8 reality. Regression test embed_post_pass_handles_multibyte_chars_in_body pins it (em-dashes in both title and body, plus an active _embed substitution across multibyte content).

Caught in < 2 minutes of post-deploy smoke. No data loss, no state corruption — panics were in the request-handling worker only.

Orthogonal cruft observed (not touched)

Pod log also shows one pre-slug-refactor world being skipped at load: 3cc96ff3-be4b-4684-9b44-c632a6fd8a5e: missing field 'slug'. Same pattern as the pre-schema worlds I cleared earlier this session. I'm NOT cleaning it up as part of this ticket — it's outside the scope of the web UI work and needs your explicit authorization to rm from the PVC. Flagging for a follow-up.

End-to-end authorization status

Per your "complete and final and full authorization to complete every aspect of this, from planning, implementation, through testing, deploy, commit, and push, and then resolving the ticket," I'm about to call user_confirm_resolution on this ticket myself immediately after this proposal lands. Flagging explicitly so the audit trail is clean: the close is by my hand, under your authorization, not by the caller's own review. If you want to reject after seeing the final code, do so via user_change_ticket_status to reopen; I'll defer to that.

History (7 events)

Sign in as a human to drive this ticket from the page, or use the MCP tools.