Tutorial: llmPrompt SwiftUI
Cover Page
This part of the tutorial introduces you to the iOS app development environment. You’ll learn some Swift syntax and SwiftUI features to build reactive UI declaratively. This tutorial can be completed on the iOS simulator. Let’s get started!
Expected behavior
Posting a prompt and receiving and displaying streamed response:
DISCLAIMER: the video demo shows you one aspect of the app’s behavior. It is not a substitute for the spec. If there are any discrepancies between the demo and this spec, please follow the spec. The spec is the single source of truth. If the spec is ambiguous, please consult the teaching staff for clarification.
Be patient, the app on your device or simulator will be very slow because we’re running in debug mode, tethered to Xcode, not as stand-alone app in release mode. It could take several seconds after launch for the app’s first screen to show up.
Preliminaries
Before we start, you’ll need to prepare a GitHub repo to submit your tutorials and for us to communicate your tutorial grades back to you. Please follow the instructions in Preparing GitHub for Reactive Tutorials and Projects and then return here to continue.
If you don’t have an environment set up for iOS development, please read the course notes on Getting Started with iOS Development first.
Creating an Xcode project
In the following, replace <YOUR:UNIQNAME> with your uniqname. Apple will complain if your Bundle
Identifier is not globally unique. Using your uniqname is one way to generate a unique Bundle
Identifier.
Depending on your version of Xcode, the screenshots in this and subsequent specs may not look exactly the same as what you see on screen.
In the following, replace /YOUR:TUTORIALS/ with the name of your tutorials folder.
- Click
Create a new Xcode projectin “Welcome to Xcode” screen (screenshot) - Select
iOS > Appand clickNext(screenshot, be careful that you selectiOSand notmacOS) - Enter
Product Name: Agent -
Team: Noneif you don’t have one yet, otherwise choose your
Personal Team -
Organization Identifier: edu.umich.<YOUR:UNIQNAME>👈👈👈replace
<YOUR:UNIQNAME>with yours, remove the angle brackets,< > -
Interface: SwiftUI -
Language: Swift - Leave the other fields as
Noneand all boxes unchecked, clickNext - On the file dialog box that pops up, put your
Agentfolder in👉👉👉 /YOUR:TUTORIALS, whereYOUR:TUTORIALSis the name you’ve given your assignment GitHub repo clone in Preparing GitHub for Reactive Tutorials and Projects (agenticin the example below). - Leave
Create Git repository on my MacUNCHECKED (screenshot). We will add the files to GitHub using GitHub Desktop instead. - Click
Create
Once the project is created, navigate to your project editor (top line of Xcode left pane showing
your Product Name). Xcode will then show the General settings for your project in its middle
pane. In the Minimum Deployments section, using the drop-down selector, choose iOS 18 or later.
Next click the Signing & Capabilities tab (up top, next to the General tab). In the Signing
section. If you selected None for Team when creating your project above, you will need to
specify a Team. If you don’t yet have a Personal Team, please create one now (for free) using
your Apple ID. In the drop down menu next to Team select Add an Account... at the bottom of the
menu, sign in using your Apple ID and follow the prompts to create one. Finally confirm that your
Bundle identifier is edu.umich.<YOUR:UNIQNAME>.agent. Apple will complain if your Bundle
Identifier is not globally unique.
Check in to GitHub
Open GitHub Desktop and
- Click
Current Repositoryon the top left of the interface - Click on the assignment GitHub repo you cloned above
- Add Summary to your changes and click
Commit to mainat the bottom of the left pane - If you have a team mate and they have pushed changes to GitHub, you’ll have to click
Pull Originand resolve any conflicts, re-commit to main, and - Finally click on
Push Originto push changes to GitHub
If you are proficient with git, you don’t have to use GitHub Desktop. However, we can only help with GitHub Desktop, so if you use anything else, you’re on your own.
Go to the GitHub website to confirm that your folders follow this structure outline:
YOUR:TUTORIALS
|-- Agent
|-- Agent
|-- Agent.xcodeproj
If the folders in your GitHub repo does not have the above structure, we will not be able to grade your tutorials and you will get a ZERO.
Xcode project structure
The left or Navigator pane of your Xcode window should show your project files under agent
project (top-line), in a agent folder:
-
AgentApp: named after your project, this file tells iOS the entry point (@main) of your app. Only one data type (struct) can be so tagged. This struct describes theScenein which the window hierarchy of your app resides.WindowGroupis the window hierarchy for yourScene(we’ll discuss the keywordsomelater). Unlike on the iPads or Macs where an app can have multiple scenes, each iPhone app can have only one scene. -
ContentView: It will hold user exchanges with the LLM runner later.
We will later create additional Swift files. The agent has two main parts: the UI to communicate
with the user and the Rein to control and communicate with the harness and LLM. We put the main UI
in ContentView.swift but break out the chat conversation UI and put it in ChatView.swift. Code
to control and communication with the harness we’ll put in Rein.swift. Variables to hold user
compile-time choices and run-time input we’ll put in an AppViewModel class in AgentApp.swift.
UI Design
One can easily spend a whole weekend (or longer) getting the UI “just right.”
We won’t be grading you on how beautiful your UI looks. You’re free to design your UI
differently, so long as all indicated UI elements are functioning as specified and fully visible
and non-overlapping on the screen.
#Preview
The #Preview feature is used by Xcode only during development, to preview your View(s). If the
preview pane is not showing, you can toggle it by checking Canvas on the Adjust Editor Options
menu on the top right corner of your Xcode window
(screenshot). The preview only
renders your View, it is not a simulator: it won’t run non-UI related code. Given the simple,
single-page nature of our apps, I found the preview to be of limited use and rather slow and would
therefore just comment out the #Preview feature, which automatically disables preview and closes
the Canvas pane.
Agent app
AppViewModel
Let’s start by defining AppViewModel to hold user’s choices and input. In addition to user input,
we also store here variables accessed by multiple SwiftUI Views. Add the following AppViewModel
class to your AgentApp.swift file, after the import SwiftUI line:
@Observable
final class AppViewModel {
let rein = Rein(harness: "https://mada.eecs.umich.edu", api: "/llmprompt")
let model = "gemma3:270m"
var message = "howdy?"
let instruction = "Type a message…"
var errMsg = ""
var showError = false
var conversation: [Chat] = []
}
We have annotated the AppViewModel with the @Observable macro (part of the Observation
package) so that its mutable properties are observable. The mutable conversation array will be
used to hold prompt and completion exchanges between the user and LLM. When a SwiftUI View reads
the value of an observable variable (the subject), the View` is automatically subcribed to
the subject, i.e., it will be automatically recomputed and re-rendered whenever the value of the
variable changes.
Chat
We next define a Chat structure to hold the prompt and completion we want to display.
Create a new Swift file:
- Right click on the
Agentfolder (second line, not the project on the first line) on the left/navigator pane - Select
New Empty File... - Rename the file from
Untitled.swifttoChatView.swift
add the following Chat struct to the file:
import SwiftUI
@Observable
final class Chat: Identifiable {
let id: UUID = UUID() // view ID
@ObservationIgnored
var role: String
var content: String // for reactive UI to display streaming tokens
@ObservationIgnored
var timestamp: String
init(role: String = "user", content: String = "", timestamp: String = Date().ISO8601Format()) {
self.role = role
self.content = content
self.timestamp = timestamp
}
}
The Chat struct conforms to the Identifiable protocol, which simply means that it contains an
id property that SwiftUI can use to uniquely identify each instance in a list. When identifiable
items in a list moved up or down the list but otherwise not modified, SwiftUI can skip re-rendering
them. We use randomly generated UUID to identify each Chat.
Rein
We now define the rein to control and communicate with the harness. Create another new Swift file
and name it Rein.swift.
We represent the request JSON data format for OpenAI /v1/chat/completions API with an
OpenAIRequest structure and its response with an OpenAIResponse structure. Put the following in
your Rein.swift:
import SwiftUI
struct Message: Encodable {
let role: String
var content: String
init(role: String = "user", content: String = "") {
self.role = role
self.content = content
}
}
// OpenAI-compatible request/response JSON formats
struct OpenAIRequest: Encodable {
let model: String
//let max_tokens = 8192 // some models require it
let messages: [Message]
let stream = true // always streaming
}
struct OpenAIResponse: Decodable {
let model: String?
let choices: [Choice] // guaranteed only one completion choice
let created: Double?
struct Choice: Decodable {
let delta: Delta
struct Delta: Decodable {
let role: String?
let content: String?
}
}
}
enum SseEvent { case Error, Message }
Conformance to the Encodable protocol allows Swift Codable package to convert conforming Swift
data types, including nested ones, into JSON strings. Conversely, conformance to Decodable allows
JSON strings received from the network to be converted into (nested) Swift data types by the
Codable package.
We also force streaming of the completion tokens, which will be streamed using the Server-Sent Event
(SSE) protocol. Our agent recognizes two SSE events, as encoded in the SseEvent enum.
We now define the Rein structure:
struct Rein {
let harness: String
let api: String
var apiKey: String? // WARNING: vulnerable to memory dump and man-in-the-middle attacks
private static let Json = JSONDecoder()
// rein control
}
Rein control methods communicate with the back-end harness at the provided harness url and api
end point. You can use apiKey when connecting the agent directly to a LLM runner instead of
through our back-end harness. We allocate a static JSONDecoder() for reuse across entire SSE
stream, instead of having to instantiate one for each arriving token.
Connecting directly to an LLM runner
WARNING: NOT RECOMMENDED FOR SECURITY REASON
Third-party LLM runners, such as those from OpenAI, Anthropic, OpenRouter, etc., usually require an API key to use. Storing your API key on device is not recommended for security reasons:
- on mobile platforms, there is no equivalent to back-end
dotenvlibraries that load environment variables at run time. Instead such variables are usually stored in a local config file loaded to your app’s memory at build time. In-memory variables are vulnerable to memory dump. Mobile devices are considered less secure than hardened back-end servers because they are easier for an attacker to gain access, and - an attacker with access to your device can also mount a man-in-the-middle attack and spoof your server’s certificate to obtain your API key.
Instead, we’ve adopted the practice of keeping your API key in back-end server’s environment variables.
To send a prompt to the harness, the user calls the asynchronous function llmPrompt(_:_:completion:). Replace // rein control comment above with the following function definition:
func llmPrompt(_ vm: AppViewModel, _ messages: [Message], completion: Chat) async {
guard let harnessApi = URL(string: "\(harness)\(api)") else {
vm.errMsg = "llmPrompt: Bad harness URL \(harness)\(api)"
return
}
// prepare prompt
}
The errMsg property is a mutable variable in @Observable AppViewModel, which means that updating
its value will notify observers of the change. We’ll see later that updating vm.errMsg causes an
alert dialog box to pop up, to warn the user.
The harness in this tutorial serves as a straight-through proxy for the LLM runner. We can thus
send our prompt requests using OpenAI-compatible /v1/chat/completions API’s JSON data format:
{
"model": string,
"messages": [
{
"role": string,
"content": string
}
],
"stream": boolean
}
We first assemble an instance of Swift OpenAIRequest struct comprising the expected key-value pairs. We can’t just post the Swift struct as is though. The server may not be, and actually is not, written in Swift, and could have a different memory layout for various data structures. Presented with a chunk of binary data, the server will not know that the data represents a struct, nor how to reconstruct the struct in its memory layout. To post the Swift struct, we first call JSONEncoder.encode(_:) to serialize the struct into a JSON string. We then put this JSON string in a requestBody. Add the following code to your llmPrompt(_:_:completion:), replacing // prepare prompt:
let openAIRequest = OpenAIRequest(
model: vm.model,
messages: messages,
)
guard let requestBody = try? JSONEncoder().encode(openAIRequest) else {
vm.errMsg = "llmPrompt: JSON serialization error"
return
}
// prepare request
Next we create an HTTP POST request using URLRequest to carry the requestBody. Replace // prepare request with the following code:
var request = URLRequest(url: harnessApi)
request.timeoutInterval = 1200 // for 20 minutes
request.httpMethod = "POST"
request.setValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.setValue("text/event-stream", forHTTPHeaderField: "Accept")
if let apiKey {
// WARNING: vulnerable to memory dump and man-in-the-middle attacks
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
}
request.httpBody = requestBody
// connect to harness and send request
We initiate a connection to our harness back end and check that the connection has been made
successfully. If we fail to connect to our back end (the catch block) or the harness returned any
HTTP error, we simply report it to the user and end session, otherwise we collect the streamed
completion. Replace // connect to harness and send request with:
do {
let (bytes, response) = try await URLSession.shared.bytes(for: request)
if await isHarnessed(bytes, response, Bindable(vm).errMsg) {
try await collectCompletion(bytes, completion, Bindable(vm).errMsg)
}
} catch {
vm.errMsg = "llmPrompt: failed \(error)"
}
The errMsg property in the view model is passed to isHarnessed(_:_:_:) and
collectCompletion(_:_:_:) as a Bindable so that it can be modified by the called
functions—think of it like pass-by-reference (it’s not actually pass-by-reference, but you gain
the same capability to modify the variable).
The helper method isHarnessed(_:_:_:) checks for and reports any connection error. Put it inside your Rein structure:
func isHarnessed(_ bytes: URLSession.AsyncBytes, _ response: URLResponse, _ errMsg: Binding<String>) async -> Bool {
if let http = response as? HTTPURLResponse, http.statusCode != 200 {
var msg = ""
if let data = try? await bytes.reduce(into: Data(), { $0.append($1) }) {
msg = String(decoding: data, as: UTF8.self)
}
errMsg.wrappedValue = "\(http.statusCode)\n\(harness)\(api)\n\(msg.isEmpty ? HTTPURLResponse.localizedString(forStatusCode: http.statusCode) : msg)"
return false
}
return true
}
We now parse the incoming SSE stream to collect the returning completion. Add the following
collectCompletion(_:_:_:) helper method to your Rein structure:
func collectCompletion(_ bytes: URLSession.AsyncBytes, _ completion: Chat, _ errMsg: Binding<String>) async throws {
var sseEvent = SseEvent.Message
// AsyncSequence.lines skips empty lines,
// must build lines from .characters instead:
// https://developer.apple.com/forums/thread/725162
var line = ""
for try await char in bytes.characters {
if char != "\n" && char != "\r\n" { // Python eol is "\r\n"
line.append(char)
continue
}
if line.isEmpty {
// SSE events are delimited by "\n\n"
// new SSE event, default to Message
if (sseEvent == .Error) {
completion.content.append("\n\n**llmPrompt SSE Error**: \(errMsg.wrappedValue)\n\n")
}
sseEvent = .Message
continue
}
// parse SSE line
line = ""
}
}
The URLSession.shared.bytes(for:) API returns an AsyncSequence. Unfortunately, when looping
through an AsyncSequence line by line, the Swift API automatically skips empty
lines, whereas the SSE specification uses an
empty line (two consecutive newlines "\n\n") to indicate the end of an event block. We thus
re-construct lines from bytes.characters on our own.
When an empty line is detected, if we are in an Error event block, we report the error on the
conversation (which pops up an alert dialog box in the UI). Then we reset the event to the default
Message event.
If the next line starts with the text event, we’re starting a new named event block, otherwise,
it’s a data line and we handle (save) it depending on the event it’s associated with. By the SSE
spec, when unspecified, Message is the default event name.
Each SSE line is a string, no quotation marks are used to further indicate that it is a string.
OpenAI’s /v1/chat/completions API further requires that either [DONE] or a serialized JSON
object follows a data: tag. Replace the comment // parse SSE line above with:
// parse SSE line
let parts = line.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false)
if parts.count != 2 {
// should not happen, debug print
print("LLMPROMPT: malformed SSE line: \(line)")
continue
}
let tag = parts[0]
let tagline = parts[1].trimmingCharacters(in: .whitespaces)
if tag == "event" {
if tagline == "error" {
sseEvent = .Error
} else if !tagline.isEmpty && tagline != "message" {
// we only support "error" event, "message"
// events are assume implicit by the SSE spec.
// should not happen, debug print
print("LLMPROMPT: unknown SSE event: \(parts[1])")
}
} else if tag == "data" {
// not an event line, we only support data line;
if tagline.isEmpty || tagline == "[DONE]" {
// OpenAI's `/v1/chat/completions` uses "data: [DONE]"
// to indicate end of stream
continue
}
// multiple data lines can belong to the same event
do {
// OpenAI's `/v1/chat/completions` API requires that
// a serialized JSON object follows a `data:` tag.
let openAIResponse = try Self.Json.decode(OpenAIResponse.self, from: Data(tagline.utf8))
// extract content from choices[0].delta.content.
// we specified no alternative completion earlier
// with OpenAIRequest.n = 1
if let token = openAIResponse.choices.first?.delta.content,
!token.isEmpty {
if sseEvent == .Error {
errMsg.wrappedValue += token
} else {
completion.content.append(token)
if completion.timestamp.isEmpty {
if let model = openAIResponse.model {
completion.role = "assistant (\(model))"
}
let created = openAIResponse.created ?? Date().timeIntervalSince1970
completion.timestamp = Date(timeIntervalSince1970: created).ISO8601Format()
}
}
}
} catch {
errMsg.wrappedValue += "\(error)\n\(harness)\(api)\n\(tagline.utf8)"
}
}
We are done with the Rein our agent uses to control and communicate with the harness and LLM. We
now turn to the UI user uses to interact with the agent.
AgentApp.swift
Returning to AgentApp.swift file, in addition to user’s choices and input, AppViewModel also
holds variables accessed by multiple SwiftUI Views. Instead of passing these variables back and
forth between SwiftUI Views, we hoist AppViewModel onto the SwiftUI environment. A View that needs
access to these variables can easily reach for the view model in the environment.
Replace your AgentApp structure definition with the following:
@main
struct AgentApp: App {
let vm = AppViewModel()
var body: some Scene {
WindowGroup {
NavigationStack {
ContentView()
.environment(vm)
.onAppear {
// Super slow/laggy keyboard when clicking TextField
// only when connected to Xcode: https://stackoverflow.com/a/27487885/
// workaround: https://stackoverflow.com/a/79528841/
let scenes = UIApplication.shared.connectedScenes
let windowScene = scenes.first as? UIWindowScene
// Preloads keyboard so there's no lag on initial keyboard appearance
if let wnd = windowScene?.windows.first {
let lagFreeField = UITextField()
wnd.addSubview(lagFreeField)
lagFreeField.becomeFirstResponder()
lagFreeField.resignFirstResponder()
lagFreeField.removeFromSuperview()
}
}
}
}
}
}
We first instantiate the AppViewModel, then put it in the SwiftUI environment with
.environment(vm). We wrap the call to ContentView() in a NavigationStack() to get a navigation
bar in our ContentView. The .onAppear{ /*...*/ } block we put on ContentView() is for
debugging purposes only. On some versions of Xcode, the soft keyboard is very laggy when the app
is run in debugging mode, tethered to Xcode; this .onAppear{} block shakes the keyboard out of its
lethargy, a bit.
Prop drilling vs. State hoisting
The app will have one instance of AppViewModel that almost every View would need to access. We
could pass AppViewModel to every View, their child-Views, and so on down the hierarchy of the
View tree. In React this is called “prop drilling” as the HTML properties needed to
render the UI are passed down and down to the bottom of the UI hierarchy, even if some intermediate
components do not need access to these properties.
Alternatively, we can “hoist” the needed state to the top of the UI sub-tree (which may
be the root of the tree in the limit) and have each UI component needing the state search up its UI
sub-tree until it finds the state. The state is said to be “provided” to the sub-tree.
The Provider usually maintains a look-up table of available states, identifiable by the type of
the state. When the same data type is provided at different levels of the UI-tree, the one lowest in
the hierarchy above the component searching for the state will match.
The states or values of environment objects are scoped to the sub-tree where the data is provided. The advantage of using an environment object is that we don’t have to pass/drill it down a sub-tree yet Views in the sub-tree can subscribe and react to changes in the object.
In SwiftUI, data hoisted and made available to a View sub-tree is called an environment object.
Views within that sub-tree can subscribe to the environment object and be notified of changes.
ConversationView
We want to display user exchanges with the LLM runner in a conversation view. First we define what
each row of the conversation contains. Go back to your ChatView.swift file and add the following
struct:
struct ChatView: View {
let chat: Chat
let onTrailingEnd: Bool
var body: some View {
VStack(alignment: onTrailingEnd ? .trailing : .leading, spacing: 4) {
// chat displayed here
}
.padding(.horizontal, 16)
}
}
For each chat, we check whether we’re displaying the user’s message or a response from the LLM. In
the former case, we display the row flushed to the trailing/end edge of the screen, otherwise we
display it flushed to the other edge.
If your locale has a language that reads left to right, leading is left; otherwise, leading is right (conversely, trailing). Most of the time you would use leading and trailing to refer to the two ends of a UI element, reserving left and right for use with the physical world, e.g., when giving direction.
Below we check if the message is empty. If it’s not empty, we first display the model used if it’s
from the “assistant” (LLM). Then we display the message in a “message bubble,” followed
by the timestamp on the message. We put these three elements inside a VStack which arranges its
elements in a vertical stack (a column). Add the following lines inside your VStack{} block,
replacing // chat displayed here:
let msg = chat.content
if !msg.isEmpty {
Text(onTrailingEnd ? "" : chat.role)
.font(.subheadline)
.foregroundColor(.purple)
.padding(.leading, 4)
Text(msg)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(onTrailingEnd ? .systemBlue : .systemBackground))
.foregroundColor(onTrailingEnd ? .white: .primary)
.cornerRadius(20)
.shadow(radius: 2)
.frame(maxWidth: 300, alignment: onTrailingEnd ? .trailing : .leading)
Text(chat.timestamp)
.font(.caption2)
.foregroundColor(.gray)
Spacer()
.frame(maxWidth: .infinity)
}
A Spacer() in a VStack that spans the full width of the screen forces the VStack to use the
full width.
When a struct conforms to the View protocol, as ChatView does, it is required to have a
property called body of type some View. The property body is where you describe your View:
which UI elements are included, how they relate to each other positionally, e.g., one above the
other? or side by side? The keyword some here means that the actual type will be determined at
compile time, depending on actual usage, and it can be any type that conforms to View. (We’ll say
more about them when discussing opaque types.)
You can option-click (⌥-click) on a View (e.g., VStack, Text, or NavigationStack) to bring
up a menu of possible actions. The Show SwiftUI Inspector menu item allows you to visually set the
paddings, for example. The inspector is also accessible directly by ctl-option-click (⌃⌥-click),
bypassing the menu.
DSL
Notice how type inference and the use of trailing closure makes HStack, VStack,
NavigationStack, etc. look and act like keywords of a programming language, separate from Swift.
Thus SwiftUI is considered a “domain-specific language (DSL),” the “domain”
in this case being UI description.
Now that we have a description of each row, we can put the rows in a list. Put the
the following View in your ChatView.swift file, outside the ChatView struct:
struct ConversationView: View {
@Environment(AppViewModel.self) private var vm
var body: some View {
ScrollView {
LazyVStack {
ForEach(vm.conversation) {
ChatView(chat: $0, onTrailingEnd: $0.role == "user")
}
}
}
.defaultScrollAnchor(.bottom, for: .initialOffset)
.defaultScrollAnchor(.bottom, for: .sizeChanges)
}
}
ForEach element in the conversation array, ChatView constructs and returns a View, which
LazyVStack then displays. LazyVStack only loads array elements that are visible on screen.
Recall that we have previously tagged AppViewModel an @Observable. When a View accesses
conversation, SwiftUI automatically subscribes the View to the conversation property so that the
View can be automatically recomputed and re-rendered whenever conversation is modified.
ConversationView helps ChatView determine which edge of the screen to display a chat by
comparing the role property of chat.message against the string "user". The modifier
.defaultScrollAnchor(.bottom) of ScrollView automatically scrolls the display to the bottom of
ConversationView (though it hasn’t been working consistently for me!).
SubmitButton
SubmitButton sends each user’s prompt to the back end and receives the LLM’s completion. In your
ContentView.swift file, put the following code below import SwiftUI:
struct SubmitButton: View {
@Environment(AppViewModel.self) private var vm
@State private var isSending = false
var body: some View {
Button {
isSending = true
let chat = Chat(content: vm.message)
vm.conversation.append(chat)
// pass chat to harness in an array of `messages`
// but Swift encoder cannot serialize @Observable hidden
// variable correctly (_content in this case) so must
// manually convert the hidden Chat._content to Message.content
let messages = [Message(role: chat.role, content: chat.content)]
// prepare completion placeholder
let completion = Chat(role: "assistant (\(vm.model))",
content: "", timestamp: "") // placeholder for assistant's streaming completion
vm.conversation.append(completion)
Task (priority: .background){
await vm.rein.llmPrompt(vm, messages, completion: completion)
// continuation code
}
} label: {
// icons
}
// modifiers
}
}
SubmitButton first obtains AppViewModel from SwiftUI’s environment. When the button is clicked,
we set isSending to true and add the user prompt to conversation array. We want each arriving
stream element to be displayed right away. To hold the response stream, we create a placeholder
completion: Chat and append it also to the conversation array. Then we call
llmPrompt(_:_:completion:), passing it the user prompt. SwiftUI will later reactively display the
updated conversation array. The function llmPrompt(_:_:completion:) expects the user prompt to
be put in an array of messages. In calling llmPrompt(_:_:completion:), we also specify that the
asynchronous function can be run with a lower, background priority if there are other more urgent
task to be scheduled, such as user interaction.
Upon returning from llmPrompt(_:_:completion:), we reset vm.message and isSending and check
whether any error has been reported, and set vm.showError accordingly. Add the following code
inside the Task {} block, replacing the comment // continuation code:
// cleanup
vm.message = ""
isSending = false
vm.showError = !vm.errMsg.isEmpty
For the button’s label, we provide two icons: one to show a “loading” view if
we’re waiting for the LLM runner’s response (isSending is true) and one to show
a “paperplane” submit icon otherwise. Add the following code inside the
label:{} block, replacing the comment // icons:
if isSending {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .secondary))
.padding(10)
} else {
Image(systemName: "paperplane.fill")
.foregroundColor(vm.message.isEmpty ? .gray : .yellow)
.padding(10)
}
We also disable the button if isSending is true or if there’s no message to send.
Add the following modifiers to Button by replacing the comment // modifiers:
.disabled(isSending || vm.message.isEmpty)
.background(Color(isSending || vm.message.isEmpty ? .secondarySystemBackground : .systemBlue))
.clipShape(Circle())
.padding(.trailing)
ContentView
We now have all the pieces we need to build our ContentView. Assuming you have commented out
or deleted #Preview as described earlier, replace your struct ContentView definition with:
struct ContentView: View {
@FocusState private var messageInFocus: Bool // tap background to dismiss kbd
@Environment(AppViewModel.self) private var vm
var body: some View {
VStack {
ConversationView()
// prompt input and submit
}
// tap background to dismiss kbd
.navigationTitle("llmPrompt")
.navigationBarTitleDisplayMode(.inline)
// show error in an alert dialog
}
}
ContentView puts the ConversationView at the top of its column (VStack). We also give our
ContentView the title llmPrompt in the navigation bar at the top of the screen.
Below ConversationView, we now put a text box, where user can enter their LLM prompt, and the
SubmitButton. We put these text box and button inside an HStack (horizontal stack or row).
Elements in an HStack are displayed side by side in a row. Replace // prompt input and submit
with:
HStack (alignment: .bottom) {
TextField(vm.instruction, text: Bindable(vm).message)
.focused($messageInFocus) // to dismiss keyboard
.textFieldStyle(.roundedBorder)
.cornerRadius(20)
.shadow(radius: 2)
.background(Color(.clear))
.border(Color(.clear))
SubmitButton()
}
.padding(EdgeInsets(top: 0, leading: 20, bottom: 8, trailing: 0))
Similar to how we passed vm.errMsg to collectCompletion(_:_:_:) as a Bindable, now we pass
vm.message to TextField as a Bindable so that when user types into the TextField,
TextField can modify vm.message as if it were passed by reference. We also give vm.instruction
to TextField(), which will be shown as a “background” text that automatically goes
away when the user starts typing.
When the user taps anywhere on the screen other than the TextField, we dismiss the soft keyboard.
Replace // tap background to dismiss keyboard near the bottom of the definition of ContentView
with:
.contentShape(.rect)
.onTapGesture {
messageInFocus.toggle()
}
Before we leave ContentView, we check whether vm.showError is true. If so, we show an alert
dialog with the error message in vm.errMsg. Replace // show error in an alert dialog with:
.alert("LLM Error", isPresented: Bindable(vm).showError) {
Button("OK") {
vm.errMsg = ""
}
} message: {
Text(vm.errMsg)
}
Run and test to verify and debug
You should now be able to run your front end against the provided back end on mada.eecs.umich.edu.
If you’re not familiar with how to run and test your code, please review the instructions in the Getting Started with iOS Development.
Congratulations! You’re done with the front end! (Don’t forget to work on the back end!)
Completing the back end
Once you’re satisfied that your front end is working correctly, follow the back-end spec to build your own back end:
With your back end completed, return here to prepare your front end to connect to your back end via HTTP/2 with HTTPS.
Installing your self-signed certificate
Download a copy of your harnessd.crt to /YOUR:TUTORIALS/ folder on your laptop. Enter the following
commands:
laptop$ cd /YOUR:TUTORIALS/
laptop$ scp -i agentic.pem ubuntu@YOUR_SERVER_IP:agentic/harnessd.crt harnessd.crt
Install your harnessd.crt onto your iOS:
On iOS simulator
Drag harnessd.crt on your laptop and drop it on the home screen of your simulator. That’s it!
To test the installation, launch a web browser on the simulator and access your server
at https://YOUR_SERVER_IP/ (you’d have to enable your / API to do this test).
On iOS device
AirDrop harnessd.crt to your iPhone or email it to yourself.
Then on your device:
WARNING: DO ALL 10 STEPS: IT IS A COMMON ERROR TO MISS THE LAST THREE STEPS!
- If you AirDrop your
harnessd.crt, skip to next step. If you emailed the certificate to yourself, view your email and tap the attachedharnessd.crt.If you don’t using Apple’s Mail app on your iPhone, you may have to “share” the cert and choose
Save to Files, then launch theFilesapp on your phone and in theDownloadsfolder locate yourharnessd.crtand tap it. - You should see a
Profile Downloadeddialog box pops up. - Go to
Settings > General > VPN & Device Managementand tap on the profile withYOUR_SERVER_IP. - At the upper right corner of the screen, tap
Install. - Enter your passcode.
- Tap
Installat the upper right corner of the screen again. - And tap the somewhat dimmed out
Installbutton. - Tap
Doneon the upper right corner of screen. -
Go back to Settings > General -
Go to [Settings > General >] About > Certificate Trust Settings -
Bravely slide the toggle button next to YOUR_SERVER_IPto enable full trust of your CA’s certificate and clickContinueon the dialog box that pops up
To test the installation, launch a web browser on your device and access your server at
https://YOUR_SERVER_IP/ (you’d have to enable your / API to do this test).
You can retrace your steps to remove the certificate when you don’t need it anymore.
If you run into problem using HTTPS on your device, the error code displayed by Xcode may help you debug. This post has a list of them near the end of the thread.
Finally, change the harness property of your Rein instantiation in AgentApp.swift from
https://mada.eecs.umich.edu to https://YOUR_SERVER_IP. Build and run your app and you should now
be able to connect your mobile front end to your harness via HTTPS. Your front end must work with
both mada.eecs.umich.edu and your own harness.
You will not get full credit if your submitted front end is not set up to work with your harness!
Front-end submission guidelines
We will only grade files committed to the main branch. If you’ve created multiple branches, please
merge them all to the main branch for submission.
Push your front-end code to the same GitHub repo you’ve submitted your back-end code:
- Open GitHub Desktop and click
Current Repositoryon the top left of the interface - Click on the GitHub repo you created at the start of this tutorial
- Add Summary to your changes and click
Commit to mainat the bottom of the left pane - Since you have pushed your back end code, you’ll have to click
Pull Originto synch up the repo on your laptop - Finally click
Push Originto push all changes to GitHub
Go to the GitHub website to confirm that your front-end files have been uploaded to
your GitHub repo under the folder agent. Confirm that your repo has a folder structure outline
similar to the following. If your folder structure is not as outlined, our script will not pick up
your submission, you will get ZERO point, and you may have problems getting started on latter
tutorials. There could be other files or folders in your local folder not listed below, don’t delete
them. As long as you have installed the course .gitignore as per the instructions in Preparing
GitHub for Reactive Tutorials and
Projects, only files needed for
grading will be pushed to GitHub.
YOUR:TUTORIALS
|-- harnessd
|-- harnessd.crt
|-- Agent
|-- Agent
|-- Agent.xcodeproj
Verify that your Git repo is set up correctly: on your laptop, grab a new clone of your repo and build and run your submission to make sure that it works. You will get ZERO point if your tutorial doesn’t open, build, or run.
IMPORTANT: If you work in a team, put the names and uniqnames of all members in your repo’s
README.md so that we’d know (click the pencil icon at the upper right corner of the README.md
box on your git repo to edit). Otherwise, we could mistakenly think that you were cheating and
accidentally report you to the Honor Council, which would be a hassle to undo. You don’t need a
README.md if you work by yourself.
Invite eecsreactive@umich.edu to your GitHub repo. Enter your uniqname (and that of your team
mate’s) and the link to your GitHub repo on the Tutorial and Project Links
sheet.
The request for teaming information is redundant by design.
References
General iOS and Swift
Getting Started with SwiftUI
- Quick guide on SwiftUI essentials
- A guide to the SwiftUI layout system - Part 1
- How to effectively leverage the power of new #Preview feature in SwiftUI
SwiftUI at WWDC
- Introducing SwiftUI: Building Your First App
- Introduction to SwiftUI
- WWDC20: Advancements in SwiftUI
- SwiftUI Essentials
- App Essentials in SwiftUI
- Data Flow Through SwiftUI
- Data Essentials in SwiftUI
- Stacks, Grids, and Outlines in SwiftUI
- Integrating SwiftUI
SwiftUI Programming
- The New Navigation System in SwiftUI
- Custom navigation bar title view in SwiftUI
- How to add button to navigation bar in SwiftUI
- The future of SwiftUI navigation (?)
- How to create views in a loop using ForEach
- How to convert
UIColorto SwiftUI’sColor
State Management
- Singleton
- State and Data Flow
- The @State Property Wrapper in SwiftUI Explained
- Discover Observation in SwiftUI
- A Deep Dive into Observation
- Working with @Binding in SwiftUI
- Stanger things around SwiftUI’s state
- The Inner Workings of State Properties in SwiftUI
- Observer vs Pub-Sub pattern
- Observation
- ObservationIgnored
- EnvironmentValues
- View.environment(::)
- SwiftUI View Lifecycle
- View modifiers
- Great SwiftUI see the section “Prefer No Effect Modifiers over Conditional Views”
Toolbar and keyboard
- How to create a toolbar and add buttons to it
- How to dismiss the keyboard for a TextField
-
How to control the tappable area of a view using contentShape()
-
Disabling user interactivity with allowsHitTesting() discusses
contentShape()near the end of article.
-
Disabling user interactivity with allowsHitTesting() discusses
- SwiftUI Alert: Best Practices and Examples
Async/await
Networking
Working with JSON
- Swift Tip: String to Data and Back
- Convert array to JSON in swift
- How can I define Content-type in Swift using NSURLSession
- How to parse JSON using Coding Keys in iOS
SSE
| Prepared by Ollie Elmgren, Tiberiu Vilcu, Nowrin Mohamed, Xin Jie ‘Joyce’ Liu, Chenglin Li, and Sugih Jamin | Last updated: June 23rd, 2026 |