React AG-UI tool views preview
Display a weather lookup as an application component while the agent writes its answer. The server executes the weather tool; the browser observes its arguments and result and chooses the presentation.
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.
Render a result without executing it
ToolObservation accepts literal strings supplied by the application. It does not invoke a function or infer business success from a result. Associate the arguments and result by tool-call ID before passing them to a view, especially when the model calls the same tool more than once.
import { ToolObservation } from '@threadplane/react/chat';
export function WeatherResult({ location, result }: { location: string; result: string }) {
return <ToolObservation name="get_weather" argumentsText={location} resultText={result} />;
}The React conversation walkthrough connects this capability to requests, tools, UI state, and saved history.
Observe a server tool
Choose Run and ask for the weather in San Francisco. The agent calls weather_card on the server. React displays its location, temperature, conditions, humidity and wind. Open Tool details to inspect literal arguments and results. This page supplies no browser tool executor.
// 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, useState } from 'react';
import { useAgent } from '@threadplane/react';
import {
ChatInput,
TextTranscript,
ToolObservation,
} from '@threadplane/react/chat';
import {
createConnectedApplication,
type toolViewsConnection,
} from './connection';
import { WeatherCard } from './weather-card';
export function ToolViewsDemo({
connection,
onReady,
}: {
readonly connection: ReturnType<typeof toolViewsConnection>;
readonly onReady: () => void;
}) {
const [application] = useState(() => createConnectedApplication(connection));
const [draft, setDraft] = useState('');
const snapshot = useAgent(application);
useEffect(() => {
onReady();
}, [onReady]);
useEffect(
() => () => {
void application.dispose();
},
[application]
);
useEffect(() => {
setDraft('');
}, [snapshot.viewGeneration]);
const status = snapshot.busy
? snapshot.activity === 'replacing'
? 'Starting new conversation…'
: 'Getting a weather reading…'
: !snapshot.canSubmit
? 'Start a new conversation to continue.'
: snapshot.outcome === 'success'
? 'Response complete.'
: 'Ready.';
return (
<ExampleLayout className="streaming-demo">
<header>
<div>
<h1>AG-UI weather cards</h1>
</div>
<button
type="button"
disabled={snapshot.busy}
onClick={() => void application.newConversation()}
>
New conversation
</button>
</header>
<p>
Ask for a city’s weather. The agent runs a server tool and the page
displays its demo reading alongside the conversation.
</p>
<section aria-label="Runtime connection">
<h2>Runtime connection</h2>
<p>{connection.label}</p>
</section>
<section aria-label="Conversation" className="weather-conversation">
<ol>
{snapshot.views.map((message) => (
<li key={message.id} data-weather-message={message.id}>
{message.text && (
<TextTranscript
messages={[message.text]}
label="Message text"
/>
)}
{message.tools.map((tool) => (
<div key={tool.id} data-weather-tool={tool.id}>
<WeatherCard reading={tool.weather} />
<details>
<summary>Tool details</summary>
<ToolObservation
name={tool.name}
argumentsText={tool.argumentsText}
resultText={tool.resultText}
/>
</details>
</div>
))}
</li>
))}
</ol>
</section>
<ExampleFooter>
{snapshot.error && <p role="alert">{snapshot.error}</p>}
<ChatInput
label="Message"
placeholder="Ask for a city’s weather…"
value={draft}
onValueChange={setDraft}
disabled={!snapshot.canSubmit}
busy={snapshot.busy}
onStop={snapshot.busy ? () => void application.stop() : undefined}
onSubmit={(text) => {
if (!application.getSnapshot().canSubmit) return false;
void application.submit(text);
if (!application.getSnapshot().busy) return false;
return true;
}}
/>
<p role="status">{status}</p>
</ExampleFooter>
</ExampleLayout>
);
}import type { WeatherReading } from './tool-view-policy';
/** Displays observed server data; completion and commands belong to the session. */
export function WeatherCard({
reading,
}: {
readonly reading: WeatherReading | undefined;
}) {
if (!reading) return <p>Waiting for weather data…</p>;
return (
<section
className="weather-card"
aria-label={`Weather result for ${reading.location}`}
>
<h3>Weather for {reading.location}</h3>
<dl>
<dt>Temperature</dt>
<dd>{reading.temperatureF} °F</dd>
<dt>Conditions</dt>
<dd>{reading.conditions}</dd>
<dt>Humidity</dt>
<dd>{reading.humidity}%</dd>
<dt>Wind</dt>
<dd>{reading.windMph} mph</dd>
</dl>
</section>
);
}The native AG-UI session owns requests, messages, state and run identity. useAgent observes it; TextTranscript, ToolObservation and ChatInput display native text and tools with an app-authored weather card. Each card belongs to the assistant that requested it. Markup remains literal text.
Confirm the whole turn
Partial arguments and tool-call endings do not complete the request. A received reading can appear while the agent is still working. Another Send becomes available only after the current root run succeeds, its thread and run IDs match, and the full canonical history contains the submitted question, every associated result and the final assistant reply. Ordinary text, a batch of calls and multiple tool rounds use the same rule.
import type { Session, TextTranscriptRow } from '@threadplane/ag-ui';
export type NativeSnapshot = ReturnType<Session['getSnapshot']>;
type Transcript = NativeSnapshot['transcript'];
export interface OwnedTurn {
readonly threadId: string;
readonly runId: string;
readonly human: TextTranscriptRow;
/** Borrowed immutable native history; the app never constructs request history. */
readonly prefix: Transcript;
}
export interface WeatherReading {
readonly location: string;
readonly temperatureF: number;
readonly conditions: string;
readonly humidity: number;
readonly windMph: number;
}
export interface WeatherToolView {
readonly id: string;
readonly name: string;
readonly argumentsText: string;
readonly resultText?: string;
readonly weather?: WeatherReading;
}
export interface WeatherMessageView {
readonly id: string;
readonly text?: TextTranscriptRow;
readonly tools: readonly WeatherToolView[];
}
function record(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) &&
Object.values(Object.getOwnPropertyDescriptors(value)).every((descriptor) =>
Object.hasOwn(descriptor, 'value')
)
);
}
function parsed(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return undefined;
}
}
function locationArguments(text: string) {
const value = parsed(text);
return record(value) &&
Object.keys(value).length === 1 &&
typeof value.location === 'string' &&
value.location.trim()
? value.location
: undefined;
}
function weatherReading(
text: string,
location: string | undefined
): WeatherReading | undefined {
const value = parsed(text);
if (
!location ||
!record(value) ||
Object.keys(value).length !== 5 ||
value.location !== location ||
typeof value.conditions !== 'string' ||
!value.conditions.trim() ||
typeof value.temperatureF !== 'number' ||
!Number.isFinite(value.temperatureF) ||
typeof value.humidity !== 'number' ||
!Number.isFinite(value.humidity) ||
value.humidity < 0 ||
value.humidity > 100 ||
typeof value.windMph !== 'number' ||
!Number.isFinite(value.windMph) ||
value.windMph < 0
)
return;
return Object.freeze({
location,
temperatureF: value.temperatureF,
conditions: value.conditions,
humidity: value.humidity,
windMph: value.windMph,
});
}
/** Semantic comparison of already-owned native data, including optional metadata. */
function sameData(
left: unknown,
right: unknown,
valid: () => boolean,
depth = 0
): boolean {
if (!valid() || depth > 100) return false;
if (Object.is(left, right)) return true;
if (Array.isArray(left) && Array.isArray(right))
return (
left.length === right.length &&
left.every((value, index) =>
sameData(value, right[index], valid, depth + 1)
) &&
valid()
);
if (!record(left) || !record(right)) return false;
const keys = Object.keys(left);
return (
keys.length === Object.keys(right).length &&
keys.every(
(key) =>
Object.hasOwn(right, key) &&
sameData(left[key], right[key], valid, depth + 1)
) &&
valid()
);
}
function structuralTranscript(
transcript: Transcript,
valid: () => boolean
): boolean {
const ids = new Set<string>(),
calls = new Set<string>(),
results = new Set<string>();
for (const message of transcript) {
if (!valid()) return false;
const { id, role, content, subagentRunId } = message;
if (
typeof id !== 'string' ||
!id ||
ids.has(id) ||
typeof subagentRunId === 'string' ||
(role !== 'user' && role !== 'assistant' && role !== 'tool')
)
return false;
ids.add(id);
if (role === 'user' && (typeof content !== 'string' || !content))
return false;
if (role === 'assistant') {
if (
content !== undefined &&
content !== null &&
typeof content !== 'string'
)
return false;
for (const call of message.toolCalls ?? []) {
if (
typeof call.id !== 'string' ||
!call.id ||
calls.has(call.id) ||
call.type !== 'function' ||
call.function.name !== 'weather_card' ||
typeof call.function.arguments !== 'string'
)
return false;
calls.add(call.id);
}
}
if (role === 'tool') {
if (
typeof content !== 'string' ||
!calls.has(message.toolCallId) ||
results.has(message.toolCallId)
)
return false;
results.add(message.toolCallId);
}
}
return valid();
}
function read(snapshot: NativeSnapshot, valid: () => boolean) {
if (!valid()) return;
const { status, run, decision, subagents, transcript } = snapshot;
if (
!valid() ||
decision ||
subagents.length ||
run?.legacyInterrupt ||
!structuralTranscript(transcript, valid)
)
return;
if (
status === 'error' ||
(run?.outcome !== undefined && run.outcome !== 'success') ||
run?.terminal?.type === 'RUN_ERROR' ||
run?.terminal?.type === 'CUSTOM' ||
(run?.terminal?.type === 'RUN_FINISHED' && run.terminal.runId !== run.id)
)
return;
if (
run?.terminal?.type === 'RUN_FINISHED' &&
run.terminal.outcome?.type === 'success' &&
run.terminal.outcome.pendingToolCallIds?.length
)
return;
return valid() ? { status, run, transcript } : undefined;
}
/** Incomplete current arguments and results remain observable, never executable. */
export function unsupportedSnapshot(
snapshot: NativeSnapshot,
valid: () => boolean = () => true
): boolean {
return !read(snapshot, valid);
}
export function captureTurn(
snapshot: NativeSnapshot,
threadId: string,
previousRunId: string | undefined,
prefix: Transcript,
valid: () => boolean = () => true
): OwnedTurn | undefined {
const state = read(snapshot, valid);
if (
!state ||
state.status !== 'running' ||
!threadId ||
!state.run?.id ||
state.run.id === previousRunId ||
state.run.outcome !== undefined ||
state.transcript.length !== prefix.length + 1 ||
!prefix.every((row, index) =>
sameData(row, state.transcript[index], valid)
) ||
!valid()
)
return;
const message = state.transcript.at(-1)!;
if (
message.role !== 'user' ||
typeof message.content !== 'string' ||
!valid()
)
return;
return Object.freeze({
threadId,
runId: state.run.id,
prefix,
human: Object.freeze({
id: message.id,
role: 'user',
content: message.content,
}),
});
}
function settledTurn(messages: Transcript, valid: () => boolean): boolean {
if (messages.length < 2 || messages[0].role !== 'user') return false;
let index = 1;
while (index < messages.length) {
if (!valid()) return false;
const owner = messages[index++];
if (owner.role !== 'assistant') return false;
const calls = owner.toolCalls ?? [];
if (!calls.length)
return (
index === messages.length &&
typeof owner.content === 'string' &&
!!owner.content &&
valid()
);
const pending = new Map(
calls.map((call) => [call.id, locationArguments(call.function.arguments)])
);
if ([...pending.values()].some((location) => location === undefined))
return false;
while (index < messages.length && messages[index].role === 'tool') {
const result = messages[index++];
if (
result.role !== 'tool' ||
!pending.has(result.toolCallId) ||
typeof result.content !== 'string' ||
!weatherReading(result.content, pending.get(result.toolCallId))
)
return false;
pending.delete(result.toolCallId);
}
if (pending.size || !valid()) return false;
}
return false;
}
export function confirmTranscript(
snapshot: NativeSnapshot,
turn: OwnedTurn,
valid: () => boolean = () => true
): Transcript | undefined {
const state = read(snapshot, valid),
terminal = state?.run?.terminal;
if (
!state ||
state.status !== 'idle' ||
state.run?.id !== turn.runId ||
state.run.outcome !== 'success' ||
terminal?.type !== 'RUN_FINISHED' ||
terminal.threadId !== turn.threadId ||
terminal.runId !== turn.runId ||
(terminal.outcome !== undefined && terminal.outcome.type !== 'success') ||
!turn.prefix.every((row, index) =>
sameData(row, state.transcript[index], valid)
) ||
!sameData(state.transcript[turn.prefix.length], turn.human, valid) ||
!settledTurn(state.transcript.slice(turn.prefix.length), valid) ||
!valid()
)
return;
return state.transcript;
}
/** Pure presentation; selected scalars never become command or completion authority. */
export function projectWeatherViews(
transcript: Transcript,
valid: () => boolean = () => true
): readonly WeatherMessageView[] {
const views: WeatherMessageView[] = [];
for (const message of transcript) {
if (!valid()) return Object.freeze([]);
if (
(message.role !== 'user' && message.role !== 'assistant') ||
typeof message.subagentRunId === 'string'
)
continue;
const text =
typeof message.content === 'string' && message.content
? Object.freeze({
id: message.id,
role: message.role,
content: message.content,
})
: undefined;
const tools: WeatherToolView[] = [];
for (const call of message.role === 'assistant'
? message.toolCalls ?? []
: []) {
if (call.function.name !== 'weather_card') continue;
const results = transcript.filter(
(result) =>
result.role === 'tool' &&
result.toolCallId === call.id &&
typeof result.subagentRunId !== 'string'
);
const resultText =
results.length === 1 && typeof results[0].content === 'string'
? results[0].content
: undefined;
const weather =
resultText === undefined
? undefined
: weatherReading(
resultText,
locationArguments(call.function.arguments)
);
tools.push(
Object.freeze({
id: call.id,
name: call.function.name,
argumentsText: call.function.arguments,
...(resultText !== undefined && { resultText }),
...(weather && { weather }),
})
);
}
if (text || tools.length)
views.push(
Object.freeze({
id: message.id,
...(text && { text }),
tools: Object.freeze(tools),
})
);
}
return valid() ? Object.freeze(views) : Object.freeze([]);
}The first Send creates a local UUID lazily. Confirmed follow-ups retain full native history, including tool calls and results, on that UUID. New conversation clears the display and draft; its next Send creates another UUID. Stop and page exit revoke active work. Unknown tools, malformed readings, child agents, pauses, errors and uncertain outcomes require New. There is no automatic replay, reconnect or saved-history restoration; a local UUID does not claim server persistence.
import type { Session } from '@threadplane/ag-ui';
import type { CompleteOutcome } from '@threadplane/core';
import {
captureTurn,
projectWeatherViews,
unsupportedSnapshot,
type WeatherMessageView,
confirmTranscript,
type NativeSnapshot,
type OwnedTurn,
} from './tool-view-policy';
export interface ToolViewsSnapshot {
readonly threadId: string | null;
readonly native: NativeSnapshot | undefined;
readonly views: readonly WeatherMessageView[];
readonly busy: boolean;
readonly canSubmit: boolean;
readonly activity: 'idle' | 'running' | 'replacing';
readonly outcome: CompleteOutcome | null;
readonly error: string | null;
readonly viewGeneration: number;
}
/** Owns lifetime and admission; the native session owns all protocol execution. */
export function createToolViewsApplication(options: {
readonly sessionFactory: (threadId: string) => Session;
}) {
type Primary = {
session: Session;
threadId: string;
closed: boolean;
release?: () => void;
unsafe: boolean;
confirmed: NativeSnapshot['transcript'];
previousRunId?: string;
turn?: OwnedTurn;
};
const listeners = new Set<() => void>(),
cleanups = new Set<Promise<void>>();
let primary: Primary | undefined;
let operation: AbortController | undefined;
let disposed = false,
disposal: Promise<void> | undefined;
let snapshot: ToolViewsSnapshot = Object.freeze({
threadId: null,
native: undefined,
views: Object.freeze([]),
busy: false,
canSubmit: true,
activity: 'idle',
outcome: null,
error: null,
viewGeneration: 0,
});
function publish(update: Partial<ToolViewsSnapshot>) {
if (disposed) return;
snapshot = Object.freeze({ ...snapshot, ...update });
for (const notify of listeners) {
try {
notify();
} catch {
/* A view cannot interrupt native execution or cleanup. */
}
}
}
function current(selected: Primary) {
return (
!disposed &&
primary === selected &&
!selected.closed &&
!operation?.signal.aborted
);
}
function close(selected: Primary) {
if (selected.closed) return Promise.resolve();
selected.closed = true;
try {
selected.release?.();
} catch {
/* Revoked observation cannot prevent native cleanup. */
}
const cleanup = Promise.resolve()
.then(() => selected.session.dispose())
.then(
() => undefined,
() => undefined
);
cleanups.add(cleanup);
void cleanup.then(() => cleanups.delete(cleanup));
return cleanup;
}
function detach() {
const previous = primary;
primary = undefined;
return previous ? close(previous) : Promise.resolve();
}
function read(selected: Primary, valid: () => boolean) {
if (!valid()) return;
const root = selected.session.getSnapshot();
if (!valid()) return;
const blocked = unsupportedSnapshot(root, valid);
return valid() ? { root, blocked } : undefined;
}
function capture(selected: Primary, valid: () => boolean) {
const state = read(selected, valid);
if (!state || !valid()) return;
selected.unsafe ||= state.blocked;
if (!selected.turn && snapshot.activity === 'running') {
const turn = captureTurn(
state.root,
selected.threadId,
selected.previousRunId,
selected.confirmed,
valid
);
if (!valid()) return;
if (turn) selected.turn = turn;
}
const confirmed =
selected.turn && !selected.unsafe
? confirmTranscript(state.root, selected.turn, valid)
: undefined;
if (!valid()) return;
const views = projectWeatherViews(state.root.transcript, valid);
return valid() ? { native: state.root, views, confirmed } : undefined;
}
function observe(selected: Primary) {
const valid = () => current(selected);
try {
const result = capture(selected, valid);
if (!result || !valid()) return;
publish({
native: result.native,
views: result.views,
...(selected.unsafe ? { canSubmit: false } : {}),
});
} catch {
if (valid()) {
selected.unsafe = true;
publish({
canSubmit: false,
error:
'The weather result could not be confirmed. Start a new conversation.',
});
}
}
}
function install(valid: () => boolean) {
const threadId = crypto.randomUUID();
const session = options.sessionFactory(threadId);
const selected: Primary = {
session,
threadId,
closed: false,
unsafe: false,
confirmed: [],
};
if (!valid()) {
void close(selected);
return;
}
primary = selected;
const release = session.subscribe(() => observe(selected));
selected.release = release;
if (selected.closed) release();
if (!valid() || primary !== selected) {
void close(selected);
return;
}
return selected;
}
function admit(activity: ToolViewsSnapshot['activity'], execution: boolean) {
const admitted = new AbortController();
operation = admitted;
const valid = () =>
!disposed && operation === admitted && !admitted.signal.aborted;
publish({
busy: true,
canSubmit: false,
activity,
...(execution ? { outcome: null, error: null } : {}),
});
return { admitted, valid };
}
function finish(admitted: AbortController) {
if (!disposed && operation === admitted) {
operation = undefined;
publish({ busy: false, activity: 'idle' });
}
}
async function execute(text: string) {
if (disposed || snapshot.busy || !snapshot.canSubmit || !text.trim())
return;
const before = primary;
const initialValid = () =>
!disposed &&
!snapshot.busy &&
snapshot.canSubmit &&
primary === before &&
!before?.closed;
let state: ReturnType<typeof read>;
try {
state = before && read(before, initialValid);
} catch {
if (initialValid())
publish({
canSubmit: false,
error:
'The weather session is unavailable. Start a new conversation.',
});
return;
}
if (!initialValid()) return;
if (before && (before.unsafe || state?.blocked)) {
publish({ canSubmit: false });
return;
}
const { admitted, valid } = admit('running', true);
try {
if (!valid()) return;
const selected = primary ?? install(valid);
if (!selected || !valid()) return;
publish({ threadId: selected.threadId });
const ownedValid = () => valid() && current(selected);
const root = read(selected, ownedValid);
if (!root || !ownedValid()) return;
if (root.blocked || selected.unsafe) {
selected.unsafe = true;
publish({ canSubmit: false });
return;
}
selected.previousRunId = root.root.run?.id;
selected.turn = undefined;
if (!ownedValid()) return;
const pending = selected.session.submit(text, {
signal: admitted.signal,
});
void pending.catch(() => undefined);
if (!ownedValid()) return;
observe(selected);
const outcome = await pending;
if (!ownedValid()) return;
const result = capture(selected, ownedValid);
if (!result || !ownedValid()) return;
const safe =
outcome === 'success' && !!result.confirmed && !selected.unsafe;
if (safe) selected.confirmed = result.confirmed!;
publish({
native: result.native,
views: result.views,
outcome,
canSubmit: safe,
error: safe
? null
: 'The current weather outcome could not be confirmed. Start a new conversation.',
});
} catch {
if (valid())
publish({
canSubmit: false,
outcome: 'error',
error: 'The AG-UI request failed. Start a new conversation.',
});
} finally {
finish(admitted);
}
}
async function newConversation() {
if (disposed || snapshot.busy) return;
const { admitted, valid } = admit('replacing', false);
const cleanup = detach();
try {
if (!valid()) return;
publish({
threadId: null,
native: undefined,
views: Object.freeze([]),
outcome: null,
error: null,
viewGeneration: snapshot.viewGeneration + 1,
});
if (!valid()) return;
await cleanup;
if (valid()) publish({ canSubmit: true });
} finally {
finish(admitted);
}
}
async function stop() {
if (disposed || !operation) return;
operation.abort();
operation = undefined;
const selected = primary,
cleanup = detach();
publish({
busy: false,
activity: 'idle',
canSubmit: false,
outcome: 'aborted',
error: 'The operation stopped. Start a new conversation.',
});
try {
await selected?.session.stop();
} catch {
/* Detached work has no publication authority. */
}
await cleanup;
}
function dispose(): Promise<void> {
if (disposal) return disposal;
disposed = true;
operation?.abort();
operation = undefined;
void detach();
listeners.clear();
disposal = (async () => {
do {
await Promise.resolve();
await Promise.all([...cleanups]);
} while (cleanups.size);
})();
return disposal;
}
return {
getSnapshot: () => snapshot,
subscribe(notify: () => void) {
if (disposed) return () => undefined;
listeners.add(notify);
return () => {
listeners.delete(notify);
};
},
submit: execute,
newConversation,
stop,
dispose,
};
}Use the existing agent
Shared runtime uses the examples site's existing AG-UI Tool Views route. Runtime settings can supply an authored Developer runtime endpoint. Its address stays outside the visible preview.
import { createSession } from '@threadplane/ag-ui';
import { createToolViewsApplication } from './application';
// 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';
export function toolViewsConnection(
configuration: RuntimeBridgeConfiguration
): {
url: string;
label: 'Shared runtime' | 'Developer runtime';
} {
if (
configuration.status === 'error' ||
(configuration.status === 'configured' &&
configuration.target.kind === 'langsmith')
)
throw new Error('Runtime configuration failed.');
const target =
configuration.status === 'configured' &&
configuration.target.kind === 'ag-ui'
? configuration.target
: null;
return {
url:
target?.endpoint ??
new URL('/ag-ui/tool-views/agent', window.location.origin).href,
label: target ? 'Developer runtime' : 'Shared runtime',
};
}
export function createConnectedApplication(
connection: ReturnType<typeof toolViewsConnection>
) {
return createToolViewsApplication({
sessionFactory(threadId) {
const session = createSession({ threadId, url: connection.url });
try {
const snapshot = session.getSnapshot();
if (
!snapshot ||
!Array.isArray(snapshot.transcript) ||
!Array.isArray(snapshot.subagents) ||
![
'getSnapshot',
'subscribe',
'submit',
'resume',
'stop',
'dispose',
].every(
(method) =>
typeof session[method as keyof typeof session] === 'function'
)
)
throw new Error('Runtime session is unavailable.');
} catch {
if (typeof session?.dispose === 'function') {
try {
void Promise.resolve(session.dispose()).catch(() => undefined);
} catch {
/* Rejected candidates have no authority. */
}
}
throw new Error('Runtime session is unavailable.');
}
return session;
},
});
}The existing graph keeps its model, prompt and server tool loop. The weather tool returns the same five fields as plain JSON in a ToolMessage. Its runtime-injected call ID also identifies the result message, so incremental events and the final snapshot preserve one causal result. The injected ID is absent from model-facing arguments. Angular retains the same endpoint and reading.
"""
LangGraph Tool-Views Graph
Demonstrates tool-driven view rendering over AG-UI. The agent calls a
`weather_card` tool that returns plain JSON data — no UI spec. The tool call
and its result travel over the wire as AG-UI TOOL_CALL_* events; the Angular
frontend owns a component registered under the matching name and renders it
live from the call's args/result/status.
Flow: START -> agent <-> tools -> agent (loop) -> END
"""
from pathlib import Path
import json
from typing import Annotated
from langchain_core.messages import SystemMessage, ToolMessage
from langchain_core.tools import tool, InjectedToolCallId
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, MessagesState, END
from langgraph.prebuilt import ToolNode
from langgraph.checkpoint.memory import MemorySaver
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
# region weather-tool
@tool
async def weather_card(location: str, tool_call_id: Annotated[str, InjectedToolCallId]) -> ToolMessage:
"""Look up the current weather for a location.
Returns plain JSON data. The frontend renders a component registered
under the name `weather_card` from this call's args and result.
Args:
location: The city or place to look up weather for.
Returns:
A tool message containing location, temperatureF, conditions, humidity, windMph.
"""
# Deterministic demo data so e2e fixtures stay stable.
reading = {
"location": location,
"temperatureF": 68,
"conditions": "Sunny",
"humidity": 55,
"windMph": 8,
}
# Set the result ID before the bridge streams it; add_messages otherwise
# assigns a different ID later, after native clients have placed the result.
return ToolMessage(
id=tool_call_id,
tool_call_id=tool_call_id,
name="weather_card",
content=json.dumps(reading),
)
# endregion
_TOOLS = [weather_card]
# region graph
def build_tool_views_graph():
llm = ChatOpenAI(model="gpt-5-mini", streaming=True).bind_tools(_TOOLS)
async def agent(state: MessagesState) -> dict:
system_prompt = (PROMPTS_DIR / "tool-views.md").read_text()
messages = [SystemMessage(content=system_prompt)] + state["messages"]
response = await llm.ainvoke(messages)
return {"messages": [response]}
def route(state: MessagesState) -> str:
last = state["messages"][-1]
return "tools" if getattr(last, "tool_calls", None) else END
graph = StateGraph(MessagesState)
graph.add_node("agent", agent)
graph.add_node("tools", ToolNode(_TOOLS))
graph.set_entry_point("agent")
graph.add_conditional_edges("agent", route, {"tools": "tools", END: END})
graph.add_edge("tools", "agent")
return graph.compile(checkpointer=MemorySaver())
# endregion
# The graph instance — referenced by server.py
graph = build_tool_views_graph()from fastapi import FastAPI
from ag_ui_langgraph import LangGraphAgent, add_langgraph_fastapi_endpoint
from .graph import graph
agent = LangGraphAgent(name="tool-views", graph=graph)
app = FastAPI(title="cockpit-ag-ui-tool-views")
add_langgraph_fastapi_endpoint(app, agent, path="/agent")
@app.get("/ok")
def ok() -> dict:
return {"ok": True}Source build and verification
From a Threadplane checkout with dependencies installed:
npx nx build cockpit-ag-ui-tool-views-react
npx nx e2e cockpit-ag-ui-tool-views-reactThe isolated build installs locked candidate artifacts with strict types. Python checks exercise the actual graph and bridge with frozen model responses. Browser checks use the installed SDK over HTTP/SSE for tool rounds, retained history, partial results, cancellation and protected outcomes. Docs, Code and Run select this same implementation. AG-UI Streaming, Interrupts, JSON Render and Subagents and nine LangGraph topics also have React previews; other topics remain pending.