Add the Char agent to your web application using npm or a script tag.
Installation
Choose your preferred installation method:
Add the script to your HTML:<script src="https://unpkg.com/@mcp-b/char/dist/web-component-standalone.iife.js" defer></script>
Then import in your JavaScript:import "@mcp-b/char/web-component";
Basic Usage
Add the custom element to your page:
<char-agent></char-agent>
The agent renders a full chat interface.
Choosing Your Authentication Path
How you authenticate depends on your application architecture:
| Architecture | Auth Method | Best For |
|---|
| SPA (client-side auth) | connect({ idToken, clientId }) | React, Vue, Angular with client-side IDP SDKs |
| SSR (server-side auth) | connect({ ticketAuth }) | Next.js, Remix, Rails, Django, Laravel |
Which should you use? If your users authenticate client-side (IDP SDK returns a JWT to the browser), use the SPA path. If your users authenticate server-side (session cookies, server-rendered pages), use the SSR path.
SPA Authentication (Client-Side)
For single-page applications where users authenticate client-side through your IDP’s SDK.
Using the connect() Method (Recommended)
Use the imperative connect() method to pass tokens securely. This keeps tokens out of the DOM, protecting them from session replay tools and error monitoring services.
Required: When using idToken, you must also pass clientId. This is your OIDC client ID (the aud claim in your ID token) and must be added to Allowed Client IDs in your Char dashboard.
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 allowed_audiences list)
const CLIENT_ID = "your-oidc-client-id";
function App({ idToken }: { idToken: string }) {
const agentRef = useRef<WebMCPAgentElement>(null);
useEffect(() => {
agentRef.current?.connect({ idToken, clientId: CLIENT_ID });
}, [idToken]);
return <char-agent ref={agentRef} />;
}
<template>
<char-agent ref="agentRef" />
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from "vue";
import "@mcp-b/char/web-component";
import type { WebMCPAgentElement } from "@mcp-b/char/web-component";
// Your OIDC client ID (must be in your allowed_audiences list)
const CLIENT_ID = "your-oidc-client-id";
const props = defineProps<{ idToken: string }>();
const agentRef = ref<WebMCPAgentElement | null>(null);
const connectAgent = () => {
agentRef.value?.connect({ idToken: props.idToken, clientId: CLIENT_ID });
};
onMounted(connectAgent);
watch(() => props.idToken, connectAgent);
</script>
import "@mcp-b/char/web-component";
import type { WebMCPAgentElement } from "@mcp-b/char/web-component";
// Your OIDC client ID (must be in your allowed_audiences list)
const CLIENT_ID = "your-oidc-client-id";
const agent = document.querySelector("char-agent") as WebMCPAgentElement;
// Use connect() - keeps token out of DOM
agent.connect({ idToken, clientId: CLIENT_ID });
The connect() method stores tokens as JavaScript properties, not DOM attributes. This protects tokens from:
- Session replay tools (FullStory, LogRocket)
- Error monitoring (Sentry, DataDog)
- DOM inspection and browser extensions
- Page source visibility
SSR Authentication (Server-Side)
For server-side rendered applications where authentication happens on the server. Instead of passing your IDP’s JWT to the client, exchange it server-side for a short-lived ticket.
How It Works
Exchanging a Token for a Ticket
Your server exchanges the user’s IDP token for a short-lived ticket:
curl -X POST https://api.usechar.ai/api/auth/ticket \
-H "Authorization: Bearer <IDP_TOKEN>" \
-H "Content-Type: application/json" \
-d '{
"org_id": "org_abc123",
"client_id": "your-oidc-client-id"
}'
| Parameter | Required | Description |
|---|
org_id | Yes (SSR) | Your Char organization ID (from the dashboard). Required for SSR requests since there’s no Origin header. |
client_id | Yes (production) | Your OIDC client ID. Must be in your allowed_audiences list. |
Response:
{
"ticket": "a1b2c3d4e5f6...",
"userId": "user_123",
"orgId": "org_456",
"expiresAt": 1705315800000,
"expiresIn": 60
}
Tickets are opaque hex strings, not JWTs. They’re single-use, short-lived (60 seconds), and validated server-side. The 60-second window is just for establishing the initial connection—once the WebSocket connects, the session persists indefinitely.
Framework Examples
Next.js (App Router)
Next.js (Pages Router)
Remix
// app/dashboard/page.tsx
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 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(); // { ticket, userId, orgId, expiresAt }
}
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} />;
}
// 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 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>
);
}
// 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 { getIdToken } from "~/session.server";
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 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 }>();
const agentRef = useRef<WebMCPAgentElement>(null);
useEffect(() => {
agentRef.current?.connect({ ticketAuth });
}, [ticketAuth]);
return (
<main>
<h1>Dashboard</h1>
<char-agent ref={agentRef} />
</main>
);
}
Why ticketAuth for SSR?
| Aspect | idToken (SPA) | ticketAuth (SSR) |
|---|
| IDP token exposed to browser | Yes | No |
| Token validation | On every connection (JWKS fetch) | Once server-side |
| Connection window | Token’s exp claim (often hours) | 60 seconds to connect |
| Best for | SPAs with client-side auth | SSR apps with server sessions |
The ticket pattern keeps your IDP token server-side while providing secure, short-lived authentication for the embedded agent. Once the agent connects, the WebSocket session persists—the 60-second limit only applies to establishing the initial connection.
Without idToken or ticketAuth, the agent requires dev-mode with an API key for anonymous mode (localhost development only). See Deployment Tiers for details.
Controlling Open State
Open or close the agent programmatically:
const agent = document.querySelector("char-agent");
// Open the agent
agent.open = true;
// Close the agent
agent.open = false;
In React with state:
import { useState, 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 allowed_audiences list)
const CLIENT_ID = "your-oidc-client-id";
function App({ idToken }: { idToken: string }) {
const [isOpen, setIsOpen] = useState(false);
const agentRef = useRef<WebMCPAgentElement>(null);
useEffect(() => {
agentRef.current?.connect({ idToken, clientId: CLIENT_ID });
}, [idToken]);
return (
<>
<button onClick={() => setIsOpen(true)}>Open Agent</button>
<char-agent ref={agentRef} open={isOpen} />
</>
);
}
Development Mode (Anonymous)
For local development without IDP setup, use your own API key via dev-mode:
<char-agent dev-mode='{"anthropicApiKey":"sk-ant-..."}'></char-agent>
This runs in anonymous mode with full Durable Object persistence—messages persist across page refreshes.
Anonymous mode only works from localhost origins. Never embed API keys in production code—they’re visible to users.
Script Loading
The defer attribute ensures the script loads in parallel without blocking page rendering:
<!-- Recommended: defer loads async, executes after DOM ready -->
<script src="https://unpkg.com/@mcp-b/char/dist/web-component-standalone.iife.js" defer></script>
Preconnect Hint
Speed up loading by adding a preconnect hint in your <head>:
<link rel="preconnect" href="https://unpkg.com" crossorigin>
Bundle Sizes
| Build | Size | Gzipped |
|---|
| ESM (npm) | ~560 KB | ~115 KB |
| Standalone (script tag) | ~2.2 MB | ~400 KB |
The standalone build includes React for zero-dependency embedding. The ESM build is smaller when your app already uses React.
Security Best Practices
Token Handling
| Do | Don’t |
|---|
Use connect({ idToken, clientId }) for SPAs | Put JWTs in HTML attributes |
Use connect({ ticketAuth }) for SSR apps | Pass your IDP’s JWT from server to client |
Configure allowed_audiences in dashboard | Skip audience validation |
| Validate tokens on every request | Store tokens in localStorage (use memory or cookies with httpOnly) |
| Set appropriate token TTLs | Use long-lived tickets (max 120s) |
Minimizing Token Exposure
For SPAs: The connect() method stores tokens as JavaScript properties, preventing them from appearing in:
- Page source (view-source)
- Browser dev tools Elements panel
- Server logs (if HTML is logged)
- Session replay tools (FullStory, LogRocket)
For SSR: Tickets are preferable to JWTs because:
- They’re opaque (not JWTs, just hex strings)
- They’re scoped to Char only (not your entire IDP)
- They have a narrow connection window (60s) and are single-use, limiting replay attacks
- Your IDP token never leaves the server
Ticket Refresh (SSR)
Tickets must be used within 60 seconds, but once the WebSocket connects, the session persists indefinitely. You only need to refresh tickets when the WebSocket disconnects (network interruption, laptop sleep, browser throttling):
Each page load fetches a fresh ticket server-side. Since tickets are valid for 60 seconds and WebSocket connections persist once established, this works for most use cases.If the WebSocket disconnects after a long session, the user refreshes the page to get a new ticket.
Add an API route that fetches a new ticket without a full page reload:// app/api/char/ticket/route.ts (Next.js App Router)
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 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());
}
Then in your client component, reconnect when needed:import type { WebMCPAgentElement, TicketAuth } from "@mcp-b/char/web-component";
const reconnect = async () => {
const res = await fetch("/api/char/ticket", { method: "POST" });
const ticketAuth: TicketAuth = await res.json();
agentRef.current?.connect({ ticketAuth });
};
// Call reconnect() when WebSocket disconnects or user returns to tab
Next steps
See also