Prerequisites
- A React 18 or 19 application.
- A publishable key for your workspace (
pk_live_…orpk_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.- Digital Worker chat
- Portal shell + projects
- Both
--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 yourgetToken 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 standardiat / exp claims plus the end user’s email, which is how the gateway resolves who is asking.
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:
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.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.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:
<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 publishesdata-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.
createPortalAuthClient, render <PortalAccountSwitcher client={client} onSwitched={…} /> directly (use onSwitched to discard account-scoped state), or build your own control on the headless hook:
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.
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):
4. Render your first component
Now any SDK component works anywhere beneath the provider — no connection props needed.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:rootor any ancestor. Colors are space-separated RGB triplets, not hex.data-dw-partanatomy selectors to restyle exactly one element.
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
401 user_token_invalid
401 user_token_invalid
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.403 not_a_customer
403 not_a_customer
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.
Components throw 'must be rendered inside <StatisfyProvider>'
Components throw 'must be rendered inside <StatisfyProvider>'
An SDK component is mounted outside the provider. Move it beneath
<StatisfyProvider> — or, in a portal, beneath <PortalAuthGate>, which supplies the provider itself.