Stand Guidebook
Chapter 12 of 12
Field guide
What to learn in this chapter
Implement a visitor client from this contract, preserve the conversation lifecycle and required disclosures, then test it on a registered site. This chapter is technical reference material for developers and AI coding agents. The beta contract can evolve; tolerate additive fields and unknown events, and retest your integration when adopting contract changes.
Need an exact capability definition, plan requirement, or limitation? Browse the Feature Reference.
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. If you only need a custom launcher, keep the supplied widget and use the public JavaScript API instead.
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.
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.
Illustration source
Explore the original UI concept.
The example shows the visual freedom a custom client can provide. It still needs the session, messaging, and recovery implementation described here.

Working example · Beta
Use this page as a working example.
The chat in this chapter’s hero is a custom React client connected to the Stand website’s real coverage. It discovers an available human or AI Stand-in when mounted and creates a conversation only when you send your first message. Availability, replies, and chat history come from Stand.
The example uses HTTP for sends and recovery, and a WebSocket for incoming messages. It renders completed replies, link cards, identity changes, and email follow-up offers. It keeps its controls in English and leaves out streamed previews, typing indicators, rich Markdown, automatic behavior-rule triggers, and optional launcher/greeting analytics. It owns its inline presentation; it does not call the widget’s JavaScript API.
One browser-side client retains the transcript, pending send, and draft while Next.js moves between pages. Leaving chapter 12 releases its socket, retries, and listeners. Returning restores the saved conversation and reconciles messages sent while you were away. A separate, environment-and-site-scoped sessionStorage entry supports reload recovery in the same tab. If browser storage is unavailable, continuity lasts only for that page’s JavaScript lifetime. Ending a chat clears its credentials; New chat performs discovery before another visitor message can create a session.
This client mounts only in chapter 12. The site’s normal floating Stand widget is hidden while this chapter is open and returns when you navigate away, with its separate conversation state preserved. Other chapters retain their existing hero and do not mount this example.
Adapt the example to your site
Copy the two TypeScript files below. Replace the example’s Site ID with your registered Site ID and the getEmbedConfig import with your API and WebSocket origins. Use the current page URL; a production Site ID does not grant coverage to an unregistered localhost or preview domain. The React component uses this site’s Tailwind classes; replace those classes with your own design. The client itself has no React or widget dependency.
The source shown here is read from the running implementation at build time. Keep the protocol behavior when adapting its appearance, and run the acceptance checklist at the end of this chapter. An uncertain first-start request requires an explicit visitor decision before another start; message retries reuse their original client message ID.
// A small visitor client for the chapter 12 example. No widget globals or 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 Config {
apiBase: string
wsBase: string
siteId: string
page: () => string
storage?: Storage
}
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 = `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 && 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
this.update({ phase: 'ended', connection: 'offline', pending: null, 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 {
const incoming = messages([data])
if (incoming.length) this.merge(incoming) // Ignore transient/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) {
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: false, showId: this.offer.showId,
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, '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 () => {
if (!this.session || this.state.busy) 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
this.update({ ...INITIAL_CHAT_STATE, draft: this.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>()
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.
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.
Browser discovery (JavaScript)
const api = 'https://api.stand.chat';
const siteId = 'YOUR_SITE_ID';
const page = window.location.href;
const query = new URLSearchParams({
siteId, page, greetingsEnabled: 'true'
});
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.Run on the registered website. Poll sparingly after user action or a bounded retry delay, not in a tight loop.
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 billable conversation. 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. 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. Use responderType and subsequent system cards for AI 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.
Create from a successful offer (JavaScript)
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. Clear local credentials when the client enters its ended state.
Closing a UI panel or disconnecting its socket is not ending the chat. Only send DELETE when the visitor explicitly ends the conversation. For a new chat, discard the old local credentials and start again through discovery/create. The server remains authoritative for token validity and session state.
REST send and retry (JavaScript)
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. 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.
Authenticated socket and visitor frames (JavaScript)
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.
Server → browser: canonical message example
{
"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.
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; use REST retry with the same ID or read the transcript to resolve uncertain delivery. 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 cross-page recovery promise.
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
Give the coding agent a contract and a launch checklist.
Start with one real site, one configured responder, and a text conversation. Then exercise the optional skills that your Stand-ins can emit. A visually complete mockup is not connected until the actual transcript and state transitions work in Stand.
Keep the network adapter, canonical transcript reducer, and UI presentation separate. This lets a terminal, pixel character, full-page assistant, or conventional chat panel share the same tested integration behavior. During beta, retain a way to return to the supplied widget if your custom client cannot handle a service or 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, streaming completion, typing, language updates, link cards, and unknown/malformed events render safely.
- 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.
- 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.
Copyable brief for an AI coding agent
Build a custom visitor chat UI for my registered Stand site.
Contract: https://stand.chat/guide/custom-chat-ui (Beta).
Feature scope: https://stand.chat/features/custom-chat-ui.
Use the site's public ID and real page URL, not account credentials.
Implement discovery, one-time creation, visitor-token HTTP requests,
WebSocket receive, canonical transcript reconciliation, safe rendering,
reload/reconnect recovery, handoff, and explicit end/new-chat actions.
Preserve AI/human identity, configured notices, and Stand attribution.
Implement link cards and the unanswered-chat email form when emitted.
Keep pending sends and reuse clientMessageId on retry.
Do not auto-confirm AI integration writes or replay ambiguous creates.
Treat identity hints as unverified; never put private keys in the browser.
Make keyboard/mobile/reduced-motion states usable.
Complete the guide's acceptance checklist against my configured site.Add your design brief and public site ID. Provide any privileged server-side credentials through a separate secure development process, never in this client.
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 keep the standard widget and only customize its launcher?
Yes. Use stand-button, stand-card, or the public JavaScript API described in Tune Chat Behavior. Use this beta when you 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.
Is the pixel-character example already connected?
No. The supplied screenshot illustrates a possible design. It is not a working Stand integration or a compatibility test.
Continue the guide