Technical Deep-Dive

Nexus Trace

A streaming AI agent that shows its reasoning live — every token, tool call, latency and cost rendered in real time as the model thinks. Here's how it works under the hood.

Next.js 16
App Router
LangGraph
ReAct agent
SSE
Token streaming
9
Stream events
2
Agent tools
01 — The Big Picture

What is Nexus Trace?

It's a chat app split into two panes: a normal chat on the left, and a live trace panel on the right. While the assistant answers, the trace panel reveals what's normally hidden — when the model is reasoning vs. responding, which tools it calls, how long each step takes, how many tokens it burns, and the running cost in USD.

🔭 Observable

Every internal step of the ReAct loop is surfaced as a typed event and drawn as a card with status, duration and reasoning.

⚡ Streaming-first

Server-Sent Events push tokens the instant the LLM produces them. No polling, no waiting for the full answer.

🧱 Strictly layered

AI, protocol, API and UI live in separate folders with a one-way import rule — no cross-layer shortcuts.

The stack

Next.js 16 · App Router React 19 TypeScript 5 LangGraph.js · createAgent() Fireworks.ai or Claude · LLM (swappable) Tavily · web search LangSmith · tracing Upstash Redis · rate limit OpenAI · moderation Tailwind v4 + shadcn/ui Framer Motion Vitest · unit Playwright · E2E
02 — Architecture

Four layers, one rule

Code is organized by responsibility, and dependencies flow in exactly one direction. The UI knows about the API; the API knows nothing about the UI; the AI layer knows about neither.

AI layer
lib/agent/
The LangGraph ReAct graph, tool definitions (web search, datetime), and the "Nexus" system prompt. Pure orchestration — emits typed events, knows nothing about HTTP or React.
Protocol
lib/streaming/
The StreamEvent discriminated union plus encode/decode helpers — generatorToStream() on the server, parseSSE() on the client.
API layer
lib/api/ + app/api/
The HTTP boundary: a thin fetch wrapper (streamChat()) and the route handler that runs session, validation, rate-limit and moderation before streaming.
UI layer
components/ + hooks/
React components (one per file) and the useAgentStream hook that turns the event stream into reducer state. A single "use client" boundary at ChatContainer.
UI API Protocol  ·  AI Protocol  ·  never the reverse
Why it matters: the AI layer can be tested without a browser, the protocol can be reused, and a UI rewrite can't accidentally reach into model code. Shared constants (STREAM_EVENT, TRACE_STATUS, GRAPH_EVENTS) live in as const objects so logic never depends on raw string literals.
03 — End to End

The journey of one message

From keystroke to rendered token, here's the full round trip. Colors mark the segment: client, server pipeline, model loop, stream back.

1
User types & hits send
ChatInput → useAgentStream.sendMessage()
Dispatches SEND_START: adds the user message and an empty streaming assistant bubble, flips isStreaming on.
2
Client fetch with abort support
lib/api/chat.ts · streamChat()
POSTs { message, history } to /api/chat, attaching API-key headers from localStorage and an AbortSignal for the stop button.
3
Server policy pipeline
app/api/chat/route.ts · POST
Linear gauntlet: session cookie → Zod validate → resolve keys → rate limit → moderation. Any failure short-circuits before a single token is generated.
4
Agent graph starts streaming
lib/agent/graph.ts · runAgentStream()
createAgent() builds a ReAct graph; graph.streamEvents() (v2) fires LangGraph events. Handlers translate each into a StreamEvent.
5
Reason → maybe call a tool → respond
LLM (Fireworks / Claude) ⇄ Tavily web_search
The model reasons (label "Reasoning"), optionally calls web_search / get_current_datetime, sees results, then streams the answer (label "Responding"). Loops up to 4 iterations.
6
Events encoded as SSE frames
generatorToStream() → ReadableStream
Each event becomes data: <json>\n\n. A DONE frame carries total latency, TTFT, token counts and estimated cost.
7
Client parses & reduces
parseSSE() → dispatch(STREAM_EVENT) → reducer
applyStreamEvent() switches on the event type: append a token, open a trace step, close one with its duration, or record the final metrics.
8
React re-renders live
MessageList + TracePanel
The bubble grows token-by-token; trace cards appear and complete. State mirrors to sessionStorage so a refresh keeps the conversation.
04 — The Wire Format

The SSE streaming protocol

Every server→client message is a single SSE frame: data: <JSON>\n\n. The JSON is one variant of the StreamEvent discriminated union — nine event types, each with a typed payload. A switch on event.type stays exhaustive at compile time.

EventFires when…Key payload
model_startA model call beginsmodelCallId, label
token_deltaEach reply token (reasoning excluded)content
model_endA model call finishesdurationMs, reasoning?
tool_startA tool is invokedtoolName, toolCallId, args
tool_resultA tool returnsresult, durationMs
step_endA ReAct step closesstepIndex (marker)
moderationContent check completesblocked, reason?, durationMs
doneRun completelatencyMs, ttftMs, inputTokens, outputTokens, estimatedCostUsd, truncated?
errorRecoverable failuremessage

Server: generator → stream

The agent is an async generator yielding SSE strings. generatorToStream() wraps it in a Web ReadableStream, TextEncoder-ing each chunk and emitting an error frame if the generator throws.

Client: stream → events

parseSSE() reads the body reader, buffers partial UTF-8, splits on the \n\n frame boundary, strips data: , and JSON.parses each into a typed event — dropping malformed frames safely.

05 — The Brain

The agent & its tools

A LangGraph ReAct agent built with createAgent() from the langchain package (the prebuilt createReactAgent is deprecated). The LLM is a swappable adapter — pick it right in Settings → Agent provider (sent per request as x-llm-provider, with LLM_PROVIDER as the server default): Fireworks via the OpenAI-compatible ChatOpenAI, or Anthropic Claude via ChatAnthropic. Both plug into the same graph and stream identically; the agent is given two tools.

Who drives the loop? The graph runs the loop, the LLM steers it. LangGraph owns the iteration: model node → conditional edge → tools node → back to model. The model never loops or calls a tool itself — it just emits a message that either contains tool_calls (graph routes to the tools node and goes around again) or doesn't (graph routes to END and the run finishes). The for await in runAgentStream() is only a consumer of the resulting event stream — it narrates the loop, it doesn't control it. The hard stop is the graph's recursionLimit, not the model's discretion.

🔎 web_search

Tavily search, Zod-typed { query }. Returns Tavily's synthesized answer plus up to 3 snippets.

maxResults 3 recency 90d retry / 500ms backoff

🕐 get_current_datetime

No arguments. Returns a UTC timestamp so the model can reason about "today" without hallucinating dates.

What the trace panel actually shows

A model call is split into two visible phases — the agent relabels the step from "Reasoning" to "Responding" on the first content token. Reasoning tokens are accumulated and shown in the trace only, never mixed into the reply.

🧠Reasoning0.4s
🔎web_search · "nexus trace latest"1.2s
Responding2.1s
🛡️content_checkrunning…

The "Nexus" system prompt

  • Identity: introduces itself as Nexus — not ChatGPT, Claude or Gemini.
  • Search discipline: at most 3 searches, each a distinct angle; stop early once it has enough.
  • Formatting: bold labels and structured sections, lists over markdown tables, device-aware output.
06 — Client State

One hook, one reducer

All client state lives in useAgentStream, backed by a single useReducer. The reducer is the only place state changes — the stream just feeds it actions.

Actions

  • HYDRATE — load persisted state after mount
  • SEND_START — add user + empty assistant msg
  • STREAM_EVENT — route into applyStreamEvent()
  • STREAM_SETTLED — clear the streaming flag
  • SET_ERROR / CLEAR_ERROR
  • CLEAR_ALL — reset everything

Careful details

  • Post-mount hydration avoids SSR/client mismatch — storage loads only after first render.
  • Refs capture latest messages & keys without bloating dependency arrays.
  • AbortController makes the stop button cancel mid-stream instantly.
  • Immutable updates via readonly types + patchById() let memoized cards skip re-renders.
  • useSyncExternalStore syncs API keys across tabs.
Persistence split: messages, trace steps and usage go to sessionStorage (per-tab, survives refresh); API keys go to localStorage (shared across tabs). Nothing sensitive touches the server beyond the request itself.
07 — Safety & Limits

Guardrails before generation

Two independent defense layers run before the model ever sees a request — and both degrade gracefully if their backing service is missing.

🛡️ Moderation — two passes

1. Fast regex jailbreak patterns ("ignore previous instructions", DAN, etc.) run synchronously. 2. If they pass and an OpenAI key exists, the text goes to the Moderation API. Fails open — unavailability never blocks a legit user.

⏳ Rate limiting — two tiers

Production: Upstash Redis sliding window keyed by session. Dev: in-memory bucket map with an unref'd sweep timer. Demo keys get a tighter budget than your own keys.

Rate-limit budgets (per session / hour)

Demo keys
20
Your own keys
100
08 — The Tuning Knobs

Key numbers (all in lib/config.ts)

A core rule of the codebase: no magic numbers or strings. Every constant lives in one config file or an as const object.

Model

provider fireworks / anthropic default minimax-m2p7 / claude-sonnet-4-6 temperature 0.3 max tokens 2048 max iterations 4

Search

results 3 retries 3 backoff 500ms recency 90 days

History

max turns 10 chars/msg 2000 max message 4000

Limits

demo 20/hr own key 100/hr window 3600s

Session

cookie nexus-sid max-age 1 year HTTP-only · SameSite=Strict

Pricing /1M tok

fireworks $0.30 in · $1.20 out claude-sonnet $3 in · $15 out cost shown per query + session
09 — Observability & Testing

Cost, latency, tracing & tests

Beyond the live trace, every run is measured, optionally traced to LangSmith, and the golden path is guarded by an end-to-end test.

📊 Cost & latency

The done event carries TTFT, total latency, token usage and estimated USD cost — computed server-side in graph.ts from per-provider pricing tables. Per-step durations ride each model_end / tool_result.

🔬 LangSmith

Because it's a LangChain app, setting the LANGSMITH_* env vars auto-traces every run — no code. Runs are tagged with provider + model; in dev the trace panel shows a "View in LangSmith" deep link.

🎭 Playwright E2E

A CI-gated test drives the real UI against a production build and mocks /api/chat with canned SSE frames — covering send → tool-call → stream → replay with no LLM or network calls.