diff --git a/apps/website/content/blog/2026-08-13-angular-chat-app-tutorial-with-ag-ui.mdx b/apps/website/content/blog/2026-08-13-angular-chat-app-tutorial-with-ag-ui.mdx new file mode 100644 index 000000000..b26b3c289 --- /dev/null +++ b/apps/website/content/blog/2026-08-13-angular-chat-app-tutorial-with-ag-ui.mdx @@ -0,0 +1,410 @@ +--- +title: 'Angular Chat App Tutorial with AG-UI' +description: 'Build an Angular chat app on AG-UI where the browser owns its own tools — action, view, and ask client tools rendering real components inline, plus agent-shared state.' +date: 2026-08-13 +tags: [tutorial, ag-ui, angular, client-tools, agentic-ui] +author: brian +featured: false +draft: false +--- + +Let's build an Angular chat app on AG-UI where the interesting tools run in the _browser_. + +Most chat tutorials stop at streaming text. The app in this one saves links to a reading list, renders each one as a real Angular component inside the transcript, and asks the user to confirm before it clears anything — and none of that work happens on the server. + +That's the part AG-UI makes straightforward, because the protocol carries a tool catalog in both directions. The browser ships what it can do; the model calls it; the browser executes and answers. + +## Goals + +- Stand up an AG-UI endpoint over a LangGraph agent. +- Bind it to Angular with `@threadplane/ag-ui` and `@threadplane/chat`. +- Declare `action`, `view`, and `ask` client tools the model can call. +- Read agent-shared state as an Angular signal. +- Be clear about what AG-UI gives you and what it doesn't. +- Have fun! + + + `@threadplane/ag-ui` is MIT-licensed. `@threadplane/chat` is available for + noncommercial use under PolyForm Noncommercial 1.0.0; commercial production use + requires a Threadplane license. The [chat installation + guide](/docs/chat/getting-started/installation) covers activation. + + +For a tour of the AG-UI event model and how it maps onto signals, read [Build Fullstack Agentic Angular Apps Using AG-UI](/blog/build-fullstack-agentic-angular-apps-using-ag-ui). This post assumes that and builds the app on top. + +## What are we building? + +```text +Angular + -> @threadplane/ag-ui + -> @ag-ui/client HttpAgent + -> POST /agent + AG-UI events over SSE + -> FastAPI + ag-ui-langgraph + -> your graph +``` + +A reading list. The user asks the assistant to save something; the assistant calls `add_link`, which runs in the browser and mutates an Angular signal store. Then it calls `link_card` to show it, and `confirm_clear` when the user wants the list emptied. + +Three tools, three different shapes, and the server implements none of them. + +## How do we get an AG-UI endpoint running? + +Install the integration: + +```bash +python -m venv .venv +source .venv/bin/activate +pip install "ag-ui-langgraph==0.0.40" langchain-openai "fastapi>=0.115" "uvicorn[standard]" "threadplane-middleware>=0.0.1" +``` + +I'm using the LangGraph integration because it's the shortest path to a running endpoint, but this is the interchangeable half. CrewAI, Mastra, Pydantic AI, AG2, and AWS Strands all expose the same AG-UI endpoint shape, and the Angular half below doesn't change for any of them. + +Now `server.py`: + +```python +from typing import Annotated, Optional + +from ag_ui_langgraph import LangGraphAgent, add_langgraph_fastapi_endpoint +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from langchain_core.messages import SystemMessage, ToolMessage +from langchain_openai import ChatOpenAI +from langgraph.checkpoint.memory import MemorySaver +from langgraph.graph import END, START, StateGraph +from langgraph.graph.message import add_messages +from typing_extensions import TypedDict + +from threadplane.middleware.langgraph import bind_client_tools + +llm = ChatOpenAI(model="gpt-5-mini") + +SYSTEM = SystemMessage( + content=( + "You help the user build a reading list. " + "Use add_link to save a link, link_card to show one, and confirm_clear " + "before emptying the list. Keep replies to one short sentence." + ) +) + + +class State(TypedDict): + messages: Annotated[list, add_messages] + # ag-ui-langgraph merges RunAgentInput.tools into state["tools"] — that is + # the browser's tool catalog, shipped on every run. + tools: Optional[list] + # Any other state field is snapshotted to the client as `agent.state()`. + saved_count: int + + +async def generate(state: State) -> dict: + # Bind the client catalog per run: it arrives in state and can change. + bound = bind_client_tools(llm, [], state) + reply = await bound.ainvoke([SYSTEM, *state["messages"]]) + return {"messages": [reply], "saved_count": count_saved(state["messages"])} + + +# Every tool in this app is a client tool, so the graph always ends its turn +# after the model speaks. The browser executes the call and starts the next run +# with a ToolMessage. There is no server-side ToolNode to route to. +builder = StateGraph(State) +builder.add_node("generate", generate) +builder.add_edge(START, "generate") +builder.add_edge("generate", END) + +# ag-ui-langgraph reads graph state via aget_state, which needs a checkpointer. +graph = builder.compile(checkpointer=MemorySaver()) + +app = FastAPI(title="agui-reading-list") +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:4200"], + allow_methods=["*"], + allow_headers=["*"], +) + +add_langgraph_fastapi_endpoint(app, LangGraphAgent(name="chat", graph=graph), path="/agent") +``` + +The load-bearing line is `bind_client_tools(llm, [], state)`. + +AG-UI's `RunAgentInput` has a `tools` field, and `ag-ui-langgraph` merges it into `state["tools"]`. So the catalog the browser declared this run is sitting right there in graph state. `bind_client_tools` turns those entries into function-tool stubs and binds them alongside your server tools — an empty list, here, since this app has none. + +Bind it _inside_ the node, not once at module scope. The catalog arrives per run and can differ between runs. + +The routing is worth a sentence too. In an app with server tools you'd add a conditional edge to a `ToolNode`, and route to `END` when every call is a client tool. Here there are no server tools at all, so the graph always ends its turn after the model speaks, and the browser picks it up. + +Run it: + +```bash +export OPENAI_API_KEY=… +uvicorn server:app --port 8000 +``` + + + Sourcing a whole shared `.env` here is a good way to switch on auth middleware + you didn't mean to enable and get a confusing 401 on `/agent`. Export the one + key. + + +## How do we bind Angular to it? + +```bash +npm install @threadplane/chat @threadplane/ag-ui @ag-ui/client @ag-ui/core marked zod +``` + +The provider is one line, because AG-UI's connection surface is one URL: + +```ts +import { provideChat } from '@threadplane/chat'; +import { provideAgent } from '@threadplane/ag-ui'; + +export const appConfig: ApplicationConfig = { + providers: [ + provideRouter(routes), + provideAgent({ url: 'http://localhost:8000/agent' }), + provideChat({ assistantName: 'Librarian' }), + ], +}; +``` + +`provideAgent` also takes `headers` for auth tokens and `agentId` when one endpoint serves several agents. That's the whole config. + +## How does the browser get its own tools? + +This is the part worth the trip. + +A client tool is declared in Angular, shipped to the model as part of the catalog, and executed in the browser. There are three kinds, and they differ in what produces the result: + +| Helper | What it does | Result comes from | +|---|---|---| +| `action()` | Runs an async handler | The handler's return value | +| `view()` | Renders a component inline | Auto-acknowledged when it mounts | +| `ask()` | Renders an interactive component | The value the user's interaction emits | + +Let's build all three over one signal store. + +### The store + +Ordinary Angular. The agent never sees this — it only calls tools. + +```ts +@Injectable({ providedIn: 'root' }) +export class ReadingList { + private readonly _links = signal([]); + + readonly links = this._links.asReadonly(); + readonly count = computed(() => this._links().length); + + add(link: Link): number { + this._links.update((list) => [...list, link]); + return this._links().length; + } + + clear(): number { + const removed = this._links().length; + this._links.set([]); + return removed; + } +} +``` + +### The registry + +```ts +import { inject } from '@angular/core'; +import { action, ask, tools, view, type ClientToolRegistry } from '@threadplane/chat'; +import { z } from 'zod'; + +/** Call inside an injection context — it injects the browser-owned store. */ +export function readingListTools(): ClientToolRegistry { + const list = inject(ReadingList); + + return tools({ + add_link: action( + 'Save a link to the reading list. Afterwards, show it with link_card.', + z.object({ title: z.string(), url: z.string() }), + async ({ title, url }) => ({ saved: list.add({ title, url }) }), + ), + link_card: view( + 'Display a saved link with a one-line reason to read it.', + LINK_CARD_SCHEMA, + LinkCardComponent, + ), + confirm_clear: ask( + 'Ask the user to confirm emptying the reading list before doing it.', + CONFIRM_CLEAR_SCHEMA, + ConfirmClearComponent, + ), + }); +} +``` + +The object keys are the tool names the model sees. The descriptions are the _only_ steering the model gets about when to call them, so write them like instructions, not labels — "Afterwards, show it with `link_card`" is doing real work in that first one. + +Arguments are typed by a [Standard Schema](https://standardschema.dev), so Zod works directly and `action` handlers infer their argument type from it. + +### A `view` component + +The model fills the component's inputs from the schema. Under `strict: true` the typed overload fails the build if the component's inputs and the schema disagree, which is a nice place for that mistake to surface: + +```ts +export const LINK_CARD_SCHEMA = z.object({ + title: z.string(), + url: z.string(), + why: z.string(), +}); + +@Component({ + selector: 'app-link-card', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + `, +}) +export class LinkCardComponent { + readonly title = input.required(); + readonly url = input.required(); + readonly why = input.required(); +} +``` + +Derive the input types with `ViewProps` if you'd rather not repeat them by hand. + +### An `ask` component + +`ask` is the interesting one, because the component decides the result. It announces it through `injectRenderHost().result(...)`, and _that_ becomes the tool result that resumes the run: + +```ts +@Component({ + selector: 'app-confirm-clear', + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (cleared() === undefined) { +
+

Clear all {{ list.count() }} saved links? ({{ reason() }})

+ + +
+ } @else if (cleared()) { +

Cleared {{ removed() }} links.

+ } @else { +

Kept the reading list.

+ } + `, +}) +export class ConfirmClearComponent { + readonly reason = input.required(); + /** Spread back onto props once the ask resolves. */ + readonly cleared = input(undefined); + readonly removed = input(undefined); + + protected readonly list = inject(ReadingList); + private readonly host = injectRenderHost(); + + protected clear(): void { + this.host.result({ cleared: true, removed: this.list.clear() }); + } + + protected cancel(): void { + this.host.result({ cleared: false, removed: 0 }); + } +} +``` + +Two details make this behave well. + +The mutation happens _here_, in the component, not in a handler — an `ask` emits its own result and nothing sits in between to intercept it. And once it resolves, the adapter writes the emitted value back onto the local tool call, so the component re-renders with `cleared` and `removed` as props. That's why the template branches: the live card only shows while `cleared()` is still `undefined`, and afterwards the transcript shows a frozen line instead of buttons the user could press again. + +### Binding it + +```ts +template: ``, +``` + +```ts +protected readonly agent = injectAgent(); +protected readonly clientTools = readingListTools(); +``` + +That's it. Ask the assistant to save a link, and you'll watch `add_link` execute in the browser, the sidebar count go up, and `link_card` mount inside the transcript as a real component. + +## How does the agent share state? + +Anything else in graph state is snapshotted to the client and lands on `agent.state()`: + +```ts +protected readonly savedCount = computed( + () => (this.agent.state() as { saved_count?: number }).saved_count ?? 0, +); +``` + +Which brings up a detail that's easy to lose an hour to. + +The graph counts completed `add_link` calls. The obvious implementation is to look for `ToolMessage`s named `add_link` — and it silently returns zero forever. **Client-tool results come back carrying a `tool_call_id` but no `name`**, because the adapter adds them as `{ id, role: 'tool', toolCallId, content }`. Match on the id instead: + +```python +def count_saved(messages: list) -> int: + add_link_ids = { + call["id"] + for m in messages + for call in getattr(m, "tool_calls", None) or [] + if call.get("name") == "add_link" + } + return sum( + 1 + for m in messages + if isinstance(m, ToolMessage) and m.tool_call_id in add_link_ids + ) +``` + +For progress _during_ a run rather than state after it, AG-UI `CUSTOM` events accumulate on `agent.customEvents()` — a LangGraph node emits them with `get_stream_writer()`. The [custom events guide](/docs/ag-ui/guides/custom-events) covers that path. + +## What happens when we swap the backend? + +The Angular half doesn't change. That's the payoff, and it's worth being precise about what it costs. + +Swapping runtimes is the provider line: + +```diff +- import { provideAgent } from '@threadplane/ag-ui'; +- providers: [provideAgent({ url: 'http://localhost:8000/agent' })], ++ import { provideAgent } from '@threadplane/langgraph'; ++ providers: [provideAgent({ apiUrl: '…', assistantId: 'chat' })], +``` + +Those are two different functions from two different packages, not one symbol that takes both shapes. Components stay identical because both adapters produce the same runtime-neutral `Agent` contract — and client tools are declared against `@threadplane/chat`, so the registry above moves across untouched. + +The cost is a thin translation layer per adapter, and a real compatibility surface: your new backend has to emit the AG-UI events the UI reads. The [event mapping reference](/docs/ag-ui/reference/event-mapping) is the checklist when a stream renders as nothing. + +## What doesn't AG-UI give you? + +Thread history, and it's a protocol fact rather than a gap in any library. + +AG-UI is event-stream-only. It defines no server-side thread-lookup endpoint, so there's nothing to enumerate past conversations with and nothing to validate a thread id against. `injectThreadRouting()` still works for a single id in the URL, but its `validate` callback has no backend to ask. + +So a conversation sidebar over AG-UI is app-owned: you keep the list, you title the threads, you decide what "restore" means. If server-backed thread history is the feature you actually want, LangGraph exposes per-thread checkpoints and a thread API, and I walked through building exactly that in [Angular Chat App Tutorial with LangChain and LangGraph](/blog/angular-chat-app-tutorial-with-langchain-langgraph). + +Worth saying plainly: pick the protocol for the backend you have, not for the sidebar. Portability across agent frameworks and server-managed thread history are different features, and AG-UI is unambiguously the better answer to the first one. + +## What still needs work before production? + +- **Client tools run in the browser, so they run with the user's authority.** A handler that calls your API is a client calling your API. Authorize on the server; a tool description is not an access-control policy. +- **Side effects need a guard.** Tools are non-idempotent by default. If a handler moves money or sends mail, pair it with a `[clientToolExecutionGuard]` so a reload can't run it twice, and mark only genuinely safe tools `idempotent: true`. +- **Runaway loops are capped, but tune the cap.** A model that keeps calling client tools stops after 10 continuation groups per user turn. Adjust with `[clientToolContinuationPolicy]` and decide what the UI says when it trips. +- **CORS and auth.** `http://localhost:4200` is a development origin. Use your real one, or route through the same domain and skip cross-origin entirely. Pass tokens with `headers`. +- **A buffering proxy breaks streaming.** Disable response buffering and preserve the streaming content type, or the whole thing collapses into a spinner. +- **`MemorySaver` is not persistence.** It's in this tutorial because `ag-ui-langgraph` needs a checkpointer to read state. It is not a store. + +## Conclusion + +The good boundary in an AG-UI app is that the protocol carries capability in both directions. The server streams events; the browser declares tools. Once both halves are true, "which framework is behind this" stops being a question your components can answer — and that's the point. + +Start with one `action`, get it mutating a signal store, then add a `view` for how the result should look and an `ask` for the moments that need a human. That order keeps each step small enough to debug. + +And when you need a conversation sidebar with real history, reach for a runtime that stores threads rather than making the protocol do something it never claimed to. + +Have fun! diff --git a/apps/website/content/blog/2026-08-13-angular-chat-app-tutorial-with-langchain-langgraph.mdx b/apps/website/content/blog/2026-08-13-angular-chat-app-tutorial-with-langchain-langgraph.mdx new file mode 100644 index 000000000..3006bfa33 --- /dev/null +++ b/apps/website/content/blog/2026-08-13-angular-chat-app-tutorial-with-langchain-langgraph.mdx @@ -0,0 +1,357 @@ +--- +title: 'Angular Chat App Tutorial with LangChain and LangGraph' +description: 'Build a multi-thread Angular chat app on LangGraph: a conversation sidebar, server-titled threads, and bookmarkable URLs that survive a refresh.' +date: 2026-08-13 +tags: [tutorial, langgraph, langchain, angular, threads, agentic-ui] +author: brian +featured: false +draft: false +--- + +Let's build an Angular chat _app_ on LangGraph — not a single chat surface, but the whole thing: a conversation sidebar, threads that keep their history, and URLs you can bookmark. + +One conversation is a demo. +A list of them, each restoring on reload, is a product. + +The difference is smaller than you'd think, because the durable part lives on the server. LangGraph already checkpoints every thread and exposes a thread API. Our job in Angular is mostly to stop throwing that away. + +## Goals + +- Get a LangGraph server running with a graph that streams. +- Bind it to Angular with `@threadplane/langgraph` and `@threadplane/chat`. +- Add a conversation sidebar backed by the server's own thread list. +- Make reload, back/forward, and shared links land in the right conversation. +- Name the parts that are still not production-ready. +- Have fun! + + + `@threadplane/langgraph` is MIT-licensed. `@threadplane/chat` is available for + noncommercial use under PolyForm Noncommercial 1.0.0; commercial production use + requires a Threadplane license. The [chat installation + guide](/docs/chat/getting-started/installation) covers activation. + + +If you only want the streaming surface and none of the app around it, read [Build a Streaming Chat UI in Angular with LangGraph](/blog/build-a-streaming-chat-ui-in-angular-with-langgraph) instead. This post picks up where that one stops. + +## What are we building? + +Here's the whole path: + +```text +Angular + + -> @threadplane/langgraph + -> @langchain/langgraph-sdk + -> langgraph dev (thread store + checkpoints) + -> your graph + -> ChatOpenAI +``` + +Two Angular pieces, and they read from different places. +`` renders the active conversation from the agent. `` renders the thread _list_ from the LangGraph thread API. + +That split is the thing to hold onto. The agent knows about one conversation. The thread adapter knows about all of them. + +## How do we get a LangGraph server running? + +Let's do the backend first, because the Angular side has nothing to bind to without it. + +Install the CLI and the model package into a virtualenv: + +```bash +python -m venv .venv +source .venv/bin/activate +pip install "langgraph-cli[inmem]" langgraph langgraph-sdk langchain-openai +``` + +Now `graph.py`. A generate node, and a second node that gives the thread a title: + +```python +import os + +from langchain_core.messages import SystemMessage +from langchain_core.runnables import RunnableConfig +from langchain_openai import ChatOpenAI +from langgraph.graph import END, START, MessagesState, StateGraph +from langgraph_sdk import get_client + +llm = ChatOpenAI(model="gpt-5-mini") + +SYSTEM = SystemMessage( + content="You are a concise assistant for an Angular chat app. Keep answers short." +) + + +async def generate(state: MessagesState) -> dict: + reply = await llm.ainvoke([SYSTEM, *state["messages"]]) + return {"messages": [reply]} + + +async def title_thread(state: MessagesState, config: RunnableConfig) -> dict: + """Write a short title into thread metadata, once, after the first reply.""" + thread_id = (config.get("configurable") or {}).get("thread_id") + if not isinstance(thread_id, str) or not thread_id: + return {} + + client = get_client(url=os.environ.get("LANGGRAPH_API_URL")) + thread = await client.threads.get(thread_id) + if (thread.get("metadata") or {}).get("title"): + return {} + + titled = await llm.ainvoke( + [ + SystemMessage( + content="In 3-5 words, summarize what the user is asking about. " + "Output ONLY the title." + ), + *state["messages"], + ] + ) + await client.threads.update(thread_id, metadata={"title": titled.text().strip()}) + return {} + + +builder = StateGraph(MessagesState) +builder.add_node("generate", generate) +builder.add_node("title_thread", title_thread) +builder.add_edge(START, "generate") +builder.add_edge("generate", "title_thread") +builder.add_edge("title_thread", END) + +graph = builder.compile() +``` + +Two things there are worth calling out. + +**No checkpointer.** `graph.compile()` takes no argument. `langgraph dev` — and LangGraph Platform — provide persistence themselves, and compiling one in fights the server. This trips people up because most LangGraph tutorials you'll read are about invoking a graph in-process, where you _do_ pass `MemorySaver()`. + +**`title_thread` calls back into the server.** It's the same SDK your Angular app will use, pointed at the thread it's currently running inside. Leaving `url` as `None` lets the SDK use its in-process transport instead of an HTTP round trip. + +Add `langgraph.json`: + +```json +{ + "graphs": { "chat": "./graph.py:graph" }, + "dependencies": ["."], + "python_version": "3.12", + "env": ".env" +} +``` + +Put `OPENAI_API_KEY=…` in `.env`, then start it: + +```bash +langgraph dev --no-browser --port 2024 +``` + +The `chat` key in `graphs` is what you'll pass to Angular as `assistantId`. That mapping is easy to forget later when the id doesn't match and every run 404s. + +## How do we bind Angular to it? + +```bash +npm install @threadplane/chat @threadplane/langgraph @langchain/core @langchain/langgraph-sdk marked +``` + +Now `app.config.ts`. This is the file that does the most work in the whole app: + +```ts +import { + ApplicationConfig, + provideBrowserGlobalErrorListeners, + provideZoneChangeDetection, + signal, +} from '@angular/core'; +import { provideRouter } from '@angular/router'; +import { provideChat } from '@threadplane/chat'; +import { LANGGRAPH_THREADS_CONFIG, provideAgent } from '@threadplane/langgraph'; + +import { routes } from './app.routes'; + +const API_URL = 'http://localhost:2024'; + +/** The active conversation. Module scope, so `provideAgent()` can reference it. */ +export const ACTIVE_THREAD = signal(null); + +export const appConfig: ApplicationConfig = { + providers: [ + provideBrowserGlobalErrorListeners(), + provideZoneChangeDetection({ eventCoalescing: true }), + provideRouter(routes), + provideAgent({ + apiUrl: API_URL, + assistantId: 'chat', + threadId: ACTIVE_THREAD, + onThreadId: (id) => ACTIVE_THREAD.set(id), + }), + { provide: LANGGRAPH_THREADS_CONFIG, useValue: { apiUrl: API_URL } }, + provideChat({ assistantName: 'Assistant' }), + ], +}; +``` + +`ACTIVE_THREAD` sits at module scope on purpose. `provideAgent()` runs when providers are registered — before any component exists — so it can't reference a class field. + +The two options that make this an app rather than a demo: + +- `threadId: ACTIVE_THREAD` — the adapter _watches_ this signal. Set it, and the conversation switches. You never call a "load thread" method. +- `onThreadId` — fires when the adapter creates a thread on the first submit. Writing it back into the same signal is what closes the loop. + +`LANGGRAPH_THREADS_CONFIG` is separate because the thread list is a separate concern from the agent. It's the config for `LangGraphThreadsAdapter`, which wraps `client.threads.*`. + +## How do we render the sidebar? + +`` is the conversation list. One thing to know before you write the template: it has named projection slots for its own regions, but no default slot. The chat goes _beside_ it, not inside it. + +```ts +import { ChangeDetectionStrategy, Component, inject } from '@angular/core'; +import { + ChatComponent, + ChatSidenavComponent, + injectThreadRouting, + type ThreadActionAdapter, +} from '@threadplane/chat'; +import { + injectAgent, + LangGraphThreadsAdapter, + refreshOnRunEnd, +} from '@threadplane/langgraph'; + +import { ACTIVE_THREAD } from './app.config'; + +@Component({ + selector: 'app-shell', + imports: [ChatComponent, ChatSidenavComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + +
+ +
+ `, + styles: ` + :host { display: flex; height: 100dvh; } + .chat-pane { flex: 1; min-width: 0; } + `, +}) +export class ShellComponent { + protected readonly agent = injectAgent(); + protected readonly threads = inject(LangGraphThreadsAdapter); + protected readonly activeThread = ACTIVE_THREAD; + + protected readonly threadActions: ThreadActionAdapter = { + rename: async (id, title) => { + await this.threads.rename(id, title); + await this.threads.refresh(); + }, + delete: async (id) => { + await this.threads.delete(id); + await this.threads.refresh(); + }, + archive: async (id) => { + await this.threads.archive(id); + await this.threads.refresh(); + }, + unarchive: async (id) => { + await this.threads.unarchive(id); + await this.threads.refresh(); + }, + }; + + constructor() { + injectThreadRouting({ + threadId: ACTIVE_THREAD, + validate: (id) => this.threads.getThread(id).then(Boolean), + }); + refreshOnRunEnd(this.agent, () => this.threads.refresh()); + void this.threads.refresh(); + } +} +``` + +Notice how little of this is thread _logic_. + +Selecting a conversation is `activeThread.set($event)`. Starting a new one is `activeThread.set(null)`. Both work because the adapter is watching the signal — the sidebar doesn't talk to the agent at all. + +`ThreadActionAdapter` is the right-click menu contract: rename, delete, archive, unarchive, pin, move to a project. `LangGraphThreadsAdapter` already implements each of those against the SDK, so wiring them is mostly forwarding. The `refresh()` after each call matters: the framework clears its optimistic override in a `finally` block, so an action that doesn't change the input list will re-render the old row. + +`refreshOnRunEnd` re-fetches the list when a run finishes, which is how a brand-new conversation shows up in the sidebar. + +## How do links survive a refresh? + +That's `injectThreadRouting()`, and it's three lines in the constructor above. + +It restores the thread id from the URL on load, stamps signal changes back into the URL, and keeps the two in sync across back/forward. **The URL is the only source of truth — nothing is written to localStorage**, which is what makes links shareable without extra plumbing. + +The `validate` callback earns its place the first time someone pastes a stale link. It runs on any id that appears in the URL; returning `false` redirects to the bare path with `replaceUrl: true`, so the dead URL doesn't sit in history. `LangGraphThreadsAdapter.getThread()` returns `null` on a 404 and rethrows genuine network errors, so `.then(Boolean)` is the whole implementation. + +Your routes just need both shapes: + +```ts +export const routes: Routes = [ + { path: '', component: ShellComponent }, + { path: ':threadId', component: ShellComponent }, +]; +``` + +Bare path means no thread, which is the welcome state. + +## Where do thread titles come from? + +The server, which is why `title_thread` is in the graph at all. + +`LangGraphThreadsAdapter` maps each SDK thread to the framework's `Thread` type and reads the label from `metadata.title`. Threads that don't have one yet render as `Untitled` — configurable with `titleFallback` on `LANGGRAPH_THREADS_CONFIG`. + +There's a wrinkle worth knowing, and it shows up on the very first conversation. + +A new conversation appears in the sidebar as **Untitled** and _stays_ Untitled for a while. The title node writes `metadata.title` during the run, but the thread list keeps returning the old value for several seconds after the run ends — so the `refreshOnRunEnd` fetch reads a thread that isn't titled yet. + +My first instinct was to refresh a second time on a delay. That doesn't work, and I'd rather save you the detour: against `langgraph dev` I tried a 1.5s follow-up, then a 4s one, then a bounded poll refreshing five times at 1.5s intervals. All three finished before the new title showed up in the list, while a direct fetch of that same thread already had it. + +So don't engineer around it with a timer. The label settles on the next list refresh you were going to do anyway — the next message, a navigation, or a reload. + +If you want the row correct immediately, own the label instead of waiting for it: title the thread optimistically in your own state from the first user message, and let the server's value replace it whenever the list catches up. That sidesteps the race rather than racing it. + +Either way, don't make the title node block the run to keep a sidebar label in sync. For me that's the wrong trade — the title is a nicety, and the conversation shouldn't wait on it. + +## What about the bundle? + +One practical note, because it'll be your first failed build. + +A fresh `ng new` sets a 500 kB warning and 1 MB error budget. A chat UI plus the LangGraph SDK lands around 1.4 MB raw, so `ng build` fails on budget before it fails on anything real. Raise it in `angular.json`: + +```json +{ + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "3mb" +} +``` + +You'll also see a warning that `p-queue`, used by `@langchain/core`, isn't ESM. It's a bailout warning, not an error. + +## What still needs work before production? + +The app runs now. It isn't finished. + +- **The checkpointer has to be durable.** `langgraph dev` keeps threads in memory. Bookmarkable URLs against an in-memory store are a lie — the link survives, the conversation doesn't. Move to Postgres before you ship the sidebar. Persistence UI should match what the backend can actually restore. +- **Threads need owners.** A `threadId` in a URL is an identifier, not proof the caller may read that conversation. Bind threads to the authenticated user on the server; `client.threads.search()` will happily list everything otherwise. +- **The API key can't live in the browser.** Right now Angular talks to `localhost:2024` directly. In production put a same-origin backend-for-frontend in front, and make `apiUrl` point at that. +- **A buffering proxy breaks streaming.** If a gateway sits in the path, disable response buffering and preserve the streaming content type, or your token-by-token UI becomes a spinner. +- **Errors need a path back.** The `Agent` contract has `retry()` and `regenerate()`; `` wires both. Decide what a failed run should look like before a user finds out for you. + +## Conclusion + +The useful split here is that the server already owns everything durable. LangGraph checkpoints the conversation and stores the thread; the Angular app subscribes to one thread through a signal and lists the rest through the thread adapter. + +Once `threadId` is a signal, most of what feels like "app" is just setting it — from a click, from the URL, from a new run. That's the part I'd take away from this even if you build the UI yourself. + +Start with `langgraph dev`, get the sidebar listing real threads, then swap in a durable checkpointer and put an authenticated proxy in front. + +Then go spend your time on the parts your users actually see. Have fun! diff --git a/docs/superpowers/specs/2026-08-13-angular-chat-app-tutorials-design.md b/docs/superpowers/specs/2026-08-13-angular-chat-app-tutorials-design.md new file mode 100644 index 000000000..715d77660 --- /dev/null +++ b/docs/superpowers/specs/2026-08-13-angular-chat-app-tutorials-design.md @@ -0,0 +1,88 @@ +# Angular Chat App Tutorials (AG-UI + LangChain/LangGraph) — Design + +Date: 2026-08-13 +Status: approved + +## Goal + +Two net-new blog posts under `apps/website/content/blog/`, in Brian's voice, that +build a chat **app** rather than a chat surface. They sit alongside the existing +May posts rather than replacing them. + +## Relationship to existing posts + +| Existing post | Scope | +|---|---| +| `2026-05-17-build-a-streaming-chat-ui-in-angular-with-langgraph` | Wires a single `` to LangGraph | +| `2026-05-21-build-fullstack-agentic-angular-apps-using-ag-ui` | AG-UI event model → Angular signals | + +The new posts assume that ground is covered and cross-link to it. They do not +re-explain the protocol event model or the streaming rationale. + +## The two posts are deliberately differentiated + +Server-backed thread history is a LangGraph capability: `LangGraphThreadsAdapter` +wraps `client.threads.*`, and `injectThreadRouting({ validate })` needs a +thread-lookup endpoint. AG-UI is event-stream-only and defines no such endpoint. + +Rather than paper over that, each post gets its own spine: + +- **LangGraph post** — the multi-thread app: sidebar, history, routing, persistence. +- **AG-UI post** — the portable app: client tools, shared state, backend swap. + +Each post names the other's strength and links to it. + +## Post 1 — `Angular Chat App Tutorial with LangChain and LangGraph` + +File: `apps/website/content/blog/2026-08-13-angular-chat-app-tutorial-with-langchain-langgraph.mdx` + +1. Lede + `## Goals` +2. What are we building? — architecture as a text block +3. Getting a LangGraph server running — minimal `graph.py` (`MessagesState`, + `ChatOpenAI`, `MemorySaver`), `langgraph.json`, `langgraph dev` +4. Wiring Angular — `provideAgent({ apiUrl, assistantId })`, `` +5. One conversation → an app — module-scope `ACTIVE_THREAD` signal, + `threadId`/`onThreadId`, ``, `LangGraphThreadsAdapter`, + `ThreadActionAdapter`, `refreshOnRunEnd` +6. Surviving a refresh — `injectThreadRouting({ threadId, validate })`; + URL is the sole source of truth, nothing in localStorage +7. Where thread titles come from — a terminal node writing `metadata.title`, + `titleFallback` +8. Before production — `MemorySaver` makes bookmarkable URLs lie; thread + ownership; keys behind a BFF; CORS; `retry()` +9. `## Conclusion` + +## Post 2 — `Angular Chat App Tutorial with AG-UI` + +File: `apps/website/content/blog/2026-08-13-angular-chat-app-tutorial-with-ag-ui.mdx` + +1. Lede + `## Goals` +2. What are we building? +3. Getting an AG-UI endpoint running — FastAPI + an official integration; note + that CrewAI / Mastra / Pydantic AI / Strands expose the same shape +4. Wiring Angular — `provideAgent({ url })`, `` +5. Giving the browser its own tools — `tools()`/`action()`/`view()`/`ask()`, + `[clientTools]`, `ViewProps`, `followUp: false` +6. Sharing state — `agent.state()`, `agent.customEvents()` +7. Swapping the backend — one-line `provideAgent` change; event-mapping checklist +8. What AG-UI doesn't give you — no thread-lookup, so no `validate`; thread + history is app-owned. Links to Post 1 +9. Before production — auth headers, proxy buffering, CORS, thread ownership +10. `## Conclusion` + +## Constraints + +- Voice: the register of the 2026-08-09 Strands post — H2-as-question, `## Goals`, + "Let's" transitions, explicit `## Conclusion`, tradeoffs named, contractions. + No invented first-person anecdotes. +- Frontmatter: `author: brian`, `draft: false`, `featured: false`, dated 2026-08-13. +- `@threadplane/*` at 0.0.57. Angular 20/21, Node 22. +- MDX components available to blog posts (same renderer as docs): `Callout`, + `Steps`/`Step`, `Tabs`/`Tab`, `Card`/`CardGroup`, `CodeGroup`. +- Licensing callout for `@threadplane/chat`, matching the Strands post. + +## Verification + +Every snippet must come from code that actually ran. Both backends and both +Angular apps are scaffolded in the scratchpad against the **published** npm +packages, built, and driven in a browser before the posts are written.