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
| Header | Required | Value |
|---|---|---|
Content-Type | yes | application/json |
x-widget-version | no | 2 — 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"
}
| Field | Type | Required | Description |
|---|---|---|---|
botPublicId | string | yes | The target bot's public id. |
visitorId | string | yes | Anonymous per-browser identifier. The launcher persists this in localStorage; if you bypass the launcher, generate a stable id once and reuse it. |
conversationId | string | null | no | null 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. |
message | string (1–4000) | yes | The visitor's text. Trimmed server-side; empty messages are rejected. |
hostPageUrl | string (URL) | no | The page the visitor is chatting from. Stored on the conversation row for analytics. |
Response 200 — Content-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
type | When | Payload |
|---|---|---|
text-start | Assistant turn begins streaming | { id: string } |
text-delta | One LLM token (or partial word) | { id: string, delta: string } |
text-end | Assistant turn finished streaming | { id: string } |
data-action-proposal | The agent proposed an action the widget should execute | { id, data: ActionProposalPayload } — see the Actions API for the payload shape |
data-citations | Citations for the turn's text | { data: Citation[] } |
data-done | Terminal event — the turn is persisted | { data: { messageId, confidence, conversationId } } |
error | Upstream LLM or stream error | internal-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 chunk | Legacy SSE event | Data |
|---|---|---|
text-delta | event: token | "the delta as a JSON string" |
data-action-proposal | event: action_proposal | ActionProposalPayload as JSON |
data-citations | event: citations | Citation[] as JSON |
data-done | event: 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)— catchesvisitorIdrotation - 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 (failsChatRequestSchema)402 PLAN_LIMIT_REACHED— the agency hit its monthly message ceiling; the widget should show a neutral "unavailable" notice404 NOT_FOUND— no bot with thatbotPublicId429 RATE_LIMITED— one of the caps above tripped; see Rate limits
Public endpoint errors return { "error": { "code": "..." } }. See Errors.