Thinking about how to make Tool UIs feel more like collaboration than chrome.
Thinking about how to make Tool UIs feel more like collaboration than chrome.
This is a collection of three separate, platform-specific components:
Each component is independent and can be imported separately, allowing for tree-shaking when you only need one platform.
Each component mirrors its platform's visual language
Add custom actions below any post
Three independent components (XPost, InstagramPost, LinkedInPost) with shared patterns
Profile and post links open directly to original content on each platform
Copy the component(s) you need from components/tool-ui into your project. The shared folder is required for all social post components. The tool-ui directory should sit alongside your shadcn ui directory.
These components require the following shadcn/ui components:
pnpm dlx shadcn@latest add button tooltipimport { XPost } from "@/components/tool-ui/x-post";import { InstagramPost } from "@/components/tool-ui/instagram-post";import { LinkedInPost } from "@/components/tool-ui/linkedin-post";// X Post<XPost post={{ id: "tweet-123", author: { name: "Jane", handle: "jane", avatarUrl: "...", verified: true }, text: "Hello world!", stats: { likes: 42 }, createdAt: "2025-01-01T12:00:00Z", }} onAction={(action, post) => console.log(action, post.id)} responseActions={[{ id: "report", label: "Report", variant: "destructive" }]} onResponseAction={(id) => console.log(id)}/>// Instagram Post<InstagramPost post={{ id: "ig-123", author: { name: "Jane", handle: "jane", avatarUrl: "..." }, text: "Beach day!", media: [{ type: "image", url: "...", alt: "Beach photo" }], stats: { likes: 1234 }, }}/>// LinkedIn Post<LinkedInPost post={{ id: "li-123", author: { name: "Jane Smith", avatarUrl: "...", headline: "CEO at Acme" }, text: "Excited to announce...", linkPreview: { url: "...", title: "News", domain: "acme.com" }, stats: { likes: 89 }, }}/>Define a tool that returns a post payload, then render the component with runtime validation.
// Backend toolimport { tool, jsonSchema } from "ai";const showXPost = tool({ description: "Show an X post", inputSchema: jsonSchema<{}>({ type: "object", properties: {}, additionalProperties: false, }), async execute() { return { id: "post-x-1", author: { name: "Tool UI", handle: "assistant_ui", avatarUrl: "https://api.dicebear.com/9.x/shapes/svg?seed=tool-ui", verified: true, }, text: "DX test: rendering an XPost tool UI inline in chat.", stats: { likes: 12 }, createdAt: new Date().toISOString(), }; },});// Frontend with assistant-uiimport { makeAssistantToolUI } from "@assistant-ui/react";import { XPost, XPostErrorBoundary, parseSerializableXPost,} from "@/components/tool-ui/x-post";function ParsedXPost({ result }: { result: unknown }) { const post = parseSerializableXPost(result); return <XPost post={post} />;}export const ShowXPostUI = makeAssistantToolUI({ toolName: "showXPost", render: ({ result }) => { // Tool outputs stream in; `result` will be `undefined` until the tool resolves. if (result === undefined) { return ( <div className="bg-card/60 text-muted-foreground w-full max-w-[540px] rounded-2xl border px-5 py-4 text-sm shadow-xs"> Loading post… </div> ); } return ( <XPostErrorBoundary> <ParsedXPost result={result} /> </XPostErrorBoundary> ); },});The same pattern works for InstagramPost and LinkedInPost by swapping the component and parser (parseSerializableInstagramPost / parseSerializableLinkedInPost).
All three components share the same prop pattern:
Prop
Type
Prop
Type
Prop
Type
Prop
Type
<time> elementspnpm dlx shadcn@latest add button tooltipimport { XPost } from "@/components/tool-ui/x-post";
import { InstagramPost } from "@/components/tool-ui/instagram-post";
import { LinkedInPost } from "@/components/tool-ui/linkedin-post";
// X Post
<XPost
post={{
id: "tweet-123",
author: { name: "Jane", handle: "jane", avatarUrl: "...", verified: true },
text: "Hello world!",
stats: { likes: 42 },
createdAt: "2025-01-01T12:00:00Z",
}}
onAction={(action, post) => console.log(action, post.id)}
responseActions={[{ id: "report", label: "Report", variant: "destructive" }]}
onResponseAction={(id) => console.log(id)}
/>
// Instagram Post
<InstagramPost
post={{
id: "ig-123",
author: { name: "Jane", handle: "jane", avatarUrl: "..." },
text: "Beach day!",
media: [{ type: "image", url: "...", alt: "Beach photo" }],
stats: { likes: 1234 },
}}
/>
// LinkedIn Post
<LinkedInPost
post={{
id: "li-123",
author: { name: "Jane Smith", avatarUrl: "...", headline: "CEO at Acme" },
text: "Excited to announce...",
linkPreview: { url: "...", title: "News", domain: "acme.com" },
stats: { likes: 89 },
}}
/>// Backend tool
import { tool, jsonSchema } from "ai";
const showXPost = tool({
description: "Show an X post",
inputSchema: jsonSchema<{}>({
type: "object",
properties: {},
additionalProperties: false,
}),
async execute() {
return {
id: "post-x-1",
author: {
name: "Tool UI",
handle: "assistant_ui",
avatarUrl: "https://api.dicebear.com/9.x/shapes/svg?seed=tool-ui",
verified: true,
},
text: "DX test: rendering an XPost tool UI inline in chat.",
stats: { likes: 12 },
createdAt: new Date().toISOString(),
};
},
});
// Frontend with assistant-ui
import { makeAssistantToolUI } from "@assistant-ui/react";
import {
XPost,
XPostErrorBoundary,
parseSerializableXPost,
} from "@/components/tool-ui/x-post";
function ParsedXPost({ result }: { result: unknown }) {
const post = parseSerializableXPost(result);
return <XPost post={post} />;
}
export const ShowXPostUI = makeAssistantToolUI({
toolName: "showXPost",
render: ({ result }) => {
// Tool outputs stream in; `result` will be `undefined` until the tool resolves.
if (result === undefined) {
return (
<div className="bg-card/60 text-muted-foreground w-full max-w-[540px] rounded-2xl border px-5 py-4 text-sm shadow-xs">
Loading post…
</div>
);
}
return (
<XPostErrorBoundary>
<ParsedXPost result={result} />
</XPostErrorBoundary>
);
},
});