Message Flow: End-to-End โ
TL;DR: User types. Socket.IO receives
message:send, validates, authenticates, rate-limits, saves the user message. The server spawns the LangGraph agentic-RAG pipeline, streams tokens back asmessage:ai:chunk, streams stage updates asrag:stage, then emitsmessage:ai:endwith sources. Three non-blocking post-processing jobs run in parallel: title generation, memory extraction, and APDR observations.Code path:
socket.tsโai-service.tsโlangchain.tsโagentic-rag.ts.
The full sequence โ
Layer boundaries โ
The WebSocket handler does transport and persistence. The AIService does access control and write-through persistence. LangChainService manages LLM config and picks between agentic RAG and the (effectively unused) basic-RAG fallback. Agentic-RAG is the actual brain.
Files and responsibilities:
| File | Lines | Role |
|---|---|---|
packages/api/src/chat/socket.ts | ~680 | WebSocket handler. Rate limit, validate, sanitise, save user message, emit events, spawn AI generation, post-processing. |
packages/api/src/chat/ai-service.ts | ~200 | Thin orchestration. Access verification (IDOR), crisis helpline safety net, persist AI message, resolve school URNs to human-readable names. |
packages/api/src/chat/langchain.ts | ~1450 | LLM client. Loads LLM settings from the database (60-second cache), routes between agentic and basic RAG, buffers streaming to avoid splitting security markers. |
packages/api/src/chat/agentic-rag.ts | ~3800 | The graph. State definition, nine nodes, routing edges, execution. See Agentic RAG. |
WebSocket events โ
Client to server:
| Event | Payload | Purpose |
|---|---|---|
message:send | { conversationId?, content, attachments? } | Send a message (creates conversation if needed) |
conversations:list | (n/a) | Request conversation list |
conversation:history | { conversationId } | Request message history |
conversation:delete | { conversationId } | Soft-delete a conversation |
typing:start / typing:stop | { conversationId } | Typing indicator |
message:feedback | { messageId, feedback } | Thumbs up (1), thumbs down (0), or clear (null) |
Server to client:
| Event | Payload | When |
|---|---|---|
message:created | { conversationId, message } | User message saved |
message:ai:start | { conversationId } | AI generation about to start |
message:ai:chunk | { conversationId, content } | Streaming token chunk |
rag:stage | { conversationId, stage } | Graph stage transition (see below) |
message:ai:end | { conversationId, message } | AI response complete with sources[] |
conversations:list | Conversation[] | Response to list request |
conversation:history | { conversationId, messages } | Response to history request |
conversation:deleted | { conversationId } | Deletion confirmed |
conversation:title:updated | { conversationId, title } | Auto-generated title arrived |
error | { message } | Error notification |
RAG stage events โ
The graph emits stage updates via onStage(stage) so the UI can render a live progress indicator. The stages:
greeting
retrieve_start retrieve_complete
transform_start transform_complete (only if query transform enabled)
generate_start generate_complete
hallucination_check_start hallucination_check_complete (only if enabled)
tool_execution_start tool_execution_complete (only if a tool is called)Most production messages emit four events: retrieve_start, retrieve_complete, generate_start, generate_complete. The hallucination check and query transform are both disabled by default. See Agentic RAG.
Entry point: socket.ts โ
The Socket.IO handler for message:send sits in packages/api/src/chat/socket.ts around lines 83โ292. It:
- Enforces per-user WebSocket rate limiting.
- Validates the payload through
validateUserMessage(). 8 000-character maximum, 20+ prompt-injection regex patterns, Unicode sanitisation. See Safety & Grounding. - Creates the conversation or verifies the user owns it.
- Persists the user message.
- Runs the deterministic crisis check. If the content matches the crisis regex set, the helpline block is emitted as the first
message:ai:chunkbefore any LLM output runs. This is the safety net. - Calls
aiService.generateResponse(conversationId, userId, content, onStream, attachments, onStage). - Emits
message:ai:endwith the full message and resolved source objects when the call returns. - Kicks off three async post-processing jobs in parallel: title generation, memory extraction, APDR observations. None of them block the user response.
Streaming internals โ
Streaming is threaded through four functions. Each takes an onStream(chunk: string) callback:
socket.ts
โ onStream callback emits socket event
ai-service.ts
โ onStream pass-through
langchain.ts
โ buffers to avoid splitting [USER_MESSAGE_START]/[USER_MESSAGE_END] markers
agentic-rag.ts
โ generate node iterates the LLM stream, calls onStream per token
Azure OpenAIThe security-marker buffer in langchain.ts matters. Token boundaries can split strings like [USER_MESSAGE_ and START], which would leak into the UI if emitted raw. The buffer accumulates until a complete marker can be detected, then strips markers before forwarding the cleaned prefix.
Non-blocking post-processing โ
Three things happen in parallel after message:ai:end is emitted:
Title generation. For a brand-new conversation, a separate LLM call produces a 4โ6 word title. Result emitted as conversation:title:updated.
Memory extraction. Identifies durable facts about the user or child ("child has ASD diagnosis", "LA is Lancashire") and stores them in the four-tier memory system.
APDR observations. For teacher or SENCO users with a linked pupil, the system extracts graduated-approach observations (Assess, Plan, Do, Review notes) for the child's timeline.
If any of these fail, the user doesn't see it. It's logged. The user's response is already delivered.
What the user never sees โ
- The five modules assembled into the system prompt for this specific message.
- The retrieved chunks, until they arrive as
sources[]onmessage:ai:end. - The graph node transitions, apart from the coarse
rag:stageevents. - The grounding-pass hedges applied post-generation.
- The statutory-fact mismatch metrics.
All of that is logged (with userId and conversationId, no PII) and all of it is inspectable from /admin/rag-workflow, which streams a live trace of graph execution over SSE. Admins can replay any conversation through the pipeline.
Latency budget โ
Typical production happy-path, no crisis, no clarification, no tool call, no hallucination check:
| Stage | p50 latency | Notes |
|---|---|---|
| Rate limit + validate + save user message | <20 ms | Postgres write and Redis counter |
| Greeting detection (regex, no LLM) | <5 ms | Skipped entirely for non-openers |
| Ambiguity pre-filter (deterministic) | <50 ms | Only reaches the LLM classifier if patterns are ambiguous |
| Retrieve (embed + HNSW + BM25 + RRF) | 200โ500 ms | Single round-trip for embedding, single SQL query for hybrid |
| Generate (streaming LLM) | 2โ5 s | Time-to-first-token about 400 ms on GPT-4.1 UK South |
| Persist AI message + resolve URNs | <50 ms | |
| End-to-end first-token | ~1 s | |
| End-to-end last-token | ~3โ5 s | For a 400-word response |
Enabling the hallucination check adds 2โ4 seconds. Enabling query transformation on low retrieval recall adds 1โ2 seconds per retry (default: disabled).
Security invariants in this path โ
Access control. aiService.generateResponse calls chatService.verifyConversationAccess(conversationId, userId) before anything else. Every read and write is gated on ownership. IDOR defence.
Rate limit. A WebSocket-specific limit sits alongside the HTTP rate limits. Prevents message flooding.
Input validation. validateUserMessage() rejects prompt-injection, template injection, Unicode direction overrides, and attachment mismatches.
Sanitisation. User text is wrapped with [USER_MESSAGE_START]...[USER_MESSAGE_END] markers before entering any prompt context.
Crisis safety net. Double-layered. Deterministic helpline emission from socket.ts, plus a second idempotent check in ai-service.ts before persistence. Either layer alone guarantees the helpline appears.
Further reading โ
- Prompt System. What's actually in the system prompt assembled for this message.
- Agentic RAG. The graph nodes above, in detail.
- Safety & Grounding. Validators, sanitisation, security markers, the crisis regex.
- Traces. Worked examples of this flow end-to-end.