Page actions

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.

graph.py — the title write
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.py — the graph
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)
Warning: Serving through langgraph dev or LangGraph Platform? Do not compile a checkpointer.

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.

app.config.ts
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.

threads.component.ts — the active-thread signal
// 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.

threads.component.ts — the agent provider
// 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.

Warning: Thread ids come from the server

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.

threads.component.ts — the template
<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.

threads.component.ts — the action adapter
/** 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.

Note: Which menu entries appear

<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.

threads.component.ts — initial fetch and refresh
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.

threads.component.ts — switching and creating
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.

BehaviorDetail
Restore on loadReads the thread id from the URL on startup and seeds the signal
Signal to URLWhen the signal changes, navigates to the matching URL
URL to signalOn every NavigationEnd, keeps the signal in sync with the current URL
Validate stale linksWhen validate resolves false, redirects to the bare path with replaceUrl: true
Bare URL is the welcome stateWhen 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:

  1. 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.

  2. The backend can restore the conversation from the id. A LangGraph server backed by a durable checkpointer satisfies this. An in-process MemorySaver does 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.

Note: AG-UI backends

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.

What's Next

Looking for something specific?