Sitelet https://roxyapi.com/docs/tutorials/ai-chat-widgets
Skip to content
  1. Docs
  2. Build With RoxyAPI
  3. AI Chat Widgets

AI chat widgets that render tool results as charts

Your chatbot already gets structured JSON back from every RoxyAPI tool call. Hand that JSON to the matching component and the answer arrives as a real natal chart wheel, kundli, or tarot spread instead of a wall of text. Time to ship: about 15 minutes.

An LLM should never draw a chart. It should call a tool and hand the result to something that already knows how to draw it. That is generative UI, also called tool UI: the model picks the tool, your interface picks the component. Every component in @roxyapi/ui is a stateless data consumer, and a tool result is the same JSON the SDK returns, so the one piece your app supplies is the map from the tool NAME the model used to the component that renders it. componentForTool() is that map.

What you get

  • A chat reply that renders the wheel, the spread, or the bodygraph inline, next to the prose the model wrote.
  • One component in your app, roughly 20 lines, that covers every tool your agent can call. No per-tool wiring, no list to maintain as you add domains.
  • The same four lines whether the model is Claude, GPT, or Gemini, and whether you run the tool call yourself or let the vendor call our Remote MCP server for you.

Prerequisites

  1. A RoxyAPI key. Get your API key.
  2. A chat app that already calls RoxyAPI tools. If you do not have one yet, the AI chatbot tutorial builds it, and the open source chatbot template is the same thing ready to clone.
  3. The component package for your stack: npm install @roxyapi/ui-react, npm install @roxyapi/ui-vue, or the CDN bundle for plain HTML.
  4. React 19 if you are on React. It assigns an object prop to a custom element as a property, which is what lets a whole chart response through as data.

The recipe

Four lines, and they are the same on every surface below:

  1. Find the tool name and the result text in whatever your framework or model API hands you.
  2. JSON.parse the text.
  3. componentForTool(name) for the component.
  4. Set data.
import { componentForTool } from '@roxyapi/ui';

const binding = componentForTool('post_astrology_natal_chart');
// { tag: 'roxy-natal-chart', pascal: 'RoxyNatalChart', operationId: 'generateNatalChart', toolName: 'post_astrology_natal_chart' }

tag is the custom element for plain DOM, pascal is the wrapper name in the React and Vue packages, and attrs is present when one component covers several tools and needs an attribute to tell them apart. A tool nothing renders returns undefined, so a bot that calls a reference lookup simply keeps answering in prose instead of breaking.

Leave the compact response shape on. Every component decodes it, so you keep the lower inference cost per turn and still pass the result straight through. If you want the plain object for your own code, expandCompact is exported from all three packages.

Vercel AI SDK

Your process holds the connection, so you see the tool result directly. Connect the servers your agent needs:

import { createMCPClient } from '@ai-sdk/mcp';

const tarot = await createMCPClient({
  transport: {
    type: 'http',
    url: 'https://roxyapi.com/mcp/tarot',
    headers: { 'X-API-Key': process.env.ROXY_API_KEY! },
  },
});

const tools = await tarot.tools();

Those tools arrive in useChat as dynamic-tool parts, each carrying toolName, state, and output. One component renders every one of them:

'use client';

import * as RoxyUI from '@roxyapi/ui-react';
import type { UIMessage } from 'ai';
import type { ElementType } from 'react';

export function ToolWidget({ message }: { message: UIMessage }) {
  return message.parts.map((part, i) => {
    if (part.type !== 'dynamic-tool' || part.state !== 'output-available') return null;

    const binding = RoxyUI.componentForTool(part.toolName);
    if (!binding) return null;

    const Widget = RoxyUI[binding.pascal as keyof typeof RoxyUI] as ElementType;
    const data = JSON.parse(part.output.content[0].text);

    return <Widget key={i} data={data} {...binding.attrs} />;
  });
}

Render it under the text parts of the assistant message and you are done. One line in your system prompt pays for itself here: tell the model that the app draws every chart, spread and table it receives beside the reply, so it refers to the drawing and interprets it instead of reprinting the positions the reader can already see. The open source chatbot template ships exactly this pattern: src/lib/tool-widgets.ts maps the message parts and src/components/chat/ToolWidget.tsx renders them above the reply, so cloning it gives you the widgets on day one, and both files are the reference for your own app.

Using AI Elements? Its ToolOutput slot takes any node, so pass the same <Widget> as the output and keep the collapsible tool header you already have.

Which server does what

The per-domain servers above are for the deployed agent making real calls at run time. The coding agent helping you write this connects the keyless docs server at https://roxyapi.com/mcp/docs instead, which returns the reference rather than live calculations.

Vendor-hosted connectors

When the model vendor calls our Remote MCP server for you, the tool name and the result come back inside their own response objects. The recipe does not change, only where you read the two values from.

Messages API, MCP connector beta. Send mcp_servers plus a matching mcp_toolset entry in tools, then read the name off the mcp_tool_use block and the result off the mcp_tool_result block, whose first content block holds the JSON string.

const use = message.content.find((b) => b.type === 'mcp_tool_use');
const result = message.content.find((b) => b.type === 'mcp_tool_result');

const binding = componentForTool(use.name);          // post_tarot_spreads_three_card
const data = JSON.parse(result.content[0].text);

Reference: MCP connector.

Other chat frameworks

Any chat UI that lets you return your own markup for a tool result can host these components, because the render slot is ordinary JSX and the component is an ordinary element.

  • assistant-ui: register the tool in defineToolkit() and mount it with Tools({ toolkit }). The render function receives the result, so it is the four lines above returning <Widget data={...} />.
  • CopilotKit: useRenderTool for a named tool, or useDefaultRenderTool as the catch-all, which is the one you want when the tools come from an MCP server and you do not want to name each.
  • AI Elements: covered above, the output slot takes any node.
  • Plain React, Svelte, Angular, Solid, or a hand-rolled UI: nothing to install beyond the component package. You already control the markup.

One exception worth knowing before you plan around it: OpenAI ChatKit renders widgets from a closed declarative schema of cards, lists, and a fixed set of nodes, with no slot for a custom component, so it can show RoxyAPI fields in its own cards but cannot host these charts.

Vanilla and Vue

Load the bundle once, then create the element the binding names. No build step, no framework.

<script src="https://cdn.jsdelivr.net/npm/@roxyapi/ui@latest/dist/cdn/roxy-ui.js" defer></script>
<div id="widgets"></div>

<script>
  function renderToolResult(toolName, resultText) {
    const binding = RoxyUI.componentForTool(toolName);
    if (!binding) return;

    const el = document.createElement(binding.tag);
    for (const [name, value] of Object.entries(binding.attrs ?? {})) el.setAttribute(name, value);
    el.data = JSON.parse(resultText);
    document.getElementById('widgets').append(el);
  }
</script>

Call renderToolResult from wherever your chat client receives a completed tool call.

Gotchas

Nothing renders and there is no error

componentForTool returned undefined, which is the correct answer for a tool no component covers. Log the name you passed and compare it with the component catalog. Reference and lookup tools deliberately have no chart.

The chart is empty but the tool call succeeded

You passed the envelope instead of the result. A tool result is one text block holding a JSON string, so the value you want is content[0].text parsed, not the object that wraps it. In the Vercel AI SDK that is part.output.content[0].text, not part.output.

The tool name has a prefix in front of it

Gemini prefixes the tool name with the name you gave the server, as in roxy_tarot:post_tarot_daily. componentForTool strips it, so pass the name through untouched rather than splitting it yourself.

Do I have to turn the compact shape off

No, and you should not. Every component decodes it, so keep it on and keep the lower inference cost. expandCompact gives you the plain object if your own code needs to read the fields.

'use client' errors in the Next.js App Router

The wrappers use the DOM. Add 'use client' to any file that imports from @roxyapi/ui-react, and keep the fetch or the model call in the server half.

The key ends up in the browser

Keep the secret key on the server. The model call and the MCP connection both belong in a server route or server action; only the parsed result crosses to the client. For a browser-side call, use a publishable key with your origins allowlisted, as described on Copy-paste widgets.

What to build next