Skip to content
Early preview — you found Guuey before launch · official launch soon

Sign in your own users

read as .md

A surface you build yourself — on the chat kit or the client SDK — can know who the visitor is, so their conversations, memory, files and profile follow them across sessions. There are two ways to tell it, and you pick by what you already run:

You have… Use
An OIDC-compatible identity provider (Cognito, Auth0, …) Bring your own auth: point the app at your issuer and forward your users’ ID tokens. Guuey verifies them against your provider’s published keys.
Just a backend that knows who is logged in This page. Enrol the app in Guuey’s per-app token issuer once, mint short-lived tokens for your signed-in users from your backend with @guuey/widget-auth, and hand them to the SDK. No IdP, no JWKS to host, no signing key on your side — it is the same endpoint an identified widget embed uses, and the same identity.
  1. You enrol the app once. Guuey generates a signing keypair for it (the private half is sealed in Guuey’s KMS and never leaves it) and prints an app secret for your backend.
  2. Your backend adds a token endpoint: it authenticates the visitor’s session the way it always does, then calls Guuey’s mint route with the app secret and the visitor’s stable user id, and returns the short-lived token it gets back. The browser never sees the secret.
  3. Your surface passes that token to the SDK’s getAccessToken. Every turn and every history read carries it, and the agent resolves the visitor to a durable identity derived from the userId you sent.

The token is a plain signed JWT bound to your app’s own issuer and audience. Nothing in it says widget or custom surface — every Guuey surface verifies it the same way and derives the same identity — so a user is the same person on your page, in the widget, and on the app’s standalone page, as long as your backend sends the same userId. What exactly a token proves, how issuer and audience bind it to one app, and where guests fit is on The identity model.

Terminal window
guuey widget keys create <appId> --audience <your-audience>

The secret is printed once — store it the way you store a database password. --audience is any string you choose (a name for your product is fine); it is baked into every token and checked on every verification. With --audience, the command also configures the app to trust its own issuer in the same step (userAuthMode: byo, pointing at Guuey’s per-app issuer).

Any framework works; the shape is one authenticated route that returns a token as plain text. This is a Next.js route handler:

app/api/guuey-token/route.ts
import { signUserToken } from "@guuey/widget-auth";
import { getSession } from "@/lib/auth";
export async function GET(request: Request) {
const session = await getSession();
if (!session) return new Response("Unauthorized", { status: 401 });
const { token } = await signUserToken(
{ userId: session.userId, name: session.name, email: session.email },
{
appId: process.env.GUUEY_APP_ID!,
appSecret: process.env.GUUEY_APP_SECRET!,
// The API origin, without `/v1` — or set GUUEY_API_URL.
apiBaseUrl: "https://api.us-east-1.guuey.com",
}
);
return new Response(token, { headers: { "content-type": "text/plain" } });
}

userId becomes the token’s subject and is what the visitor’s identity is derived from — it must be stable for the life of the account. A session id or an editable email silently orphans everything the user has when it changes.

The secret is server-side only: signUserToken refuses to run in a browser, because a secret in a shipped bundle lets every visitor mint a token for any of your users. Tokens last 15 minutes by default (ttlSeconds up to 3600); if your endpoint caches them, cache per user and expire a couple of minutes early — see the package README.

Pass a getAccessToken resolver that fetches from your endpoint. With the drop-in kit:

import { GuueyChat } from "@guuey/chat/react";
async function getAccessToken({ forceRefresh = false } = {}) {
const res = await fetch(`/api/guuey-token?reason=${forceRefresh ? "expired" : "initial"}`);
if (!res.ok) throw new Error(`token endpoint failed: ${res.status}`);
return res.text();
}
<GuueyChat
endpointUrl="https://your-agent-endpoint"
appId="your-app-id"
apiBaseUrl="https://api.us-east-1.guuey.com/v1"
getAccessToken={getAccessToken}
/>;

With the raw client, the same resolver goes to createWebAdapters({ apiBaseUrl, getAccessToken }).

Two rules that keep this correct:

  • Throw on failure — never return null. To the SDK, null means “signed out on purpose”. A signed-in surface whose token fetch quietly returns null would continue as an anonymous visitor, and those turns land in a guest thread the user can never reach. Failing loudly is the right behavior.
  • forceRefresh means “that token was just rejected” — mint fresh. The SDK asks the resolver anew on every attempt it sends, and calls it with { forceRefresh: true } when a history read is refused on a token the resolver already returned — the same signal as the widget’s getToken("expired"). If your endpoint caches, forward the reason and skip the cache for expired, and expire cached tokens a couple of minutes before they do.

Supply one identity mode per surface: getAccessToken for signed-in users, getGuestSecret for anonymous ones — never both.

Your agent accepts browser requests from localhost and Guuey’s own surfaces automatically. Before your surface goes to production, allow the domain it runs on — this admits both the live stream from the agent and the transcript read on reload:

Terminal window
guuey apps update <appId> --domains "yourapp.com"
guuey apps check --origin https://yourapp.com # sends a real preflight, prints the verdict

This is the agent endpoint’s CORS check: a bare domain covers https://yourapp.com and every subdomain, and localhost needs no entry. The widget’s frame allowlist reads the same list more strictly — a bare domain matches its apex only, and a local origin must be listed with its scheme — so if you also embed the widget, list explicit origins; see Allow your site’s domain.

  • Continuity across surfaces. The same userId from your backend resolves to the same Guuey identity on your page, in the widget, and on the app’s standalone page — one history, one memory, one profile.
  • History on reload. With apiBaseUrl set, the SDK repaints the visitor’s earlier turns from the read plane using the same token.
  • Fail-loud, never fail-anonymous. A rejected token surfaces as an error on the turn; it never silently degrades to a guest identity.

You are done: the widget’s identified mode uses this exact endpoint. Point your custom surface’s getAccessToken at it and both surfaces share the user.

One exception: the app’s standalone page cannot use this route as its identity endpoint — that page requires a JSON { "token": … } body plus an exact CORS contract, not the raw string this route returns. Reuse the minting code, but give the standalone page its own route.

  • signUserToken throws WidgetAuthAppNotConfiguredError (409). The app is not configured to trust its own issuer — re-run guuey widget keys create with --audience, or set the binding with guuey apps update as printed.
  • signUserToken throws WidgetAuthCredentialError (401). Wrong, revoked, or another app’s secret — deliberately indistinguishable. Check the value, then guuey widget keys rotate <appId> --new-secret if in doubt.
  • Turns fail with an unexplained Failed to fetch. That is a CORS refusal, not an identity problem — the origin is missing from --domains. guuey apps check --origin tells you.
  • Turns fail with 401. The token was rejected — expired past its TTL with a caching endpoint that ignored expired, or minted before the app’s binding was fixed. Mint fresh.