> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mithunai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Stream an answer as it is being written

> Ask a question and receive the answer as Server-Sent Events as it is generated. The same turn as Send a message, with the same body and rules; delivery differs.

This runs the same turn as [Send a message](/api-reference/conversations/send-message), with the same request body and the same rules: one question at a time, only the owner can ask, archived conversations refuse questions, and `idempotency_key` makes retries safe. The difference is delivery. The answer arrives as a `text/event-stream` response. See [Streaming](/api-reference/streaming) for a walkthrough.

**Refusals arrive before the stream opens.** Authentication, validation, permission checks and the one-turn-at-a-time claim all run first. If any of them fails, you get an ordinary HTTP error with a JSON body (`400`, `401`, `403`, `404`, `409` or `429`) and no stream.

This request also resolves the conversation's assistant. If that assistant can no longer answer (it is `disabled` or `archived`, or has no knowledge attached), this request returns `400` `validation_error`.

<Warning>
  A `200` does not mean the answer succeeded. Once the stream opens, a failure is reported as a
  terminal `answer.error` event on the `200` response. Always read the terminal event.
</Warning>

If you retry with an `idempotency_key` whose turn has already finished, the stored turn is replayed as a stream: `message.created`, `answer.started`, the full answer text in one `answer.delta`, its `answer.sources` if it has citations, and a terminal event matching how the turn ended. The model is not asked again. A replayed turn that had failed ends with `answer.error`: send a new key to ask again.

If you disconnect, the answer is saved with whatever text was produced and marked `interrupted`, and the conversation is freed for the next question. Streams cannot be resumed and `Last-Event-ID` is not supported; read the answer back with [List messages](/api-reference/conversations/list-messages).

<ParamField path="conversation_id" type="string" required>
  The conversation ID.
</ParamField>

<ParamField body="text" type="string" required>
  The question. 1 to 16,000 characters after trimming. Newlines and tabs are allowed; other control
  characters and invisible formatting characters are rejected.
</ParamField>

<ParamField body="idempotency_key" type="string">
  Your own key for this question, unique within the conversation. Up to 128 characters from `A-Z`,
  `a-z`, `0-9`, `_`, `.`, `:` and `-`.
</ParamField>

<ParamField body="metadata" type="object">
  Your own labels for the question. At most 20 entries; keys up to 64 characters matching
  `^[a-z][a-z0-9_.-]*$`; values up to 512 characters on a single line.
</ParamField>

## Response

Returns `200 OK` with `Content-Type: text/event-stream; charset=utf-8`. Each event is framed as:

```text theme={"theme":{"light":"github-light","dark":"github-dark"}}
id: 3
event: answer.delta
data: {"text":"Mint a new key, "}

```

* `id` is the event's sequence number. It starts at 1 and increases by exactly 1, so a gap means you lost an event.
* `event` is the event type. It is the only place the type appears: `data` does not repeat it.
* `data` is a single-line JSON object. Non-ASCII characters are escaped as `\uXXXX`.
* Ignore event types you do not recognize. New ones may be added.

The first event is always `message.created`. Exactly one terminal event ends the stream: `answer.completed`, `answer.interrupted` or `answer.error`.

#### `message.created`

The question is saved and an answer message exists with `status` `pending`.

<ResponseField name="conversation_id" type="string" required>
  The conversation ID.
</ResponseField>

<ResponseField name="user_message_id" type="string" required>
  The ID of your question message.
</ResponseField>

<ResponseField name="answer_message_id" type="string" required>
  The ID of the answer message being generated.
</ResponseField>

#### `answer.started`

Generation has begun. The gap between `message.created` and this event is retrieval.

<ResponseField name="answer_message_id" type="string" required>
  The ID of the answer message.
</ResponseField>

#### `answer.delta`

A piece of answer text. Each delta is an increment, not the text so far: append deltas in order to build the answer.

<ResponseField name="text" type="string" required>
  The next piece of answer text.
</ResponseField>

#### `answer.sources`

The citations the answer uses so far. A new `answer.sources` is sent whenever the answer first cites another source, and it always arrives **before** the `answer.delta` containing that source's `[n]` marker, so every marker you render can be linked immediately. Each event carries the full list, so replace your citation list rather than appending to it.

When the answer has citations, a final `answer.sources` with the complete list is sent just before `answer.completed`. If the assistant abstains after sources were sent, a final `answer.sources` with an empty `citations` list clears them.

<ResponseField name="citations" type="object[]" required>
  <Expandable title="properties">
    <ResponseField name="source_id" type="string">
      Identifier of the cited passage. It stays the same while the document's content is unchanged
      and changes when changed content is re-ingested. It is not a knowledge source ID or a document
      ID.
    </ResponseField>

    <ResponseField name="ordinal" type="integer">
      The citation's number, from 1, matching the `[1]`-style marker in the answer text.
    </ResponseField>

    <ResponseField name="title" type="string">
      Title of the cited document. May be an empty string.
    </ResponseField>

    <ResponseField name="url" type="string | null">
      The source URL, when the document has one.
    </ResponseField>

    <ResponseField name="snippet" type="string | null">
      An excerpt supporting the answer.
    </ResponseField>

    <ResponseField name="anchor" type="string | null">
      A location within the source, such as a heading or page.
    </ResponseField>

    <ResponseField name="score" type="number | null">
      The retrieval relevance score of the cited passage. Higher is more relevant. Use it for
      diagnostics and ranking, not as a probability.
    </ResponseField>
  </Expandable>
</ResponseField>

#### `answer.replaced`

The answer's final checks withdrew or rewrote the text already streamed, for example turning an answer that did not cite its sources into an abstention, or removing a link that could not be verified. Discard every `answer.delta` you have received and show this text instead. Later deltas, if any, append to it. The stored message holds only the final text.

<ResponseField name="text" type="string" required>
  The whole answer so far, replacing everything streamed before it.
</ResponseField>

#### `answer.completed` (terminal)

The answer finished and is saved with `status` `complete`.

<ResponseField name="answer_message_id" type="string" required>
  The ID of the answer message.
</ResponseField>

<ResponseField name="status" type="string" required>
  `answered`, or `abstained` when the assistant declined because your knowledge did not support an
  answer.
</ResponseField>

<ResponseField name="abstained" type="boolean" required>
  `true` for an abstention. An abstention is a successful outcome, not an error; do not present its
  sources as supporting an answer. See [How answers work](/concepts/how-answers-work).
</ResponseField>

<ResponseField name="finish_reason" type="string | null" required>
  Why generation stopped, as reported. `length` means the answer was cut off at the output limit and
  is incomplete.
</ResponseField>

<ResponseField name="abstention_reason" type="string | null" required>
  Why the assistant declined, or `null` when it declined without naming a reason. Always `null` when
  `abstained` is `false`.

  An **open string**: match the values you know and fall back to a generic notice for one you do not.
  New values are not a breaking change.

  | Value                    | What it means                                                               | What changes it                                                                |
  | ------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
  | `no_evidence`            | Nothing came back from your sources at all.                                 | Connect a source that covers the subject.                                      |
  | `below_score_threshold`  | Passages came back; none was a close enough match.                          | A source that covers it directly, or a lower grounding threshold.              |
  | `weak_best_match`        | Passages cleared the floor, but the closest was still only loosely related. | A more specific question, or better coverage.                                  |
  | `too_few_passages`       | Fewer passages cleared the bar than the assistant answers from.             | Coverage in more than one place, or a lower minimum.                           |
  | `model_declined`         | The passages passed the gate and the model still judged them insufficient.  | Reword, or improve the source. A retrieval-quality signal, not a coverage gap. |
  | `no_supporting_citation` | A draft answer cited no real passage, so it was withheld.                   | Nothing on your side — the grounding check working.                            |
  | `unverified_link`        | The draft contained a link that appears in none of the cited passages.      | Same.                                                                          |
  | `provider_filtered`      | The model provider declined to produce output.                              | Reword, or retry.                                                              |
  | `empty_generation`       | The provider returned nothing.                                              | Retry.                                                                         |

  The widget surface receives a deliberately coarser set — see
  [Stream a thread message](/api-reference/widget/stream-thread-message).

  This is a streaming field only: a message read back from
  [List messages](/api-reference/conversations/list-messages) carries `abstained` but no reason, and a
  replayed idempotent turn sends `null`.
</ResponseField>

#### `answer.interrupted` (terminal)

Generation produced some text and then stopped early. The partial answer is saved with `status` `interrupted`. You have text to show, but it is not a complete answer.

<ResponseField name="answer_message_id" type="string" required>
  The ID of the answer message.
</ResponseField>

<ResponseField name="reason" type="string" required>
  Why it stopped: an error code such as `provider_failure` when generation failed part-way,
  `incomplete_stream` when generation ended without finishing, or `interrupted` on a replayed turn.
</ResponseField>

#### `answer.error` (terminal)

Generation failed before producing any text. The answer is saved with `status` `failed`, and the conversation is free for the next question.

<ResponseField name="code" type="string" required>
  A stable error code, such as `provider_failure`, `retrieval_failure`, `dependency_failure` or
  `internal_error`. See [Errors](/api-reference/errors).
</ResponseField>

<ResponseField name="message" type="string" required>
  A client-safe message. It never contains internal detail.
</ResponseField>

<RequestExample>
  ```bash cURL theme={"theme":{"light":"github-light","dark":"github-dark"}}
  curl -N --request POST "$MITHUNAI_URL/arukz/api/v1/conversations/c41f8a2e-6d93-4b0a-b7e5-3f1d2c8a9e60/messages:stream" \
    --header "Authorization: Bearer $MITHUNAI_API_KEY" \
    --header "Content-Type: application/json" \
    --data '{"text": "How do I rotate an API key?", "idempotency_key": "ticket-4821-q1"}'
  ```

  ```python Python theme={"theme":{"light":"github-light","dark":"github-dark"}}
  import json, os, requests

  conversation_id = "c41f8a2e-6d93-4b0a-b7e5-3f1d2c8a9e60"
  url = f"{os.environ['MITHUNAI_URL']}/arukz/api/v1/conversations/{conversation_id}/messages:stream"

  with requests.post(
      url,
      headers={"Authorization": f"Bearer {os.environ['MITHUNAI_API_KEY']}"},
      json={"text": "How do I rotate an API key?", "idempotency_key": "ticket-4821-q1"},
      stream=True,
      timeout=120,
  ) as response:
      response.raise_for_status()  # Refusals arrive here, before any event.
      event = None
      for line in response.iter_lines(decode_unicode=True):
          if line.startswith("event: "):
              event = line[len("event: "):]
          elif line.startswith("data: "):
              data = json.loads(line[len("data: "):])
              if event == "answer.delta":
                  print(data["text"], end="", flush=True)
              elif event == "answer.replaced":
                  print(f"\n[replaced]\n{data['text']}", end="", flush=True)
              elif event == "answer.completed":
                  print(f"\n[{data['status']}]")
              elif event in ("answer.interrupted", "answer.error"):
                  print(f"\n[{event}] {data}")
  ```

  ```javascript JavaScript theme={"theme":{"light":"github-light","dark":"github-dark"}}
  const conversationId = 'c41f8a2e-6d93-4b0a-b7e5-3f1d2c8a9e60'
  const response = await fetch(
    `${process.env.MITHUNAI_URL}/arukz/api/v1/conversations/${conversationId}/messages:stream`,
    {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.MITHUNAI_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        text: 'How do I rotate an API key?',
        idempotency_key: 'ticket-4821-q1',
      }),
    },
  )
  if (!response.ok) throw new Error(JSON.stringify(await response.json()))

  const reader = response.body.getReader()
  const decoder = new TextDecoder()
  let buffer = ''
  for (;;) {
    const { done, value } = await reader.read()
    if (done) break
    buffer += decoder.decode(value, { stream: true })
    let boundary
    while ((boundary = buffer.indexOf('\n\n')) !== -1) {
      const record = buffer.slice(0, boundary)
      buffer = buffer.slice(boundary + 2)
      let event = ''
      let data = ''
      for (const line of record.split('\n')) {
        if (line.startsWith('event: ')) event = line.slice(7)
        else if (line.startsWith('data: ')) data = line.slice(6)
      }
      const payload = JSON.parse(data)
      if (event === 'answer.delta') process.stdout.write(payload.text)
      else if (event === 'answer.replaced') process.stdout.write(`\n[replaced]\n${payload.text}`)
      else if (event === 'answer.completed') console.log(`\n[${payload.status}]`)
      else if (event === 'answer.interrupted' || event === 'answer.error')
        console.log(`\n[${event}]`, payload)
    }
  }
  ```
</RequestExample>

<ResponseExample>
  ```text 200 theme={"theme":{"light":"github-light","dark":"github-dark"}}
  id: 1
  event: message.created
  data: {"answer_message_id":"7c3a9e52-1b6d-4f08-a4e7-2d9b5f1c8e36","conversation_id":"c41f8a2e-6d93-4b0a-b7e5-3f1d2c8a9e60","user_message_id":"0e6b1d4f-8a27-4c93-b5d0-7f2e9a3c6b18"}

  id: 2
  event: answer.started
  data: {"answer_message_id":"7c3a9e52-1b6d-4f08-a4e7-2d9b5f1c8e36"}

  id: 3
  event: answer.delta
  data: {"text":"Mint a new key, move your integration to it, "}

  id: 4
  event: answer.sources
  data: {"citations":[{"anchor":"rotation","ordinal":1,"score":0.82,"snippet":"Rotation is mint-then-revoke: create the replacement first, then revoke the old key.","source_id":"3d8f2a61-7e4b-4c19-9a05-b6e1d7c2f480","title":"Managing API keys","url":"https://docs.example.com/admin/api-keys#rotation"}]}

  id: 5
  event: answer.delta
  data: {"text":"then revoke the old key [1]."}

  id: 6
  event: answer.sources
  data: {"citations":[{"anchor":"rotation","ordinal":1,"score":0.82,"snippet":"Rotation is mint-then-revoke: create the replacement first, then revoke the old key.","source_id":"3d8f2a61-7e4b-4c19-9a05-b6e1d7c2f480","title":"Managing API keys","url":"https://docs.example.com/admin/api-keys#rotation"}]}

  id: 7
  event: answer.completed
  data: {"abstained":false,"abstention_reason":null,"answer_message_id":"7c3a9e52-1b6d-4f08-a4e7-2d9b5f1c8e36","finish_reason":"end_turn","status":"answered"}

  ```

  ```text 200 (failure) theme={"theme":{"light":"github-light","dark":"github-dark"}}
  id: 1
  event: message.created
  data: {"answer_message_id":"7c3a9e52-1b6d-4f08-a4e7-2d9b5f1c8e36","conversation_id":"c41f8a2e-6d93-4b0a-b7e5-3f1d2c8a9e60","user_message_id":"0e6b1d4f-8a27-4c93-b5d0-7f2e9a3c6b18"}

  id: 2
  event: answer.started
  data: {"answer_message_id":"7c3a9e52-1b6d-4f08-a4e7-2d9b5f1c8e36"}

  id: 3
  event: answer.error
  data: {"code":"provider_failure","message":"The upstream provider could not complete the request."}

  ```

  ```json 409 theme={"theme":{"light":"github-light","dark":"github-dark"}}
  { "code": "conflict", "message": "This conversation is already waiting for an answer." }
  ```

  ```json 404 theme={"theme":{"light":"github-light","dark":"github-dark"}}
  { "code": "not_found", "message": "The requested resource was not found." }
  ```
</ResponseExample>
