@uiresponse/slot-sdk
v0.2.0
Published
React SDK for UI Response delivery slots: a UIResponseSlot renders a validated generated view, plain text, or your existing fallback — decided per ask.
Downloads
525
Maintainers
Readme
@uiresponse/slot-sdk
Put UI Response (UIR) inside an app you already have. A slot is one spot in an existing page where an answer may arrive as a live, validated view — or as plain text, or as nothing at all, decided per ask. Your app stays static by default; UIR enters only where you place it.
import { UIResponseSlot } from "@uiresponse/slot-sdk";
import "@uiresponse/slot-sdk/styles.css";
<UIResponseSlot
id="artist-empty-state"
ask="What should this artist's manager do next?"
context={{ artist_id: artist.id }}
lifetime="ephemeral"
ui="auto"
fallback={<EmptyState />}
/>fallback is mandatory and paints immediately. A valid envelope then does exactly one thing:
textkeeps the host fallback (usually the response text the host already had).uireplaces it with the existing@uiresponse/webembed renderer.emptyrenders nothing.
The default sidecar deadline is 2.5 seconds. Three consecutive endpoint failures open a shared
30-second circuit, so cards using one dead origin stop rediscovering the outage. Timeouts, network
errors, non-2xx responses, invalid envelopes, and an open circuit all remain on fallback. A host
can use useUIResponseSlot() directly for custom placement chrome while keeping the same semantics.
Props
| Prop | Default | What it does |
|---|---|---|
| fallback | — | Required. Your current component. Paints immediately; stands on text, empty, timeout, error, or open circuit. Omitting it throws. |
| id | — | Slot identity. Part of the snapshot/pin key together with ask and context. |
| ask | — | The question. View-shaped asks ("show every artist as a table…") verdict ui; open judgment-shaped asks often verdict text. |
| ui | "auto" | "auto" \| "always" \| "never". auto lets the router choose prose over a view — and prose means your fallback stands. always forces a view. |
| timeoutMs | 2500 | Time-to-verdict. A cold generation takes 20s+, so the default fails closed to the fallback on a first fill. Raise it, or accept snapshot-only painting. |
| onStatus | — | (state) => void. The only way to see why a slot didn't fill — the slot logs nothing. Gives status, kind, error, upgrading, refreshed, pinned. |
| context | {} | Extra scoping for the ask (e.g. { artist_id }). Part of the snapshot key. Not a credential channel. |
| rendererProps | {} | Passed to the renderer. rendererProps.params is forwarded into every POST /v1/resolve body — this is where a per-user JWT goes; see Per-user data and RLS. |
| headers | {} | Extra HTTP headers on the fill request — tenant auth for the endpoint (Bearer <your UIR token>). Not the per-user RLS channel. |
| endpoint | "" | Origin serving the slot route. |
| path | /v1/tenants/demo/ui | Override the slot route (note the demo tenant in the default). |
| lifetime | "ephemeral" | ephemeral renders inline. |
| chrome | true | false removes all SDK chrome (including the Pin control) and keeps the semantics. |
Why is my slot still showing the fallback?
Usually because it is working as specified, not because it broke:
- the router verdicted
text(open, judgment-shaped ask + defaultui="auto") — prose is your fallback. Ask for something view-shaped, or setui="always"; - the fill took longer than
timeoutMs(the 2.5s default vs a 20s+ cold generation); - the endpoint is unreachable, or the circuit is open.
The slot is silent by design — a failed fill logs nothing — so onStatus is your debugger:
<UIResponseSlot {...slotProps} onStatus={(s) => console.log(s.status, s.kind, s.error)} />
// loading → ready + kind:"ui" ← filled
// loading → ready + kind:"text" ← verdict was text; the fallback correctly standsTypeScript
This package ships no type declarations today. tsc rejects the import with TS7016, so
npm run build fails in a typechecked app (vite dev is fine — it doesn't typecheck). Until types
ship, declare the modules yourself:
// src/uiresponse.d.ts
declare module "@uiresponse/slot-sdk";
declare module "@uiresponse/renderer-react";
declare module "@uiresponse/slot-sdk/styles.css";Per-user data and RLS
When rows sit behind row-level security, the resolver must act as the signed-in user. The user's JWT rides in the resolve body — never in a header, never in a URL:
<UIResponseSlot
{...slotProps}
rendererProps={{ params: { __postgrest_user_jwt: session.access_token } }}
/>rendererProps.params is forwarded verbatim into every POST /v1/resolve. The PostgREST connector
reads params.__postgrest_user_jwt and sends it as the Authorization: Bearer to your PostgREST,
alongside the publishable anon key — so RLS evaluates as that user, and this SDK holds no
credential that could over-read on its own. The server never logs a resolve body.
Do not reach for headers here: that is tenant auth for the endpoint, a different axis. And
UIR_POSTGREST_ANON_KEY must be publishable-class — a service-role key bypasses RLS entirely.
The transport: a fill is an AG-UI run
The default transport is the sibling /v1/tenants/:tenant/agui route: one slot fill is ONE AG-UI
run (client.runFill()), on a thread that IS the slot identity. The verdict arrives as a
CUSTOM uir.slot_envelope event — the deadline above governs time-to-verdict on the run
connection — a stored answer paints as an immediate STATE_SNAPSHOT, and the live upgrade
arrives as the same run's events, ending in RUN_FINISHED. A failed upgrade after a painted
snapshot is RUN_ERROR: the snapshot stays on screen, the failure counts against the circuit.
The JSON sidecar (client.fill(), POST /v1/ui) remains for compatibility — documented legacy,
same envelope metadata, same shared stores, no run semantics.
Snapshot→live
A slot answer paints INSTANTLY from the server's last snapshot for {slot id, ask, context} —
the envelope arrives with snapshot: true, upgrading: true — and the hook immediately runs the
live leg (refresh: true, same circuit breaker). The live answer swaps in place; the state
exposes upgrading while the snapshot is showing and refreshed once the live answer landed,
and the default <UIResponseSlot> chrome renders those as a quiet mono stamp. Blur-up at envelope
scale. Failure semantics are unchanged: if the live leg fails, the (validated) snapshot simply
stays; a snapshot never stands in for the host fallback on text/empty verdicts — the live
router's verdict always wins, even when it downgrades a snapshot's ui to text.
Pin
useUIResponseSlot() returns pin(): it promotes the answer on screen to a SAVED view server-side
(POST /v1/slots/pin — the same validator gate as saving a page) and remembers the slot→view
association, so subsequent fills of that exact key serve the saved view via the open_page path
instead of regenerating. State exposes pinned/pinning/pinError; the envelope of a
pin-served fill carries pinned: true. <UIResponseSlot> shows a minimal Pin control in its chrome;
pass chrome={false} to remove all SDK chrome and keep the semantics.
unpin() is the inverse on the same deterministic key (POST /v1/slots/unpin): it removes the
slot→view association — subsequent fills stop taking the saved view's path — but the saved view
itself stays saved; unpinning a placement never destroys an artifact. The chrome's Pin control is
a toggle. Server-side, snapshots also carry an honest freshness posture: a week's TTL and a
500-entry cap by default (UIR_SNAPSHOT_TTL_MS / UIR_SNAPSHOT_MAX_ENTRIES), expired snapshots
losing to a live generation instead of racing it.
Three-slot demo
The runnable Vite demo uses the real /v1/tenants/:tenant/ui and /v1/resolve request shapes with
a local middleware sidecar. It places slots in an empty state, a sidebar insight, and an inline
dashboard row. Together they exercise text, ui, and empty; the sidebar maps metric, table,
and badge to host-native components. A fourth panel walks one dead origin through three 2.5-second
timeouts and shows the shared circuit bypassing the next attempt.
# prereq, once, from uir/ — this package has no install step of its own; it resolves
# vite/react and the built @uiresponse/web dist through the workspace root:
npm install && npm run build && npm run build:embed
cd uir/packages/slot-sdk
npm run demoOpen the URL Vite prints (normally http://localhost:5173). The mock sidecar is demo-only; change
endpoint and/or path on each <UIResponseSlot> to point the same app at a real sidecar.
Host-native component registry
<UIResponseProvider> maps validated semantic block types to host components. It changes painting, not
the document or its data semantics:
function TheirStatCard({ block, value, loading, error, actions }) {
if (loading) return <TheirSkeleton />;
if (error) return <TheirError message={error.message} />;
const click = actions.onClick();
return <TheirCard label={block.label} value={value} disabled={click?.disabled} onClick={click?.run} />;
}
<UIResponseProvider components={{ metric: TheirStatCard, table: TheirDataTable }}>
<UIResponseSlot {...slotProps} />
</UIResponseProvider>Every mapping receives adapter contract 1.0 with exactly:
adapterVersionand the validatedblockdeclaration;- resolved
value,rows, andcolumns; loadinganderrorstate;actions, whose callbacks re-check the renderer action gate before executing; andchildrenfor mapped container blocks (section,card, andtabs).
actions.onClick(row?), rowAction(row), itemAction(row), and onChange(row?) return either
null or { declaration, disabled, reason, run }. actions.rowActions(row) adapts every declared
table row action, and actions.invoke(declaration, row?) handles another schema-owned action. The
adapter never exposes a connector, query, credential, raw renderer context, or arbitrary React.
An input can pass its control value with run({ self: nextValue }); a repeating component can
override row scope with run({ row }).
An unmapped block is rendered by the first-party renderer. If a mapped host component throws while
rendering, its error is contained at that block boundary and that block switches to the first-party
renderer; siblings and the surrounding host app remain mounted. rendererProps.onHostComponentError
can observe these contained exceptions.
Host-native components, in depth
The goal: a generated view should compose out of your elements — the same hero, the same compact metric cards, the same table density your own pages already use — rather than arriving as a second component library inside your app.
Component Registry
The provider maps UIR's closed semantic block vocabulary onto the host application's own components:
<UIResponseProvider
components={{
metric: TheirStatCard,
table: TheirDataTable,
badge: TheirStatusChip,
section: TheirSection,
}}
>
<UIResponseSlot {...slotProps} />
</UIResponseProvider>This is a headless-renderer pattern: semantics are ours; skin is theirs. A generated metric stays
a validated metric with UIR's binding, formatting, empty-state, intent, and action rules, but the
registry selects the component that paints it. Registry components receive a normalized, versioned
adapter contract—not raw internal renderer context—containing the block declaration, resolved
value/rows/columns, loading and error state, and gated action callbacks. Missing mappings fall back
to UIR's first-party component. A host-component exception is contained at the block boundary and
falls back there too; it must never take down the slot or surrounding app.
Container mappings (section, card, tabs) and leaf mappings (metric, table, and so on) use
the same registry, but the schema remains the authority on legal composition. The registry cannot
introduce arbitrary generated React, bypass validation, read connector credentials, or change data
semantics. It is a renderer adapter, not a component-generation escape hatch.
Design catalog (not yet available)
The component registry above is the runtime half, and it ships today. The generation half — a schema-only design catalog, the mirror of UIR's data catalog — is not implemented yet; it is described here so the direction is legible, not because you can use it:
- A local tool introspects approved component metadata and representative page composition—types, Storybook controls, design tokens, allowed variants, common parent/child patterns, density, and responsive idioms.
- The developer reviews a generated catalog file and explicitly pushes it to UIR Cloud.
- Cloud generation sees names and structural idioms such as “product detail pages open with a hero, then use compact metric cards and this table density.” It never receives source code, rendered screenshots, proprietary page content, row data, or credentials.
- The generated view still contains only UIR block semantics. At runtime those block types are resolved through the Component Registry, so the result composes like the host app and paints in its actual design system.
Catalog versions should be content-addressed and echoed in generation envelopes for drift checks, exactly as the data catalog is planned to do for schema. Local linting should flag mappings or design idioms that disappeared before a generated view reaches production.
