Page actions

LangGraphThreadsAdapter

LangGraphThreadsAdapter is a root-provided service that wraps client.threads.* from @langchain/langgraph-sdk and maps every SDK thread onto the Thread type that <chat-thread-list> and <chat-sidenav> consume. It is the store behind a thread sidebar: list, create, rename, archive, pin, move, reorder, and delete, without hand-rolling a local thread cache.

The service is declared @Injectable({ providedIn: 'root' }), so inject(LangGraphThreadsAdapter) works anywhere once its configuration token is provided.

Configure

LANGGRAPH_THREADS_CONFIG is the only required provider. It is separate from provideAgent() because the thread store and the agent can point at the same server while living at different injector levels.

// app.config.ts
import type { ApplicationConfig } from '@angular/core';
import { LANGGRAPH_THREADS_CONFIG } from '@threadplane/langgraph';
 
export const appConfig: ApplicationConfig = {
  providers: [
    {
      provide: LANGGRAPH_THREADS_CONFIG,
      useValue: {
        apiUrl: 'http://localhost:2024',
        titleFallback: 'New chat',
      },
    },
  ],
};
FieldTypeDetail
apiUrlstringBase URL for the LangGraph Platform API. Accepts absolute URLs and relative /api-style paths, the same shapes provideAgent() accepts.
titleFallbackstring (optional)Label used for a thread whose metadata.title has not been written yet — a thread created but never sent, for instance. Defaults to 'Untitled'.

The adapter reads the thread label from metadata.title, so the backend has to write it there. The threads example does that from a generate_title node after the user-visible turn; see Thread Routing for that node and its idempotency guard.

What the store exposes

Two read-only signals hold the last fetched snapshot. Nothing subscribes to the server, so both change only when a fetch resolves.

SignalContents
threads()Threads whose metadata.archived is not true, sorted with pinned rows first and pinned rows ordered by metadata.pinnedOrder.
archivedThreads()Threads whose metadata.archived === true, in server order.

Each SDK thread is mapped onto the framework Thread shape:

Thread fieldSource
idthread_id
titlemetadata.title, or titleFallback when it is missing or empty
status'archived' when metadata.archived === true, otherwise 'active'
pinnedmetadata.pinned === true
projectIdmetadata.projectId when it is a non-empty string, otherwise null
pinnedOrdermetadata.pinnedOrder when it is a number
updatedAtDate.parse(updated_at)

Methods

Every mutation writes through client.threads.update() and then calls refresh() itself, which is exactly what ThreadActionAdapter requires of a consumer: the thread list clears its optimistic row overrides once the adapter promise settles, so the input list has to already carry the new value.

MethodReturnsDetail
refresh()Promise<void>Fetches up to 50 threads and repopulates both signals. Failures are logged through console.error and never rethrown, so a failed refresh leaves the previous snapshot in place.
getThread(threadId)Promise<Thread | null>Fetches one thread. Resolves null for the two outcomes the server reports as missing — 404 for an unknown id and 422 for an id that is not a valid UUID — and rethrows genuine network errors. This is what injectThreadRouting({ validate }) calls.
create(metadata?)Promise<string | null>Creates a thread with the supplied metadata, refreshes, and resolves the new id. Resolves null when the request fails, so check the result before using it.
rename(threadId, newTitle)Promise<void>Writes metadata.title.
archive(threadId)Promise<void>Writes metadata.archived = true, which moves the row from threads() to archivedThreads() on the next refresh.
unarchive(threadId)Promise<void>Writes metadata.archived = false.
pin(threadId)Promise<void>Writes metadata.pinned = true.
unpin(threadId)Promise<void>Writes metadata.pinned = false.
moveToProject(threadId, projectId)Promise<void>Writes metadata.projectId. Pass null to remove the thread from every project.
reorderPinned(threadId, beforeId)Promise<void>Re-stamps metadata.pinnedOrder as 0, 1, 2, … across the whole pinned slice so the new ordering survives a reload. beforeId is the pinned thread the moved row lands in front of, or null to move it to the end.

Unlike refresh() and create(), the mutating methods reject on failure. <chat-thread-list> awaits each call and rolls its optimistic row change back when the promise rejects, so let the rejection propagate rather than swallowing it.

Pair with the thread list

<chat-thread-list> renders the rows; the application owns the active-thread signal. That signal is the same one handed to provideAgent({ threadId }), which is what makes selecting a row switch the conversation: the agent watches the signal, resets its derived state, and re-fetches that thread's server-side history.

import { Component, inject, signal } from '@angular/core';
import { ChatThreadListComponent, type ThreadActionAdapter } from '@threadplane/chat';
import {
  injectAgent,
  provideAgent,
  LangGraphThreadsAdapter,
  refreshOnRunEnd,
} from '@threadplane/langgraph';
 
/** Source of truth for the active thread. Module scope, because the
 *  `provideAgent()` config below is evaluated at provider-registration
 *  time and has to close over it. */
const activeThreadIdState = signal<string | null>(null);
 
@Component({
  selector: 'app-threads-panel',
  standalone: true,
  imports: [ChatThreadListComponent],
  providers: [
    provideAgent({
      apiUrl: 'http://localhost:2024',
      assistantId: 'agent',
      threadId: activeThreadIdState,
      onThreadId: (id: string) => activeThreadIdState.set(id),
    }),
  ],
  template: `
    <chat-thread-list
      [threads]="threads.threads()"
      [activeThreadId]="activeThreadId() ?? ''"
      [actions]="actions"
      [showNewThreadButton]="true"
      (threadSelected)="onThreadSelected($event)"
      (newThreadRequested)="onNewThread()"
    />
  `,
})
export class ThreadsPanel {
  protected readonly agent = injectAgent();
  protected readonly threads = inject(LangGraphThreadsAdapter);
  protected readonly activeThreadId = activeThreadIdState;
 
  protected readonly actions: ThreadActionAdapter = {
    rename: (id, title) => this.threads.rename(id, title),
    pin: (id) => this.threads.pin(id),
    unpin: (id) => this.threads.unpin(id),
    archive: async (id) => {
      await this.threads.archive(id);
      if (activeThreadIdState() === id) activeThreadIdState.set(null);
    },
    delete: async (id) => {
      await this.threads.delete(id);
      if (activeThreadIdState() === id) activeThreadIdState.set(null);
    },
  };
 
  constructor() {
    void this.threads.refresh();
    refreshOnRunEnd(this.agent, () => this.threads.refresh());
  }
 
  protected onThreadSelected(id: string): void {
    activeThreadIdState.set(id);
  }
 
  protected async onNewThread(): Promise<void> {
    const id = await this.threads.create();
    if (id !== null) activeThreadIdState.set(id);
  }
}

Three details in that template are easy to get wrong:

  • activeThreadId is input<string>(''), not a nullable input, so a null active thread has to become ''. Passing null is a template type error.
  • The selection output is threadSelected, and <chat> re-emits an output of the same name, so either surface can drive the switch.
  • The agent has no threadId member of its own. The id lives in the application's signal; reading it back off the agent is not possible.

An application that does not bind a threadId signal can call agent.switchThread(id) from the same handler instead. Binding the signal is the better default, because it also survives a provideAgent() factory that reads the id from the router.

Note: Which menu entries appear

<chat-thread-list> builds its row menu from the keys the adapter actually defines, and from [mode]. A list in the default active mode offers Rename, Pin or Unpin, Move up and Move down for a pinned row with reorderPinned, Move to project when [projects] is bound, Archive, and Delete. A list rendered with [mode]="'archived'" — the natural home for archivedThreads() — offers only Unarchive and Delete.

Keeping the list fresh

The store is a snapshot, not a subscription, so something has to re-fetch it. refreshOnRunEnd() fires on every transition out of the running status, which is precisely when a title-writing node has finished.

refreshOnRunEnd(this.agent, () => this.threads.refresh());

refreshOnTransition(watch, isActive, fn) is the generic form for any other signal that has an active state: it calls fn whenever isActive(watch()) goes from true to false.

Both helpers use an Angular effect() under the hood, so both must be called from an injection context — a constructor body or a field initializer. refresh() itself is a plain asynchronous call with no such requirement.

Shared SDK client

Provide LANGGRAPH_CLIENT to hand the adapter an SDK Client you already own, or a test double; when the token is absent the adapter builds its own client from apiUrl. Provide LANGGRAPH_CLIENT_OPTIONS once at the application root to tune the SDK retry budget and auth headers shared by FetchStreamTransport and this adapter.

Warning: Thread ids come from the server

Never generate a thread id in the browser. Use the id create() resolves, the one handed to onThreadId when the transport allocates a thread on first submit, or one the LangGraph Threads API returned earlier.

What's Next

LangGraphThreadsAdapterclass

SDK-backed thread store. Wraps `client.threads.*` and maps SDK threads to the framework's Thread type for direct use with `<chat-thread-list>` / `<chat-sidenav>`. Consumers wire the framework's `ThreadActionAdapter` to instance methods (rename/delete/archive/pin/...) so the right-click menu round-trips through the LangGraph SDK without per-app boilerplate.

Properties

ParameterTypeDescription
archivedThreadsSignal<Thread[]>Threads whose `metadata.archived === true`.
threadsSignal<Thread[]>Active (non-archived) threads, sorted with pinned first.

Methods

archive(threadId: string): Promise<void>
ParameterTypeDescription
threadIdstring
create(metadata: Record<string, unknown>): Promise<string | null>
ParameterTypeDescription
metadata?Record<string, unknown>
delete(threadId: string): Promise<void>
ParameterTypeDescription
threadIdstring
getThread(threadId: string): Promise<Thread | null>

Fetch a single thread by id. Returns `null` when the server returns 404 (thread doesn't exist) so callers can distinguish "missing" from "couldn't reach the server" — genuine network errors rethrow. Used by URL-based thread routing to validate a pasted/shared thread id before activating it.

ParameterTypeDescription
threadIdstring
moveToProject(threadId: string, projectId: string | null): Promise<void>
ParameterTypeDescription
threadIdstring
projectIdstring | null
pin(threadId: string): Promise<void>
ParameterTypeDescription
threadIdstring
refresh(): Promise<void>

Fetch the latest thread list from the server. Failures are logged via `console.error` (not swallowed silently — silent catches have masked prod issues in the past). Invocation and resolution are logged at `console.debug` so prod inspection can distinguish "never called" from "called but resolved empty" from "called and threw." This was prompted by a demo.threadplane.ai cold-load bug where the sidenav stayed empty with no visible signal. Tighten the log volume if it becomes noisy.

rename(threadId: string, newTitle: string): Promise<void>
ParameterTypeDescription
threadIdstring
newTitlestring
reorderPinned(threadId: string, beforeId: string | null): Promise<void>

Re-stamp `metadata.pinnedOrder = 0,1,2,...` for the pinned slice to reflect the new ordering.

ParameterTypeDescription
threadIdstring
beforeIdstring | null
unarchive(threadId: string): Promise<void>
ParameterTypeDescription
threadIdstring
unpin(threadId: string): Promise<void>
ParameterTypeDescription
threadIdstring

Examples

const svc = inject(LangGraphThreadsAdapter);
const actions: ThreadActionAdapter = {
  rename: (id, t) => svc.rename(id, t),
  delete: (id) => svc.delete(id),
};

Looking for something specific?