pleach
Architecture

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"), optionally message.entities for 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.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

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

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

A reader who sees an event and wants to know which stage produced it:

Event typeStageWhen
step.start / step.endanyStage boundary bracket
message.entitiesanchor-plan (anchoring) or tool-loop (extraction)After a tool result lands or anchor pass completes
tool.started / tool.delta / tool.completed / tool.failedtool-loopPer tool dispatch within the loop
thinking.delta / thinking.completetool-loopProvider emits a reasoning trace
message.deltatool-loop then synthesizeStreaming token output
provider.cascadetool-loop or synthesizeA rung in the family-strict cascade failed
message.completesynthesizeFinal assembled assistant message
checkpoint.createdpost-turnStage boundary snapshot written
erroranyRecoverable 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:

StageTyped payloadWhen
anchor-planplanGenerationPlan composed
tool-loopcacheBreakpointPer LLM call, at the provider response boundary
tool-loopfallbackStepPer in-family retry rung
tool-looptoolSelectionPer LLM call that emits tool calls
tool-looptoolFallbackStepWhen the tool cascade pivots across tools
synthesizesynthesisQualityThe 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

On this page