n4nAI

Rendering tool invocations in useChat's message parts

Learn to render tool invocations in useChat message parts with Vercel AI SDK, including streaming tool calls, results, and UI patterns for chat interfaces.

n4n Team4 min read928 words

Audio narration

Coming soon — every post will get a voice note here.

The Vercel AI SDK’s useChat hook returns message parts that include tool invocations alongside text, but the documentation leaves the rendering logic as an exercise. This guide walks through building a production-ready component that handles streaming tool calls, displays results inline, and manages the awkward intermediate states that occur during execution.

Step 1: Understand the message parts structure

When you enable tools in your AI SDK route, each message in useChat’s messages array contains a parts array instead of a simple content string. Each part has a type discriminator: "text", "tool-invocation", or "tool-result".

// Simplified type from ai/react
type MessagePart =
  | { type: "text"; text: string }
  | { type: "tool-invocation"; toolInvocation: ToolInvocation }
  | { type: "tool-result"; toolResult: ToolResult };

interface ToolInvocation {
  state: "call" | "result";
  toolCallId: string;
  toolName: string;
  args: any;
  result?: any;
}

interface ToolResult {
  toolCallId: string;
  toolName: string;
  args: any;
  result: any;
}

The key insight: a single assistant message can contain multiple tool invocations interleaved with text. The state field on tool-invocation parts tells you whether the call is still streaming ("call") or has returned ("result").

Verify: Add console.log(messages.map(m => m.parts)) to your component and trigger a tool call. You’ll see the part array grow in real time.

Step 2: Build a part renderer component

Create a component that switches on part.type and delegates to specialized renderers. This keeps the main chat component clean and makes each part testable in isolation.

// components/MessageParts.tsx
"use client";

import { MessagePart } from "ai/react";

interface MessagePartsProps {
  parts: MessagePart[];
}

export function MessageParts({ parts }: MessagePartsProps) {
  return (
    <div className="message-parts space-y-2">
      {parts.map((part, index) => (
        <MessagePartRenderer key={index} part={part} />
      ))}
    </div>
  );
}

function MessagePartRenderer({ part }: { part: MessagePart }) {
  switch (part.type) {
    case "text":
      return <TextPart text={part.text} />;
    case "tool-invocation":
      return <ToolInvocationPart invocation={part.toolInvocation} />;
    case "tool-result":
      return <ToolResultPart result={part.toolResult} />;
    default:
      return null;
  }
}

Verify: Replace your existing message rendering with <MessageParts parts={message.parts} /> and confirm text still renders correctly.

Step 3: Render streaming tool invocations

The "call" state means the model is still streaming arguments. You’ll receive partial JSON that may be invalid until complete. Handle this gracefully — don’t crash on parse errors.

// components/ToolInvocationPart.tsx
"use client";

import { ToolInvocation } from "ai/react";
import { format } from "date-fns";

interface ToolInvocationPartProps {
  invocation: ToolInvocation;
}

export function ToolInvocationPart({ invocation }: ToolInvocationPartProps) {
  const { state, toolCallId, toolName, args, result } = invocation;
  const isStreaming = state === "call";
  const displayArgs = isStreaming ? args : JSON.stringify(args, null, 2);

  return (
    <div
      className={`tool-invocation rounded-lg border p-3 font-mono text-sm ${
        isStreaming ? "border-amber-500 bg-amber-50" : "border-emerald-500 bg-emerald-50"
      }`}
      data-tool-call-id={toolCallId}
    >
      <div className="flex items-center gap-2 mb-1">
        <span className="text-xs font-semibold uppercase tracking-wide text-gray-500">
          Tool call
        </span>
        <code className="px-1.5 py-0.5 rounded bg-gray-200">{toolName}</code>
        {isStreaming && (
          <span className="ml-auto text-xs text-amber-600 animate-pulse">
            Streaming…
          </span>
        )}
        {!isStreaming && (
          <span className="ml-auto text-xs text-emerald-600">
            Completed
          </span>
        )}
      </div>

      <div className="tool-args">
        <details className="group">
          <summary className="cursor-pointer select-none text-gray-600">
            Arguments {isStreaming && "(partial)"}
          </summary>
          <pre className="mt-1 overflow-x-auto text-xs text-gray-800">
            {displayArgs}
          </pre>
        </details>
      </div>

      {result !== undefined && !isStreaming && (
        <ToolResultInline result={result} toolName={toolName} />
      )}
    </div>
  );
}

function ToolResultInline({ result, toolName }: { result: any; toolName: string }) {
  const isError = result?.error === true;
  return (
    <details className="mt-2 group" open>
      <summary className="cursor-pointer select-none text-sm font-medium text-gray-600">
        Result {isError ? "(error)" : ""}
      </summary>
      <pre className={`mt-1 overflow-x-auto text-xs ${
        isError ? "text-red-700 bg-red-50" : "text-gray-800"
      } rounded p-2`}>
        {JSON.stringify(result, null, 2)}
      </pre>
    </details>
  );
}

Verify: Trigger a tool that takes a few seconds (e.g., a web search). You should see the arguments populate character-by-character, then flip to “Completed” with the result nested inside.

Step 4: Handle tool results as separate parts

When the tool finishes, the SDK emits a "tool-result" part in addition to the "tool-invocation" part transitioning to "result" state. This duplication exists for compatibility with non-streaming consumers. Render the result once — prefer the tool-result part for final output since it’s the canonical completion signal.

// components/ToolResultPart.tsx
"use client";

import { ToolResult } from "ai/react";

interface ToolResultPartProps {
  result: ToolResult;
}

export function ToolResultPart({ result }: ToolResultPartProps) {
  const { toolCallId, toolName, args, result: toolResult } = result;
  const isError = toolResult?.error === true;

  return (
    <div
      className={`tool-result rounded-lg border p-3 font-mono text-sm ${
        isError ? "border-red-500 bg-red-50" : "border-blue-500 bg-blue-50"
      }`}
      data-tool-call-id={toolCallId}
    >
      <div className="flex items-center gap-2 mb-1">
        <span className="text-xs font-semibold uppercase tracking-wide text-gray-500">
          Tool result
        </span>
        <code className="px-1.5 py-0.5 rounded bg-gray-200">{toolName}</code>
        {isError && (
          <span className="ml-auto text-xs text-red-600 font-medium">Error</span>
        )}
      </div>

      <details className="group" open>
        <summary className="cursor-pointer select-none text-gray-600">
          Output
        </summary>
        <pre className={`mt-1 overflow-x-auto text-xs ${
          isError ? "text-red-700" : "text-gray-800"
        }`}>
          {JSON.stringify(toolResult, null, 2)}
        </pre>
      </details>

      <details className="mt-2 group">
        <summary className="cursor-pointer select-none text-gray-500 text-xs">
          Invocation arguments
        </summary>
        <pre className="mt-1 overflow-x-auto text-xs text-gray-600">
          {JSON.stringify(args, null, 2)}
        </pre>
      </details>
    </div>
  );
}

Verify: Check that you see exactly one result display per tool call. If you see duplicates, your MessagePartRenderer is rendering both the tool-invocation (with state: "result") and the tool-result part. Suppress the inline result in ToolInvocationPart when a corresponding tool-result exists — see Step 6.

Step 5: Render text parts with markdown support

Text parts arrive incrementally during streaming. Use a streaming-safe markdown renderer that doesn’t re-parse the entire string on every chunk.

// components/TextPart.tsx
"use client";

import { Markdown } from "react-markdown";
import remarkGfm from "remark-gfm";

interface TextPartProps {
  text: string;
}

export function TextPart({ text }: TextPartProps) {
  if (!text) return null;

  return (
    <div className="text-part prose prose-sm max-w-none text-gray-800">
      <Markdown remarkPlugins={[remarkGfm]}>
        {text}
      </Markdown>
    </div>
  );
}

For production, consider streamdown or a custom streaming markdown parser that avoids layout shift during incremental updates. The AI SDK’s streamText already handles this server-side; the client just receives complete text chunks.

Verify: Stream a response with code blocks, tables, and lists. No flickering or broken rendering mid-stream.

Step 6: Deduplicate tool results

As noted in Step 4, you’ll get both a tool-invocation with state: "result" and a separate tool-result part. Track which tool calls have already rendered their final result to avoid showing the same output twice.

// components/MessageParts.tsx (updated)
"use client";

import { MessagePart, ToolInvocation, ToolResult } from "ai/react";
import { useMemo } from "react";
import { MessagePartRenderer } from "./MessagePartRenderer";

interface MessagePartsProps {
  parts: MessagePart[];
}

export function MessageParts({ parts }: MessagePartsProps) {
  // Collect toolCallIds that have a tool-result part
  const completedToolCalls = useMemo(() => {
    const ids = new Set<string>();
    for (const part of parts) {
      if (part.type === "tool-result") {
        ids.add(part.toolResult.toolCallId);
      }
    }
    return ids;
  }, [parts]);

  return (
    <div className="message-parts space-y-2">
      {parts.map((part, index) => (
        <MessagePartRenderer
          key={index}
          part={part}
          isToolCallCompleted={completedToolCalls.has(
            part.type === "tool-invocation" ? part.toolInvocation.toolCallId : ""
          )}
        />
      ))}
    </div>
  );
}

Then update ToolInvocationPart to skip the inline result when isToolCallCompleted is true:

// components/ToolInvocationPart.tsx (updated)
interface ToolInvocationPartProps {
  invocation: ToolInvocation;
  isToolCallCompleted?: boolean;
}

// Inside the component:
{result !== undefined && !isStreaming && !isToolCallCompleted && (
  <ToolResultInline result={result} toolName={toolName} />
)}

Verify: Trigger a tool call and confirm the result appears exactly once — in the tool-result part, not duplicated in the invocation card.

Step 7: Add loading and error states for tool execution

Tools can fail, timeout, or return partial results. Surface these states clearly so users understand what happened.

// components/ToolInvocationPart.tsx (additions)
function ToolInvocationPart({ invocation, isToolCallCompleted }: ToolInvocationPartProps) {
  const { state, toolCallId, toolName, args, result } = invocation;
  const isStreaming = state === "call";
  const isError = result?.error === true;

  // Determine execution status
  let executionStatus: "pending" | "running" | "success" | "error" = "pending";
  if (isStreaming) executionStatus = "running";
  else if (isToolCallCompleted || state === "result") {
    executionStatus = isError ? "error" : "success";
  }

  const statusConfig = {
    pending: { label: "Queued", color: "text-gray-500", bg: "bg-gray-100" },
    running: { label: "Running…", color: "text-amber-600", bg: "bg-amber-100" },
    success: { label: "Success", color: "text-emerald-600", bg: "bg-emerald-100" },
    error: { label: "Failed", color: "text-red-600", bg: "bg-red-100" },
  }[executionStatus];

  return (
    <div className={`tool-invocation rounded-lg border p-3 font-mono text-sm ${statusConfig.bg} border-gray-200`}>
      <div className="flex items-center gap-2 mb-1">
        <span className="text-xs font-semibold uppercase tracking-wide text-gray-500">
          Tool call
        </span>
        <code className="px-1.5 py-0.5 rounded bg-gray-200">{toolName}</code>
        <span className={`ml-auto text-xs font-medium ${statusConfig.color}`}>
          {statusConfig.label}
        </span>
      </div>
      {/* ... rest of component */}
    </div>
  );
}

For the tool-result part, render errors with a retry affordance if your backend supports re-execution:

// components/ToolResultPart.tsx (additions)
{isError && (
  <div className="mt-2 flex gap-2">
    <button
      className="text-xs px-2 py-1 rounded border border-red-300 text-red-700 hover:bg-red-50"
      onClick={() => retryToolCall(toolCallId)}
    >
      Retry
    </button>
    <button
      className="text-xs px-2 py-1 rounded border border-gray-300 text-gray-700 hover:bg-gray-50"
      onClick={() => dismissToolError(toolCallId)}
    >
      Dismiss
    </button>
  </div>
)}

Verify: Force a tool error (throw in your tool implementation) and confirm the error renders with retry/dismiss options.

Step 8: Wire it into useChat

Your main chat component now composes cleanly. The messages array from useChat contains the parts structure automatically when tools are enabled on the server.

// app/chat/page.tsx
"use client";

import { useChat } from "ai/react";
import { MessageParts } from "@/components/MessageParts";

export default function Chat() {
  const { messages, input, handleInputChange, handleSubmit, isLoading, error } = useChat({
    api: "/api/chat",
  });

  return (
    <div className="flex flex-col h-screen p-4 gap-4">
      <div className="flex-1 overflow-y-auto space-y-6">
        {messages.map((message) => (
          <div
            key={message.id}
            className={`message flex gap-3 ${message.role === "assistant" ? "flex-row-reverse" : ""}`}
          >
            <div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
              message.role === "assistant" ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-700"
            }`}>
              {message.role === "assistant" ? "🤖" : "👤"}
            </div>
            <div className="flex-1 min-w-0">
              <MessageParts parts={message.parts} />
            </div>
          </div>
        ))}
        {isLoading && <div className="text-center text-gray-500">Thinking…</div>}
      </div>

      <form onSubmit={handleSubmit} className="flex gap-2">
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="Type a message…"
          className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
          disabled={isLoading}
        />
        <button
          type="submit"
          disabled={isLoading || !input.trim()}
          className="rounded-lg bg-blue-600 px-4 py-2 text-white font-medium hover:bg-blue-700 disabled:opacity-50"
        >
          Send
        </button>
      </form>

      {error && <div className="text-red-600 text-sm">Error: {error.message}</div>}
    </div>
  );
}

Verify: Send a message that triggers multiple tool calls in sequence. Each invocation should appear in order, stream arguments, show completion, then display results.

Step 9: Handle client-side tool routing (optional)

If you use a gateway like n4n.ai that supports provider fallback and routing directives, tool calls may execute on different providers mid-conversation. The message parts structure remains identical — the gateway normalizes tool calling across providers — but you may want to surface which provider executed a given tool.

// Add to ToolInvocationPart if your gateway returns provider metadata
{invocation.provider && (
  <span className="ml-2 text-xs text-gray-400 font-normal">
    via {invocation.provider}
  </span>
)}

This requires your server to forward provider metadata in the tool invocation part. The AI SDK doesn’t standardize this field, so check your gateway’s documentation.

Verify: Route a conversation through multiple providers and confirm the provider badge appears on each tool call.

Step 10: Test edge cases

Run through these scenarios before shipping:

Scenario Expected behavior
Tool streams invalid JSON mid-call Arguments display shows raw partial text, no crash
Tool returns massive result (100KB+) Result collapses in <details>, doesn’t freeze UI
Multiple parallel tool calls Each gets its own invocation card, results match by toolCallId
User navigates away during tool execution No memory leaks, no errors on unmount
Tool call ID collision (buggy server) Graceful degradation — log warning, render what you have
Empty tool result Shows “null” or empty object, not blank

Write unit tests for MessagePartRenderer with fixture data covering each part type and state combination. Integration test the full flow with a mock server that streams tool calls.

// __tests__/MessageParts.test.tsx
import { render, screen } from "@testing-library/react";
import { MessageParts } from "@/components/MessageParts";

const mockParts = [
  { type: "text", text: "I'll check the weather." },
  {
    type: "tool-invocation",
    toolInvocation: {
      state: "result",
      toolCallId: "call_123",
      toolName: "get_weather",
      args: { location: "San Francisco" },
      result: { temp: 62, conditions: "foggy" },
    },
  },
  {
    type: "tool-result",
    toolResult: {
      toolCallId: "call_123",
      toolName: "get_weather",
      args: { location: "San Francisco" },
      result: { temp: 62, conditions: "foggy" },
    },
  },
];

test("renders text, invocation, and result without duplication", () => {
  render(<MessageParts parts={mockParts} />);
  expect(screen.getByText("I'll check the weather.")).toBeInTheDocument();
  expect(screen.getByText("get_weather")).toBeInTheDocument();
  // Result should appear once (in tool-result part)
  expect(screen.getAllByText("foggy")).toHaveLength(1);
});

Verification checklist

  • Text streams smoothly without layout shift
  • Tool arguments appear incrementally during streaming
  • Each tool call shows exactly one result (no duplicates)
  • Errors render with retry/dismiss actions
  • Large results are collapsed by default
  • Parallel tool calls render independently
  • Component unmounts cleanly during streaming
  • Unit tests cover all part types and states

The pattern scales: add new part types (e.g., "file", "image", "reasoning") by extending the switch in MessagePartRenderer without touching the chat layout. This is the architecture the AI SDK expects you to build — the hook gives you structured data, you supply the presentation logic.

Tagsusechattool-callingmessage-partschat-ui

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All vercel ai sdk streaming chat ui (usechat) posts →