Turn lifecycle
What happens between runtime.executeMessage and the final SSE frame — the four-stage execution path, the events the client sees, and the rows the ledger writes.
A turn is one user message in, one assistant answer out. The runtime drives that arc through the four-stage lattice, yields typed stream events at every step, and writes one audit row per addressable decision. This page walks the dynamic path; the static lattice itself is documented at Architecture.
Session minting, resume, and delete live a layer above the turn — see Session lifecycle for that arc.
The runtime-lifecycle cluster names this concept alongside Session lifecycle (the arc above the turn) and Event log (the append-only stream both write into). Turn lifecycle is the per-message arc; the others are the container above and the persistence layer below.
The big picture
Five participants, three write streams: stream events to the client, ledger rows to the audit table, observable events to the event log (omitted here — see Event log).
Server side: where the runtime lives
One SessionRuntime per Node process — or per warm function
instance on a Fluid Compute / Lambda deployment. The runtime owns
the compiled graph, the channel state, and the seam-bound provider
entry points; constructing it is the cold-start cost.
The HTTP route is a thin adapter. It calls
runtime.executeMessage(sessionId, content), iterates the async
generator, and pipes each yielded event into an SSE data: frame.
// app/api/harness/sessions/[id]/execute/route.ts
import { createPleachRoute } from "@pleach/core/quickstart"
// Web-standard POST handler. Provider auto-detected from the env;
// pass { provider, plugins, tools, storage } to override.
export const POST = createPleachRoute()The execute route is mounted at
POST /api/harness/sessions/[id]/execute — see
API routes for
the request body and response framing. The substrate-level handler
set is documented at HarnessServer for non-Next.js
transports.
Client side: consuming the stream
The client POSTs to the execute route, reads the response body as a
stream of SSE frames, parses each frame into a StreamEvent, and
switches on type. The connection closes when the generator yields
done — or throws, in which case the last frame is an error
event.
const res = await fetch(`/api/harness/sessions/${sessionId}/execute`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
})
const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader()
let buffer = ""
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += value
const frames = buffer.split("\n\n")
buffer = frames.pop() ?? ""
for (const frame of frames) {
const line = frame.replace(/^data: /, "")
const event = JSON.parse(line) as StreamEvent
switch (event.type) {
case "message.delta": appendDelta(event.delta); break
case "tool.started": showSpinner(event.toolCall); break
case "tool.completed": resolveSpinner(event.toolCall, event.result); break
case "error": showError(event.error); break
}
}
}The full Next.js wiring — with auth, HarnessProvider, and the
useHarness hook on the client — is Recipe #1.
Crib from that rather than re-deriving the consumer.
The four-stage execution path
The runtime walks the lattice in order:
anchor-plan → tool-loop ⇄ synthesize → post-turn. Each stage
emits a distinct event family and writes its own ledger rows. The
structural why — why these four, why this order — lives in
Architecture § stage lattice.
anchor-plan
Bootstrap: classify the user's intent, build a plan, anchor any
references the tool loop will need. One or more utility /
reasoning LLM calls; no user-visible prose yet.
- Stream events:
step.start(step: "anchor-plan"), optionallymessage.entitiesfor anchored references,step.end. - Audit: one row per call with
payload.kind: "planGeneration".
tool-loop
The iterative core. Each iteration runs one reasoning LLM call
that may emit tool calls; the runtime dispatches them, collects
results, and re-enters the loop. The loop exits when the LLM
produces a response with no tool calls.
Per iteration:
- One LLM call streams
message.delta/thinking.deltaand writes acacheBreakpointrow at the provider response boundary; in-family retries writefallbackSteprows. - Each tool dispatch emits
tool.started, optionallytool.deltafor streaming argument assembly, thentool.completedortool.failed. The dispatching call writes atoolSelectionrow; a cascade across tools writestoolFallbackStep. - Async-job tools also emit
job.dispatched/job.progress/job.completed.
synthesize
Exactly one synthesize call per turn — structurally capped by
the SynthesizeSeamHolder singleton + TurnSynthesizeCounter so
the rendered string and the audited string are the same string.
- Stream events:
message.delta(the same event type thetool-loopstage yields — readers discriminate by thestageIdthe runtime stamps on the envelope, not by a separate event name), thenmessage.completewith the final assembled message. - Audit: one row with
payload.kind: "synthesisQuality".
post-turn
Terminal stage. Durable writes flush, enrichment plugins run, checkpoints land. No further LLM calls fire here.
- Stream events:
checkpoint.createdat the stage boundary, optionally latemessage.citations/message.entities, then the generator yieldsdoneand returns. - Audit: no LLM rows; plugin-namespaced rows via
pluginPayloadsif the post-turn enrichment writes any.
What the client sees, in order
A reader who sees an event and wants to know which stage produced it:
| Event type | Stage | When |
|---|---|---|
step.start / step.end | any | Stage boundary bracket |
message.entities | anchor-plan (anchoring) or tool-loop (extraction) | After a tool result lands or anchor pass completes |
tool.started / tool.delta / tool.completed / tool.failed | tool-loop | Per tool dispatch within the loop |
thinking.delta / thinking.complete | tool-loop | Provider emits a reasoning trace |
message.delta | tool-loop then synthesize | Streaming token output |
provider.cascade | tool-loop or synthesize | A rung in the family-strict cascade failed |
message.complete | synthesize | Final assembled assistant message |
checkpoint.created | post-turn | Stage boundary snapshot written |
error | any | Recoverable or terminal error |
The full payload catalog is at Stream events.
What the audit ledger sees, in order
The typed payload slots that land per stage:
| Stage | Typed payload | When |
|---|---|---|
anchor-plan | planGeneration | Plan composed |
tool-loop | cacheBreakpoint | Per LLM call, at the provider response boundary |
tool-loop | fallbackStep | Per in-family retry rung |
tool-loop | toolSelection | Per LLM call that emits tool calls |
tool-loop | toolFallbackStep | When the tool cascade pivots across tools |
synthesize | synthesisQuality | The one synthesize call |
Every row also carries the shared identity tuple
(sessionId, turnId, stageId, seqWithinTurn) and the
post-routing call shape — see
The AuditableCall row → typed payload slots
for per-slot field shapes.
Aborting mid-turn
Pass an AbortSignal into executeMessage. The signal propagates
into the active provider stream, the tool loop unwinds, and the
runtime flushes a final ledger row with outcome.status: "user-aborted"
so the partial input-token spend stays attributable.
The client sees one of two terminal states: a final error event
with the abort code, or the SSE connection closing. UI consumers
should treat them as the same end-of-stream. The abort code path is
documented at
SessionRuntime → Aborting a turn.
const ctrl = new AbortController()
const res = await fetch(url, { method: "POST", body, signal: ctrl.signal })
// later
ctrl.abort()Closing the SSE response from the client side has the same effect
on the server — createPleachRoute threads req.signal through to
the runtime's execute call.
Errors and recovery
Errors come from inside the loop: provider HTTP failures, tool
throws, guard rejections. The runtime doesn't surface them
immediately. For provider failures, the family-strict cascade walks
the next in-family rung and writes a providerCascade row per
step. For tool failures, the tool cascade tries the next in-class
tool and writes a toolFallbackStep row.
Only when every rung in the active class is exhausted does an
error event reach the client. The structured code field on the
event discriminates the failure mode — see
Error codes for the catalog. family-exhausted
is the canonical terminal state when the in-family ladder runs out;
the consumer surface is expected to ask the user to pick a
different family rather than silently widening.
Where to go next
Session lifecycle
The arc above the turn — minting, resume, abort, time-travel, delete.
SessionRuntime
The executeMessage signature, options, and abort handling.
Stream events
Every event type the generator yields, with payload shapes.
Architecture
The static lattice — why these four stages, what the seams enforce.
Recipe #1: Next.js chat
The canonical streaming-chat wiring, end to end.
Session lifecycle
How a session is minted, persisted, resumed, aborted, time-traveled, and deleted — the unit of identity that outlives any single turn.
Event log
The broader stream of observable events — distinct from the audit ledger — with durable-flush retries, hydration from events, and named projections.