From LLM Tool Calls to Native SwiftUI Screens: A Step‑by‑Step Tutorial for Vertical Story Layouts
LLM tool calls are finally reliable enough to drive real UI. But turning those JSON payloads into native SwiftUI screens is an architecture problem, not a…

LLM tool calls are finally reliable enough to drive real UI. But turning those JSON payloads into native SwiftUI screens is an architecture problem, not a prompt trick.
This tutorial walks you through a practical pattern Uzori uses: vertical story layouts that stack images, explanatory text, and data blocks, and stay flexible even when content length or ordering changes at runtime.
You’ll see how to:
- Take structured LLM responses (tool calls) and validate them server‑side
- Map them into a constrained SwiftUI schema
- Render vertical, streaming story layouts that adapt to images, text, and data blocks
1. Prerequisites and Mental Model
Before writing any SwiftUI code, you need a clear mental model: LLM → structured output → server validation → SwiftUI renderer.
1.1 What you’ll need
- An iOS app using SwiftUI (iOS 17+ is ideal)
- A backend that can:
- Call an LLM with tool/function schemas (OpenAI, Anthropic, etc.)
- Validate JSON against a schema
- Serve validated payloads to the app via HTTP or WebSockets
- Basic familiarity with:
VStack/LazyVStackAsyncImage- Codable models in Swift
If you’re using Uzori, the iOS SDK already wires most of this together:
- The SDK renders SwiftUI from a constrained schema
- The Uzori engine calls LLM tools and validates outputs server‑side
- Screens stream into your app in real time
1.2 Why vertical story layouts?
Vertical story layouts are ideal for disruption flows like rebooking and refunds, which we explore in depth in the related guide: “What are my options now? — rebooking, refunds and the shape of a disruption answer.”
They work well because:
- Users naturally scroll through a narrative: problem → options → decisions
- You can stack:
- Hero image or illustration
- Short explanatory blocks
- Data tables or cards (e.g., fare rules, flight alternatives)
- You can insert or remove blocks at runtime without breaking the layout
2. Step 1 — Design the Server‑Side Story Schema
The most important step is not in SwiftUI. It’s defining the schema the LLM must follow.
2.1 Define content blocks
Model the vertical story as a list of blocks:
{
"story_id": "disruption_123",
"title": "Your trip was disrupted — here are your options",
"blocks": [
{
"type": "image",
"id": "hero",
"url": "https://cdn.example.com/disruption/hero.jpg",
"caption": "Weather-related disruption on your outbound flight."
},
{
"type": "text",
"id": "summary",
"role": "explanatory",
"body": "You can rebook for free, request a refund, or choose travel credit."
},
{
"type": "data",
"id": "options_table",
"title": "Your options at a glance",
"rows": [
{
"label": "Free rebooking",
"value": "Any date within 30 days, subject to availability"
},
{
"label": "Refund",
"value": "Full refund to original payment method"
}
]
}
]
}
Each block has:
- A
typeconstrained to a known set:image,text,data,action, etc. - A unique
idfor tracking and analytics - Type‑specific fields (e.g.,
url,body,rows)
2.2 Tie schema to LLM tool calls
In your LLM setup (e.g., OpenAI function/tool calling):
- Define a
generate_disruption_storytool that returns exactly this schema - Use structured outputs or function calling so the model must obey the schema
OpenAI reports that gpt-4o-2024-08-06 hit 100% JSON‑schema adherence on its own evals, while older gpt-4-0613 was below 40%. Even with strong models, independent research like StructEval shows visual/Renderable formats are still tricky, so server validation is non‑optional.
3. Step 2 — Validate and Normalize on the Server
Never render LLM output directly in your app. The server must become your safety gate.
3.1 Schema validation
On the backend:
- Use a JSON schema validator or hand‑rolled checks to enforce:
- Allowed
typevalues - Required fields per type
- URL formats for images
- Max lengths for text
- Allowed
- Reject or repair any payload that doesn’t conform
This matches the industry trend:
- Google’s A2UI uses a trusted component catalog and declarative payloads
- Shopify’s remote rendering decouples UI definition from UI rendering
- Digia sends JSON “blueprints” that the device renders as native widgets
3.2 Normalizing for SwiftUI
Before sending to the client, normalize:
- Convert external IDs into stable internal identifiers
- Enforce ordering rules where necessary (e.g., always show summary before options)
- Add any server‑side metadata your app needs:
- Tracking tags
- Feature flags
- Experiments (e.g., different layout variants)
The final payload the app sees should be guaranteed renderable. No surprises.
4. Step 3 — Define Swift Models and a Block Renderer
Now you translate the validated payload into Swift.
4.1 Codable models for blocks
Create Swift types that mirror the server schema:
enum StoryBlockType: String, Codable {
case image
case text
case data
}
struct Story: Codable, Identifiable {
let id: String
let title: String
let blocks: [StoryBlock]
}
struct StoryBlock: Codable, Identifiable {
let id: String
let type: StoryBlockType
let image: ImageBlock?
let text: TextBlock?
let data: DataBlock?
}
struct ImageBlock: Codable {
let url: URL
let caption: String?
}
struct TextBlock: Codable {
let role: String
let body: String
}
struct DataRow: Codable, Identifiable {
let id: String
let label: String
let value: String
}
struct DataBlock: Codable {
let title: String?
let rows: [DataRow]
}
You’re encoding only what you support. The LLM can’t invent new types without backend and client updates.
4.2 A SwiftUI block renderer
Create a single entry point that turns StoryBlock into real views:
struct StoryBlockView: View {
let block: StoryBlock
var body: some View {
switch block.type {
case .image:
if let image = block.image {
ImageBlockView(block: image)
}
case .text:
if let text = block.text {
TextBlockView(block: text)
}
case .data:
if let data = block.data {
DataBlockView(block: data)
}
}
}
}
This is the native renderer part of the pattern: structured payload → typed models → component catalog.
5. Step 4 — Build the Vertical Story Layout in SwiftUI
With the block renderer ready, you can now build the vertical layout.
5.1 Use LazyVStack for performance
Apple’s docs highlight LazyVStack as ideal for long lists: it creates child views only as they appear on screen. That’s exactly what you want for a vertical story with many blocks.
struct StoryView: View {
let story: Story
var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
Text(story.title)
.font(.title2)
.bold()
.padding(.bottom, 8)
ForEach(story.blocks) { block in
StoryBlockView(block: block)
.padding(.horizontal)
}
}
.padding(.vertical)
}
}
}
This pattern:
- Handles variable content length
- Keeps performance acceptable, even if the LLM generates dozens of blocks
- Feels like any other native screen in your app
5.2 Handle streaming updates
If you stream blocks from the server (Uzori does this), maintain them in state:
struct StreamingStoryView: View {
@State private var blocks: [StoryBlock] = []
let title: String
var body: some View {
ScrollView {
LazyVStack(alignment: .leading, spacing: 16) {
Text(title)
.font(.title2)
.bold()
ForEach(blocks) { block in
StoryBlockView(block: block)
}
}
.padding()
}
.task {
await subscribeToStoryStream()
}
}
func subscribeToStoryStream() async {
// Pseudocode: listen to WebSocket and append blocks as they arrive
// for await newBlock in storyStream {
// blocks.append(newBlock)
// }
}
}
This gives you a conversational, AI‑driven screen without a chat UI. The interface builds itself as blocks arrive.
6. Step 5 — Implement Flexible Block Views for Images, Text, and Data
The vertical layout’s resilience depends on how you implement each block.
6.1 Image blocks with AsyncImage
Use AsyncImage so remote media doesn’t freeze the UI.
struct ImageBlockView: View {
let block: ImageBlock
var body: some View {
VStack(alignment: .leading, spacing: 8) {
AsyncImage(url: block.url) { phase in
switch phase {
case .empty:
ProgressView()
.frame(maxWidth: .infinity)
.frame(height: 200)
case .success(let image):
image
.resizable()
.scaledToFit()
.frame(maxWidth: .infinity)
.clipShape(RoundedRectangle(cornerRadius: 12))
case .failure:
RoundedRectangle(cornerRadius: 12)
.overlay(Text("Image unavailable"))
.frame(height: 200)
}
}
if let caption = block.caption, !caption.isEmpty {
Text(caption)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
This block:
- Stacks image and caption vertically
- Handles loading and failure states gracefully
- Expands to full width, which works across device sizes
6.2 Text blocks with layout priority
Use layoutPriority to keep important explanatory text from being truncated in tight layouts.
struct TextBlockView: View {
let block: TextBlock
var body: some View {
Text(block.body)
.font(fontForRole(block.role))
.fixedSize(horizontal: false, vertical: true)
.layoutPriority(1) // Prefer full text over neighboring decorative elements
}
private func fontForRole(_ role: String) -> Font {
switch role {
case "title": return .title3.bold()
case "explanatory": return .body
case "disclaimer": return .footnote
default: return .body
}
}
}
This helps when:
- The LLM generates longer text than expected
- The story appears inside a more constrained parent layout
6.3 Data blocks with dynamic rows
Data blocks summarize structured options (e.g., rebooking windows, refund eligibility).
struct DataBlockView: View {
let block: DataBlock
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if let title = block.title {
Text(title)
.font(.headline)
}
VStack(alignment: .leading, spacing: 4) {
ForEach(block.rows) { row in
HStack(alignment: .top, spacing: 8) {
Text(row.label)
.font(.subheadline)
.bold()
.frame(width: 120, alignment: .leading)
Text(row.value)
.font(.subheadline)
.fixedSize(horizontal: false, vertical: true)
}
}
}
}
.padding()
.background(
RoundedRectangle(cornerRadius: 12)
.strokeBorder(.gray.opacity(0.3))
)
}
}
This pattern:
- Handles variable numbers of rows without layout changes
- Keeps labels and values aligned, even when values are multi‑line
When applied to disruption flows, it gives users a clean, scrollable summary of “What are my options now?” without forcing them to parse long prose.
7. Step 6 — Keeping Layout Flexible When Media Types and Order Change
LLM‑driven stories will vary. Some answers may be mostly text; others may lean on images and data blocks. You need patterns that survive these variations.
7.1 Treat blocks as a sequence, not a fixed template
Instead of designing for a fixed layout (image → text → data), design for:
- Any block order the server gives you
- Optional presence or absence of each type
Because you:
- Use
ForEach(story.blocks) - Switch on
block.typeinStoryBlockView
…you automatically adapt to different sequences.
7.2 Use ViewThatFits for conditional layouts
You can refine key sections with ViewThatFits to adjust under constraints.
struct ResponsiveStoryHeader: View {
let title: String
let subtitle: String?
var body: some View {
ViewThatFits {
VStack(alignment: .leading, spacing: 4) {
Text(title)
.font(.title2).bold()
if let subtitle { Text(subtitle).font(.subheadline) }
}
Text(title)
.font(.title3).bold()
}
}
}
ViewThatFits picks the first child that fits the available space. This keeps headers readable on smaller devices or embedded surfaces.
7.3 Use containerRelativeFrame in images
For image blocks inside more complex parents (e.g., carousels), containerRelativeFrame helps maintain visual balance.
image
.containerRelativeFrame(.vertical) { size, axis in
size * 0.4
}
This ensures the image consumes a reasonable fraction of its container, regardless of device height.
8. Step 7 — Integrating Uzori’s SDK for Generative + Server‑Driven Safety
If you’d rather not build all of this from scratch, Uzori’s iOS SwiftUI SDK gives you a ready‑made path from LLM tool calls to validated SwiftUI screens.
Uzori’s engine:
- Takes user requests plus your backend APIs (usually described via OpenAPI)
- Calls tools with strict schemas tied to your components
- Validates all generated screens server‑side
- Streams SwiftUI views into a single integrated screen in your app
You get:
- The interface that builds itself for complex flows
- Generative UI with server‑driven safety
- Native SwiftUI blocks, not WebViews or arbitrary remote code
9. Why This Pattern Matters (Reliability, UX, and Scale)
Turning LLM tool calls into native iOS screens isn’t just a novelty. It aligns with broader industry trends.
- Sensor Tower’s State of Mobile 2025 reports 4.2 trillion hours spent in apps and $150B in consumer spend. Native UX quality directly impacts your share of that time and revenue.
- Stack Overflow’s 2025 survey shows 84% of developers are using or planning to use AI tools, but positive sentiment dropped to 60%. Poorly integrated AI (especially chatboxes that don’t feel native) is a big reason.
- Shopify’s move to faster native modules cut some WebView loads by 6× (P75 from 6s to 1.4s), reinforcing that native-first still sets the UX bar.

Key benchmarks show why native, structured AI interfaces matter: mobile users spend trillions of hours in apps, while modern AI models only hit full schema reliability under strong constraints.
By using structured outputs, server validation, and native SwiftUI renderers, you:
- Convert AI from “answer generator” into interface orchestrator
- Keep safety and performance under control
- Ship AI‑native experiences that feel like your app, not someone else’s chatbot
10. Putting It All Together: A Checklist
Here’s a quick checklist you can copy into your issue tracker.
- Define your story schema
- Block types: image, text, data, action
- Required fields and constraints
- Wire up LLM tool calling
- Use structured outputs / function calls
- Bind tools to your schema
- Validate on the server
- Enforce types, lengths, and URL formats
- Normalize IDs and ordering
- Create Swift models and block renderer
- Codable types for story and blocks
StoryBlockViewswitching onblock.type
- Implement vertical layout
LazyVStackinsideScrollView- Streaming support via
@Stateand async updates
- Refine block components
AsyncImagefor remote medialayoutPriorityfor critical text- Flexible data tables/cards
- Optional: integrate Uzori
- Single-screen SDK integration
- Server‑validated, generative SwiftUI screens
FAQ: Turning LLM Tool Calls into Native SwiftUI Screens
Q1. Why can’t I just have the LLM emit SwiftUI code directly?
Running arbitrary generated code on devices is a major security and reliability risk.
A safer pattern is:
- LLM emits structured JSON matching a constrained schema
- Server validates and may correct the payload
- App renders native SwiftUI components from that schema
This keeps your UI within a trusted component catalog and avoids remote code execution.
Q2. How do I handle model mistakes in the UI schema?
You catch them on the server:
- Run schema validation against every tool call
- If validation fails:
- Either repair the payload (e.g., strip unknown block types)
- Or ask the model to regenerate with stricter guidance
- Log failures to monitor reliability across models and prompts
Never let unvalidated LLM output reach the renderer.
Q3. How does this differ from traditional server‑driven UI?
Traditional SDUI:
- Server authors layouts manually or via config
- Client renders them with a runtime
LLM‑driven SDUI (with Uzori‑style patterns):
- Agents compose layouts dynamically based on user intent and backend data
- Server still validates and owns safety
- Client uses the same runtime, but now fed by generative logic
It’s the convergence of generative UI and SDUI.
Q4. Does this work for non‑story flows, like wizards or comparison views?
Yes.
The same pattern—blocks + schema + renderer—can drive:
- Multi‑step wizards for complex configuration
- Comparison layouts for products or itineraries
- Explorers and concierges that mix images, forms, and data tables
Vertical story layouts are just one specialization that works particularly well for disruption answers.
Q5. How hard is it to try this in an existing app?
You can start with a single screen behind a feature flag:
- Implement the story schema and block renderer
- Connect to a test endpoint that returns LLM‑generated stories
- Route a small cohort of users to the AI‑driven disruption screen
With Uzori’s SDK, this often reduces to one SwiftUI screen integration, and you can expand to richer flows once you’re confident in reliability.
If you’re ready to move beyond chatboxes and let AI actually build native interfaces, start with a single vertical story layout. Tie it to a disruption flow—“What are my options now?”—and let structured LLM tool calls orchestrate images, explanations, and data blocks. Once that works, the rest of your AI UX roadmap becomes much easier to reason about and ship.