Chapter 12Beta

Build your own chat interface.

Custom chat UIs are a supported Stand integration path in beta. Build the visitor experience with your own code or an AI coding agent, using the same HTTP and WebSocket APIs as stand.js and ui.js. Stand continues to route conversations, run Stand-ins, connect human reps, and retain conversation history.

Live custom UI · Beta

A real conversation, your own UI.

Talk with the Stand team or their AI Stand-in. Sending your first message starts a real chat.

View the code

Stand

Stand chat

Checking availability…

Ask us about Stand or building your own chat interface.

Replies appear when complete.

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

01

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.

View the concept
An experimental website chat UI with a pixel character and a terminal-style TEEMU.EXE speech panel.
User-supplied UI concept: a pixel character with a terminal-style conversation panel. This is visual inspiration, not a connected Stand integration or a demonstration of API compatibility.

Working example · Beta

02

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.

TypeScript
TypeScript

1. Discovery

03

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.

Response fieldavailable
Contract and client responsibilityBoolean. Only proceed with a true result and a usable responder identifier.
Response fieldresponderType; repId; standinProfileId
Contract and client responsibilityresponderType is rep or standin. For a human, use repId. For AI, use standinProfileId; repId is null and the owner rep ID is not exposed. Send exactly the selected identifier when creating the session.
Response fieldrepName; repTitle; brandName; avatar
Contract and client responsibilityVisitor-facing identity and avatar URL, including when the responder is AI. Optional values can be absent or null. Render identity text safely.
Response fieldgreeting; greetingVariantId; showId
Contract and client responsibilityGreeting text, its optional experiment attribution, and a show correlation ID. Preserve the variant only for a greeting actually shown; carry showId into activation and create.
Response fieldsensitiveNoticeText; poweredByUrl
Contract and client responsibilityConfigured sensitive-data notice and Stand attribution URL. Preserve required notice/attribution behavior; validate outbound URLs.
Response fieldlapelPin
Contract and client responsibilityOptional object: pinId, orgId, domain, pinType, color, borderColor, fontFamily, fontColor, pillText, circleIconUrl, pillLogoUrl, usePinAsBotAvatar. pinType is none, circle, pill, or free; the supplied UI renders circle/pill, while free is a policy value delegating pin choice to reps/agents. Treat text and style values as data. Discovery avatar already includes server-side pin-as-avatar selection; do not replace it a second time.
Response fieldbehavior
Contract and client responsibilityOptional matched declarative rule: ruleId, name, enabled, pathPrefix, hideButtonUntilTriggered, presentation {noGreetings, halfSize, stepAside}, trigger, action. Your custom client implements the relevant behavior or explicitly owns its own presentation rules. Never execute customJavascript; that compatibility field is empty or omitted.
Response fieldbehavior.trigger
Contract and client responsibilityObject {type, delayMs, scrollPercent}. type is immediate, delay, firstScroll, scrollDepth, or none. delayMs is 0–60,000; scrollPercent is 1–100 (default 70). The supplied launcher applies delayMs after the trigger condition, including scroll triggers; none disables automatic activation.
Response fieldbehavior.action
Contract and client responsibilityObject {type, initialMessage}. type is showButton, openChat, or openChatAfterGreeting; initialMessage is at most 500 characters. showButton reveals the launcher; openChat opens the conversation with the configured initialMessage. openChatAfterGreeting uses the supplied greeting presentation to open desktop chat; these presentation details are client behavior, not a server timer.

2. Start

04

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.

Optional creation fieldinitialMessage
Meaning and limitsFirst visitor-authored text, or null/omitted when opening an empty conversation. Do not substitute page instructions for visitor speech.
Optional creation fieldincludeOpeningGreeting; openingMessage
Meaning and limitsSet true only when persisting the opening greeting your UI shows. openingMessage supplies that text; if omitted, Stand uses the selected responder greeting. Render the canonical result once.
Optional creation fieldprompt
Meaning and limitsPer-session internal context, up to 2,000 characters. Stored as system-prompt; hidden from visitors, visible in dashboard/history and AI context. It is not a secret channel or an authorization rule.
Optional creation fieldvisitorTimezone
Meaning and limitsOptional IANA timezone, for example Europe/Helsinki. Obtain from Intl.DateTimeFormat().resolvedOptions().timeZone when available.
Optional creation fieldvisitorLanguage; visitorLanguages; pageLanguage
Meaning and limitsBCP 47 browser/page language hints; invalid tags are ignored. conversationLanguage is the authoritative current language and may later change based on visitor text.
Optional creation fieldgreetingVariantId; showId
Meaning and limitsDiscovery attribution. Only attribute the greeting variant actually rendered.
Optional creation fieldactivationId; activationSource; activationAnalyticsId
Meaning and limitsCorrelate the UI activation with this chat; use the same activation ID as the optional activate event.
Optional creation fieldvisitorExternalId; visitorIdentityName
Meaning and limitsOptional host-asserted identity; each trimmed and limited to 255 characters. Name requires an external ID. These are unverified correlation metadata, never authentication, authorization, or proof of account ownership. Raw identity fields are not returned to visitor clients.

3. HTTP contract

05

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.

Method and pathGET /v1/sessions/{sessionId}
RequestOptional messageLimit (integer; default 200, maximum 500; 0 for metadata; negative is invalid).
Successful response / behaviorSession details and the most recent N messages, plus conversationLanguage and optional closedByType for a closed session. No visitorToken field is reissued. Visitor responses exclude rep-private labels and AI usage. No cursor for older visitor transcript pages.
Method and pathPOST /v1/sessions/{sessionId}/messages
Request{ body: string, type: "text", clientMessageId: string }
Successful response / behaviorA canonical Message. Use a unique clientMessageId per logical send and reuse it on retry; the original message is returned for a duplicate in the same session. Sender identity and text type are server-derived.
Method and pathDELETE /v1/sessions/{sessionId}
RequestNo body.
Successful response / behavior{ sessionId, status: "closed", closedAt, closedBy, closedByType? }. This is terminal metadata, not a full session snapshot. Stop sends/reconnects and clear local credentials; also handle session.closed.
Method and pathPOST /v1/sessions/{sessionId}/followup-request
Request{ email: string }
Successful response / behavior{ sessionId, status: "closed", followupRequested: true }. Only valid while a rep-followup-offer is active. Email is trimmed/lowercased, validated, and limited to 320 characters.
Method and pathPOST /v1/sessions/{sessionId}/link-clicks
Request{ messageId: string, url: string }
Successful response / behaviorCanonical system-card recording a real click. messageId must identify a server-issued link-card and url must match that card. Best-effort tracking; do not block navigation or retry indefinitely.

4. Real-time protocol

06

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.

Incoming type / discriminatorevent: "message"; type: text, link-card, system-card, or standin-idle-prompt
Additional fieldsMessage fields shown above. senderType can be visitor, rep, standin, system-card, or internal system-prompt. Canonical AI events may also carry turnId.
Client actionMerge canonical transcript by messageId, reconcile pending sends by clientMessageId, sort by numeric seq, then render according to the next section.
Incoming type / discriminatortyping
Additional fieldssenderId, senderType, active, sentAt
Client actionShow transient peer typing; expire it after a short silence and clear on active:false, a message, or disconnect. Ignore your own typing echo.
Incoming type / discriminatorstandin.status
Additional fieldsturnId, phase: typing | reconnecting | fallback | clear
Client actionShow AI progress. Clear matching turn state; discard a partial preview on fallback.
Incoming type / discriminatorstandin.delta
Additional fieldsturnId, seq, text
Client actiontext is the FULL accumulated preview, not a token to append. Replace only for a newer per-turn seq. This seq is separate from transcript seq. Replace the preview with the final canonical AI message; discard it on restore/closure.
Incoming type / discriminatorconversation.language
Additional fieldslanguageTag, source: "visitor"
Client actionUpdate localized UI copy. The REST conversationLanguage remains authoritative on recovery.
Incoming type / discriminatormessage.rejected
Additional fieldsreason: "session_reassigned", message
Client actionA send raced with reassignment. Keep it pending, recover session state, and only retry with its original clientMessageId when active.
Incoming type / discriminatorsession.closed
Additional fieldssessionId; closedAt, closedBy, closedByType when available
Client actionTerminal state: stop sends/reconnects, clear cached credentials, show an ended conversation. closedByType is rep | visitor | standin | system | other; missing means other. Do not infer identity from raw closedBy.
Incoming type / discriminatorUnknown event or extra field
Additional fieldsMay appear during beta evolution or rolling deployments.
Client actionIgnore safely without throwing or displaying raw JSON. Rep-private events, including conversation labels, are not part of this visitor contract.

5. Rendering and skills

07

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.

Content / cardTypelink-card
JSON body fieldsurl, title?, description?
Required behavior when presentRender a useful link, permitting only http: or https:. For a new tab, use noopener/noreferrer. Track an actual click with messageId and url through link-clicks.
Content / cardTypesession-start
JSON body fieldscardType, standinName, greeting
Required behavior when presentShow a start notice; greeting is metadata, not an instruction to duplicate an already persisted opening message.
Content / cardTypehandoff; human-transfer
JSON body fieldscardType, repName, repTitle, repBrandName, repAvatar, message
Required behavior when presentAnnounce the human and update header identity; remove the AI badge.
Content / cardTypestandin-takeover
JSON body fieldscardType, standinName, standinTitle, standinAvatar, message
Required behavior when presentAnnounce AI coverage and update the header and AI disclosure.
Content / cardTypesession-end
JSON body fieldscardType, reason
Required behavior when presentRender an end notice; use session status/session.closed for the terminal transition.
Content / cardTyperep-followup-offer
JSON body fieldscardType, repName, message
Required behavior when presentOffer an email form for an unanswered human chat. Submit through followup-request; dismiss when a human reply supersedes it.
Content / cardTyperep-followup-confirmation
JSON body fieldscardType, message
Required behavior when presentShow successful follow-up submission, then the closed state.
Content / cardTypelink-clicked
JSON body fieldscardType, messageId, url
Required behavior when presentTracking metadata from link-clicks. Do not show as a raw visitor card.
Content / cardTypefollowup-requested
JSON body fieldscardType, reason, preferredContact?
Required behavior when presentFollow-up metadata. Do not show as a raw visitor card; this is distinct from the actionable rep-followup-offer email form.

6. Recovery and lifecycle

08

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

09

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.

Status / event400
Meaning and responseInvalid context or payload: verify siteId, absolute page URL/domain, required fields, email, or link-card target. Correct input before retrying.
Status / event401 / 403
Meaning and responseMissing, expired, or unusable session authorization. Clear unusable saved credentials; never switch to rep credentials. Offer an explicit new conversation.
Status / event404
Meaning and responseSession or requested responder no longer exists. For restore, abandon the saved session; for start, refresh discovery.
Status / event409
Meaning and responseCreation: routing/capacity/site eligibility changed. Message/follow-up: closed session, reassignment, or expired offer. Recover current state; do not repeatedly submit the same invalid transition.
Status / event429 / 5xx / network failure
Meaning and responseShow a retryable service state and preserve draft/pending text. Back off; do not automatically replay an ambiguous create.
Status / eventWebSocket error or close
Meaning and responseRecover over authenticated HTTP; a transport close is not itself a session.closed event. No separate delivery acknowledgement or replay cursor is available.

8. Optional attribution

10

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.

Endpoint/v1/events/activate
JSON body / emission ruleRequired: siteId. Optional: activationId, showId, page, repId or standinProfileId, responderType, sourceType, analyticsId, interaction, initialMessagePresent, behaviorRuleId, behaviorTriggerType, behaviorActionType. Optional fields may be null. sourceType: use unknown for a custom surface; interaction describes the action. Create one activationId per activation and reuse it in create; deduplicated for 24 hours.
Endpoint/v1/events/greeting-shown
JSON body / emission ruleRequired: siteId, repId, greetingVariantId. Optional: page, standinProfileId, visitorId. The variant must belong to that rep in the site’s organization. Emit once after the returned human greeting variant is actually visible, not for an AI or custom replacement greeting.
Endpoint/v1/events/greeting-open
JSON body / emission ruleSame fields as greeting-shown. Emit only for opening from that rendered greeting.
Endpoint/v1/events/badge-click
JSON body / emission rulesiteId, page, exactly one of repId / standinProfileId, visitorId?, repName?. Send for a real Stand attribution click; server derives the canonical name. No server idempotency promise.

9. Implementation brief

11

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.

Read the feature reference

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

Build Stand as a learning loop, one chapter at a time.

Try the guide on one real page.