Generated UI

An agent can answer with UI rather than text: a weekend plan with a map of the places, a tab for each day and a form to change the pace. The agent writes the UI into its reply, the router carries it to Stream Chat beside the reply's words, and your app shows it in place of them. What the person does in it comes back to the agent with their next message.

The router knows no UI format. The model writes the UI in a format your app has a renderer for, such as A2UI or OpenUI, and the router passes it on as it is.

How the agent writes UI

The model writes the UI in a fenced block of its reply. The block's info string is ui:<format>, or ui:<format>@<version> to name the format's version, such as ui:a2ui@1.0. OpenUI's own fence, openui-lang, is read as the openui format, since that's the fence OpenUI's prompts ask models for:

Here's a weekend in Ohrid, with a map and a plan for each day.

```openui-lang
root = Card([title, map])
title = CardHeader("A weekend in Ohrid")
```

The first such block is the reply's UI, and any later one is left out of its text. Only the agent writes UI: a block in a person's message stays part of their words.

The model writes UI when the agent's instructions ask it to: add the format's own instructions, with the components of your app it may place, to the agent's instructions.md. StreamChatAI writes those instructions from the same components your app shows. The capabilities of the app's AIUIHost hold, for each format it reads, the fence the model writes in and the instructions for the model: what you gave the renderer as its instructions, then the format's own, written from its components, yours and the host's tools:

// The instructions of each format the host reads, such as A2UI's and OpenUI's.
let instructions = (ui.capabilities["renderers"]?.arrayValue ?? [])
    .compactMap { $0.dictionaryValue?["instructions"]?.stringValue }

The app can't change the agent, so print them while you build the app and add them to instructions.md, or send them to your backend and have it add them. Write them again when your components change. A renderer's instructions parameter is where to say which answers to write as UI and when to use your components:

A2UIRenderer(instructions: "Write a trip plan as UI, with a place_card for each place. Answer everything else in Markdown.")

A2UI's capabilities also hold the catalog_id of the catalog with your components, getstream.io:app-catalog by default (the renderer's catalogId changes it). See Tell the agent what the app shows in the iOS guide.

What reaches Stream Chat

As the reply streams, the router moves the block out of its text into the message's custom data, as custom.ai_ui, and keeps the words around it as the message's text:

{
  "v": 1,
  "format": "openui",
  "status": "streaming",
  "content": "root = Card([title, map])\ntitle = CardHeader(\"A weekend in Ohrid\")"
}
Field Description
v The version of the field, 1.
format The format from the block's info string, such as a2ui or openui.
version The format's version, such as 1.0, when the info string names one.
status streaming while the agent is still writing the block, then complete.
content The block as the model wrote it, such as an OpenUI program or A2UI messages.

The text is what people read wherever the UI can't show: in a push notification, in an external thread and in an app without a renderer for the format. So have the model write a sentence saying what the UI shows before the block.

Stream Chat holds at most 50 KB of custom data on a message. When the UI would take the message's custom data over 48 KB as JSON, the router leaves it out, and the reply keeps only its words. A UI that was showing while the reply streamed is taken off again, rather than left cut off midway.

When the conversation goes on, the model reads its earlier replies with their UI, written back as the blocks it wrote.

Show it in your app

StreamChatAI's AIConversationView shows a reply's UI in place of its text, brings it in as the agent writes it, and sends what the person does in it back to the agent. Its renderers of A2UI and OpenUI are the StreamChatA2UI and StreamChatOpenUI libraries, from version 5.16.0 of the Stream Chat Swift SDK. Add the library of each format your agent writes to your app target, next to StreamChatAI:

Library Renderer Format Requires
StreamChatA2UI A2UIRenderer A2UI 1.0 iOS 15
StreamChatOpenUI OpenUIRenderer OpenUI Lang 1.0 iOS 17

The quickstart's chat UI shows the session's channel with AgentChat and AgentMessage. Give each message the UI the router moved out of its text, as its uiDocument:

struct AgentMessage: AIMessage {
    let message: ChatMessage

    // The id, role, text, isGenerating and parts, as in the quickstart.

    // The reply's UI, from the message's `custom.ai_ui`.
    var uiDocument: AIUIDocument? { AIUIDocument(customData: message.extraData) }
}

Keep an AIUIHost with the renderers of the formats your agent writes and your components in AgentChat, so each reply's UI keeps its state, such as what the person typed into a form, for as long as the conversation shows. Its send asks the session, as the composer does:

import StreamChat
import StreamChatA2UI
import StreamChatAI
import StreamChatOpenUI
import SwiftUI
import VisionAgentsCore

@MainActor
final class AgentChat: ObservableObject, ChatChannelControllerDelegate {
    let session: AgentSession
    /// The UI the agent writes for some replies, in A2UI or OpenUI, with the app's own views.
    let ui: AIUIHost<AIUIRendererPair<A2UIRenderer, OpenUIRenderer>>
    @Published private(set) var messages: [AgentMessage] = []
    @Published private(set) var isAnswering = false

    private let channel: ChatChannelController

    init(session: AgentSession, channel: ChatChannelController) {
        self.session = session
        self.channel = channel
        ui = AIUIHost(components: [PlaceCardComponent()]) {
            A2UIRenderer()
            OpenUIRenderer()
        } send: { [session] action in
            // What the person did, in their words, with the action for the agent.
            _ = try await session.responses.create(action.label, uiAction: action.json)
        }
        channel.delegate = self
        channel.synchronize()
    }

    // channelController(_:didUpdateMessages:) as in the quickstart.
}

Then pass the host to the conversation:

AIConversationView(messages: chat.messages, ui: chat.ui)

A reply shows its UI when one of the host's renderers reads its format and version, the first one listed asked first. Otherwise, it shows its text. The UI comes in as the agent writes it: each part fades in, rising into place, after the parts before it. A reply loaded from the channel's history shows at once. With an AIConversation, as the Swift assistant has, pass the host the same way: AIConversationView(conversation: conversation, ui: ui).

OpenUIRenderer needs iOS 17. With an earlier deployment target, keep a host of A2UIRenderer alone, an AIUIHost<A2UIRenderer>, before iOS 17.

A component is a view of your app the agent can place, described once with the parameters the agent fills in and the events it reports. Every renderer places it by its name, so it works whichever format the agent writes:

struct PlaceCardComponent: AIUIComponent {
    struct Arguments: Decodable {
        let title: String
    }

    let definition = AIUIComponentDefinition(
        name: "place_card",
        description: "A photo and a short summary of a place.",
        parameters: [.required("title", .string, description: "The place's Wikipedia title.")],
        events: [.init(name: "ask_about", description: "The person asks about the place.")]
    )

    func body(arguments: Arguments, context: AIUIComponentContext) -> some View {
        PlaceCard(title: arguments.title) {
            context.emit("ask_about", label: "Tell me more about \(arguments.title)")
        }
    }
}

A component can load what it shows on the device, such as a place's photo or the person's calendar, which then never reaches the agent. Its definition reads and writes JSON, so your apps and the agent's instructions can share one description of it, decoded with JSONDecoder as [AIUIComponentDefinition].

The renderers treat what the agent writes as untrusted: a reply's UI can't crash or freeze the app, and its links open through the host, which opens only web and email links by default. A reply's UI can also run your app's client side tools, passed to the host as tools, such as to load the person's calendar for the days of a plan. They are apart from the tools the agent calls, and the host runs one by itself only when its definition says it's read-only (readOnlyHint).

The iOS generative UI guide covers the rest: the renderers' options and theming, components, a renderer of your own for another format, and showing a reply's UI in your own views with AIUIView.

What the person does

When the person does something in a reply's UI that's for the agent, such as tapping a place, choosing a follow-up or submitting a form, the host calls its send with an AIUIAction. Ask the session with it, as AgentChat does above: action.label is the question, in the person's words, such as "Tell me more about St. Naum", and action.json is what they did, passed as uiAction to responses.create. See Send what the person did in a reply's UI.

Property Description
messageID The reply whose UI it came from.
format The format of that UI.
name What happened, such as a component's event (ask_about) or an action of the format, such as OpenUI's continue_conversation.
label What the person did, in their words. Use it as the question.
payload The details, in the format's own shape, such as what was chosen and a form's values.

The iOS guide sends the action to the channel, with action.customData as the message's extra data. The agent doesn't answer a message sent to the session's channel, so ask the session instead: the router writes the person's message into the channel itself, with the action on it.

The host sends a reply's actions one after another, in the order they happened, and logs a send that throws. To show the person that asking failed, catch the error in send, as the Swift assistant does. The SDK's renderers label an action with the control the person tapped, never with text the agent wrote for it, so the conversation shows only what the person chose. OpenUI sends only the values of the form the action belongs to, and never a password field's value.

Info:

The Swift SDK takes uiAction on the accelerate branch of Vision-Agents, from tag 0.2.0.

The action names the reply it came from (message_id, the id of the reply's Stream Chat message), the UI's format and the action (name), with what it carries (payload), in at most 8 KB as JSON. The router keeps it on the person's message as custom.ai_ui_action, and tells the model what they did after their words, with the opening words of the reply it was in. The model reads the action as data, not as instructions.

Examples

The Swift assistant sample plans a trip as interactive UI. Its settings choose how the agent answers: in Markdown, or with UI in A2UI or OpenUI. Ask "Plan a weekend in Ohrid" and the reply is a map of the places, the person's own calendar for those days, a tab for each day and a form to change the pace and the interests.

  • AgentChat.swift keeps one AIUIHost with both renderers for each conversation, so a reply shows whichever UI it carries, and its send asks the session with responses.create(_:uiAction:).
  • TripComponents.swift holds the map, the place cards and the calendar, the app's own views. They're described once in UIComponents.json, which the app decodes as [AIUIComponentDefinition], and both formats place them. The cards load their photos from Wikipedia and the calendar reads EventKit on the phone, so the agent never sees the person's calendar.
  • prompts/generate.sh writes a2ui/instructions.md and openui/instructions.md from that file with the renderers' own code, from a stream-chat-swift checkout. Each prompt.json picks the components the agent may use and says when to write UI, and its examples have to render, or nothing is written.
  • When the settings change, the sample's backend rewrites the agent's instructions: its instructions.md, followed by the chosen format's. A conversation keeps the instructions it started with, so the next new one is the first to answer the new way.

Tapping a place, a pin on the map, a follow-up or the form's button asks the agent in the person's words, such as "Update the plan", with the form's values in the action, and the agent answers with a new plan. The React assistant and Android assistant samples show the same replies in the browser and in a Compose app.