Skip to content

Data Flow

Problem: Apps involve three actors (the host, your server, your view) communicating in complex patterns. Understanding this flow is essential.

Solution: frac provides clear abstractions for each communication pattern.

%%{init: {'theme': 'base', 'themeVariables': { 'lineColor': '#64748b' }}}%%
flowchart LR
User["User"]
Model["Model"]
View["View"]
Server["MCP Server"]
User <-->|"Converse"| Model
User <-->|"Interact"| View
Model -->|"Features exposed<br/>as MCP tools"| Server
View -->|"UI pages exposed<br/>as MCP resources"| Server
style User fill:#f1f5f9,stroke:#64748b,color:#334155
style Model fill:#2563EB,stroke:#1e40af,color:#fff
style View fill:#2563EB,stroke:#1e40af,color:#fff
style Server fill:#dbeafe,stroke:#93c5fd,color:#1e40af
  1. The MCP Host: The conversational interface where users type messages and the model responds (ChatGPT, Claude, Goose, VSCode, etc.)
  2. Your MCP Server: The backend that exposes tools and business logic
  3. Your View (Guest): The React component rendered in an iframe inside the host

Tool responses contain three fields:

  • content: Text array shown to the model in the conversation
  • structuredContent: Typed JSON data surfaced to your view and the host
  • _meta: Delivered only to the view and hidden from the model

Before diving into the data flow, understand the difference between plain tools and tools with a view. Both use registerTool, differentiated by the optional view field:

registerTool (no view)registerTool (with view)
Has UI?NoYes
Returnscontent and/or structuredContentstructuredContent and optional content/_meta
RendersNothingA React component from src/views/
Use caseBackground operations, calculationsInteractive UI
// Plain tool: no UI, returns text
server.registerTool(
{ name: "calculate", inputSchema: { /* ... */ } },
async (args) => {
return { content: "Result: 42" };
},
);
// Tool with view: has UI, returns structured data
server.registerTool(
{
name: "chart",
inputSchema: { /* ... */ },
view: { component: "chart" },
},
async (args) => {
return {
content: "Displaying chart",
structuredContent: { data: [1, 2, 3], labels: ["A", "B", "C"] },
};
},
);

When the host calls a tool returning a view, it returns structuredContent to hydrate the React component:

Server:

server.registerTool(
{
name: "show_flights",
inputSchema: { destination: z.string() },
view: { component: "show_flights" },
},
async ({ destination }) => {
const flights = await searchFlights(destination);
return {
content: `Found ${flights.length} flights`,
structuredContent: { flights }, // This goes to the view
};
},
);

View:

import { useToolInfo } from "@usefractal/frac/web";
export function FlightView() {
const toolInfo = useToolInfo<{ flights: Flight[] }>();
if (toolInfo.isSuccess) {
const { flights } = toolInfo.output.structuredContent;
return (
<ul>
{flights.map(flight => <li key={flight.id}>{flight.name}</li>)}
</ul>
);
}
return <div>Loading...</div>;
}

Use useToolInfo for the initial data that renders your view. This data is set once when the view loads.

Views can trigger additional tool calls in response to user actions:

import { useCallTool } from "@usefractal/frac/web";
export function FlightView() {
const { callTool, isPending, data } = useCallTool("get_flight_details");
const handleViewDetails = (flightId: string) => {
callTool({ flightId });
};
return (
<button onClick={() => handleViewDetails("AF123")} disabled={isPending}>
{isPending ? "Loading..." : "View Details"}
</button>
);
}

Use useCallTool when the user performs an action that requires fetching more data.

Your view needs to communicate its state back to the model. Use the data-llm attribute to declaratively describe what the user sees. See LLM Context Sync.

Views can send messages back into the conversation:

import { useSendFollowUpMessage } from "@usefractal/frac/web";
export function FlightView() {
const sendMessage = useSendFollowUpMessage();
const handleBookFlight = (flight: Flight) => {
sendMessage({
prompt: `I'd like to book the ${flight.name} flight. What payment methods do you accept?`
});
};
return <button onClick={() => handleBookFlight(selectedFlight)}>Book Now</button>;
}

This creates a continuous loop: the view can ask the model for help, and the model responds naturally in the conversation.

Tool responses have three fields:

FieldPurposeConsumed by
contentText descriptionThe host (shown in conversation)
structuredContentTyped dataHost and view (useToolInfo, useCallTool)
_metaResponse metadataView
return {
content: [{ type: "text", text: "Found 3 flights to Paris" }],
structuredContent: {
flights: [{ id: "AF123", name: "Air France 123" }, /* ... */]
},
_meta: {
flightImages: [{ url: "https://assets.airfrance.com/flights/AF123.jpg" }, /* ... */]
}
};
NeedUseWhy
Initial view datauseToolInfoData passed at hydration, no extra calls
User-triggered fetchuseCallToolModel sees the result, can answer questions
Silent background fetchDirect API callModel doesn’t need to know
Describe current UI statedata-llmPassive context for user questions
Trigger model responseuseSendFollowUpMessageActive prompt, immediate reply
Persist view stateuseViewStateSurvives re-renders
  1. Host calls your tool → Server responds with structuredContent
  2. View hydrates with useToolInfo
  3. User interacts → View updates data-llm → Model sees the context
  4. User triggers action → View calls useCallTool → Server responds
  5. View sends follow-upuseSendFollowUpMessage → Model replies

This loop creates a seamless experience where the conversation, the UI, and your backend work together.