Skip to content

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-workbench pass into the runtime?
  • Which events can the runtime send to generator-workbench?
  • Which commands can generator-workbench send back to the runtime?
  • How does embed-mode bridge forwarding relate to runtime events?

Communication Directions

There are two official directions:

DirectionEntryPurpose
runtime -> workbenchruntime.subscribe(listener)Report runtime events such as state-change, params_change, or select_template
workbench -> runtimeruntime.dispatchWorkbenchCommand?(command)Receive shell or host commands from generator-workbench

Inside generator-workbench, both directions are managed by the public communication layer:

  • RuntimeEventRegistry
  • RuntimeEventChannel

Complete Event Tables

Before reading the details, keep this distinction clear:

  • mount({ mode, routeMode, target, state, panelFilter }) is parameter passing, not an event
  • runtime.subscribe(listener) is the standard runtime -> workbench event channel
  • workbench.dispatchRuntimeCommand(...) / runtime.dispatchWorkbenchCommand(...) is the standard workbench -> runtime command channel

Runtime -> Workbench

EventDirectionPayloadCurrent workbench handling
readyruntime -> workbenchnonecurrently reserved for runtime lifecycle signaling; no built-in shell action
state-changeruntime -> workbench{ state, patch?, source? }used by auto-save orchestration when enabled
params_changeruntime -> workbench{ data: { field, value, params? } }dispatches runtime-params-change as a DOM custom event
panel-schema-changeruntime -> workbench{ schema }currently reserved; no built-in shell action
warningruntime -> workbench{ code, message }currently reserved; no built-in shell action
errorruntime -> workbench{ code, message }currently reserved; no built-in shell action
select_templateruntime -> workbench{ data: { name, category? } }dispatches runtime-select-template; forwards generator_toSelectTemplate in ?mode=embed

Workbench -> Runtime

Command channelDirectionPayloadCurrent 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 commandDirectionPayloadPurpose
open-loginworkbench -> 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:

ts
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 container
  • routeMode: what the page route capability mode actually is

Why routeMode Exists

This is most important in shell mode.

Example:

ts
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
ts
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:

ts
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:

ts
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
  • select_template
    • dispatched as the DOM custom event runtime-select-template
    • forwarded as generator_toSelectTemplate when the route is ?mode=embed
  • template_publish_media_change
    • overrides generatorImage and initialData.cover for template publish

How Runtime Passes Specific Parameters To Workbench

First, keep the boundary clear:

  • the official generator-workbench -> runtime initialization path is still mount({ mode, routeMode, target, container, state, panelFilter })
  • there is no generic runtime -> generator-workbench direct 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

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:

  1. create the runtime first
  2. read the initial snapshot through runtime.getState()
  3. let the host derive workbench.config from that snapshot
  4. then call workbench.mount()

Example:

ts
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:

  1. the host creates the runtime
  2. the runtime prepares its default state
  3. the host calls runtime.getState()
  4. the host maps the needed fields into workbench.config
  5. 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-change
  • runtime-select-template
  • other host-owned listeners

What Not To Assume

Do not assume the current workbench already supports this:

ts
// 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 it

The 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

ts
emit({
  type: 'params_change',
  data: {
    field: 'width',
    value: 120,
    params: {
      width: 120,
      height: 128,
    },
    cover: 'data:image/svg+xml;charset=utf-8,...',
  },
})

Rules:

  • data.field is required
  • data.value is required
  • data.params is optional and can contain the current full params object
  • data.cover is optional and can carry the current runtime thumbnail; generator-workbench caches the latest non-empty value and reuses it for later cloud saves when cover was not explicitly provided to sdk.cloud.save(...)

Recommended flow:

  1. Update the generator's own internal state first, for example write the new value back into params.width or params.height
  2. Re-render the current preview so the thumbnail matches the latest params
  3. Emit params_change
  4. If the latest preview image is already available, include it as data.cover

Recommended constraints:

  • Use params_change only for real parameter changes; do not emit it for transient UI-only state such as hover state, temporary selection, or scroll position
  • data.cover should ideally be a preview data URL or a directly accessible image URL
  • If the new thumbnail is not ready yet, omit cover instead 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 params and cover stay in sync

A more complete runtime implementation can look like this:

ts
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:

ts
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:

  1. the runtime updates its internal state inside patchState()
  2. it immediately calls rerender(...) so the preview node reflects the latest params
  3. the runtime actively queries the node with aria-label="Pendant preview"
  4. it converts that current node content into a data URL
  5. it finally sends the result through params_change.data.cover

The cover extraction logic in the example:

ts
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:

ts
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:

  • cover should be collected by the runtime itself, rather than expecting the workbench to reach into generator-internal DOM
  • cover should 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 into params_change.data.cover

If your generator also has a preview node like this:

html
<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

ts
function onTemplateCardClick(template: { name: string; category?: string }) {
  emit({
    type: 'select_template',
    data: {
      name: template.name,
      category: template.category,
    },
  })
}

Rules:

  • data.name is required
  • data.category is optional
  • the latest emitted data.name is used for the first Publish as Template open; after that, generator-workbench reuses the cached first-open generatorTag

template_publish_media_change Example

ts
emit({
  type: 'template_publish_media_change',
  data: {
    generatorImage: 'https://cdn.example.com/runtime-generator.png',
    cover: 'data:image/png;base64,custom-cover',
  },
})

Rules:

  • data.generatorImage is optional and overrides generatorImage in the template publish payload
  • data.cover is optional and overrides initialData.cover in the template publish payload
  • provide at least one of them
  • the latest emitted data.generatorImage before 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:

ts
workbench.addEventListener('runtime-select-template', (event) => {
  console.log(event.detail.name)
})

When the runtime emits params_change, the workbench dispatches:

ts
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:

ts
runtime.dispatchWorkbenchCommand = async (command) => {
  if (command.type === 'open-login') {
    openLoginPanel(command.data)
  }
}

Command shape:

ts
interface WorkbenchCommand {
  type: string
  data?: unknown
}

Host-Side Workbench API

The workbench-facing method is:

ts
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

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 eventWorkbench actionParent bridge event
select_templatedispatch DOM eventgenerator_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.

Use this split by default:

  1. Generator receives routeMode through mount(...)
  2. Generator reports business-side events through subscribe(listener)
  3. Workbench handles shell-side orchestration
  4. Workbench sends reverse commands through dispatchRuntimeCommand(...)
  5. 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(...).

MIT Licensed