@frontera-sdk/chat-ui
A Frontera agent's conversation, as a React component in your own application. Streamed replies, tool activity, attachments, the files the agent produces, knowledge citations, approvals and personal-account connections.
It is a component, not an iframe: it renders in your DOM, uses your React, and your CSS can reach it (see Hazards).
Install
npm i @frontera-sdk/chat-ui react react-dom
React 19, ESM only. Installing needs Node.js 22 or later. react and
react-dom are the only packages the component needs from you — two
copies of React in one page break hooks.
Everything else it renders with installs as a dependency of this package.
npm ls react # expect one version
If your token endpoint lives in the same project, the server side needs two more, because your own code imports them directly (see Tokens):
npm i @frontera-sdk/chat @frontera-sdk/core
Both already arrive with this package, so the install is a no-op in practice. Declare them anyway: a package your source imports belongs in your manifest, or the day this package stops depending on them your build breaks.
Quick start
Two things are required: both stylesheets, and a container with a height.
'use client'
import { FronteraChat } from '@frontera-sdk/chat-ui/chat'
import '@frontera-sdk/chat-ui/platform.css'
import '@frontera-sdk/chat-ui/styles.css'
export function Support() {
return (
<div className="h-dvh">
<FronteraChat tokenEndpoint="/api/chat-token" />
</div>
)
}
tokenEndpoint is a route on your server returning
{ token: string, expiresAt: string } for the signed-in person. Building that
route is the other half of the integration — see Tokens.
Credential
Pass exactly one. All four are mutually exclusive in the type.
| Prop | Type | Notes |
|---|---|---|
tokenEndpoint | string | Recommended. URL on your server. Renewal handled for you. Return apiBaseUrl alongside the token, or pass it as a prop — the exchange does not include it. |
token + apiBaseUrl | string + string | You own replacing it before it expires. The chat stops when it does. |
getToken | () => Promise<{ token, expiresAt, apiBaseUrl? }> | Called again on renewal. |
client | FronteraClient | (() => FronteraClient | null) | You built one yourself, e.g. from useChatClient. |
Upgrading
modes, modelMode and defaultMode are gone — use modelSelector
The model is bound to the Agent on the deployment. A list supplied by the host could only ever disagree with what actually runs, and the disagreement was invisible: the picker showed one model and the reply came from another.
What remains is modelSelector?: boolean — whether the picker appears at all.
Which modes it offers, and which one starts selected, now come from the agent.
| Before | After |
|---|---|
modes={[...]} | delete it — the agent's own modes are used |
modes={[]} to hide the picker | modelSelector={false} |
modelMode="fast" to pin a model | configure that mode as the agent's default; the prop has no replacement |
defaultMode="fast" | same — it is the agent's default mode now |
TypeScript reports these as unknown props. JavaScript does not — an ignored
modelMode leaves the chat running the agent's default mode, and a modes={[]}
that used to hide the picker now shows it, since modelSelector defaults to
true. Search your integration for all three names.
Ship this with a deployment whose /v1/chat-agents projects agent modes. An
older deployment answers without them and the picker renders empty.
Props
Everything below is optional.
Composer
| Prop | Type | Default | What it does |
|---|---|---|---|
approvalSelector | boolean | true | Approval-mode picker. full ("run without approval") only appears if the token carries the approval-mode capability. |
fileUpload | boolean | true | Paperclip and drop-anywhere-on-the-conversation. false makes the whole surface inert. |
appsButton | boolean | false | Plugins button — connect accounts before asking. Requires connectRedirectUri. |
shortcuts | Shortcut[] | none | Starter cards on an empty conversation. Off by default: each default card needs a computer-equipped agent. |
modelSelector | boolean | true | Model picker. What it offers comes from the agent; it hides itself when there is one mode and no effort levels. |
placeholder | string | "Assign a task or ask anything..." | Composer placeholder. |
interface Shortcut {
id: string
label: string
description: string
prompt: string // dropped into the composer, not sent
}
Conversation and agent
| Prop | Type | Default | What it does |
|---|---|---|---|
conversationId | string | new each mount | Resume a thread. Key the component by it if you own a thread list. |
agentName | string | person chooses | Selects the agent by slug or id, and hides the picker. Ignored when the credential already fixes the agent. |
agentLabel | string | agent's own name | Display name above replies. The cosmetic one. |
agentId | string | — | Agent UUID. Lets the composer offer that agent's apps and @ mentions. |
agentPicker | boolean | true | Shown only when the credential reaches more than one agent. |
body | Partial<StartChatRequest> | — | Extra fields on every send, read at send time. messages and conversationId excluded. |
Layout and appearance
| Prop | Type | Default | What it does |
|---|---|---|---|
header | boolean | true | The conversation's own header. |
sidePanel | boolean | true | Artifact pane (desktop) / full-screen sheet (mobile), and the control that opens it. |
theme | FronteraChatTheme | inherits | Design tokens, applied to the shell and the portal root. |
dark | boolean | OS preference | Pin the scheme. Pass it if your app has a theme toggle, or the two disagree. |
className | string | — | Added to the shell element. |
empty | ReactNode | platform greeting | Replaces the new-session screen. |
mobileMenu | ReactNode | none | Leading control in the mobile header — your session list. Nothing renders if omitted. Ignored ≥768px. |
interface FronteraChatTheme {
background?: string; surface?: string; border?: string
text?: string; muted?: string
accent?: string; accentText?: string; danger?: string
radius?: string; fontFamily?: string
}
Connect flow
| Prop | Type | What it does |
|---|---|---|
connectRedirectUri | string | Your OAuth callback page. Without it the connect card explains what is missing instead of offering a dead button. |
connectOrigin | string | Origin the callback posts from, when it differs from the redirect's. |
Events
| Prop | Type | When |
|---|---|---|
onStart | (info: { conversationId: string; runId: string }) => void | A run starts. The hook for your own thread list. |
onError | (error: Error) => void | Every transport and stream error. Wire this — without it failures are visible to the user and nowhere in your logs. |
Tokens
The browser never holds your agent key. Your server exchanges it for a
short-lived, per-user cht_ token.
Server-side only. Needs @frontera-sdk/chat and @frontera-sdk/core in your
manifest — see Install.
// POST /api/chat-token — your server
import { FronteraClient } from '@frontera-sdk/core/client'
import { exchangeChatToken } from '@frontera-sdk/chat/auth'
const frontera = new FronteraClient({
apiBaseUrl: process.env.FRONTERA_API_URL!,
// Either key kind goes here, same shape — see "Which key" below.
credential: { kind: 'apiKey', key: process.env.FRONTERA_WORKSPACE_KEY! },
})
export async function POST(req: Request) {
const user = await currentUser(req) // YOUR session
if (!user) return new Response('unauthorized', { status: 401 })
const token = await exchangeChatToken(frontera, {
externalUserId: user.id, // never from the body
ttlSeconds: 1800, // 60–86400, default 3600
capabilities: ['plugin-connect'],
})
// `apiBaseUrl` is yours to add — the exchange returns only the token and its
// expiry, and the component has no other way to learn which deployment the
// token belongs to. Without it: "No apiBaseUrl: pass one, or return it from
// your token endpoint". It is the origin the browser is about to call, so
// there is nothing secret in it.
return Response.json({ ...token, apiBaseUrl: process.env.FRONTERA_API_URL })
}
Which key
Agent key sak_… | Workspace key sk-ws-… | |
|---|---|---|
| Reaches | exactly one agent, always | every chat agent in the workspace |
| Per-user choice | none — agentId naming a different agent is 403, agentIds is 403 | agentId for one, agentIds for a subset (max 50) |
| Agent picker | never shown, nothing to pick | shown, limited to what you minted |
| If leaked | one agent | every chat agent |
| Extra requirement | — | create it with the Embedded chat purpose (Workspace settings → API Keys); a platform-access key is refused, naming its scope |
One surface, one agent → agent key, and stop there. A picker, or different agents for different customers → workspace key.
// Workspace key only — narrowing what this person's token may reach.
await exchangeChatToken(frontera, {
externalUserId: user.id,
agentId: 'support-bot', // exactly one
// or
agentIds: ['support-bot', 'billing-bot'], // a subset; the picker shows these
})
agentId and agentName both accept the agent's UUID or its slug; an unknown
one is 404, never a silent fallback. Narrowing is enforced server-side —
filtering a picker in the browser looks identical and protects nothing.
Capabilities
| Capability | Default | Opens |
|---|---|---|
| (base) | always | Chat, history, own conversations, answering approvals |
plugin-connect | on | Connecting their own account for a plugin |
schedule | off | The agent's scheduled tasks |
agent-mode | off | Work items, exchanges, handoffs |
approval-mode | off | Choosing "run without approval" |
externalUserId, capabilities, agentId and ttlSeconds are privilege
decisions. Take each from your own session — if the caller can name the user,
any signed-in person can read someone else's conversations.
Renewal is automatic, up to a 12-hour session ceiling; after that your server mints again.
Other entry points
| Import | Gives you |
|---|---|
@frontera-sdk/chat-ui/chat | FronteraChat — the whole surface |
@frontera-sdk/chat-ui/client | useChatClient, ChatClient, public types — for your own sidebar |
@frontera-sdk/chat-ui/oauth-callback | FronteraOAuthCallback — the connect callback page |
@frontera-sdk/chat-ui/new-session | buildDefaultShortcuts, Shortcut |
@frontera-sdk/chat-ui/message, /composer, /parts, /header | Compose your own layout |
@frontera-sdk/chat-ui/theme | FronteraChatTheme |
Reading the conversation from your own chrome:
import { useChatClient, type Session } from '@frontera-sdk/chat-ui/client'
const { client, getClient } = useChatClient({ tokenEndpoint: '/api/chat-token' })
useEffect(() => {
client?.listSessions({ limit: 20 }).then(p => setThreads(p.data))
}, [client])
// Share the credential instead of minting a second token for the same person:
<FronteraChat client={getClient} conversationId={openId} />
Connecting a user's own account
For plugins that act as the person (their mail, their files). Four prerequisites, all required:
- Register your callback URL with the provider (Google, Microsoft…).
- Add your own OAuth client in Frontera → Settings → Secrets. A provider only authorises redirects on a domain its owner verified, so it cannot be ours.
- Add the callback URL and your app's origin to the embed allowlist
(
POST /v1/apps/embed-allowlist, needs a workspace member withapp:configure). No default, no wildcard. - Mint tokens with
plugin-connect.
// app/oauth/callback/page.tsx
'use client'
import { FronteraOAuthCallback } from '@frontera-sdk/chat-ui/oauth-callback'
export default function Page() { return <FronteraOAuthCallback /> }
<FronteraChat
tokenEndpoint="/api/chat-token"
connectRedirectUri="https://app.example.com/oauth/callback"
appsButton
/>
Popup by default, full-page redirect when popups are blocked.
Mobile
Under 768px the component follows the platform rather than shrinking the desktop layout:
- the side panel becomes a full-screen sheet, not a column;
- the header is solid and in the flow, with the title, a summary sheet and a files list;
mobileMenurenders at the head of that bar — your session list, your component;- the bar stays on a new session, captioned "New Session".
Hazards
| Your CSS reaches in | A rule like button { padding: 8px } restyles every control in the chat. Scope yours: button:not(.fr-shell *). Portalled dialogs carry .fr-shell too. |
| One React | Two copies break hooks — Cannot read properties of null (reading 'useState'). Check npm ls react. |
| Height | .fr-shell is height: 100%. A container without height collapses it. |
| CSP | Allow your Frontera API origin in connect-src. |
Troubleshooting
| Symptom | Cause |
|---|---|
| Blank chat, no error | onError not wired |
| Nothing renders / zero height | Container has no height |
401 immediately | Token endpoint returned the wrong shape — must be { token, expiresAt } |
| Chat stops after an hour | Using token without refresh; use tokenEndpoint |
useState of null | Two Reacts |
| Controls look wrong | Host CSS styling bare elements |
| Connect button does nothing | connectRedirectUri missing, or URL not on the allowlist |
| Conversation stops with nothing to click | A parked approval — GET /v1/chat/approvals?conversationId=… with the same token |
What the browser calls
For a CSP allowlist or a proxy. All against your Frontera API origin, with the
cht_ token:
POST /v1/chat conversation stream (SSE)
POST /v1/chat-tokens/renew refresh
GET /v1/chat-agents agent picker
GET /v1/sessions, /v1/sessions/:id history
PATCH /v1/sessions/:id title, approval mode
GET /v1/chat/approvals?conversationId=… parked decisions
POST /v1/chat/approvals/:id answer one
POST /v1/chat/app-connections/start|exchange|resume
POST /v1/chat-uploads attachments
GET /v1/chat-artifacts/:id[/file|/preview|/versions…]
PATCH /v1/chat-artifacts/:id save an edit