# Turn lifecycle (/docs/turn-lifecycle)



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](/docs/architecture#1-stage-lattice).

Session minting, resume, and delete live a layer above the turn —
see [Session lifecycle](/docs/session-lifecycle) for that arc.

The
[runtime-lifecycle cluster](/docs/session-lifecycle#the-runtime-lifecycle-cluster)
names this concept alongside [Session lifecycle](/docs/session-lifecycle)
(the arc above the turn) and [Event log](/docs/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 [#the-big-picture]

<Mermaid
  chart="sequenceDiagram
    participant B as Browser
    participant R as SSE route
    participant SR as SessionRuntime
    participant P as Provider
    participant A as Audit ledger
    B->>R: POST /api/harness/sessions/[id]/execute
    R->>SR: runtime.executeMessage(sessionId, content)
    SR->>P: anchor-plan + tool-loop + synthesize calls
    P-->>SR: streaming deltas
    SR-->>R: yield StreamEvent (per chunk / lifecycle)
    SR->>A: recordCall (one row per addressable decision)
    R-->>B: data: {...}\n\n  (SSE frame)
    SR-->>R: yield done / error
    R-->>B: close"
/>

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](/docs/event-log)).

## Server side: where the runtime lives [#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.

```typescript
// 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](/docs/api-routes#post-apiharnesssessionsidexecute) for
the request body and response framing. The substrate-level handler
set is documented at [HarnessServer](/docs/server) for non-Next.js
transports.

## Client side: consuming the stream [#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.

```typescript
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](/docs/recipes#1-nextjs-app-router-chat-with-streaming).
Crib from that rather than re-deriving the consumer.

## The four-stage execution path [#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](/docs/architecture#1-stage-lattice).

### `anchor-plan` [#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"`),
  optionally `message.entities` for anchored references, `step.end`.
* Audit: one row per call with `payload.kind: "planGeneration"`.

### `tool-loop` [#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.delta` and writes
  a `cacheBreakpoint` row at the provider response boundary;
  in-family retries write `fallbackStep` rows.
* Each tool dispatch emits `tool.started`, optionally `tool.delta`
  for streaming argument assembly, then `tool.completed` or
  `tool.failed`. The dispatching call writes a `toolSelection` row;
  a cascade across tools writes `toolFallbackStep`.
* Async-job tools also emit `job.dispatched` / `job.progress` /
  `job.completed`.

### `synthesize` [#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 the
  `tool-loop` stage yields — readers discriminate by the `stageId`
  the runtime stamps on the envelope, not by a separate event name),
  then `message.complete` with the final assembled message.
* Audit: one row with `payload.kind: "synthesisQuality"`.

### `post-turn` [#post-turn]

Terminal stage. Durable writes flush, enrichment plugins run,
checkpoints land. No further LLM calls fire here.

* Stream events: `checkpoint.created` at the stage boundary,
  optionally late `message.citations` / `message.entities`, then
  the generator yields `done` and returns.
* Audit: no LLM rows; plugin-namespaced rows via `pluginPayloads`
  if the post-turn enrichment writes any.

## What the client sees, in order [#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](/docs/stream-events).

## What the audit ledger sees, in order [#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](/docs/auditable-call-row#typed-payload-slots)
for per-slot field shapes.

## Aborting mid-turn [#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](/docs/session-runtime#aborting-a-turn).

```typescript
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-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](/docs/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 [#where-to-go-next]

<Cards>
  <Card title="Session lifecycle" href="/docs/session-lifecycle" description="The arc above the turn — minting, resume, abort, time-travel, delete." />

  <Card title="SessionRuntime" href="/docs/session-runtime" description="The executeMessage signature, options, and abort handling." />

  <Card title="Stream events" href="/docs/stream-events" description="Every event type the generator yields, with payload shapes." />

  <Card title="Architecture" href="/docs/architecture" description="The static lattice — why these four stages, what the seams enforce." />

  <Card title="Recipe #1: Next.js chat" href="/docs/recipes#1-nextjs-app-router-chat-with-streaming" description="The canonical streaming-chat wiring, end to end." />
</Cards>
