Front-End Panel Architecture
This page explains where plugin content appears in the session screen and how it is shown: the right-side panels, plugin cards in chat messages, stage mode, and the status display at the top. Anyone who writes plugin UI or maintains the front end needs it.
The basic idea has one rule. A plugin describes its UI in a JSON document (called a UI spec below), and the framework finds it, connects the data, and draws it. A plugin does not write React code.
- For what each component is and which properties it has, see the component catalogue.
- For how to build a panel, see plugin UI.
NOTE
The DashScope / OpenAI image generation and MiMo TTS described here come from the official community plugin repository. They show up, with their tools and UI, after you install them and authorize them in a session.
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ Header: Session ID | Phase | Execution Status │
├──────────────────────────┬──────────────────┬───────────────────┤
│ Center: Message Area │ │ Right: Plugin │
│ ──────────────────── │ │ Panels │
│ │ │ ────────── │
│ - Turn messages │ │ vertical bar │
│ - Plugin message surface│ │ ┌──┐ │
│ - Prose / Form / Alert │ │ │ │ World │
│ - Guide / Codex │ │ │ │ Character │
│ │ │ │ │ World Data │
│ │ │ │..│ (more) │
│ │ │ └──┘ │
│ Player Input │ │ Panel Content │
│ [Your action...] [→] │ │ (json-render) │
└──────────────────────────┴──────────────────┴───────────────────┘The navigation position and the vertical icon bar on the right in the diagram show the classic layout. Theme packages can change these structures with layout: an icon navigation on the left, text tabs, a scene picture that fills the session area, and so on. See theme packages §3.1.
A layout changes only where things are placed. How panels are found, grouped and ordered, and how data flows, do not change with the layout. A plugin declares ui.right / ui.message once, and every layout renders it from the same spec.
Session Toolbar, Player Status and Scene HUD
The session toolbar and status display read the World Model and public kernel UI slots, without reading plugins' private namespaces directly. Plugins supply slot values, so the same interface works across worlds and plugin combinations. A display item is hidden when its data is absent.
| Shown content | Data source |
|---|---|
| World name, turn count | WorldRecord.name, SessionRecord.completedPlayerTurns |
| Current scene name | name in the stage.backdrop@1 slot |
| Player stat gauges | Session World Model: the primary seat’s current character (see Seats), plus attributes in the character schema with category: "stats", type: "number", and both min and max declared; up to 4 items |
| Carried items | String items of the type: "array" attributes with category: "equipment" on the same player character record, merged in schema order |
| Characters on stage (scene HUD) | The actors in stage.cast@1 who have not left (up to 3), with avatars from character.visual@1 looked up by character ID; only the name is shown when there is no picture |
| "Speaking" marker | The last paragraph with a speaker in the latest story; it matches an actor on stage by character ID when the segment has speakerId, otherwise by displayName. While the story streams it matches the name as written, replaced by the resolved one at commit |
| Chapter banner | In a layout with backdrop: "banner", the world header image, the world name and the first three tags, shown above the first message |
| Status summary | The session.summary@1 slot: a few lines that any plugin can provide and that should be seen at a glance, such as the current goal, the time and the inventory. See Session summary below |
Where this content goes:
- In a layout with
panelTabs: "bar", the player name, stat gauges and carried items are fixed below the tabs of the right-side panel. - In a layout with
backdrop: "scene", they are drawn together with the characters on stage, over the lower-left corner of the scene picture. This area is called the scene HUD. - The scene HUD needs a session area that is wide enough (about 66rem). When it is not wide enough, the HUD gives way to the story bar, and the status is shown in the right-side panel instead. The two never appear at the same time.
- With
backdrop: "scene"and a scene name, the toolbar shows the scene name as a large title, and the world name and turn count are shrunk into one line above it.
How world authors and plugin authors get content to appear here:
- To show a value as a gauge, declare it as a
statsattribute with a range in the character schema. Astatsnumber with no declared range is shown as a line of numbers, up to 4 items. - To make an item appear in carried items, declare it as a string array of the
equipmentcategory. - Neither of these needs plugin UI.
- A plugin's own data (quests, inventory, time) does not go into character records. It appears in the same place through the
session.summary@1slot.
To the right of the toolbar are the "Text / Stage" switch, settings, the "More" menu, and the right-side panel toggle. Detailed view, raw view, Studio Config, back to the config page, and Debug Traces are inside "More".
Navigation Across Pages
Another page can jump into a session with a parameter and ask for a panel to be opened:
/session?sid=<sessionId>&panel=plugins|images- This request is used only once. After the session is restored and
GameViewhas mounted, the page handles the request and removes thepanelparameter from the address. - On a phone, the request is not swallowed by the drawer: the page waits until the drawer and the asynchronously loaded UI specs are both ready, and only then selects the target panel.
- The Images entry opens only a right-panel spec declaring top-level
navTarget: "images"; icons do not assign navigation destinations. If several panels declare it, the first bygroupOrderand load order wins. Undeclared panels remain manually accessible; community image plugins must add this field to their gallery spec. After a successful load, a missing target (including an explicitly requested plugin panel) clears the request and shows “This panel is not available in this session.” Later spec reloads do not replay it. A failed load retains the request until the player retries loading. - When the session ID changes, the session view is rebuilt as a whole. This keeps drafts and panel selections from mixing with the previous session.
- The debug page switches its data by the
sidin the address. When you return to the session from the debug page, the ID currently being viewed also takes priority.
Right Panels (Plugin-Driven)
The right-side panels are the main place where plugins show their state. First, some behavior that players can see directly:
- The session view keeps the tabs, the specs and the interaction state. On a phone, closing the drawer and opening it again keeps them.
- When a panel fails to load, the error and a retry button are shown. A panel that loaded successfully before stays on screen.
- Read-only plugin cards in past turns are mounted only when they are expanded.
- The execution status shows why a runtime was skipped. After the subscription drops, you can retry it by hand. When a normal action stream ends, the background subscription is restored; this does not count as a network reconnect.
- The local display cache for execution steps merges updates that arrive within 250ms into one update. It keeps at most the latest 500 lines, and it saves what has not yet been written when you leave the session. Real recovery still depends on the server's execution records.
- When the selection, the font, a menu or the size of a container changes, the tab bar keeps the current tab visible. Horizontal text tabs are centered. The vertical icon bar scrolls only as far as needed to show the selected item.
Design Principles
- A plugin's status panel is declared by
ui.right. It appears together with the built-in "World" document entry from the framework. The raw "Database" tab appears only after you open the developer view. See below. Lorebook has only an HTTP API. See the note at the end of the World document section. - The framework does not know specific plugins. It finds panels through
/api/ui-specs. - It renders with json-render. A plugin provides a JSON spec, and the framework provides a fixed set of components (the catalog).
- The data comes from pluginData. When plugin data changes, the server sends a
plugin-data.changedSSE event, and the panel updates.
Panel Discovery Flow
session created → GET /api/ui-specs?sessionId=<id>
→ server filters by the session's active set (session plugin scope)
→ { right: [...], message: [...], view: [...], diagnostics: [...] }
→ tabs are generated from right[] (icon + label)
→ each tab maps to one json-render Renderer
→ pluginData[pluginId][namespace] is injected as stateNOTE
A request without sessionId returns every plugin in the registry snapshot. It is used for startup and debugging. right-panel.tsx clears its state when you switch sessions and fetches again with the new sessionId, so no tabs from the previous session are left behind.
When you switch sessions, these things also happen:
- The selected group, the sub-panels and their local UI state are reset together.
- Plugin data is loaded with the target sessionId, so a response from an old session cannot be written into the current one.
- When the selected plugin group is removed, the view goes back to the "World" document.
- The vertical navigation can scroll on its own, and the narrow-screen overlay can hold a very long list of panels.
The Database tab is a developer view. It lists, row by row and as stored, every public plugin row, including content that is written only for the narrator to see, such as secrets on a character card. So it does not appear in the play screen by default.
- When the player turns on "Developer view" (
ui.developerView, off by default) under "General" in Settings, an extra "Database" tab appears in the right-side panel. - When it is turned off, the tab and its content are removed together, and an
open-databasenavigation request goes back to the "World" document. - The
/debugpage always shows the full view.
The Database panel's manual refresh, its retry after a failure, and its automatic refresh triggered by status events all use one request lifecycle. After the session or the refresh version changes, data and errors from old requests are ignored. They also cannot change the loading state of the current request.
Currently Registered Panels
The second column is the panel ID, which is the id in the UI spec. Every right-side panel of a built-in plugin must be listed in this table (a test checks this).
| Plugin/runtime | Panel ID | Icon | group | Data namespace | Description |
|---|---|---|---|---|---|
| affinity | affinity | heart | affinity | affinity | Affinity panel (two-way player↔NPC score bar + tier badge + reason for the latest change) |
| char-creator | character | users | character | session.characters | Character list (its type is defined by the current character schema) |
| character-blueprint | character-presence | image | character-art | presence | Character portrait gallery (PortraitGallery, read-only display, the player can upload to replace an avatar, alwaysRender) |
| codex | codex | book-open | codex | entries | Knowledge codex |
| core-quest | core-quest | scroll-text | core-quest | quests | Quest log (in progress, with a checklist of objectives / completed / failed groups) |
| dice-check | dice-check-panel | dices | (none) | checks | Check records (a reverse-order list of receipts: dice formula / success or failure colors / critical emphasis, alwaysRender) |
| inventory | inventory | backpack | inventory | items | Inventory (equipped group + backpack list, quantity badge + tags pill, alwaysRender) |
| dashscope-image-gen · openai-image-gen | <plugin>-gallery | image | image-studio | images | Story illustration gallery (MediaGallery, alwaysRender); each of the two image plugins has its own, and both group IDs are image-studio. Both provide the single-provider extension point media.image-flow@1, so only one of them can be enabled in the same session |
| dashscope-image-gen · openai-image-gen | <plugin>-jobs | loader | image-studio | _jobs | Generation job view (JobList, alwaysRender) |
| dashscope-image-gen · openai-image-gen | <plugin>-trigger | wand | image-studio | (none) | "Generate image" manual trigger button (expectsBackgroundFollower, alwaysRender) |
| mimo-tts | mimo-tts-audio-tab | headphones | tts-studio | tracks | Narration voice playlist (AudioPlayer, alwaysRender) |
| living-world-rules | living-world-rules | book-marked | world-data | rules | World rules (long-term settings / taboos, read-only; seeded by the world-data import, a sub-tab of the world-data group) |
| memory | memory | brain | (none) | blocks | Memory blocks. The memory plugin updates its own memory blocks in a background job after the turn, and the panel displays them |
| npc-graph | npc-graph | network | npc-graph | nodes + edges | NPC relationship graph (force-directed visualization) |
| scene-stage | scene-stage | image | scene-stage | stage | Current scene stage (read-only): scene name + day/night badge + sourceLabel status text (shows "No background" when the scene is not in the registry) |
| scene-stage | scene-cast | users-round | scene-stage | active-cast | Characters on stage in the current scene (read-only, only name + role; the internal selection signal stays in plugin_data, alwaysRender) |
| tabletop-rules | tabletop-rules | dices | (none) | checks | Attribute check entry button (calls tabletop-rules/check to open the check form, alwaysRender) |
| world-init | world-overview | map | world-data | session.dimensions | Current value and version of any dimension (read-only during play), plus pending settlement recovery (alwaysRender) |
| world-init | world-schema | sliders-horizontal | world-data | session.characterSchema | Character attribute schema |
| world-time | world-time | calendar-days | (none) | clock | Current world time (/current/display + reason for the change) |
| world-time | time-definition | calendar-days | (none) | definitions | Time rules (the current calendar definition, read-only; when there is no definition, it explains the default calendar) |
Notes on some groups:
- The
world-datagroup (group name "World Data") brings together three specs:world-overviewandworld-schemafromworld-init, andliving-world-rulesfromliving-world-rules. They form one outer tab, and horizontal sub-tabs switch between the overview, the attributes and the world rules. TheWorldDimensionsin the overview reads the public current values of the session directly. It does not build them fromentriesor the world's initial values. A dimension also no longer has a copy in a constant lorebook. - The
charactergroup is the character list fromchar-creator. It readssession.charactersfrom the session's World Model, which is the live state of the current save. A world's characters are imported from its character file into the World Model, and a plugin's data about a character (such as the roleplay notes ofcharacter-blueprint) is stored in the plugin's own namespace, keyed by the character's ID. Characters are created and imported through the World Model. Plugins do not copy characters to each other. - The
npc-graphpanel usesdataSource.bindingsto connect its ownnodesandedgesnamespaces into the panel state. It then passes them as properties toGraphCanvas, which draws the relationship graph (react-force-graph-2d, loaded on demand). - The
memorypackage registers theprompt.segment@1andmemory.block-definitions@1services and a right-side panel.runtimes/extract/RUNTIME.mdreads the frozenturn-digest@1and, as a background job withsettle: true, updates this plugin'sblocksnamespace. The panel reads these blocks directly. Its prompt segment is at the turn level and sits in the turn context after the history. In a session where this plugin is disabled, extraction does not run, the next turn does not wait for its job, and no memory is injected.
Dynamic Dimensions Overview and Corrections
The WorldDimensions component shows the current values of the world's dimensions. The generic SessionDimensionsPanel connects it to dimensions and dimensionSettlements in the session snapshot, along with trusted provider and recovery metadata. It does not take a different branch for each dimension ID.
Each public dimension item is {name,description?,schema,value,version}. The panel shows the name, the stable ID and the version of the value. It does not show the initial value or the maintenance rules.
- Display. Scalars are shown directly. Objects are shown field by field. Arrays, or objects keyed by dynamic names, can be shown as rows and columns according to the schema's properties, with column headers taken from
titleor the field name. - Read-only and editing. The dimension panel in the play screen is read-only. Only the pending settlement recovery card can be acted on. Field editing (
allowValueEditing) is turned on only in the session data view of the debug page. When it is on, the basic schema supports field editing and adding or removing array items and named records. Complex nested values have a clear JSON editing entry. - Validation. The client and the submit boundary share
validateDimensionValue. Missing values, nullable values and out-of-range errors are not fixed by forcing a type conversion.x-i18nnodes are shown in the right locale. Plain JSON does not try to guess translations. - Saving. A save sends the
expectedVersionfrom when editing started. It sends a runtime RPC through the host-specifiededitorRuntimeId, and does not bypass the reserved namespace to write directly. When a conflict happens, the panel refreshes the current value and shows an error. The player checks it and submits again. Nothing is overwritten silently. - Pending settlement card (pending). The card shows the logical turn of the source and the reason for the failure. It offers three actions: retry, mark as handled manually, and mark as skipped. Retry calls
trackerRuntimeIddirectly and includes the receiptsourceTurnId. The last two are written as final states by the editor againstsource.resultId. An ordinary saved value does not clear a pending state. - When editing is disabled. While a turn is running or a request is being processed, the edit buttons are disabled. After an action, the session view is fetched again to check the committed values and receipts. The
submittedvalue returned by the runtime is not treated as saved to the database. - When there is no recovery entry. If there is no trusted recovery entry, the panel is read-only. It does not fall back to the author's initial value.
Do not confuse the two editors:
- The world author's editor edits the full, open definition map. It allows any legal ID. The nine genre structures are only optional templates.
- The debug page edits the current values of a session. It cannot create new definitions or change schemas.
For the raw declaration format, the rebuild requirements for development data, and the recovery calls, see World Data and API. Hidden events currently have no editor for their triggers or payloads (#97).
World Document (Framework-Owned Tab)
The "World" tab is provided by the framework itself. It sits next to the plugin tabs, but /api/ui-specs does not affect it.
- Data source: the current world record’s
loreand editions inmetadata.localizedWorldTextselects the session’s content language; changing the UI language during play does not change it. World loading and import populate the store. See Session language for edition selection and fallback. - Interaction: rendered as Markdown (
@/components/ui/markdown,react-markdown+remark-gfm). Whenloreis empty,descriptionis shown. If that is also empty, "No world document" (worldDocumentEmpty) is shown. - Narrator-only text: content between
<!-- narrator-only -->and<!-- /narrator-only -->is not shown (playerVisibleLore, in@covel/shared). When the whole file is marked, it is treated as an emptylore. The "World Document" on the session-prep screen and the world detail page use the same function. For how to write it, see World Data. - Implementation:
apps/web/src/components/session/world-document-panel.tsx. It is mounted inright-panel.tsxas theworldtab, andgame-view.tsxpasses the world summary and session language; the panel then loads the full record by world ID. - Isolation rule: the code of this tab belongs to the framework. It depends only on the public fields of
WorldRecordand does not know any plugin ID.
IMPORTANT
Hiding narrator-only content is to avoid spoilers, not to keep secrets. The full text is still in WorldRecord.lore.
NOTE
The Lorebook is still served by the HTTP API (GET/PATCH/DELETE /api/sessions/:id/lorebook[/:entryId]). No built-in interface uses it at the moment. When it needs to be shown, a plugin can build its own view with a ui.right JSON spec.
How to Declare a Panel
A plugin lists the paths of its JSON files under contributes.ui.right in PLUGIN.md:
# PLUGIN.md
contributes:
ui:
right:
- ./ui/my-panel.jsonWhen this page says ui.right, ui.message, ui.view or ui.segment, it means these keys under contributes.ui. These are the four supported placements; a right panel can also select the stage decision area with surfaces, and story paragraph templates are described in Segment templates. Other placements, including the removed left, are ignored with a log message at load and reported as unknown fields by pnpm validate:plugin.
JSON Spec Format
description is an optional translatable description. bridge: "session" is only for a webview in ui.view; see Plugin views for scope and consent.
{
"specVersion": 1,
"id": "world-entries",
"group": "world-data",
"groupLabel": "World Data",
"label": "Entries",
"icon": "book-marked",
"dataSource": { "namespace": "entries" },
"emptyState": {
"message": "World entries not yet generated, waiting for initialization…"
},
"view": {
"component": "Accordion",
"repeat": { "statePath": "/entries", "key": "key" },
"children": [
{
"component": "Section",
"props": { "title": { "$item": "key" }, "icon": "chevron-right" },
"children": [
{
"component": "JsonView",
"props": { "value": { "$item": "value" } }
}
]
}
]
}
}Key fields:
| Field | Description |
|---|---|
specVersion | The version of the UI spec. It can be left out, and then it is treated as v1. A version higher than the server supports is rejected. See Spec validation and versions |
id | The unique identifier of the panel |
group | Panels with the same group are merged into one outer tab |
groupLabel | The name of the merged outer tab. Optional; when left out, the label of the first spec is used |
label | The panel's own name, shown on its sub-tab |
shortLabel | The short name used on the tab bar. Optional. See activity-bar short labels |
icon | The icon name (Lucide, kebab-case). Only the icons the framework allows can be used. For the full list, see Component catalogue / Display |
dataSource.source | Generic read-only session source: session.characters (keyed by character ID) or session.characterSchema (the schema itself, with attributes at /attributes). Takes precedence over namespace; missing data is an empty object. |
dataSource.namespace | Which data the panel reads: pluginData[pluginId][namespace]. _jobs is a special value, see below |
dataSource.bindings | Optional. Connects up to 8 more of the plugin's own namespaces, see below |
emptyState.message | The hint shown when the data is empty. See Empty state rendering |
view | The interface itself: a tree built from catalog components. The web interface runs only this declarative kind of spec. Interface declarations that are not JSON, such as .tsx or .js, are not supported. The API gives a diagnostic and removes that item |
Session sources update from complete snapshots and committed character/schema events without a plugin-owned copy. World Model fields such as characters, character schema and dimensions remain separate at the top level. Invalid dimension snapshots show a local panel notice; the opening scene still shows the world name and tags.
_jobs: a virtual data source for background jobs. When dataSource.namespace is set to _jobs, the panel receives this plugin's background manual jobs and event jobs (mapped from _runtime_jobs). They are keyed by jobId, and each value contains:
status:pending/done/failedruntimeId,turnId,startedAt,completedAt,durationMsmessage,error,reason,runtimeResults,deferredJobs
message appears only on prompt generation jobs queued with expectsBackgroundFollower. While it is running, it reads "Generating image prompt". After it is queued behind other jobs, it reads "Image job queued". It is shown in the player's language.
dataSource.bindings: connecting more of your own data. For example:
{ "nodes": "characters", "edges": "relations" }- The panel receives the data of these two namespaces in the current session under
/sources/nodesand/sources/edges, and refers to them with$state. - Both the names and the namespaces must be fixed strings. They cannot point to another plugin.
- When you switch sessions, these values are refreshed together with the data source. A namespace that does not exist is an empty object.
- The main
namespacestill decides the panel's ordinary data, and whether the panel counts as "empty".
Spec Validation and Versions
A UI spec is untrusted plugin input. Its rules exist once: pluginUiSpecIssues in @covel/shared (packages/shared/src/schemas/ui-spec.ts). pnpm validate:plugin, the ZIP install and /api/ui-specs all use it when they load a panel, so a spec that passes the check is not dropped in the game for its format.
Versions.
specVersioncan be left out, and then it is treated as v1. A version higher than the host supports (currentlyCURRENT_UI_SPEC_VERSION = 1) is rejected. This way, an old host that meets a new plugin package reports a clear error instead of drawing a broken panel.webview. Single-fileentrypoints to an.htmlfile in the package of at most 512 KiB; views also support multi-file pages;heightis an integer from 80 to 800.surfacescan only bepanelandstage.Data ownership.
pluginId,sourcePluginandsourcePluginIdcan only name this plugin's ID;dataSource.bindingscan only be fixed namespaces of this plugin, at most 8.Either
vieworwebview, not both. Each spec must declareview(an object) orwebview(an HTML document). If both are present or both are missing, validation fails.Recursive validation of
view. The checks covercomponent/type,children, namedslots,repeat, and the structure of the action bindings inon/watch. Component names must be in the sharedPLUGIN_UI_COMPONENT_NAMES. An unknown component produces a diagnostic with its path on the server.propsand directive expressions are not restricted. They are parsed at runtime by the components and json-render, so plugins can extend the shape of their data.Declarations that are not JSON.
contributes.uiaccepts only.jsonfiles.pnpm validate:pluginand installation reject any other file; at load such an entry gets a diagnostic, and the other valid JSON specs from the same plugin are still shown. The browser never runs a plugin's component file.When a custom component is needed. Declare
webview.entryand provide a separate HTML file. Or compose catalog components and bind the framework's actions. The browser does not import a plugin's.tsx/.jsmodules directly; multi-file pages may load classic.jsscripts from their published root.One bad spec does not break the whole response. A spec that fails validation is removed from where it sits, and the top-level
diagnostics[]of the response gives the specific diagnostic:ts{ pluginId, runtimeId, slot, specIndex, specId?, issues: [{ path, message, code }] }It says which plugin, which field and what the problem is, instead of a vague "Invalid panel spec".
How the front end uses these diagnostics.
right-panel.tsxprints the diagnostics to the console in development mode. The local fallback message inplugin-panel.tsxalso includes the spec name and the specific reason: a missingview, aviewthat is not an object, or a conversion failure.
UI declarations come from the registry snapshot taken at startup. Static assets are loaded on demand and cached by snapshot. A GET request only reads and filters by the session's active set. It does not scan the plugin directory, and it does not write UI definitions into plugin_data (see api.md for details).
When static assets are read, the real path is checked after symbolic links are resolved. A link that points inside the plugin root is allowed. A link that points to a file or directory outside the root is rejected. When a spec cannot be read or breaks a rule, only that spec is removed, with an explanation in diagnostics[] and in the server log; the plugin's other panels are still shown.
Activity-Bar Short Labels
What name a tab shows depends on the layout's panelTabs.
panelTabs: "bar" (horizontal text tabs)
- Tabs show
shortLabel. When none is declared, the fullgroupLabel/labelis shown, without truncation. - When not all tabs fit, the tab bar can scroll horizontally, and the mouse wheel can scroll it too. The truncated side fades out, and the selected tab stays in the middle.
- At the end of the tab bar there is one more "All panels" button. Clicking it opens a grid of all tabs with their full names.
panelTabs: "rail" (vertical icon bar on the right, activity-bar)
Each tab has only a very narrow space for text. When no shortLabel is declared, the framework cuts groupLabel (or label) mechanically:
- Chinese text with 2 or more characters → take the first two characters (for example, the Chinese for "Core Memory" is cut to its first two characters)
- Several English words → take the first letter of each word, capitalized, up to 3 ("Core Memory" → "CM")
- Anything else → take the first 4 characters
When a cut-off name is not recognizable, or it clashes with another tab's name, declare shortLabel at the top level of the spec:
{
"label": "Core Memory",
"shortLabel": "Memory"
}# locales/zh.yaml
messages:
Core Memory: 核心记忆
Memory: 记忆The merge rule is the same as for groupLabel: within the same group, the first one declared is used. We recommend that every spec in a group sets the same shortLabel, so the result does not depend on the loading order.
Who owns the interface text. A plugin's group names, short labels, panel labels, buttons and form fields are written in plugins/<id>/ui/*.json (in English), and translations are written in plugins/<id>/locales/<locale>.yaml. The framework's own i18n dictionary holds only the framework's navigation, system buttons and common status text. When several plugins share one group, each plugin's spec should set a matching groupLabel. The short label on the tab is decided by the first shortLabel in the group.
Implementation: compactTabLabel() and aggregateSpecsIntoGroups() in apps/web/src/lib/plugin-panel-tabs.ts.
Empty State Rendering
When a panel's namespace has no data (Object.keys(data).length === 0), PluginPanel does not render the view. It shows a single centered italic hint line instead.
Which sentence is shown: first the emptyState.message declared by the spec. When none is declared, an automatically generated line is used: "<panel name> has no data yet, waiting for the game to progress…".
How to write it: write the English in the spec, and put the translation in locales/<locale>.yaml. See plugin UI text rules below.
"emptyState": {
"message": "No characters yet…"
}Convention: every right-side panel that renders from namespace data must declare emptyState.message, and the hint must fit the panel's own business. Do not use a generic line. Examples from the built-in panels (English text; each one has a Chinese translation in its locale file):
| Panel spec | Empty-state hint |
|---|---|
character-panel.json | No characters yet. They appear here once the character creation flow is done… |
codex-panel.json | No codex entries yet. They appear here as the story goes on… |
memory-panel.json | Memory blocks are not set up yet. They fill in automatically after the first turn… |
npc-graph-panel.json | No characters or faction relations found yet. They appear as the story goes on… |
world-schema.json | The character attribute schema is not generated yet. It appears once the world is initialized… |
alwaysRender: true is the exception. Some panels do not render from namespace data. For example, the WorldDimensions in world-overview.json reads the session context directly. MediaGallery and JobList can handle empty lists themselves. Such a spec explicitly declares "alwaysRender": true at the top level. The framework only reads that field; it does not enable it automatically by component name. These specs may leave out emptyState.message, because PluginPanel never reaches the empty-state step. See UI components for component props.
Implementation: the PluginPanel component in apps/web/src/components/session/plugin-panel.tsx (the isEmpty check and the alwaysRender short-circuit).
Plugin UI Text Rules
A UI spec is written in one language only: English. Text in other languages goes in the messages section of the plugin's locales/<locale>.yaml file. On the left is the original English text from the spec, and on the right is the translation:
// ui/codex-panel.json
{
"label": "Codex",
"shortLabel": "Codex",
"emptyState": { "message": "No codex entries yet." },
"view": {
"component": "Text",
"props": { "content": "{{count}} entries collected" }
}
}# locales/zh.yaml
messages:
Codex: 知识图鉴
shortLabel|Codex: 图鉴
No codex entries yet.: 图鉴暂无词条。
"{{count}} entries collected": 共 {{count}} 条记录Rules:
- Which properties count as text. Properties named
label,title,content,message,text,description,placeholder,footer,tooltip,hint,help,summaryorsubtitle, and properties whose names end inLabel,Title,Message,Text,Placeholder,Description,HintorTooltip(shortLabel,groupLabel,searchPlaceholder,emptyMessage, and so on). - Translations apply only to these properties. So a translation file cannot change component names, actions or data paths. The contents of
on(action parameters),dataSourceandwatchare data, and they are not translated. - Display labels for data values.
$map.labelsvalues and string fallbacks are text too; see Value-label mapping. Text inside row templates follows the same rules. - When the same English sentence needs two translations, put the property name and
|in front of the key.shortLabel|Codexapplies only toshortLabel, andCodexis used everywhere else. - Text with no translation is shown in English. Placeholders in a translation (such as
{{count}}) must match the original. - When the English original changes, the old translation no longer matches. The interface falls back to English, and
pnpm validate:pluginreports the translation as an error. You do not need to record separately whether a translation is out of date. - A spec must not use inline
{ "zh": …, "en": … }values, and it must not contain Chinese.pnpm validate:pluginandpnpm check:i18nreject them. - Every interface text of a built-in plugin must have a Chinese translation (checked by
pnpm check:i18n). Text that is the same as the English can be repeated as it is. Text with no letters (symbols, numbers) does not need one.
What the front end receives. The loader compiles the translations into I18nText (string | Record<LocaleTag, string>) and passes them to the front end. So GET /api/ui-specs still returns values such as { "en": "Codex", "zh": "知识图鉴" }. For the shared resolution rules and fallback order, see Internationalization. Components must not decide the locale themselves.
The front-end resolver. resolveI18n(value, locale?) and useI18nResolver() are exported by apps/web/src/lib/catalog/helpers.tsx, and the renderers import them from this module directly. All built-in ComponentRenderers call this hook to subscribe to language changes, so when the language switches, the json-render subtree re-renders automatically.
Validation. pnpm check:i18n runs two scans: check-no-chinese-literal (application code) and check-plugin-i18n. The second one checks two kinds of content:
- Labels that a player can see, in plugin JSON, manifests and handlers.
- Text sent to the model: in plugins and templates, the descriptions of tools and parameters must be in English.
The files and line counts where Chinese appears in framework source are recorded in the FRAMEWORK_CHINESE_LINES table in the script. Any addition or change must update this table explicitly.
json-render Binding Cheat Sheet
| Need | How to write it |
|---|---|
| Read state | { "$state": "/path" } |
| Read and write state | { "$bindState": "/path" } |
| Iterate over a state array | repeat: { "statePath": "/path", "key": "id" } (a top-level field of the element, not props) |
| Iterate over a sub-array inside repeat | repeat: { "statePath": { "$item": "items" }, "key": "id" } |
| Read a field of the current item | { "$item": "field" } (the field name has no leading slash) |
| Read and write a field of the current item | { "$bindItem": "field" } |
| Current index | { "$index": true } |
| Named slots | "slots": { "header": [{ "component": "Text" }], "content": [{ "component": "Stack" }] } |
| Dynamic values and text | { "$math": "add", "a": 1, "b": 2 }, { "$concat": ["HP ", { "$state": "/hp" }] } |
NOTE
repeat.statePath accepts an absolute state path string that starts with /, or, in a nested repeat, { "$item": "field" }. It is not { "$state": ... }. An item's field name cannot have a leading slash: $item: "key" is correct, and $item: "/key" is wrong. The same holds for $bindItem. pnpm validate:plugin and the ZIP install check these forms. The error gives the spec file and the path of the node.
How panel state stays in sync when external data changes. A panel compares two external data snapshots, one before and one after, and syncs only the parts that differ:
- Deleted fields, and derived indexes that become invalid when an array gets shorter, are cleared. Empty objects are kept.
/_invokingand/sourcesare replaced as a whole, so buttons become usable again after a call finishes.- For fields whose external value did not change, the player's local edits made with
$bindStateare kept. Draft fields that are not taken by external data are kept too. The same rule applies when a cached panel is reused after switching panels. - State paths are escaped with JSON Pointer: a
~in a field name is written as~0, and a/is written as~1.
How arrays are checked for changes. Arrays (including the automatically generated /entries) are judged by their external content:
- A new array with the same content keeps the existing local edits. When the content or order really changes, the whole array is replaced.
- A recursive comparison checks at most 32 levels. A deep subtree that cannot be confirmed as the same within this range is treated as changed, so the cost of the traversal has a limit.
- An object field whose name is a number is still treated as an object key. It is not quietly taken as an array index.
Button state and interaction locks.
- The selected state and the "calling" state of a button are updated from the latest state that the action's dynamic parameters read.
- When panel interaction is locked, the JSON rendering area cannot be used with the mouse or keyboard. The action handler also checks the lock, so a callback that was kept cannot get around the interface lock.
- The lock works like this:
inertandaria-disabledare set on an ancestor container, and the check in the action handler is kept too. While locked, the browser skips keyboard focus on the controls inside, and user input is blocked. - The catalog's Input / Textarea / Select link localized labels with unique IDs. Switch uses a native button that does not submit forms, and it supports Tab, Space and Enter.
- Control accessibility does not get around the panel lock. No general disabled field has been added to the spec.
Standard Directives
A directive is a small calculation written in the spec, such as "join these two values". All json-render Providers the framework holds share the same set of directives: the right-side plugin panels, the plugin message surface, and the message blocks bound to a turn.
A directive can be written in any dynamic value position in component props, and it can also be written in on.<event>.params. So the parsed result that an action triggered by a button or an input receives matches what is shown.
| Directive | Use |
|---|---|
$format | Format a number, date, currency or percent |
$math | Add, subtract, multiply, divide, modulo, min/max, rounding and absolute value |
$concat | Join several dynamic values |
$count | Get the length of a string or array |
$truncate | Truncate text |
$pluralize | Choose zero / one / other text by count |
$join | Join array elements |
$t is not open yet. A plugin's natural-language text goes in text properties and is translated by locales/<locale>.yaml, so there are not two sources of translation alongside the existing i18next resources. Strings in directive parameters (for example, the fragments of $concat) are not in the translation scope. Text that needs translating should go in a text property.
{
"component": "Button",
"props": {
"label": { "$concat": ["Items: ", { "$count": { "$state": "/entries" } }] }
},
"on": {
"click": {
"action": "invokeCommand",
"params": {
"command": "bag",
"args": {
"pageSize": { "$math": "max", "a": 1, "b": { "$state": "/pageSize" } }
}
}
}
}
}Development Debugging
A development build mounts the json-render Devtools only on the right-side plugin panel that is currently active.
- Press
Cmd/Ctrl+Shift+Jto open the Spec, State, Actions, Stream and Pick debug panels. - When you switch to another right-side sub-panel, the inspection target switches with it.
- Plugin message surfaces in the message stream do not mount Devtools, so several instances do not fight over the same shortcut.
- In development, the Devtools load on demand and are not included in the production build.
Automatic entries Array
Plugin data pluginData[pluginId][namespace] has the shape { key: value }. The framework derives a /entries array from it, in the form [{ key, value }, ...], for repeat to iterate over.
The original keys can still be read directly with $state: "/someKey". Both ways can be used at the same time.
Multiple Panels and Groups (Shared Across Plugins)
group is a global string that can be shared across plugins. Several plugins, or several specs of the same plugin, that have the same group value are merged into one outer tab, and their specs switch with horizontal sub-tabs.
┌──────┬──────────────────────────────┐
│ [W] │ World Data │ ← outer tab (group="world-data")
│ [C] │ [Overview] [Attributes] [Rules]... │ ← sub-tabs (several specs / plugins)
│ [B] │ ┌──────────────────────────┐ │
└──────┤ │ (current sub-tab content) │ │
└──────────────────────────────┘Two typical uses:
- Several panels of one plugin:
world-overviewandworld-schemafromworld-initboth declaregroup: "world-data". - Combining across plugins:
world-initprovides the overview and attributes, andliving-world-rulesprovides the world rules. They all declaregroup: "world-data", and they are automatically gathered into the same outer tab.
Merge rules:
| Field | Behavior |
|---|---|
Same group | Merged into one outer tab (switched with horizontal sub-tabs) |
Different group or omitted | Separate outer tab (fallback key is ${pluginId}::${specId}) |
groupLabel / shortLabel / icon / groupOrder | On conflict, the first declarer wins, in the order /api/ui-specs returns them (the plugin load order) |
groupOrder | Sort order of the outer tab in the activity bar (smaller numbers first, default 500) |
navTarget | Optional; only "images" is supported. Each spec declares its own destination for Images navigation. Omitted specs stay manually selectable. |
Naming convention: a group name shared across plugins works like a CSS class name. Authors add their own namespace prefix to avoid conflicts, for example core.character or myorg.combat. The framework does not add prefixes automatically.
When several plugins share a group, provider switch buttons show each plugin's localized displayName, falling back to its ID when no name is available. Every group name follows this rule; a panel's label / shortLabel does not change the provider name.
Implementation: aggregateSpecsIntoGroups() in apps/web/src/lib/plugin-panel-tabs.ts. It is a pure function and can be tested on its own (see the Playwright smoke test).
Ordering
- Outer tabs are sorted by
groupOrder. The sort is stable: tabs with the samegroupOrderkeep their load order. - Sub-tabs appear in the order in which their specs were added.
- The selection and render state of a sub-panel are identified by
(pluginId, spec.id). After reordering, the selected panel is still the same one. - When the selected panel is removed, the view goes back to the first item of the group.
- Different plugins can use the same
spec.id. They do not share local UI state.
Message Area
Two Rendering Paths
The message area has two paths that run side by side. They use the same json-render catalog.
- Turn message path:
chat-messages.tsxreadsturn_messagesand SSE events, converts them into json-render specs withmessageToSpec(), and renders them withMessageBlockRenderer. The specific blocks and base components are split underapps/web/src/components/session/chat-messages/. - Plugin message path: in the same file, in the
block.type === "plugin_message"branch,ui.messageis discovered through/api/ui-specs?sessionId=, and thenPluginPanelandplugin_datarender the plugin message surface.
| Path | Content it carries now | Where it is implemented |
|---|---|---|
| Turn message | Narrative text, player input, interaction.requested forms and choices, notifications | apps/web/src/components/session/chat-messages.tsx, apps/web/src/components/session/chat-messages/ |
| Plugin message | Guide suggestion cards, the codex summary for this turn, the dice-check result block, the core-quest change block, the affinity toast, the inventory gain/loss toast, and other plugins' custom message surfaces | apps/web/src/components/session/chat-messages.tsx (the plugin_message branch) |
Updates for each turn are folded by default. In the parsed view (parsed), the read-only plugin cards of each turn are merged into one collapsed entry, "Turn updates · N". Its summary previews the titles of the first three cards. This keeps the narrative in the main space.
- Folded:
ui.render,ui-spec, and notifications at level info / success, such as codex discoveries, achievements and status changes. - Not folded: forms, options, confirmations, images, and warning and error notifications.
- Plugin message surfaces (
plugin_message) stay expanded in the current turn. After a later player message, the disclosure shows the provider’s localized name and “expand to review”, falling back to “Plugin content” when no name is available. Checks, quest updates and suggestions follow the same rule. - Players can turn on "Expand turn updates" (
ui.expandTurnUpdates) under "General" in Settings, so they are expanded by default. - The
detailedandrawviews do not fold. - The theme package's
layout.turnNotesdecides where these updates go:foldis the default behavior described above;inlineexpands the latest turn by default;marginputs them in a side margin next to the story text when the session area is wide enough.
See apps/web/src/components/session/chat-messages/turn-updates.tsx for the implementation.
Choices look the same everywhere. Anything the player chooses from is finally rendered as .ui-choice:
ChoiceList/Choicein a plugin spec;- The kernel's choice message block (the
choiceblock, which the framework turns into aChoiceList); - The reply variants of
CandidateList(one click adopts that version; "Draft / Send" is a secondary action below it); - The decision panel of the stage view.
So choices look the same in every style scheme. A new plugin follows automatically as long as it uses one of these paths.
Choosing with number keys. Choices in the current turn can be picked with number keys: pressing 1–9 is the same as clicking the nth choice on the screen.
- When a turn has several groups of choices, the numbers run continuously, with the ones that appear first coming first. The numbers drawn match the keys pressed.
- Disabled choices keep their numbers, but they do not respond.
- No response in these cases: focus is in an input box, a modifier key is held, a dialog or menu is open, or the turn is still running.
- The decision panel of the stage view supports this too.
See apps/web/src/hooks/use-choice-hotkeys.ts for the implementation. The numbers are CSS counters (see the .ui-choice section of apps/web/src/styles/ui-components.css).
Message Block Declarations
A plugin uses ui.message to declare how a block in the message area is rendered:
contributes:
ui:
message:
- ./ui/guide-block.jsonIMPORTANT
A message block appears only after it has data. A ui.message block is rendered only after data has been written into the message namespace it declares. So a plugin whose writes can be triggered only by a button inside the block never starts: there is no data on the first screen, the block does not appear, and the button cannot be clicked. branch-reply was completely invisible in its early days for this reason.
The write that makes the block appear for the first time must come from a path that is not manual: an auto runtime (which writes the first data after reading the narrative engine's output), an upstream runtime's plugin.data proposal, or a world-data import. branch-reply is an auto runtime (the default trigger, stage: post-turn, after the narrative engine) to write candidate[0]. See plugins.md § representative implementations for details.
Turn binding (__turnId). The turn a message block is attached to is decided by __turnId in the message namespace. There are two ways to write it:
| Style | How to write it | Result | Example |
|---|---|---|---|
| Namespace level | Write __turnId at the top level of the namespace | The whole namespace is one block, overwritten each turn, keeping only the latest turn | core-quest's changes |
| Record level | Write one record per turn, with the key being the turn ID and the value carrying __turnId (or turnId) | One block per turn, and the history can be reviewed | dice-check's checks |
When rendering, keys that start with __ do not go into the block's state.
When a recovery retry happens, the kernel (packages/runtime/src/commit/plugin-message-turn.ts) rewrites the __turnId that points to this execution to the source turn being recovered. This way, the card belongs to the content of the recovered segment.
Form Submission Flow
A form needs a turn ID and an interaction submission handler. If either is missing, the UI asks the player to reopen the session and retry, and keeps the entered values. It does not send the field JSON as a player message or mark the form submitted.
A form that appears during execution is temporarily disabled and becomes interactive in place when execution ends. Execution-state changes do not reset entered values.
Player fills in the form → clicks the submit button
→ submitInteraction():
1. POST /api/actions (`submit_actions`, an answering action): in one operation,
the server saves the answer, fills in narrativeTemplate and starts the follow-up turn
2. SSE `interaction.submitted`: the form is marked as submitted; unless the interaction
declares `submitBehavior.echoFilledNarrative: false`, the natural-language text is echoed
3. On the same stream, the follow-up turns are handled by the relevant plugin, which reads
`player.lastFormValues` and completes its business writesAction Guide Interaction
The guide analyzes the narrative → `generate-guide` writes to `plugin_data[message]`
→ `ui.message` renders a situation recap, the current decision, and 3–4 short lines
(stage mode also shows them through `stage.choices@1`)
→ the player clicks a suggestion and it goes to the pending-send area
→ InputBar sends the pending draft and typed input togetherSegment Templates
With ui.segment a plugin can draw the paragraphs of a committed story differently, for example add a name tag and an avatar to one character's lines. A template only displays: it is a json-render view with no actions, no plugin data and no sandboxed page.
# PLUGIN.md
contributes:
ui:
segment:
- ./ui/dialogue-nameplate.json{
"id": "dialogue-nameplate",
"match": { "kind": "dialogue" },
"view": {
"component": "Stack",
"children": [
{
"component": "Badge",
"props": { "label": { "$state": "/segment/speaker" } }
},
{
"component": "Text",
"props": { "content": { "$state": "/segment/text" } }
}
]
}
}match(required).kindnames segment kinds (narration/dialogue/action);speakernames a character ID or the name as written in the segment. Write either or both; when both are given, both must match. Each is one value or a list of 1–16 values, and a name has 1–80 characters. Narration has no speaker, so aspeakerrule never matches narration.- How speakers compare. First with the segment's
speakerId, then with itsspeakername after trimming. World characters have stable IDs, so an ID targets a world's character; a plugin that does not know IDs can use names. - Which template draws a segment. The most specific matching template wins:
kind + speakerbeatsspeakeralone, which beatskindalone; at the same level, a match by ID beats a match by name. Remaining ties go to the plugin order that/api/ui-specs?sessionId=returns, then to declaration order. Only plugins runnable in the session contribute templates, as withui.message. - State a template reads.
/segmentis the committed segment{ kind, text, speaker?, speakerId? };/indexis its position (from 0);/characteris{ id, name, type }whenspeakerIdnames a session character;/visualis that character'scharacter.visual@1slot value when the host has it. No plugin data, settings or other IDs. - Where templates apply. Only in the chat view's
parsedanddetailedmodes, and only to committed stories withsegments. When some segment matches, the story renders segment by segment: matched segments through their template, the rest as default text. When no segment matches, the message renders exactly as before. A story still streaming, the stage dialog and therawview never use templates (therawJSON showssegments). - Which components a template can use. Only components that draw nothing but their own props:
Stack,Row,Grid,Separator,Text,Badge,Icon,TagList,Source,Image,Media,AudioPlayer,Card,CardList,EntryCard,StatBar,Progress,Accordion,Section,JsonView,EntryList,Prose,PlayerMessage,Alert. Components that send messages, call runtimes, take input or read session data (such asCandidateList,Button,Choice, form and input components,SceneCastList,CharacterFieldsView,CharacterAvatarand the galleries) are not available; for an avatar, pointImageat/visual/avatar. Media always resolves in the current session, and asessionIdprop has no effect. - When something fails. A template that fails to render falls back to the default text for that segment only. A malformed template (a missing or empty
match;webview,dataSource,surfaces,group*,shortLabel,navTarget,alwaysRenderorbridge; anon/watchbinding in the view; a component outside the list above) is an error inpnpm validate:pluginand installation; at load it becomes a diagnostic with its declared path, and the plugin's other templates still apply. ui.segmentderives no capability (likeui.view): templates run no code and read no data.
Composer Modes
The composer does not only send a "say". It shows input modes from the session's action kind catalogue (GET /api/sessions/:id/action-kinds), and what it submits is an action of the selected kind.
- Modes come from the catalogue; there is nothing extra to declare. A mode is a kind the composer's seat may submit now, except
command: slash commands keep the/input and work in every mode. The composer acts for the primary seat (the first human-controlled player seat), or the first human-controlled seat when there is no primary seat.seatIdis sent only when that seat is not the primary seat. - Order.
saycomes first, then the other allowed base kinds, then plugin kinds in catalogue order. The mode picker is hidden when only one mode is left. Until the catalogue is read, onlysayis offered. The catalogue follows the active plugins: it is read again when a plugin is enabled or disabled, when a reconnect recovers, and whenever the plugin list is reloaded. When a read fails the old catalogue is dropped and onlysayis offered, so a kind that is no longer available is not shown, and no error toast appears. The selected mode lives in the page only. When the selected kind leaves the catalogue the selection falls back to the first mode, and it does not return to the old kind and its fields if the catalogue comes back. - Names. Base kinds use the interface language's
session.actionKind.<kind>. Plugin kinds usecontributes.actionKinds[].display.label,display.icon(an unknown icon name shows no icon) anddisplay.placeholderfor the input's placeholder. - Text rule. A kind with
text: requiredcannot be sent without text. Anoptionalkind may be sent with fields alone. A kind withnone(for exampleadvance) has no text input; one button labelled with the kind sends it. - Fields. A kind that declares
schemashows its fields under the input, drawn with the same input components as forms. Only a subset of JSON Schema is supported forfields: the root istype: objectandpropertiesare flat. Each is astring(a dropdown withenum, a multi-line input whenmaxLengthis above 200), anumber/integer(minimum/maximumbecome the range) or aboolean(a checkbox).titleis the label (the property name otherwise),descriptionis the placeholder andrequireddecides which fields are mandatory. Numbers are submitted as numbers and booleans as booleans. A checkbox that was never touched shows unchecked and is validated and sent asfalse, so a required boolean does not need to be checked and unchecked first. An empty required field shows "Please fill in required fields" and nothing is sent. The server still validatesfieldsin full. A schema outside this subset hides the mode. The kind stays usable through interactions, andpnpm validate:pluginwarns. - Queued selections. In
saymode the queued selections and the typed text become onesay, and the request is exactly what it was before modes. In any other mode the submission is two actions: the queued selections as asay, then the action of the selected kind. When the selected kind needs text and there is none, nothing is sent (the send button is off) and the queued selections stay; they are never turned into a plainsay. - While a turn executes. Steering carries text only, so only
saycan interject mid-turn. In other modes the send button is off until the turn ends and the composer says "Available after this turn". Slash commands work in every mode, and during a turn the send button stays on for a complete slash command. - A turn stopped before it started. The typed text, the selected mode and the field values all return to the composer. What returns is what the player typed, not the merged text or the field summary the bubble showed: queued selections stay apart from the typed text and go out first as a
sayon the next send, so sending again produces exactly the same actions. - Current character. When the composer's seat binds two or more characters and the selected mode is taken as a character (
actor: true), a current-character selector appears above the input. Switching callsPATCH /api/sessions/:id/seats/:seatId. On success the seats are updated, so the status panel and the scene HUD follow at once, and the next turn's actor is the character chosen. On failure the server's reason is shown and the old selection stays. The selector is off while a turn executes, because the request waits for the session lock. A message sent while a switch is still under way waits for it, so the bubble and the server use the same character. In browser-private mode the request runs inside the session workspace, or the next checkpoint upload would undo the switch. The request carries noactorId; the actor is always the seat's current character. - Stage free input. The stage's free input shares the same modes, fields and character selector with the composer and submits through the same path. The decision panel's option buttons still send
say. - Labels on player messages. A player message is labelled "Player" by default. When the message recorded its actions, the label becomes "character · kind", joined with commas for several actions. It stays "Player" when the seat binds one character and the message is a plain
sayor a slash command, and for older messages that recorded no actions.
Stage Mode (Stage View)
Stage mode is a visual-novel style screen: a scene background, character portraits, and a dialogue box whose text appears one character at a time.
It is the fourth way to present a session, viewMode: "stage". It sits next to parsed / detailed / raw, and you switch to it in the GameViewHeader at the top. When you enter the stage, the three parts of the message area (ChatMessages, PendingDraftsBar and MessageComposer) are all replaced by a full-screen stage. GameViewHeader stays.
- When it renders:
viewMode === "stage" && (session.phase === "playing" || hasSubmittedForm(...)). The character creation form is still completed in the original message stream. Once it is submitted, the stage opens right away, and a "Generating" status is shown until the first part of the narrative arrives. - Initial value: a world package can set
defaultViewMode: stageat the top level ofworld.yaml(it goes intoWorldRecord.metadata.defaultViewMode). Then the session opens in stage mode the first time it is mounted. After the player switches the view in the header, the player's choice wins. This choice is not saved. The initial mode can also beparsedorplugin:<pluginId>:<viewId>; bridge views request consent first, as described below. See world-data.md.
Layers and Data Sources
The stage has five layers. All are absolutely positioned, and their z-index values are set in tiers. The DOM order is Backdrop → Sprites → Hud → Dialog → Choices, and all of them sit inside one bounded relative container.
The stage reads the server's ui.slot@1 projection. Each provider builds the generic slot values from its own data, domain events and the World Model. The front end does not assemble the stage from plugin namespaces on its own.
| Layer | Data source | Front-end use |
|---|---|---|
| Backdrop | stage.backdrop@1 | Shows the background, the scene name and the status; falls back to the world's visuals when there is no picture |
| Sprites | stage.cast@1, with character.visual@1 indexed by character ID | Position, highlight, portraits, and a placeholder when there is no picture |
| Hud | stage.backdrop@1 | Shows the name, the variant and the source status |
| Dialog | The latest story text and its segments (live paragraph metadata while streaming) | Typing, paragraph splitting and per-paragraph speaker names |
| Choices | stage.choices@1 and the pending interaction choice block | Situation recap, decision question, options (.ui-choice) and free text input |
Switching between typing and the decision panel.
- "Streaming" follows the kernel's convention: there is no streaming boolean. The condition is
executingtogether with a story message ID that starts withstream_. - New narrative is shown character by character by
StageDialog, and each paragraph stops and waits for the player to click. The last paragraph also pauses once, waiting for a closing click. Auto-play moves on by itself according to the pause time. An empty paragraph at the end of the text does not produce an empty pause. - Narrative text is separate from the “Continue the dialogue” button so screen readers can read it. Live announcements wait until typing finishes for the paragraph. Tab focuses the advance button; Enter or Space continues.
- When typing catches up with the end of the stream, it does not jump to the decision panel automatically.
- After the text has been read, and the turn execution has ended, the same spot is replaced by the unified decision panel. It shows, in order: the scene, a "Current context" summary, the "Decision" question, grouped options, and an inline free text input (it shares the composer's modes, fields and character selector; see Composer Modes).
- Right after submitting, the whole old decision panel is hidden, including areas added by plugins. While the new narrative is coming, "Generating" is shown. Narrative that was already read is not replayed.
- When a session is restored, or when you switch into the stage from another view, the latest narrative that already existed before mounting counts as read, so it is not typed out again.
stage.choices@1.recapis optional. When omitted or empty, the panel omits “Current context” and does not extract a summary from the story. Ifdecisionis omitted, the scene or a generic question guides free input. Reading narration and submitting free input still work without a suggestions provider.- Omitting
turnIddeclares unscoped choices. Choices with aturnIdappear only when it matches the current story turn; they stay hidden when that turn is unknown. A committed retry can resolve to the source story through the turn resolver. Providers should setturnIdon choices that expire with the turn, without relying on the framework to infer old data.
Previews and caching of slots.
- While an execution is in progress, stage slots can receive preview events. After submission, the server recalculates them and caches the result. When the session is restored, the submitted slots are read again.
watchlistens only to the provider's own namespaces. A preview is discarded when it fails or is cancelled. Private plugin data is never handed to the stage directly.- The server's slot cache keeps up to 256 sessions, in least-recently-used order. A session that is handling a request, queuing a projection or dispatching an event is never evicted. During concurrent work the cache may briefly go over the limit; idle entries are reclaimed once the work finishes.
- A read gets its result before the session state is released. So a cache eviction cannot make a projection that already succeeded return an empty result.
Portrait highlight. Each actor in stage.cast@1 can have an optional active flag. As soon as one actor explicitly sets it, each one is shown with its own boolean. When all of them are left out, the first actor who has not left the scene is highlighted by default.
Speaker name tags.
- Name tags come from the
segmentsof the committed story message (see story output ofnarrative-engine@2): each paragraph is one segment, and its name tag is that segment'sspeaker. At commit the kernel replaces each speaker it can resolve with the character's current name. No plugin needs to provide data for name tags. - While the story streams, name tags come from the
segmentofnarrative.delta(see protocol): a paragraph's speaker shows as soon as it arrives, as the model wrote it. At commit thesegmentsofnarrative.completedreplace it with the resolved current name, for example an alias with the canonical name. The client never resolves names itself. - The typewriter and the speaker check share
splitStageParagraphs. CRLF is turned into LF, and three or more line breaks count as one blank line separator. After the text ends, the number of paragraphs must match the number of segments. - A character name tag is not shown for narration, for a segment without a speaker, or for an old message or prose story without
segments. An actor's focus does not take part in speaker attribution.
Size and portraits.
- On narrow screens the decision panel takes up to 60% of the area below the stage. On wide screens it takes 65%. It always stays inside the stage.
- The recap is folded by default. Click "Current context" to expand it. The recap, the question and the options share one scrollable content area. The free text input is always visible.
- Portraits fade out at the bottom. On narrow screens the position is raised so the face shows above the decision panel. On wide screens the original position height is kept.
- When there is a character picture, no name badge is layered on top. When there is no picture, the name placeholder is kept.
Background Fallback Chain (resolveBackdrop)
The input is the value of a stage.backdrop@1 slot, which matches stageBackdropSchema. label is used only for the status text and does not affect the fallback.
| Tier | When it triggers | What it shows |
|---|---|---|
scene | The slot ref is a MediaRef | Renders the scene picture (a 600ms crossfade when the picture changes) |
hero | The slot is missing, or has no valid ref | The world's header image (worldVisual().image, which is the cover in world.yaml) |
gradient | A theoretical last fallback | The world's accent gradient (the selector does not currently return it) |
History Drawer and Form Modal
- History drawer: the History button on the Hud opens a Radix
Dialog. Inside is the full<ChatMessages viewMode="parsed">, wrapped inflex h-[80vh] flex-col. So you can still look back and scroll through all the parsed messages while in the stage. - Form modal: the stage dialog takes only options and free text. So when a form-type interaction block that has not been submitted yet appears in the messages (
extractPendingFormMessages), the stage opens aDialogand usesMessageBlockRendererto let the player fill it in. It closes automatically after submission.
Implementation: apps/web/src/components/session/stage/ (StageView.tsx does the assembly. The others are Stage{Backdrop,Sprites,Hud,Dialog,Choices}.tsx, stage-selectors.ts and use-typewriter.ts).
Plugin views
A plugin view fills the story column. Players choose a page from the header’s “Plugin views” menu, which lists views of active session plugins. A host bar shows the view and plugin names. The host draws a “Back to default view” button in both the header and the view bar. Both remain available during execution, outside the plugin DOM.
Declare spec files in the root PLUGIN.md:
contributes:
ui:
view: [./ui/story-view.json]{
"id": "story",
"label": "Story reader",
"description": "Read the current story",
"webview": { "entry": "./story-view.html" },
"bridge": "session"
}Write source text in English and translations under messages in locales/<locale>.yaml. See session-view-probe.
idandlabelare required.idmatches^[a-z][a-z0-9-]{0,63}$and is unique within the plugin. The first duplicate wins; later declarations produce diagnostics.- Declare exactly one of
viewandwebview. Optional fields areicon,description,dataSource,emptyStateandalwaysRender. A declarative page without data can usealwaysRender: true. surfaces,group,groupLabel,groupOrder,shortLabelandnavTargetare not allowed here; they route panels and stage surfaces.- The mode is
plugin:<pluginId>:<viewId>, for exampleplugin:session-view-probe:story. World and packdefaultViewModeaccept this format,parsedandstage. Authors can selectdetailedandrawmanually only. - While views load, the host view is shown. Missing views, disabled plugins, request failures and render errors fall back to it. The host default is
stageonly when the world’s default is exactlystage, otherwiseparsed. A plugin view is never a fallback target. The player’s selection survives a transient request failure and can recover on a later successful load. Render errors select the host default and show a toast. - The choice lasts for the current mount and is not saved per session. Plugin views do not use stage immersion and do not show the host composer.
Multi-file pages
Pages are plugin views; there is no contributes.ui.pages. Only contributes.ui.view accepts webview.root; right panels and message blocks keep single-file HTML. Pages keep the story-column layout and do not replace a host main-view or composer system position.
Authors declare this in the spec:
{
"id": "console",
"label": "Console",
"webview": { "root": "./ui/console/", "entry": "pages/index.html" },
"bridge": "session"
}root is relative to the package root, regardless of the spec JSON directory. entry is relative to root and must end in .html. Neither may be absolute or contain a .. segment; root cannot be ., ./ or the package root. Without root, entry stays relative to the spec file.
Authoring and API output have separate shapes: single-file output is webview: { html, height? }; multi-file output is webview: { url, height? }. The URL is /plugin-assets/<pluginId>/<digest>/<entry>, with each segment encoded separately. Multi-file pages load directly into a sandbox="allow-scripts" iframe, without srcdoc or an outer frame host.
| Publication rule | Limit |
|---|---|
| HTML | Only the declared entry is published; other .html files are omitted. Static planning does not apply the inline HTML limit of 512 KiB; entry responses are capped at 2 MiB and return 404 above it |
| Classic scripts, styles, data | .js, .css, .json, .txt |
| Images and font files | .png, .jpg, .jpeg, .gif, .webp, .avif, .svg, .woff2, .woff; font files may be published but pages cannot load their URLs, as explained below |
| Other types | .mjs, .wasm, .map, extensionless files and other types are omitted; extensions are compared in lowercase |
| Hidden files and links | Names starting with ., node_modules, symlinks, Windows junctions and special files are omitted at every depth; links are never followed |
| Reserved name | __covel__ at any depth is an error; it belongs to the host bridge script |
| Root check | Direct PLUGIN.md, RUNTIME.md or .covel-install.json entries are rejected; the root cannot be the package root |
| Count and size | At most 512 published files, 32 MiB total, 8 MiB per file; exact boundary values are valid |
Every eligible file in the root is publicly downloadable. Keep keys, server code and private data out of it. Files outside the listing always return 404. See the asset API.
The entry response CSP is:
| Directive | Value |
|---|---|
default-src | 'none' |
script-src | 'self' |
style-src | 'self' 'unsafe-inline' |
img-src | 'self' data: blob: |
font-src | 'self' data: |
connect-src / frame-src / form-action / base-uri / object-src | 'none' |
sandbox | allow-scripts |
frame-ancestors | 'self' |
Use classic external scripts such as <script src="app.js"></script>, without modules, inline scripts, event attributes or eval. Inline styles are allowed; network requests are blocked. Embed custom fonts as data:font/woff2;base64,... (or WOFF) in CSS @font-face. Font URL requests use CORS, and the sandboxed document has an opaque origin, so even relative .woff / .woff2 URLs are cross-origin. The asset route sends no CORS allow headers, so those URLs cannot load. font-src 'self' data: is unchanged; publishing a font file does not make its URL usable. 'self' uses the browser-visible origin, supporting Vite and reverse proxies without a path-prefix restriction. The route checks the published listing for containment. The main page's frame-src 'self' limits iframe navigation; /api separately rejects opaque origins and embedded destinations. See the security boundary.
The host injects the classic bridge script __covel__/bridge-<hash8>.js after the entry's <head>. With no <head>, it uses <html>, then the doctype, then the start of the document. Nested entries get the required ../ prefix. Authors do not copy bridge code. The script acknowledges its port with ready; URL pages wait 8 seconds, then show a failure overlay with Retry over the iframe. A credential-free HEAD probe distinguishes 404 ("This page was updated; reload it.") from other failures ("This page could not be loaded."). A late ready clears the error. Retry fetches the spec again and remounts the iframe; host back controls stay available.
The digest is the first 32 lowercase hexadecimal characters of SHA-256 over JSON lines of [relativePath, size, sha256(content)], sorted by relative path. It is computed on the first registry projection and recomputed on re-registration. Production files should change only on restart; old file copies are not retained. After re-registration old digest URLs return 404 and the new URL works. Entry HTML uses Cache-Control: no-cache and must revalidate on every reuse; its ETag includes the bridge script hash. A host upgrade therefore returns HTML referencing the new bridge even when plugin files and the URL are unchanged. Other assets, including the bridge script with its hashed filename, keep a one-year immutable cache.
Per-capability consent for bridge: "session" is unchanged. The whole-spec consent hash includes the URL, whose digest covers every published file; any file change invalidates old consent. A session-reading page is not mounted before consent. No session write operation or per-session view persistence is added. In development, an unapproved server-code plugin may need a restart to recompute its digest.
Consent before reading a session
Only a webview in contributes.ui.view can declare bridge: "session". Until the player consents, the page is not mounted and no session projection is sent. A world default naming it also opens the consent prompt while showing parsed / stage underneath.
The capability is session reading, bound to the plugin ID and a SHA-256 hash of the entire loaded spec, including inlined HTML. SettingsStore persists the record on this device at ui.pluginViewConsent.<pluginId>.<hash>.session. Changing the spec or HTML asks again. Choose “Revoke story access” in the view bar’s “View permissions” menu to revoke and return to the host default. This consent is independent of server-code approval and applies to every plugin source.
SessionProjection (version 1)
After consent, the page reads the session field of the window.covel state object, and can also use window.covel.subscribe to follow changes. The table below describes that field. The projection does not repeat top-level locale and theme.
| Field | Content |
|---|---|
version | Always 1 |
phase | setup or playing |
turn | Committed player turns, completedPlayerTurns |
executing | Whether execution is in progress |
messages[] | { seq, role, text, truncated? }. Only assistant messages with kind === "story" and user messages without a block. seq is a stable positive ordinal allocated on first observation; host eviction and history loading do not renumber it |
streaming | { seq, text } or null. A stream_ placeholder appears only here, never in messages; the final story message retains its ordinal |
pending[] | { seq, block }. Interactions the host still expects the player to answer. seq is the stable ordinal of the same message; hidden block fields are removed |
characters[] | Public id, name, type, with optional aliases, description, fields. Other character fields are excluded |
slots[] | { slot, key?, value } for kernel UI slots the host already shows; media references stay opaque objects |
Ordinals last for the current session bridge instance and are never reused. Newly loaded older history receives new ordinals. Render messages in array order; do not sort by seq.
The projection excludes message id, turnId, requestId, runtimeId, timestamps, internal non-story messages, reasoning, execution steps, plugin data rows, settings, provider keys and session tokens. Building it never reads plugin_data. A public character id identifies a character; it is not message metadata.
Nested values recursively drop every key starting with _, plus hidden, concealed, turnId, requestId, runtimeId, sessionId, timestamp, createdAt, updatedAt and uuid. Values beyond depth 8 become null. The host validates the projection; invalid projections are withheld with one warning.
| Content | Limit and handling |
|---|---|
| Message window | Last 200 messages, at most 256 KiB of message text total; oldest messages drop first |
| Single message text | 32 KiB, with truncated: true when cut |
| Streaming text | 64 KiB |
| Characters | At most 100, each at most 8 KiB serialized; oversized records are skipped |
| Pending interactions | At most 8; each block at most 16 KiB serialized; oversized blocks become { truncated: true } |
KiB counts UTF-8 bytes. State changes push a full projection. Streaming uses coalesced { type: "stream", seq, append } messages at 100 ms intervals, with an immediate tail flush at completion. Every full push includes the current streaming text for resynchronization. A narrative reset invalidates the old append baseline: the next push replaces the discarded draft with the full current text, even when replacement text outgrows it within 100 ms. Normal completion still flushes the final tail.
The session bridge is read-only in this version. It adds no submitAction, form submission, abort or open-panel capability. Existing invokeRuntime, invokePluginAction, invokeCommand, emitEvent and setUiState handlers are unchanged. Images travel only as references, without bytes or blob URLs, so the sample page shows no art. There is no composer. Single-file pages have no handshake timeout notice; see multi-file pages for timeout and Retry behaviour. Host back controls remain available.
Component Catalogue
The component catalogue is on its own page: ui-components.md. There are currently 50 components. The covelRegistry exported by apps/web/src/lib/catalog.tsx is the authority.
Data Flow
Right-Panel Data Flow
plugin local tool / builtin tool / function handler proposes plugin_data writes
→ proposal validation and execution commit
→ store write
→ eventBus emits plugin-data.changed
→ pushed to the front end over SSE
→ pluginData store updates
→ json-render Renderer re-renders automaticallyMessage Area Data Flow
Turn runs → each Runtime runs behind the stage barrier and the DAG inside each stage
→ SSE event stream:
narrative.delta → streamed narrative is appended
narrative.completed → the full narrative message
interaction.requested → interaction block (form / choice)
execution.started/completed → execution step status
plugin-data.changed → plugin data update
→ chat-messages renders the turn messages
→ the `plugin_message` branch of the same component renders `ui.message`Extension Guide (Third-Party Plugins)
None of the three steps below requires changing framework code.
To add a right-side panel:
- Add
./ui/my-panel.jsonundercontributes.ui.rightinPLUGIN.md. - Create
ui/my-panel.json, and write a json-render spec with the components in the catalog. - Write to
plugin_datain a local tool, a builtin tool or a function handler. - The framework discovers the panel automatically and generates the tab. After that, whenever the plugin data changes, the panel updates with it.
To add a plugin message surface:
- Add
./ui/my-block.jsonundercontributes.ui.messageinPLUGIN.md. - Create
ui/my-block.json. - Write to
plugin_data[pluginId][message]in a runtime or a tool. - The
plugin_messagebranch ofchat-messages.tsxdiscovers the spec automatically and renders it.
To add an interaction block bound to a turn:
- A function returns
effects.interactions, or a tool returns a pendinginteraction.requestproposal. An agent requests interaction through tools such ascreate-formorcreate-choices. - The kernel validates and commits it. An identically named field in business
outputalone has no effect. chat-messages.tsxrenders the form, choice or confirmation UI throughmessageToSpec().
Gameplay Completion and Reading Order
Reading order.
- Within one turn, story messages come before the plugin message surfaces. This holds even when a plugin message surface arrives first during streaming or data recovery.
- The message order is decided by which turn a message belongs to, not by client timestamps.
Failures must be visible.
- After the task chip is collapsed, an execution failure is still visible, along with the affected runtime and its original retry action.
- A turn that only partly completed must not look like it finished cleanly.
Sending.
- The input box can submit only the queued choices, without typing anything.
- The main send button and the choice bar both send the queued actions together with the typed text.
- When an interaction RPC is rejected, the form stays editable. It never falls back to sending the form fields as an unvalidated story message.
Other.
- The panel-size callback uses the size it is given. It does not query constraints actively while the panel is registering a change.
- Body text uses the current theme's tokens in both light and dark modes.
Plugin-Provided Components and Stage Mounting
When the components in the catalog are not enough, a plugin can bring its own HTML. In the JSON spec, webview is used instead of view, and only one of the two can be written:
{ "webview": { "entry": "./widget.html", "height": 280 } }- What the component can do. Through the message bridge it receives the current style scheme (
themeand the--covel-*CSS variables), and it can call actions such asemitEventanddraftMessage. - Enlarging. Every plugin panel in the right-side panel can be "enlarged" to show in a large dialog.
- Showing on the stage. A
ui.rightspec can declaresurfaces: ["panel", "stage"]. When it is not written, it shows only in the right-side panel. - Player confirmation and loading failures. A stage plugin panel’s
sendMessagefills the input for the player to edit and send. The stage reuses the session’s loaded panel definitions. If loading fails, it shows a message and Retry while the player can still use the input. - Isolation. Single-file HTML sits in two sandboxed opaque-origin frames; views can also use multi-file pages:
- The outer layer is the static document
/plugin-frame.htmlin the web build. It is loaded by URL, so it has its own CSP and does not inherit the main page's policy (the main page does not allow inline scripts). Its CSP stops the plugin document from navigating itself to a network address, and the port is handed over only once. - The inner layer is the plugin's HTML. It keeps inline scripts and the
window.coveldata / action bridge. The structure of the component code and of the business state belongs to the plugin.
- The outer layer is the static document
For the policies of both layers and the validation of the bridge, see security.md. For the full protocol, resource limits and examples, see the plugin extension contract.
Kernel UI Slots
Kernel UI slots are a few fixed positions on the screen, such as the stage background, the characters on stage and the status summary. A plugin does not draw these positions directly. Instead, it provides data, and the framework draws them.
How to provide one. A plugin declares a provider for ui.slot@1 in contributes.extensions, and registers a handler in its server entry. slot chooses which slot to fill:
stage.backdrop@1stage.cast@1stage.choices@1character.visual@1session.summary@1stage.music@1
How several providers combine.
- Providers run in the order they declare, and then in the order of plugin ID / provider ID.
- A handler receives
{ slot, previous, events }, and it can usectx.pluginDatawithin its own scope and the kernel'sctx.worldview. - The host validates each provider's output against the slot's model.
- A failed provider is skipped. It cannot replace the valid value before it.
When it is recalculated.
watchlists the provider's own namespaces. When one of these namespaces has a commit, the projection is invalidated and recalculated, with a 50 ms debounce.previewlists the validated domain event topics. A provider can give a preview based on these events before the commit.- A preview is temporary and belongs only to the current turn. It is discarded when the turn completes, fails or is cancelled. It never replaces the committed cache.
- A result that arrives after a terminal event is discarded.
Character pictures.
- The character picture provider returns
{ characters: CharacterVisualModel[] }. This includes art that was imported before the character was created. - The provider is responsible for normalizing the IDs. The host emits one keyed
character.visual@1snapshot for eachcharacterId. - The front end looks them up with exactly the same key. It does not guess the plugin namespace or the suffix of an ID.
- The stage, avatars, the portrait gallery and the list of characters on stage all read these slots.
Stage Slot Values
These are the return values for the four stage slots, defined in packages/shared/src/extension-points/ui-slot.ts. Each provider returns the object for its declared slot, without bundling the four objects together. Return previous when there is no change, or null to clear the slot.
The four slot objects, and the actor, visual and choice objects below, accept only the listed fields. An invalid output skips that provider and keeps the previous valid value; it does not reject the whole session. I18nText is a string or a map from locale to string. MediaRef is a reference returned by the media store, not a path or a bare URL.
stage.backdrop@1
Every field is optional. {} is also valid.
| Field | Type | Required | Meaning |
|---|---|---|---|
sceneId | string | No | Scene identifier |
name | string | No | Scene name shown in the HUD |
ref | MediaRef | No | Current background image; when absent, use the background fallback chain |
variant | "day" or "night" | No | Day/night marker. The HUD shows a moon only for night, otherwise a sun. Other enum values are not accepted |
label | I18nText | No | Extra status text in the HUD; does not affect background fallback |
preload | MediaRef[] | No | Images to preload; the schema has no item limit, and the client skips references whose MIME type is not image/* |
stage.cast@1
The value is { actors, retainWhenEmpty }. Both fields are required.
On an actual location change, built-in Scene Stage clears existing direction actors before applying new cues. Preview and commit use the same scene identity and ordering. Existing direction state with no new entries yields empty actors and retainWhenEmpty: false, preventing old-location portraits from remaining. Without direction state, the original cast provider remains in use.
| Field | Type | Required | Meaning |
|---|---|---|---|
actors | Array of the objects below, at most 64 | Yes | Actors currently on stage; an empty array is valid |
retainWhenEmpty | boolean | Yes | When actors is empty, true keeps the previous portraits without highlights; false clears them. The schema has no default |
Each actor:
| Field | Type | Required | Meaning |
|---|---|---|---|
characterId | Non-empty string | Yes | Character ID; uses the same ID to read character.visual@1 |
displayName | Non-empty string | Yes | Display name |
type | string | No | Character type |
description | string | No | Character description |
active | boolean | No | Whether to highlight this actor; if all actors omit it, highlight the first actor who is not exiting |
exiting | boolean | No | Whether the actor is exiting |
visual | { variantId?: string, outfit?: string, expression?: string, pose?: string } | No | Requested portrait variant; all four fields are optional |
position | left / center-left / center / center-right / right | No | Requested position |
transition | none / fade / slide-left / slide-right / dissolve | No | Portrait transition; the client uses fade when omitted |
Per-paragraph attribution is stored in story message segments; the stage.dialogue@1 slot has been removed. See Story output.
stage.choices@1
| Field | Type | Required | Meaning |
|---|---|---|---|
turnId | string | No | When set, the choices apply only to the matching story turn; omitting it leaves them unscoped |
scene | I18nText | No | Scene text in the decision panel |
recap | I18nText | No | "Current context" recap; hidden when omitted or empty |
decision | I18nText | No | The question to decide now |
choices | Array of the objects below, at most 64 | Yes | Choices; an empty array is valid |
Each choice:
| Field | Type | Required | Meaning |
|---|---|---|---|
id | Non-empty string | Yes | Choice identifier |
text | string | Yes | Text displayed and used for the player's message; the client trims it and skips blank choices |
label | I18nText | No | Extra description; does not replace text |
See Stage mode for turn matching, the default decision question and free input.
Stage Music
stage.music@1 stores "what music should be playing behind the session right now":
{ trackId?, title?, ref?, loop?, volume?, fadeMs? }It is a state, not a command.
- The client plays
ref(anaudio/*media reference). - When
trackIdchanges, the track changes with a crossfade. - When the value has no
ref, it stays silent. - So after a page refresh, a session restore or a fork, you hear the same music. When a provider gives the same track again, it is not replayed from the beginning.
Details:
loopdefaults to true.volume(0–1) is the volume the author set for this track. The player's own volume and mute switch apply on top of it.- A provider with nothing to say returns
previous. This lets several plugins share the slot in declared order: one plays the world's own tracks, and another fills in a generated track where the world has none. - Playback is handled by the client. When another sound is playing on the page (for example, a narration line), it lowers the music. It pauses when the page is hidden. When the browser does not allow automatic sound, it waits for the first click or key press before starting.
- The player's switches are in the session header and in "Settings → General" (
audio.musicEnabled,audio.musicVolume).
The built-in soundtrack plugin fills this slot with the world's own tracks. It records the mood (music.cue) and the scene (scene.set), and it chooses a track when the slot is projected. See world-data.md.
Session Summary
session.summary@1 holds what a plugin wants the player to see at a glance: the current goal, the time, and what the player is carrying.
The client shows it where the current layout keeps its status: below the text-tab panel, or on the scene HUD. So a plugin declares it once and does not need to know what the layout is.
Shape of the value. { entries: SessionSummaryEntry[] }, up to 16 entries. Each has an id, a label (I18nText) and a kind:
kind | Fields | Drawn as |
|---|---|---|
text | value (I18nText), tone? | A label and a line of text |
meter | value, max, min? (0), tone? | A label, a gauge and value / max |
list | items (I18nText[], ≤24), total? | A label and a row of small tags; when total is larger, +N is shown at the end |
tone is info, success, warning or danger.
Rules for writing it.
- Providers form a chain: each provider returns the entries that came before it, plus its own.
appendSummaryEntries(previous, entries)from@covel/plugin-handlers-utilsdoes this. - Prefix each entry ID with the plugin's own name, for example
inventory.items. A later entry with the sameidreplaces the earlier one. - Keep it short: one to three entries per plugin. Leave the details to the plugin's own panel.
contributes:
extensions:
- point: ui.slot@1
id: summary
slot: session.summary@1
order: 20 # lower runs first and shows first
watch:
- quests # own namespaces whose commits refresh the summaryimport { appendSummaryEntries, labelText } from "@covel/plugin-handlers-utils";
export default function (covel) {
covel.provideExtension("ui.slot@1", "summary", {
async handler({ previous }, ctx) {
const quests = await ctx.pluginData.list("quests");
return appendSummaryEntries(previous, [
{
id: "quest.current",
kind: "text",
// English here; `locales/<locale>.yaml` translates it under `messages`.
label: labelText(ctx, "Objective"),
value: quests[0]?.value?.name ?? "",
},
]);
},
});
}The built-in world-time (current time), core-quest (the next unfinished objective and its progress) and inventory (carried items) all provide entries this way.
Several rules on data ownership.
- A panel's data binding and the
pluginIdfield of an action can point only to the plugin that owns the panel. The loader rejects targets that point to another plugin or that are computed at runtime. - To share data between plugins, use a kernel slot or the World Model.
- A component that needs kernel data before its own namespace has records can bind a session source with
dataSource.source. A component that reads session context itself must setalwaysRender: true. - Panels in past messages render from their saved
stateOverride. They do not subscribe to live namespaces, tasks or the whole session context. A collapsed past card is not mounted. - Live plugin namespaces have only one source of truth on the front end: the external plugin-data store. The session reducer keeps only the
messagenamespace needed to build snapshots of past message cards, along with the derived persistent task execution steps. Updates to unrelated namespaces do not replace the session context's values.
Suspended Resume Panel
After an agent runtime calls suspend, a badge appears on the session toolbar. Clicking it opens the "Suspended executions" dialog (suspensions-panel.tsx).
Each card uses reason as its title, and it picks the answer control from resumeSchema (suspension-input.ts):
resumeSchema | Control |
|---|---|
boolean | Yes / No buttons |
enum, or const in oneOf | Option buttons |
string | Text box |
Object schema compiled from suspend.fields | Form controls labelled with each property's title; answer keys are field names |
| An object / number schema that the dimension value editor can cover | A form (reusing DimensionValueEditor) |
| No schema, or no control can cover it | A raw JSON text box inside the "Advanced" collapsible section |
Loading Older Messages and Execution Status
- When loading an earlier page while scrolling up fails, a translated failure message and a "Retry" button appear at the top of the message area. The message disappears after a successful load.
- When the execution timeline meets a runtime status that the client does not recognize, it shows a neutral "Unknown status" label and logs it once to the console. It is never drawn as "completed".
Manual performers and orchestration location
Turn Policy declares Choose actors through the generic action catalogue in both composers. Its strict flat fields object requires one multiline actors string, avoiding unsupported arrays. Players and hosts may use it; spectators retain Advance. The framework recognises no bundled wire ID. Orchestration under Loaded Plugins groups the provider and three limits, linking to the provider's own settings for algorithm-specific fields. See session orchestration settings for automatic resolution with one provider, selection with multiple providers, and clearing stale selections.