Skip to content

Protocol

github-actions[bot] edited this page Oct 11, 2026 · 4 revisions

The HelpPuff protocol

Version v1. JSON in, JSON out, over HTTPS.

The protocol is the contract between a widget and a server. The widget knows nothing about which AI backend answers; the server knows nothing about how the widget renders. Either side can be replaced with your own implementation in any language.

The normative definition is the Zod schemas in packages/protocol/src in the repository. Where this page and the schemas disagree, the schemas win.


Endpoints

Method Path Purpose
GET /v1/sites/:siteId/config Public widget config
POST /v1/sites/:siteId/sessions Start a session
POST /v1/sessions/messages Send a message or an action
GET /v1/sessions/messages?after=:id Poll for new messages
POST /v1/sessions/feedback Rate a reply (thumbs up/down)
POST /v1/sites/:siteId/retell/kb Knowledge lookup for a Retell agent (signed by Retell; see knowledge.md)
POST /v1/sessions/end End a session, best effort

Session endpoints authenticate with Authorization: Bearer <sessionToken> and respond with Cache-Control: no-store.

GET /v1/sites/:siteId/config is public and cacheable (public, max-age=60, s-maxage=300).


Errors

Every failure, at every status code, uses one envelope:

{ "error": { "code": "rate_limited", "message": "Too many messages. Try again shortly.", "retryAfter": 30 } }
Code Status Meaning
bad_request 400 The request did not match the schema, or a lead field failed validation
unauthorized 401 Missing, malformed or tampered session token
session_expired 401 A well-formed token that has aged out
forbidden_origin 403 Origin missing or not on the site's allowlist, or the visitor's IP is blocked
forbidden 403 The public API: the key lacks the scope, belongs to another site, or is used from an address it does not allow
conflict 409 The public API: it already exists (a lead with that email, an account)
captcha_failed 403 Turnstile rejected the token
not_found 404 Unknown site id or unknown route
rate_limited 429 A per-IP or per-minute limit was hit; see retryAfter
quota_exceeded 429 A per-session or per-day cap was hit
connector_error 502 The backend failed or timed out
internal 500 Anything else

message is always safe to show a visitor. A backend's own error text, a stack trace, and the internal detail used for logging never appear in the envelope. An HTML error page is never returned — a client's parser always has valid JSON to read.


GET /v1/sites/:siteId/config

type ConfigResponse = {
  siteId: string;
  widget: WidgetConfig;           // see packages/protocol/src/config.ts
  capabilities: { poll: boolean; end: boolean; stream?: boolean; feedback?: boolean };
};

Only public values appear here. Secrets stay on the server; a captcha's site key is public by design, its secret is not.

Every field of WidgetConfig has a default, so a client that receives {} still renders a working widget.


POST /v1/sites/:siteId/sessions

type StartSessionRequest = {
  lead?: Record<string, string>;   // keys match the configured form fields
  context: {
    pageUrl: string;
    pageTitle?: string;
    referrer?: string;
    utm?: Partial<Record<'source' | 'medium' | 'campaign' | 'term' | 'content', string>>;
    locale?: string;
    timezone?: string;
  };
  firstMessage?: string;
  captchaToken?: string;
};

type StartSessionResponse = {
  sessionToken: string;
  sessionId: string;
  expiresAt: number;               // epoch ms
  messages: Message[];             // greeting, and the reply to firstMessage
  capabilities: { poll: boolean; end: boolean; stream?: boolean; feedback?: boolean };
};

Lead values are trimmed, capped at 200 characters each, and checked against the site's configured fields. Keys that are not configured are dropped rather than rejected, so a stale client cannot break a site whose form just changed.

The session token

The server stores no session state. sessionToken is base64url(payload).base64url(hmacSha256(payload)), and the payload carries the site id, session id, a small opaque connectorState, a message count and an expiry. Every later request presents it; the server verifies the signature and hands the state back to the connector.

Treat it as opaque. It is not a bearer credential for anything but this one conversation, and it expires.


POST /v1/sessions/messages

type SendRequest =
  | { kind: 'text'; text: string; clientId: string }
  | { kind: 'action'; actionId: string; value: string; label: string; clientId: string };

type SendResponse = { messages: Message[] };

clientId is generated by the client for optimistic rendering and deduplication. An action carries both the label (for display and history) and the value (for the backend).

When the connector's state changes, the response carries a refreshed token in the X-HelpPuff-Token header. A client must use it for the next request. The header is listed in Access-Control-Expose-Headers so a browser can read it.

A refreshed token keeps the original expiry: a conversation cannot extend itself indefinitely.


Streamed replies

When capabilities.stream is true, a client may ask for either POST …/sessions or POST /v1/sessions/messages to be streamed by sending Accept: text/event-stream. Streaming is a per-site setting on connectors that support it (the stream option on openai, gemini and echo).

The server streams only when it was asked and the site streams. Otherwise it answers with the usual JSON, so asking is always safe; a client reads either by the response's Content-Type.

Everything that can fail cheaply — the origin, the body, rate limits, the captcha — is checked before the stream starts and fails with the usual status code and envelope. Only the connector call is streamed, as server-sent events:

Event data
delta { text: string } Reply text as it is written. A preview: never stored, superseded by done
done the endpoint's JSON body The sanitized messages. For a send, it also carries token, the refreshed session token, since the headers left before it existed
error the envelope's error A failure after the 200 was sent

done is authoritative. A client shows the deltas while they arrive, then replaces them with done's messages — which are sanitized; the deltas are not rich messages and may differ in detail, such as a trailing space. A card, an options list or a link list arrives only in done.

Reasoning is never streamed. Connectors forward only the text meant for the visitor; a model's thinking produces no deltas, so the client keeps showing its typing indicator until the answer begins.

A stream that ends without done or error was cut off, and should be treated as a retryable failure.


GET /v1/sessions/messages?after=:id

Only meaningful when capabilities.poll is true; otherwise it returns bad_request. Returns { messages: Message[] }, possibly empty.

POST /v1/sessions/feedback

Authorization: Bearer <sessionToken>, body { "messageId": "m_…", "value": 1 | -1 | 0 } (0 takes a rating back) → 204. Rates one of the assistant's replies in this session; stored with the transcript for the dashboard. Offered only when the /config capabilities include "feedback": true (the deployment records conversations); otherwise 404. Rate-limited per IP. A widget sends it fire-and-forget and never shows its failure.

POST /v1/sessions/end

Returns 204 immediately. Any backend teardown runs after the response.


Messages

type Base = {
  id: string;
  ts: number;
  role: 'user' | 'agent' | 'system';
  /** `human`: written by a person on the team (live chat), shown with `agentName`. */
  meta?: { agentName?: string; avatar?: string; human?: boolean };
};

type Message =
  | Base & { type: 'text'; text: string }
  | Base & { type: 'notice'; text: string; tone?: 'info' | 'warn' }
  | Base & { type: 'options'; text?: string; options: Option[]; multi?: boolean }
  | Base & { type: 'card'; title: string; body?: string; image?: Img; actions?: Action[] }
  | Base & { type: 'carousel'; cards: CardItem[] }
  | Base & { type: 'links'; title?: string; links: LinkItem[] }
  | Base & { type: 'form'; title?: string; fields: Field[]; submitLabel?: string }
  // Live chat's progress: waiting for a person, joined, back to the assistant, closed, or nobody in time.
  | Base & { type: 'handover'; status: 'waiting' | 'joined' | 'left' | 'closed' | 'missed'; text: string; agentName?: string }
  // The booking picker (Settings → Bookings), or with `booked` the appointment it booked. Only the server writes `booked`.
  | Base & { type: 'booking'; text?: string; from?: string; to?: string; part?: 'morning' | 'afternoon' | 'evening';
             prefill?: { name?: string; email?: string; phone?: string }; reschedule?: string;
             booked?: { id: string; title: string; start: number; end: number; timezone: string; status: 'confirmed' | 'cancelled'; location?: string; cancel?: boolean; change?: boolean } };

Rules both sides must follow:

  • The server validates every connector message before returning it. Invalid messages are dropped and counted in the logs. If that leaves nothing, a single friendly notice is returned instead of an error.
  • A client must never crash on unexpected input. An unknown type renders nothing.
  • text is a markdown subset only: paragraphs, line breaks, **bold**, *italic*, `code`, [links](url) and - bullets. No headings, images, HTML or tables.
  • URLs accept only https:, http:, mailto: and tel: — anywhere in the protocol. Image sources are narrower still: https: or http: only. Anything else is rejected server-side and rendered as plain text client-side.

Batches are capped at 20 messages; options at 10; card actions at 3.

Actions

type Action =
  | { id: string; kind: 'reply'; label: string; value: string }
  | { id: string; kind: 'url';   label: string; url: string; newTab?: boolean }
  | { id: string; kind: 'tel';   label: string; phone: string }
  | { id: string; kind: 'email'; label: string; email: string }
  | { id: string; kind: 'flow';  label: string; flowId: string }
  | { id: string; kind: 'form';  label: string; formId: string }
  | { id: string; kind: 'booking'; label: string };

reply sends a SendRequest of kind action. flow and form are handled entirely client-side and reach the server only as the message they produce. booking opens the booking picker (a booking message the widget shows itself).

The picker talks to the server on the session token, outside the message stream: POST /v1/sessions/booking/slots { from?, to?, tz?, part?, reschedule? } (free times, each with a signed id; a POST so the origin is always checked), POST /v1/sessions/booking { slot, name, email, phone?, timezone?, reschedule? } (answers { messages } with the booked card, or { redirect, messages } when the visitor finishes on the provider's page) and POST /v1/sessions/booking/cancel { appointment }. A connector can open the picker by returning a booking message without booked; a booked one from a connector is dropped.


Live chat

When capabilities.live is true, a visitor can be handed to a person (see Live chat). The server decides: the action { kind: "action", actionId: "handover" } asks for a person whichever backend runs the chat, and the reply is a handover message (waiting, or missed plus the callback form when nobody is available).

While the latest handover message is waiting, joined or missed, the client opens GET /v1/live/socket as a WebSocket with the subprotocols helppuff.v1 and t.<session token> (never the token in the URL). The server sends JSON frames:

type LiveFrame =
  | { t: 'msg'; message: Message }        // a person's reply, or a handover message
  | { t: 'status'; status: HandoverStatus; agentName?: string }
  | { t: 'typing'; on: boolean };

The client may send { "t": "typing", "on": true }, and ping (answered pong). The visitor's messages still go over POST /v1/sessions/messages, which answers { messages: [] } while a person has the chat. A socket the server closes with code 1000 and reason left or closed is over. Clients that cannot open a socket poll GET /v1/sessions/messages?after=<ms> for messages newer than that time.

Origin and CORS

The origin allowlist is mandatory. Without it, anyone can copy an embed snippet and spend the site owner's AI budget.

  • A session request whose Origin is missing or not on the site's allowlist is rejected with forbidden_origin.
  • The server reflects an allowlisted origin only, sends Access-Control-Allow-Headers: content-type, authorization, and exposes X-HelpPuff-Token and Retry-After.
  • CORS reflection decides only what a browser may read. Authorization is enforced separately, per site, inside each route.

Implementing your own server

Implement the five endpoints above and any HelpPuff widget can point at you with data-api. The checklist:

  1. Return the error envelope for every failure, including 404 and 500. Never HTML.
  2. Enforce an origin allowlist on session endpoints.
  3. Validate every outbound message against the schema — drop what fails.
  4. Send Cache-Control: no-store on session endpoints.
  5. Expose X-HelpPuff-Token when you rotate tokens.

The Zod schemas in packages/protocol are what the reference server validates with; copy them (MIT) rather than re-deriving the shapes.

Clone this wiki locally