@gravity-rail/react
Canonical React components & hooks for embedding Gravity Rail — chat, forms, data, and voice — into any React app. The same components run unchanged in a Gravity Rail source site (sandboxed, zero-config) and in a third-party host (your own Next.js/Vite app) by swapping one transport seam.
import { GravityRailProvider, ChatButton, FormDialog, useDataType } from '@gravity-rail/react';
import '@gravity-rail/react/styles.css'; // optional default theme
- Zero required dependencies beyond React. No data-fetching library, no UI kit — it runs in a bare source-site bundle (React + this package only).
- Pluggable transport. Same-origin by default (works in a sandboxed source site with no token in JS); pass a custom
fetchto route through your own BFF. - Themeable. Every visual is driven by
--gr-*CSS custom properties. Ship the default stylesheet or bring your own. - TypeScript-first, ESM + CJS, tree-shakeable.
Installation
npm install @gravity-rail/react
# peers: react >=18, react-dom >=18. @gravity-rail/sdk is optional.
In a Gravity Rail source site the package is already on the build allow-list — just import it.
Subpath imports (tree-shaking)
Everything is available from the root barrel (which is side-effect-free, so modern bundlers tree-shake unused features). For an explicit, minimal surface you can also import from curated subpaths — all of which share the same provider/context instance:
import { GravityRailProvider } from '@gravity-rail/react/context';
import { DataForm, DataWizard, useDataRecords } from '@gravity-rail/react/data';
import { ChatWidget, useChat } from '@gravity-rail/react/chat';
import { VoiceButton } from '@gravity-rail/react/voice';
import { Router, Route, Link } from '@gravity-rail/react/router';
Quick start
A. Source site (same-origin, zero config)
A published source site is served behind the Gravity Rail origin proxy, which forwards /api/v2/... and attaches the httpOnly site-session cookie. JS never sees a token. The injected window.GRAVITY_RAIL_SITE_CONTEXT supplies the workspace + theme, so a bare provider Just Works:
import { GravityRailProvider, ChatButton, FloatingChat, VoiceButton } from '@gravity-rail/react';
import '@gravity-rail/react/styles.css';
export function App() {
return (
<GravityRailProvider>
<h1>Welcome</h1>
<ChatButton label="Ask a question" />
<VoiceButton />
<FloatingChat />
</GravityRailProvider>
);
}
B. Third-party host (your own app + BFF)
Off-platform, serve your own same-origin BFF routes (use @gravity-rail/sdk server-side with an OAuth token) and either mirror the /api/v2/... shape or pass a custom fetch. The browser still only ever talks same-origin:
<GravityRailProvider
config={{
workspaceUuid: 'YOUR-WORKSPACE-UUID',
// Attach auth / rewrite to your BFF. The browser never holds a long-lived token.
fetch: (input, init) => fetch(input, { ...init, headers: { ...init?.headers } }),
// For voice (cross-origin WebSocket): mint a short-lived token from your BFF.
apiUrl: 'https://api.gravityrail.com',
getAccessToken: async () => (await fetch('/api/gr-token')).then((r) => r.text()),
}}
>
<App />
</GravityRailProvider>
Building blocks
Forms & data
Render any workspace DataType ("Form") schema as a live form and read its records — no schema duplication:
function ContactForm() {
const { data: dataType, loading } = useDataType('contact'); // slug or numeric id
if (loading || !dataType) return null;
return <DataForm dataType={dataType} onSuccess={(rec) => console.log('saved', rec.id)} />;
}
| Export | Kind | Purpose |
|---|---|---|
DataForm | component | Render a DataType schema as inputs; create a record on submit |
DataRecordList | component | List of records for a DataType — variant="table" (default) or variant="cards" |
FormDialog | component | Button → modal containing a DataForm |
useDataType / useDataRecords / useCreateDataRecord | hooks | Imperative data access with loading/error state |
fetchDataType / listDataRecords / createDataRecord | functions | Promise-based, hook-free |
The easy path (@gravity-rail/react/query)
The core above is dependency-free. The /query subpath layers in
React Query (an optional peer —
@tanstack/react-query) for caching + global invalidation, plus
batteries-included components that fetch their own data. Wrap your app in a
QueryClientProvider alongside GravityRailProvider:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { GravityRailProvider } from '@gravity-rail/react';
import { useMe, AccountMenu } from '@gravity-rail/react/query';
<QueryClientProvider client={new QueryClient()}>
<GravityRailProvider>
<AccountMenu /> {/* renders the signed-in chip, or nothing when anonymous */}
</GravityRailProvider>
</QueryClientProvider>;
| Export | Kind | Purpose |
|---|---|---|
useDataType / useDataRecords / useDataRecord + create/update/delete | hooks | React Query versions with caching + invalidation |
useMe | hook | The current signed-in member (GET /me), cached |
fetchMe (+ Member type) | function | Promise-based, hook-free current-member fetch |
AccountMenu | component | Signed-in account chip (initials avatar + name + Log out); renders null when anonymous |
gravityKeys | object | Cache keys for manual invalidateQueries / prefetchQuery |
Chat
Same-origin AG-UI SSE streaming, assembled into messages — no WebSocket, no extra token:
const { messages, suggestions, start, sendMessage, isStreaming, isLoading, reset } = useChat({
greeting: 'Hi! How can I help?',
});
start() creates the chat and loads its server-posted greeting + suggestions — call it when the visitor opens the chat (never on page load, so an idle page doesn't create chats). sendMessage lazily creates the chat too, so an explicit start() is optional. suggestions holds the latest assistant message's suggested replies; isLoading is true while start() is bootstrapping; reset() discards the chat and re-bootstraps.
| Export | Kind | Purpose |
|---|---|---|
useChat | hook | A self-contained text-chat session (lazy chat creation + streaming, greeting/suggestions bootstrap) |
ChatWidget | component | Message list + composer panel. Calls start() on mount by default; pass autoStart={false} to opt out |
ChatButton | component | Inline button → chat in a modal |
FloatingChat | component | Floating action button → docked chat panel (with Reset) |
InlineChat | component | Embedded chat card with an explicit "Start the conversation" gate + Reset |
Voice
Browser voice chat (mic in, agent audio out) over the realtime WebSocket. PCM16 @ 24 kHz both ways:
<VoiceButton label="Talk to an agent" />
// or drive your own UI:
const { status, isActive, start, stop, error } = useVoice();
Router & presentational helpers
A tiny History-API router for source-site SPAs (Router, Route, Link, useRoute) plus the presentational pieces carried over from the source-site runtime (SiteHeader, DataList, ProgressSteps, ConsentCheckbox).
Server-side flow (with @gravity-rail/sdk)
The React components cover the in-page surface (forms, chat, voice). The steps a host does on the server — register a member, populate a form, enroll into a journey, verify inbound webhooks — live in @gravity-rail/sdk and its @gravity-rail/sdk/helpers entry, so a BFF host uses the same package everywhere:
import { GravityRailClient } from '@gravity-rail/sdk';
import {
upsertDataRecordForMember,
enrollMemberInJourney,
verifyWebhookSignature,
WEBHOOK_SIGNATURE_HEADER,
} from '@gravity-rail/sdk/helpers';
const client = new GravityRailClient(oauthToken, apiUrl);
// 1. Register the member
const member = await client.createWorkspaceMember(wid, { name, phone, externalId, memberRoleId });
// 2. Populate a form (idempotent, keyed by member)
await upsertDataRecordForMember(client, wid, dataTypeId, member.id, { mrn, provider });
// 3. Enroll into a journey (resolve by memberId | phone | externalId; find-or-create labels)
await enrollMemberInJourney(client, wid, { memberId: member.id, journeyId, labels: ['pilot'] });
// 4. Verify an inbound webhook (in your POST handler, over the RAW body)
const result = await verifyWebhookSignature(rawBody, req.headers.get(WEBHOOK_SIGNATURE_HEADER), secret);
if (!result.ok) return new Response(result.reason, { status: 401 });
The same BFF that serves these routes can expose the same-origin /api/v2/... proxy the provider (transport seam below) talks to, so the browser never holds a token.
Theming
Import the optional stylesheet and override any --gr-* token at the root (or any ancestor):
:root {
--gr-color-primary: #0ea5e9;
--gr-radius-md: 4px;
--gr-font: 'Inter', system-ui, sans-serif;
}
Source sites populate these tokens from the site's theme settings automatically. Skip the stylesheet entirely and target the gr-* class names with your own CSS if you prefer.
The transport seam
Both hosting models put a same-origin request in front of the browser; only what sits behind the origin differs:
| Source site | Third-party host | |
|---|---|---|
baseUrl | "" (proxy forwards) | "" (your BFF) |
| Auth | httpOnly gr_sess_ cookie | your BFF attaches an OAuth token |
| Workspace | from injected site context | config.workspaceUuid |
| Voice token | same-origin /realtime/ws-token | config.getAccessToken |
This single seam is where a future @gravity-rail/proxy server package will plug in.
SSR
All components are client components (useState/window). The provider degrades gracefully when window is absent (it returns an empty site context), so importing the package in a Server Component is safe — render the interactive pieces on the client.
Links
- Docs & API reference: https://developer.gravityrail.com
- TypeScript SDK:
@gravity-rail/sdk
License
MIT