> For the complete documentation index, see [llms.txt](https://beamline.gitbook.io/beamline-docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://beamline.gitbook.io/beamline-docs/getting-started/connect-your-data.md).

# Connect your data

How Beamline components take data and report actions: market data for trading, tables, streaming AI text and whole screens, your backend in charge.

> The original of this page is on beamline.io: [beamline.io/docs/data-integration](https://beamline.io/docs/data-integration).

> Beamline code and your custom style files stay in your repo; your app has no Beamline runtime dependency. This documentation ships with the complete system (docs/DATA-INTEGRATION.md in the kit). Use [local kit tools](https://beamline.io/docs/install) or [connect an agent](https://beamline.io/connect) to install parts.

Components never fetch. They take plain data through props and report what people do through callbacks. Your app owns requests, persistence, authentication and permissions. The same interface can sit over your REST API, an existing cache, a server-rendered page or a live stream; Beamline does not supply those services.

## The data seam: props in, callbacks out

Keep one small adapter between your API and the component. Decode the response there, map its fields once, and keep network state outside the view. `DataGrid` takes `data` and `columns`; `OrderBook` takes `bids` and `asks`; `PriceChart` takes `candles`. Inspect the installed props rather than assuming all parts call their data `rows`.

* **Your app holds saved state.** A controlled `value` and `onValueChange` let the app own a field. `defaultValue` lets the component own its draft. A changed value is not evidence of a saved record.
* **Callbacks have different contracts.** `Form.onSubmit` and `Settings.onSave` await promises. Reject on failure. `DataGrid.onCellEdit` is a notification returning `void`; handle its pending/error state and update `data` yourself. Do not assume every callback awaits an external operation.
* **Missing is not zero.** Use `null` for an unmeasured value where the prop accepts it. Numeric displays can show a hatch or a missing marker; grid cells have `nullLabel`. Do not turn `null` into `0` with `Number(value)` or `value || 0`. An array prop still needs an array: use `[]` plus loading/error state, not `null` cast to an array.
* **Keep numbers raw.** Format at the view boundary with the component's formatter. A real zero is still zero. Market candles use UTC **seconds**; do not send JavaScript milliseconds as candle times.

## Before copying the examples

These files use selected compiled packages: `@beamline/data-grid`, `@beamline/table`, `@beamline/admin-users`, `@beamline/pagination`, `@beamline/order-book`, `@beamline/price-chart`, `@beamline/form`, `@beamline/input` and `@beamline/button`. Install those parts through the kit's installer, as described in [Install](https://beamline.io/docs/install). Import the generated stylesheet once and put the app inside `UIRoot` with its installed look. An aggregate kit instead exports the same parts under `@beamline/ui/<id>`.

The examples also use Zod 4 for response validation, React Hook Form 7 for form state, and, in the cache example, TanStack Query 5 and SWR 2. Those libraries belong to the app. Each filename below is complete; put the files beside one another except the Next.js page, whose path is given separately. Replace the example `/api/...` routes with your own. The payloads are example contracts, not endpoints included with Beamline.

All code blocks on this page are compiled and rendered in a fresh consumer app against built kit packages. The checks exercise a local fixture API, including failed requests, a persisted form save, SSE and WebSocket snapshots. That proves these example connections; it does not certify your API, exchange or authentication provider.

## REST: validate once, then render

This example endpoint returns a page of deals: `data` is an array of records and `total` counts all matching records. The amount may be `null`. Malformed responses fail before they reach the grid.

### data.ts

```ts
import { z } from "zod";

export const dealSchema = z.object({
  id: z.string().min(1),
  name: z.string(),
  amount: z.number().finite().nullable(),
});
export const dealsSchema = z.object({
  data: z.array(dealSchema),
  total: z.number().int().nonnegative(),
});
export type Deal = z.infer<typeof dealSchema>;
export type DealsPage = z.infer<typeof dealsSchema>;

export async function readDeals(url: string, signal?: AbortSignal): Promise<DealsPage> {
  const response = await fetch(url, { signal, credentials: "same-origin" });
  if (!response.ok) throw new Error(`Could not load deals (${response.status}).`);
  return dealsSchema.parse(await response.json());
}
```

### records.tsx

```tsx
"use client";

import { useEffect, useState } from "react";
import { DataGrid, type DataGridColumn } from "@beamline/data-grid";
import { Pagination } from "@beamline/pagination";
import { Button } from "@beamline/button";
import { readDeals, type Deal, type DealsPage } from "./data";

const columns: DataGridColumn<Deal>[] = [
  { id: "name", header: "Deal", sortable: false },
  { id: "amount", header: "Amount", type: "currency", nullLabel: "Unmeasured", sortable: false },
];

export function DealsView({ data = [], loading = false, error }: {
  data?: Deal[];
  loading?: boolean;
  error?: string;
}) {
  return <DataGrid data={data} columns={columns} getRowId={(row) => row.id}
    label="Deals" height={280} loading={loading} error={error}
    emptyLabel={error ? "Records unavailable" : "No deals yet"} toolbar={false} footer={false} selectable={false} />;
}

export function RestDeals({ endpoint = "/api/deals" }: { endpoint?: string }) {
  const [page, setPage] = useState(1);
  const [retry, setRetry] = useState(0);
  const [state, setState] = useState<{
    result?: DealsPage; loading: boolean; error?: string;
  }>({ loading: true });
  const pageSize = 20;
  useEffect(() => {
    const controller = new AbortController();
    setState({ loading: true });
    const query = new URLSearchParams({ page: String(page), pageSize: String(pageSize) });
    readDeals(`${endpoint}?${query}`, controller.signal).then(
      (result) => {
        if (!controller.signal.aborted) setState({ result, loading: false });
      },
      (error: unknown) => {
        if (!controller.signal.aborted) setState({ loading: false,
          error: error instanceof Error ? error.message : "Could not load deals." });
      },
    );
    return () => controller.abort();
  }, [endpoint, page, retry]);
  return <section aria-label="REST deals">
    <DealsView data={state.result?.data} loading={state.loading} error={state.error} />
    {state.error && <Button onClick={() => setRetry((n) => n + 1)}>Retry deals</Button>}
    {state.result && state.result.total > 0 && <Pagination page={page}
      pageSize={pageSize} total={state.result.total}
      pageCount={Math.ceil(state.result.total / pageSize)} onPageChange={setPage} />}
  </section>;
}
```

The endpoint is a path without a query string. On a page change this small example clears the old page; it cannot silently label page 1's records as page 2. Abort cleanup prevents a slow earlier request overwriting a newer result. If you keep previous rows while refreshing, name them as previous data and keep the previous page number until the new page arrives. Reset or clamp the page when filters or deletion change the result count.

## Loading, empty, failed and stale are different states

| State                                | What the person should see                         | What the app sends                                               |
| ------------------------------------ | -------------------------------------------------- | ---------------------------------------------------------------- |
| First request in progress            | Loading, not a claim that there are no records     | `data={[]}` and `loading` on DataGrid                            |
| Successful request, no records       | A specific empty message                           | Empty data, loading off, no error                                |
| Request failed                       | Error and a real retry action                      | `error` plus app-owned retry; never a fabricated empty success   |
| Refresh failed with previous records | Previous records remain, with an error/stale label | Keep previous data and report that it is no longer current       |
| Missing measurement                  | Unmeasured, distinct from a measured zero          | `null` in a supported value/cell                                 |
| Stream disconnected                  | Last received data and a connection warning        | `stale` on OrderBook/PriceChart; error/status beside other views |

`OrderBook` has no general loading or error prop; provide a status beside it. `PriceChart` has `loading`, `error` and `stale`. Use the installed API for each part. For a whole screen, the kit also has Skeleton, EmptyState and Alert; you do not need to build a second blank-state layout inside the component.

## React Query and SWR

Reuse the app's existing cache. The component should not create a second cache or fetch the same resource again. Include the resource, account/tenant and query parameters in keys; clear private cached data on sign-out. After a successful mutation, invalidate the relevant React Query key or call SWR's `mutate` for it. Report mutation errors separately from read errors and only replace cached rows with a confirmed response.

This file shows two alternatives using the same `readDeals` adapter and `DealsView`. Pick one in your app. React Query needs a `QueryClientProvider` above its view; the example creates a client per mounted provider.

### cached-records.tsx

```tsx
"use client";

import { useState } from "react";
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
import useSWR from "swr";
import { readDeals } from "./data";
import { DealsView } from "./records";

function QueryDeals({ url }: { url: string }) {
  const query = useQuery({
    queryKey: ["deals", url],
    queryFn: ({ signal }) => readDeals(url, signal),
  });
  return <DealsView data={query.data?.data} loading={query.isPending}
    error={query.error?.message} />;
}

export function ReactQueryDeals({ url = "/api/deals?page=1&pageSize=20" }: { url?: string }) {
  const [client] = useState(() => new QueryClient());
  return <QueryClientProvider client={client}><QueryDeals url={url} /></QueryClientProvider>;
}

export function SwrDeals({ url = "/api/deals?page=1&pageSize=20" }: { url?: string }) {
  const { data, error, isLoading } = useSWR(url, (key: string) => readDeals(key));
  return <DealsView data={data?.data} loading={isLoading}
    error={error instanceof Error ? error.message : undefined} />;
}
```

Cache reference: [TanStack Query](https://tanstack.com/query/latest/docs/framework/react/overview) and [SWR](https://swr.vercel.app/docs/getting-started), read 11 October 2026. These examples exercise reads; choose and test your own cache invalidation and optimistic-update policy before using them for writes.

## Live streams: WebSocket or SSE into a book, chart and grid

Normalize exchange-specific messages on your server or in one adapter. This example expects **complete snapshots** with a rising `sequence`, a book, candles and deals. It replaces a snapshot; it does not apply exchange deltas. A level is `[price, size]`. Candles must be ordered oldest first with unique UTC-second times. Retain only a bounded history.

### market.tsx

```tsx
"use client";

import { useEffect, useState } from "react";
import { z } from "zod";
import { OrderBook } from "@beamline/order-book";
import { PriceChart } from "@beamline/price-chart";
import { Button } from "@beamline/button";
import { dealSchema } from "./data";
import { DealsView } from "./records";

const price = z.number().finite().positive();
const candle = z.object({
  time: z.number().int().nonnegative(),
  open: price, high: price, low: price, close: price,
  volume: z.number().finite().nonnegative().optional(),
});
const snapshotSchema = z.object({
  sequence: z.number().int().nonnegative(),
  bids: z.array(z.tuple([price, z.number().finite().nonnegative()])),
  asks: z.array(z.tuple([price, z.number().finite().nonnegative()])),
  last: price.nullable(),
  candles: z.array(candle).refine((rows) => rows.every((row, i) =>
    i === 0 || row.time > rows[i - 1].time), "Candle times must increase"),
  deals: z.array(dealSchema),
});
type Snapshot = z.infer<typeof snapshotSchema>;
type Transport = "sse" | "websocket";

// Remount when the resource changes so a previous symbol's book cannot flash under a new name.
export function LiveMarket({ url, transport }: { url: string; transport: Transport }) {
  return <MarketConnection key={`${transport}:${url}`} url={url} transport={transport} />;
}

function MarketConnection({ url, transport }: { url: string; transport: Transport }) {
  const [snapshot, setSnapshot] = useState<Snapshot>();
  const [status, setStatus] = useState("Connecting");
  const [retry, setRetry] = useState(0);
  useEffect(() => {
    let closed = false;
    let sequence = -1;
    let receivedAt = 0;
    setStatus("Connecting");
    const stream = transport === "sse" ? new EventSource(url) : new WebSocket(url);
    stream.onmessage = (event) => {
      if (closed) return;
      try {
        const next = snapshotSchema.parse(JSON.parse(String(event.data)));
        if (next.sequence <= sequence) return;
        sequence = next.sequence;
        receivedAt = Date.now();
        setSnapshot({ ...next, bids: next.bids.slice(0, 50), asks: next.asks.slice(0, 50),
          candles: next.candles.slice(-500), deals: next.deals.slice(0, 100) });
        setStatus("Live");
      } catch {
        setStatus("Invalid snapshot; showing last valid data");
      }
    };
    stream.onerror = () => {
      if (!closed) setStatus(transport === "sse" ? "Reconnecting" : "Connection failed; retry");
    };
    if (stream instanceof WebSocket) stream.onclose = () => {
      if (!closed) setStatus("Disconnected; retry");
    };
    const timer = setInterval(() => {
      if (receivedAt && Date.now() - receivedAt > 15_000) setStatus("Stale: no recent snapshot");
    }, 1000);
    return () => { closed = true; clearInterval(timer); stream.close(); };
  }, [url, transport, retry]);
  const stale = status === "Live" ? undefined : status;
  return <section aria-label={`${transport} market`}>
    <p role="status">{status}</p>
    <Button onClick={() => setRetry((n) => n + 1)}>Reconnect stream</Button>
    <OrderBook bids={snapshot?.bids ?? []} asks={snapshot?.asks ?? []}
      last={snapshot?.last ?? null} stale={stale} />
    <PriceChart candles={snapshot?.candles ?? []} height={260}
      loading={!snapshot && status === "Connecting"} stale={stale} />
    <DealsView data={snapshot?.deals} loading={!snapshot && status === "Connecting"}
      error={stale && status !== "Connecting" ? stale : undefined} />
  </section>;
}
```

Serve SSE as `text/event-stream`, separate events with a blank line, and flush each event. Disable proxy buffering and compression that holds small chunks; `Cache-Control: no-cache, no-transform` prevents the Next.js proxy from compressing this example stream. Confirm that the first event reaches the browser before the connection closes.

SSE messages use the default `message` event. A named event needs `addEventListener` for that name. SSE reconnects through the browser; this WebSocket example reconnects when the person presses the button. The server must send a fresh full snapshot after either reconnection and keep sequence numbers increasing within a connection. The timer assumes a snapshot at least every 15 seconds; adapt the freshness rule to your feed's heartbeat and market hours. Use a server-owned session epoch if sequence numbers can reset without a new connection.

For an exchange's delta protocol, load its snapshot, apply only correctly sequenced deltas, and resnapshot on a gap. Coalesce fast updates before setting React state, while preserving every event needed to compute the book. Do not append indefinitely or sort a whole large dataset on every tick. The `PriceChart` can update an appended/amended last candle in place; still supply a new array when data changes. Neither these samples nor the kit execute a trade.

### The complete trading terminal

For individual trade prints, `TradesTape` takes a `trades` array of the shared `Trade` shape.

`TradingTerminal` is imported from `@beamline/trading-terminal`; it takes `symbols`, a `useFeed` hook and a `TradingAccount`. Shared types come from `@beamline/lib-market`: `Candle`, `Level`, `Trade`, `MarketFeed`, `FeedRequest`, `UseMarketFeed`, `Ticker`, `Position`, `OrderRequest` and `TradingAccount`. Read the installed declarations for your release.

The terminal calls `useFeed` once per open market and remounts it for a new symbol/interval. The hook returns candles, book levels, trades, `last`, `prev` and `change`. `MarketFeed` currently requires numeric prices: wait for a validated initial snapshot before mounting a terminal whose values are not yet measured; do not seed missing prices with zero. Load that snapshot outside the terminal and initialize `useFeed` from the adapter's cached snapshot on its first call.

Your account adapter supplies `balance`, `positions`, `orders`, `fills`, `submitOrder`, `cancelOrder` and `closePosition`. Map requests to your order API. A confirmed submission returns the declared filled/resting result; a rejection throws an `Error`. Handle unknown outcomes by reconciling with the server before allowing a duplicate submission. The terminal never fills an order itself. Use a non-production account to verify this boundary.

Supply the exchange/API adapter and validated initial data in your app. If your adapter exposes a `useExchangeAccount` hook, call it at the top level inside your React component and pass its returned account to `TradingTerminal`. That hook is an app-owned requirement, not a Beamline export.

`useMockFeed` in `@beamline/demo-market-simulator` is a random walk; `usePaperAccount` in `@beamline/demo-paper-account` fills paper orders in the browser. The gallery and reference app label those as simulations. They are not exchange adapters.

## Forms that post back

A successful HTTP request must mean the server accepted and persisted the operation. Check the status, decode the response and display success only then. Keep the draft after rejection. Client validation helps the person; the server must still validate input, authenticate the caller, authorize the record and apply the app's CSRF policy.

### profile.tsx

```tsx
"use client";

import { useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Form, FormField, FormError, FormSubmit } from "@beamline/form";
import { Input } from "@beamline/input";

const profileSchema = z.object({ name: z.string().trim().min(1) });
type Profile = z.infer<typeof profileSchema>;

export function ProfileForm({ initial, endpoint = "/api/profile" }: {
  initial: Profile; endpoint?: string;
}) {
  const form = useForm<Profile>({ defaultValues: initial });
  const [saved, setSaved] = useState(false);
  return <Form form={form} onSubmit={async (values) => {
    setSaved(false);
    form.clearErrors();
    const response = await fetch(endpoint, {
      method: "POST", credentials: "same-origin",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(profileSchema.parse(values)),
    });
    if (!response.ok) throw new Error(`Profile was not saved (${response.status}). Try again.`);
    const confirmed = profileSchema.parse(await response.json());
    form.reset(confirmed);
    setSaved(true);
  }}>
    <FormField<Profile, "name"> name="name" rules={{ validate: (value) => value.trim().length > 0 || "Enter your name." }}
      render={({ field, error }) => <Input {...field} label="Name" error={error}
        disabled={form.formState.isSubmitting} onChange={(event) => {
          setSaved(false); field.onChange(event);
        }} />} />
    <FormError />
    <FormSubmit>Save profile</FormSubmit>
    {saved && <p role="status">Profile saved</p>}
  </Form>;
}
```

This form disables edits during the save and resets to the server's confirmed response. A form that allows continued editing needs a snapshot-aware merge, so an earlier response cannot erase later typing. For a structured validation response, validate its shape and map known field errors through `form.setError`; never trust arbitrary keys from an unvalidated response. A lost response can leave the outcome unknown: read the record back before retrying a non-idempotent action. The same applies to grid edits; `onCellEdit` alone is not a save indicator.

## Server components and Next.js

Fetch private data in a Server Component, check the session there, and pass serializable records to a small client view. Keep event handlers, hooks, column render functions, `UIRoot` and interactive controls on the client side of the boundary. A normal callback cannot be passed from a Server Component to a Client Component. Server Actions are another possible write boundary; the server must still authorize them.

Here `app/page.tsx` loads public demonstration deals; `app/examples/records.tsx` above owns the grid's functions. `BACKEND_ORIGIN` is a server-only configured origin, never user input. For private records, add your actual server session/authorization check before this fetch and do not share a cached response across users. This sample uses `no-store` so the result is fetched per request.

### app/page.tsx

```tsx
import { dealsSchema } from "./examples/data";
import { DealsView } from "./examples/records";

export const dynamic = "force-dynamic";

export default async function DealsPage() {
  const origin = process.env.BACKEND_ORIGIN;
  if (!origin) throw new Error("Set BACKEND_ORIGIN on the server.");
  const response = await fetch(`${origin}/api/deals?page=1&pageSize=20`, { cache: "no-store" });
  if (!response.ok) throw new Error("Could not load deals on the server.");
  const result = dealsSchema.parse(await response.json());
  return <DealsView data={result.data} />;
}
```

Give the route a loading boundary and an error boundary appropriate to your app. Keep secrets in server-only modules, never in a `NEXT_PUBLIC_` variable or a client prop. Reference: [Next.js server and client components](https://nextjs.org/docs/app/getting-started/server-and-client-components), read 11 October 2026.

## Authentication: what is proven and what is a pattern

**Clerk is the only authentication-provider integration proven in Beamline's own service.** beamline.io uses the kit's `Auth` block with Clerk for email-code and Google/GitHub flows. This is evidence about our service, not a claim that every Clerk flow or every auth control has been tested with it. The kit does not include a configured Clerk project, session store or server authorization layer.

| Provider or approach                         | Status here                       | Application boundary                                                                                                                            |
| -------------------------------------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| Clerk                                        | Used by beamline.io's own service | The app maps Clerk results into Auth's sign-in/sign-up handlers and activates the session before reporting success.                             |
| Auth.js, Supabase Auth, Firebase Auth, Auth0 | Untested integration patterns     | Implement the installed Auth/SignIn callbacks using that provider's SDK; validate redirects, session creation, errors and sign-out in your app. |
| Existing cookie/session API                  | Untested integration pattern      | Call your session endpoints from the handlers, return the expected account result, and enforce permissions on the server.                       |

Never treat a hidden button or an `Auth` success screen as authorization. Protect reads, writes and stream subscriptions on the server. Browser `EventSource` and `WebSocket` do not take an arbitrary Authorization header in their constructors: use your app's same-origin authenticated endpoint or a server-issued, short-lived connection mechanism. Do not put a long-lived API key in a URL or ship one in a browser bundle. Provider credentials stay with the app.

## One server-list recipe

DataGrid, Table record mode and the record-list screens accept the same list state. Use `manual` when your server returns an already sorted, filtered page. Keep the toolbar: its callbacks request the next result without sorting or filtering the loaded page again. The app owns fetching, validation, cancellation and persistence.

`page` starts at 1. `pageSize` counts records per page. `rowCount` counts all matching records before paging; pass `null` when the server cannot count them, and supply `hasMore`. A real zero means the result is empty. In local mode, the component derives the count before slicing. `Pagination.pageCount` remains a count of pages; its old `total` prop is a deprecated alias for `rowCount`.

The mutable states are `page`, `pageSize`, `query`, `sort`, `filters` and `selected`. Each also has `default…` and `on…Change` forms. `sort` is `{ id, desc }[]`; `filters` maps field IDs to string arrays. An empty query, sort array or filter map is a controlled cleared state. Selection contains stable IDs and survives page changes. The app must apply bulk mutations to those IDs; a block's existing local mutation handler cannot update unloaded records by itself. Opt into `allowSelectAllMatching` and handle `onSelectAllMatching({ rowCount, query, filters })` for a server-wide bulk action.

Install `table` and `admin-users` through the kit installer. Their common type package, `@beamline/lib-list`, is included in the dependency graph. Import the generated stylesheet and wrap the page in UIRoot, as in [Install](https://beamline.io/docs/install). These two files are the same adapter in a plain Table and the complete administration screen. The endpoint returns `{ rows, rowCount, hasMore }`; adapt and validate your own response at this boundary. Keep `decodeRow` stable (outside the component or memoized) so a render does not start another request.

### use-server-list.ts

```ts
import { useEffect, useState } from "react";
import type { ListFilters, ListProps, ListSort } from "@beamline/lib-list";

export function useServerList<T>(url: string, decodeRow: (input: unknown) => T) {
  const [page, setPage] = useState(1);
  const [pageSize, setPageSize] = useState(10);
  const [query, setQuery] = useState("");
  const [sort, setSort] = useState<ListSort>([]);
  const [filters, setFilters] = useState<ListFilters>({});
  const [selected, setSelected] = useState<string[]>([]);
  const [rows, setRows] = useState<T[]>([]);
  const [rowCount, setRowCount] = useState<number | null>(null);
  const [hasMore, setHasMore] = useState(false);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
  const [retry, setRetry] = useState(0);

  useEffect(() => {
    const abort = new AbortController();
    const params = new URLSearchParams({
      page: String(page), pageSize: String(pageSize), query,
      sort: JSON.stringify(sort), filters: JSON.stringify(filters),
    });
    setLoading(true);
    setError(null);
    void (async () => {
      try {
        const response = await fetch(`${url}?${params}`, { signal: abort.signal });
        if (!response.ok) throw new Error(`Could not load records (${response.status}).`);
        const body: unknown = await response.json();
        if (!body || typeof body !== "object" || !("rows" in body) || !Array.isArray(body.rows) ||
          !("rowCount" in body) || !("hasMore" in body) || typeof body.hasMore !== "boolean" ||
          (body.rowCount !== null && (typeof body.rowCount !== "number" || !Number.isSafeInteger(body.rowCount) || body.rowCount < 0))) {
          throw new Error("Invalid list response.");
        }
        const next = body.rows.map(decodeRow);
        if (abort.signal.aborted) return;
        setRows(next);
        setRowCount(body.rowCount as number | null);
        setHasMore(body.hasMore);
      } catch (reason) {
        if (!abort.signal.aborted) setError(reason instanceof Error ? reason : new Error("Could not load records."));
      } finally {
        if (!abort.signal.aborted) setLoading(false);
      }
    })();
    return () => abort.abort();
  }, [url, decodeRow, page, pageSize, query, sort, filters, retry]);

  const list: ListProps & { manual: true; selected: string[] } = {
    manual: true, page, pageSize, rowCount, hasMore, query, sort, filters, selected, loading, error,
    onPageChange: setPage,
    onPageSizeChange: (value) => { setPageSize(value); setPage(1); },
    onQueryChange: (value) => { setQuery(value); setPage(1); },
    onSortChange: (value) => { setSort(value); setPage(1); },
    onFiltersChange: (value) => { setFilters(value); setPage(1); },
    onSelectedChange: setSelected,
    onRetry: () => setRetry((value) => value + 1),
  };
  return { rows, list };
}
```

### users.tsx

```tsx
import { AdminUsers, type AdminUser } from "@beamline/admin-users";
import { Table, type TableColumn } from "@beamline/table";
import { Button } from "@beamline/button";
import { useServerList } from "./use-server-list";

// This endpoint's row schema belongs to the app; validate your own API's fields here.
function decodeUser(input: unknown): AdminUser {
  if (!input || typeof input !== "object") throw new Error("Invalid user.");
  const row = input as Record<string, unknown>;
  if (typeof row.id !== "string" || typeof row.name !== "string" || typeof row.email !== "string" ||
    !["active", "suspended", "invited", "deactivated"].includes(String(row.status)) ||
    !(row.plan === null || typeof row.plan === "string") || typeof row.workspaces !== "number" ||
    !Number.isFinite(row.workspaces) || typeof row.twoFactor !== "boolean" ||
    !(row.lastSeen === null || (typeof row.lastSeen === "number" && Number.isFinite(row.lastSeen))) ||
    typeof row.created !== "number" || !Number.isFinite(row.created)) throw new Error("Invalid user fields.");
  return row as unknown as AdminUser;
}
const columns: TableColumn<AdminUser>[] = [
  { id: "name", header: "Name" },
  { id: "email", header: "Email", fold: "md" },
  { id: "plan", header: "Plan", filterable: true, filterValues: ["Free", "Team", "Business"] },
  { id: "lastSeen", header: "Last seen", value: (row) => row.lastSeen,
    cell: (row) => row.lastSeen === null ? <span aria-label="Not measured">—</span> : new Date(row.lastSeen).toLocaleDateString() },
];

export function Users({ view }: { view: "table" | "admin" }) {
  const { rows, list } = useServerList("/api/users", decodeUser);
  return <section style={{ minWidth: 0 }}>
    <Button size="sm" variant="secondary" onClick={() => void list.onRetry?.()}>Refresh list</Button>
    {view === "table"
      ? <Table {...list} label="Users" data={rows} columns={columns} getRowId={(row) => row.id} />
      : <AdminUsers {...list} users={rows} />}
  </section>;
}
```

The spread stays the same when changing the surface; only the domain records and required display inputs change:

| Surface                                                 | Records / setup                                                                        |
| ------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| DataGrid / Table                                        | `data={rows}`, `columns`, `getRowId` (Table compound children remain supported)        |
| CRM / Invoices / OrdersAdmin / AdminUsers               | `deals`, `invoices`, `orders`, `users`                                                 |
| AgentRuns / WorkflowBuilder run history                 | `runs`; on WorkflowBuilder pass `runsList={list}`                                      |
| SocialScheduler publishing queue                        | `posts`, `view="queue"`, `queueList={list}`; publishing `onRetry(id)` remains separate |
| OrderHistory                                            | `orders`; for manual `view="fills"`, pass `fills` with each fill's parent order        |
| Portfolio / MarketScreener / Watchlist / PositionsTable | `holdings`, `markets`, `items`, `positions`                                            |
| HelpdeskInbox / InboxList / SupportConsole              | `tickets` plus `agents`/`me`, `items`, `conversations` plus `me`                       |
| AuditLog / ChangelogFeed / Timeline                     | `entries`, `entries`, `events`                                                         |
| TeamMembers / Projects / FileManager / Integrations     | `members`, `projects`, `items`, `apps`                                                 |
| KnowledgeBase / ConversationHistory                     | `sources`, `threads`                                                                   |
| Billing / ApiKeys                                       | `invoices` plus `plan`; active-tab `keys` or `webhooks`                                |

Custom list screens expose selection through their selection menu when `selectable` is true. Opening one record remains separate: `openUser`, `openTicket`, `openSource`, `openConversation`, etc. Old scalar `selected` values on KnowledgeBase and SupportConsole remain compatible aliases for opening a record; array `selected` is list selection.

Screen-specific filter IDs keep their domain meaning: CRM `scope` (`all`/`mine`); MarketScreener `screen`; HelpdeskInbox/TeamMembers/SupportConsole `view`; Invoices `state`; OrdersAdmin `view` (`all`/`fulfil`/`unpaid`/`returns`); ChangelogFeed `tags`; Projects `health`; Integrations `category`; KnowledgeBase `attention`; ConversationHistory `bookmarked`; AuditLog `area`, `actor`, `from`, `to` (ISO date strings for the range). Implement those semantics in your server adapter. Unknown field IDs do not filter local rows. Changing a folder or reporting range still uses the screen's domain navigation callbacks. If a screen has multiple record views, switch the request's scope and `rowCount` with its active view.

For append loading, omit the page props and pass `onEndReached`, `hasMore` and `loadingMore`. Append the received records in your app. A rejected promise offers an explicit retry; it is not retried in a loop. A paged or appended list keeps its completed rows through pending and failed requests, even if a fetching library temporarily supplies `[]`. A successful empty response clears them. `error` accepts a message/React node or an Error; `onRetry()` requests the failed page again. `loading` and `error` are facts from your request, not editable states with change callbacks.

Virtualization reduces rendered rows, not the cost of downloading everything. Bound live streams and chart windows. A metric or aggregate calculated from supplied records describes the loaded records. Pass separate account-wide metrics to your app where needed. The gallery examples simulate a server; the fresh-consumer proof uses a local HTTP fixture and compiled packages. Neither certifies your backend or authentication provider.

## Streaming text, blocks and reference adapters

`Response` renders markdown while it arrives. `useSmoothText` in `@beamline/hook-use-smooth-text` smooths bursts; pass the received text and a `streaming` flag. Cancellation, transport errors and provider billing remain with your app. Never turn an interrupted answer into a completed answer silently.

Each block's installed catalog and `source/ui/blocks/<id>/meta.json` document its props. The reference app's `reference-app/src/data/` adapters show where to replace demonstration data with your API. The CRM screen uses the same `DataGrid`; `@beamline/demo-crm-fixtures` generates fictional deals. Replace the adapter body, preserve its shape, and test the real persistence boundary after reload.

## Personal profile and continued edits

The ordinary Settings configuration uses `name`, `email`, `timeZone` and `notifications`. In the matching 0.2 release, omit `workspace` and `slug` to omit Workspace. Optional extra inputs are declared by `profileFields` and stored under `values.profile`; their changed keys use `profile.<id>`. An optional Job title can therefore use the existing save path instead of a second form or handler. Empty optional values are valid. Inspect the installed types before applying these newer fields to an older artifact.

`onSave(values, changed)` resolves only after the destination has persisted that record and rejects when it has not. The block trims name/email before invoking it. A general Error retains the draft and retry. An Error carrying a known `field` key adds an inline error and focuses that field, or offers a return to its section. Edits made while the promise is pending remain a new unsaved draft after that snapshot succeeds. The callback currently returns `void`; if your server canonicalizes other fields, coordinate the saved-value contract rather than silently displaying a different record.

`createBrowserSettingsApi` in `demo/settings-fixtures` is a labelled sample adapter: it writes the current origin's `localStorage` under `beamline-profile-sample-v1` by default. Reload in the same browser/origin retains its fictional record; clearing browser storage removes it, and another browser/account does not share it. The old `createSettingsApi` remains an in-memory demonstration for specialized workspace sections, so reload resets those changes. Replace either with the application's existing handler for real product data.

For an API adapter, preserve its existing endpoint and request mapping, check the response status and reject on failures. Do not mark the UI successful before the operation resolves. Keep a failed draft available for retry; validation must also protect the actual persistence boundary. Verify the stored result after reload, rejection without mutation, and a controlled failure at that real boundary. A toast proves only the UI response.

When embedded in a host that already supplies navigation, matching Settings releases use `navigation="tabs"` and `heading={null}`. When the host routes sections, use controlled `section` with `navigation="none"`. These choices prevent an accidental second application shell.

[Knowledge: sourced answers](https://beamline.io/knowledge.md): concise answers with their original sources.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://beamline.gitbook.io/beamline-docs/getting-started/connect-your-data.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
