Skip to content

Commit 0dff8ad

Browse files
bloveclaude
andauthored
feat(website): /blog infrastructure + streaming-chat pillar post (Spec 5) (#410)
* docs(gtm): spec for content-pillar-pages as blog (Spec 5) Blog infrastructure at cacheplane.ai/blog plus one seed pillar post ("Build a streaming chat UI in Angular with LangGraph") written in Brian Love's voice. Flat /blog/[slug] routes, date-prefixed MDX, RSS, OG, sitemap, new blog:* analytics namespace. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(gtm): plan for content-pillar-pages blog (Spec 5) 18 tasks across 7 phases: gray-matter dep, blog.ts parser (TDD), author registry, analytics events, taxonomy, blog components, nav, index + slug routes, per-post OG, RSS, sitemap, seed pillar post in Brian's voice, build verification, PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(website): add gray-matter for blog frontmatter parsing * feat(website): scaffold blog content directory * feat(website): add blog frontmatter parser + listing utilities Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(website): add blog author registry Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(website): add blog:* analytics events + blog surface * docs(gtm): document blog:* events in taxonomy * feat(website): add AuthorByline component * feat(website): add PostCard component with tests * feat(website): add FeaturedPostCard + TagChips components * feat(website): add Blog link to nav * fix(website): pin blog.spec.ts to node environment The new app-local vite config uses jsdom, but blog.spec.ts mocks fs which requires node. Pin per-file environment directive. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(website): add /blog index page * feat(website): add /blog/[slug] dynamic post route * feat(website): add per-post OG image route * feat(website): add /blog/rss.xml RSS 2.0 feed * feat(website): include /blog routes in sitemap * feat(website): seed pillar post on streaming chat in Angular * fix(website): normalize YAML Date frontmatter to string YAML parses unquoted ISO dates (date: 2026-05-17) as Date objects; this broke React rendering and the typed string contract. Coerce to YYYY-MM-DD string in readPost. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 42ed9e9 commit 0dff8ad

23 files changed

Lines changed: 3461 additions & 8 deletions

apps/website/content/blog/.gitkeep

Whitespace-only changes.
Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
---
2+
title: "Build a Streaming Chat UI in Angular with LangGraph"
3+
description: "Step-by-step tutorial for shipping a production streaming chat in Angular — signal-native, design-system-friendly, and wired to a LangGraph backend."
4+
date: 2026-05-17
5+
tags: [tutorial, streaming, langgraph, angular]
6+
author: brian
7+
featured: true
8+
---
9+
10+
Most AI chat features still ship without streaming.
11+
They buffer the full response on the server, then drop it into the UI in one paste.
12+
13+
That is the line between a demo and a production interface.
14+
15+
Streaming is not a polish item.
16+
It is the difference between a user waiting two seconds and a user waiting eight.
17+
It is also the difference between an interface that feels alive and one that feels broken.
18+
19+
This post is the tutorial I wish I had when I started shipping agentic chat in Angular.
20+
We are going to wire a real streaming chat UI — signal-native, design-system-friendly, and connected to a LangGraph backend — using `@ngaf/chat` and `@ngaf/langgraph`.
21+
22+
No buffered responses.
23+
No hand-rolled SSE plumbing.
24+
No fork of someone's React component.
25+
26+
## tl;dr
27+
28+
- Streaming is the production-vs-demo line for chat. Buffered responses feel broken once a user has seen the alternative.
29+
- `@ngaf/chat` is a signal-native Angular chat UI. `@ngaf/langgraph` is the transport that wires it to a LangGraph backend.
30+
- The wiring is three lines: `provideAgent`, `agent()`, and `<chat [agent]="...">`.
31+
- The production work is not the chat. It is errors, threads, and generative UI fallbacks.
32+
- If you are building agentic features in Angular today, this stack is the shortest path to a real interface.
33+
34+
## Why streaming matters
35+
36+
A user reads at roughly 200 to 300 words per minute.
37+
A modern model produces tokens faster than that.
38+
39+
If you stream, the user starts reading before the model has finished thinking.
40+
If you buffer, every response feels like a page load — except the user does not know how long the page will take.
41+
42+
The numbers are not subtle.
43+
Buffered chat measurably loses users.
44+
Streaming chat measurably keeps them.
45+
The longer the response, the wider the gap.
46+
47+
But the deeper reason is psychological.
48+
A response that appears one token at a time tells the user the system is working.
49+
A response that hangs for six seconds and then dumps a wall of text tells the user nothing — until it is too late to course-correct.
50+
51+
Streaming also unlocks a second behavior most teams forget about: **interruption**.
52+
If the response is incremental, the user can stop a run that is going the wrong direction before it finishes burning tokens.
53+
That is not just a cost story. It is a trust story.
54+
A system that can be steered mid-thought feels collaborative.
55+
A system that has to be waited out feels like a slot machine.
56+
57+
This is why streaming changes the conversation from "is it broken?" to "is this the answer I wanted?"
58+
That second question is the one your product actually needs the user to be asking.
59+
60+
## The architecture in three boxes
61+
62+
The wiring is simple once you see the seams.
63+
64+
**LangGraph backend.**
65+
This is where your agent graph lives — nodes, edges, tools, interrupts.
66+
It exposes a streaming endpoint over SSE.
67+
You do not write the transport. LangGraph does.
68+
69+
**`@ngaf/langgraph` adapter.**
70+
This is the Angular-side translator.
71+
It takes LangGraph's `ThreadState` and turns it into a signal-shaped `AgentCheckpoint` that the chat UI knows how to render.
72+
It also owns the run lifecycle — submit, cancel, retry — and the thread CRUD.
73+
74+
**`@ngaf/chat` UI.**
75+
This is the rendering layer.
76+
Message list, input, suggestions, interrupt panels, tool-call cards, reasoning blocks.
77+
Every piece is a signal, every signal flows through OnPush change detection, and everything is themeable through CSS custom properties.
78+
79+
The contract between the adapter and the UI is small on purpose.
80+
The chat does not know it is talking to LangGraph.
81+
The adapter does not know how messages get rendered.
82+
That separation is what lets you swap the backend, theme the UI, or replace a single component without touching the rest.
83+
84+
It is also what makes the stack viable for teams that do not control the backend.
85+
If your backend is LangGraph, the adapter is what you reach for.
86+
If your backend is something else, the adapter is the only piece that changes.
87+
The chat — and every component, theme, and slot inside it — is the same.
88+
89+
<ArchFlowDiagram />
90+
91+
## Scaffold
92+
93+
We will start with a fresh Angular 20 app. Three commands and three files.
94+
95+
<Steps>
96+
<Step title="Install the packages">
97+
98+
<Tabs items={["npm", "pnpm", "yarn"]}>
99+
<Tab label="npm">
100+
101+
```bash
102+
npm install @ngaf/chat @ngaf/langgraph marked
103+
```
104+
105+
</Tab>
106+
<Tab label="pnpm">
107+
108+
```bash
109+
pnpm add @ngaf/chat @ngaf/langgraph marked
110+
```
111+
112+
</Tab>
113+
<Tab label="yarn">
114+
115+
```bash
116+
yarn add @ngaf/chat @ngaf/langgraph marked
117+
```
118+
119+
</Tab>
120+
</Tabs>
121+
122+
`marked` is the markdown renderer the chat uses for assistant messages.
123+
It is a peer dependency so you can swap it if you need to.
124+
125+
</Step>
126+
<Step title="Wire the providers">
127+
128+
```ts
129+
// app.config.ts
130+
import { ApplicationConfig } from '@angular/core';
131+
import { provideAgent } from '@ngaf/langgraph';
132+
import { provideChat } from '@ngaf/chat';
133+
134+
export const appConfig: ApplicationConfig = {
135+
providers: [
136+
provideAgent({ apiUrl: 'http://localhost:2024' }),
137+
provideChat({ assistantName: 'Assistant' }),
138+
],
139+
};
140+
```
141+
142+
`provideAgent` is the transport. `provideChat` is the UI configuration.
143+
They are independent on purpose — you can use one without the other.
144+
145+
</Step>
146+
<Step title="Create the agent in your component">
147+
148+
```ts
149+
// chat-page.component.ts
150+
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
151+
import { agent } from '@ngaf/langgraph';
152+
import { ChatComponent } from '@ngaf/chat';
153+
154+
@Component({
155+
selector: 'app-chat-page',
156+
standalone: true,
157+
imports: [ChatComponent],
158+
changeDetection: ChangeDetectionStrategy.OnPush,
159+
template: `<div style="height: 100vh"><chat [agent]="chatAgent" /></div>`,
160+
})
161+
export class ChatPageComponent {
162+
protected readonly chatAgent = agent({
163+
assistantId: 'chat_agent',
164+
threadId: signal(null),
165+
});
166+
}
167+
```
168+
169+
`agent()` is a factory.
170+
It returns a signal-shaped object that the chat reads from and writes to.
171+
`threadId` is a signal so the chat can swap threads without re-instantiating the agent.
172+
173+
</Step>
174+
</Steps>
175+
176+
That is the entire wiring.
177+
No global stylesheet to import. No PostCSS config. No Tailwind setup.
178+
The chat ships with its own design tokens and component-scoped styles.
179+
180+
The one thing worth calling out is the `assistantId`.
181+
That is the name of your LangGraph graph — the entry point the run will execute against.
182+
If you have multiple graphs, you have multiple `agent()` instances, and you decide which one mounts where.
183+
A support graph in one route, a coding graph in another, the same chat shell rendering both.
184+
185+
## Render the chat
186+
187+
The `<chat>` component handles the standard surface — message list, input, send button, suggestion chips, scroll behavior, autosizing, the lot.
188+
189+
```html
190+
<chat [agent]="chatAgent" />
191+
```
192+
193+
That is the minimum.
194+
You will quickly want a welcome state, suggestions, and a header.
195+
196+
```ts
197+
template: `
198+
<div style="height: 100vh">
199+
<chat [agent]="chatAgent">
200+
<ng-template chatWelcome>
201+
<h1>What can I help you ship today?</h1>
202+
<p>Ask me about your repo, your roadmap, or the deploy that just failed.</p>
203+
</ng-template>
204+
205+
<ng-template chatSuggestions>
206+
<chat-suggestion (click)="ask('Summarize the open PRs')">
207+
Summarize the open PRs
208+
</chat-suggestion>
209+
<chat-suggestion (click)="ask('Explain the last deploy')">
210+
Explain the last deploy
211+
</chat-suggestion>
212+
</ng-template>
213+
</chat>
214+
</div>
215+
`,
216+
```
217+
218+
The slot pattern is intentional.
219+
The chat does not opinion-set your welcome state.
220+
It does not pick your suggestions.
221+
It hands you the slots and gets out of the way.
222+
223+
This is the thing I would not compromise on when picking a chat library.
224+
A chat component that owns its welcome copy, its suggestion list, and its empty states is a chat component that fights you the moment your product matures past the demo phase.
225+
A chat component that exposes those as slots is one you can grow with.
226+
227+
Theming is a separate concern.
228+
The chat reads from CSS custom properties — `--chat-bg`, `--chat-fg`, `--chat-accent`, and a few dozen more.
229+
If you are already using a design system, you map your tokens onto theirs in a single stylesheet and the chat picks them up.
230+
231+
## What is happening under the hood
232+
233+
The adapter exposes a small contract.
234+
The chat consumes it.
235+
Everything else is implementation detail.
236+
237+
The contract is roughly:
238+
239+
- `messages` — a signal of the current thread's messages
240+
- `status` — a signal of `'idle' | 'loading' | 'streaming' | 'error'`
241+
- `submit(text)` — push a user message and start a run
242+
- `cancel()` — abort the current run
243+
- `retry()` — re-run the last user message
244+
245+
Notice what is not in there.
246+
No event emitters. No observables to subscribe to. No imperative `subscribe(messages, render)` plumbing.
247+
248+
Everything is a signal.
249+
The chat template reads `messages()` directly.
250+
OnPush change detection picks up the writes and re-renders only the components that depend on the changed signal.
251+
252+
This matters more than it looks.
253+
Angular's signal model maps cleanly onto streaming because streaming is, at the data-shape level, a sequence of small writes to a growing list.
254+
Signals are a sequence of small writes to a growing list.
255+
The mental model and the runtime model are the same shape.
256+
257+
That is why the chat does not need an internal store, a reducer, or a state machine.
258+
The agent is the state. The signal is the read. The template is the render.
259+
260+
It also matters for testing.
261+
Because the contract is signals all the way down, you can mock the agent with a plain object — no harness, no fakes, no subscription bookkeeping.
262+
Write a signal, the chat re-renders. Write another signal, the chat re-renders again.
263+
That is the entire testing story for the UI layer.
264+
265+
The transport story is the same shape one level down.
266+
The adapter reads from LangGraph's SSE stream and writes the parsed events into the agent's signals.
267+
You can swap the transport — a fake stream, a recorded fixture, a different backend entirely — and the chat does not know the difference.
268+
That is the boundary the contract was designed to enforce.
269+
270+
## Production patterns
271+
272+
The scaffold above gets you to a working chat in about ten minutes.
273+
The remaining ninety percent of the work is in three buckets.
274+
275+
### 1. Errors and retries
276+
277+
Models fail. Networks fail. Tools time out.
278+
The default behavior is to surface the error in the chat with a retry affordance, but most teams want more than the default.
279+
280+
Two patterns I would always implement:
281+
282+
- **Per-message retry**, so a failed assistant response can be re-run without resubmitting the user's message.
283+
- **Transport-level retry with backoff**, so transient SSE drops do not surface as errors at all.
284+
285+
The adapter exposes both as configuration on `provideAgent`.
286+
A third pattern worth thinking about early: **graceful degradation when streaming is unavailable**.
287+
Some networks strip SSE. Some corporate proxies buffer it. The chat will fall back to a non-streaming render, but you should know that path exists before a customer finds it.
288+
The depth of what you can do here is in the [error handling guide](/docs/chat/guides/lifecycle).
289+
290+
### 2. Threads and persistence
291+
292+
A single-thread chat is a demo.
293+
A real product remembers conversations across sessions and across devices.
294+
295+
LangGraph handles the persistence on the backend.
296+
The adapter exposes thread CRUD through `@langchain/langgraph-sdk` — list threads, create a thread, switch threads, delete a thread.
297+
298+
The pattern is to bind your `threadId` signal to your router or your sidebar selection.
299+
When the signal changes, the chat re-reads from the new thread automatically. No manual unsubscribe. No race conditions.
300+
301+
What you do with that capability is a product decision, not a framework one.
302+
Some teams scope threads to a project. Others scope them to a task. Others scope them to the user's session and let the agent's memory do the cross-session work.
303+
There is no right answer — but there is a wrong one, which is to ignore the question and ship a chat where every refresh starts from zero.
304+
305+
If you are building a multi-thread sidebar, the [`<chat-sidebar>`](/docs/chat/components/chat-sidebar) primitive gives you the layout without locking you into a specific persistence model.
306+
307+
### 3. Generative UI fallbacks
308+
309+
Sometimes the model wants to do more than render text.
310+
Tool calls produce structured output. Interrupts ask for human input. Subagents return their own state.
311+
312+
The chat handles each of these as a first-class message type.
313+
You do not need to write a custom renderer to get the default behavior — tool calls become cards, interrupts become panels, subagents become collapsible blocks.
314+
315+
When you do need custom rendering — and you will — every one of these is a templatable slot.
316+
Pass a template, override the default, ship the variant you actually want.
317+
This is the seam where a generic chat becomes your chat.
318+
The branded card for your most important tool. The custom approval flow for your most sensitive interrupt. The product-specific summary block when a subagent finishes a long task.
319+
The [generative UI guide](/docs/chat/guides/generative-ui) walks through each surface in depth.
320+
321+
## Where to go from here
322+
323+
The scaffold above is the smallest possible production-shaped chat.
324+
It is not the whole story.
325+
326+
Three places to look next:
327+
328+
- The [cockpit chat example](/docs/chat/getting-started/quickstart) — a full multi-thread, multi-agent chat shell with real LangGraph graphs behind it.
329+
- The [persistence guide](/docs/chat/guides/lifecycle) — how to manage threads, runs, and resume state across sessions.
330+
- The [interrupts guide](/docs/chat/guides/streaming) — how to handle human-in-the-loop pauses without breaking the streaming model.
331+
332+
<Callout type="info" title="Building this for an enterprise team?">
333+
If you are wiring `@ngaf/chat` into a regulated, multi-tenant, or design-system-heavy environment, we have a paid track for that. [Talk to us about the enterprise track →](/contact?source=blog_streaming_pillar&track=enterprise)
334+
</Callout>
335+
336+
The interesting work in agentic software is not the chat itself.
337+
It is everything the chat lets you stop building so you can get back to the part that actually matters.

0 commit comments

Comments
 (0)