Stream a widget answer to a visitor
Ask in a widget thread and receive the answer as Server-Sent Events while it is written, so the visitor watches it appear instead of waiting.
POST
/
widget
/
conversations
/
{conversation_id}
/
messages:stream
// Runs on your website.
const MITHUNAI_API = `${MITHUNAI_URL}/mithunai/api/v1`
const WIDGET_KEY = 'mithunai_wk_8d0f5a2e-3c41-4b7a-9e6d-1f2a3b4c5d6e'
const conversationId = sessionStorage.getItem('mithunai.conversation')
const response = await fetch(
`${MITHUNAI_API}/widget/conversations/${conversationId}/messages:stream`,
{
method: 'POST',
headers: {
'X-MITHUNAI-Widget-Key': WIDGET_KEY,
'X-MITHUNAI-Visitor-Token': sessionStorage.getItem('mithunai.visitorToken'),
'Content-Type': 'application/json',
},
body: JSON.stringify({ text: 'How do I raise my rate limit?' }),
},
)
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
let buffer = ''
let answer = ''
for (;;) {
const { value, done } = await reader.read()
if (done) break
buffer += value
let boundary
while ((boundary = buffer.indexOf('\n\n')) !== -1) {
const record = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const event = record.match(/^event: (.*)$/m)?.[1]
const data = JSON.parse(record.match(/^data: (.*)$/m)?.[1] ?? '{}')
if (event === 'answer.delta') answer += data.text
if (event === 'answer.replaced') answer = data.text
if (event === 'answer.sources') console.log('sources', data.citations)
if (event === 'answer.completed') console.log(answer, 'abstained:', data.abstained)
if (event === 'answer.error') console.error(data.code, data.message)
}
}
curl -N --request POST "$MITHUNAI_URL/mithunai/api/v1/widget/conversations/0e6a9b52-7d3f-4c18-a2e5-9b8c7d6e5f4a/messages:stream" \
--header "X-MITHUNAI-Widget-Key: mithunai_wk_8d0f5a2e-3c41-4b7a-9e6d-1f2a3b4c5d6e" \
--header "X-MITHUNAI-Visitor-Token: $VISITOR_TOKEN" \
--header "Origin: https://docs.example.com" \
--header "Content-Type: application/json" \
--data '{"text": "How do I raise my rate limit?"}'
id: 1
event: message.created
data: {"answer_message_id":"9f8e7d6c-5b4a-4c3d-9e2f-1a0b9c8d7e6f","conversation_id":"0e6a9b52-7d3f-4c18-a2e5-9b8c7d6e5f4a","user_message_id":"4a1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"}
id: 2
event: answer.started
data: {"answer_message_id":"9f8e7d6c-5b4a-4c3d-9e2f-1a0b9c8d7e6f"}
id: 3
event: answer.delta
data: {"text":"Rate limits are set per plan. "}
id: 4
event: answer.sources
data: {"citations":[{"anchor":"requesting-a-higher-limit","ordinal":1,"score":0.82,"snippet":"To request a higher limit, open a support ticket from the billing page.","source_id":"6e1d3c5b-9a7f-4b2e-8d0c-3f5a7b9d1e2f","title":"Rate limits","url":"https://docs.example.com/guides/rate-limits"}]}
id: 5
event: answer.delta
data: {"text":"To request a higher limit, open a support ticket from the billing page [1]."}
id: 6
event: answer.sources
data: {"citations":[{"anchor":"requesting-a-higher-limit","ordinal":1,"score":0.82,"snippet":"To request a higher limit, open a support ticket from the billing page.","source_id":"6e1d3c5b-9a7f-4b2e-8d0c-3f5a7b9d1e2f","title":"Rate limits","url":"https://docs.example.com/guides/rate-limits"}]}
id: 7
event: answer.completed
data: {"abstained":false,"abstention_reason":null,"answer_message_id":"9f8e7d6c-5b4a-4c3d-9e2f-1a0b9c8d7e6f","finish_reason":"stop","status":"answered"}
{ "code": "authentication_error", "message": "Authentication is required." }
{ "code": "authorization_error", "message": "This widget may not be embedded from that origin." }
{ "code": "not_found", "message": "The requested resource was not found." }
{ "code": "conflict", "message": "This conversation is already waiting for an answer." }
{
"code": "quota_exceeded",
"message": "The monthly answer limit for this account has been reached."
}
Call this from the visitor’s browser. It takes the embed’s public widget key in
Exactly one terminal event ends every stream. An abstention ends with
A widget visitor is anonymous — anyone who can load the page the widget is embedded in. Telling them apart which kind of gap they hit would let a stranger map a private knowledge base one question at a time, without ever being shown a passage.
X-MITHUNAI-Widget-Key, and the browser’s Origin must be allowed by both the deployment’s allowed_origins and the platform-wide widget allowlist. See Website widget.
You must also send the thread’s X-MITHUNAI-Visitor-Token. Every check (key, origin, visitor token, one question at a time, input validation) runs before the stream opens, so a refusal is an ordinary HTTP error with a JSON body, never an event on a 200 stream. A missing token, another thread’s token and an unknown conversation all return the same 404; a question sent while another answer is being generated returns 409.
The request body is the same as Send a widget message. Because the request is a POST with custom headers, read the stream with fetch rather than EventSource. Retrying with the same idempotency_key after a dropped connection replays the settled answer as a stream without calling the model again.
A successful response is 200 with Content-Type: text/event-stream. Each event has an id (a sequence number that increases by one), an event name and a single-line JSON data payload. message.created and answer.started always come first; answer.delta, answer.sources and answer.replaced follow as the answer is written; exactly one terminal event comes last:
| Event | data | When |
|---|---|---|
message.created | conversation_id, user_message_id, answer_message_id | First. The question is saved. |
answer.started | answer_message_id | Generation has begun. |
answer.sources | citations (same shape as a message’s citations) | Whenever the answer first cites another source, before the delta containing its [n] marker, and once more with the complete list just before answer.completed. Each carries the full list: replace, don’t append. An empty list after sources were sent means the assistant abstained. |
answer.delta | text | Each new piece of the answer. Append in order. |
answer.replaced | text | The final checks withdrew or rewrote the streamed text. Discard every earlier delta and show this text instead. |
answer.completed | answer_message_id, status (answered or abstained), abstained, finish_reason, abstention_reason | Terminal. The answer is saved as complete. |
answer.interrupted | answer_message_id, reason | Terminal. Generation stopped early; the partial text is kept. |
answer.error | code, message | Terminal. Generation failed after the stream opened, before any answer text was produced. |
answer.completed and abstained: true; it is a successful answer, not an error. See Streaming and How answers work.
abstention_reason on this surface is coarser than the authenticated API’s, and has only two values:
| Value | Meaning |
|---|---|
unsupported | The sources did not support an answer. |
unavailable | The model returned nothing; the question was never judged against the sources. |
unavailable stays separable because it says nothing about the corpus and “try again” is different advice. It may also be null.
string
required
The conversation’s ID (UUID).
string
required
The deployment’s public widget key,
mithunai_wk_….string
required
The visitor token returned when this thread was started.
string
required
Set by the browser. It must exactly match an origin allowed for this deployment. When you call
from outside a browser, set it yourself.
string
required
The question. Up to 16,000 characters, and not empty after surrounding whitespace is trimmed.
string
Your own key for this question, up to 128 characters of
A-Z, a-z, 0-9, _, ., : and
-.object
Your own string labels for the question. At most 20 entries; keys up to 64 characters, values up
to 512.
Response
Atext/event-stream body of the events above. Error responses before the stream opens are JSON, in the usual {"code": ..., "message": ...} shape. See Errors.
// Runs on your website.
const MITHUNAI_API = `${MITHUNAI_URL}/mithunai/api/v1`
const WIDGET_KEY = 'mithunai_wk_8d0f5a2e-3c41-4b7a-9e6d-1f2a3b4c5d6e'
const conversationId = sessionStorage.getItem('mithunai.conversation')
const response = await fetch(
`${MITHUNAI_API}/widget/conversations/${conversationId}/messages:stream`,
{
method: 'POST',
headers: {
'X-MITHUNAI-Widget-Key': WIDGET_KEY,
'X-MITHUNAI-Visitor-Token': sessionStorage.getItem('mithunai.visitorToken'),
'Content-Type': 'application/json',
},
body: JSON.stringify({ text: 'How do I raise my rate limit?' }),
},
)
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
let buffer = ''
let answer = ''
for (;;) {
const { value, done } = await reader.read()
if (done) break
buffer += value
let boundary
while ((boundary = buffer.indexOf('\n\n')) !== -1) {
const record = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const event = record.match(/^event: (.*)$/m)?.[1]
const data = JSON.parse(record.match(/^data: (.*)$/m)?.[1] ?? '{}')
if (event === 'answer.delta') answer += data.text
if (event === 'answer.replaced') answer = data.text
if (event === 'answer.sources') console.log('sources', data.citations)
if (event === 'answer.completed') console.log(answer, 'abstained:', data.abstained)
if (event === 'answer.error') console.error(data.code, data.message)
}
}
curl -N --request POST "$MITHUNAI_URL/mithunai/api/v1/widget/conversations/0e6a9b52-7d3f-4c18-a2e5-9b8c7d6e5f4a/messages:stream" \
--header "X-MITHUNAI-Widget-Key: mithunai_wk_8d0f5a2e-3c41-4b7a-9e6d-1f2a3b4c5d6e" \
--header "X-MITHUNAI-Visitor-Token: $VISITOR_TOKEN" \
--header "Origin: https://docs.example.com" \
--header "Content-Type: application/json" \
--data '{"text": "How do I raise my rate limit?"}'
id: 1
event: message.created
data: {"answer_message_id":"9f8e7d6c-5b4a-4c3d-9e2f-1a0b9c8d7e6f","conversation_id":"0e6a9b52-7d3f-4c18-a2e5-9b8c7d6e5f4a","user_message_id":"4a1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"}
id: 2
event: answer.started
data: {"answer_message_id":"9f8e7d6c-5b4a-4c3d-9e2f-1a0b9c8d7e6f"}
id: 3
event: answer.delta
data: {"text":"Rate limits are set per plan. "}
id: 4
event: answer.sources
data: {"citations":[{"anchor":"requesting-a-higher-limit","ordinal":1,"score":0.82,"snippet":"To request a higher limit, open a support ticket from the billing page.","source_id":"6e1d3c5b-9a7f-4b2e-8d0c-3f5a7b9d1e2f","title":"Rate limits","url":"https://docs.example.com/guides/rate-limits"}]}
id: 5
event: answer.delta
data: {"text":"To request a higher limit, open a support ticket from the billing page [1]."}
id: 6
event: answer.sources
data: {"citations":[{"anchor":"requesting-a-higher-limit","ordinal":1,"score":0.82,"snippet":"To request a higher limit, open a support ticket from the billing page.","source_id":"6e1d3c5b-9a7f-4b2e-8d0c-3f5a7b9d1e2f","title":"Rate limits","url":"https://docs.example.com/guides/rate-limits"}]}
id: 7
event: answer.completed
data: {"abstained":false,"abstention_reason":null,"answer_message_id":"9f8e7d6c-5b4a-4c3d-9e2f-1a0b9c8d7e6f","finish_reason":"stop","status":"answered"}
{ "code": "authentication_error", "message": "Authentication is required." }
{ "code": "authorization_error", "message": "This widget may not be embedded from that origin." }
{ "code": "not_found", "message": "The requested resource was not found." }
{ "code": "conflict", "message": "This conversation is already waiting for an answer." }
{
"code": "quota_exceeded",
"message": "The monthly answer limit for this account has been reached."
}
Last modified on November 7, 2026
// Runs on your website.
const MITHUNAI_API = `${MITHUNAI_URL}/mithunai/api/v1`
const WIDGET_KEY = 'mithunai_wk_8d0f5a2e-3c41-4b7a-9e6d-1f2a3b4c5d6e'
const conversationId = sessionStorage.getItem('mithunai.conversation')
const response = await fetch(
`${MITHUNAI_API}/widget/conversations/${conversationId}/messages:stream`,
{
method: 'POST',
headers: {
'X-MITHUNAI-Widget-Key': WIDGET_KEY,
'X-MITHUNAI-Visitor-Token': sessionStorage.getItem('mithunai.visitorToken'),
'Content-Type': 'application/json',
},
body: JSON.stringify({ text: 'How do I raise my rate limit?' }),
},
)
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader()
let buffer = ''
let answer = ''
for (;;) {
const { value, done } = await reader.read()
if (done) break
buffer += value
let boundary
while ((boundary = buffer.indexOf('\n\n')) !== -1) {
const record = buffer.slice(0, boundary)
buffer = buffer.slice(boundary + 2)
const event = record.match(/^event: (.*)$/m)?.[1]
const data = JSON.parse(record.match(/^data: (.*)$/m)?.[1] ?? '{}')
if (event === 'answer.delta') answer += data.text
if (event === 'answer.replaced') answer = data.text
if (event === 'answer.sources') console.log('sources', data.citations)
if (event === 'answer.completed') console.log(answer, 'abstained:', data.abstained)
if (event === 'answer.error') console.error(data.code, data.message)
}
}
curl -N --request POST "$MITHUNAI_URL/mithunai/api/v1/widget/conversations/0e6a9b52-7d3f-4c18-a2e5-9b8c7d6e5f4a/messages:stream" \
--header "X-MITHUNAI-Widget-Key: mithunai_wk_8d0f5a2e-3c41-4b7a-9e6d-1f2a3b4c5d6e" \
--header "X-MITHUNAI-Visitor-Token: $VISITOR_TOKEN" \
--header "Origin: https://docs.example.com" \
--header "Content-Type: application/json" \
--data '{"text": "How do I raise my rate limit?"}'
id: 1
event: message.created
data: {"answer_message_id":"9f8e7d6c-5b4a-4c3d-9e2f-1a0b9c8d7e6f","conversation_id":"0e6a9b52-7d3f-4c18-a2e5-9b8c7d6e5f4a","user_message_id":"4a1b2c3d-5e6f-4a7b-8c9d-0e1f2a3b4c5d"}
id: 2
event: answer.started
data: {"answer_message_id":"9f8e7d6c-5b4a-4c3d-9e2f-1a0b9c8d7e6f"}
id: 3
event: answer.delta
data: {"text":"Rate limits are set per plan. "}
id: 4
event: answer.sources
data: {"citations":[{"anchor":"requesting-a-higher-limit","ordinal":1,"score":0.82,"snippet":"To request a higher limit, open a support ticket from the billing page.","source_id":"6e1d3c5b-9a7f-4b2e-8d0c-3f5a7b9d1e2f","title":"Rate limits","url":"https://docs.example.com/guides/rate-limits"}]}
id: 5
event: answer.delta
data: {"text":"To request a higher limit, open a support ticket from the billing page [1]."}
id: 6
event: answer.sources
data: {"citations":[{"anchor":"requesting-a-higher-limit","ordinal":1,"score":0.82,"snippet":"To request a higher limit, open a support ticket from the billing page.","source_id":"6e1d3c5b-9a7f-4b2e-8d0c-3f5a7b9d1e2f","title":"Rate limits","url":"https://docs.example.com/guides/rate-limits"}]}
id: 7
event: answer.completed
data: {"abstained":false,"abstention_reason":null,"answer_message_id":"9f8e7d6c-5b4a-4c3d-9e2f-1a0b9c8d7e6f","finish_reason":"stop","status":"answered"}
{ "code": "authentication_error", "message": "Authentication is required." }
{ "code": "authorization_error", "message": "This widget may not be embedded from that origin." }
{ "code": "not_found", "message": "The requested resource was not found." }
{ "code": "conflict", "message": "This conversation is already waiting for an answer." }
{
"code": "quota_exceeded",
"message": "The monthly answer limit for this account has been reached."
}