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.

  1. Click Create a new Xcode project in “Welcome to Xcode” screen (screenshot)
  2. Select iOS > App and click Next (screenshot, be careful that you select iOS and not macOS)
  3. Enter Product Name: Agent
  4. Team: None

    if you don’t have one yet, otherwise choose your Personal Team

  5. Organization Identifier: edu.umich.<YOUR:UNIQNAME> 👈👈👈

    replace <YOUR:UNIQNAME> with yours, remove the angle brackets,< >

  6. Interface: SwiftUI
  7. Language: Swift
  8. Leave the other fields as None and all boxes unchecked, click Next
  9. On the file dialog box that pops up, put your Agent folder in 👉👉👉 /YOUR:TUTORIALS, where YOUR:TUTORIALS is the name you’ve given your assignment GitHub repo clone in Preparing GitHub for Reactive Tutorials and Projects (agentic in the example below).
  10. Leave Create Git repository on my Mac UNCHECKED (screenshot). We will add the files to GitHub using GitHub Desktop instead.
  11. 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

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.

:point_right: 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:

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.”

:point_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:

  1. Right click on the Agent folder (second line, not the project on the first line) on the left/navigator pane
  2. Select New Empty File...
  3. Rename the file from Untitled.swift to ChatView.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:

  1. on mobile platforms, there is no equivalent to back-end dotenv libraries 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
  2. 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!

  1. If you AirDrop your harnessd.crt, skip to next step. If you emailed the certificate to yourself, view your email and tap the attached harnessd.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 the Files app on your phone and in the Downloads folder locate your harnessd.crt and tap it.

  2. You should see a Profile Downloaded dialog box pops up.
  3. Go to Settings > General > VPN & Device Management and tap on the profile with YOUR_SERVER_IP.
  4. At the upper right corner of the screen, tap Install.
  5. Enter your passcode.
  6. Tap Install at the upper right corner of the screen again.
  7. And tap the somewhat dimmed out Install button.
  8. Tap Done on the upper right corner of screen.
  9. :point_right:Go back to Settings > General
  10. :point_right:Go to [Settings > General >] About > Certificate Trust Settings
  11. :point_right:Bravely slide the toggle button next to YOUR_SERVER_IP to enable full trust of your CA’s certificate and click Continue on 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.

:point_right: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:

:point_right: 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

SwiftUI at WWDC

SwiftUI Programming

State Management

Toolbar and keyboard

Async/await

Networking

Working with JSON

SSE


Prepared by Ollie Elmgren, Tiberiu Vilcu, Nowrin Mohamed, Xin Jie ‘Joyce’ Liu, Chenglin Li, and Sugih Jamin Last updated: June 23rd, 2026