Chat
Build your own chat UI
Drive a conversation from your own interface with the public chat API v1, which this site's docs chat uses too. Start, stream, retry safely, resume and end.
When the widget's look or placement doesn't fit (a chat inside your app, a kiosk, a support panel), call the public chat API v1 directly. It is made for browsers and apps, and runs the same conversations as the widget: a publishable key starts a conversation, and a conversation token authorises everything after that. No secret key is involved.
The exchange
- Start a conversation with your publishable key. You get a
conversation_idand a token (ct_...). - Send each visitor message with the token. The reply streams back as server-sent events.
- Poll for updates when someone else may write (a person from your team who took over).
- End the conversation when the visitor is done.
All requests go to https://api.myleadsfactory.tech/public/v1, every JSON success comes wrapped in data, and any site may call it: the headers these calls send are allowed from every origin, and your page can read Retry-After, X-Request-Id and the X-RateLimit-* headers. The assistant needs web chat enabled. With a site key, your page's origin must also be in the assistant's allowed_origins (Add chat to your website); a test key checks its own origins instead and runs the assistant's draft. The origin checked is the one the browser puts in the Origin header, which your page cannot change. A parent_origin field sent from a browser on your site is ignored.
Start a conversation
const API = 'https://api.myleadsfactory.tech/public/v1'
async function startConversation() {
const res = await fetch(`${API}/conversations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
widget_key: 'pk_live_4b1f0c9e2a7d4e6b8c3f5a1d9e0b7c62',
visitor_id: getVisitorId(), // optional: your stable id for this visitor
metadata: { plan: 'pro' }, // optional: stored on the conversation
}),
})
if (!res.ok) throw new Error((await res.json()).error)
const { data } = await res.json()
return { id: data.conversation_id, token: data.token }
}{
"data": {
"conversation_id": "conv_0c8e5f2a9b7d4c1e86f3a0d2b4c6e8f1",
"token": "ct_...",
"status": "open",
"messages": [],
"config": { "name": "Front desk", "quick_replies": [], "appearance": { "position": "bottom-right" } }
}
}config is the assistant's public chat settings (name, greeting, quick replies, appearance, and when they are on, the lead panel and voice), the same as GET/public/v1/config returns, so you can draw the header before the first message. With a key for a whole project, also send assistant with the assistant's ID.
Keep the id and token (for example in sessionStorage) to continue the conversation after a reload. The token is valid for up to 30 days (less for a test key that is revoked) and works only for this conversation.
Send a message and read the stream
Send the text with the token. Add a client_message_id you generate per message, so a retry after a network error can never produce two answers, and the page the visitor is on, which the assistant can take into account.
const res = await fetch(`${API}/conversations/${chat.id}/messages`, {
method: 'POST',
headers: { Authorization: `Bearer ${chat.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
content: 'Do you have trips to Lisbon in May?',
client_message_id: crypto.randomUUID(),
page: { url: location.href, title: document.title },
}),
})content is up to 4,000 characters. The response is 200 with Content-Type: text/event-stream. Each event is an event: line and a data: line of JSON, followed by a blank line:
event: message.accepted
data: {}
event: message.delta
data: {"text":"We do. "}
event: message.delta
data: {"text":"Which dates suit you?"}
event: message.completed
data: {"message":{"id":"msg_...","role":"assistant","content":"We do. Which dates suit you?","seq":2,"created_at":"2026-10-28T09:30:02.114Z"},"conversation":{"status":"open","resumable":false,"handoff_requested_at":null,"taken_over":false,"lead_id":null}}Read the stream
| Event | Data | What to do |
|---|---|---|
message.accepted | {} | The message is stored. Show it as sent. A replayed send, or one sent while a person has taken over, may skip it |
message.delta | {"text": "..."} | Append the text to the reply you are drawing |
tool.status | {"name": "...", "state": "started"} or "completed" | Optional: show that the assistant is looking something up |
heartbeat | {} | Sent every 15 seconds while the turn runs. Ignore it |
message.completed | {"message": ..., "conversation": ...} | The turn is over. message.content is the full reply |
error | {"code": "..."} | The turn failed. Keep any text you drew and offer a retry |
A turn always ends with message.completed or error; there is no [DONE] line. A 200 status alone does not mean the turn succeeded.
EventSource cannot send a POST body or an Authorization header, so read the stream with fetch:
async function readTurn(res, onText) {
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader()
let buffer = ''
for (;;) {
const { value, done } = await reader.read()
if (done) throw new Error('stream_closed')
buffer += value
let end
while ((end = buffer.indexOf('\n\n')) >= 0) {
const frame = buffer.slice(0, end)
buffer = buffer.slice(end + 2)
const event = frame.match(/^event: (.*)$/m)?.[1]
const data = JSON.parse(frame.match(/^data: (.*)$/m)?.[1] ?? '{}')
if (event === 'message.delta') onText(data.text)
else if (event === 'message.completed') return data
else if (event === 'error') throw new Error(data.code)
}
}
}
const turn = await readTurn(res, (text) => (bubble.textContent += text))Before reading, check res.ok: a refusal before the stream starts is an ordinary JSON error (see below).
Retry safely
Send one message at a time. A second send while a turn is running answers 409 turn_in_progress.
If the connection drops mid-turn, send the same content with the same client_message_id again. The server recognises it and sends the stored result (sometimes only the closing message.completed) instead of answering twice. If the reply itself was cut off, the turn is answered again, unless something with an effect already ran (a lead captured, a handoff) or a person has taken over; then the retry ends with error turn_interrupted. Reusing a client_message_id with different content answers 409 message_id_conflict.
Poll for updates
When a person on your team takes over the conversation, their replies don't come through your send's stream: message.completed arrives with message: null and conversation.taken_over: true. Poll for new messages instead:
let lastSeq = 0
async function poll(chat) {
const res = await fetch(`${API}/conversations/${chat.id}/updates?after_seq=${lastSeq}`, {
headers: { Authorization: `Bearer ${chat.token}` },
})
const { data: { conversation, messages } } = await res.json()
for (const m of messages) {
draw(m)
lastSeq = Math.max(lastSeq, m.seq)
}
return conversation // status, taken_over, handoff_requested_at
}Every message has a seq that only goes up: ask for messages after the highest you have. Updates allow 60 requests a minute per conversation and IP address, so every few seconds is plenty, and only while a person is involved or a voice session has just ended.
Resume after a reload
GET/public/v1/conversations/{id} returns the conversation's status and its visible messages, so you can redraw the thread. A conversation closed because the visitor went quiet (ended_reason idle_timeout) is resumable: the next message reopens it. One the visitor ended, or that reached its message limit, stays closed: start a new one.
End the conversation
await fetch(`${API}/conversations/${chat.id}/end`, {
method: 'POST',
headers: { Authorization: `Bearer ${chat.token}` },
})It answers {"data":{"status":"ended"}}. Ending is final and sends the conversation.ended webhook with the transcript and lead (to test subscriptions for a test chat; a preview chat sends none). Conversations a visitor walks away from are closed for them after a quiet spell (30 minutes by default).
Files
When the assistant allows attachments, upload each file first and send the returned IDs with the message (up to five per message, 20 per conversation, 10 MB each; JPEG, PNG, WebP, GIF, PDF and DOCX, checked by content, not by name):
const up = await fetch(`${API}/conversations/${chat.id}/attachments`, {
method: 'POST',
headers: {
Authorization: `Bearer ${chat.token}`,
'Content-Type': file.type,
'X-MLF-Filename': encodeURIComponent(file.name), // the name to show and keep
},
body: file, // the raw bytes, not multipart form data
})
const { data } = await up.json() // { id, name, content_type, size, url }
// then send { content: 'Here is my passport', attachment_ids: [data.id] }Show what the chat has captured
With lead panel on (channels.web_chat.dynamic_ui.lead_panel, off by default) and lead capture on, your page can show the visitor what they have shared so far, for example a card beside the chat that fills in as they talk.
The chat settings (config from the start response, or GET/public/v1/config) then include lead_capture: the details the chat asks for. Each label is a short name for people to read (the start of your label, or the key made readable), never the instructions the assistant reads. With no fields of your own, the list is name, email and phone:
{
"require_complete": false,
"fields": [
{ "key": "name", "label": "Name", "type": "string", "required": false },
{ "key": "email", "label": "Email", "type": "email", "required": false },
{ "key": "phone", "label": "Phone", "type": "phone", "required": false }
]
}The conversation reports what this visitor has given as lead, in message.completed, the updates poll and GET/public/v1/conversations/{id}. It is null until there is a lead, and holds only this conversation's details:
{ "fields": { "name": "Maya", "email": "maya@example.com" }, "complete": true }complete turns true once an email or phone number and every required field are in.
Talk instead of type
When the assistant takes voice in the browser (channels.web_chat.voice.enabled, with max_seconds from 60 to 600, 300 by default), the chat settings include voice: { "enabled": true, "max_seconds": 300 }. Preview links never offer it. The visitor can then switch to their microphone in the same conversation: the assistant hears what was typed so far and carries on, and the spoken turns join the transcript.
Start a voice session from a click, once the visitor has agreed to use their microphone. It returns a token for a LiveKit room, joined with LiveKit's client library as in calls in the browser:
import { Room, RoomEvent } from 'livekit-client'
async function talk(chat) {
const res = await fetch(`${API}/conversations/${chat.id}/voice`, {
method: 'POST',
headers: { Authorization: `Bearer ${chat.token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ consent: { microphone: true } }),
})
if (!res.ok) throw new Error((await res.json()).error)
const { data } = await res.json() // { voice_session_id, url, token, expires_at, max_seconds }
const room = new Room()
room.on(RoomEvent.TrackSubscribed, (track) => {
if (track.kind === 'audio') document.body.append(track.attach()) // the assistant's voice
})
// live captions: a segment can arrive more than once (interim, then final), so update it by id
room.registerTextStreamHandler('lk.transcription', async (reader, from) => {
const text = await reader.readAll()
const attrs = reader.info.attributes ?? {}
const who = from.identity === `web_${data.voice_session_id}` ? 'visitor' : 'assistant'
caption(attrs['lk.segment_id'] ?? reader.info.id, who, text, attrs['lk.transcription_final'] === 'true')
})
await room.connect(data.url, data.token)
await room.localParticipant.setMicrophoneEnabled(true)
return { room, voiceSessionId: data.voice_session_id }
}To hang up, call room.disconnect() and POST/public/v1/conversations/{id}/voice/{voiceSessionId}/end, which is safe to repeat. A session also ends after max_seconds, when the assistant hangs up, or when the browser never joins. GET/public/v1/conversations/{id}/voice/{voiceSessionId} says whether it is still running and why it ended.
Once it ends, the spoken turns arrive through the updates poll as messages with rich.metadata.channel voice, and the conversation is checked for a lead as a typed chat is. One voice session runs per conversation at a time, and none while a person has taken over. Each session is also a browser call: its voice_session_id is the call's ID, and once it ends the call appears in GET/api/calls with call_type web (a test key's with mode=test). Ending it leaves the conversation open for typing.
Errors
Refusals before the stream starts are JSON with an error code:
| Status | Error | Meaning |
|---|---|---|
| 401 | unauthorized | Missing, wrong or expired conversation token, or a conversation ID the token does not belong to |
| 403 | origin_not_allowed | The page's origin (the browser's Origin header) is not in the assistant's allowed_origins |
| 404 | not_found | Unknown or revoked publishable key, or web chat is off for the assistant |
| 409 | turn_in_progress | A reply is still being written |
| 409 | conversation_ended | The conversation is closed; start a new one |
| 409 | message_id_conflict | That client_message_id was used for a different message |
| 422 | invalid_content | Empty message with no files, or over 4,000 characters |
| 429 | rate_limited | Over this route's budget (30 a minute per IP for most chat routes, 60 per conversation and IP for updates); wait for Retry-After |
| 429 | key_quota_exceeded | The publishable key used a budget every visitor of your site shares (rate limits); wait for Retry-After |
| 503 | chat_temporarily_unavailable | Retry after the Retry-After seconds (usually 1) |
Starting a voice session can also answer 403 voice_disabled, 409 voice_session_active (end the running one first), 409 conversation_taken_over, 429 voice_busy (every voice line for the key is in use) or 503 voice_unavailable.
Inside the stream, error codes include llm_error (the model failed), turn_timeout (the reply took over two minutes) and llm_not_configured. Show what arrived, and let the visitor send again.