Stand Guidebook
Chapter 12 of 12
Field guide
What to learn in this chapter
Looking for an idea? Start with the reel and the design brief. Ready to build? Copy a working client, configure your site, and follow the complete visitor protocol below. This is a supported beta integration: your developer or coding agent needs browser JavaScript, HTTP, and WebSockets, and should retest when adopting contract changes.
Need an exact capability definition, plan requirement, or limitation? Browse the Feature Reference.
From idea to implementation
Design the invitation, then the conversation.
Start with a moment your visitor cares about: choosing a plan, understanding a code example, or exploring a story. Decide what invites the question, what context the responder needs, and where the answer belongs. An unusual interface earns its place when it helps someone take that next step.
Borrow a complete example from the reel or use this page’s smaller React client. Keep the network adapter and transcript state separate from the presentation so you can change the visual design without rebuilding delivery and recovery.
Page context makes the experience useful. Supply relevant starting context in the session prompt; it is fixed at creation. When the visitor changes a selection or step, include that current context with their next question. The wizard and documentation examples show how to keep contextual questions readable in Stand’s transcript. Page navigation and annotations are implemented by your UI; they are not new server-side message types.
Build a custom Stand chat experience for my website.
My idea: [the invitation, page context, where replies appear, and next step].
Match my website’s design. Start from the closest working example at
https://github.com/standchat/examples or the client source in this guide.
Contract: https://stand.chat/guide/custom-chat-ui (Beta).
Feature scope: https://stand.chat/features/custom-chat-ui.
Use siteId demo for a prototype; use [MY_SITE_ID] on my registered domain
for my responders. Never put account credentials in browser code.
First build one complete text conversation: discovery, one-time creation,
visitor-token HTTP sends, WebSocket receive, transcript reconciliation,
safe rendering, reload/reconnect recovery, and explicit end/new-chat.
Preserve AI/human identity, handoff, notices, attribution, link cards,
and the unanswered-chat email form when emitted.
Keep pending sends; reuse clientMessageId on retry. Do not automatically
replay ambiguous creates, confirm AI writes, or resend into a new chat.
Then add my visual experience, with keyboard, mobile, and reduced-motion
support. Run every applicable check in the guide’s launch checklist.Replace the brackets with your idea and public Site ID. The examples are public-domain starting points; adapt their demo branding, content, and configuration before publishing.
Scope and requirements
Replace the visitor UI, keep the Stand conversation service.
You do not need to load stand.js or ui.js for a fully custom client. Those bundles are the supplied launcher and chat implementation, not a separately supported headless JavaScript SDK. Use the documented network contract below. For a styled inline conversation, stand-chatbox keeps the supplied widget’s full message rendering, cards, recovery, and controls while exposing CSS variables, shadow parts, and slots. Use stand-button or stand-card for a page-native invitation, or the public JavaScript API for an existing control.
The visitor API is available on all plans. Existing plan entitlements, configured skills, routing, concurrent capacity, and chat quotas still apply. Creating a session consumes chat capacity even if no visitor text has been sent; opening a local panel or running discovery does not itself create a conversation. Your team owns the custom code, hosting, accessibility, and browser testing.
In Stand, add and enable the real website in Sites and copy its site ID from the generated installation snippet. Configure a human with I chat here and availability, or enable an eligible Stand-in. Run discovery from that page: a valid site/domain discovery records installation observation, so loading the default widget is not a prerequisite. Use a separately configured site and eligible responder for a different test hostname.
To prototype before registering, use demo as the siteId for discovery and session creation. Stand Chat’s demo Stand-in then answers from any page domain. Only that Stand-in responds, and demo conversations do not appear in your account. Switch to your own site ID to test your responders, instructions, knowledge, skills, and History.
Use https://api.stand.chat for production HTTP and wss://api.stand.chat for WebSockets. Browser requests use credentials: omit; the visitor endpoints allow cross-origin requests without cookies. Allow both API origins in your Content Security Policy connect-src, and allow the image origins you actually render. Use the current absolute page URL for page, including its path; domain matching normalizes case and a leading www., but arbitrary subdomains do not match.
siteId and the responder identifiers returned by discovery are public identifiers. They are not credentials. Never put a rep/admin JWT, account password, or backend integration secret in a visitor client. A visitor token authorizes one conversation only; it does not authenticate the visitor to your own application.
Working example · Beta
Use this page as a working example.
The chat at the top of this page is a React client connected to Stand’s real website coverage. It discovers a responder on mount and starts a conversation on the first send. The two files below are its actual implementation, published at build time.
The framework-independent client handles HTTP sends, WebSocket replies, storage, and recovery. The React component renders completed text, links, identity changes, email follow-up offers, notices, and attribution. Copy both, keep the protocol handling, and change the presentation.
Adapt the example to your site
- Replace the example’s Site ID with
demoto prototype, or your registered Site ID to use your own responders. Pass the current page URL; a production Site ID does not cover an unregistered localhost or preview domain. - Remove the
getEmbedConfigimport and replace its call with{ apiBase: 'https://api.stand.chat', wsBase: 'wss://api.stand.chat' }. The client appends endpoint paths. - Update the component’s local client import. Replace this site’s Tailwind classes with your design; the client itself has no React or widget dependency.
- Implement any optional presentation you need, then run the launch checklist against your own configured site.
Example behavior and deliberate omissions
This component uses English controls and omits streamed previews, typing indicators, rich Markdown, behavior-rule triggers, and optional launcher/greeting analytics. It implements its own inline presentation without the widget JavaScript API.
One browser-side client retains the transcript, pending send, and draft across Next.js navigation. Leaving this chapter releases its socket, retries, and listeners; returning restores and reconciles the conversation. Environment-and-site-scoped sessionStorage supports same-tab reload recovery. With storage unavailable, continuity lasts only for the JavaScript lifetime.
The floating widget is hidden on this chapter and returns when you leave, with its independent conversation state preserved. Mounting this example in another app requires equivalent lifecycle handling.
If closure interrupts a send, unconfirmed text stays visible and, when storage works, survives reload. A late successful response can still confirm it. Otherwise, New chat restores it as an editable draft for the visitor to review and send. This sample clears credentials at closure without a final transcript fetch; without a canonical confirmation, delivery remains uncertain. The full contract below also describes final recovery before clearing credentials.
Uncertain first-start requests need an explicit visitor decision before another start. Retries within an active conversation reuse the original client message ID; nothing is automatically replayed into a new conversation.
// A small visitor client for the chapter 12 example. No widget globals or shared widget storage.
// REST sends provide an acknowledgement; the socket only receives live events.
export interface ChatMessage {
messageId: string
body: string
type: string
senderType: string
seq: number
clientMessageId?: string
}
export interface GuideChatState {
phase: 'loading' | 'available' | 'unavailable' | 'active' | 'ended' | 'uncertain'
connection: 'connecting' | 'online' | 'offline'
busy: boolean
error: string | null
draft: string
pending: { body: string; clientMessageId: string } | null
messages: ChatMessage[]
host: { name: string; kind: 'rep' | 'standin' | null; avatar?: string }
notice: string
poweredByUrl: string
greeting: string
followupOffered: boolean
}
export const INITIAL_CHAT_STATE: GuideChatState = {
phase: 'loading', connection: 'offline', busy: false, error: null,
draft: '', pending: null, messages: [],
host: { name: 'Stand', kind: null }, notice: '', poweredByUrl: '',
greeting: '', followupOffered: false,
}
interface StartFields { openingMessage?: string; prompt?: string; activationSource?: string; activationAnalyticsId?: string }
interface Config {
apiBase: string
wsBase: string
siteId: string
page: () => string
storage?: Storage
// Separate keys keep several clients on one site from sharing a saved chat.
storageKey?: string
// Optional session fields: a greeting recorded as the first message, hidden
// context for the responder, and attribution for your own analytics. A
// function is read when the conversation starts, so context can be current.
start?: StartFields | (() => StartFields)
// Optional: typing, standin.status and standin.delta frames, for a typing
// indicator or streamed preview. They are never part of the transcript.
onTransient?: (event: Record<string, unknown>) => void
}
type Json = Record<string, unknown>
const object = (value: unknown): Json => value && typeof value === 'object' ? value as Json : {}
const text = (value: unknown): string => typeof value === 'string' ? value : ''
const messages = (value: unknown): ChatMessage[] => (Array.isArray(value) ? value : []).filter(
(item): item is ChatMessage => Boolean(item && typeof item.messageId === 'string'
&& typeof item.body === 'string' && typeof item.type === 'string'
&& typeof item.senderType === 'string' && Number.isFinite(item.seq)),
)
class HttpError extends Error {
constructor(publicStatus: number) { super(`Request failed (${publicStatus}).`); this.status = publicStatus }
status: number
}
export class GuideChatClient {
private state = INITIAL_CHAT_STATE
private session: { sessionId: string; visitorToken: string } | null = null
private offer: Json = {}
private listeners = new Set<() => void>()
private mounted = false
private initialized = false
private epoch = 0
private socket: WebSocket | null = null
private timer: ReturnType<typeof setTimeout> | undefined
private read: AbortController | null = null
private retries = 0
private uncertain = false
private readonly key: string
private readonly api: string
constructor(private config: Config) {
this.api = config.apiBase.replace(/\/$/, '')
this.key = config.storageKey || `stand-guide-chapter-12:v1:${this.api}:${config.siteId}`
}
getSnapshot = (): GuideChatState => this.state
subscribe = (listener: () => void): (() => void) => {
this.listeners.add(listener)
listener()
return () => { this.listeners.delete(listener) }
}
private update(patch: Partial<GuideChatState>) {
this.state = { ...this.state, ...patch }
try {
this.config.storage?.setItem(this.key, JSON.stringify({
session: this.session, uncertain: this.uncertain, phase: this.state.phase,
draft: this.state.draft, pending: this.state.pending,
host: this.state.host, notice: this.state.notice,
poweredByUrl: this.state.poweredByUrl, greeting: this.state.greeting,
messages: this.state.messages.slice(-500),
}))
} catch { /* Storage can be denied or full; this client still works in memory. */ }
this.listeners.forEach(listener => listener())
}
mount(): () => void {
this.mounted = true
if (!this.initialized) {
this.initialized = true
try {
const saved = object(JSON.parse(this.config.storage?.getItem(this.key) || '{}'))
const session = object(saved.session)
if (text(session.sessionId) && text(session.visitorToken)) {
this.session = { sessionId: text(session.sessionId), visitorToken: text(session.visitorToken) }
}
this.uncertain = saved.uncertain === true && !this.session
const pending = object(saved.pending)
const host = object(saved.host)
this.state = { ...INITIAL_CHAT_STATE,
phase: !this.session && saved.phase === 'ended' ? 'ended' : 'loading',
draft: text(saved.draft), messages: messages(saved.messages),
pending: (this.session || saved.phase === 'ended') && text(pending.body) && text(pending.clientMessageId)
? { body: text(pending.body), clientMessageId: text(pending.clientMessageId) } : null,
host: { name: text(host.name) || 'Stand', avatar: text(host.avatar),
kind: host.kind === 'rep' || host.kind === 'standin' ? host.kind : null },
notice: text(saved.notice), poweredByUrl: text(saved.poweredByUrl), greeting: text(saved.greeting),
}
} catch { /* Ignore corrupt or unavailable storage. */ }
this.update({})
}
const epoch = ++this.epoch
if (!this.state.busy && this.state.phase !== 'ended') void this.retry()
return () => {
if (epoch !== this.epoch) return
this.mounted = false
++this.epoch
this.disconnect()
this.read?.abort()
// Mutations intentionally finish: a create response still needs saving.
}
}
setDraft = (draft: string) => { this.update({ draft }) }
private async request(path: string, body?: Json, method = body ? 'POST' : 'GET', signal?: AbortSignal): Promise<Json> {
const timeout = new AbortController()
const abort = () => timeout.abort()
const timer = setTimeout(abort, 15000)
signal?.addEventListener('abort', abort, { once: true })
if (signal?.aborted) abort()
try {
const response = await fetch(this.api + path, {
method, credentials: 'omit', signal: timeout.signal,
headers: {
...(body ? { 'Content-Type': 'application/json' } : {}),
...(this.session ? { Authorization: `Bearer ${this.session.visitorToken}` } : {}),
},
...(body ? { body: JSON.stringify(body) } : {}),
})
if (!response.ok) throw new HttpError(response.status)
return object(await response.json())
} finally {
clearTimeout(timer)
signal?.removeEventListener('abort', abort)
}
}
private path(suffix = '') { return `/v1/sessions/${encodeURIComponent(this.session!.sessionId)}${suffix}` }
private merge(incoming: ChatMessage[]) {
const byId = new Map(this.state.messages.map(message => [message.messageId, message]))
incoming.forEach(message => byId.set(message.messageId, message))
const transcript = [...byId.values()].sort((a, b) => a.seq - b.seq)
let host = this.state.host
let followupOffered = false
for (const message of transcript) {
if (message.senderType === 'rep') followupOffered = false
if (message.type !== 'system-card' && message.senderType !== 'system-card') continue
try {
const card = object(JSON.parse(message.body))
if (card.cardType === 'handoff' || card.cardType === 'human-transfer') {
host = { name: text(card.repName) || host.name, avatar: text(card.repAvatar), kind: 'rep' }
followupOffered = false
} else if (card.cardType === 'session-start' || card.cardType === 'standin-takeover') {
host = { name: text(card.standinName) || host.name, avatar: text(card.standinAvatar) || host.avatar, kind: 'standin' }
followupOffered = false
} else if (card.cardType === 'rep-followup-offer') followupOffered = true
else if (card.cardType === 'rep-followup-confirmation' || card.cardType === 'session-end') followupOffered = false
} catch { /* Unknown/malformed cards never execute code. */ }
}
const pending = this.state.pending
this.update({ messages: transcript, host, followupOffered,
pending: pending && transcript.some(m => m.clientMessageId === pending.clientMessageId) ? null : pending })
}
private applySession(data: Json) {
if (data.sessionId !== this.session?.sessionId) throw new Error('Unexpected session response')
const participant = (Array.isArray(data.participants) ? data.participants : []).map(object).find(p => p.isRep === true)
if (participant) this.update({ host: { ...this.state.host,
name: text(participant.name) || this.state.host.name,
avatar: text(participant.avatar) || this.state.host.avatar } })
this.merge(messages(data.messages))
if (data.status !== 'active') this.finish()
else this.update({ phase: 'active', error: null })
}
private finish(error: string | null = null) {
this.disconnect()
this.read?.abort()
this.session = null
this.uncertain = false
// Keep unresolved text and its ID: an in-flight HTTP response may still
// confirm it after closure. Otherwise the visitor can copy or review it.
this.update({ phase: 'ended', connection: 'offline', followupOffered: false, error })
}
private handleFailure(error: unknown, fallback: string) {
if (error instanceof HttpError && [401, 403, 404].includes(error.status) && this.session) {
this.finish('This conversation is no longer available. Start a new chat to continue.')
} else this.update({ error: fallback })
}
retry = async () => {
if (!this.mounted || this.state.busy || this.state.phase === 'ended') return
this.disconnect()
this.read?.abort()
const read = new AbortController()
this.read = read
const epoch = this.epoch
if (this.uncertain) {
this.update({ phase: 'uncertain', error: 'The previous start could not be confirmed. It may have created a chat. Choose New chat to try again.' })
return
}
this.update({ error: null, ...(this.session ? { connection: 'connecting' } : { phase: 'loading' }) })
try {
if (this.session) {
const data = await this.request(this.path('?messageLimit=500'), undefined, 'GET', read.signal)
if (!this.mounted || epoch !== this.epoch || read.signal.aborted) return
this.applySession(data)
if (this.session) this.connect(epoch)
} else {
const query = new URLSearchParams({ siteId: this.config.siteId, page: this.config.page(), greetingsEnabled: 'false' })
const offer = await this.request(`/v1/reps/find?${query}`, undefined, 'GET', read.signal)
if (!this.mounted || epoch !== this.epoch || read.signal.aborted) return
this.offer = offer
const available = offer.available === true && Boolean(text(offer.standinProfileId) || text(offer.repId))
this.update({ phase: available ? 'available' : 'unavailable',
host: { name: text(offer.repName) || 'Stand', avatar: text(offer.avatar), kind: offer.responderType === 'standin' ? 'standin' : offer.responderType === 'rep' ? 'rep' : null },
notice: text(offer.sensitiveNoticeText), poweredByUrl: text(offer.poweredByUrl), greeting: '' })
}
} catch (error) {
if (!this.mounted || epoch !== this.epoch || read.signal.aborted) return
this.handleFailure(error, 'Could not connect. Your draft is saved; try again.')
if (this.session) this.scheduleReconnect()
else if (this.getSnapshot().phase !== 'ended') this.update({ phase: 'unavailable' })
}
}
private disconnect() {
clearTimeout(this.timer)
this.timer = undefined
if (this.socket) {
this.socket.onclose = null
this.socket.onmessage = null
this.socket.onerror = null
this.socket.close()
this.socket = null
}
}
private scheduleReconnect() {
if (!this.mounted || !this.session || this.timer) return
this.update({ connection: 'offline' })
if (this.retries >= 5) {
this.update({ error: 'Connection paused. Retry connection to check for replies.' })
return
}
const delay = Math.min(1000 * 2 ** this.retries++, 15000) + Math.random() * 300
this.timer = setTimeout(() => { this.timer = undefined; void this.retry() }, delay)
}
private connect(epoch: number) {
if (!this.mounted || !this.session || epoch !== this.epoch) return
// Use the configured trusted origin, not an arbitrary URL from an event.
const url = new URL(`${this.config.wsBase.replace(/\/$/, '')}${this.path().replace('/v1/sessions/', '/ws/sessions/')}`)
url.searchParams.set('token', this.session.visitorToken)
const socket = new WebSocket(url)
this.socket = socket
const current = () => this.mounted && epoch === this.epoch && this.socket === socket && Boolean(this.session)
this.timer = setTimeout(() => {
this.timer = undefined
if (current()) { this.disconnect(); this.scheduleReconnect() }
}, 10000)
socket.onmessage = event => {
if (!current()) return
let data: Json
try { data = object(JSON.parse(event.data)) } catch { return }
if (data.sessionId && data.sessionId !== this.session?.sessionId) return
if (data.type === 'connected') {
clearTimeout(this.timer)
this.timer = undefined
this.update({ connection: 'online', error: null })
// Subscribe first, then merge a snapshot to cover missed messages.
void this.request(this.path('?messageLimit=500'), undefined, 'GET', this.read?.signal).then(snapshot => {
if (current()) { this.applySession(snapshot); this.retries = 0 }
}).catch(error => {
if (!current()) return
this.handleFailure(error, 'Could not refresh replies. Reconnecting to recover.')
this.disconnect()
this.scheduleReconnect()
})
} else if (data.type === 'session.closed') this.finish()
else if (data.type === 'typing' || data.type === 'standin.status' || data.type === 'standin.delta') {
this.config.onTransient?.(data)
} else {
const incoming = messages([data])
if (incoming.length) this.merge(incoming) // Ignore unknown frames.
}
}
socket.onclose = () => {
if (current()) { this.disconnect(); this.scheduleReconnect() }
}
socket.onerror = () => { /* onclose drives bounded recovery. */ }
}
send = async () => {
if (this.state.busy) return
const body = this.state.pending?.body || this.state.draft.trim()
if (!body || !['available', 'active'].includes(this.state.phase)) return
this.update({ busy: true, error: null })
let recover = false
try {
if (!this.session) {
const start = typeof this.config.start === 'function' ? this.config.start() : this.config.start
this.uncertain = true
this.update({ draft: body }) // Persist an ambiguous-create marker before sending.
const data = await this.request('/v1/sessions', {
siteId: this.config.siteId, page: this.config.page(), initialMessage: body,
...(text(this.offer.standinProfileId) ? { standinProfileId: this.offer.standinProfileId } : { repId: this.offer.repId }),
includeOpeningGreeting: Boolean(start?.openingMessage), showId: this.offer.showId,
...start,
visitorLanguage: navigator.language, visitorLanguages: navigator.languages,
pageLanguage: document.documentElement.lang,
})
if (!text(data.sessionId) || !text(data.visitorToken)) throw new Error('Incomplete session credentials')
this.session = { sessionId: text(data.sessionId), visitorToken: text(data.visitorToken) }
this.uncertain = false
this.update({ draft: '', pending: null })
this.applySession(data)
if (this.mounted && this.session) this.connect(this.epoch)
} else {
const pending = this.state.pending || { body, clientMessageId: crypto.randomUUID() }
this.update({ pending, draft: '' })
const data = await this.request(this.path('/messages'), { ...pending, type: 'text' })
const accepted = messages([data])
if (!accepted.length) throw new Error('Incomplete message response')
this.merge(accepted)
}
} catch (error) {
if (!this.session && this.uncertain) {
// A definite rejection can be retried after fresh discovery; a lost
// response must never trigger an automatic second create.
this.uncertain = !(error instanceof HttpError && error.status >= 400 && error.status < 500)
this.update({ phase: this.uncertain ? 'uncertain' : 'unavailable',
error: this.uncertain ? 'We could not confirm whether the chat started. Choose New chat to try again.' : 'Chat could not start. Check availability and try again.' })
} else {
this.handleFailure(error, this.state.phase === 'ended'
? 'Delivery was not confirmed before the chat ended. Choose New chat to review your saved message before sending again.'
: 'Message not confirmed. Retry message to check delivery without sending a duplicate.')
recover = error instanceof HttpError && error.status === 409
}
} finally {
this.update({ busy: false })
// Returning during an in-flight send skips mount recovery until it settles.
if (this.mounted && this.session && (recover || (!this.socket && !this.timer))) void this.retry()
}
}
end = async () => {
// A close request must outlive an in-flight mutation, even after unmount.
// Creation may still return credentials that we need in order to end it.
while (this.state.busy) {
await new Promise<void>(resolve => {
const unsubscribe = this.subscribe(() => {
if (this.state.busy) return
unsubscribe()
resolve()
})
})
}
if (!this.session) return
this.update({ busy: true, error: null })
try { await this.request(this.path(), undefined, 'DELETE'); this.finish() }
catch (error) { this.handleFailure(error, 'Could not confirm the end of this chat. Retry End chat or reconnect.') }
finally { this.update({ busy: false }) }
}
newChat = async () => {
if (this.state.busy || this.session) return
this.uncertain = false
this.retries = 0
// Starting over only prepares an editable draft. It never replays a send
// into a different conversation or reuses that conversation's message ID.
const pendingBody = this.state.pending?.body
const draft = pendingBody && pendingBody !== this.state.draft
? [pendingBody, this.state.draft].filter(Boolean).join('\n\n')
: this.state.draft || pendingBody || ''
this.update({ ...INITIAL_CHAT_STATE, draft })
await this.retry()
}
submitEmail = async (email: string) => {
if (!this.session || !this.state.followupOffered || this.state.busy) return
this.update({ busy: true, error: null })
try { await this.request(this.path('/followup-request'), { email }); this.finish('Your follow-up request was sent.') }
catch (error) {
if (error instanceof HttpError && error.status === 409) {
this.update({ followupOffered: false, error: 'That offer has changed. Retry connection to refresh the conversation.' })
} else this.handleFailure(error, 'Could not submit your email. Check it and try again.')
} finally { this.update({ busy: false }) }
}
trackLinkClick = async (messageId: string, url: string) => {
if (!this.session) return
try { await this.request(this.path('/link-clicks'), { messageId, url }) }
catch { /* Best-effort tracking never blocks navigation. */ }
}
}
Framework-independent visitor protocol, storage, recovery, and connection lifecycle.
import { useEffect, useRef, useState } from 'react'
import { getEmbedConfig } from '@/lib/embed'
import { GuideChatClient, INITIAL_CHAT_STATE, type ChatMessage } from '@/lib/guideChatClient'
// This module is imported only by chapter 12. Nothing starts until it mounts.
// Keep one client across Next.js navigation; mount() owns its live connection.
let chapterClient: GuideChatClient | undefined
function getChapterClient() {
if (!chapterClient) {
let storage: Storage | undefined
try { storage = window.sessionStorage } catch { /* In-memory chat still works. */ }
const { apiBase, wsBase } = getEmbedConfig()
chapterClient = new GuideChatClient({
apiBase,
wsBase,
siteId: 'f8592bc6-af7f-4dca-8c7f-65e9da38da49',
page: () => window.location.href,
storage,
})
}
return chapterClient
}
const buttonClass = 'rounded-md bg-aqua-2 px-3 py-2 text-sm font-bold text-white transition hover:bg-aqua-1 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-aqua-2 disabled:cursor-not-allowed disabled:bg-snow-2 disabled:text-ash-4'
const secondaryClass = 'rounded px-1 py-1 text-sm font-semibold text-aqua-2 underline underline-offset-4 hover:text-aqua-1 focus-visible:outline focus-visible:outline-2 focus-visible:outline-aqua-2 disabled:cursor-not-allowed disabled:text-ash-5'
export function InlineGuideChat() {
const [state, setState] = useState(INITIAL_CHAT_STATE)
const [email, setEmail] = useState('')
const client = useRef<GuideChatClient | undefined>(undefined)
const transcript = useRef<HTMLDivElement>(null)
const keepAtBottom = useRef(true)
const form = useRef<HTMLFormElement>(null)
useEffect(() => {
const current = getChapterClient()
client.current = current
const unsubscribe = current.subscribe(() => setState(current.getSnapshot()))
const unmount = current.mount()
return () => {
unsubscribe()
unmount()
client.current = undefined
}
}, [])
useEffect(() => {
if (transcript.current && keepAtBottom.current) {
transcript.current.scrollTop = transcript.current.scrollHeight
}
}, [state.messages, state.pending])
const canCompose = state.phase === 'available' || state.phase === 'active'
const connectionLabel = state.phase === 'active'
? state.connection === 'online' ? 'Connected' : state.connection === 'connecting' ? 'Connecting…' : 'Connection interrupted'
: state.phase === 'available' ? 'Available' : state.phase === 'loading' ? 'Checking availability…' : state.phase === 'ended' ? 'Chat ended' : state.phase === 'uncertain' ? 'Start not confirmed' : 'Currently unavailable'
const attribution = safeUrl(state.poweredByUrl)
return (
<section aria-label="Live custom chat example" className="ph-no-capture min-w-0 overflow-hidden rounded-xl bg-white text-ash-2 shadow-2xl shadow-black/30 ring-1 ring-white/10">
<div className="border-b border-snow-2 p-5">
<p className="text-xs font-bold uppercase tracking-[0.18em] text-aqua-2">Live custom UI · Beta</p>
<h2 className="mt-2 font-display text-2xl font-bold leading-tight">A real conversation, your own UI.</h2>
<p className="mt-2 text-sm leading-6 text-ash-4">Talk with the Stand team or their AI Stand-in. Sending your first message starts a real chat.</p>
<a href="#live-example-source" className={`${secondaryClass} mt-2 inline-block`}>View the code</a>
</div>
<div className="flex items-center justify-between gap-3 border-b border-snow-2 bg-snow-5 px-5 py-3">
<div className="min-w-0">
<p className="break-words text-sm font-bold">{state.host.name || 'Stand team'}</p>
<p className="mt-0.5 text-xs text-ash-4">{state.host.kind === 'standin' ? 'AI Stand-in' : state.host.kind === 'rep' ? 'Human rep' : 'Stand chat'}</p>
</div>
<p role="status" className="text-right text-xs text-ash-4">{connectionLabel}</p>
</div>
<div
ref={transcript}
role="log"
aria-label="Conversation messages"
aria-live="polite"
aria-relevant="additions text"
onScroll={() => {
const panel = transcript.current
if (panel) keepAtBottom.current = panel.scrollHeight - panel.scrollTop - panel.clientHeight < 48
}}
className="h-64 space-y-3 overflow-y-auto overscroll-contain p-5"
>
{state.messages.length === 0 && (
<p className="whitespace-pre-wrap break-words rounded-lg bg-snow-4 p-4 text-sm leading-6 text-ash-3">
{state.phase === 'unavailable' ? 'Nobody is available for a new chat right now. You can check again in a moment.' : state.greeting || 'Ask us about Stand or building your own chat interface.'}
</p>
)}
{state.messages.map((message) => (
<Message key={message.messageId} message={message} onLinkClick={(url) => void client.current?.trackLinkClick(message.messageId, url)} />
))}
{state.pending && (
<div className="ml-6 rounded-lg bg-aqua-3/15 p-3 text-sm leading-6">
<p className="text-xs font-bold text-ash-4">You · {state.busy ? 'Sending…' : 'Delivery not confirmed'}</p>
<p className="whitespace-pre-wrap break-words">{state.pending.body}</p>
</div>
)}
</div>
<div className="space-y-3 border-t border-snow-2 p-5">
{state.error && <p role="alert" className="rounded-md bg-gold-3/15 p-3 text-sm leading-6 text-ash-3">{state.error}</p>}
{state.phase === 'ended' && <p className="text-sm leading-6 text-ash-4">This conversation has ended. You can start a new one below.</p>}
{state.phase === 'uncertain' && <p className="text-sm leading-6 text-ash-4">The first request may have reached Stand. Starting again could create another conversation; your draft is kept.</p>}
{state.notice && <p className="whitespace-pre-wrap break-words text-xs leading-5 text-ash-4">{state.notice}</p>}
{canCompose && (
<form ref={form} onSubmit={(event) => { event.preventDefault(); keepAtBottom.current = true; void client.current?.send() }}>
<label htmlFor="guide-chat-message" className="text-sm font-semibold">Your message</label>
<textarea
id="guide-chat-message"
rows={2}
value={state.draft}
onChange={(event) => client.current?.setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault()
if (!state.busy && !state.pending && state.draft.trim()) form.current?.requestSubmit()
}
}}
disabled={state.busy || Boolean(state.pending)}
placeholder="What would you like to know?"
className="mt-2 block w-full resize-y rounded-md border border-snow-1 bg-white px-3 py-2 text-sm leading-6 text-ash-2 placeholder:text-ash-5 focus:border-aqua-2 focus:outline-none focus:ring-2 focus:ring-aqua-3/30 disabled:bg-snow-4"
/>
<div className="mt-3 flex items-center justify-between gap-3">
<button type="submit" disabled={state.busy || Boolean(state.pending) || !state.draft.trim()} className={buttonClass}>Send message</button>
{state.phase === 'active' && <button type="button" onClick={() => void client.current?.end()} disabled={state.busy} className={secondaryClass}>End chat</button>}
</div>
</form>
)}
{state.followupOffered && state.phase === 'active' && (
<form className="rounded-lg bg-snow-4 p-3" onSubmit={(event) => { event.preventDefault(); void client.current?.submitEmail(email) }}>
<label htmlFor="guide-chat-email" className="text-sm font-semibold">Your email</label>
<p id="guide-chat-email-help" className="mt-1 text-xs leading-5 text-ash-4">Leave your email for the team to follow up on this conversation.</p>
<input id="guide-chat-email" type="email" autoComplete="email" required disabled={state.busy} value={email} onChange={(event) => setEmail(event.target.value)} aria-describedby="guide-chat-email-help" className="mt-2 block w-full rounded-md border border-snow-1 px-3 py-2 text-sm focus:border-aqua-2 focus:outline-none focus:ring-2 focus:ring-aqua-3/30" />
<button type="submit" disabled={state.busy || !email.trim()} className={`${buttonClass} mt-3`}>Request follow-up</button>
</form>
)}
{state.pending && !state.busy && state.phase === 'active' && <button type="button" onClick={() => void client.current?.send()} className={secondaryClass}>Retry message</button>}
{!state.pending && !state.busy && (state.phase === 'unavailable' || (Boolean(state.error) && state.phase !== 'ended' && state.phase !== 'uncertain') || (state.phase === 'active' && state.connection === 'offline')) && <button type="button" onClick={() => void client.current?.retry()} className={secondaryClass}>Retry connection</button>}
{(state.phase === 'ended' || state.phase === 'uncertain') && <button type="button" onClick={() => { setEmail(''); keepAtBottom.current = true; void client.current?.newChat() }} disabled={state.busy} className={buttonClass}>New chat</button>}
<div className="flex flex-wrap items-center justify-between gap-2 pt-1 text-xs leading-5 text-ash-4">
<span>Replies appear when complete.</span>
{attribution && <a href={attribution} target="_blank" rel="noopener noreferrer" className="font-semibold text-aqua-2 underline underline-offset-2">Powered by Stand</a>}
</div>
</div>
</section>
)
}
function safeUrl(value: unknown): string | null {
if (typeof value !== 'string') return null
try {
const url = new URL(value)
return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : null
} catch { return null }
}
function Message({ message, onLinkClick }: { message: ChatMessage; onLinkClick: (url: string) => void }) {
if (message.type === 'system-prompt' || message.senderType === 'system-prompt') return null
if (message.type === 'text' || message.type === 'standin-idle-prompt') {
return (
<div className={`${message.senderType === 'visitor' ? 'ml-6 bg-aqua-3/15' : 'mr-6 bg-snow-4'} rounded-lg p-3 text-sm leading-6`}>
<p className="text-xs font-bold text-ash-4">{message.senderType === 'visitor' ? 'You' : message.senderType === 'standin' ? 'AI Stand-in' : 'Stand team'}</p>
<p className="whitespace-pre-wrap break-words">{message.body}</p>
</div>
)
}
let card: Record<string, unknown>
try {
const parsed: unknown = JSON.parse(message.body)
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
card = parsed as Record<string, unknown>
} catch { return null }
if (message.type === 'link-card') {
const url = safeUrl(card.url)
if (!url) return null
return <a href={url} target="_blank" rel="noopener noreferrer" onClick={() => onLinkClick(url)} className="mr-6 block rounded-lg border border-aqua-3 bg-white p-3 text-sm leading-6 text-aqua-2 hover:bg-snow-5"><span className="break-words font-bold underline underline-offset-2">{typeof card.title === 'string' ? card.title : url}</span>{typeof card.description === 'string' && <span className="mt-1 block whitespace-pre-wrap break-words text-ash-3">{card.description}</span>}</a>
}
if (message.type !== 'system-card') return null
const labels: Record<string, string> = {
'session-start': 'Conversation started with an AI Stand-in.',
handoff: 'A human rep has joined the chat.',
'human-transfer': 'Your conversation has moved to a human rep.',
'standin-takeover': 'An AI Stand-in has joined the chat.',
'session-end': 'Conversation ended.',
'rep-followup-offer': 'The team is offering to follow up by email.',
'rep-followup-confirmation': 'Your follow-up request has been received.',
}
if (typeof card.cardType !== 'string' || !Object.prototype.hasOwnProperty.call(labels, card.cardType)) return null
return <p className="whitespace-pre-wrap break-words rounded-md border border-snow-2 p-3 text-xs leading-5 text-ash-4">{labels[card.cardType]}{typeof card.message === 'string' ? ` ${card.message}` : ''}</p>
}
React presentation, accessible controls, safe message rendering, and chapter-scoped mounting.
1. Discovery
Ask Stand which responder is available.
The protocol calls a persisted message canonical, a local pending bubble optimistic, and a short-lived update such as typing transient. Reconcile means confirming or replacing a pending bubble with its server message. Read all six lifecycle sections together before implementing.
GET /v1/reps/find is public. Send siteId and page, plus greetingsEnabled=true if you render the supplied greeting (otherwise false). Pass a previously rendered greetingVariantId only when reusing that greeting. URL-encode query values with URLSearchParams.
A successful response is either { available: false } or an available responder with the fields below. Unavailable can mean no coverage, exhausted capacity, a disabled site, an ineligible page, or temporarily unavailable routing data. Treat it as a normal UI state. Do not create a session or invent a responder ID when unavailable.
Discovery is a point-in-time offer, not a reservation. Create can still return 409, and a requested human can be replaced by an eligible teammate or Stand-in. The session response, its initial system cards, and later handoff events are authoritative for the assigned identity.
const api = 'https://api.stand.chat';
const siteId = 'YOUR_SITE_ID';
const page = window.location.href;
const query = new URLSearchParams({
siteId, page, greetingsEnabled: 'false'
});
const discoveryResponse = await fetch(api + '/v1/reps/find?' + query, {
credentials: 'omit'
});
if (!discoveryResponse.ok) throw new Error('Discovery failed: ' + discoveryResponse.status);
const offer = await discoveryResponse.json();
// Show an unavailable state unless offer.available is true.
// Preserve this response for create and attribution; never fabricate IDs.Replace YOUR_SITE_ID and run on that registered website, or use demo to try the protocol with Stand Chat’s demo Stand-in before registering. These excerpts use top-level await: put them in a JavaScript module or an async function. This minimal client does not display a greeting; request greetingsEnabled: true only if yours does. Poll sparingly after user action or a bounded retry delay.
2. Start
Create once, then use the returned visitor token.
POST /v1/sessions with Content-Type: application/json and no Authorization header. Required context is page and siteId, plus exactly one responder identifier from discovery: repId or standinProfileId. The server revalidates the site, path, responder, and capacity. Do not send a fabricated visitor ID.
Serialize creation in the client so a double click cannot start two conversations. POST /v1/sessions has no client idempotency key. Do not automatically replay a creation request after an ambiguous network failure: it may already have created a conversation that counts against the chat quota. Present a retry decision to the visitor instead of a background retry loop.
The response contains sessionId, status, createdAt, lastActivityAt, closedAt, closedBy, participants, messages, page, siteId, websocketUrl, visitorToken, and conversationLanguage. Timestamps are ISO 8601 strings; absent optional values may be null. Require a non-empty sessionId and visitorToken before considering creation successful.
Save sessionId and visitorToken together in storage scoped to this API environment and site. Preserve the discovery notice and attribution URL with that conversation too: session reads do not return sensitiveNoticeText or poweredByUrl. Handle denied/full storage by continuing in memory.
The visitor participant is participants.find(p => !p.isRep); its userId comes from the server. Host participants have isRep: true and can represent a human or Stand-in. The responderType from discovery is only a preliminary identity. The creation response has no responderType field: apply its session-start card and later takeover/handoff cards to update AI/human disclosure. isRep alone does not mean human. Host presentation includes name, brand, title, and avatar when available.
Render the returned messages as the canonical initial transcript. If initialMessage was included in create, do not send it again. Remove or reconcile your optimistic initial bubble against that snapshot; create does not accept clientMessageId. A Stand-in session includes a persisted session-start card, and the first AI turn can begin before the socket opens, so recover the transcript again after connecting.
if (!offer.available || !(offer.standinProfileId || offer.repId)) {
throw new Error('No responder available');
}
const createResponse = await fetch(api + '/v1/sessions', {
method: 'POST',
credentials: 'omit',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
page: window.location.href,
siteId,
...(offer.standinProfileId
? { standinProfileId: offer.standinProfileId }
: { repId: offer.repId }),
initialMessage: 'Can you help me choose?',
includeOpeningGreeting: false,
showId: offer.showId,
visitorLanguage: navigator.language,
visitorLanguages: navigator.languages,
pageLanguage: document.documentElement.lang
})
});
if (!createResponse.ok) throw new Error('Start failed: ' + createResponse.status);
const session = await createResponse.json();
if (!session.sessionId || !session.visitorToken) {
throw new Error('Incomplete session credentials');
}
// Preserve credentials; render session.messages; connect and recover.
// This example intentionally does not persist an opening greeting.The snippets share api, siteId, offer, and session as integration state. Wire the result to your UI reducer and the recovery requirements below; they are not a complete UI.
3. HTTP contract
Use session-scoped authorization for every later request.
Send Authorization: Bearer <visitorToken> on all requests in this table. Use JSON request bodies where specified and credentials: omit. Treat the token as opaque; do not parse it or log it. Production visitor tokens currently have a fixed 24-hour lifetime from issuance; activity does not refresh them and there is no visitor refresh endpoint. A later session read does not reissue the token. Closing a session does not itself revoke its token: an authenticated read can return its closed archive until the token expires.
On closure, stop sends and reconnects. If a send remains unconfirmed, a final authenticated read may recover its accepted message; perform that read before clearing credentials. Then discard local credentials while retaining unresolved text. A failed final read does not prove rejection.
Closing a UI panel or disconnecting its socket is not ending the chat. Only send DELETE when the visitor explicitly ends the conversation. With usable credentials, end or confirm the closure of the current chat before offering a new one; merely discarding credentials would leave it running. If authorization is unusable, explain that you cannot confirm closure and let the visitor explicitly start again through discovery/create. The server remains authoritative for token validity and session state.
const pending = {
body: 'What is included?',
type: 'text',
clientMessageId: crypto.randomUUID()
};
async function sendPending() {
const response = await fetch(
api + '/v1/sessions/' + encodeURIComponent(session.sessionId) + '/messages',
{
method: 'POST', credentials: 'omit',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + session.visitorToken
},
body: JSON.stringify(pending)
}
);
if (!response.ok) throw new Error('Send failed: ' + response.status);
return response.json(); // Merge by messageId/clientMessageId.
}
// Retry sendPending with this same pending object after recovery.
// Create a NEW ID only for a new logical visitor message.REST sends can be combined with WebSocket receive. Both transports may deliver the same canonical message; deduplicate it.
4. Real-time protocol
Distinguish persisted messages from transient events.
Connect to the session response websocketUrl with ?token=<URL-encoded visitorToken>. Validate its scheme and expected API host before attaching credentials. In production use wss:. The browser WebSocket API cannot set an Authorization header. Keep token-bearing socket URLs out of analytics, error reporting, and access logs under your control.
All frames are JSON. Canonical messages and terminal state changes must be handled even if you omit typing indicators or streamed previews. Unused transient events can be ignored safely. The server acknowledges an established subscription with { type: "connected", sessionId }, but live messages or a terminal close can arrive before that acknowledgement. Process frames immediately. After connected, fetch a session snapshot while also receiving events and merge by messageId and seq. This covers the interval between create or a previous snapshot and the socket subscription. There is no WebSocket replay cursor or exactly-once delivery guarantee.
Client-to-server sends use type: "message" and messageType: "text". Server-to-client persisted messages use event: "message" and type as the content type, for example text or system-card. Do not dispatch incoming chat solely on type === "message". REST Message objects have the same canonical fields but do not need the event wrapper.
const socketUrl = new URL(session.websocketUrl);
if (socketUrl.protocol !== 'wss:' || socketUrl.host !== new URL(api).host) {
throw new Error('Unexpected WebSocket origin');
}
socketUrl.searchParams.set('token', session.visitorToken);
const socket = new WebSocket(socketUrl);
// Register message/close/error handlers immediately.
// Send only when socket.readyState === WebSocket.OPEN:
const visitorFrame = {
type: 'message', messageType: 'text',
body: 'Can a person join?', clientMessageId: crypto.randomUUID()
};
// socket.send(JSON.stringify(visitorFrame));
// socket.send(JSON.stringify({ type: 'typing', active: true }));
// Send active:false on submit, blur, or an idle timeout.Keep a pending send until a canonical response/echo confirms it. A successful socket.send is not a delivery acknowledgement.
{
"event": "message",
"type": "text",
"messageId": "message-123",
"sessionId": "session-123",
"senderId": "visitor-123",
"senderType": "visitor",
"body": "Can a person join?",
"sentAt": "2026-09-15T12:00:00Z",
"seq": 3,
"clientMessageId": "client-message-123"
}IDs are opaque strings. seq orders persisted transcript messages; sentAt is not a deduplication key. Optional fields may be null or absent.
5. Rendering and skills
Keep the meaning of each message while changing its appearance.
body is a string. Text and standin-idle-prompt messages contain visitor-facing text. link-card and system-card bodies contain serialized JSON: parse defensively, dispatch by message type, then check cardType for system cards. Link-card bodies have no cardType. Never render arbitrary message HTML. Plain text is acceptable; if supporting Markdown, escape HTML and validate links. Hide any message whose type or senderType is system-prompt on both live and restored paths.
Preserve truthful AI/human identity, the configured sensitive-data notice when returned, and Powered by Stand attribution when poweredByUrl is returned. Apply the account’s branding entitlement instead of assuming a custom layout removes it. Unknown or malformed cards should not break the transcript or expose raw metadata.
AI skills still run on Stand. Link sharing and human handoff need the client behavior below. OpenAPI write confirmation is a later visitor-authored text message consisting of confirm or confirmed (case-insensitive; surrounding whitespace and trailing periods/exclamation marks are ignored). A custom confirmation button may send that text only after an explicit visitor click on the displayed action; never auto-confirm. There is no browser integration-secret or confirmation-token endpoint.
The current visitor send API is text-only. File uploads, image attachments, arbitrary HTML messages, custom server-side message types, rep administration, and private conversation labels are not capabilities of this interface. A decorative avatar in your UI does not add a media-message API.
6. Recovery and lifecycle
Treat the server transcript as the source of truth.
On page reload, try a saved session before fresh discovery. GET its details with its token. For active sessions, restore the authoritative participants, conversationLanguage, and canonical transcript, then connect. After connected, fetch again and merge any concurrent socket messages. Restore AI/human identity by applying the transcript’s start/takeover/handoff cards as well as participant data. Restore the saved discovery notice and attribution with that session; a fresh availability offer describes a potential new conversation and must not overwrite the current participants.
On unexpected disconnect, retain pending sends and show a reconnecting state. Use bounded exponential backoff with jitter; recover through HTTP before reconnecting and after connected. Stop when the server says the session is closed, credentials are rejected, or the visitor ends it. Do not create a new conversation merely because a socket closed.
Use messageId for persisted-message deduplication and clientMessageId for optimistic reconciliation. A duplicate WebSocket send can be suppressed without another echo; while active, use REST retry with the same ID or read the transcript to resolve uncertain delivery. If the session has closed, you can read its transcript before discarding a still-valid token. Closure alone does not prove an unresolved send was accepted; keep its text available to copy without automatically sending it into a new chat.
A capped snapshot is the newest 200 messages by default (up to 500), not a guarantee of the full history; retain already-known canonical messages during in-page recovery rather than deleting them when absent from a capped snapshot.
HTTP failures or socket closure alone do not prove a message was rejected. Preserve the visitor’s draft and explicit pending/failed state. Never silently retry with a new message ID. Discard transient AI deltas on recovery and replace them with canonical messages; do not persist partial streamed previews as transcript entries.
Inactivity is server-controlled. Current defaults close idle human chats after 30 minutes; AI chats receive an idle check-in after 5 minutes and close after another idle interval. Do not implement a client timer that claims the conversation has ended before the server does. Session status is active or closed; a closedByType of other may cover older or unattributed closures.
Client states to implement
- Discovering → available / unavailable / recoverable discovery error.
- Creating → active / explicit start error; suppress duplicate create requests.
- Active → connecting / connected / reconnecting, with pending and confirmed messages.
- Active → handoff / AI takeover / follow-up offer without opening a second session.
- Closed or unusable credentials → ended state and an explicit new-chat action.
- Storage unavailable → in-memory operation; no recovery promise after a document reload.
7. Errors
Handle HTTP status before relying on error wording.
Prefer error.message in the documented { error: { code, message, timestamp } } response. Some rejection paths return message or detail instead; intermediaries can return no JSON at all. Parse defensively, provide a safe fallback, and do not match human-readable text to drive session state.
The API does not promise a stable per-visitor session-creation rate allowance. Deployment throttles and service failures can occur. Treat 429 and temporary 5xx as recoverable for reads, use bounded backoff and Retry-After when supplied, and preserve creation/message idempotency rules when considering retries.
8. Optional attribution
Report interactions that actually happened.
The following public POST endpoints accept JSON with Content-Type: application/json, or text/plain containing JSON for sendBeacon. They need no visitor token and return an empty successful response. Treat them as best-effort telemetry: do not wait for them before opening chat or sending a message. Only emit events for UI elements you actually rendered and interactions that occurred. Disabling telemetry means those custom UI interactions are absent from the corresponding Stand analytics.
Use the exact site/responder context returned by discovery. For AI activation and badge clicks, preserve standinProfileId and a null/omitted human repId. Greeting-variant events require a human repId and greetingVariantId; AI discovery returns both as null, so skip greeting-shown/open for an AI offer. Never invent or look up an owner rep ID to satisfy those endpoints. visitorId, when used, is the server-issued visitor participant ID; omit it before a session exists. Do not substitute visitorExternalId.
9. Implementation brief
Verify the whole conversation before launch.
Test with your registered site and its configured human and AI responders. Compare the visitor experience with the actual conversation in Stand, then exercise the skills you have enabled. During beta, retain a way to return to the supplied widget if your client cannot handle a contract change.
Feature reference
Custom chat UI (Beta)
Review availability, prerequisites, supported behavior, and limitations before deciding what to replace.
Before publishing a custom client
- Discovery succeeds on the registered domain/path and fails gracefully on a wrong domain, unavailable responder, or exhausted capacity.
- One interaction creates exactly one session; initial text and opening greetings appear once in both the visitor transcript and Stand dashboard.
- Human and AI text, link cards, and unknown/malformed events render safely. If enabled, streamed previews, typing indicators, and localized controls handle their transient events correctly.
- AI-to-human handoff and unanswered-human recovery update identity correctly; an offered email form submits, handles a late rep reply, and reaches a closed state.
- Explicit OpenAPI confirmation works only after visitor action when the configured Stand-in has that skill.
- Reload, dropped socket, reconnect races, duplicate echoes, REST retries, expired tokens, and capped transcript recovery do not lose drafts or duplicate accepted messages. A terminal event during an unresolved send preserves text without claiming delivery.
- Ending the chat stops reconnects, clears local credentials, and requires an explicit action to start a new conversation.
- Tokens and query-string credentials are absent from your logs/analytics; unsafe links, raw HTML, internal prompts, and private metadata are not rendered.
- Keyboard focus, screen-reader labels, new-message announcements, mobile sizing, loading/error states, and reduced-motion behavior work.
- The configured notice and Stand attribution appear correctly, and optional interaction telemetry reflects actual actions.
Questions
Common reader notes
Do I need an API key?
No. Discovery and visitor session creation are public with site validation. Subsequent requests use the opaque visitor token issued for that conversation. Never use rep or admin credentials in the browser.
Can I try the visitor API before registering a site?
Yes. Use demo as the siteId. Stand Chat’s demo Stand-in answers from any page domain. Your own responders, instructions, knowledge, skills, and conversation history require your registered site ID.
Can I customize inline chat without rebuilding the client?
Yes. Use stand-chatbox for the complete supplied conversation inside your page, with CSS variables, shadow parts, slots, and capped or growing height. Use stand-button, stand-card, or the public JavaScript API for an invitation. Tune Chat Behavior documents these elements. Use this beta when you need to own the whole visitor interface and its lifecycle.
Does a custom UI bypass plan or branding limits?
No. The same server-side entitlements and quotas apply. Preserve returned notices and attribution, and implement UI behavior for the configured skills.
Are the reel examples working integrations?
Yes. The reel shows recorded previews of examples.stand.chat projects. Open a live example to interact and follow its source link to copy it. The fictional brands, page interactions, animations, and any sample questions belong to each example; adapt them for your own site and test with your configured responders.
Continue the guide