Sub-surface composition
A host surface sometimes needs to ask a different surface to collect a result. The user is looking at flight options, taps "pick this one," and a derivative surface renders to confirm the seat. The host surface is paused until the derivative produces a value (or cancels). When the derivative settles, the host receives the value and continues.
This pattern is cross-surface composition. Beach ships a small event family — three events — that canonicalises the orchestration. The pattern composes existing A2UI primitives (Card, List, Button, Modal); Beach does not introduce new UI components.
When to use this — and when not to
Two patterns are easy to confuse. Use this guide for one; use plain A2UI usage for the other.
| Situation | Use |
|---|---|
A modal on the host's own surface — A2UI Modal component overlaying the existing surfaceId, host-local state |
Plain A2UI usage. updateComponents adds a Modal; the Modal's submit action carries the result back via normal A2UI action handling. Beach has nothing useful to add. Do not use this guide. |
A derivative surface — new surfaceId, distinct rendering context, host pauses until the derivative settles |
This guide. The three subsurface:* events orchestrate the lifecycle. |
The simple test: does opening this thing create a new surfaceId via createSurface? If yes, it's cross-surface composition. If no, it's an in-surface overlay and not Beach's concern.
The three events
import {
SUBSURFACE_EVENTS,
type SubSurfaceOpenData,
type SubSurfaceResultData,
type SubSurfaceCancelData,
} from '@cool-ai/beach-protocol';
SUBSURFACE_EVENTS exposes the canonical (source, eventType) tuples so refactors do not silently drift:
SUBSURFACE_EVENTS.open // ['subsurface', 'open']
SUBSURFACE_EVENTS.result // ['subsurface', 'result']
SUBSURFACE_EVENTS.cancel // ['subsurface', 'cancel']
subsurface:open — host opens a derivative surface
const open: SubSurfaceOpenData = {
scope: 'pick-flight-001', // host generates; correlation token
parentSurfaceId: 'travel-results', // host's own surfaceId
derivativeSurfaceId: 'flight-picker',
hint: 'picker', // informational layout cue (optional)
returnShape: { kind: 'flight' }, // schema the host expects (typed `unknown` for now; see "What this is NOT")
};
The scope is the load-bearing field. The host generates it; the derivative echoes it back on result or cancel. That's how the host correlates a response with the originating open without needing turnId/sessionId/surfaceId machinery in the event payload.
hint is not structural. Beach's routing does not branch on it. The renderer or a presentation-layer adapter MAY use it as a layout cue (overlay vs full-page, inset vs side-drawer); Beach treats it as opaque.
returnShape: unknown is the slot for the schema the host expects. A Standard-Schema convention is on the roadmap; until then, consumers wishing to validate value at result-time bring their own validator.
subsurface:result — derivative returns a value
const result: SubSurfaceResultData = {
scope: 'pick-flight-001', // echoes the host's scope
value: { flightNumber: 'BA178' }, // typed `unknown` for now (Standard-Schema convention pending)
};
subsurface:cancel — derivative declines
const cancel: SubSurfaceCancelData = {
scope: 'pick-flight-001',
reason: 'user-dismissed', // optional; observability only
};
The host MUST treat cancel as a structurally equivalent terminator to result — same scope correlation, same lifecycle close — but semantically distinct (no value to apply).
Worked example 1 — drill-down
A travel-search results surface where tapping a row opens a derivative "details" surface. The detail surface lets the user save the flight to a folder and emits the saved record back.
Wiring
import { EventRouter } from '@cool-ai/beach-core';
import {
SUBSURFACE_EVENTS,
type SubSurfaceOpenData,
type SubSurfaceResultData,
} from '@cool-ai/beach-protocol';
const router = new EventRouter();
// Host: receives the row-tap action and emits subsurface:open.
router.register('host-row-tapped', async (event, ctx) => {
const { flightId } = event.data as { flightId: string };
const open: SubSurfaceOpenData = {
scope: `drill-flight-${flightId}`,
parentSurfaceId: 'travel-results',
derivativeSurfaceId: `flight-detail-${flightId}`,
hint: 'drill-down',
returnShape: { kind: 'savedFlight' },
};
await ctx.routeEvent({
source: SUBSURFACE_EVENTS.open[0],
eventType: SUBSURFACE_EVENTS.open[1],
data: open,
});
});
// Derivative-surface handler: renders the detail surface (via A2UI primitives —
// not shown here), collects user input, emits subsurface:result.
router.register('flight-detail-builder', async (event, ctx) => {
const open = event.data as SubSurfaceOpenData;
// …handler creates `derivativeSurfaceId` via the A2UI command stream,
// composes a Card with flight details + Save button, awaits the user's
// action on the surface, then…
const result: SubSurfaceResultData = {
scope: open.scope,
value: { flightId: 'BA178', folder: 'shortlist' },
};
await ctx.routeEvent({
source: SUBSURFACE_EVENTS.result[0],
eventType: SUBSURFACE_EVENTS.result[1],
data: result,
});
});
// Host: receives the result, updates the parent surface with the saved indicator.
router.register('host-flight-saved', async (event, ctx) => {
const result = event.data as SubSurfaceResultData;
// …update the host's surface to show the saved-state indicator on the row
// matching `result.scope`.
});
router.loadRoutingConfig({
rules: [
{ source: 'host', eventType: 'row_tapped', handler: 'host-row-tapped' },
{ source: SUBSURFACE_EVENTS.open[0], eventType: SUBSURFACE_EVENTS.open[1], handler: 'flight-detail-builder' },
{ source: SUBSURFACE_EVENTS.result[0], eventType: SUBSURFACE_EVENTS.result[1], handler: 'host-flight-saved' },
],
});
Note on the rendering layer
The derivative-surface handler is responsible for emitting the A2UI commands that build derivativeSurfaceId. That happens through the regular A2UI command stream (createSurface + updateComponents), not through this event family. The subsurface:* events orchestrate the lifecycle; A2UI commands render the content.
For the drill-down case, the renderer's hint: 'drill-down' may inform a presentation choice — full-page replacement vs slide-in panel — but Beach does not branch on it.
Worked example 2 — picker
A travel-search surface where the user picks one of several seat options. The picker is a derivative surface (its own surfaceId) rendered as a list, and produces the selected option.
// Host: emits subsurface:open with hint='picker'
router.register('host-pick-seat', async (event, ctx) => {
const { tripId } = event.data as { tripId: string };
const open: SubSurfaceOpenData = {
scope: `seat-pick-${tripId}`,
parentSurfaceId: 'trip-summary',
derivativeSurfaceId: `seat-picker-${tripId}`,
hint: 'picker',
returnShape: { kind: 'seat' },
};
await ctx.routeEvent({
source: SUBSURFACE_EVENTS.open[0],
eventType: SUBSURFACE_EVENTS.open[1],
data: open,
});
});
// Derivative: renders a List of seats (A2UI), waits for selection, returns the choice.
router.register('seat-picker-builder', async (event, ctx) => {
const open = event.data as SubSurfaceOpenData;
// … build the picker via A2UI: createSurface(derivativeSurfaceId), then
// updateComponents with a List of Card-each-Button items …
// On user selection:
await ctx.routeEvent({
source: SUBSURFACE_EVENTS.result[0],
eventType: SUBSURFACE_EVENTS.result[1],
data: { scope: open.scope, value: { seatNumber: '14A' } } as SubSurfaceResultData,
});
});
// Host: applies the selection.
router.register('host-seat-selected', async (event) => {
const result = event.data as SubSurfaceResultData;
// …apply { seatNumber: '14A' } to the trip summary.
});
The picker uses A2UI's List and Card primitives — Beach does not ship a Picker component. The pattern is the orchestration; the rendering is consumer-composed.
Cancel path
If the user dismisses the picker without selecting:
const cancel: SubSurfaceCancelData = {
scope: open.scope,
reason: 'user-dismissed',
};
await ctx.routeEvent({
source: SUBSURFACE_EVENTS.cancel[0],
eventType: SUBSURFACE_EVENTS.cancel[1],
data: cancel,
});
A separate host-seat-cancelled handler subscribes to subsurface:cancel and, e.g., reverts the host surface to its pre-pick state.
Cooperative cancellation
Beach's turn-signal cascade applies. If the host's turn aborts (cancelTurn, timeout, downstream disconnect), the derivative-surface handler observes ctx.signal.aborted === true and can clean up cooperatively before emitting subsurface:cancel (or just bailing). See Cooperative cancellation for the full pattern.
Migration — from TA-local picker conventions to the canonical pattern
If your application has a TA-local picker convention — three picker-shaped events with surface-specific names, ad-hoc correlation IDs — migrate to the canonical pattern as follows:
Before (TA-local example, illustrative):
// Hypothetical TA-local convention
router.register('flight-picker-opened', async (event, ctx) => {
await ctx.routeEvent({
source: 'ta-pickers',
eventType: 'flight_pick_request',
data: { tripId, flights, requestId: 'fp-123' },
});
});
router.register('flight-pick-result', async (event, ctx) => {
const { requestId, selectedFlightId } = event.data;
// …
});
After (canonical):
router.register('host-pick-flight', async (event, ctx) => {
const open: SubSurfaceOpenData = {
scope: 'fp-123',
parentSurfaceId: 'trip-summary',
derivativeSurfaceId: 'flight-picker',
hint: 'picker',
returnShape: { kind: 'flight' },
};
await ctx.routeEvent({
source: SUBSURFACE_EVENTS.open[0],
eventType: SUBSURFACE_EVENTS.open[1],
data: open,
});
});
router.register('host-flight-result', async (event) => {
const { scope, value } = event.data as SubSurfaceResultData;
// …
});
Beach does not ship the codemod. Beach knows the canonical "after" shape; it does not know any specific consumer's "before" shape. Consumers write their own codemod; the canonical-pattern code above is the migration target.
What this is NOT
- Not a UI primitive. The picker is an A2UI
ListofCards withButtons, not a<SubSurfacePicker>component. The drill-down is whatever the consumer composes; Beach has no opinion. - Not for in-surface modals. A
Modalon the host's own surface is pure A2UI usage. There is nosubsurface:openinvolved because nothing creates a newsurfaceId. - Not a schema validator.
returnShapeisunknownuntil a Standard-Schema convention lands. Until then, consumers wishing to validatevalueat result-time bring their own validator. - Not a wizard / multi-step flow. Multi-step composition is achieved by composing multiple
subsurface:opencalls in sequence. Each step is an independent open/result lifecycle. - Not concurrent-sub-surface support. Concurrent sub-surfaces on the same host are technically possible (different
scopetokens) but not specifically supported or tested. Treat as an open behaviour pending consumer demand.
Related
@cool-ai/beach-protocol— where the event types live.- Cooperative cancellation in @cool-ai/beach-core — applies through the sub-surface chain.
- Host-fit conventions —
pickerHost: 'self' | 'parent'. - A2UI v0.9 spec for the rendering primitives the patterns compose from.