Skip to main content

Public chat streaming

The widget sends visitor messages to this endpoint and receives a streamed response. One request equals one conversation turn. The endpoint is CORS-unrestricted and requires no authentication header — only the bot's publicId, an anonymous visitorId, and the message text.

POST /v1/public/chat

Headers

HeaderRequiredValue
Content-Typeyesapplication/json
x-widget-versionno2 — selects the modern Vercel AI SDK UIMessageStream wire format. Omit to fall through to the legacy text/event-stream translator (kept alive for ≥ 30 days after every major widget release).

Request body

{
"botPublicId": "bot_a1b2c3d4e5",
"visitorId": "visitor_xyz",
"conversationId": null,
"message": "How do I install the widget?",
"hostPageUrl": "https://docs.acme.com/getting-started"
}
FieldTypeRequiredDescription
botPublicIdstringyesThe target bot's public id.
visitorIdstringyesAnonymous per-browser identifier. The launcher persists this in localStorage; if you bypass the launcher, generate a stable id once and reuse it.
conversationIdstring | nullnonull to start a new conversation, or a prior conversationId to continue. If the server cannot match the id to (botId, visitorId), it silently mints a fresh conversation and returns the new id in the data-done chunk.
messagestring (1–4000)yesThe visitor's text. Trimmed server-side; empty messages are rejected.
hostPageUrlstring (URL)noThe page the visitor is chatting from. Stored on the conversation row for analytics.

Response 200Content-Type: text/event-stream, Access-Control-Allow-Origin: *. The body is a stream of chunked events until the server emits a terminal data-done and closes the socket.

The UIMessageStream protocol (x-widget-version: 2)

The modern route returns a ReadableStream of UI message chunks produced by the Vercel AI SDK createUIMessageStream. A ChatTransport from @ai-sdk/react's useChat hook consumes it directly, or you can read the stream yourself.

Chunk types

typeWhenPayload
text-startAssistant turn begins streaming{ id: string }
text-deltaOne LLM token (or partial word){ id: string, delta: string }
text-endAssistant turn finished streaming{ id: string }
data-action-proposalThe agent proposed an action the widget should execute{ id, data: ActionProposalPayload } — see the Actions API for the payload shape
data-citationsCitations for the turn's text{ data: Citation[] }
data-doneTerminal event — the turn is persisted{ data: { messageId, confidence, conversationId } }
errorUpstream LLM or stream errorinternal-only; legacy translator collapses to a refusal token

After data-done, the server closes the connection. Save data.conversationId and send it back on the next turn.

Consuming via @ai-sdk/react

The recommended path for a React app is useChat<BotUIMessage> with a DefaultChatTransport pointing at /v1/public/chat. MimicBot ships this already wired up in @mimicbot/chat-ui — see BotChatProps for the component API.

import { DefaultChatTransport } from 'ai';
import { useChat } from '@ai-sdk/react';
import type { BotUIMessage } from '@mimicbot/chat-ui';

const transport = new DefaultChatTransport<BotUIMessage>({
api: 'https://api.mimicbot.app/v1/public/chat',
headers: { 'x-widget-version': '2' },
prepareSendMessagesRequest: ({ body, messages }) => ({
body: {
...body,
botPublicId: 'bot_a1b2c3d4e5',
visitorId: getOrCreateVisitorId(),
conversationId: currentConversationId,
message: lastUserText(messages),
},
}),
});

Consuming with raw fetch

If you're not in React, read the stream directly. Split on SSE event boundaries and JSON.parse each chunk.

const response = await fetch('https://api.mimicbot.app/v1/public/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-widget-version': '2',
},
body: JSON.stringify({
botPublicId: 'bot_a1b2c3d4e5',
visitorId: getOrCreateVisitorId(),
conversationId: currentConversationId,
message: 'How do I install the widget?',
}),
});

const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';

while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });

// UIMessageStream chunks are framed as `data: <json>\n\n`.
const events = buffer.split('\n\n');
buffer = events.pop() ?? '';

for (const raw of events) {
const line = raw.replace(/^data: /, '');
if (!line || line === '[DONE]') continue;
const chunk = JSON.parse(line);
handleChunk(chunk);
}
}

handleChunk dispatches on chunk.type and renders the bubble for text-delta, shows an action card for data-action-proposal, and persists the new conversationId on data-done.

The legacy SSE protocol (no x-widget-version header)

Widgets built before the April 2026 chat UI rewrite use a smaller SSE protocol. The server translates the modern UIMessageStream to this shape when the x-widget-version header is absent. Mapping:

UIMessageStream chunkLegacy SSE eventData
text-deltaevent: token"the delta as a JSON string"
data-action-proposalevent: action_proposalActionProposalPayload as JSON
data-citationsevent: citationsCitation[] as JSON
data-doneevent: done{ messageId, confidence, conversationId } as JSON

text-start and text-end are dropped. Errors collapse to a refusal token. New integrations should always send x-widget-version: 2 and consume the modern stream directly — the legacy translator is kept alive for the 30-day deprecation window following each major widget release.

Rate limits

The endpoint enforces four independent caps:

  • 30 messages/hour per (botPublicId, visitorId) — the primary defense against chatty visitors
  • 60 messages/hour per (botPublicId, ip) — catches visitorId rotation
  • 10,000 messages/day per botPublicId — per-bot burst ceiling
  • 50,000 messages/day per agency — agency-wide last line of defense

A 429 response includes Retry-After in seconds.

Errors

  • 400 INVALID_REQUEST — missing/malformed body (fails ChatRequestSchema)
  • 402 PLAN_LIMIT_REACHED — the agency hit its monthly message ceiling; the widget should show a neutral "unavailable" notice
  • 404 NOT_FOUND — no bot with that botPublicId
  • 429 RATE_LIMITED — one of the caps above tripped; see Rate limits

Public endpoint errors return { "error": { "code": "..." } }. See Errors.