TUTORIAL · May 17, 2026 · 8 min read

Build a Streaming Chat UI in Angular with LangGraph

Step-by-step tutorial for shipping a production streaming chat in Angular — signal-native, design-system-friendly, and wired to a LangGraph backend.

Brian Love · Founder, Threadplane

Let's build a real streaming chat UI in Angular, wired to a LangGraph backend, using @threadplane/chat and @threadplane/langgraph.

Most AI chat features still ship without streaming — they buffer the full response on the server, then drop it into the UI in one paste. Streaming is the difference between a user waiting two seconds and waiting eight, and between an interface that feels alive and one that feels broken.

#Goals

  • Scaffold a signal-native Angular chat with @threadplane/chat and @threadplane/langgraph in three files.
  • Wire a real LangGraph backend to the UI without writing any transport code.
  • Cover the three production patterns that matter once the scaffold works: errors, threads, and generative UI.

#Why streaming matters

A user reads at roughly 200 to 300 words per minute; a modern model produces tokens faster than that. If you stream, the user starts reading before the model has finished. If you buffer, every response feels like a page load with no progress indicator.

Streaming also enables interruption: if the response is incremental, the user can stop a run that's going the wrong direction before it finishes burning tokens.

#The architecture in three boxes

Let's look at the seams before we touch any code.

LangGraph backend. This is where your agent graph lives — nodes, edges, tools, interrupts. It exposes a streaming endpoint over SSE. You don't write the transport. LangGraph does.

@threadplane/langgraph adapter. This is the Angular-side translator. It takes LangGraph's ThreadState and turns it into a signal-shaped AgentCheckpoint that the chat UI knows how to render. It also owns the run lifecycle — submit, cancel, retry — and the thread CRUD.

@threadplane/chat UI. This is the rendering layer. Message list, input, suggestions, interrupt panels, tool-call cards, reasoning blocks. Every piece is a signal, every signal flows through OnPush change detection, and everything is themeable through CSS custom properties.

The contract between the adapter and the UI is small on purpose. The chat doesn't know it's talking to LangGraph. The adapter doesn't know how messages get rendered. That separation is what lets you swap the backend, theme the UI, or replace a single component without touching the rest.

It's also what makes the stack viable for teams that don't control the backend. If your backend is LangGraph, the adapter is what you reach for. If your backend is something else, the adapter is the only piece that changes. The chat — and every component, theme, and slot inside it — is the same.

injectAgent() — live architecture flowlocalhost:4200
Chat Interface
Developer Console
Waiting for interaction...

#Scaffold

Let's start with a fresh Angular 20 app. Three commands and three files.

1
Install the packages
npm install @threadplane/chat @threadplane/langgraph marked

marked is the markdown renderer the chat uses for assistant messages. It's a peer dependency so you can swap it if you need to.

2
Wire the providers
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/langgraph';
import { provideChat } from '@threadplane/chat';
 
export const appConfig: ApplicationConfig = {
  providers: [
    provideAgent({ apiUrl: 'http://localhost:2024' }),
    provideChat({ assistantName: 'Assistant' }),
  ],
};

provideAgent is the transport. provideChat is the UI configuration. They're independent on purpose — you can use one without the other.

3
Create the agent in your component
// chat-page.component.ts
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { agent } from '@threadplane/langgraph';
import { ChatComponent } from '@threadplane/chat';
 
@Component({
  selector: 'app-chat-page',
  standalone: true,
  imports: [ChatComponent],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `<div style="height: 100vh"><chat [agent]="chatAgent" /></div>`,
})
export class ChatPageComponent {
  protected readonly chatAgent = agent({
    assistantId: 'chat_agent',
    threadId: signal(null),
  });
}

agent() is a factory. It returns a signal-shaped object that the chat reads from and writes to. threadId is a signal so the chat can swap threads without re-instantiating the agent.

That's the entire wiring. No global stylesheet to import. No PostCSS config. No Tailwind setup. The chat ships with its own design tokens and component-scoped styles.

One thing worth calling out is the assistantId. That's the name of your LangGraph graph — the entry point the run will execute against. If you have multiple graphs, you have multiple agent() instances, and you decide which one mounts where: a support graph in one route, a coding graph in another, the same chat shell rendering both.

#Render the chat

Let's look at the rendering surface. The <chat> component handles the standard stuff — message list, input, send button, suggestion chips, scroll behavior, autosizing, the lot.

<chat [agent]="chatAgent" />

That's the minimum. You'll quickly want a welcome state, suggestions, and a header.

template: `
  <div style="height: 100vh">
    <chat [agent]="chatAgent">
      <ng-template chatWelcome>
        <h1>What can I help you ship today?</h1>
        <p>Ask me about your repo, your roadmap, or the deploy that just failed.</p>
      </ng-template>
 
      <ng-template chatSuggestions>
        <chat-suggestion (click)="ask('Summarize the open PRs')">
          Summarize the open PRs
        </chat-suggestion>
        <chat-suggestion (click)="ask('Explain the last deploy')">
          Explain the last deploy
        </chat-suggestion>
      </ng-template>
    </chat>
  </div>
`,

The slot pattern is intentional: the chat doesn't set your welcome copy, pick your suggestions, or own your empty states. It exposes those as slots so you can grow past the demo phase without fighting the component.

Theming is a separate concern. The chat reads from CSS custom properties — --chat-bg, --chat-fg, --chat-accent, and a few dozen more. If you already use a design system, map your tokens onto theirs in a single stylesheet and the chat picks them up.

#What's happening under the hood

Let's peek at the contract. The adapter exposes a small surface, the chat consumes it, and everything else is implementation detail.

The contract is roughly:

  • messages — a signal of the current thread's messages
  • status — a signal of 'idle' | 'loading' | 'streaming' | 'error'
  • submit(text) — push a user message and start a run
  • cancel() — abort the current run
  • retry() — re-run the last user message

Notice what isn't in there. No event emitters. No observables to subscribe to. No imperative subscribe(messages, render) plumbing.

Everything is a signal. The chat template reads messages() directly. OnPush change detection picks up the writes and re-renders only the components that depend on the changed signal.

Angular's signal model maps cleanly onto streaming: at the data-shape level, streaming is a sequence of small writes to a growing list, and so are signals. That's why the chat doesn't need an internal store, a reducer, or a state machine. The agent is the state, the signal is the read, the template is the render.

It also matters for testing. 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. Write a signal, the chat re-renders. Write another signal, the chat re-renders again. That's the entire testing story for the UI layer.

The transport story is the same shape one level down. The adapter reads from LangGraph's SSE stream and writes the parsed events into the agent's signals. You can swap the transport — a fake stream, a recorded fixture, a different backend entirely — and the chat doesn't know the difference. That's the boundary the contract was designed to enforce.

#Production patterns

The scaffold above gets you to a working chat in about ten minutes. The remaining ninety percent of the work is in three buckets. Let's walk through each one.

#1. Errors and retries

Models fail. Networks fail. Tools time out. The default behavior is to surface the error in the chat with a retry affordance, but most teams want more than the default.

Two patterns are worth setting up early:

  • Per-message retry, so a failed assistant response can be re-run without resubmitting the user's message.
  • Transport-level retry with backoff, so transient SSE drops don't surface as errors at all.

The adapter exposes both as configuration on provideAgent. A third pattern worth thinking about early: graceful degradation when streaming is unavailable. 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. The depth of what you can do here is in the error handling guide.

#2. Threads and persistence

A single-thread chat is a demo. A real product remembers conversations across sessions and across devices.

LangGraph handles persistence on the backend. The adapter exposes thread CRUD through @langchain/langgraph-sdk — list threads, create a thread, switch threads, delete a thread.

Bind your threadId signal to your router or sidebar selection. When the signal changes, the chat re-reads from the new thread automatically — no manual unsubscribe, no race conditions.

How you scope threads is a product decision: per project, per task, or per session with the agent's memory doing the cross-session work. The one thing to avoid is shipping a chat where every refresh starts from zero.

If you're building a multi-thread sidebar, the <chat-sidebar> primitive gives you the layout without locking you into a specific persistence model.

#3. Generative UI fallbacks

Sometimes the model wants to do more than render text. Tool calls produce structured output. Interrupts ask for human input. Subagents return their own state.

The chat handles each of these as a first-class message type. You don't need to write a custom renderer to get the default behavior — tool calls become cards, interrupts become panels, subagents become collapsible blocks.

When you do need custom rendering — and you will — every one of these is a templatable slot. Pass a template, override the default, ship the variant you actually want. This is the seam where a generic chat becomes your chat. 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. The generative UI guide walks through each surface in depth.

#Where to go from here

The scaffold above is the smallest possible production-shaped chat. It's not the whole story.

Three places to look next:

  • The cockpit chat example — a full multi-thread, multi-agent chat shell with real LangGraph graphs behind it.
  • The persistence guide — how to manage threads, runs, and resume state across sessions.
  • The interrupts guide — how to handle human-in-the-loop pauses without breaking the streaming model.
Building this for an enterprise team?

If you're wiring @threadplane/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 →

#Conclusion

@threadplane/chat plus @threadplane/langgraph gets an Angular app to a streaming, production-shaped chat in three files: signals all the way down, and a contract small enough that you can swap the backend without touching the UI.