Generator Workbench Runtime Communication
This page focuses on one thing only: how generator-workbench and the generator runtime communicate with each other.
If you only need the shell overview, read Generator Workbench. If you need the raw runtime protocol surface, read Runtime Contract. This page sits in the middle and explains the actual communication model.
What This Page Answers
Use this page when you need to answer questions like:
- How does the runtime know the current route capability mode such as
?mode=embed? - Which parameters does
generator-workbenchpass into the runtime? - Which events can the runtime send to
generator-workbench? - Which commands can
generator-workbenchsend back to the runtime? - How does embed-mode bridge forwarding relate to runtime events?
Communication Directions
There are two official directions:
| Direction | Entry | Purpose |
|---|---|---|
runtime -> workbench | runtime.subscribe(listener) | Report runtime events such as state-change, params_change, or select_template |
workbench -> runtime | runtime.dispatchWorkbenchCommand?(command) | Receive shell or host commands from generator-workbench |
Inside generator-workbench, both directions are managed by the public communication layer:
RuntimeEventRegistryRuntimeEventChannel
Complete Event Tables
Before reading the details, keep this distinction clear:
mount({ mode, routeMode, target, state, panelFilter })is parameter passing, not an eventruntime.subscribe(listener)is the standardruntime -> workbenchevent channelworkbench.dispatchRuntimeCommand(...)/runtime.dispatchWorkbenchCommand(...)is the standardworkbench -> runtimecommand channel
Runtime -> Workbench
| Event | Direction | Payload | Current workbench handling |
|---|---|---|---|
ready | runtime -> workbench | none | currently reserved for runtime lifecycle signaling; no built-in shell action |
state-change | runtime -> workbench | { state, patch?, source? } | used by auto-save orchestration when enabled |
params_change | runtime -> workbench | { data: { field, value, params? } } | dispatches runtime-params-change as a DOM custom event |
panel-schema-change | runtime -> workbench | { schema } | currently reserved; no built-in shell action |
warning | runtime -> workbench | { code, message } | currently reserved; no built-in shell action |
error | runtime -> workbench | { code, message } | currently reserved; no built-in shell action |
select_template | runtime -> workbench | { data: { name, category? } } | dispatches runtime-select-template; forwards generator_toSelectTemplate in ?mode=embed |
Workbench -> Runtime
| Command channel | Direction | Payload | Current built-in command names |
|---|---|---|---|
runtime.dispatchWorkbenchCommand?(command) | workbench -> runtime | { type: string, data?: unknown } | none reserved yet; command names are host-defined |
Current documented example command:
| Example command | Direction | Payload | Purpose |
|---|---|---|---|
open-login | workbench -> runtime | { source: 'topbar' } | example only, shows how the shell or host can ask the runtime to open a login-related panel |
1. Parameters Passed From Workbench To Runtime
Mount Parameters
Every runtime mount still starts from:
await runtime.mount({
mode: 'full' | 'embed',
routeMode: 'full' | 'embed',
target: 'full' | 'canvas' | 'panel',
container,
state,
panelFilter,
})The key distinction is:
mode: how the runtime is mounted into the current host containerrouteMode: what the page route capability mode actually is
Why routeMode Exists
This is most important in shell mode.
Example:
await runtime.mount({
mode: 'full',
routeMode: 'embed',
target: 'full',
container,
})This means:
- the runtime is mounted as a full workspace
- but the page itself is running under
?mode=embed - so the runtime can branch on route-level capability while keeping the same workspace mount path
Recommended Generator Usage
async function mount(options: {
mode: 'full' | 'embed'
routeMode?: 'full' | 'embed'
target?: 'full' | 'canvas' | 'panel'
container: HTMLElement
}) {
const routeMode = options.routeMode ?? 'full'
if (routeMode === 'embed') {
disableShellDependentUi()
}
renderApp(options)
}2. Events Passed From Runtime To Workbench
The standard runtime event entry is:
const unsubscribe = runtime.subscribe((event) => {
// generator-workbench listens here
})Note: all runtime events below depend on runtime.subscribe(listener) being implemented. If the runtime does not expose this entry, the workbench will not receive these events.
Current documented runtime event shapes include:
type RuntimeEvent =
| { type: 'ready' }
| { type: 'state-change'; state: Record<string, unknown>; patch?: Record<string, unknown>; source?: string }
| { type: 'params_change'; data: { field: string; value: unknown; params?: Record<string, unknown> } }
| { type: 'panel-schema-change'; schema: PanelSchema }
| { type: 'select_template'; data: { name: string; category?: string } }
| { type: 'template_publish_media_change'; data: { generatorImage?: string; cover?: string } }
| { type: 'warning'; code: string; message: string }
| { type: 'error'; code: string; message: string }Built-in Workbench Handling
generator-workbench currently has built-in handling for:
state-change- used by the shell auto-save flow
params_change- dispatched as the DOM custom event
runtime-params-change
- dispatched as the DOM custom event
select_template- dispatched as the DOM custom event
runtime-select-template - forwarded as
generator_toSelectTemplatewhen the route is?mode=embed
- dispatched as the DOM custom event
template_publish_media_change- overrides
generatorImageandinitialData.coverfor template publish
- overrides
How Runtime Passes Specific Parameters To Workbench
First, keep the boundary clear:
- the official
generator-workbench -> runtimeinitialization path is stillmount({ mode, routeMode, target, container, state, panelFilter }) - there is no generic
runtime -> generator-workbenchdirect channel that automatically injects arbitrary runtime fields into shell config
That means if the runtime wants the workbench to know about some specific data, the recommended paths fall into three categories.
1. Use built-in runtime events when the semantics already exist
If the parameter already matches a built-in workbench meaning, emit the documented runtime event:
params_change- for parameter field change notifications
- the workbench dispatches
runtime-params-change
select_template- for current template-card selection metadata
- the workbench dispatches
runtime-select-template
template_publish_media_change- for template publish media such as
generatorImage/cover - the workbench writes them into the template publish payload
- for template publish media such as
In other words, only documented runtime events with known workbench semantics are consumed automatically by the shell.
2. For generic business parameters, let the host bridge them
If the runtime wants to expose arbitrary business data such as:
- a runtime-derived title
- a business mode
- a startup feature flag
- custom meta fields
do not assume the workbench will auto-read them. The recommended flow is host-side bridging:
- create the runtime first
- read the initial snapshot through
runtime.getState() - let the host derive
workbench.configfrom that snapshot - then call
workbench.mount()
Example:
const runtime = createRuntime()
const initialState = runtime.getState()
workbench.runtime = runtime
workbench.config = {
title:
typeof initialState.meta?.generatorName === 'string'
? initialState.meta.generatorName
: 'My Generator',
style:
typeof initialState.style === 'string'
? initialState.style
: 'modern-minimal',
}
await workbench.mount()The key rule is:
- the runtime owns business state
- the host decides which runtime fields should become shell config
- the workbench does not scan arbitrary runtime fields and write them back into config automatically
- for example, if the publish modal needs a fixed style mode, the host should bridge it explicitly into
workbench.config.style
3. For continuous sync after mount, listen through the host
If those specific parameters are not one-time initialization values and may change later:
- for standardized events, the host can listen to workbench DOM events
- for arbitrary custom events, the host should listen to
runtime.subscribe(listener)directly instead of expecting automatic workbench forwarding
So the practical split is:
- standardized event:
runtime -> workbench -> DOM event - custom business event:
runtime -> host
The current workbench does not automatically expose every unknown runtime event as a DOM event.
How generator-workbench Reads Runtime-Specific Parameters During Initialization
The recommended model is to think about two different timings.
Timing 1: read before mount()
This is the best option for shell initialization config.
The flow is:
- the host creates the runtime
- the runtime prepares its default state
- the host calls
runtime.getState() - the host maps the needed fields into
workbench.config - the host calls
workbench.mount()
This timing is appropriate for:
- title
- logo text
- mode switches
- initial template metadata
- any shell config that must be decided before the first workbench render
Timing 2: read after mount()
If those parameters are only known after runtime mount, for example because they depend on:
- container size
- first async restore
- embed-host bootstrap
- the user's first interaction inside the runtime
then they should not be treated as pre-mount initialization data. In that case, keep syncing through:
runtime.subscribe(...)runtime-params-changeruntime-select-template- other host-owned listeners
What Not To Assume
Do not assume the current workbench already supports this:
// This is not a generic built-in workbench mechanism today
await workbench.mount()
// workbench automatically reads an arbitrary runtime field
// and rewrites its own config from itThe current source and public contract do not define a generic runtime arbitrary field -> workbench config auto-injection path. If you need that behavior, bridge it explicitly in the host, or introduce a new standardized runtime event in a future contract update.
params_change Example
emit({
type: 'params_change',
data: {
field: 'width',
value: 120,
params: {
width: 120,
height: 128,
},
cover: 'data:image/svg+xml;charset=utf-8,...',
},
})Rules:
data.fieldis requireddata.valueis requireddata.paramsis optional and can contain the current full params objectdata.coveris optional and can carry the current runtime thumbnail;generator-workbenchcaches the latest non-empty value and reuses it for later cloud saves whencoverwas not explicitly provided tosdk.cloud.save(...)
Recommended flow:
- Update the generator's own internal state first, for example write the new value back into
params.widthorparams.height - Re-render the current preview so the thumbnail matches the latest params
- Emit
params_change - If the latest preview image is already available, include it as
data.cover
Recommended constraints:
- Use
params_changeonly for real parameter changes; do not emit it for transient UI-only state such as hover state, temporary selection, or scroll position data.covershould ideally be a previewdata URLor a directly accessible image URL- If the new thumbnail is not ready yet, omit
coverinstead of sending an empty string; the workbench only caches the latest non-empty value - If your preview depends on async rendering or the next animation frame, wait until the preview is ready before emitting the event so
paramsandcoverstay in sync
A more complete runtime implementation can look like this:
function resolvePreviewCover(container: HTMLElement): string | undefined {
const preview = container.querySelector('[aria-label="Pendant preview"]')
if (preview instanceof SVGElement) {
return svgMarkupToDataUrl(preview.outerHTML)
}
if (preview instanceof HTMLImageElement && preview.src) {
return preview.src
}
return undefined
}
async function patchState(
patch: Record<string, unknown>,
options?: { source?: string },
) {
Object.entries(patch).forEach(([path, value]) => {
setByPath(state, path, value)
})
rerender()
Object.entries(patch).forEach(([path, value]) => {
if (!path.startsWith('params.')) {
return
}
emit({
type: 'params_change',
data: {
field: path.replace(/^params\./, ''),
value,
params: cloneParams(state),
cover: resolvePreviewCover(container),
},
})
})
}If your preview is not DOM / SVG based and instead comes from Canvas or another renderer, the principle is the same:
- keep runtime state and preview output aligned first
- export the latest thumbnail
- pass that thumbnail through
data.cover
For example:
emit({
type: 'params_change',
data: {
field: 'width',
value: nextWidth,
params: cloneParams(state),
cover: canvas.toDataURL('image/png'),
},
})Pendant preview Example
generator-workbench/src/examples/basic-app.ts contains a more complete reference flow that shows when cover should be captured and when it should be sent to the workbench.
In that example, generator-workbench does not watch DOM mutations on aria-label="Pendant preview" by itself. Instead:
- the runtime updates its internal state inside
patchState() - it immediately calls
rerender(...)so the preview node reflects the latest params - the runtime actively queries the node with
aria-label="Pendant preview" - it converts that current node content into a
data URL - it finally sends the result through
params_change.data.cover
The cover extraction logic in the example:
function resolvePendantPreviewCover(
mounts: Set<MountRecord>,
state: ExampleState,
): string {
for (const mount of Array.from(mounts).reverse()) {
const previewElement = mount.container.querySelector('[aria-label="Pendant preview"]')
if (previewElement instanceof SVGElement) {
return svgMarkupToDataUrl(previewElement.outerHTML)
}
if (previewElement instanceof HTMLImageElement && previewElement.src) {
return previewElement.src
}
}
return svgMarkupToDataUrl(
buildPendantPreviewSvg(state.params.width, state.params.height),
)
}The corresponding event emission logic:
async patchState(patch, options) {
Object.entries(patch).forEach(([path, value]) => {
setByPath(state as unknown as Record<string, unknown>, path, value)
})
rerender(mounts, state, runtime, applyTemplatePreset)
Object.entries(patch).forEach(([path, value]) => {
if (!path.startsWith('params.')) {
return
}
emit({
type: 'params_change',
data: {
field: path.replace(/^params\./, ''),
value,
params: cloneParams(state),
cover: resolvePendantPreviewCover(mounts, state),
},
})
})
}This example highlights 3 important points:
covershould be collected by the runtime itself, rather than expecting the workbench to reach into generator-internal DOMcovershould be collected only after the parameter change has already been rendered, otherwise you may send an outdated thumbnail- as long as the runtime can reliably access the current preview image, whether it comes from SVG,
img, or Canvas, it can normalize that result intoparams_change.data.cover
If your generator also has a preview node like this:
<div aria-label="Pendant preview">
<!-- SVG / img / canvas preview for the current params -->
</div>the recommended pattern is:
- update params first
- re-render the preview
- read that preview node and export the latest thumbnail
- emit
params_change
This keeps the cover received by the workbench consistent with the current parameter state.
select_template Example
function onTemplateCardClick(template: { name: string; category?: string }) {
emit({
type: 'select_template',
data: {
name: template.name,
category: template.category,
},
})
}Rules:
data.nameis requireddata.categoryis optional- the latest emitted
data.nameis used for the firstPublish as Templateopen; after that,generator-workbenchreuses the cached first-opengeneratorTag
template_publish_media_change Example
emit({
type: 'template_publish_media_change',
data: {
generatorImage: 'https://cdn.example.com/runtime-generator.png',
cover: 'data:image/png;base64,custom-cover',
},
})Rules:
data.generatorImageis optional and overridesgeneratorImagein the template publish payloaddata.coveris optional and overridesinitialData.coverin the template publish payload- provide at least one of them
- the latest emitted
data.generatorImagebefore the first publish open is cached and reused on later opens in the same workbench instance; if that first image is uploaded to OSS, later opens reuse the cached OSS URL instead of re-uploading the original base64 payload
DOM Event Exposed By Workbench
When the runtime emits select_template, the workbench also dispatches:
workbench.addEventListener('runtime-select-template', (event) => {
console.log(event.detail.name)
})When the runtime emits params_change, the workbench dispatches:
workbench.addEventListener('runtime-params-change', (event) => {
console.log(event.detail.field, event.detail.value)
})3. Commands Passed From Workbench To Runtime
For the reverse direction, the recommended runtime entry is:
runtime.dispatchWorkbenchCommand = async (command) => {
if (command.type === 'open-login') {
openLoginPanel(command.data)
}
}Command shape:
interface WorkbenchCommand {
type: string
data?: unknown
}Host-Side Workbench API
The workbench-facing method is:
await workbench.dispatchRuntimeCommand({
type: 'open-login',
data: {
source: 'topbar',
},
})Naming note:
workbench.dispatchRuntimeCommand(...)is the host-side helper- internally it forwards through
RuntimeEventChannel.dispatchWorkbenchCommand(...) - the runtime receives it through
runtime.dispatchWorkbenchCommand(...)
All three refer to the same direction: workbench -> runtime.
4. Runtime Event Channel Internals
generator-workbench uses a dedicated public communication layer:
RuntimeEventRegistry- registers runtime event handlers
- registers workbench command handlers
RuntimeEventChannel- connects to
runtime.subscribe(...) - routes runtime events by type
- dispatches workbench commands back to the runtime
- connects to
This keeps GeneratorWorkbenchElement from growing into a large event switchboard.
5. Embed Route And Bridge Forwarding
When the page route is ?mode=embed, communication also extends to the parent page bridge.
Generator -> Workbench -> Parent
Current built-in bridge forwarding:
| Generator event | Workbench action | Parent bridge event |
|---|---|---|
select_template | dispatch DOM event | generator_toSelectTemplate |
Workbench -> Generator Is Separate From Bridge
workbench.dispatchRuntimeCommand(...) is local shell-to-runtime communication. It does not automatically become a parent-page bridge event.
If the main-site host needs iframe-level communication, keep using the dedicated embed bridge protocol described in Embed Host Integration.
6. Recommended Integration Pattern
Use this split by default:
- Generator receives
routeModethroughmount(...) - Generator reports business-side events through
subscribe(listener) - Workbench handles shell-side orchestration
- Workbench sends reverse commands through
dispatchRuntimeCommand(...) - Embed-only parent-page communication stays in the iframe bridge layer
This keeps responsibilities clean:
- generator owns business rendering and business events
- workbench owns shell orchestration
- bridge owns cross-window communication
7. Error Handling
Current behavior:
- runtime event handler failures are reported through
config.onError/workbench-error - workbench command handler failures are also reported through
config.onError/workbench-error dispatchRuntimeCommand()currently resolves after the dispatch attempt and does not reject on handler errors
If your host needs strict success/failure semantics, add your own command-level ack protocol inside runtime.dispatchWorkbenchCommand(...).