Page actions

Tool Views

A tool view renders a backend tool call through a component the frontend owns, keyed by the tool's name. The agent runs the tool and returns plain data, such as a weather reading, and the Angular application decides how that data looks. No UI specification crosses the wire. This guide walks the five files across six excerpts of the running example, from the tool to the card it fills.

What the demo does

The Run tab shows the prebuilt <chat> composition in front of a LangGraph agent served over AG-UI. Ask what the weather is in San Francisco and the model calls a tool named weather_card. In place of the default tool-call card, the transcript mounts an Angular component: the location and a loading badge while the arguments stream, then a temperature, the conditions, the humidity, and the wind speed. The agent adds a one-sentence confirmation afterwards, because the system prompt tells it to keep the prose short and let the card carry the detail.

How it is built

Five files carry the feature: a graph whose one tool returns plain JSON, the server that exposes it over AG-UI, an application config, the component that registers the view, and the card itself. Open the Code tab to read them in place.

The tool returns data, not markup

The tool is an ordinary LangGraph tool. It takes a location and returns a tool message containing plain JSON, and the values are fixed so the recorded end-to-end fixtures stay stable. The runtime injects the call identifier without adding it to the model's tool schema. Using that identifier as the result message ID keeps the streamed result and final snapshot aligned, preserving tool history before the agent's final answer.

graph.py — the 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),
    )

Every key in that JSON object is a name the card declares as an input, which is the only presentation contract between the two sides.

The graph runs the tool itself

The agent node calls a model bound to the tool, and route sends a turn that produced a tool call to a ToolNode and everything else to the end. The edge from tools back to agent closes the loop, so the tool executes on the server and its result reaches the browser inside the same run.

graph.py — the 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())

The graph compiles with MemorySaver() because ag-ui-langgraph reads the thread's graph state through a checkpointer, so a graph served this way needs one; outside development it should be durable.

Note: This is what separates a tool view from a client tool

Here the backend owns the implementation and the browser only draws the result. A client tool inverts that: the graph ends its turn on the tool call, and the browser produces the result and submits it back to continue the run.

Serving the graph over AG-UI

The server is a FastAPI application. add_langgraph_fastapi_endpoint mounts the compiled graph at /agent as an AG-UI event stream, and /ok is a plain health check.

server.py
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}

Providing the agent

provideAgent() from @threadplane/ag-ui registers the agent at the application root, and it is the only provider the <chat> composition requires. The example resolves its URL at runtime because the host that serves the demo decides which runtime is attached; your own application passes a url directly.

app.config.ts
import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';
import { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/ag-ui';
 
export const appConfig: ApplicationConfig = {
  providers: [
    provideAgent(() => {
      const connection = injectCockpitRuntimeConnection();
      if (connection.adapter !== 'ag-ui') {
        throw new Error('incompatible runtime');
      }
      return {
        url: connection.url,
      };
    }),
  ],
};

Registering the view under the tool name

views() builds a frozen map from name to component, and the key is the tool name the agent calls — one identifier, not two. Passing that map to <chat [views]> is the entire wiring, and the component itself is a plain standalone Angular component with no base class to extend.

tool-views.component.ts
import { Component } from '@angular/core';
import { ChatComponent, views } from '@threadplane/chat';
import { injectAgent } from '@threadplane/ag-ui';
import { ExampleChatLayoutComponent } from '@threadplane/example-layouts';
import { WeatherCardComponent } from './weather-card.component';
 
/**
 * Tool-views demo — renders a frontend component for a tool call by reusing
 * the chat composition's `views` registry. The agent calls a tool named
 * `weather_card`; the component registered under that key renders inline in
 * the transcript, live from the call's args/result/status. No UI spec is
 * sent from the backend.
 */
@Component({
  selector: 'app-tool-views',
  standalone: true,
  imports: [ChatComponent, ExampleChatLayoutComponent],
  template: `
    <example-chat-layout>
      <chat main [agent]="agent" [views]="views" class="flex-1 min-w-0" />
    </example-chat-layout>
  `,
})
export class ToolViewsComponent {
  protected readonly agent = injectAgent();
  protected readonly views = views({ weather_card: WeatherCardComponent });
}
Tip: One registry, two rendering paths

views is defined in @threadplane/render and re-exported from @threadplane/chat. The same input also supplies the registry <chat> uses for render specs the backend sends, so a component registered for a tool call is reusable for generative UI.

The card the tool call fills

The card declares one signal input per field it renders, plus a status of 'running' or 'complete'. Every input is optional, because the arguments arrive while the call is still streaming and the result fields arrive later. A pending computed decides between the loading badge and the reading.

weather-card.component.ts
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
 
/**
 * A frontend-owned view rendered for the `weather_card` tool call. Receives
 * the tool call's arguments while it streams (`location`), the merged result
 * on completion (`temperatureF`, `conditions`, `humidity`, `windMph`), and a
 * `status` of 'running' | 'complete'. Renders a loading affordance until the
 * result arrives.
 */
@Component({
  selector: 'app-weather-card',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="wc">
      <div class="wc__head">
        <span class="wc__loc">{{ location() ?? 'Weather' }}</span>
        @if (pending()) { <span class="wc__badge">Loading…</span> }
      </div>
      @if (!pending()) {
        <div class="wc__temp">{{ temperatureF() }}°F</div>
        <div class="wc__cond">{{ conditions() }}</div>
        <dl class="wc__meta">
          <div><dt>Humidity</dt><dd>{{ humidity() }}%</dd></div>
          <div><dt>Wind</dt><dd>{{ windMph() }} mph</dd></div>
        </dl>
      }
    </div>
  `,
  styles: [`
    .wc { max-width: 320px; padding: 16px; border: 1px solid var(--tplane-chat-separator); border-radius: var(--tplane-chat-radius-card); background: var(--tplane-chat-surface-alt); color: var(--tplane-chat-text); }
    .wc__head { display: flex; align-items: center; justify-content: space-between; }
    .wc__loc { color: var(--tplane-chat-text); font-weight: 600; }
    .wc__badge { padding: 2px 8px; border: 1px solid var(--tplane-chat-separator); border-radius: var(--tplane-chat-radius-button); background: color-mix(in srgb, var(--tplane-chat-primary) 12%, var(--tplane-chat-surface-alt)); color: var(--tplane-chat-primary); font-size: var(--tplane-chat-font-size-xs); }
    .wc__temp { margin-top: 8px; color: var(--tplane-chat-text); font-size: 32px; font-weight: 700; }
    .wc__cond { color: var(--tplane-chat-text-muted); }
    .wc__meta { display: flex; gap: 24px; margin: 12px 0 0; }
    .wc__meta dt { color: var(--tplane-chat-text-muted); font-size: var(--tplane-chat-font-size-xs); text-transform: uppercase; }
    .wc__meta dd { margin: 0; color: var(--tplane-chat-text); font-weight: 600; }
  `],
})
export class WeatherCardComponent {
  readonly location = input<string>();
  readonly temperatureF = input<number>();
  readonly conditions = input<string>();
  readonly humidity = input<number>();
  readonly windMph = input<number>();
  readonly status = input<'running' | 'complete'>();
 
  readonly pending = computed(() => this.status() !== 'complete' || this.temperatureF() === undefined);
}
Warning: Status turns complete before the result arrives

The adapter marks a call complete on TOOL_CALL_END, which is the end of the argument stream, and the result lands afterwards on TOOL_CALL_RESULT. That is why pending also checks that temperatureF() is defined. A card that trusted status alone would flash an empty reading between the two events.

How a tool call becomes a component

1
The adapter reduces the events into a tool call

TOOL_CALL_START appends a call with the tool's name, empty arguments, and a status of running. Each TOOL_CALL_ARGS delta is appended to a per-call buffer and the whole buffer is parsed, so a half-arrived JSON fragment leaves the last good arguments in place. TOOL_CALL_END marks the call complete and TOOL_CALL_RESULT parses the serialized content onto it.

2
The call is scoped to its assistant message

When TOOL_CALL_START carries a parentMessageId, the adapter links the call to that assistant message, creating the message when the turn produced no text at all. A server may also stream a tool call with no parent message; the adapter then creates an assistant message keyed by the tool call id, matching the message the protocol client records for the call. Either way the message lists the call in toolCallIds, so each bubble renders only its own calls.

3
A registered name is bridged into a one-element spec

<chat-tool-views> keeps the calls whose name is a key in the registry and wraps each one into a synthetic render spec holding a single element of that type. Its props are the live arguments merged with the parsed result, plus status and a clientTool lifecycle object.

4
The render pipeline mounts the component

The registry is converted with toRenderRegistry, and RenderElement filters the props down to the inputs the component actually declares, so the extra keys are harmless.

5
The default tool-call card steps aside

Every key in the registry is added to the tool names excluded from <chat-tool-calls>, so a call that has a view does not also render as a generic card.

Views alongside client tools

A view declared through the tools() registry is the mirror image of this page's pattern. It has no server implementation: the model fills its props, and the client-tools coordinator acknowledges the call on the component's behalf with { shown: true } so the run can continue. A tool view renders a call the server really executed, and the registry only decides how the returned data is drawn.

The two coexist. <chat> merges the coordinator's view and ask components into the views map you pass, so one transcript can hold both kinds of card without any extra wiring.

What's Next

Looking for something specific?