Request Sequence
The sequence of interactions that handles a single user turn through
POST /chat. The FastAPI handler runs the turn through the compiled
LangGraph graph; which graph API it uses depends on content negotiation.
A plain JSON request (any Accept that is not text/event-stream) is
run via ainvoke and returns a ChatResponse. A request that carries
Accept: text/event-stream is run via astream and returns a
server-sent-events stream of per-node execution events; the Agent
Execution Graph in the demo single-page app consumes that stream. The
streaming variant is the second sequence below. Either way the guardrails
are not a tier the API orchestrates around the graph - they run as graph
nodes. guardrail_pre runs after intake; guardrail_post runs after
generate_response. A failing pre-guardrail decision is carried forward
on the state and short-circuits the LLM call inside generate_response;
the turn still flows through every downstream node, so even a refusal or
no-match turn comes back as an assistant message in the graph’s final
state. OpenTelemetry spans are opened in every node and around the LLM
call.
See c4-container.md for the static decomposition and c4-component.md for the node-and-module view.
Single completed turn
Section titled “Single completed turn”sequenceDiagram
autonumber
actor User
participant API as FastAPI /chat
participant Graph as LangGraph agent graph
participant RAG as RAG store
participant LLM as LLMClient
participant Provider as LLM Provider
User->>API: POST /chat {messages, locale, thread_id?}
activate API
API->>Graph: ainvoke(state, config={thread_id, model})
activate Graph
Note over Graph: node intake - mints trace id, increments turn
Note over Graph: node guardrail_pre - input-validation,<br/>PII redaction, escalation, rule-based scope,<br/>optional judge scope. Failing decisions are<br/>carried on the state.
alt clean turn and RAG enabled
Graph->>RAG: retrieve_context: embed query, query(top_k)
activate RAG
RAG-->>Graph: top_k hits (top_k from RETRIEVAL_TOP_K)
deactivate RAG
else pre-guardrail failure present
Note over Graph: conditional edge skips retrieve_context<br/>(rejected input is never embedded)
end
Note over Graph: node generate_response
alt pre-guardrail failure or no-match
Note over Graph: short-circuit, no LLM call:<br/>escalation template / refusal /<br/>no-context refusal
else proceed to generation
Graph->>LLM: complete(system + messages, params)
activate LLM
LLM->>Provider: HTTPS request
Provider-->>LLM: completion
LLM-->>Graph: completion (tokens_in, tokens_out, latency)
deactivate LLM
Note over Graph: extract + verify citations
end
Note over Graph: node guardrail_post - missing-citation check,<br/>persona-stability check (flag-only, never blocks)
Note over Graph: node closing - finalises turn
Graph-->>API: final state (messages, guardrail_decisions, cost)
deactivate Graph
API-->>User: 200 ChatResponse {content, citations,<br/>guardrail_decisions, cost, status=complete}
deactivate API
HITL pause and resume
Section titled “HITL pause and resume”When the graph is compiled with HITL enabled, a review_response node
sits between generate_response and guardrail_post. A
high-risk-but-not-acute draft pauses the graph via LangGraph
interrupt(); the turn is resumed by a separate POST /chat/resume call.
sequenceDiagram
autonumber
actor User
actor Reviewer as Human reviewer
participant API as FastAPI
participant Graph as LangGraph agent graph (HITL)
participant Saver as Checkpointer
User->>API: POST /chat {messages, locale}
activate API
API->>Graph: ainvoke(state, config={thread_id})
activate Graph
Note over Graph: intake -> guardrail_pre -><br/>[retrieve_context] -> generate_response
Note over Graph: node review_response - assess_review_need(state)
alt verdict = clear
Note over Graph: no-op, flows to guardrail_post then closing
Graph-->>API: final state (status=complete)
else verdict = needs review
Graph->>Saver: persist paused state
Graph-->>API: __interrupt__ with ReviewInterruptPayload
end
deactivate Graph
API-->>User: 200 {status=paused_for_review, review, content=draft}
deactivate API
Reviewer->>API: POST /chat/resume {thread_id, decision, edited_text?}
activate API
API->>Graph: aget_state then ainvoke(Command(resume=...))
activate Graph
Note over Graph: review_response re-enters, applies<br/>approve / edit / reject -> guardrail_post -> closing
Graph-->>API: final state (status=complete, hitl-review decision)
deactivate Graph
API-->>Reviewer: 200 ChatResponse {content, guardrail_decisions}
deactivate API
Streaming turn (Accept: text/event-stream)
Section titled “Streaming turn (Accept: text/event-stream)”When the request asks for text/event-stream, the handler drives the
same compiled graph through astream instead of ainvoke and maps each
per-node LangGraph event to a server-sent-events record. The stream
opens with a graph_topology event (so the SPA draws the real node set
before any node runs), then emits a node_started / node_completed
pair per executed node and a synthesized skipped node_completed per
genuinely bypassed conditional node, and ends with a terminal
turn_completed event carrying the full ChatResponse. A failure after
the first byte is an in-stream error event; a failure before the first
byte is a normal HTTP error. See
ADR-0009 for the event
schema.
sequenceDiagram
autonumber
actor Client as SSE client (demo SPA)
participant API as FastAPI /chat
participant Graph as LangGraph agent graph
Client->>API: POST /chat (Accept: text/event-stream)
activate API
Note over API: content negotiation selects the SSE path;<br/>build the graph_topology payload before streaming
API-->>Client: 200 text/event-stream (Cache-Control: no-cache,<br/>X-Accel-Buffering: no)
API-->>Client: event: graph_topology (real node set + edges + flags)
API->>Graph: astream(state, config={thread_id, model})
activate Graph
loop per executed node, in graph order
Graph-->>API: node lifecycle event
API-->>Client: event: node_started {node, run_id, ts_ms}
Note over Graph: node body runs (guardrails / retrieval /<br/>generation), spans opened as in the JSON path
Graph-->>API: node lifecycle event
API-->>Client: event: node_completed {node, status=executed,<br/>duration_ms}
end
opt a conditional node was genuinely bypassed
Note over API: diff topology vs nodes that emitted events
API-->>Client: event: node_completed {status=skipped,<br/>duration_ms=0}
end
Graph-->>API: final state
deactivate Graph
API-->>Client: event: turn_completed { ...full ChatResponse... }
deactivate API
A streaming HITL turn ends its /chat stream with a paused event
(carrying the ReviewInterruptPayload) instead of turn_completed, and
closes; the turn continues on a fresh POST /chat/resume SSE stream that
opens with its own graph_topology event, re-emits the post-pause
nodes, and ends with turn_completed carrying an envelope-level
human_wait_ms.