Thread Routing
A chat that holds more than one conversation needs two things: a store that knows which threads exist, and a single signal that says which one is active. @threadplane/langgraph supplies the store, provideAgent({ threadId }) watches the signal, and injectThreadRouting() binds that signal to the Angular Router when you want conversations to survive a reload and travel in a link. The running example wires the store and the signal, and this guide walks its three files before turning to the URL layer.
What the demo does
The Run tab shows the prebuilt <chat> composition with a thread sidebar beside it. Ask the aviation assistant about a route — a flight from LAX to JFK, for instance — and the backend allocates a thread. The sidebar row starts out labeled Untitled and picks up a short generated title once the run finishes.
Press "+ New" above the list to open a second conversation, then move between the rows and watch the transcript swap. Each row also carries a menu with Rename, Archive, and Delete, and every one of those round-trips through the LangGraph Threads API. Archiving a thread moves it out of the top list and into a second one headed Archived, whose rows offer Unarchive, so the round trip is reachable from the page.
How it is built
Three files carry the feature: a graph that writes a readable title into thread metadata, an application config that registers the thread store, and a component that owns the active-thread signal and the sidebar. Open the Code tab to read them in place.
Naming a thread from its first turn
A thread id is a UUID, which makes a poor sidebar label. The generate_title node asks a small model to summarize the first human message and writes the result to the thread's metadata.title, which is exactly where the thread store looks for a label. The write itself sits inside a try/except that swallows failures, so a title write that fails never blocks the run.
thread = await client.threads.get(thread_id)
if (thread.get("metadata") or {}).get("title"):
return {}
first_user = next(
(m for m in state["messages"] if getattr(m, "type", None) == "human"),
None,
)
if not first_user or not isinstance(first_user.content, str):
return {}
# Skip action-message JSON (those flow as human-role too)
if first_user.content.lstrip().startswith("{"):
return {}
llm = ChatOpenAI(model=_TITLE_MODEL, temperature=0)
response = await llm.ainvoke([
SystemMessage(content=_TITLE_PROMPT),
HumanMessage(content=first_user.content),
])
title = (response.content or "").strip().strip('"').strip("'")[:80]
if title:
await client.threads.update(thread_id, metadata={"title": title})The write is idempotent: the node reads the thread first and returns early when a title is already present, so later turns cost nothing.
Wiring the title node into the graph
The title node runs after the user-visible turn, never before it, so nothing about the response waits on it.
graph = StateGraph(MessagesState)
graph.add_node("generate", generate)
graph.add_node("generate_title", generate_title)
graph.set_entry_point("generate")
graph.add_edge("generate", "generate_title")
graph.add_edge("generate_title", END)This graph calls compile() with no checkpointer, because the LangGraph API server provides persistence itself. langgraph dev refuses to load a graph that compiles its own saver, and a deployment ignores one. See LangGraph persistence for the full story and for the cases where you do supply a saver.
Registering the thread store
LangGraphThreadsAdapter is a root-provided service that wraps client.threads.* from @langchain/langgraph-sdk. It reads its base URL from LANGGRAPH_THREADS_CONFIG, and that token is the reason this application config is longer than most. The second root token, LANGGRAPH_CLIENT_OPTIONS, carries the client options — auth headers, retries — that both the adapter and the component-scoped provideAgent read.
import { ApplicationConfig } from '@angular/core';
import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';
import {
LANGGRAPH_CLIENT_OPTIONS,
LANGGRAPH_THREADS_CONFIG,
} from '@threadplane/langgraph';
export const appConfig: ApplicationConfig = {
providers: [
// The agent is provided at the component (ThreadsComponent) because its
// threadId + onThreadId config is per-instance — see threads.component.ts.
// The adapter expects metadata.title; the cap's generate_title
// graph node writes there. No per-cap key override needed.
{
provide: LANGGRAPH_THREADS_CONFIG,
useFactory: () => {
const connection = injectCockpitRuntimeConnection();
if (connection.adapter !== 'langgraph') {
throw new Error('incompatible runtime');
}
return { apiUrl: connection.apiUrl };
},
},
{
provide: LANGGRAPH_CLIENT_OPTIONS,
useFactory: () => {
const connection = injectCockpitRuntimeConnection();
if (connection.adapter !== 'langgraph') {
throw new Error('incompatible runtime');
}
return connection.clientOptions;
},
},
],
};Nothing else is registered at the root, because the agent itself is provided at the component instead.
The active-thread signal
One writable signal is the source of truth for the active thread. It lives at module scope so the component's provideAgent() factory, which runs at provider registration time, can close over it.
// Writable signal the agent watches — assigning to it switches the active
// thread without forcing a full agent rebuild. Shared between the
// component-scoped provideAgent() config (threadId + onThreadId) and the
// component. Module scope is safe: each demo app bootstraps one
// ThreadsComponent instance.
export const activeThreadIdState = signal<string | null>(null);Module scope is safe here because the demo bootstraps exactly one instance of the component; an application that mounts several should move the signal into a service.
The agent provider
provideAgent() sits in the component's providers array rather than in the application config, because threadId and onThreadId are per-instance configuration.
// Scoped agent (Option B): threadId + onThreadId are per-instance, so the
// agent is provided at the component rather than in app.config.ts.
providers: [
provideAgent(() => {
const connection = injectCockpitRuntimeConnection();
if (connection.adapter !== 'langgraph') {
throw new Error('incompatible runtime');
}
return {
apiUrl: connection.apiUrl,
assistantId: connection.assistantId,
clientOptions: connection.clientOptions,
threadId: activeThreadIdState,
// When the agent auto-creates a thread on first submit, the
// adapter calls back with its id; mirror that into our signal so
// the sidenav highlights it immediately.
onThreadId: (id: string) => activeThreadIdState.set(id),
};
}),
],The adapter watches threadId, so assigning to the signal switches conversations, and onThreadId fires when the backend allocates a thread on first submit, which keeps the signal in step without a round-trip through the sidebar.
Never generate a thread id in the browser. Use the value handed to onThreadId, or one the LangGraph Threads API returned earlier.
The sidebar and the thread list
The <chat> composition owns the transcript, the input, and the loading and error states, so the template adds the lists beside it. <chat> can also render a <chat-thread-list> of its own, in a sidebar shown at widths of 768px and up whenever its [threads] input is non-empty, and it re-emits that list's threadSelected. The demo leaves those inputs unbound and mounts explicit lists instead, so that they can carry the row actions shown below: one over threads() in the default active mode, and a second over archivedThreads() in archived mode that renders only when something has been archived.
<example-chat-layout sidebarPosition="left" sidebarWidth="16rem">
<chat main [agent]="agent" class="flex-1 min-w-0" />
<div sidebar class="panel">
<div class="panel-header">
<h3 class="cap">Threads</h3>
<button type="button"
class="action-button"
(click)="onNewThread()">+ New</button>
</div>
<chat-thread-list
[threads]="threadsSvc.threads()"
[activeThreadId]="activeThreadId() ?? ''"
[actions]="threadActions"
(threadSelected)="onThreadSelected($event)" />
<!-- The Unarchive action is only built for a list in archived
mode, so archiving is a round trip only when a second list
renders the archived threads. -->
@if (threadsSvc.archivedThreads().length) {
<h3 class="cap">Archived</h3>
<chat-thread-list
[threads]="threadsSvc.archivedThreads()"
[activeThreadId]="activeThreadId() ?? ''"
[actions]="threadActions"
[mode]="'archived'"
(threadSelected)="onThreadSelected($event)" />
}
</div>
</example-chat-layout>Both <chat> and <chat-thread-list> emit threadSelected, so either surface can drive the switch.
Row actions
ThreadActionAdapter is how the list asks the application to perform a rename, an archive, or a delete. The framework owns the row interactions around each call — the inline rename field, the delete confirmation dialog, and the optimistic row update — so the adapter only has to perform the write.
/** Action adapter: framework calls these on rename / delete / archive
* after confirmation. Adapter handles SDK round-trip + refresh. */
protected readonly threadActions: ThreadActionAdapter = {
delete: async (id) => {
await this.threadsSvc.delete(id);
if (this.activeThreadId() === id) this.activeThreadId.set(null);
},
rename: (id, title) => this.threadsSvc.rename(id, title),
archive: async (id) => {
await this.threadsSvc.archive(id);
if (this.activeThreadId() === id) this.activeThreadId.set(null);
},
unarchive: (id) => this.threadsSvc.unarchive(id),
};Deleting or archiving the active conversation clears the signal, which returns the surface to its empty state.
<chat-thread-list> builds its menu from the keys the adapter defines, and then filters by mode. The demo's first list runs in the default active mode, so it offers Rename, Archive, and Delete; unarchive surfaces only in a list rendered with [mode]="'archived'", which is the second list. One adapter serves both. Each adapter method refreshes the store on success, which the framework relies on to clear its optimistic overrides.
Refreshing after a run
The store is a snapshot, not a subscription, so something has to re-fetch it. refreshOnRunEnd() calls back on every transition out of the running state, which is precisely when the title node has finished writing.
constructor() {
// Initial fetch.
void this.threadsSvc.refresh();
// Re-fetch when an agent run completes. The graph's generate_title
// node writes metadata.title on the first turn; refreshing
// on the running→idle transition surfaces it in the sidenav
// without a manual reload.
refreshOnRunEnd(this.agent, () => this.threadsSvc.refresh());
}refreshOnRunEnd() must run inside an injection context, because it uses an Angular effect; refresh() is a plain async call.
Switching and creating
Two handlers close the loop. Selecting a row tells the agent to load that thread and updates the signal; the "+ New" button asks the store for a thread and then activates it. create() returns null when the request fails, which is why the result is checked before it is used.
protected onThreadSelected(threadId: string): void {
// switchThread is the LangGraph adapter's canonical thread-switch API
// (resets derived state + reloads server messages for the new thread).
this.agent.switchThread(threadId);
this.activeThreadId.set(threadId);
}
protected async onNewThread(): Promise<void> {
const id = await this.threadsSvc.create();
if (id) {
this.agent.switchThread(id);
this.activeThreadId.set(id);
}
}Each handler calls both agent.switchThread(id) and activeThreadId.set(id) because the two calls serve different consumers: the adapter watches the activeThreadId signal to drive the sidebar highlight, while switchThread(id) resets the agent's own derived state and re-fetches that thread's server-side history.
Routing a URL to a thread
The example stops at an in-memory signal, which is the right choice for a surface with no visible URL. When the address bar is visible and the user may share it, injectThreadRouting() binds the same signal to the Angular Router: it restores the thread id from the URL on load, stamps signal changes back into the URL, validates stale links, and treats a bare URL as "no thread". The URL is the sole source of truth, and nothing is written to localStorage.
| Behavior | Detail |
|---|---|
| Restore on load | Reads the thread id from the URL on startup and seeds the signal |
| Signal to URL | When the signal changes, navigates to the matching URL |
| URL to signal | On every NavigationEnd, keeps the signal in sync with the current URL |
| Validate stale links | When validate resolves false, redirects to the bare path with replaceUrl: true |
| Bare URL is the welcome state | When the URL holds no thread segment, the signal is null |
Given the module-scoped signal and the store the example already has, adding the URL layer is one call from an injection context:
import { Component, inject } from '@angular/core';
import { injectThreadRouting } from '@threadplane/chat';
import { LangGraphThreadsAdapter } from '@threadplane/langgraph';
import { activeThreadIdState } from './threads.component';
@Component({ /* ... */ })
export class ShellComponent {
private readonly threads = inject(LangGraphThreadsAdapter);
constructor() {
injectThreadRouting({
threadId: activeThreadIdState,
validate: (id) => this.threads.getThread(id).then(Boolean),
});
}
}injectThreadRouting() calls inject(Router) internally, so it must run in an injection context: a constructor body, as above, or a field initializer. The validate callback receives every candidate id the URL produces. getThread() resolves null for both outcomes the server reports as "missing" — a 404 for a thread it does not have (a deleted id, or one pasted from another environment) and a 422 for an id that is not even a valid UUID — and rethrows genuine network errors, so a redirect happens only when the thread is really gone.
Custom URL shapes
The defaults are / for the welcome state and /<threadId> for a conversation. Supply toCommands and threadIdFromUrl for mode-prefixed or nested paths:
import { activeThreadIdState } from './threads.component';
const MODES = ['embed', 'popup', 'sidebar'] as const;
type DemoMode = (typeof MODES)[number];
function parseUrl(url: string): { mode: DemoMode; threadId: string | null } {
const segs = url.split('?')[0].split('#')[0].split('/').filter(Boolean);
const mode = (MODES as readonly string[]).includes(segs[0])
? (segs[0] as DemoMode)
: 'embed';
const threadId = segs[1]?.length ? segs[1] : null;
return { mode, threadId };
}
// Inside the shell constructor — `this.mode()` reads the current mode signal:
injectThreadRouting({
threadId: activeThreadIdState,
threadIdFromUrl: (url) => parseUrl(url).threadId,
toCommands: (id) => (id ? ['/', this.mode(), id] : ['/', this.mode()]),
validate: (id) => this.threads.getThread(id).then(Boolean),
});threadIdFromUrl extracts the id from whatever URL the router lands on, and toCommands builds the navigate command array for an id, or for null when the conversation is cleared.
Configuration reference
import { injectThreadRouting, type ThreadRoutingConfig } from '@threadplane/chat';
import { WritableSignal } from '@angular/core';
import { NavigationExtras } from '@angular/router';
export interface ThreadRoutingConfig {
/** App-owned signal that is the source of truth for the active thread. */
threadId: WritableSignal<string | null>;
/** Build router commands for a thread id (null = welcome/bare path).
* Default: (id) => id ? ['/', id] : ['/'] */
toCommands?: (id: string | null) => unknown[];
/** Extract a thread id from a URL string (null = no thread).
* Default: the last non-empty path segment. */
threadIdFromUrl?: (url: string) => string | null;
/** Async check; on false, redirect to the bare path (replaceUrl: true).
* Omit when the backend has no thread-lookup endpoint. */
validate?: (id: string) => Promise<boolean>;
/** Extras merged into every navigate call.
* Default: { queryParamsHandling: 'preserve' } */
navigationExtras?: NavigationExtras;
}
function injectThreadRouting(config: ThreadRoutingConfig): void;When to use URL-backed threads
Reach for injectThreadRouting() when both of these hold:
-
The user sees the URL and may share it. A standalone application at its own route is the typical case. Inside an iframe whose address is never surfaced, URL persistence adds plumbing and buys nothing; keep the active thread in a plain signal, as the example does.
-
The backend can restore the conversation from the id. A LangGraph server backed by a durable checkpointer satisfies this. An in-process
MemorySaverdoes not: the id survives the reload but the state behind it does not, so the surface comes back empty while the URL still looks correct.
The principle underneath is that persistence in the interface should match what the backend can actually restore. A stateless server should not offer bookmarkable conversation links.
The AG-UI protocol is event-stream only, and it defines no server-side thread-lookup endpoint, so there is nothing for validate to call. With @threadplane/ag-ui, pass no validate callback and handle thread switching in your own host service before the agent boots. See AG-UI architecture for details.