React memory preview
An assistant can use a preference from earlier in the conversation without asking for it again. Thread memory keeps that fact with the conversation; it is different from a local input value and from memory shared across threads.
The native React APIs on this page are experimental, private source-build packages. Run the repository example to use them; they are not an npm installation recipe.
Read the backend projection
The memory graph extracts facts into its state and streams those values alongside messages. React observes the native LangGraph snapshot, and the example validates the expected memory fields before presenting them. Start by inspecting values as data; keep the composer draft in local React state. A new conversation has a new thread identity and therefore a separate thread-scoped memory.
import type { LangGraphSession } from '@threadplane/langgraph';
import { useAgent } from '@threadplane/react';
export function MemoryState({ session }: { session: LangGraphSession }) {
const snapshot = useAgent(session);
return <pre>{JSON.stringify(snapshot.values, null, 2)}</pre>;
}The React conversation walkthrough connects this capability to requests, tools, UI state, and saved history.
Learn and recall in one conversation
Choose Run and introduce a fictional name or preference. The response streams first; a separate extraction pass then updates Learned facts. Wait for Response complete, then ask the assistant to recall those facts in the same conversation. Corrections are extracted on a later turn.
The panel displays confirmed string facts from the server. It replaces the previous dictionary rather than merging facts in the browser. Missing or malformed memory clears the panel, and keys and values render as literal text.
New conversation releases the previous session and starts with an empty transcript and facts panel. These facts are thread-scoped: this preview does not demonstrate the Store API, a user namespace, or memory shared across conversations.
Ownership and rendering
A stable application owner confirms conversation creation before sending a message. React borrows snapshots with useAgent and renders the native MessageList and ChatInput alongside a simple facts panel.
// eslint-disable-next-line @nx/enforce-module-boundaries -- The isolated consumer copies this internal example layout.
import { ExampleFooter, ExampleLayout } from '../../../../shared/react-example';
import { useEffect, useRef, useState } from 'react';
import { useAgent } from '@threadplane/react';
import { ChatInput, MessageList } from '@threadplane/react/chat';
import {
createConnectedApplication,
type memoryConnection,
} from './connection';
export function MemoryDemo({
connection,
onReady,
}: {
readonly connection: ReturnType<typeof memoryConnection>;
readonly onReady: () => void;
}) {
const [application, setApplication] = useState(() =>
createConnectedApplication(connection)
);
const [resetting, setResetting] = useState(false);
const [generation, setGeneration] = useState(0);
const live = useRef(true);
const snapshot = useAgent(application);
useEffect(() => {
onReady();
}, [onReady]);
useEffect(
() => () => {
void application.dispose();
},
[application]
);
useEffect(
() => () => {
live.current = false;
},
[]
);
async function reset() {
if (application.getSnapshot().busy || resetting) return;
setResetting(true);
await application.dispose();
if (!live.current) return;
setApplication(createConnectedApplication(connection));
setGeneration((value) => value + 1);
setResetting(false);
}
const status = snapshot.busy
? snapshot.creation === 'pending'
? 'Creating conversation…'
: 'Receiving response and extracting facts…'
: snapshot.outcome === 'success' && snapshot.canSubmit
? 'Response complete.'
: snapshot.outcome === 'aborted'
? 'Stopped. Start a new conversation to continue.'
: !snapshot.canSubmit
? 'Start a new conversation to continue.'
: 'Ready.';
return (
<ExampleLayout className="memory-demo">
<header>
<div>
<h1>LangGraph memory</h1>
</div>
<button
type="button"
disabled={snapshot.busy || resetting}
onClick={() => void reset()}
>
New conversation
</button>
</header>
<p>
Teach the assistant a fictional fact, then ask it to recall it in this
conversation. Each reply is followed by a memory extraction pass. A new
conversation starts empty.
</p>
<MessageList rows={snapshot.rows} label="Conversation" />
<section aria-label="Learned facts" className="learned-facts">
<h2>Learned facts</h2>
<p>Confirmed facts for this thread.</p>
{snapshot.facts.length ? (
<dl>
{snapshot.facts.map((fact) => (
<div key={fact.key}>
<dt>{fact.key}</dt>
<dd>{fact.value}</dd>
</div>
))}
</dl>
) : (
<p>No facts yet.</p>
)}
</section>
<ExampleFooter>
{snapshot.error && <p role="alert">{snapshot.error}</p>}
<ChatInput
key={generation}
label="Message"
placeholder="Tell me a fictional fact…"
disabled={resetting || !snapshot.canSubmit}
busy={snapshot.busy}
onStop={snapshot.busy ? () => void application.stop() : undefined}
onSubmit={(text) => {
if (!application.getSnapshot().canSubmit) return false;
void application.submit(text);
}}
/>
<p role="status">{status}</p>
</ExampleFooter>
</ExampleLayout>
);
}Stop cancels active work. Cancellation, failed or uncertain requests, and every unexpected pause require New conversation. The owner blocks duplicate commands synchronously and fences late results after disposal. It never guesses a resume command or silently retries thread creation.
import type {
AgentSession,
AgentSnapshot,
CompleteOutcome,
} from '@threadplane/core';
import {
createMessageContent,
type MessageRow,
} from '@threadplane/content/messages';
export interface MemorySession extends Omit<AgentSession, 'getSnapshot'> {
getSnapshot(): AgentSnapshot & {
readonly values?: unknown;
readonly interrupts: readonly { readonly value?: unknown }[];
};
}
export interface MemoryFact {
readonly key: string;
readonly value: string;
}
export interface MemorySnapshot {
readonly creation: 'idle' | 'pending' | 'confirmed' | 'unconfirmed';
readonly rows: readonly MessageRow[];
readonly facts: readonly MemoryFact[];
readonly busy: boolean;
readonly canSubmit: boolean;
readonly outcome: CompleteOutcome | null;
readonly error: string | null;
}
function plainRecord(value: unknown): value is Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
function projectFacts(
values: unknown,
previous: readonly MemoryFact[]
): readonly MemoryFact[] {
const field =
plainRecord(values) && Object.getOwnPropertyDescriptor(values, 'memory');
const memory =
field && Object.hasOwn(field, 'value') ? field.value : undefined;
const facts: MemoryFact[] = [];
if (plainRecord(memory)) {
for (const key of Object.keys(memory)) {
const entry = Object.getOwnPropertyDescriptor(memory, key);
if (
entry &&
Object.hasOwn(entry, 'value') &&
typeof entry.value === 'string'
) {
facts.push(Object.freeze({ key, value: entry.value }));
}
}
}
if (
facts.length === previous.length &&
facts.every(
(fact, index) =>
fact.key === previous[index].key && fact.value === previous[index].value
)
)
return previous;
return Object.freeze(facts);
}
/** Owns one confirmed thread; facts reflect authoritative backend state. */
export function createMemoryApplication(options: {
readonly createThread: (signal: AbortSignal) => Promise<string>;
readonly sessionFactory: (threadId: string) => MemorySession;
}) {
const content = createMessageContent();
const listeners = new Set<() => void>();
let snapshot: MemorySnapshot = Object.freeze({
creation: 'idle',
rows: Object.freeze([]),
facts: Object.freeze([]),
busy: false,
canSubmit: true,
outcome: null,
error: null,
});
let session: MemorySession | undefined;
let release: (() => void) | undefined;
let operation: AbortController | undefined;
let disposed = false;
let disposal: Promise<void> | undefined;
function publish(update: Partial<MemorySnapshot>) {
if (disposed) return;
snapshot = Object.freeze({ ...snapshot, ...update });
for (const notify of listeners) notify();
}
function projection() {
if (!session) return {};
const state = session.getSnapshot();
return {
rows: content.project(state),
facts: projectFacts(state.values, snapshot.facts),
};
}
function observe() {
if (disposed || !session) return;
publish({
...projection(),
...(session.getSnapshot().interrupts.length ? { canSubmit: false } : {}),
});
}
async function submit(text: string) {
if (disposed || snapshot.busy || !snapshot.canSubmit || !text.trim())
return;
// Recheck the borrowed backend snapshot even if a notification was missed.
if (session?.getSnapshot().interrupts.length) {
observe();
return;
}
const admitted = new AbortController();
operation = admitted;
publish({ busy: true, canSubmit: false, outcome: null, error: null });
const current = () =>
!disposed && operation === admitted && !admitted.signal.aborted;
try {
if (!current()) return;
if (!session) {
publish({ creation: 'pending' });
if (!current()) return;
const threadId = await options.createThread(admitted.signal);
if (!current()) return;
session = options.sessionFactory(threadId);
release = session.subscribe(observe);
publish({ creation: 'confirmed', ...projection() });
}
if (!current()) return;
if (session.getSnapshot().interrupts.length) {
publish({ ...projection(), outcome: 'paused', canSubmit: false });
return;
}
const outcome = await session.submit(text, { signal: admitted.signal });
if (!current()) return;
publish({
...projection(),
outcome,
canSubmit:
outcome === 'success' &&
session.getSnapshot().interrupts.length === 0,
error: outcome === 'error' ? 'The LangGraph request failed.' : null,
});
} catch {
if (!current()) return;
publish({
creation: session ? 'confirmed' : 'unconfirmed',
outcome: 'error',
canSubmit: false,
error: 'The LangGraph request failed.',
});
} finally {
if (!disposed && operation === admitted) {
operation = undefined;
publish({ busy: false });
}
}
}
async function stop() {
const admitted = operation;
if (disposed || !admitted) return;
admitted.abort();
operation = undefined;
publish({
busy: false,
canSubmit: false,
outcome: 'aborted',
creation: session ? 'confirmed' : 'unconfirmed',
});
try {
await session?.stop();
} catch {
/* Recovery requires a new conversation. */
}
}
function dispose(): Promise<void> {
if (disposal) return disposal;
disposed = true;
operation?.abort();
operation = undefined;
release?.();
content.dispose();
listeners.clear();
disposal = Promise.resolve()
.then(() => session?.dispose())
.then(() => undefined);
return disposal;
}
return {
getSnapshot: () => snapshot,
subscribe(notify: () => void) {
if (disposed) return () => undefined;
listeners.add(notify);
return () => {
listeners.delete(notify);
};
},
submit,
stop,
dispose,
};
}The existing shared backend is used by default. A developer LangGraph target is configured through runtime settings, with credentials passed as SDK headers. Keep credentials out of messages and source code.
import { Client } from '@langchain/langgraph-sdk';
import { createSession } from '@threadplane/langgraph';
// eslint-disable-next-line @nx/enforce-module-boundaries -- The isolated build copies this neutral internal bridge; it is not a published package.
import type { RuntimeBridgeConfiguration } from '../../../../../libs/cockpit-runtime-bridge/src/index';
import { createMemoryApplication } from './application';
export function memoryConnection(configuration: RuntimeBridgeConfiguration): {
apiUrl: string;
headers: Record<string, string>;
} {
if (
configuration.status === 'error' ||
(configuration.status === 'configured' &&
configuration.target.kind === 'ag-ui')
)
throw new Error('Runtime configuration failed.');
const target =
configuration.status === 'configured' &&
configuration.target.kind === 'langsmith'
? configuration.target
: null;
return {
apiUrl: target?.apiUrl ?? new URL('/api', window.location.origin).href,
headers: target ? { 'x-api-key': target.apiKey } : {},
};
}
export function createConnectedApplication(
connection: ReturnType<typeof memoryConnection>
) {
const client = new Client({
apiUrl: connection.apiUrl,
apiKey: null,
defaultHeaders: connection.headers,
callerOptions: { maxRetries: 0 },
});
return createMemoryApplication({
async createThread(signal) {
const threadId = crypto.randomUUID();
const created = await client.threads.create({
threadId,
ifExists: 'raise',
signal,
});
if (created.thread_id !== threadId)
throw new Error('Conversation creation was not confirmed.');
return threadId;
},
sessionFactory: (threadId) =>
createSession({
assistantId: 'memory',
threadId,
apiUrl: connection.apiUrl,
clientOptions: { maxRetries: 0, defaultHeaders: connection.headers },
}),
});
}Build from source
From a Threadplane checkout with dependencies installed:
npx nx build cockpit-langgraph-memory-react
npx nx e2e cockpit-langgraph-memory-reactThe isolated build installs local candidate packages and checks strict declarations and emitted browser modules. Browser tests use real SDK HTTP and SSE against a local fixture. Docs, Code, and Run select this same React implementation.