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 List of Cards with Buttons, not a <SubSurfacePicker> component. The drill-down is whatever the consumer composes; Beach has no opinion.
  • Not for in-surface modals. A Modal on the host's own surface is pure A2UI usage. There is no subsurface:open involved because nothing creates a new surfaceId.
  • Not a schema validator. returnShape is unknown until a Standard-Schema convention lands. Until then, consumers wishing to validate value at result-time bring their own validator.
  • Not a wizard / multi-step flow. Multi-step composition is achieved by composing multiple subsurface:open calls 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 scope tokens) 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.