This Runtime Kit Cookbook entry builds the same shape explained in Placeable surfaces: package material becomes named data reads and targets, descriptors declare what each surface may touch, and a dumb shell places the surfaces.
The snippets on this page come from examples/product-cookbook/placeable-surface-product-loop/ and are checked by cookbook-examples.json.When to use this
Use this recipe when the product is bigger than one component.| Good fit | Not this recipe |
|---|
| A product shell with multiple surfaces: launch, help, support, settings, billing, or dashboards. | A single component reading one data name. |
| A sidebar/tab/region system that should stay generic. | A one-off hardcoded marketing page. |
| Product areas that need different data names and targets. | A pure package-boundary example with no UI shell. |
| Each new surface keeps adding layout special cases. | A reference lookup for one function signature. |
The point is not “make a dashboard.” The point is to keep arrangement generic while package-specific UI rules live in package data, targets, descriptors, and surface bodies.What you will build
| Layer | File | Public names |
|---|
| Package file | launch-product/things-to-store-and-run.json | Records welcome-copy, checklist; targets launch.next-step, help.search. |
| Surface descriptors | app/surfaces.ts | Surface ids, labels, regions, component keys, data names, targets. |
| Dumb shell | app/ProductShell.tsx | Renders labels, active surface, and region from descriptors. |
| Main surface | app/LaunchHomeSurface.tsx | Reads welcome-copy and launch-checklist; calls launch.next-step. |
| Help surface | app/LaunchHelpSurface.tsx | Calls help.search with cancellation. |
What should happen
The shell can show Launch, Help, and Support without hardcoded product branches.
Launch can read package-prepared copy/checklist and call launch.next-step.
Help can call help.search.
Adding another surface means adding descriptor data and a surface body, not editing shell policy.
Step 1: package records and targets
The package owns product facts and callable targets.{
"package": "launch-product",
"things": [
{
"type": "record",
"address": "package::launch-product::welcome-copy",
"payload": {
"headline": "Your launch stays fast.",
"body": "Every feature stays behind a package boundary, so the next idea does not have to tangle the last one."
}
},
{
"type": "record",
"address": "package::launch-product::checklist",
"payload": {
"items": [
{ "id": "account", "label": "Create account", "done": true },
{ "id": "device", "label": "Activate this device", "done": true },
{ "id": "package", "label": "Run a package", "done": false },
{ "id": "target", "label": "Call a target", "done": false }
]
}
},
{
"type": "slot",
"name": "launch.next-step",
"methods": [{ "name": "query" }],
"boundary": {
"call_shape": "envelope-bytes-in-envelope-bytes-out",
"methods": [{ "name": "query" }]
},
"artifact": { "source_path": "slots/next-step.bin" }
},
{
"type": "slot",
"name": "help.search",
"methods": [{ "name": "query" }],
"boundary": {
"call_shape": "envelope-bytes-in-envelope-bytes-out",
"methods": [{ "name": "query" }]
},
"artifact": { "source_path": "slots/help-search.bin" }
}
]
}
Runtime Kit admits this package set and prepares the public read/request surface for the app.The package contract is:| Public name | Kind | Used by |
|---|
welcome-copy | Record/data name source | LaunchHomeSurface |
launch-checklist | Record/data name source | LaunchHomeSurface |
launch.next-step | Callable target | LaunchHomeSurface |
help.search | Callable target | LaunchHelpSurface |
Step 2: surface descriptors
A descriptor says where the surface can appear and which Runtime Kit names the surface body may use.export type SurfaceRegion = 'main' | 'rail' | 'panel';
export type PlaceableSurface = {
id: string;
label: string;
region: SurfaceRegion;
order: number;
componentKey: string;
preparedInputs: string[];
targets: string[];
};
export const launchSurfaces: PlaceableSurface[] = [
{
id: 'launch.home',
label: 'Launch',
region: 'main',
order: 10,
componentKey: 'surface.launch.home',
preparedInputs: ['welcome-copy', 'launch-checklist'],
targets: ['launch.next-step'],
},
{
id: 'launch.help',
label: 'Help',
region: 'panel',
order: 20,
componentKey: 'surface.launch.help',
preparedInputs: [],
targets: ['help.search'],
},
{
id: 'launch.support',
label: 'Support',
region: 'panel',
order: 30,
componentKey: 'surface.launch.support',
preparedInputs: [],
targets: [],
},
];
Notice what is missing: no dashboard branch, no help branch, no support branch in the shell. The product-specific names are data on the descriptor.Descriptor contract:| Field | Why it exists |
|---|
id | Stable surface identity. |
label | Text the shell can render without importing package-specific UI code. |
region | Generic placement target such as main, rail, or panel. |
order | Generic sort key. |
componentKey | Registry key for the render body. |
preparedInputs | Public read names this surface may use. |
targets | Public action names this surface may call. |
Step 3: dumb shell
The shell receives surface descriptors and a render registry. It builds navigation from descriptor labels and chooses the active surface by id.import { useState } from 'react';
import type { PlaceableSurface } from './surfaces';
type SurfaceComponentProps = {
surface: PlaceableSurface;
};
export type SurfaceRegistry = Record<string, React.ComponentType<SurfaceComponentProps>>;
type ProductShellProps = {
surfaces: PlaceableSurface[];
registry: SurfaceRegistry;
};
export function ProductShell({ surfaces, registry }: ProductShellProps) {
const ordered = [...surfaces].sort((a, b) => a.order - b.order);
const [activeSurfaceId, setActiveSurfaceId] = useState(ordered[0]?.id ?? '');
const activeSurface = ordered.find((surface) => surface.id === activeSurfaceId) ?? ordered[0];
const ActiveSurface = activeSurface ? registry[activeSurface.componentKey] : null;
return (
<div className="product-shell">
<aside aria-label="Product surfaces">
{ordered.map((surface) => (
<button
key={surface.id}
type="button"
aria-current={surface.id === activeSurface?.id ? 'page' : undefined}
onClick={() => setActiveSurfaceId(surface.id)}
>
{surface.label}
</button>
))}
</aside>
<main data-region={activeSurface?.region ?? 'main'}>
{activeSurface && ActiveSurface ? <ActiveSurface surface={activeSurface} /> : null}
</main>
</div>
);
}
That shell can place any future surface that follows the descriptor. It does not change when you add analytics, onboarding, billing, settings, or a new customer-facing product area.Shell verification:| Check | Pass condition |
|---|
| Navigation | Renders from surface.label, not hardcoded product strings. |
| Active state | Tracks surface.id, not component names. |
| Placement | Uses surface.region, not product-specific branches. |
| Render body | Resolves through surface.componentKey and registry. |
| Future surface | Adding a descriptor can add a surface without editing shell policy. |
Step 4: surface body
The surface body is where product work happens. It reads Runtime Kit data and calls Runtime Kit targets.import { sendRequestToBitfieldTarget } from '@bitfield/runtime-kit';
import { useBitfieldData } from '@bitfield/runtime-kit/react';
import type { PlaceableSurface } from './surfaces';
type WelcomeCopy = {
headline: string;
body: string;
};
type Checklist = {
items: Array<{ id: string; label: string; done: boolean }>;
};
type NextStepReply = {
nextStep: string;
reason: string;
};
export function LaunchHomeSurface({ surface }: { surface: PlaceableSurface }) {
const welcome = useBitfieldData<WelcomeCopy>('welcome-copy');
const checklist = useBitfieldData<Checklist>('launch-checklist');
async function getNextStep(): Promise<NextStepReply> {
const reply = await sendRequestToBitfieldTarget({
target: 'launch.next-step',
payload: {
surfaceId: surface.id,
completed: checklist.data?.items.filter((item) => item.done).map((item) => item.id) ?? [],
},
});
return JSON.parse(new TextDecoder().decode(reply.payload)) as NextStepReply;
}
if (welcome.loading || checklist.loading) return <section>Loading.</section>;
if (welcome.error || checklist.error) return <section>Could not load this surface.</section>;
if (!welcome.data || !checklist.data) return <section>No data yet.</section>;
return (
<section>
<h2>{welcome.data.headline}</h2>
<p>{welcome.data.body}</p>
{checklist.data.items.map((item) => (
<p key={item.id}>{item.done ? 'Done' : 'Next'}: {item.label}</p>
))}
<button type="button" onClick={getNextStep}>
Show next step
</button>
</section>
);
}
This surface can be moved from main to panel, opened beside another surface, or reused in a different shell. The product work stays with the surface body. The arrangement stays with the shell.Surface body contract:| Surface | Reads | Calls | Must render |
|---|
launch.home | welcome-copy, launch-checklist | launch.next-step | loading, error, empty, success |
launch.help | none in this fixture | help.search | request entry point and request failure in a real UI |
launch.support | none in this fixture | none | public names placeholder or future body |
Step 5: second surface, same request primitive
import { useRef } from 'react';
import { sendRequestToBitfieldTarget } from '@bitfield/runtime-kit';
type HelpSearchReply = {
results: Array<{ title: string; excerpt: string }>;
};
export function LaunchHelpSurface() {
const current = useRef<AbortController | null>(null);
async function search(query: string): Promise<HelpSearchReply> {
current.current?.abort();
current.current = new AbortController();
const reply = await sendRequestToBitfieldTarget(
{ target: 'help.search', payload: { query } },
current.current.signal,
);
return JSON.parse(new TextDecoder().decode(reply.payload)) as HelpSearchReply;
}
return <button type="button" onClick={() => search('activation')}>Search help</button>;
}
The app has another capability. The shell still did not change.Verify the full loop
Before you call the feature done, write this checklist beside it:Package set:
launch-product
Data names:
welcome-copy
launch-checklist
Targets:
launch.next-step
help.search
Placeable surfaces:
launch.home -> main -> welcome-copy, launch-checklist -> launch.next-step
launch.help -> panel -> help.search
launch.support -> panel -> public names only
Generic shell proof:
The sidebar renders from surface labels.
The active region renders from surface.region.
The render body resolves from surface.componentKey.
Adding a new surface does not edit shell code.
Public request/reply shapes:
launch.next-step -> { surfaceId: string; completed: string[] } -> { nextStep: string; reason: string }
help.search -> { query: string } -> { results: Array<{ title: string; excerpt: string }> }
Check the product loop against this list before it ships. Use the same checklist for teammate code, your own code, or AI-written code.Common failures
| Symptom | Cause | Fix |
|---|
Shell needs a new if (surface.id === ...) branch for every product area. | Package-specific UI rules leaked into arrangement. | Move those rules into descriptors and registry entries. |
| Surface calls a target not listed on its descriptor. | The surface contract and body drifted. | Add the target to the descriptor or remove the call. |
| Surface reads a data name not listed on its descriptor. | The read contract and body drifted. | Add the input to the descriptor or change the surface body. |
| Adding Help requires editing Launch code. | Surface bodies are coupled. | Keep each surface body behind its own componentKey. |
| Package setup is imported into the shell. | The shell was treated like product code. | Reject the output; shell only arranges descriptors. |
| Reply parsing crashes. | Target reply shape changed or was never written. | Document request/reply shape and decode only that shape. |
Safe variations
You can extend this recipe without changing the primitive:
- Add
launch.analytics in the rail region with its own data name.
- Move
launch.help from panel to main by changing region.
- Add
settings.billing with a new component key and target.
- Add a second shell that renders the same descriptors differently for mobile.
- Add a support surface that reads account/device status once that public names exists.
The shell stays generic in every variation. The product grows by adding descriptors, package material, and surface bodies.Review checklist
The finished product loop should be able to answer:Package records:
Package stored bytes:
Package targets:
Surface descriptors:
Shell generic proof:
Surface bodies:
Data name use:
Action request use:
Request/reply shapes:
Files changed:
Validation run:
Reject the work if the shell names product concepts in branches, if a surface reads/calls names missing from its descriptor, or if app code imports package implementation files.Next
Read Placeable surfaces when you need the concept behind this recipe.Read Package file for exact package thing fields.Read Runtime Kit API for the public read/request functions.