@grahak/react
v0.0.7
Published
React bindings for the Grahak customer-conversation widget — <GrahakProvider> + hooks over @grahak/js.
Maintainers
Readme
@grahak/react
React bindings for the Grahak customer-conversation widget
— a thin convenience layer over @grahak/js.
A provider that mounts the widget once, plus hooks to read its state and drive it.
The widget runs in an iframe on Grahak's origin; your page never holds Grahak tokens — the session is established and kept inside the iframe.
Contents
- Install
- How it works
- Backend — mint an identity JWT
- 1. Generate your keypair
- 2. The JWT claims
- 3. Mint it on your backend — TypeScript · Go · Python
- Frontend — wrap your app
- Notes
- Troubleshooting
Install
npm install @grahak/reactreact >= 18 is a peer dependency.
How it works
Two pieces:
- Backend — an authed endpoint that signs a short-lived identity JWT for the logged-in user with your project's private key. This is the security boundary; the JWT is what proves who the user is.
- Frontend — wrap your app in
<GrahakProvider>once; use hooks to drive it.
The widget runs in an iframe on Grahak's origin and runs the token exchange itself, so Grahak tokens never touch your page.
Backend — mint an identity JWT
Identical to
@grahak/js— the JWT contract is the same regardless of which SDK renders the widget.
1. Generate your keypair
You sign identity JWTs with an RS256 private key and upload only the public
half to Grahak — the GitHub-SSH-keys model. A leak of what Grahak stores grants no
signing power. Any RS256 keypair works; with openssl:
# Private key — backend secret, never sent to the browser (PKCS#8 PEM)
openssl genpkey -algorithm RSA -pkcs8 -out grahak_private.pem -pkeyopt rsa_keygen_bits:2048
# Public key — upload to Console → project → Identity keys (SPKI PEM)
openssl rsa -in grahak_private.pem -pubout -out grahak_public.pemPaste grahak_public.pem into Console → project → Identity keys, and store the
private key as a backend secret (e.g. GRAHAK_PRIVATE_KEY). You can register
multiple public keys per project and rotate freely — old keys keep verifying
until you revoke them.
2. The JWT claims
| Part | Field | Required | Notes |
|------|-------|----------|-------|
| header | alg | yes | must be RS256 |
| header | typ | — | JWT (conventional) |
| header | kid | optional | RFC 7638 JWK thumbprint of your public key. If set, Grahak goes straight to it; if omitted, it tries all the project's active keys. Omitting it is fine. |
| claim | aud | yes | must equal your projectId (in Console: your project → Settings → Project ID) |
| claim | sub | yes | your app's stable, immutable user id (the host externalId) — never an email |
| claim | iat | yes | issued-at; the token must be < 10 min old (30s clock tolerance) |
| claim | exp | yes | set short, e.g. now + 10 min |
| claim | email | optional | display only |
| claim | name | optional | display only |
| claim | avatar_url | optional | display only — snake_case |
| claim | metadata | optional | arbitrary JSON object, stored on the end user |
email/name/avatar_url/metadata refresh on every boot; sub is the fixed
identity (Grahak upserts the end user by (projectId, sub)).
The JWT is single-use and short-lived — mint a fresh one per widget boot. That's why the provider takes a
userJwtgetter, not a string.
3. Mint it on your backend
Any RS256 JWT library works — the claim contract above is all that matters.
TypeScript / Node (jose):
// GET /api/me/grahak-identity -> { jwt } (an authed route — behind your login)
import { SignJWT, importPKCS8 } from "jose";
const key = await importPKCS8(process.env.GRAHAK_PRIVATE_KEY, "RS256");
const jwt = await new SignJWT({
name: user.name,
email: user.email,
avatar_url: user.avatarUrl, // snake_case
})
.setProtectedHeader({ alg: "RS256", typ: "JWT" }) // kid optional
.setSubject(user.id) // your stable user id → externalId
.setAudience(process.env.GRAHAK_PROJECT_ID)
.setIssuedAt()
.setExpirationTime("10m")
.sign(key);Go (golang-jwt/jwt v5):
import (
"time"
"github.com/golang-jwt/jwt/v5"
)
claims := jwt.MapClaims{
"sub": user.ID, // stable user id → externalId
"aud": grahakProjectID,
"iat": time.Now().Unix(),
"exp": time.Now().Add(10 * time.Minute).Unix(),
"name": user.Name,
"email": user.Email,
"avatar_url": user.AvatarURL, // snake_case
}
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
// kid optional: tok.Header["kid"] = grahakKeyThumbprint
signed, err := tok.SignedString(privateKey) // *rsa.PrivateKey (parse the PEM)Python (PyJWT):
# pip install "pyjwt[crypto]"
import time
import jwt # PyJWT
with open("grahak_private.pem") as f:
private_key = f.read()
now = int(time.time())
token = jwt.encode(
{
"sub": user.id, # stable user id → externalId
"aud": GRAHAK_PROJECT_ID,
"iat": now,
"exp": now + 600, # 10 min
"name": user.name,
"email": user.email,
"avatar_url": user.avatar_url, # snake_case
},
private_key,
algorithm="RS256",
# headers={"kid": GRAHAK_KEY_THUMBPRINT}, # optional
)
# PyJWT >= 2.0 returns a strFrontend — wrap your app
Get started
Wrap your app once. A GETTER, not a string — the identity JWT is single-use + short-lived, so the SDK fetches a fresh one on every (re)connect:
import { GrahakProvider } from "@grahak/react";
function App() {
return (
<GrahakProvider
projectId={GRAHAK_PROJECT_ID}
userJwt={async () => {
const res = await fetch("/api/me/grahak-identity");
return (await res.json()).jwt;
}}
>
<YourApp />
</GrahakProvider>
);
}That's it — Grahak renders a floating launcher (with the unread badge) and owns the chat panel. The iframe stays mounted across navigation, so the badge keeps updating even while the widget is closed.
Mount
<GrahakProvider>only for authenticated users — theuserJwtgetter needs a session. For logged-out users, don't render it.
Hooks
Read state or drive the widget from anywhere below the provider:
import { useGrahak, useGrahakUnread } from "@grahak/react";
function HelpButton() {
const { open, isOpen, unreadCount } = useGrahak();
return (
<button onClick={open}>
Help {unreadCount > 0 && <span className="badge">{unreadCount}</span>}
</button>
);
}
// Or just the count, for a badge elsewhere in your nav:
const count = useGrahakUnread();useGrahak() returns:
{
widget, // the underlying @grahak/js controller (advanced; may be null pre-mount)
unreadCount, // number — re-renders on change
isOpen, // boolean
isReady, // handshake complete
open, close, toggle,
setTheme, // (theme: "light" | "dark") => void
}Common patterns
Match your app's theme (live, no remount):
<GrahakProvider projectId={...} userJwt={getJwt} theme={isDark ? "dark" : "light"}>Use your own launcher button — the launcher render prop:
Want a fully custom floating button instead of Grahak's? Pass a launcher
render prop. Grahak suppresses its own FAB (so you don't also touch
overlay.launcher), docks your element in the same corner, and re-renders it
with live state — you just describe the button:
<GrahakProvider
projectId={...}
userJwt={getJwt}
launcher={({ isOpen, unreadCount, toggle }) => (
<button onClick={toggle} data-open={isOpen}>
{isOpen ? <CloseIcon /> : <ChatIcon />}
{unreadCount > 0 && <span className="dot" />}
</button>
)}
>
<YourApp />
</GrahakProvider>The renderer receives { isOpen, unreadCount, isReady, open, close, toggle } and
re-runs whenever they change. This is the idiomatic React path.
Place your own trigger anywhere instead — if you'd rather put the button
somewhere in your own layout (not docked in the corner), turn Grahak's off with
overlay={{ launcher: false }} and drive it from useGrahak():
function MyTrigger() {
const { toggle, unreadCount } = useGrahak();
return <button onClick={toggle}>Support {unreadCount || ""}</button>;
}(overlay.launcher also accepts a raw HTMLElement, but that's for the vanilla
@grahak/js SDK — in React use the
launcher prop or useGrahak() above.)
Position / size the launcher + panel — overlay forwards to mountOverlay():
<GrahakProvider
projectId={...}
userJwt={getJwt}
overlay={{ position: "bottom-left", accentColor: "#4f46e5" }}
>Custom placement (attach mode)
Want the widget inside your own popover / drawer / panel instead of Grahak's
floating overlay? Set mode="attach" and render a <GrahakWidget> where you
want it:
import { GrahakProvider, GrahakWidget, useGrahak } from "@grahak/react";
<GrahakProvider projectId={...} userJwt={getJwt} mode="attach">
<HelpPopover />
</GrahakProvider>;
function HelpPopover() {
const { isOpen, open, close, unreadCount } = useGrahak();
return (
<Popover open={isOpen} onOpenChange={(o) => (o ? open() : close())}>
<PopoverTrigger>Help {unreadCount || ""}</PopoverTrigger>
{/* forceMount keeps the iframe alive when the popover closes */}
<PopoverContent forceMount>
<GrahakWidget style={{ width: 380, height: 560 }} />
</PopoverContent>
</Popover>
);
}⚠️ Keep
<GrahakWidget>mounted. An iframe reloads if it's unmounted or moved in the DOM — dropping the session, the open thread + draft, and the unread polling. With RadixPopover/Dialogthat meansforceMounton the content + hiding via CSS; don't let it unmount on close. (Overlay mode avoids this entirely — prefer it unless you specifically need in-popover placement.)
Notes
- Mount the provider once, high in your tree. It creates + tears down the widget on (un)mount; remounting it on every route would reload the iframe and drop the session + in-iframe state.
<GrahakProvider>is client-only. It uses effects +postMessage, so it can't render on the server — in Next.js App Router, render it from a"use client"component (or wrap it in one).- Mount
<GrahakProvider>only for authenticated users — theuserJwtgetter needs a session. For logged-out users, don't render it (and gate anyuseGrahak()consumers behind your auth check). - You don't set a widget URL. The widget always loads from
widget.grahak.devin dev and prod alike; to test, use a dev-mode project'sprojectId. (widgetBaseUrlexists only for Grahak's own dev / self-hosting.) - Changing
userJwt(e.g. a new getter each render) does not remount — the latest getter is always used on reconnect. ChangingprojectIddoes. - A rejecting
userJwtgetter (or a stale/invalid token) surfaces on the underlying controller'serrorevent —const { widget } = useGrahak();thenwidget?.on("error", …).
For the lower-level controller API, see
@grahak/js.
Troubleshooting
| Symptom | Likely cause | Fix |
|---------|--------------|-----|
| Widget shows "We don't recognize who you are" | Identity exchange failed: the JWT is > 10 min old, already used (it's single-use), a static string that went stale, aud ≠ your projectId, or the public key isn't registered (or was revoked) on the project. | Pass userJwt as a getter, not a string. Confirm aud == projectId, the registered public key matches your private key, and iat is fresh (≤ 10 min, ~30s clock tolerance). |
| Open thread / draft / unread badge resets every time the panel closes | <GrahakWidget> is being unmounted on close (e.g. a Radix Popover/Dialog without forceMount). | forceMount + a CSS hide, or use overlay mode. (See the attach-mode callout above.) |
| "Invalid hook call" / two copies of React | Two React instances — only happens when the package is npm link/symlinked for local dev. | Ensure a single React (Vite: resolve.dedupe: ["react","react-dom"]). A normal published npm install never hits this. |
| One transient postMessage "target origin … does not match" on load | Benign load-timing race; the SDK ignores mismatched-origin inbound messages. | No action unless it's persistently noisy. |
