ADR-028: The answer reaches the reader while it is written 

Status: Accepted, amends ADR-007

Date: 2026-11-01

Context 

The chat ran a turn in a background process and polled every two seconds. A reader saw nothing of an answer until the turn had ended, and then, on average, another second later. On the demo the median turn took 11 s. nr-llm ADR-240 makes the text of a tool round available while the model writes it, in the process that runs the turn. It has to reach the browser from there.

Decision 

The conversation's row carries the text. The process writes the whole text so far to live_text, at most every 100 ms (LiveTextRecorder), and empties the column once the turn's messages are stored. Every reader starts from the same state, so a second tab, a reload in the middle of an answer and a reconnect need no code of their own. Only the transcript is a record.

A request of its own delivers it. GET /ai-chat/conversations/stream (ConversationStream) reads the row every 100 ms and sends what changed as Server-Sent Events, with the same headers and padding as nr-llm's playground and the Cowriter dialog. Through the demo's chain (Caddy, nginx, PHP-FPM) the Cowriter route delivered its events as they were sent, measured on 2026-11-01.

ADR-007 is amended, for its two reasons. The connection limit of FPM is met by maxLiveStreams and a lease; the message bus between the processes is the row, which the stream reads. Polling stays the contract.

Polling stays. The stream carries the text, the activity and the number of messages, nothing else. A message that arrives inside a turn that goes on makes the chat poll at once, as the end of the turn does. Its done event makes the chat poll once, at once, and that poll brings the messages, the approval card, the question and the guided state exactly as before. While a stream is open the poll runs every 10 s to notice a stream that went quiet. When a stream is refused or breaks without having delivered, the chat goes on polling every 2 s, and the poll shows live_text too.

A stream is bounded, because it holds a PHP-FPM worker. It opens only for a running turn and ends with it. It ends after 100 s in any case, and the chat opens a new one. maxLiveStreams conversations stream at the same time, 3 by default. A lease on the row counts them, so a stream that died stops counting after 15 s. A conversation over the limit is refused and polls. Two readers can pass the count at the same moment and go one over, and two tabs on one conversation count once: the limit is a margin below pm.max_children, not an exact number. The nginx comment of the demo records what a pinned pool costs.

No announcement per token. The running text is aria-hidden. The finished message is added to the live region as before and is announced once.

What a guardrail holds does not appear early. nr-llm decides: with a guardrail that holds a stream back (nr-llm ADR-238) the round is not streamed, and the text appears with the message. Otherwise a guardrail that refuses the finished text fails the turn after the reader saw it; the running text then goes and the error stands in its place.

Consequences 

  • The reader sees the first text about when the model has written the first 128 bytes, the part nr-llm's secret redaction holds back. An answer shorter than that appears when it is complete.
  • A model round that only calls a tool shows no text; the activity list and the typing indicator say the turn is working.
  • A provider that cannot stream a tool round behaves as before, through the poll.
  • Every running turn with a reader causes ten small reads per second and one write per 100 ms of model output.
  • Two more columns on the conversation. Neither is part of the full-row write, so a turn's final write cannot put back an old value.