Migration: Legacy Widget to Headless SDK

Date: 2026-05-11

This guide covers migrating one workflow at a time from the legacy Google Customer Engagement AI web widget to the framework-neutral headless SDK. Existing widget deployments stay supported; nothing here forces a full cutover.

1. chatSdk.registerContext to createChatClient

Legacy:

<script src="https://cdn.example.com/chat-widget.js"></script>
<script>
  window.chatSdk.registerContext({
    userId: 'u-123',
    locale: 'en-US',
    deployment: 'support-prod',
  });
</script>

Headless:

import { createChatClient, tokenEndpointAuth } from 'gecx-chat';

const client = createChatClient({
  auth: tokenEndpointAuth({ endpoint: '/api/gecx-chat-token' }),
  deployment: 'support-prod',
});
const session = await client.createSession({
  metadata: { userId: 'u-123', locale: 'en-US' },
});

Context that used to live in registerContext becomes the metadata argument on createSession or per-message send calls. Move secrets server-side: the browser only sees the customer-hosted token endpoint.

2. Widget DOM events to typed session handlers

Legacy widgets dispatch DOM events like chatSdk:message, chatSdk:tool-result, and chatSdk:reset. Two paths:

2a. Use the compatibility bridge (incremental)

import { createWidgetEventBridge } from 'gecx-chat/compat';

const bridge = createWidgetEventBridge({ session });
// On unmount:
bridge.dispose();

The bridge listens on window (or any EventTarget you pass) for legacy custom events and forwards them to the new ChatSession methods. Analytics listeners that already subscribe to chatSdk:* events keep working. This is the recommended first step when migrating one page at a time.

2b. Replace listeners with useChatSession (target state)

import { useChatSession } from 'gecx-chat/react';

const chat = useChatSession({ config });

chat.session?.on('message_added', ({ message }) => analytics.track('chat_message', message));
chat.session?.on('status_changed', ({ status }) => analytics.track('chat_status', { status }));
chat.session?.on('handoff_status_changed', ({ status }) => analytics.track('chat_handoff', { status }));

The typed session events replace string-based DOM events. The bridge from 2a is temporary scaffolding while listeners are ported.

3. Custom widget templates to renderer registry

Legacy templates are HTML strings registered with chatSdk.registerTemplate('order-card', html). Headless customers register typed renderers per ChatMessagePart type:

import { ChatProvider, MessagePart, createRendererRegistry } from 'gecx-chat/react';
import { createRichContentRegistry } from 'gecx-chat/rich-content';

const richContent = createRichContentRegistry();
richContent.register('order-card', { schema: orderCardSchema });

const renderers = {
  'order-summary': (part) => <OrderCard order={part} />,
  'product-carousel': (part) => <ProductGrid products={part.products} />,
  'custom': (part) => part.payloadType === 'order-card' ? <OrderCard order={part.data} /> : null,
};

<ChatProvider client={client} renderers={renderers}>
  {messages.map((m) => m.parts.map((p) => <MessagePart key={p.id} part={p} />))}
</ChatProvider>

Custom HTML is replaced by typed React components keyed on the discriminated part type. The SDK never emits Shadow-DOM markup -- your component library owns the chrome.

4. Client functions to defineClientTool

Legacy:

chatSdk.registerFunction('lookup_invoice', async (input) => {
  return fetchInvoice(input.invoiceId);
});

Headless:

import { defineClientTool } from 'gecx-chat';

export const lookupInvoiceTool = defineClientTool({
  name: 'lookup_invoice',
  description: 'Look up an invoice for the signed-in user',
  inputSchema: {
    type: 'object',
    required: ['invoiceId'],
    properties: { invoiceId: { type: 'string' } },
  },
  timeoutMs: 10_000,
  permissions: { requiresUserApproval: false },
  execute: async ({ invoiceId }, ctx) => {
    ctx.signal.throwIfAborted();
    return fetchInvoice(invoiceId);
  },
});

The differences from legacy registration:

  • Inputs are validated against JSON Schema before your handler runs.
  • ctx.signal lets you cancel in-flight requests when the user calls session.stop().
  • Timeouts and approval hooks are first-class config, not implementation details.

5. Backend agent: no changes required

The agent definition, deployment, tools registered server-side, and routing logic are unchanged. Migration is strictly a UI- and event-consumption refactor.

6. Verification checklist

After migrating a workflow, verify:

  • pnpm typecheck passes with no remaining references to the legacy chatSdk global.
  • Browser DevTools network panel shows requests only to the customer-hosted token endpoint and (if applicable) the proxy endpoint -- no direct calls to legacy widget CDN URLs.
  • Existing analytics listeners on chatSdk:* events still fire (verified via the compatibility bridge).
  • Playwright/E2E tests for the migrated route pass against the new mock transport.
  • pnpm gecx doctor reports green for token endpoint, deployment ID, and CSP.

7. Out of scope (post-GA)

  • Automated codemods.
  • Migration of voice or video workflows.
  • Mobile SDK migration.

What's next

Source: docs/guides/migration-from-widget.md