Skip to main content
This guide takes you from an empty React app to a working Digital Worker chat embedded in your product.

Prerequisites

  • A React 18 or 19 application.
  • A publishable key for your workspace (pk_live_… or pk_test_…) — see Publishable Keys & Secrets.
  • Embedding in your own app only: a JWT secret (generated alongside the publishable key) and a backend that can sign a short-lived JWT with it. The secret must never reach the browser. A Statisfy-hosted Portal needs neither — <PortalAuthGate> owns sign-in, so there is no secret to hold and no backend to write.
The worked example below is the own-app path, where your backend mints the token. Building a Portal instead? Steps 1 and 4 are the same; skip step 3 entirely (the gate supplies the provider) and read the Portal half of step 2 in place of the own-app half.

1. Install

The SDK ships as two packages. Install the one whose surfaces you render — or both.
Import the stylesheets once, near your app’s entry point. Both sheets carry the same --dw-* token layer, so importing both is idempotent.
@statisfy/sdk-core — the shared foundation both packages resolve at runtime — is a required peer dependency of each, so npm 7 and newer installs it for you and the commands above are all you need. You never import it directly either; everything it holds is re-exported from the package you installed.What matters is that it resolves to exactly one copy: two would leave an app rendering both surfaces with two <StatisfyProvider> contexts, and a chat that cannot see the provider your portal shell rendered. So if your install skips peer dependencies — legacy-peer-deps in your .npmrc or on the command line (a common React 19 workaround), or Yarn — name it yourself, at the same version as the surface package:

2. Supply a session token

Every SDK request carries a short-lived HS256 bearer token. The SDK never mints it — it calls your getToken callback and sends whatever you return. How you produce it depends on where the SDK is running.

In your own app: your backend signs it

Sign a short-lived JWT with your JWT secret on your server, and expose an endpoint your frontend can call. Statisfy requires the standard iat / exp claims plus the end user’s email, which is how the gateway resolves who is asking.
Never sign tokens in the browser. Only the publishable key is safe to ship client-side — the JWT secret is a server credential.

In a Statisfy Portal: <PortalAuthGate> handles it

A hosted portal has no backend of its own, so its customers sign in passwordless: Statisfy emails a one-time code, and the exchange returns a session token plus a rotating refresh token that keeps them signed in across reloads. You don’t implement any of that. Wrap your portal in <PortalAuthGate> — it renders the sign-in form, owns the token lifecycle, and supplies StatisfyProvider internally, so nothing below it touches getToken:
Portal templates ship with equivalent wiring — they build their own header from createPortalAuthClient and <OtpSignIn> — so in practice you rarely touch it. Because the gate provides the provider, skip step 3 when you use it.
<PortalUserMenuSlot> is the recommended portal header: one control combining the signed-in customer’s email and account, their account switcher (shown only when the contact belongs to more than one account), and Sign out in one popover. If you’d rather lay these out separately, <PortalAccountSwitcherSlot> and <PortalSignOutButton> are still exported. Outside <PortalAuthGate>, render <PortalUserMenu client={client} onSignOut={…} /> or <PortalAccountSwitcher client={client} /> directly; <PortalSignOutButton> works only inside the gate.
<PortalAuthGate> also renders an <ImpersonationBanner> across the top for any session that was started as an impersonation from Portal Studio. The banner names the acting admin and offers a Stop action, and the account controls stop offering a choice for that session, since the account was pinned when the admin launched it. If you drive auth yourself with createPortalAuthClient, render the banner yourself: pass it actor={client.getActor()} (re-read it whenever client.subscribe fires) and onStop={() => client.stopImpersonation()} — without onStop there is no Stop button. See Impersonating a Customer for how impersonation is launched.
The email the customer receives — sender, subject, body, and how long the code stays valid — is configured per portal under Settings → Auth in Portal Studio. See Customizing the Login Email. A visitor gets a code only if the portal admits them on its Access tab: a customer contact matched by one of the portal’s sign-in grants, or one of your own team when the Your team switch is on. A customer’s session is bound to the customer_id of an account they’re a contact of. A request for any other address succeeds silently and sends nothing, so the sign-in screen can’t be used to probe your customer list. See How Sign-In Is Decided.
The session token is short-lived (default 1 hour). <PortalAuthGate> re-mints it silently before it expires using the stored refresh token, so customers stay signed in without seeing the form again.
Only the portal sign-in produces a customer_id claim, and the project endpoints require one. With a token your own backend signed, the Digital Worker chat works but OnboardingProject / ProjectModule / useProject return 401 not_a_portal_session.
If you need the raw endpoints — you’re not using React, or you’re building your own sign-in UI — see Portal sign-in. The createPortalAuthClient export gives you the same token lifecycle without the React components.

Multi-account customers: the account switcher

A contact can be attached to more than one account in your workspace, but a portal session always reads exactly one — the account named in the session token. <PortalUserMenuSlot>, the user menu you place inside <PortalAuthGate>, already lets such a viewer pick which one. If you’d rather lay out the header yourself, use <PortalAccountSwitcherSlot> next to <PortalSignOutButton> instead — the switcher re-mints the session scoped to the viewer’s choice:
Most portal templates already render the switcher in their header next to sign-out (the Onboarding template uses <PortalUserMenu> instead), so in a template there is nothing to wire up — this section only matters for custom layouts. How it behaves:
  • The switcher always names the account; it’s a control only when there’s a choice. A customer contact who holds two or more accounts gets a dropdown; a single-account contact sees a static label naming their account (falling back to the account’s domain, then its id, when it has no name). If the contact holds no account it reads “No account”, and if the listing failed it reads “Couldn’t load account”. Nothing renders while the first listing is in flight, and <PortalAccountSwitcherSlot> renders nothing in the editor preview or during an impersonation session. The wrapper publishes data-dw-state (switcher | single | empty | error) so you can style each state — see Theming.
  • Your own team gets a searchable picker. When the portal’s Your team switch is on and a teammate signs in, the switcher renders a button naming the account they’re on instead of a dropdown, because their book can run to hundreds of accounts. The button opens <PortalAccountPicker> in a popover with the search box focused; picking an account, pressing Escape, or clicking outside closes it. <PortalUserMenu> shows the same picker inside its own popover. It has a scope control for My Book, My Team, and All Accounts (the last only if their role can see all accounts), a search box that matches account name or domain, and a Show more button that loads the next page. Nothing changes for customer contacts.
  • Only accounts the viewer may act as are offered. For a contact, that’s the accounts they’re a contact of; for a teammate, the accounts in the scopes their role allows. The server validates every switch against the same rules.
  • Switching refreshes all account-scoped data. The session token is re-minted for the chosen account and the subtree under the gate remounts, so the project, tasks, comments, and chat reload under the new account.
  • The choice is remembered per portal in the browser, so a returning viewer lands on the account they last used. A stale selection — an account the contact has since been detached from — quietly falls back to their primary account.
If you drive auth yourself with createPortalAuthClient, render <PortalAccountSwitcher client={client} onSwitched={…} /> directly (use onSwitched to discard account-scoped state), or build your own control on the headless hook:
The hook serves both audiences. Branch on scope: it’s null for a customer contact and set for a teammate, who also gets these fields for a scoped, searchable, paged list: <PortalAccountPicker> and the PortalAccountScope and PortalAccountQuery types are also exported if you want the default picker inside your own control. The client itself exposes the same operations as listAccounts() and switchAccount(customerId) — see the API reference for the underlying endpoints, and its TypeScript types.

Naming the signed-in viewer

<PortalUserMenu> already shows who is signed in. In your own layout, to show who is signed in next to which account, read client.getEmail() — the viewer’s email, or null when no session is cached. It is synchronous, and client.subscribe(listener) fires whenever it changes (sign-out, or a different person signing in). Portal templates already show it — in the header between the account switcher and sign-out, or inside the user menu.
getEmail() is display only. The value comes from a token claim the browser does not verify, so never gate any decision on it — authorization stays server-side.

3. Wrap your app in StatisfyProvider

StatisfyProvider supplies authentication to every SDK component via React context. Set it up once, high in your tree. It is re-exported identically from both packages — import it from whichever you installed (@statisfy/ai-react for a chat-only app):
Pass a stable getToken (wrap it in useCallback). The provider memoizes its context value on getToken identity, so an inline arrow function would re-render every consumer on each render.

4. Render your first component

Now any SDK component works anywhere beneath the provider — no connection props needed.
That’s a fully working chat: conversation history, unread tracking, and inline forms. See Digital Worker Chat for every prop and the headless client. To show a customer’s onboarding project instead, render the Project Module:
This needs a portal session — see the warning in step 2 about 401 not_a_portal_session on a self-signed token.

5. Theming (optional)

The SDK’s styles are self-contained and ship inside @layer, so they won’t collide with your app’s CSS — and any plain, unlayered rule you write wins over them without !important. There are two override mechanisms:
  • --dw-* CSS variables (~31 tokens: colors, radius, elevation, focus rings, status colors, motion), set on :root or any ancestor. Colors are space-separated RGB triplets, not hex.
  • data-dw-part anatomy selectors to restyle exactly one element.
Dark mode is opt-in: set data-dw-theme="dark" (or "auto" to follow the visitor’s OS) on your html element. Without the attribute the widget stays light. See Theming for the full variable tables, the guaranteed part names, and the dark palette.

Troubleshooting

The bearer token’s signature didn’t verify. Make sure getToken returns the session token (in a portal, the one from /auth/verify — not the opaque refresh token), that it hasn’t expired, and that you minted it against the same tenant/environment your gateway points at.
The signed-in user’s email isn’t a person on any account in your tenant. Add them as a contact, or sign in as a known customer.
An SDK component is mounted outside the provider. Move it beneath <StatisfyProvider> — or, in a portal, beneath <PortalAuthGate>, which supplies the provider itself.