ag
← back
July 14, 2026Case study

Adding live co-browse to a Lovable app: a Webfuse x Lovable integration

A reference build for adding Webfuse co-browsing to a Lovable app - PIN-join over a video call, with the customer side needing no backend and the whole demo running on one command.

WebfuseLovableCo-browseSupabaseReactJWT

Adding live co-browse to a Lovable app: a Webfuse x Lovable integration

Built on Webfuse. A prospect had built their product on Lovable - scaffolded as an AI-generated React app with Supabase functions - and needed live co-browsing inside their video calls. This is the reference build that answers "how do you add Webfuse co-browse to a Lovable app," end to end: a customer on a call clicks Cobrowse, a Webfuse session starts and shows a 4-digit PIN, the customer reads the PIN aloud to the agent, the agent types it, and their screen joins the same live session.

The deliverable was shaped by who receives it. A Lovable builder describes features in a chat box; they don't open terminals or hand-write Edge Functions. So the output is two things: a working reference to import, and a build spec written to be pasted straight into Lovable's chat by a non-engineer. The stack matches Lovable's own - React 19, Vite, TypeScript, Tailwind v4 - with the backend as a Supabase Edge Function, because that is exactly what a Lovable app already deploys to.

The problem

Lovable hands you a React frontend and a Supabase backend. That is the whole surface you get to work with, and every naive way to bolt on "join a shared session" breaks against it - each for a reason no better prompt in the Lovable chat can fix:

  • Pass a session link around. Every customer gets their own Webfuse session. A shared link or shared join state collides the moment two customers are on calls at once. The agent needs to target one specific customer's session, not "the" session.
  • Do the join from the browser. Finding the customer's session and minting an authenticated host login needs two secret keys - a Space REST key and a Magic Link signing key. Put those in a Lovable-generated frontend and anyone viewing source can mint host logins. So the agent side must run server-side - but the customer side must not need a backend at all, because it only starts a session.
  • Reach for infrastructure Lovable doesn't have. No Docker, no separate API gateway, no CLI step. If the integration can't be expressed as "a Supabase Edge Function plus some frontend," a Lovable builder can't ship it.

Underneath those, three root causes shaped the build:

  1. The trust boundary is the whole design. One key is public by design - the Widget Key, domain-locked. Two are secret. On Lovable that line is already drawn for you: public VITE_* env versus Supabase Secrets. The job is to honor it and never let a secret cross into the frontend.
  2. Webfuse auth is easy to get subtly wrong. REST uses Authorization: Token, not Bearer - a common cause of a silent 401. And the join mechanism is a session_id query param that isn't obvious from the public Widget docs.
  3. The reference has to actually run for a non-engineer. If starting the demo needs the Supabase CLI plus Docker, the prospect - and future-me - won't run it, and the reference loses its whole point as a live thing to point at.

The design

Two independent sides of one feature, split on the trust boundary Lovable already gives you.

The customer side is frontend only - pure Lovable territory. It loads surfly.js, calls initSpace() with the public Widget Key, and starts a session. Webfuse generates and displays the PIN itself - the app never fetches or shows it. No secret ever reaches this path, which is exactly why the customer side needs nothing but the React that Lovable already generates.

// In an Assisted Space, Webfuse shows a 4-digit PIN inside the session
// automatically - the customer reads it to the agent. No secret keys here.
export async function startCustomerSession(iframeSelector: string) {
  loadSurfly()
  const space = await window.webfuse.initSpace(WIDGET_KEY, SPACE_ID, {})
  // block_until_agent_joins keeps the customer on a "connecting" screen until
  // the agent joins with the PIN - the live-help experience.
  const session = space.session({ block_until_agent_joins: true })
  await session.start(iframeSelector)
  return session
}

The agent side is a PIN box plus a Supabase Edge Function. The PIN POSTs to the function, which finds the active session whose pin matches via REST, signs a short-lived Magic Link so the agent joins authenticated and as host, and returns a join URL. The agent's iframe loads that URL and lands in the same session. Choosing a Supabase Edge Function wasn't a preference - it's the one server runtime a Lovable app already ships with, so the integration adds nothing to the prospect's deployment story.

The judgment calls are where the design earns its keep.

PIN, not a passed link. Each customer has their own session and PIN, and the agent's lookup targets exactly one session_id. Concurrent sessions just work - there's no shared state to collide. The cost is honest: a 4-digit PIN is only 10,000 combinations, so the join function must be authenticated and rate-limited before production. I called that out in the hand-off's hardening section rather than letting it read as a finished security model.

One core.ts, two runtimes - so a Lovable builder can actually run it. The PIN-to-join logic lives in a single import-free core.ts. fetch is global in Deno, Node 18+, and Bun, so the file needs nothing from any runtime. The only two things that differ between environments - how the JWT is signed, and where env comes from - are passed in as parameters.

// This file imports nothing. The only runtime difference - how a Magic Link
// (HS256 JWT) is signed - is injected as `signMagicLink`; env is a plain object.
export async function resolveJoin(pin: string, env: JoinEnv, signMagicLink: Signer): Promise<JoinResult> {
  const headers = { Authorization: `Token ${env.WEBFUSE_SPACE_REST_KEY ?? ''}` }  // Token, not Bearer

  // 1. Find the active session by PIN.
  const sres = await fetch(`${apiBase}/spaces/${spaceId}/sessions/?active=true`, { headers })
  const sessions: any[] = Array.isArray(payload) ? payload : (payload.results ?? [])
  const match = sessions.find((s) => String(s.pin) === String(pin))
  if (!match) throw new HttpError(404, 'No active session for that PIN. Ask the customer to confirm it.')

  // 2. Resolve the Space link; normalise the trailing slash so we don't emit `…//?…`.
  const base = String(space.link).replace(/\/+$/, '')

  // 3. Sign the Magic Link (host) and build the join URL.
  const claims = { space_id: spaceId, name: env.WEBFUSE_AGENT_NAME ?? 'Agent', can_host: true }
  const jwt = await signMagicLink(claims, env.WEBFUSE_MAGIC_LINK_KEY ?? '')
  return {
    url: `${base}/?magic_link=${encodeURIComponent(jwt)}&session_id=${encodeURIComponent(match.session_id)}`,
    session_id: match.session_id,
  }
}

In production that core runs inside the Supabase Edge Function (Deno). In local dev it runs inside a Vite middleware that injects Node's jose signer and reads the same secret file layout the deploy uses - so bun run dev alone runs the entire demo, with no Supabase CLI and no Docker:

// Serves POST /api/join from INSIDE the Vite dev server, using the SAME core.ts
// as the Supabase Edge Function - so `bun run dev` alone runs the demo.
const sign = (claims, secret) =>
  new SignJWT(claims).setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime(MAGIC_LINK_TTL).sign(new TextEncoder().encode(secret))

export function devJoin(): Plugin {
  return { name: 'dev-join', apply: 'serve', configureServer(server) {
    const env = loadEnvFile('./supabase/.env.local')
    server.middlewares.use('/api/join', (req, res) => {
      /* ...parse {pin}... */
      send(200, await resolveJoin(String(pin), env, sign))
    })
  }}
}

What I test locally is byte-for-byte the logic that ships to Supabase. The environments can't drift, because there's one copy of the logic and the differences are parameters.

Secrets split by file, mirroring the Lovable deploy. .env.local holds only public VITE_* values; supabase/.env.local holds the secret keys and is never imported by the frontend. That is a one-to-one mirror of Lovable public env versus Supabase Secrets, so running the reference teaches the prospect the exact production shape they'll use.

Magic Link TTL is 5 minutes. The token is signed and used within seconds; exp is checked only at join time and does not limit session length. Short means a smaller window if the URL leaks. I documented that so nobody "fixes" it by lengthening it.

A real teardown bug, documented at the fix. Webfuse tears down its own iframe and modal synchronously inside the session_ended event. Clearing the stage in that same tick detaches nodes mid-teardown and makes the platform's apiframe.js throw on removeChild, which hangs the modal. Deferring cleanup one tick lets it finish first.

session.on('session_ended', () => {
  setStarted(false); setBusy(false)
  // Defer DOM cleanup to the next tick. Let Webfuse finish its own synchronous
  // teardown, then clean up, or apiframe.js throws on removeChild (null parent).
  setTimeout(() => { if (stageRef.current) stageRef.current.innerHTML = '' }, 0)
})

One note on the reference default. The join endpoint ships with Supabase JWT verification off, because for a local reference it only takes a PIN and returns a URL - auth is enforced by the secret keys inside the function. The hand-off doc is explicit that this is dev-only: before going live, turn Supabase JWT verification back on, require an authenticated agent, and rate-limit the endpoint. A 10,000-combination PIN behind an unauthenticated function is not a production posture, and the docs lead with that.

Results

  • Adds co-browse to a Lovable app with nothing new in the stack - a React frontend plus one Supabase Edge Function, the two things a Lovable project already has.
  • The customer side needs no backend at all - public Widget Key only, so it's pure Lovable-generated React.
  • One command, zero Docker to run the full demo end-to-end - bun run dev - because the join backend runs as Vite middleware instead of the Supabase CLI.
  • Logic drift between local and production: zero. A single shared core.ts; only the JWT signer and the env source differ per runtime.
  • Three keys, one public and two secret - and that split maps exactly onto Lovable public env versus Supabase Secrets.
  • Magic Link TTL: 5 minutes. Signed and consumed within seconds; it never gates session length.
  • PIN space: 10,000 combinations - the reason authentication and rate limiting are mandatory before production, called out in the hand-off rather than left implicit.
  • Deliverable: one reference repo plus two hand-off docs - a paste-into-Lovable build spec for a non-engineer, and a stack-agnostic integration guide.

The principle

Integrating a platform into a no-code stack like Lovable is mostly about respecting the surface it already gives you - a React frontend and a Supabase backend - and drawing the trust boundary exactly where the stack already draws it. Keep the secrets on the Edge Function, keep the customer side backend-free, and make the join logic run identically in dev and production. Then the hard part isn't code the builder can't write - it's a spec they can paste into a chat box.