> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bitfield.so/llms.txt
> Use this file to discover all available pages before exploring further.

# Placeable surface product loop

> Build a full product loop with package records, action requests, surface descriptors, a dumb shell, and surface bodies.

<div className="bf-article">
  <p className="bf-lead">
    This Runtime Kit Cookbook entry builds the same shape explained in [Placeable surfaces](/runtime-kit/placeable-surfaces): package material becomes named data reads and targets, descriptors declare what each surface may touch, and a dumb shell places the surfaces.
  </p>

  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

  ```text theme={null}
  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.

  ```json theme={null}
  {
    "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.

  ```ts theme={null}
  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.

  ```tsx theme={null}
  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.

  ```tsx theme={null}
  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

  ```tsx theme={null}
  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:

  ```text theme={null}
  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:

  1. Add `launch.analytics` in the `rail` region with its own data name.
  2. Move `launch.help` from `panel` to `main` by changing `region`.
  3. Add `settings.billing` with a new component key and target.
  4. Add a second shell that renders the same descriptors differently for mobile.
  5. 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:

  ```text theme={null}
  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](/runtime-kit/placeable-surfaces) when you need the concept behind this recipe.

  Read [Package file](/reference/package-boundary) for exact package thing fields.

  Read [Runtime Kit API](/reference/runtime-kit-api) for the public read/request functions.
</div>
