Quick Reference
| Framework | SPA Pattern | SSR Pattern |
|---|---|---|
| React (Vite, CRA) | useEffect + ref | N/A (client-only) |
| Next.js App Router | 'use client' + ref | Server Component → Client Component |
| Next.js Pages Router | ref in component | getServerSideProps |
| TanStack Start | lazy() import | Loader → client component |
| Remix | ClientOnly or lazy() | Loader → useLoaderData |
React SPA (Vite, Create React App)
For client-side only React apps without SSR, integration is straightforward:Copy
npm install @mcp-b/char
Copy
// 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} />;
}
Copy
// 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:- Auth0
- Firebase
- Clerk
Copy
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;
}
Copy
import { useEffect, useState } from 'react';
import { getAuth, onIdTokenChanged } from 'firebase/auth';
import { CharAgent } from './components/CharAgent';
function App() {
const [idToken, setIdToken] = useState<string | null>(null);
useEffect(() => {
const auth = getAuth();
return onIdTokenChanged(auth, async (user) => {
if (user) {
setIdToken(await user.getIdToken());
} else {
setIdToken(null);
}
});
}, []);
return idToken ? <CharAgent idToken={idToken} /> : null;
}
Copy
import { useEffect, useState } from 'react';
import { useAuth } from '@clerk/clerk-react';
import { CharAgent } from './components/CharAgent';
function App() {
const { getToken, isSignedIn } = useAuth();
const [idToken, setIdToken] = useState<string | null>(null);
useEffect(() => {
if (isSignedIn) {
getToken().then(setIdToken);
}
}, [isSignedIn, getToken]);
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:Copy
// 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} />;
}
Copy
// 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:Copy
// 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>
);
}
Copy
// 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:Copy
'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:Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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>
);
}
Copy
// 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
Copy
// 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>
);
}
Copy
// 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. UseClientOnly from remix-utils or lazy imports.
SPA: Client-Side Authentication
Using ClientOnly (recommended):Copy
npm install remix-utils
Copy
// 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} />;
}
Copy
// 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
Copy
// 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):- Next.js App Router
- Remix
Copy
// 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());
}
Copy
// 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 });
};
Copy
// app/routes/api.char.ticket.tsx
import type { ActionFunctionArgs } from '@remix-run/node';
import { json, redirect } from '@remix-run/node';
import { getIdToken } from '~/session.server';
// 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 action({ request }: ActionFunctionArgs) {
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
}),
});
return json(await response.json());
}
Copy
// 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:Copy
// 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
- Embedding the Agent - Full authentication guide
- Identity Providers - Get ID tokens from Okta, Auth0, Azure AD, etc.
- Agent Attributes - Configuration options

