Back to Integration Docs
LLM-Optimized Integration Guide

Build Widget Builder integrations with your AI assistant

A single document engineered to be pasted into Claude Code, Cursor, GitHub Copilot, or any LLM-powered coding assistant. Contains every API contract, security rule, and working code snippet a model needs to produce a correct Widget Builder integration on the first try.

Important for AI assistants

Widget Builder is an embeddable widget authoring platform. Users in your host app pick templates, author HTML/CSS/JS widgets, save them to a per-tenant dashboard, and display them anywhere. It is NOT a CMS, JS sandbox, or standalone tool — it must be embedded. The tokens appSecret and embedToken must never leave the server. Use session-based auth via POST /api/embed/sessions minted from your backend.

Quick Start Checklist

One-time setup

Bootstrap first admin via POST /api/setup
Register your app in the admin portal UI
Copy appId + appSecret to backend env
Expose GET /api/config on your host app
Onboard each tenant via POST /api/embed/onboard
Store embedToken in your DB keyed by tenant

Per-user integration

Backend route mints session via POST /api/embed/sessions
Frontend fetches sessionId from your backend
Load /embed/sdk.js script
Call WidgetBuilder.init(...) or .addButton(...)
Validate x-app-secret on your API routes
Scope data by x-tenant-id

Key Concepts

App Secret

wbs_... — long-lived app secret from the admin portal. Server-side only! Never expose.

Embed Token

emb_... — per-tenant token from onboarding. Server-side only!

Session ID

ess_... — short-lived (24h) user session. Safe for the browser. Passed to the SDK.

Dashboard ID

Logical grouping. Symmetric: save with X → read with X. Auto-refresh bridge when same ID used on button + init.

Multi-Tenant

Each onboarded org is an isolated tenant with its own widgets. Pass tenantId to every SDK method.

Widget

HTML + CSS + JS strings stored per tenant. Rendered in a sandboxed iframe. Fetches data via the widget-builder proxy.

Architecture Flow

Host Application (your product)
  │
  ├── Backend (Node / Python / any)
  │     │
  │     ├── POST /api/embed/onboard       → one-time per tenant (uses appId + appSecret)
  │     │                                   returns: embedToken, organizationId
  │     │
  │     ├── POST /api/embed/sessions      → per page load (appId + appSecret + embedToken)
  │     │                                   returns: sessionId (ess_...)
  │     │
  │     └── Stores server-side ONLY: appId, appSecret, embedToken
  │
  └── Frontend (React / Vue / any)
        │
        ├── Fetches sessionId from YOUR backend
        ├── Loads https://widget-builder.example.com/embed/sdk.js
        └── Calls WidgetBuilder.init({ sessionId, ... })
              ↓
        Widget Builder iframe loads the dashboard
              ↓
        Widget inside iframe: fetchAPI('/api/projects')
              ↓
        Widget Builder proxy: GET /api/embed/proxy/projects
              ↓
        YOUR backend /api/projects receives:
              x-app-secret: wbs_...       ← validate!
              x-tenant-id: your_tenant    ← scope data by this!

Environment Variables (host app)

.env.localenv
# Public — safe for the browser
NEXT_PUBLIC_WIDGET_BUILDER_URL=https://widget-builder.example.com

# Server-only — NEVER expose to the browser
WIDGET_BUILDER_URL=https://widget-builder.example.com
WIDGET_BUILDER_APP_ID=6651b...
WIDGET_BUILDER_APP_SECRET=wbs_...

API Quick Reference

Core endpoints needed for a full integration. See the downloaded guide for request/response shapes and every endpoint in the API.

POST/api/setupBootstrap

Create the very first admin. One-shot (refuses to run twice).

POST/api/embed/onboardOne-time per tenant

Create org + owner user + embed token atomically. One call per tenant (not idempotent!).

POST/api/embed/sessionsPer page load

Mint a 24h session for a user. Call from YOUR backend — never the browser.

POST/api/embed/usersAs needed

Add an additional user to a tenant. Call when /sessions returns 404 for a new user.

POST/api/embed/users/emailAs needed

Change an existing user's email. Call after your own backend commits the new address.

POST/api/embed/sessions/{id}/refreshBefore expiry

Extend a session TTL by 24 hours.

GET/api/embed/verifyHealth check

Check if a session is still valid.

GET/api/embed/widgetsWidget CRUD

List widgets. Headers: x-session-id, x-dashboard-id.

POST/api/embed/widgets

Create a widget with html/css/js strings.

GET/api/embed/apisDiscovery

Returns the host app's API catalog (from /api/config) for editor autocomplete + AI.

Minting a Session (Next.js)

app/api/widget-builder-session/route.tstypescript
import { NextRequest, NextResponse } from 'next/server'
import { getAuthenticatedUser } from '@/lib/auth'

const WB_URL = process.env.WIDGET_BUILDER_URL!
const APP_ID = process.env.WIDGET_BUILDER_APP_ID!
const APP_SECRET = process.env.WIDGET_BUILDER_APP_SECRET!

export async function POST(req: NextRequest) {
  const user = await getAuthenticatedUser(req)
  if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })

  // 1. Resolve the per-tenant embed token (or onboard if missing)
  let embedToken = await db.embedTokens.findOne({ tenantId: user.tenantId })
  if (!embedToken) {
    const onboardRes = await fetch(`${WB_URL}/api/embed/onboard`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'x-app-secret': APP_SECRET },
      body: JSON.stringify({
        organizationName: user.tenantName,
        firstName: user.firstName,
        lastName: user.lastName,
        email: user.email,
        appId: APP_ID,
      }),
    })
    const { data } = await onboardRes.json()
    await db.embedTokens.insertOne({
      tenantId: user.tenantId,
      token: data.embedToken.token,
    })
    embedToken = { token: data.embedToken.token }
  }

  // 2. Mint a session (handles the "user not found" 404 retry)
  const sessionRes = await fetch(`${WB_URL}/api/embed/sessions`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      appId: APP_ID,
      appSecret: APP_SECRET,
      userEmail: user.email,
      embedToken: embedToken.token,
    }),
  })

  const session = await sessionRes.json()
  return NextResponse.json({ data: session.data })
}

React Component (reusable across pages)

components/WidgetBuilderDashboard.tsxtsx
'use client'
import { useEffect, useRef, useState } from 'react'

const WB_URL = process.env.NEXT_PUBLIC_WIDGET_BUILDER_URL!

export function WidgetBuilderDashboard({
  dashboardId,
}: { dashboardId: string }) {
  const [sessionId, setSessionId] = useState<string | null>(null)
  const [sdkReady, setSdkReady] = useState(
    typeof window !== 'undefined' && !!(window as any).WidgetBuilder,
  )
  const inited = useRef(false)

  // 1. Fetch session from your backend
  useEffect(() => {
    fetch('/api/widget-builder-session', { method: 'POST' })
      .then(r => r.json()).then(({ data }) => setSessionId(data.sessionId))
  }, [])

  // 2. Load sdk.js once (idempotent)
  useEffect(() => {
    if (sdkReady) return
    const existing = document.querySelector<HTMLScriptElement>('script[data-wb-sdk]')
    if (existing) {
      existing.addEventListener('load', () => setSdkReady(true))
      return
    }
    const s = document.createElement('script')
    s.src = `${WB_URL}/embed/sdk.js`
    s.async = true
    s.dataset.wbSdk = 'true'
    s.onload = () => setSdkReady(true)
    document.head.appendChild(s)
  }, [sdkReady])

  // 3. Init dashboard + button when both ready
  useEffect(() => {
    if (!sessionId || !sdkReady || inited.current) return
    inited.current = true

    ;(window as any).WidgetBuilder.init({
      sessionId, baseUrl: WB_URL,
      container: `#wb-${dashboardId}`,
      dashboardId, height: '680px',
    })

    ;(window as any).WidgetBuilder.addButton({
      sessionId, baseUrl: WB_URL,
      container: `#wb-btn-${dashboardId}`,
      dashboardId, label: '+ Add Widget',
    })
  }, [sessionId, sdkReady, dashboardId])

  if (!sessionId || !sdkReady) return <div>Loading…</div>
  return (
    <div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
      <div style={{ display: 'flex', justifyContent: 'flex-end', padding: 8 }}>
        <div id={`wb-btn-${dashboardId}`} />
      </div>
      <div id={`wb-${dashboardId}`} style={{ flex: 1 }} />
    </div>
  )
}

Use it on multiple pages by varying dashboardId:

<WidgetBuilderDashboard dashboardId="projects" />
<WidgetBuilderDashboard dashboardId="staff" />
<WidgetBuilderDashboard dashboardId="reports" />

If your app sends widget permissions

AI generation is permission-checked like any other authoring action: POST /api/embed/ai/widget/code requires create or an edit level other than NONE — someone who can only edit their own widgets keeps AI help, someone who can do neither is refused with a 403 before the model is called. Hide your addButton for roles without create using WidgetBuilder.getPermissions(), rather than letting them open a picker whose every outcome is refused. Apps that have not adopted RBAC are unrestricted and see none of this — see Widget Permissions (RBAC).

Your Host API Routes (receive proxied requests)

When widgets call fetchAPI('/api/projects'), it lands on YOUR /api/projects route via Widget Builder's proxy. Validate the app secret and scope by tenant:

app/api/projects/route.tstypescript
export async function GET(request: Request) {
  // 1. Authenticate — reject anything without your app secret
  const appSecret = request.headers.get('x-app-secret')
  if (appSecret !== process.env.WIDGET_BUILDER_APP_SECRET) {
    return new Response('Unauthorized', { status: 401 })
  }

  // 2. Scope by tenant
  const tenantId = request.headers.get('x-tenant-id')
  const projects = await db.projects.find({ tenantId }).toArray()

  return Response.json({ data: projects })
}

Security Invariants

appSecret and embedToken never appear in browser bundles, VITE_/NEXT_PUBLIC_ env vars, or git history
Browser only ever holds sessionId (ess_...), 24h TTL
Sessions minted only from an authenticated backend endpoint
x-app-secret validated on every host API route including /api/config
x-tenant-id used to scope data in multi-tenant apps
Same dashboardId passed to addButton + init so the auto-refresh bridge fires
Sessions cached in your DB keyed by (tenantId, userId) — not re-minted every render

Ready for your AI assistant

Download the full markdown or copy its entire content to paste into Claude, Cursor, or any LLM-based coding tool.