Embedding FluidGrids
There are two ways to put FluidGrids inside another product:
- The React SDK (
@burdenoff/fluidgrids-fe-sdk): the full builder, workflow list and run pages, rendered in your own React app with your user's session. This page covers its theme, token-refresh and event APIs. - The iframe viewer at
https://app.fluidgrids.ai/embed/workflow: a public, read-only view of a workflow for any web page. See The iframe viewer.
Both always show the Powered by FluidGrids mark. Theme tokens change colours, corners and fonts; they do not hide or replace the mark.
Install and mount
npm install @burdenoff/fluidgrids-fe-sdk
Wrap the SDK pages in FluidGridsProvider. The theme, auth and onEvent props below are the embedding hooks; the same props exist on FluidGridsRoot and WorkflowBuilder.
import "@burdenoff/fluidgrids-fe-sdk/styles.css";
import {
FluidGridsProvider,
FluidGridsRoot,
type FluidGridsEventHandler,
type FluidGridsTheme,
} from "@burdenoff/fluidgrids-fe-sdk";
const theme: FluidGridsTheme = {
mode: "light",
colors: { primary: "#2563eb", bgCanvas: "#f8fafc" },
radius: { base: "10px" },
font: { body: "Inter, system-ui, sans-serif" },
};
const onEvent: FluidGridsEventHandler = (name, payload) => {
if (name === "workflow:saved") console.log("saved", payload.workflowKey);
if (name === "run:completed") console.log(payload.runId, payload.status);
};
export function Workflows({ session }: { session: MySession }) {
return (
<FluidGridsProvider
apiGatewayUrl="https://graphqlworkspaces.burdenoff.com/workspaces/graphql"
globalApiUrl="https://graphql.burdenoff.com/global/graphql"
authToken={session.accessToken}
workspaceToken={session.workspaceToken}
workspaceId={session.workspaceId}
theme={theme}
auth={{ onTokenExpired: () => session.refresh() }}
onEvent={onEvent}
>
{/* SDK pages */}
</FluidGridsProvider>
);
}
If your app configures the SDK imperatively instead of through the provider, pass the same keys to setFluidGridsConfig({ theme, auth, onEvent }).
FluidGridsRoot, WorkflowBuilder and WorkflowBuilderCore do not need a theme provider from your app. When your page already has the Burdenoff (@burdenoff/fe-libs) ThemeProvider above them, they use it; when it has none, they mount their own, so there is never a second one.
Theme
theme takes one of:
"light"or"dark": force that mode and keep the default colours.- An object with any of
mode,colors,radiusandfont. Leavemodeout to keep the mode your app (or the visitor) already uses.
Each token sets one or more CSS custom properties. The SDK and its dialogs are styled from these properties, so a token restyles everything that uses it.
| Token | Value | CSS custom properties |
|---|---|---|
colors.bgCanvas | colour | --color-bg-canvas |
colors.bgSurface | colour | --color-bg-surface |
colors.bgElevated | colour | --color-bg-elevated |
colors.bgSunken | colour | --color-bg-sunken |
colors.textPrimary | colour | --color-text-primary |
colors.textSecondary | colour | --color-text-secondary |
colors.textMuted | colour | --color-text-muted |
colors.borderDefault | colour | --color-border-default |
colors.borderSubtle | colour | --color-border-subtle |
colors.borderStrong | colour | --color-border-strong |
colors.primary | colour | --color-action-primary-bg, --color-action-primary-border |
colors.primaryHover | colour | --color-action-primary-bgHover |
colors.primaryActive | colour | --color-action-primary-bgActive |
colors.primaryText | colour | --color-action-primary-text |
colors.success | colour | --color-status-success-bg |
colors.warning | colour | --color-status-warning-bg |
colors.error | colour | --color-status-error-bg |
colors.info | colour | --color-status-info-bg |
radius.base | length | --radius |
radius.card | length | --radius-card |
radius.button | length | --radius-button |
radius.input | length | --radius-input |
font.body | font list | --font-family-body, --font-sans |
font.heading | font list | --font-family-heading |
font.mono | font list | --font-family-mono, --font-mono |
The same table is exported as FLUIDGRIDS_THEME_TOKEN_MAP, so you can build a settings screen from it.
How values are handled:
- Colours accept hex (
#2563eb), named colours, the CSS colour functions (rgb(),hsl(),oklch(),color-mix(), …) andvar(--your-token). Lengths accept a number with a unit (8px,0.5rem),0, orcalc(). Fonts accept a comma-separated family list; quote names with single quotes ('Fira Code', monospace). - A value that is not a plain CSS value (one containing
;,{,},url(,expression(, comments or!important) is ignored and a warning is logged to the browser console. Unknown keys are ignored the same way. - The four radius tokens are independent:
radius.basedoes not change--radius-card,--radius-buttonor--radius-input, which keep their defaults (12px, 8px, 8px). Builder node cards useradius.card. Set all four for one consistent corner radius. - When you set
colors.primaryalone, the hover and pressed shades are derived from it, and for a hex colour the text on it is set to black or white for contrast. SetprimaryHover,primaryActiveorprimaryTextto override them.
The theme is applied to the whole page, not to a wrapper element: the SDK renders in your document and its menus and dialogs render at the end of body, so the properties are set on the <html> element while the provider is mounted. Unmounting the provider, or changing the theme, puts back the values the page had before. If your own UI reads the same custom properties, it picks up the theme too.
Outside the provider you can apply a theme yourself:
import {
applyFluidGridsTheme,
themeToCssVariables,
useFluidGridsTheme,
} from "@burdenoff/fluidgrids-fe-sdk";
themeToCssVariables({ colors: { primary: "#16a34a" } }); // { "--color-action-primary-bg": "#16a34a", … }
const remove = applyFluidGridsTheme("dark"); // returns a function that removes it again
useFluidGridsTheme(theme); // in a component: applied while mounted
Token refresh
Workspace tokens are short-lived, so a builder left open outlives the token it started with. Give the SDK a way to get a new one:
<FluidGridsProvider
// …
auth={{
onTokenExpired: async () => {
const next = await mySession.refresh();
return next.accessToken; // or { authToken, workspaceToken }
},
}}
/>
When a request fails because the session expired (HTTP 401, a GraphQL UNAUTHENTICATED error, or an expired or revoked credential), the SDK:
- calls
onTokenExpiredonce. Requests that fail while it is running wait for the same call; - stores the new token and retries the failed request once with it;
- if
onTokenExpiredrejects or returns an empty token, emitsauth:failedand lets the request fail with its original error.
A request is never retried more than once, so a token that keeps being refused cannot cause a loop.
A failed refresh is reported once. onTokenExpired is called once and auth:failed is sent once for each failed refresh, however many requests were in flight with the expired token: a request sent before a refresh that has since failed fails with its original error without calling onTokenExpired again, even when its own 401 arrives after the failure. A request sent after the failure starts a new refresh, so once your app has a valid session again (for example, the user signed in again) the SDK picks it up. Return a string to replace the user token, or an object { authToken?, workspaceToken? } to replace either or both. Subscriptions send the current token whenever they reconnect.
Your own Apollo client
If you pass your own Apollo clients (workspaceClient / globalClient), add the refresh link to their chain, before the link that sets authorization:
import { ApolloLink, HttpLink } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import {
createFluidGridsTokenRefreshLink,
getFluidGridsConfig,
} from "@burdenoff/fluidgrids-fe-sdk";
const authLink = setContext((_, { headers }) => ({
headers: {
...headers,
authorization: `Bearer ${getFluidGridsConfig().authToken}`,
},
}));
const link = ApolloLink.from([
createFluidGridsTokenRefreshLink(),
authLink,
new HttpLink({ uri: "https://graphqlworkspaces.burdenoff.com/workspaces/graphql" }),
]);
The link reads auth.onTokenExpired from the SDK config and stores the new token there. If your client keeps its token elsewhere, pass your own callback and setter: createFluidGridsTokenRefreshLink({ onTokenExpired, onTokenRefreshed }).
Events
onEvent(name, payload) is called for each of these events. Checking name narrows the type of payload in TypeScript.
| Event | Payload | When |
|---|---|---|
workflow:saved | { workflowKey, version?, id? } | A save reached the server: a new workflow, an update, or a new version. |
workflow:activated | { workflowKey, version? } | A version was activated. |
run:started | { runId, workflowKey?, mode? } | The server accepted a run: a builder test run, a run from a node, a manual trigger, a replay or a retry. |
run:completed | { runId, status, workflowKey? } | A run the builder is following reached COMPLETED, FAILED, CANCELLED or TERMINATED. Sent once per run. |
node:selected | { nodeId, nodeType? } | Exactly one node became selected on the canvas. nodeId: null means the selection was cleared. Selecting several nodes sends nothing. |
error | { source, message, code? } | A save (workflow:save), an activation (workflow:activate) or a run start (run:start) failed. message is fit to show; code is the API error code when there is one. |
auth:failed | { message } | The session expired and onTokenExpired could not provide a new token. |
Events are delivered synchronously. If your handler throws, the error is logged and the SDK carries on.
The standalone builder
WorkflowBuilder and WorkflowBuilderCore are a lightweight canvas that works on the workflow you pass in. Its Save and Run buttons do not call the FluidGrids API: Save passes the edited workflow to your onSave, and Run passes a client-generated id (run-<timestamp>) to your onExecute. Saving and starting the run are your app's job, so this canvas sends only node:selected, with the same rules as the full builder. It never sends workflow:saved, run:started or error. FluidGridsRoot and the full builder page send every event in the table above.
Versioning
The SDK exports the contract version as FLUIDGRIDS_EVENTS_VERSION (currently 1.0.0) and the event names as FLUIDGRIDS_EVENT_NAMES. The version follows semantic versioning:
- Minor: a new event, or a new optional field in a payload. Ignore names you do not know.
- Major: an event removed or renamed, a field removed, or a field's type or meaning changed. A major change is released as a breaking SDK release.
The iframe viewer
https://app.fluidgrids.ai/embed/workflow renders a workflow read-only, without signing in. The app's Share dialog does not produce an embed snippet; build the <iframe> yourself as shown below. The viewer reads these query parameters:
| Parameter | Value | Effect |
|---|---|---|
productId | store product id | The workflow template to show. Without it, the viewer waits for a postMessage from the parent page. |
theme | light or dark | Forces the mode. Without it, the visitor's own preference is used. |
accent | colour | Primary colour (colors.primary). A bare hex value works without #: accent=2563eb. |
radius | length | Corner radius for the base, cards (including the node cards), buttons and inputs: it sets radius.base, radius.card, radius.button and radius.input to the same value. For example 12px. |
font | font list | Body font (font.body), for example Inter,sans-serif. |
cta | false | Hides the Use this template button. |
accent, radius and font go through the same checks as an SDK theme; a value that fails them is ignored. For example:
<iframe
src="https://app.fluidgrids.ai/embed/workflow?productId=YOUR_PRODUCT_ID&theme=dark&accent=2563eb&radius=12px&font=Inter,sans-serif"
width="100%"
height="520"
style="border: 0"
title="FluidGrids workflow"
></iframe>
Sending a workflow with postMessage
Without productId, the viewer shows a workflow your page sends it. When it is ready it posts { type: "fluidgrids:embed:ready" } to the parent page. Reply with:
iframe.contentWindow.postMessage(
{
type: "fluidgrids:embed:openWorkflow",
workflow: { name: "My workflow", nodes: [/* … */], edges: [/* … */] },
},
"https://app.fluidgrids.ai",
);
The viewer then posts { type: "fluidgrids:embed:loaded", nodeCount }, or { type: "fluidgrids:embed:error", message } when the message has no nodes or a productId fails to load. Each openWorkflow message replaces the workflow on show.