@grahak/js
v0.0.7
Published
Grahak JS SDK — the render-agnostic core: api client + auth/session + conversation state.
Maintainers
Readme
@grahak/js
The JavaScript SDK for embedding the Grahak customer-conversation widget in your web app. Render-agnostic, zero runtime dependencies, ESM.
It loads the Grahak widget in an iframe on Grahak's origin and bridges your
page ↔ the iframe over postMessage. Your page never holds Grahak tokens — the
session is established and kept inside the iframe.
Using React?
@grahak/reactwraps all of this in a provider + hooks. Prefer it in React apps.
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 — embed the widget
- API reference
- Troubleshooting
- Testing (dev mode)
Install
npm install @grahak/jsHow 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 — a few lines that
init()the widget with that JWT and mount 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
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. See the frontend
userJwtgetter.
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 — embed the widget
Overlay mode (recommended)
One call. Grahak injects a floating launcher button (with an unread badge) and owns the chat panel — you don't wire up mounting, visibility, or polling:
import { init } from "@grahak/js";
const widget = init({
projectId: GRAHAK_PROJECT_ID,
// A GETTER, not a string: the identity JWT is single-use + short-lived, so the
// SDK fetches a fresh one on every (re)connect (e.g. after a reload).
userJwt: async () => {
const res = await fetch("/api/me/grahak-identity");
return (await res.json()).jwt;
},
}).mountOverlay();That's the whole integration. Keep the returned widget if you want to drive it:
widget.setTheme("dark"); // match your app's theme, live
widget.on("unread", ({ count }) => { ... }); // render your own badge too, if you like
widget.open(); widget.close(); widget.toggle();Options (all optional): mountOverlay({ position, launcher, accentColor, width, height, offset, zIndex }).
position—"bottom-right"(default) or"bottom-left".launcher: false— hide Grahak's button and drive it from your own trigger (widget.open()), while Grahak still owns the persistent panel.launcher: myElement— hand Grahak your own button and skip the wiring. Grahak binds its click totoggle()and mirrors state onto the element so plain CSS drives the look: it setsdata-grahak-open="true|false"(andaria-expanded) anddata-grahak-unread="true|false"(true only while closed with something unread). The element stays where it is in your DOM.const myButton = document.querySelector("#support"); init({ projectId, userJwt }).mountOverlay({ launcher: myButton });#support[data-grahak-open="true"] .close { display: block } #support[data-grahak-unread="true"] .dot { display: block }
Custom placement (attach mode)
Want the widget inside your own drawer, popover, or page section instead of the
floating overlay? Use mount(container) and own the placement + show/hide
yourself.
const widget = init({ projectId, userJwt });
widget.mount(containerEl);
openButton.onclick = () => { showContainer(); widget.open(); };
closeButton.onclick = () => { hideContainer(); widget.close(); };It's fine to mount/unmount on toggle if you don't mind the widget starting fresh
each time. But know the trade-off: an <iframe> fully reloads if it's
unmounted or moved in the DOM. A reload throws away
- the open conversation, scroll position, and any half-typed reply, and
- the background polling that powers the unread badge (it can't update while the iframe isn't mounted).
So if you want that state to persist — and a badge that updates before the widget is ever opened — keep the iframe mounted once in a stable container and hide it (CSS) instead of unmounting, toggling only visibility. In a React SPA that's one mount in your app shell that survives client-side navigation.
⚠️ A common footgun: putting the iframe inside a Radix
Popover/DialogwithoutforceMountunmounts it on every close — so state and the badge reset. UseforceMount+ a CSS hide, or mount in your shell and toggle visibility.
mountOverlay()does all of this for you — reach for attach mode only when you genuinely need custom placement. In React, the@grahak/react<GrahakWidget>component wraps it.
API reference
const widget = init({ projectId, userJwt, theme? });
widget.mountOverlay(opts?); // Grahak-owned launcher + panel (recommended)
widget.mount(container); // custom placement — see "Custom placement" above
widget.open(); // show the panel
widget.close();
widget.toggle();
widget.setTheme("dark"); // live theme change, no re-init
widget.destroy(); // tear down (on logout)
widget.isOpen; // boolean
widget.isReady; // handshake complete
widget.unreadCount; // current unread count (also via the `unread` event)
const off = widget.on("unread", ({ count }) => {}); // also: ready | open | close | resize | error
off(); // unsubscribeListen for
errorto catch a failed identity exchange — if youruserJwtgetter throws/rejects or returns a stale or invalid token, the widget emits anerrorevent instead of connecting.
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 | The iframe 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 footgun above.) |
| 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. |
Testing (dev mode)
You don't point the SDK anywhere special to test. Create a dev-mode project in
the Console and use its projectId — the widget always loads from the hosted
widget.grahak.dev, in dev and production alike.
widgetBaseUrlexists only for Grahak's own development and self-hosting; leave it unset.
