IntentFlow separates what to render (server decision) from how to render it (client implementation). This separation enables a single Flow definition to produce native experiences across mobile, web, and agent interfaces.
IntentFlow uses A2UI concepts for declarative UI components that can be rendered natively on each platform.
┌─────────────────────────────────────────────────────────────────┐
│ SERVER │
│ │
│ Flow Definition ──► Props Hydration ──► Protocol Instruction │
│ │
└───────────────────────────────┬─────────────────────────────────┘
│
JSON Protocol
│
┌───────────────────────────────┼─────────────────────────────────┐
│ ▼ │
│ CLIENTS │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Mobile │ │ Web │ │ MCP │ │
│ │ (Native) │ │ (DOM) │ │ (HTML/MD) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ React Native React DOM SSR to HTML │
│ Components Components or Markdown │
│ │
└─────────────────────────────────────────────────────────────────┘
The server emits AG-UI events containing IntentFlow payloads with intentId and props. Each client looks up its own implementation of that Flow and renders it appropriately for its platform using A2UI-compatible components.
Flow components are written once using a universal component system. The framework provides primitives that compile to native elements per platform.
import { Stack, Text, Button, Card, Input } from '@intentflow/ui'
// These primitives compile to:
// - React Native: View, Text, TouchableOpacity, etc.
// - Web: div, span, button, etc.
// - MCP: Semantic HTML elements| Primitive | Mobile | Web | MCP |
|---|---|---|---|
<Stack> |
<View> |
<div> |
<div> |
<Text> |
<Text> |
<span> |
<p> or <span> |
<Button> |
<TouchableOpacity> |
<button> |
<button> |
<Card> |
<View> + styles |
<div> + styles |
<div class="card"> |
<Input> |
<TextInput> |
<input> |
<input> |
Tamagui (recommended) or React Native Web enables this universal approach:
// flows/order/place/component.tsx
import { Stack, Text, Button, YStack, XStack } from '@intentflow/ui'
export function PlaceOrderFlow(props: PlaceOrderFlowProps) {
return (
<YStack padding="$4" gap="$3">
<Text fontSize="$6" fontWeight="bold">
Review Order
</Text>
<Card>
{props.items.map(item => (
<XStack key={item.id} justifyContent="space-between">
<Text>{item.name}</Text>
<Text>${item.price.toFixed(2)}</Text>
</XStack>
))}
</Card>
<XStack gap="$2">
<Button theme="secondary" onPress={handleCancel}>
Cancel
</Button>
<Button theme="primary" onPress={handleConfirm}>
Place Order
</Button>
</XStack>
</YStack>
)
}This single component renders natively on iOS/Android and as standard DOM on web.
Sometimes platforms need different implementations. IntentFlow supports platform overrides:
/flows/order/place/
├── definition.ts
├── component.tsx # Default/shared implementation
├── component.native.tsx # Mobile-specific (optional)
├── component.web.tsx # Web-specific (optional)
└── index.ts
Within a single component, use platform detection sparingly:
import { Platform } from '@intentflow/ui'
export function PlaceOrderFlow(props: PlaceOrderFlowProps) {
return (
<Stack>
{/* Shared content */}
<OrderSummary items={props.items} />
{/* Platform-specific payment UI */}
{Platform.OS === 'ios' && <ApplePayButton />}
{Platform.OS === 'android' && <GooglePayButton />}
{Platform.OS === 'web' && <StripePaymentForm />}
</Stack>
)
}When Flows render in MCP contexts (Claude, ChatGPT, etc.), they output HTML or Markdown instead of interactive components.
The MCP server wraps Flow components with a server-side renderer:
// mcp/server.ts
import { renderToString } from 'react-dom/server'
import { registry } from '@app/registry'
async function handleToolCall(intentId: string, params: unknown) {
const flow = registry.getFlow(intentId)
const props = await hydrateFlow(intentId, params)
// Server-render the component to HTML
const html = renderToString(
<MCPRenderContext>
<flow.component {...props} />
</MCPRenderContext>
)
return {
type: 'text/html',
content: html,
}
}Components can detect MCP context and adjust:
import { useRenderContext } from '@intentflow/react'
export function PlaceOrderFlow(props: PlaceOrderFlowProps) {
const { isMCP } = useRenderContext()
if (isMCP) {
// Static representation for agent display
return (
<MCPCard title="Order Summary">
<MCPList items={props.items.map(i => `${i.name}: $${i.price}`)} />
<MCPText>Total: ${calculateTotal(props.items)}</MCPText>
<MCPAction label="Confirm Order" action="CONFIRM" />
</MCPCard>
)
}
// Interactive version for native/web
return (
<InteractiveOrderFlow {...props} />
)
}For MCP contexts, use primitives that render clean HTML:
// These render to semantic HTML for agent display
<MCPCard> → <div class="card">
<MCPList> → <ul><li>...</li></ul>
<MCPTable> → <table>
<MCPAction> → <button data-action="...">
<MCPLink> → <a href="...">The server can specify how a Flow should be presented:
interface RenderInstruction {
type: 'RENDER'
intentId: string
instanceId: string
props: Record<string, unknown>
displayMode: 'inline' | 'modal' | 'fullscreen' | 'sheet'
}| Mode | Mobile | Web | MCP |
|---|---|---|---|
inline |
Embedded in chat | Embedded in page | Inline HTML |
modal |
Centered modal | Dialog overlay | Card block |
fullscreen |
Full screen takeover | Full viewport | Full response |
sheet |
Bottom sheet | Side panel | Card block |
The client interprets displayMode according to platform conventions:
function FlowHost({ instruction }) {
const Component = registry.get(instruction.intentId)
switch (instruction.displayMode) {
case 'modal':
return (
<Modal visible onDismiss={handleDismiss}>
<Component {...instruction.props} />
</Modal>
)
case 'sheet':
return (
<BottomSheet>
<Component {...instruction.props} />
</BottomSheet>
)
case 'fullscreen':
return (
<FullScreenContainer>
<Component {...instruction.props} />
</FullScreenContainer>
)
default:
return <Component {...instruction.props} />
}
}For Flows with progressive content, the server can stream props:
// Server sends initial frame
{
type: 'RENDER',
intentId: 'order.track',
instanceId: 'flow_123',
props: {
status: 'preparing',
estimatedTime: 8,
},
streaming: true
}
// Server sends updates as state changes
{
type: 'PROPS_UPDATE',
instanceId: 'flow_123',
patch: {
status: 'ready',
estimatedTime: 0,
}
}The client merges prop patches and re-renders:
function StreamingFlowHost({ instruction }) {
const [props, setProps] = useState(instruction.props)
useProtocolStream(instruction.instanceId, (message) => {
if (message.type === 'PROPS_UPDATE') {
setProps(prev => ({ ...prev, ...message.patch }))
}
})
const Component = registry.get(instruction.intentId)
return <Component {...props} />
}The universal component system supports theming for brand consistency:
// theme.ts
export const theme = createTheme({
colors: {
primary: '#2563eb',
secondary: '#64748b',
background: '#ffffff',
surface: '#f8fafc',
text: '#0f172a',
textMuted: '#64748b',
error: '#dc2626',
success: '#16a34a',
},
spacing: {
xs: 4,
sm: 8,
md: 16,
lg: 24,
xl: 32,
},
radii: {
sm: 4,
md: 8,
lg: 16,
},
fonts: {
body: 'Inter',
heading: 'Inter',
mono: 'JetBrains Mono',
},
})Components consume theme values:
<Button
backgroundColor="$primary"
padding="$md"
borderRadius="$md"
>
<Text color="white">Place Order</Text>
</Button>The theme compiles to platform-appropriate styling (StyleSheet on mobile, CSS on web).
Universal components include accessibility by default:
<Button
onPress={handleConfirm}
accessibilityLabel="Confirm order for $12.50"
accessibilityRole="button"
>
Place Order
</Button>Platform mappings:
- Mobile:
accessibilityLabel,accessibilityRoleprops - Web:
aria-label,roleattributes - MCP: Semantic HTML elements
Wrap Flow rendering in error boundaries:
function FlowHost({ instruction }) {
return (
<FlowErrorBoundary
intentId={instruction.intentId}
onError={(error) => reportError(error, instruction)}
fallback={<FlowRenderError intentId={instruction.intentId} />}
>
<FlowRenderer instruction={instruction} />
</FlowErrorBoundary>
)
}Errors during rendering don't crash the app—they show a graceful fallback and report to your error tracking.