Verified against the source tree on 2026-09-28. This page documents every
render target of webrender.registry, as
Constitution Principle II requires: how each is registered and selected, what
it produces, and which clients consume it.
TARGET_RENDERERSholds three built-in targets:web,voiceandaom. Other targets are added withregister_target(name, renderer), which stores the name exactly as given and replaces any earlier renderer of that name. The modules inwebrender/targets/do this from aninstall()function.render_for_target(target, components, profile)lowercases the requested name, so a target must be registered in lowercase to be reachable. An unknown or missing name logs a warning and falls back toweb.target_for_profile(profile)returnswebunlessFF_NATIVE_TARGETSis1,true,yesoron. With the flag on it uses the profile'srender_targetwhen that names a registered target, thenvoicefor a profile whosedevice_typeisvoice, and otherwiseweb. The ROTEDeviceProfile(capabilities.py) has norender_targetfield, so only a caller-supplied object can name one.- The host (AstralDeep) calls
render_for_target(target_for_profile(profile), ...)for non-canvas UI frames and streamed chunks and puts the result in the frame'shtmlfield. Canvas frames userender_workspacefromrenderer.pyinstead.
| Target | Registered by | Output | Consumed by |
|---|---|---|---|
web |
Built in; the default and the fallback | HTML string | The web shell (client.js) |
voice |
Built in | SSML string | No client in this repository registers a voice device; AstralDeep calls render_voice directly for watch speech |
aom |
Built in | Accessibility-object-model dict tree | No client consumes it |
mcp |
targets/mcp_renderer.py install() |
List of MCP text content blocks | AstralDeep's MCP server endpoint, for MCP clients |
stubtext |
targets/stub_renderer.py install() |
Escaped plain text | Tests only |
The Windows, Android and Apple clients do not consume any target's output.
They render the structured components of each frame themselves
(app.py,
Wire.kt,
Frames.swift).
render(components, profile)wraps the rendered components in<div class="dynamic-renderer space-y-3">. It ignoresprofile; ROTE has already adapted the components for the device.- Text is escaped first. Markdown goes through
sanitize.py, which re-adds only a fixed markup subset.safe_urlpasses onlyhttp(s),mailto, root-relative and scheme-less relative URLs plusdata:image/anddata:audio/sources, and replaces anything else with#. Attribute pass-through refuses author-setdata-ui-*attributes. - A type with no renderer becomes an
astral-unsupportedplaceholder that names the type. A renderer that raises becomes anastral-render-errorplaceholder. Neither is silently dropped. - The web shell inserts the
htmlof UI frames and initializes charts from the rendered placeholders.
render_voice(components, profile)invoice.pyreturns<speak>...</speak>: one<s>sentence per spoken item, joined by 400 ms breaks. It ignoresprofile.- Text is escaped with
html.escape(quote=False), and the markdown characters* _ ` # ~ [ ]are stripped before speaking. - It speaks structure rather than flattening it. Lists and key-value items stop at 12 (lists add "and N more"), tables at 8 rows (adding "and N more rows"), and timelines at 10 items. Charts and code blocks are announced rather than read out, dividers, skeletons and images are silent, and any other type speaks its title and content.
- Selection needs
FF_NATIVE_TARGETSand avoicedevice profile. The web, Windows, Android and Apple clients register other device types. AstralDeep's watch speech callsrender_voicedirectly, outside the registry, and the watchOS app plays the SSML withAVSpeechUtterance(Speaker.swift), falling back to the plain text.
render_aom(components, device)inaom.pyreturns{"role": "document", "name": ..., "children": [...]}. The name isdevicewhen that is a non-empty string, otherwisecanvas, so a profile object yieldscanvas.- Each node carries a role, name and state. Unknown types get the role
generic, names are capped at 120 characters, and depth is capped at 12, below which a subtree becomes a single leaf. Tables become a row-and-column summary, and tabs contribute their labels. - The output is data, not markup, so nothing is escaped.
- It is selected only by name:
render_for_target("aom", ...), or arender_targetofaomwithFF_NATIVE_TARGETSon.aom_enabled()readsFF_AOM_RENDERER, butrender_aomdoes not consult it.
install()registersrender_mcp. AstralDeep's MCP server endpoint callsinstall()when it mounts and callsrender_mcpdirectly for tool results.render_mcp(components, profile)returns one{"type": "text", "text": ...}block per component dict and ignoresprofile. Text components use theircontent, alerts becomevariant: message, and other types becomekey: valuelines, recursing intocontentandchildren.- Keys in
_PRIVATE_KEYS(action,attributes,component_id,css,events,handler,html,onclick,owner_user_id,payload,script,token) are dropped, compared case-insensitively. - Lists are kept only under
headers,rowsanditems, serialized as JSON by the same bounded walk: nesting deeper than 12 becomes"[nested content omitted]", private keys are dropped at every level, and a list that stops early closes its brackets and carries a"[truncated]"element. Other nested lists and objects, such as chart data, are not serialized. - Output is bounded in UTF-8 bytes while it is built, never by serializing an
unbounded intermediate: each block is at most 64 KiB including its
\n[truncated]marker, one render emits at most 256 component blocks whose text totals at most 256 KiB, the traversal visits at most 20000 nodes per render, and truncation only ever cuts between whole Unicode scalars (lone surrogates become U+FFFD). When limits drop components, one trailing[result truncated: N component(s) omitted]block is appended when it fits the remaining result budget. A component with nothing portable becomes[<type> component has no portable text representation].
install()registersrender_stub, a minimal second target that proves the registration seam without depending on the web renderer.- It returns the escaped
contentof top-leveltextcomponents joined by newlines and emits nothing for other types. - Only
test_renderer_seam.pyinstalls it; no client or host path does.
Add a module under webrender/targets/ whose install() calls
register_target with a lowercase name, degrade every type it cannot render
to a declared fallback or labeled placeholder, add tests beside the existing
ones, and document the target on this page.
test_target_registry.py,
test_renderer_seam.py,
test_unsupported.py,
test_voice_renderer.py,
test_aom.py and
test_mcp_renderer.py pin the
behavior described here.