Turn lifecycle
How a message becomes a turn, a stream of saved events and a final status, and how your browser picks the stream back up after a refresh.
A turnTurnLượtEverything between sending a message and getting the answer: checks, model calls, tool use, pauses for you, and the reply. It keeps running if you close the tab.Open in glossary is everything between sending a message and getting the answer: checks, model calls, tool use, pauses for you, and the stream back. One turn drives many model calls and leaves one audit row per call. The agent keeps working whether or not a browser is watching, and one reader serves every browser.
Key rules
- One message can mean many model calls, each logged and costed on its own.
- A turn is a list of typed blocks: thinking summaries, text, tool calls and results.
- Approvals and questions pause the same turn; answering resumes it.
- The agent keeps working if you close the tab.
- Refreshing never duplicates a block or repeats an action.
The next step would change a connected app, so the turn pauses and asks you. It waits as long as it takes — answering resumes the same turn.
Turn start
Five checks run in order before the turn is even saved. The first one that fails stops the message with a clear reason.
| Check | Rule |
|---|---|
| Department | Always set. Anyone unassigned sits in the organisation’s seeded General department. |
| Agent visibility | “Not found” and “not visible” both answer 404. If the organisation’s default-agent switch is on, a non-admin is refused rather than silently swapped to another agent. |
| Model | The per-message override, else the agent’s model, must be a served chat model your department has not blocked. An override can only narrow. |
| Policy | Fails closed: if no budget policy resolves, the message is refused. |
| Attachments | A document with no indexed copy fails the request. A document still being read does not. |
Then, in order:
- Short-term memory is built (see Building the prompt).
- The turn row is written, and the prompt is stored before the agent is invoked.
- The turn id returns as a response header before the stream body opens.
- The model is stored on the turn only when it differs from the agent’s own.
Thread naming
A thread opens unnamed and its first turn names it, in two writes:
- A name read off your opening message lands at once.
- The organisation’s background model then supplies a better one.
The browser refreshes its thread list when a turn settles; a short answer can settle before the background model’s reply returns.
- Only an unnamed thread is named, so your rename always survives, even one typed mid-turn.
- A missing background model, a gateway refusal or a department out of budget leave the first name standing.
- The naming call runs on the department’s gateway key and leaves the same audit row a chat turn leaves, naming no agent.
A short, safe summary of what the agent is doing — never its raw reasoning.
Each tool the agent uses appears as a row you can open to see what it was given and what came back.
When the next step changes something in a connected app, the turn stops and asks you first.
The final reply streams in word by word, with citations back to the documents it used.
Chunk persistence
The agent sends its work as chunks. Each chunk is written to the database before the next one is read, so a reload mid-stream recovers every block written so far.
| Chunk | Effect |
|---|---|
delta | Ephemeral; the block arrives whole later. |
tool_start | Ephemeral; opens the tool row while the model is still writing arguments, which can be a whole document. |
tool_progress | Ephemeral; how many argument characters the open call has written so far, at most twice a second. The row counts up toward each report over a second, so it trails the model by about half a second. |
compaction_start | Ephemeral; a summary is being written, which takes a model call during which nothing else arrives. It is raised from inside a hook while the stream is blocked, so it travels on a side channel merged into the stream. |
guardrail | Ephemeral; what one guardrail check found (checkpoint, kind, subject, action), never the text. Findings on your message are written before the runtime starts; findings on a tool result or the answer go ahead of the next chunk. The transcript carries the same findings per turn. |
block | Append the block, then advance the high-water mark (the last saved position). A tool result finalises the trace step of a call no portal route served. |
usage | Append a call step, write the audit row and rollup in one transaction, then check budget thresholds. A call on the turn’s own model also carries its input plus output as the context size; a summary, review or compaction call carries none. |
pause | Append the block, create the clarification or approval request, move the turn to awaiting. Published after its write, so it carries the request id. |
done | Succeed the turn with totals. |
error, or the stream ends | Fail or time out, and append a synthetic error chunk so a live reader stops at once. |
Trace rows. A tool call leaves exactly one trace row, with what it was given, what it answered and how long it took. The portal route that serves a tool writes it. Arithmetic runs inside the runtime and reaches no route, so the block stream records that one, along with every tool a connected app brings. Each call has exactly one writer.
Pauses. A pause ends this invocation only. Resuming moves the turn back to running under a second producer, whose chunks reach you through the same buffer.
Replay from a cursor
Every event is saved before it’s sent and carries a number. If your connection drops, the browser reconnects with the last number it saw and the portal replays only what you missed, then carries on live.
last event = 4 → replays 5–6 → follows live from 7 Everything on screen comes back, then the answer continues. Nothing appears twice.
The agent keeps working. Open the conversation later to see the result.
It waits for your approval or answer, however long you take.
The turn is marked as timed out and you see a clear end state, not a spinner.
- Agent events go to a Redis Streams buffer and out to the browser as server-sent events (SSE). One reader serves the first connection and every reconnect, from any backend instance.
- The run id is minted before the stream opens and never changes. The SSE event id is the chunk’s sequence number, increasing within the turn; Redis’s own entry id stays internal.
- Only
blockandpausechunks carry a sequence number, and so an event id. - History and completed blocks are durable in PostgreSQL. Redis is only the replay buffer for an active run: it holds a bounded number of entries, refreshes its expiry while the run is active, and drops the run once a terminal chunk lands.
- On refresh the browser reloads saved history, then reconnects with its last acknowledged event id. The backend reads entries strictly after that cursor.
- Saving deduplicates on message and sequence, so a replayed chunk changes nothing and repeats no side effect.
- A single frame may only grow so far. One that passes the bound is dropped and the stream carries on, because losing one tool result costs less than losing a turn that has run for minutes. A sender whose frame never ends still fails the turn.
| Cursor | What the reader answers |
|---|---|
| Matches a retained entry, or absent | Replay from there, then follow live until a terminal chunk. |
| Fell off the buffer, turn finished | A synthetic terminal-state event from the durable record. |
| Fell off, or silence for a whole idle window, turn still running | The driver died (deploy, crash, lost buffer connection). The run is recorded as timed out and the browser gets a terminal state. |
The third rule ends only a running turn; a turn awaiting you keeps waiting. A stale cursor is never guessed forward.
The reader’s wait is measured from the last event, not from when it connected. A live driver appends a heartbeat every few seconds, so silence past the idle window means nobody is left to wait for. Reopening a conversation after a restart ends its abandoned turn within one window.
| What happens | What you see |
|---|---|
| You refresh mid-answer | Everything on screen comes back, then the answer continues. Nothing appears twice. |
| You close the tab | The agent keeps working. Open the conversation later to see the result. |
| The turn is waiting for you | It waits for your approval or answer, however long you take. |
| The server running it stops | The turn is marked as timed out and you see a clear end state, not a spinner. |
Orphaned-run sweep
Only the process driving a turn writes its ending. A backend that stops mid-generation leaves the row running with nobody to finish it, and nothing in the conversation would ever ask again.
A sweep on the shared job queue ends those. A turn still running past the ceiling its own driver stops at cannot have a driver, so the sweep needs no liveness record of its own. It records the same interruption a reader does, and leaves paused turns alone: a paused turn is waiting for a person and may wait as long as it likes.
Run time limits
Two deadlines bound a run, and they answer different questions.
| Deadline | Question it answers | Behaviour |
|---|---|---|
| Idle window | How long may a run go without producing a single chunk? | Restarts on every chunk. A run that spends twenty minutes across a dozen tool calls is never cut off for being slow, only for going quiet. |
| Ceiling | How long may the whole run take, whatever it does? | Fixed. A turn’s gateway key is issued once at the start and expires on its own schedule; a run that outlived its key would start failing model calls halfway through. |
The deployment sets both. The key’s life must exceed the ceiling, and the configuration refuses to start otherwise.
Turn states
| State | Reached when |
|---|---|
| Running | The turn row is written; also when the last answer or decision resumes a paused turn. |
| Awaiting input or approval | A pause chunk lands. |
| Succeeded | A done chunk lands. |
| Failed | An error chunk lands, or the stream ends. |
| Timed out | The reader’s deadline passes with the driver gone, or the sweep ends an orphaned run. |
| Cancelled | You press Stop, from Running or Awaiting. Cancelled is the only status reachable from any non-terminal state, and carries no error code. |
Pressing Stop ends the turn at once and keeps whatever was already written. See Pausing and stopping.
Invariants
- One turn, N model calls, one audit row per call.
- A unique constraint on message and event sequence, so a replayed chunk cannot insert twice.
- No foreign key from the audit trail into chat; the two have independent retention.
- Runtime callbacks are guarded by a constant-time shared secret, name only a turn id, re-derive organisation, user and department from it, and reach only running turns.
- Trace ordinals are allocated atomically, since the model loop and the runtime’s callbacks both append for one turn; the chat screen orders steps by ordinal.
Why it works this way
Why save every event before sending it?
So a refresh, a second tab or a reconnect all read the same history. The browser never has to guess what it missed, and an action that already ran is never run again.
Why show thinking summaries instead of the model's reasoning?
The model’s raw reasoning never leaves the agent runtime. The organisation’s background model writes a few sentences there on what the agent considered and decided: enough to follow its direction and spot a wrong turn, without exposing unfiltered reasoning. See the agentic loop.
Why does a pause end the run and start a new one later?
You might answer in a minute or the next day, and a run has a fixed ceiling. So the question is saved, the run ends, and your answer starts a fresh run from the saved state. Nothing waits open in the meantime, and the reply carries on exactly where it stopped.
Why does a stale cursor never get guessed forward?
Guessing could skip or repeat blocks. When the cursor matches no retained entry, the durable record is the only honest answer: a finished turn reports its outcome, an abandoned running turn is ended, and a waiting turn is left alone.