Skip to main content
Integrate the Char agent into React applications. This guide covers both SPA (client-side auth) and SSR (server-side auth) patterns.

Quick Reference

FrameworkSPA PatternSSR Pattern
React (Vite, CRA)useEffect + refN/A (client-only)
Next.js App Router'use client' + refServer Component → Client Component
Next.js Pages Routerref in componentgetServerSideProps
TanStack Startlazy() importLoader → client component
RemixClientOnly or lazy()Loader → useLoaderData

React SPA (Vite, Create React App)

For client-side only React apps without SSR, integration is straightforward:
npm install @mcp-b/char
// src/components/CharAgent.tsx
import { useEffect, useRef } from 'react';
import '@mcp-b/char/web-component';
import type { WebMCPAgentElement } from '@mcp-b/char/web-component';

// Your OIDC client ID (must be in your Char allowed_audiences list)
const CLIENT_ID = 'your-oidc-client-id';

export function CharAgent({ idToken }: { idToken: string }) {
  const agentRef = useRef<WebMCPAgentElement>(null);

  useEffect(() => {
    agentRef.current?.connect({ idToken, clientId: CLIENT_ID });
  }, [idToken]);

  return <char-agent ref={agentRef} />;
}
// src/App.tsx
import { CharAgent } from './components/CharAgent';
import { useAuth } from './hooks/useAuth'; // Your auth hook

function App() {
  const { idToken, isAuthenticated } = useAuth();

  return (
    <div>
      <h1>My App</h1>
      {isAuthenticated && <CharAgent idToken={idToken} />}
    </div>
  );
}

export default App;
No SSR considerations needed for pure SPAs. The web component imports and runs entirely in the browser.

With Auth Providers

Works with any auth library that provides an ID token:
import { useEffect, useState } from 'react';
import { useAuth0 } from '@auth0/auth0-react';
import { CharAgent } from './components/CharAgent';

function App() {
  const { isAuthenticated, getIdTokenClaims } = useAuth0();
  const [idToken, setIdToken] = useState<string | null>(null);

  useEffect(() => {
    if (isAuthenticated) {
      getIdTokenClaims().then(claims => setIdToken(claims?.__raw));
    }
  }, [isAuthenticated, getIdTokenClaims]);

  return idToken ? <CharAgent idToken={idToken} /> : null;
}

Next.js (App Router)

SPA: Client-Side Authentication

For apps where users authenticate client-side through your IDP’s SDK:
// app/dashboard/char-agent.tsx
'use client';

import { useEffect, useRef } from 'react';
import '@mcp-b/char/web-component';
import type { WebMCPAgentElement } from '@mcp-b/char/web-component';

// Your OIDC client ID (must be in your Char allowed_audiences list)
const CLIENT_ID = 'your-oidc-client-id';

export function CharAgent({ idToken }: { idToken: string }) {
  const agentRef = useRef<WebMCPAgentElement>(null);

  useEffect(() => {
    agentRef.current?.connect({ idToken, clientId: CLIENT_ID });
  }, [idToken]);

  return <char-agent ref={agentRef} />;
}
// app/dashboard/page.tsx
'use client';

import { useAuth } from '@/hooks/use-auth'; // Your auth hook
import { CharAgent } from './char-agent';

export default function DashboardPage() {
  const { idToken } = useAuth();

  if (!idToken) return <div>Loading...</div>;

  return (
    <main>
      <h1>Dashboard</h1>
      <CharAgent idToken={idToken} />
    </main>
  );
}
The 'use client' directive is required. The embedded agent uses browser APIs that don’t exist during server-side rendering.

SSR: Server-Side Authentication

For apps where authentication happens on the server (session cookies, server-rendered pages). Exchange the IDP token for a ticket server-side—the ticket has a 60-second connection window, but once connected the session persists:
// app/dashboard/page.tsx (Server Component)
import { getServerSession } from 'next-auth';
import { redirect } from 'next/navigation';
import { CharAgent } from './char-agent';

// Your Char organization ID (from the dashboard)
const ORG_ID = 'org_abc123';
// Your OIDC client ID (must be in your Char allowed_audiences list)
const CLIENT_ID = 'your-oidc-client-id';

async function getTicketAuth(idToken: string) {
  const response = await fetch('https://api.usechar.ai/api/auth/ticket', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${idToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      org_id: ORG_ID,      // Required for SSR (no Origin header)
      client_id: CLIENT_ID // Required for production
    }),
  });
  return response.json();
}

export default async function DashboardPage() {
  const session = await getServerSession();
  if (!session?.idToken) redirect('/login');

  const ticketAuth = await getTicketAuth(session.idToken);

  return (
    <main>
      <h1>Dashboard</h1>
      <CharAgent ticketAuth={ticketAuth} />
    </main>
  );
}
// app/dashboard/char-agent.tsx
'use client';

import { useEffect, useRef } from 'react';
import '@mcp-b/char/web-component';
import type { WebMCPAgentElement, TicketAuth } from '@mcp-b/char/web-component';

export function CharAgent({ ticketAuth }: { ticketAuth: TicketAuth }) {
  const agentRef = useRef<WebMCPAgentElement>(null);

  useEffect(() => {
    agentRef.current?.connect({ ticketAuth });
  }, [ticketAuth]);

  return <char-agent ref={agentRef} />;
}

Dynamic Import (Alternative)

If you need to dynamically import the embedded agent:
'use client';

import dynamic from 'next/dynamic';

const CharAgent = dynamic(
  () => import('./char-agent-impl').then(mod => mod.CharAgent),
  { ssr: false }
);

export function Dashboard({ ticketAuth }) {
  return <CharAgent ticketAuth={ticketAuth} />;
}

Layout Placement

Place the agent in a single layout to avoid duplicates across nested routes:
// app/dashboard/layout.tsx
'use client';

import { CharAgent } from '@/components/char-agent';
import { useAuth } from '@/hooks/use-auth';

export default function DashboardLayout({ children }) {
  const { idToken } = useAuth();

  return (
    <>
      {children}
      {idToken && <CharAgent idToken={idToken} />}
    </>
  );
}

Next.js (Pages Router)

SPA: Client-Side Authentication

// pages/dashboard.tsx
import { useEffect, useRef } from 'react';
import { useAuth } from '@/hooks/use-auth';
import '@mcp-b/char/web-component';
import type { WebMCPAgentElement } from '@mcp-b/char/web-component';

export default function Dashboard() {
  const { idToken } = useAuth();
  const CLIENT_ID = 'your-oidc-client-id';
  const agentRef = useRef<WebMCPAgentElement>(null);

  useEffect(() => {
    if (idToken) {
      agentRef.current?.connect({ idToken, clientId: CLIENT_ID });
    }
  }, [idToken]);

  return (
    <main>
      <h1>Dashboard</h1>
      <char-agent ref={agentRef} />
    </main>
  );
}

SSR: Server-Side Authentication

// pages/dashboard.tsx
import type { GetServerSideProps } from 'next';
import { getSession } from 'next-auth/react';
import { useEffect, useRef } from 'react';
import '@mcp-b/char/web-component';
import type { WebMCPAgentElement, TicketAuth } from '@mcp-b/char/web-component';

// Your Char organization ID (from the dashboard)
const ORG_ID = 'org_abc123';
// Your OIDC client ID (must be in your Char allowed_audiences list)
const CLIENT_ID = 'your-oidc-client-id';

export const getServerSideProps: GetServerSideProps = async (context) => {
  const session = await getSession(context);
  if (!session?.idToken) {
    return { redirect: { destination: '/login', permanent: false } };
  }

  const response = await fetch('https://api.usechar.ai/api/auth/ticket', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${session.idToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      org_id: ORG_ID,      // Required for SSR (no Origin header)
      client_id: CLIENT_ID // Required for production
    }),
  });
  const ticketAuth = await response.json();

  return { props: { ticketAuth } };
};

export default function Dashboard({ ticketAuth }: { ticketAuth: TicketAuth }) {
  const agentRef = useRef<WebMCPAgentElement>(null);

  useEffect(() => {
    agentRef.current?.connect({ ticketAuth });
  }, [ticketAuth]);

  return (
    <main>
      <h1>Dashboard</h1>
      <char-agent ref={agentRef} />
    </main>
  );
}

TanStack Start

TanStack Start components run during both SSR and client-side hydration. Use lazy imports to ensure the agent only loads client-side.

SPA: Client-Side Authentication

// routes/dashboard.tsx
import { createFileRoute } from '@tanstack/react-router';
import { lazy, Suspense } from 'react';

const CharAgent = lazy(() => import('../components/char-agent'));

export const Route = createFileRoute('/dashboard')({
  component: Dashboard,
});

function Dashboard() {
  const { idToken } = useAuth(); // Your auth hook

  return (
    <main>
      <h1>Dashboard</h1>
      <Suspense fallback={null}>
        <CharAgent idToken={idToken} />
      </Suspense>
    </main>
  );
}
// components/char-agent.tsx
import { useEffect, useRef } from 'react';
import '@mcp-b/char/web-component';
import type { WebMCPAgentElement } from '@mcp-b/char/web-component';

export default function CharAgent({ idToken }: { idToken: string }) {
  const CLIENT_ID = 'your-oidc-client-id';
  const agentRef = useRef<WebMCPAgentElement>(null);

  useEffect(() => {
    agentRef.current?.connect({ idToken, clientId: CLIENT_ID });
  }, [idToken]);

  return <char-agent ref={agentRef} />;
}

SSR: Server-Side Authentication

// routes/dashboard.tsx
import { createFileRoute } from '@tanstack/react-router';
import { lazy, Suspense } from 'react';

const CharAgent = lazy(() => import('../components/char-agent-ticket'));

// Your Char organization ID (from the dashboard)
const ORG_ID = 'org_abc123';
// Your OIDC client ID (must be in your Char allowed_audiences list)
const CLIENT_ID = 'your-oidc-client-id';

export const Route = createFileRoute('/dashboard')({
  loader: async ({ context }) => {
    const idToken = await context.auth.getIdToken();
    if (!idToken) throw redirect({ to: '/login' });

    const response = await fetch('https://api.usechar.ai/api/auth/ticket', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${idToken}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        org_id: ORG_ID,      // Required for SSR (no Origin header)
        client_id: CLIENT_ID // Required for production
      }),
    });
    return { ticketAuth: await response.json() };
  },
  component: Dashboard,
});

function Dashboard() {
  const { ticketAuth } = Route.useLoaderData();

  return (
    <main>
      <h1>Dashboard</h1>
      <Suspense fallback={null}>
        <CharAgent ticketAuth={ticketAuth} />
      </Suspense>
    </main>
  );
}
// components/char-agent-ticket.tsx
import { useEffect, useRef } from 'react';
import '@mcp-b/char/web-component';
import type { WebMCPAgentElement, TicketAuth } from '@mcp-b/char/web-component';

export default function CharAgent({ ticketAuth }: { ticketAuth: TicketAuth }) {
  const agentRef = useRef<WebMCPAgentElement>(null);

  useEffect(() => {
    agentRef.current?.connect({ ticketAuth });
  }, [ticketAuth]);

  return <char-agent ref={agentRef} />;
}

Remix

Remix renders components on both server and client. Use ClientOnly from remix-utils or lazy imports.

SPA: Client-Side Authentication

Using ClientOnly (recommended):
npm install remix-utils
// app/routes/dashboard.tsx
import { ClientOnly } from 'remix-utils/client-only';

export default function Dashboard() {
  return (
    <main>
      <h1>Dashboard</h1>
      <ClientOnly fallback={null}>
        {() => <CharAgentClient />}
      </ClientOnly>
    </main>
  );
}

function CharAgentClient() {
  const { useEffect, useRef } = require('react');
  require('@mcp-b/char/web-component');

  const { idToken } = useAuth(); // Your auth hook
  const CLIENT_ID = 'your-oidc-client-id';
  const agentRef = useRef(null);

  useEffect(() => {
    if (idToken) {
      agentRef.current?.connect({ idToken, clientId: CLIENT_ID });
    }
  }, [idToken]);

  return <char-agent ref={agentRef} />;
}
Using lazy imports:
// app/routes/dashboard.tsx
import { lazy, Suspense } from 'react';

const CharAgent = lazy(() => import('~/components/char-agent'));

export default function Dashboard() {
  return (
    <main>
      <h1>Dashboard</h1>
      <Suspense fallback={null}>
        <CharAgent />
      </Suspense>
    </main>
  );
}

SSR: Server-Side Authentication

// app/routes/dashboard.tsx
import type { LoaderFunctionArgs } from '@remix-run/node';
import { json, redirect } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { useEffect, useRef } from 'react';
import { ClientOnly } from 'remix-utils/client-only';
import { getIdToken } from '~/session.server';
import type { TicketAuth } from '@mcp-b/char/web-component';

// Your Char organization ID (from the dashboard)
const ORG_ID = 'org_abc123';
// Your OIDC client ID (must be in your Char allowed_audiences list)
const CLIENT_ID = 'your-oidc-client-id';

export async function loader({ request }: LoaderFunctionArgs) {
  const idToken = await getIdToken(request);
  if (!idToken) throw redirect('/login');

  const response = await fetch('https://api.usechar.ai/api/auth/ticket', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${idToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      org_id: ORG_ID,      // Required for SSR (no Origin header)
      client_id: CLIENT_ID // Required for production
    }),
  });
  const ticketAuth = await response.json();

  return json({ ticketAuth });
}

export default function Dashboard() {
  const { ticketAuth } = useLoaderData<{ ticketAuth: TicketAuth }>();

  return (
    <main>
      <h1>Dashboard</h1>
      <ClientOnly fallback={null}>
        {() => <CharAgentClient ticketAuth={ticketAuth} />}
      </ClientOnly>
    </main>
  );
}

function CharAgentClient({ ticketAuth }: { ticketAuth: TicketAuth }) {
  const { useEffect, useRef } = require('react');
  require('@mcp-b/char/web-component');

  const agentRef = useRef(null);

  useEffect(() => {
    agentRef.current?.connect({ ticketAuth });
  }, [ticketAuth]);

  return <char-agent ref={agentRef} />;
}

Ticket Refresh for Long Sessions

Tickets must be used within 60 seconds to establish the connection, but once connected, the WebSocket session persists indefinitely. You only need to refresh if the WebSocket disconnects (network interruption, laptop sleep):
// app/api/char/ticket/route.ts
import { getServerSession } from 'next-auth';

// Your Char organization ID (from the dashboard)
const ORG_ID = 'org_abc123';
// Your OIDC client ID (must be in your Char allowed_audiences list)
const CLIENT_ID = 'your-oidc-client-id';

export async function POST() {
  const session = await getServerSession();
  if (!session?.idToken) {
    return Response.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const response = await fetch('https://api.usechar.ai/api/auth/ticket', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${session.idToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      org_id: ORG_ID,      // Required for SSR (no Origin header)
      client_id: CLIENT_ID // Required for production
    }),
  });

  return Response.json(await response.json());
}
// In your client component
const reconnect = async () => {
  const res = await fetch('/api/char/ticket', { method: 'POST' });
  const ticketAuth = await res.json();
  agentRef.current?.connect({ ticketAuth });
};

TypeScript Setup

Add type declarations for the web component:
// types/webmcp.d.ts
import type { WebMCPAgentElement } from '@mcp-b/char/web-component';

declare global {
  namespace JSX {
    interface IntrinsicElements {
      'char-agent': React.DetailedHTMLProps<
        React.HTMLAttributes<WebMCPAgentElement> & {
          open?: boolean;
        },
        WebMCPAgentElement
      >;
    }
  }
}

See Also