When a user sends a message to an agent in Getraind's AI Studio, they see tokens streaming back in real time — starting within 200ms of hitting send. Behind that experience is a pipeline with eight distinct steps, each with its own failure modes and design decisions.
This post traces exactly what happens, end to end — from browser to model and back.
Architecture Overview
Browser
│ HTTPS WebSocket wss://hostname/ai-ws/{session_id}?token=...
▼
nginx (proxy_pass → getraind-gtaif:5000)
▼
GTAIF /ws/{session_id}
│
├─ validate_ws_token() ← HMAC signature + Redis single-use check
├─ redis.register_ws_session() ← session registry for future multi-node routing
├─ factory.get_or_build() ← CompiledStateGraph (cached)
├─ graph.astream_events() ← LangGraph execution
│ └─ LLMGateway.ainvoke() ← NVIDIA → Anthropic failover chain
└─ _translate_event() → ws.send_json() ← token / node_start / node_complete / done
Step 1: Token Acquisition
Before opening a WebSocket, the browser fetches a short-lived auth token from the Portal:
GET /api/studio/ws-token
Authorization: (session cookie)
← 200 {"token": "mpabba:1749600000:a3f9...", "expires_in": 300}
The Portal signs the token with HMAC-SHA256:
token = f"{username}:{expiry_unix}:{hmac_sha256(username:expiry, SECRET_KEY)}"
Token lifetime: 5 minutes. The browser must re-fetch before reconnecting after expiry.
Step 2: WebSocket Handshake and Token Validation
wss://hostname/ai-ws/{session_id}?token=mpabba%3A1749600000%3Aa3f9...
GTAIF validates the token in validate_ws_token():
- Parse — split on
:to extract username, expiry timestamp, and HMAC signature - Expiry check — reject if
expiry_unix < now - HMAC verification — recompute HMAC with the server's
SECRET_KEYand compare. Constant-time comparison to prevent timing attacks. - Single-use enforcement — atomic Redis
SET NX EXon the HMAC signature:
key = f"gtaif:ws_token:used:{sig}"
result = await redis.set(key, "1", nx=True, ex=remaining_ttl)
# True → first use (OK to proceed)
# None → already consumed (replay attack — reject)
If Redis is unavailable, steps 1–3 still run but replay prevention is disabled — the platform degrades gracefully but logs a warning.
On success: WebSocket accepted, {"type": "connected", "session_id": session_id} sent to browser.
Step 3: Rate Limiting
Before processing a chat message, Redis enforces a per-user rate limit:
allowed, count = await redis.check_rate_limit(username)
# Default: 30 messages per 60-second window
Implementation: a pipeline INCR + TTL on key gtaif:rate:chat:{username}. First message sets a 60-second expiry. If count exceeds the limit, the user receives:
{"type": "error", "message": "Rate limit exceeded. Please wait a moment.", "code": "rate_limited"}
The WebSocket connection stays open — rate limit violations don't disconnect the user. Without Redis: rate limiting is disabled.
Step 4: Graph Resolution
factory = app.state.agent_factory
graph = factory.get_or_build(
slug = agent_id,
username = username,
checkpointer = app.state.checkpointer, # MongoDBSaver
)
Cache hit is O(1). Cache miss triggers compilation (described in the Agent Factory post). The MongoDBSaver checkpointer enables crash recovery and HITL (human-in-the-loop) approval waits — the graph can be suspended and resumed across WebSocket reconnections.
Step 5: Initial State Construction
The LangGraph graph executes against a typed AgentState. On each new chat message, the initial state is constructed:
initial_state = {
"messages": [HumanMessage(content=message)],
"route": "",
"context": [], # RAG results injected here by rag_retriever nodes
"scratchpad": [], # thinker intermediate thoughts
"notes": [], # evaluator / architect persistent notes
"reasoning_steps": [], # thinker ReAct step trace
"error": None,
"done": False,
"session_id": session_id,
"username": username,
"agent_slug": agent_id,
}
For the Agent Builder Studio (designer mode), prior session state is restored from working memory before execution — the architect's stage progress, confirmed requirements, and design notes from previous sessions are injected so the conversation resumes in context.
Step 6: LangGraph Execution and Event Streaming
config = {"configurable": {"thread_id": session_id}}
async for event in graph.astream_events(initial_state, config, version="v2"):
await _translate_event(ws, event)
astream_events emits events as each node executes. The _translate_event function maps LangGraph events to WebSocket messages:
| LangGraph Event | WebSocket Message |
|---|---|
on_chain_start (node) |
{"type": "node_start", "node_id": "router"} |
on_chain_end (node) |
{"type": "node_complete", "node_id": "router", "route": "execute"} |
on_chat_model_stream |
{"type": "token", "content": "The purchase orders..."} |
on_chain_end (reasoning) |
{"type": "reasoning", "step": "..."} |
on_tool_start |
{"type": "tool_call", "tool": "web_search", "args": {...}} |
on_chain_end (tool) |
{"type": "tool_result", "tool": "web_search", "preview": "..."} |
The browser renders these progressively — node badges show which agent stage is active, reasoning steps appear collapsed in a details element, and tokens stream into the message bubble as they arrive.
Step 7: Inside a Node — What Happens During an LLM Call
When LangGraph executes an llm_agent node:
async def _node(state: AgentState) -> dict:
question = _last_human_message(state)
# 1. Retrieve and inject memory context
memory_context = await memory_store.inject_into_context(
username=state["username"], agent_slug=state["agent_slug"], query=question
)
full_prompt = f"### Relevant Memory\n{memory_context}\n\n---\n\n{node_system_prompt}"
# 2. Build messages
messages = [SystemMessage(content=full_prompt), *state["messages"]]
# 3. Call LLM via gateway (handles provider selection and failover)
response = await gateway.ainvoke(messages, role=LLMRole.EXECUTOR)
# 4. Extract route from first word (if this node sets_route=True)
route = response.content.split()[0].lower() if sets_route else ""
# 5. Record interaction to episodic memory (background, non-blocking)
asyncio.create_task(_record_episode(state, response))
return {
"messages": [AIMessage(content=response.content)],
"route": route,
"iteration": state["iteration"] + 1,
}
The LLM Gateway call at step 3 handles all provider selection, retry logic, and failover transparently. The node only sees a response object.
Step 8: Portal Tool Calls
When a thinker node decides to call an external system, it writes a tool spec to state["extra"]:
{
"tool_call": {
"tool": "getraind-sap",
"operation": "RFC_READ_TABLE",
"params": {
"table": "EKPO",
"where": "LIFNR = '0000001000' AND ELIKZ = ''",
"fields": ["EBELN", "EBELP", "MATNR", "MENGE", "NETPR"]
}
}
}
The portal_task node picks this up, calls the Portal's task API with the spec, and appends the result to state["tool_results"] and state["context"]. On the next thinker iteration, the result is visible in context as if it were retrieved knowledge.
This indirection — thinker writes a spec, portal_task executes it — means the thinker never has direct access to external systems. All tool execution goes through the Portal's access control and audit layer.
Human-in-the-Loop Approval
For nodes flagged in interrupt_before (defined in the primitive's spec), LangGraph suspends execution and checkpoints the full state:
# Graph suspends here, state saved to MongoDB
# Browser receives:
{"type": "approval_request", "action": "delete_records", "params": {...}, "approval_id": "uuid"}
The user sees an approval UI in AI Studio. When they approve or reject, the WebSocket sends:
{"type": "approval_response", "approval_id": "uuid", "approved": true}
GTAIF resumes the graph from the checkpoint with approved=True in state. The agent continues. If rejected, the graph routes to a cleanup or explanation node instead.
This pattern ensures destructive operations — writes, deletes, financial transactions — always have a human decision point. The LLM proposes; the human disposes.
What the Browser Sees
From the browser's perspective, the experience is:
- User sends message → WebSocket sends
{type: "chat", message: "..."} - Server sends
{type: "node_start", node_id: "router"}— badge appears - Server sends
{type: "node_complete", node_id: "router", route: "execute"}— badge updates - Server sends
{type: "node_start", node_id: "executor"}— next badge - Server sends
{type: "token", content: "The"},{type: "token", content: " purchase"}, ... — tokens stream into bubble - Server sends
{type: "node_complete", node_id: "executor"}— done - Server sends
{type: "done"}— streaming ends
Time from user send to first token: typically 150–300ms on the happy path (cache hit for graph, NVIDIA NIM primary provider available).
Failure Handling
Every failure mode is handled explicitly at the WebSocket layer — no unhandled exceptions reach the user:
| Failure | Response |
|---|---|
| Token invalid / expired | close(4001) with reason |
| Agent not found | {"type": "error", "code": "agent_not_found"} — connection stays open |
| Rate limit exceeded | {"type": "error", "code": "rate_limited"} — connection stays open |
| All LLM providers failed | {"type": "error", "code": "llm_unavailable", "message": "..."} |
| Tool call failed | Error written to state["error"], agent routes to error handler node |
| Graph crashes | Exception caught, {"type": "error", "message": "..."} sent, connection stays open |
The design principle: a failure in one conversation should never affect other users' sessions. Every error is caught at the per-connection handler level. A graph crash for one user is a send_json call; it doesn't touch anyone else's WebSocket connection.
Scaling to Multi-Node
Getraind currently runs as a single GTAIF worker behind nginx. The Redis session registry (gtaif:ws:session:{session_id} → username) is the foundation for future horizontal scaling.
When multiple workers run, a chat message from the browser must reach the worker that holds that session's WebSocket connection. The session registry tells a pub/sub router which worker to target. The infrastructure for this is in place; the multi-worker deployment is the next operational milestone.
For enterprise deployments on Cisco UCS clusters with OpenShift, this horizontal scaling is achievable from day one — the software is ready for it even if Getraind's personal deployment hasn't needed it yet.
Next: Cache and Memory Architecture: How Agents Get Faster and Smarter Over Time