Advanced Teacher Demo

Build a basic iPhone app that generates a lesson plan with ChatGPT

You will build a minimal SwiftUI app that takes (1) grade band, (2) topic, (3) minutes, and (4) standards (optional), then calls the OpenAI API and returns a teacher-ready lesson plan you can display in the iOS Simulator.

OpenAI Responses API docs
You will see two architectures: (A) Direct API call (demo-only to prove the concept quickly), then (B) Proxy (the correct real-world pattern). If you distribute an iOS app beyond your own Simulator, do not ship an API key inside the app.

Use case: “Lesson Plan Builder” (teacher-facing)

This is a single-screen, teacher-facing utility: collect a few inputs, generate a structured plan, and display it for copy/paste. The goal is a reliable, friendly “input → API → output” loop.

What the app does

  • Collects: grade band, topic, duration, standards (optional), constraints (optional)
  • Sends one request to an AI model
  • Receives a lesson plan and displays it (selectable text)
  • Shows loading + errors (instead of crashing)
Teaching point: You can demonstrate product value and core architecture before adding features.

What it does not do (by design)

  • No login / accounts
  • No database / cloud storage
  • No student data ingestion
  • No background tasks / push notifications
  • No App Store distribution in this workshop deliverable
Boundary: This workshop app is intentionally basic. Complexity kills delivery speed and increases failure points.

Prerequisites and costs (what you must have before the workshop starts)

This section exists to prevent setup derailment. If you do not have these items ready, the build steps will fail. Costs are called out explicitly so participants understand what is free vs. paid.

Required (and what it costs)

  • Mac computer capable of running a current version of Xcode
  • Xcode installed from the Mac App Store
    • Cost: Xcode itself is free; you still need Apple hardware to run it.
  • Apple ID
    • Cost: free (sufficient for Simulator-only testing).
  • OpenAI API access + API key
    • Cost: API usage is billed; you need a funded project / billing enabled to reliably run calls.
  • Internet access (this app calls a hosted API)
Reality check: You do not need App Store Connect just to run in the iOS Simulator.

Strongly recommended (avoids predictable failures)

  • Funded OpenAI API project
    • Why: workshop demos fail most often due to quota / billing / permissions issues, not code.
  • Developer Program membership (only if you want TestFlight or device installs)
    • Cost: paid Apple Developer Program membership is required for TestFlight distribution.
    • Not required for Simulator-only workshop completion.
  • A second monitor (Xcode + Simulator visible at once)
  • Git and a GitHub account (optional, but useful)
Billing / quota reality:

If the API returns quota or billing errors, your app can be correct and still fail. Fix account funding first.

Security prerequisite (non-negotiable):

If you plan to distribute this app beyond your own Simulator, do not embed an API key in the app. You must use the Proxy option (Cloudflare Worker or a server) so the key stays off-device.

Recommended flow (what this page is actually for)

The point is not to ship a polished product in one sitting. The point is: get a working baseline fast, then iterate deliberately. This mirrors real app-building: baseline → improve UX → secure architecture → test → distribute.

Phase 1: Baseline (10–30 minutes)

  • Create the Xcode project
  • Copy/paste the provided Swift files
  • Run successfully in Simulator
  • Confirm the UI loop works end-to-end
Goal: Everyone sees a working app quickly. Momentum matters.

Phase 2: Improve UX and product behavior (after baseline)

  • Add an app entry experience (title, guidance, defaults)
  • Add “Clear” and “Copy output” actions
  • Add input validation and better error messages
  • Refine prompt/output structure for consistency
How to iterate:

After the baseline is running, use ChatGPT to propose incremental enhancements (new files, code edits), then test each change in Simulator before moving on.

Phase 3 (required for real distribution):

Switch from Direct API calls to a Proxy. Only then should you consider TestFlight distribution.

Build the iPhone app (step-by-step, zero guessing)

Follow in order. The Copy Blocks section is aligned 1:1 with these steps. If you skip wiring steps (xcconfig → target config → Info keys), you will get “Missing OPENAI_API_KEY” or 401 errors.

Create a new Xcode project

Build the simplest SwiftUI project possible.

  • Open XcodeFileNewProject…
  • Select: iOSAppNext
  • Product Name: LessonPlanBuilder
  • Interface: SwiftUI
  • Language: Swift
  • Identifier: pick any valid reverse-DNS bundle ID (e.g., com.example.lessonplanbuilder)
  • Save it somewhere you can find again

Pick your architecture (Direct for demo vs. Proxy for real use)

Both are included because workshops need fast success and real products need secure design.

  • Direct (demo-only): Put the key into a local Secrets.xcconfig and call OpenAI from the app.
  • Proxy (recommended): Put the key on a server/worker and call your proxy from the app. The phone has no key.
Practical teaching approach:

Teach Direct first to prove the concept in Simulator, then immediately show Proxy as the pattern you would actually ship.

Add the Swift files

You will add two new Swift files and replace ContentView.

  • In Xcode Project Navigator: right-click the app folder → New File…Swift File
  • Create: OpenAIClient.swift
  • Create: OpenAIModels.swift
  • Open ContentView.swift and replace its contents with the provided version

Direct path: create Secrets.xcconfig (local only)

This is the fastest way to get a successful Simulator demo.

  • In Finder (project folder): create Secrets.xcconfig
  • Drag Secrets.xcconfig into Xcode Project Navigator (ensure “Copy items if needed” is checked if prompted)
  • In Xcode: select the project (blue icon) → Info tab → Configurations
  • Set Debug to use Secrets.xcconfig (set Release too if you want, but Debug is enough for Simulator)
Do not leak secrets:

Do not commit Secrets.xcconfig to Git. Add it to .gitignore.

Direct path: map build settings into runtime (Info keys)

The app reads config at runtime via Bundle.main. That requires Info keys.

  • Select your TargetInfo tab
  • Add two custom rows:
  • OPENAI_API_KEY (String) = $(OPENAI_API_KEY)
  • OPENAI_MODEL (String) = $(OPENAI_MODEL)
If you skip this step:

The app will compile but fail at runtime with “Missing OPENAI_API_KEY”.

Run in iOS Simulator (workshop deliverable)

This is the baseline success condition.

  • Select a Simulator device (e.g., iPhone 15)
  • Click Run (▶)
  • Fill inputs and tap Generate lesson plan
  • Confirm you see output text, and errors do not crash the app

After baseline: iterate (UX + product) and then migrate to Proxy

Use ChatGPT as a development partner once the base works.

  • Add an entry page or onboarding text to set expectations
  • Add “Copy output” and “Clear” actions
  • Add improved validation and better error states
  • Switch to Proxy before any real distribution
  • Only then consider TestFlight
Recommended workflow: Small change → run in Simulator → verify → repeat.

Copy and paste blocks (SwiftUI + OpenAI)

Copy each block into the file named in its label. These blocks are intentionally minimal to reduce workshop risk. This uses the OpenAI Responses endpoint (POST https://api.openai.com/v1/responses).

Project files (what you will have in Xcode)

Folder layout Xcode files
LessonPlanBuilder (Xcode project)
  LessonPlanBuilderApp.swift
  ContentView.swift (replace)
  OpenAIClient.swift (new)
  OpenAIModels.swift (new)
  Secrets.xcconfig (Direct demo path; local only)
Tip:

The most common workshop failure is not adding the new Swift files to the correct target. When you create the files in Xcode, keep the default target checked.

Secrets.xcconfig (Direct demo path, local only)

Put your real API key here. Do not commit this file.

File: Secrets.xcconfig Local secret
// DO NOT COMMIT THIS FILE.
// Add it to .gitignore.
// Direct-call demo only. For real apps, use a server/proxy.

OPENAI_API_KEY = sk-PASTE_YOUR_KEY_HERE

// Set a model string you have access to in your OpenAI project.
// Keep it in config so you can change later without code edits.
OPENAI_MODEL = gpt-5.2
Xcode wiring (required):

After adding Secrets.xcconfig to the project, go to Project → Info → Configurations and set Debug to use Secrets.xcconfig. Then add the Info keys shown below so runtime code can read the values.

OpenAIModels.swift

File: OpenAIModels.swift Codable models
import Foundation

// MARK: - Responses API request (minimal)
// POST https://api.openai.com/v1/responses
struct OpenAIResponsesRequest: Codable {
    let model: String
    let instructions: String?
    let input: String
    let max_output_tokens: Int?

    init(model: String, instructions: String? = nil, input: String, maxOutputTokens: Int? = 900) {
        self.model = model
        self.instructions = instructions
        self.input = input
        self.max_output_tokens = maxOutputTokens
    }
}

// MARK: - Responses API response (minimal parse)
// Responses API returns "output": [ ...items... ]
// One common item shape contains:
// { "type": "message", "content": [ { "type":"output_text", "text":"..." } ] }
struct OpenAIResponsesResponse: Codable {
    let id: String?
    let output: [OpenAIOutputItem]?
}

struct OpenAIOutputItem: Codable {
    let type: String?
    let content: [OpenAIContentItem]?
}

struct OpenAIContentItem: Codable {
    let type: String?
    let text: String?
}

OpenAIClient.swift

File: OpenAIClient.swift URLSession Responses API
import Foundation

enum OpenAIClientError: Error, LocalizedError {
    case missingConfig(String)
    case badURL
    case http(Int, String)
    case decode(String)

    var errorDescription: String? {
        switch self {
        case .missingConfig(let s): return s
        case .badURL: return "Bad URL."
        case .http(let code, let body): return "HTTP \(code): \(body)"
        case .decode(let s): return "Decode error: \(s)"
        }
    }
}

final class OpenAIClient {
    // Direct-call demo endpoint
    private let endpoint = URL(string: "https://api.openai.com/v1/responses")

    // Read values from Info.plist (which we map from Secrets.xcconfig)
    private var apiKey: String {
        (Bundle.main.object(forInfoDictionaryKey: "OPENAI_API_KEY") as? String)?
            .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
    }

    private var model: String {
        (Bundle.main.object(forInfoDictionaryKey: "OPENAI_MODEL") as? String)?
            .trimmingCharacters(in: .whitespacesAndNewlines) ?? "gpt-5.2"
    }

    // IMPORTANT:
    // For Secrets.xcconfig values to be visible at runtime, you must map them into the target’s Info keys:
    // Target → Info tab:
    // OPENAI_API_KEY = $(OPENAI_API_KEY)
    // OPENAI_MODEL   = $(OPENAI_MODEL)

    func generateLessonPlan(grade: String, topic: String, minutes: Int, standards: String, constraints: String) async throws -> String {
        guard let url = endpoint else { throw OpenAIClientError.badURL }
        if apiKey.isEmpty {
            throw OpenAIClientError.missingConfig("Missing OPENAI_API_KEY. Set it in Secrets.xcconfig and map it into Target → Info.")
        }
        if model.isEmpty {
            throw OpenAIClientError.missingConfig("Missing OPENAI_MODEL. Set it in Secrets.xcconfig and map it into Target → Info.")
        }

        let sys = """
You are an expert instructional designer. Produce a practical lesson plan that a teacher can run immediately.
Output must be plain text with headings. No markdown code fences. No references to policies.
"""

        let input = buildPrompt(grade: grade, topic: topic, minutes: minutes, standards: standards, constraints: constraints)

        let reqBody = OpenAIResponsesRequest(
            model: model,
            instructions: sys,
            input: input,
            maxOutputTokens: 900
        )

        var req = URLRequest(url: url)
        req.httpMethod = "POST"
        req.setValue("application/json", forHTTPHeaderField: "Content-Type")
        req.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
        req.httpBody = try JSONEncoder().encode(reqBody)

        let (data, resp) = try await URLSession.shared.data(for: req)
        guard let http = resp as? HTTPURLResponse else {
            throw OpenAIClientError.http(-1, "No HTTP response.")
        }

        if !(200...299).contains(http.statusCode) {
            let body = String(data: data, encoding: .utf8) ?? ""
            throw OpenAIClientError.http(http.statusCode, String(body.prefix(1600)))
        }

        do {
            let decoded = try JSONDecoder().decode(OpenAIResponsesResponse.self, from: data)
            let text = extractOutputText(decoded)
            if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
                return "No text returned. Check model access and response format."
            }
            return text
        } catch {
            let raw = String(data: data, encoding: .utf8) ?? ""
            throw OpenAIClientError.decode("Could not decode Responses API payload. Raw: \(raw.prefix(1600))")
        }
    }

    private func extractOutputText(_ resp: OpenAIResponsesResponse) -> String {
        let items = resp.output ?? []
        var parts: [String] = []
        for item in items {
            let content = item.content ?? []
            for c in content {
                if c.type == "output_text", let t = c.text, !t.isEmpty {
                    parts.append(t)
                }
            }
        }
        return parts.joined(separator: "\n")
    }

    private func buildPrompt(grade: String, topic: String, minutes: Int, standards: String, constraints: String) -> String {
        // Deterministic and workshop-friendly. This is not a general chatbot.
        var s = ""
        s += "Create a lesson plan.\n"
        s += "Grade band: \(grade)\n"
        s += "Topic: \(topic)\n"
        s += "Duration: \(minutes) minutes\n"
        if !standards.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
            s += "Standards (optional): \(standards)\n"
        }
        if !constraints.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
            s += "Constraints (optional): \(constraints)\n"
        }

        s += "\nOutput format (exact headings):\n"
        s += "1) Learning Objective\n"
        s += "2) Materials\n"
        s += "3) Warm-Up (5 min)\n"
        s += "4) Direct Instruction\n"
        s += "5) Guided Practice\n"
        s += "6) Independent Practice\n"
        s += "7) Differentiation\n"
        s += "8) Check for Understanding\n"
        s += "9) Exit Ticket\n"
        s += "10) Homework / Extension (optional)\n"

        s += "\nRules:\n"
        s += "- Use teacher-ready language.\n"
        s += "- Include approximate minute-by-minute pacing aligned to the total.\n"
        s += "- Provide at least 3 CFU questions.\n"
        s += "- Provide an exit ticket with 3 prompts.\n"
        return s
    }
}
Direct-call warning:

This is for a controlled demo. If you embed a key in an app and share it, you are effectively publishing your key. For distribution, switch to the Proxy option below.

ContentView.swift (replace your existing ContentView)

This is deliberately simple: one screen, clear input fields, a generate button, and a text output area. This is your baseline. After it runs, iterate on UX and features.

File: ContentView.swift SwiftUI Single screen
import SwiftUI

struct ContentView: View {
    @State private var grade: String = "6–8"
    @State private var topic: String = "Photosynthesis"
    @State private var minutesText: String = "45"
    @State private var standards: String = ""
    @State private var constraints: String = "No worksheets; discussion + small group activity."

    @State private var output: String = "Ready."
    @State private var isBusy: Bool = false
    @State private var errorText: String = ""

    private let client = OpenAIClient()

    var body: some View {
        NavigationView {
            ScrollView {
                VStack(alignment: .leading, spacing: 12) {
                    header

                    GroupBox("Inputs") {
                        VStack(alignment: .leading, spacing: 10) {
                            Picker("Grade band", selection: $grade) {
                                Text("K–2").tag("K–2")
                                Text("3–5").tag("3–5")
                                Text("6–8").tag("6–8")
                                Text("9–12").tag("9–12")
                            }
                            .pickerStyle(.segmented)

                            TextField("Topic (example: Fractions, Civil War, Photosynthesis)", text: $topic)
                                .textFieldStyle(.roundedBorder)

                            TextField("Minutes (example: 45)", text: $minutesText)
                                .keyboardType(.numberPad)
                                .textFieldStyle(.roundedBorder)

                            TextField("Standards (optional)", text: $standards)
                                .textFieldStyle(.roundedBorder)

                            TextField("Constraints (optional)", text: $constraints)
                                .textFieldStyle(.roundedBorder)
                        }
                        .padding(.vertical, 6)
                    }

                    Button(action: generate) {
                        HStack {
                            Spacer()
                            Text(isBusy ? "Generating..." : "Generate lesson plan")
                                .fontWeight(.bold)
                            Spacer()
                        }
                    }
                    .buttonStyle(.borderedProminent)
                    .disabled(isBusy || topic.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)

                    if !errorText.isEmpty {
                        Text(errorText)
                            .foregroundColor(.red)
                            .font(.footnote)
                            .padding(.top, 2)
                    }

                    GroupBox("Lesson plan output") {
                        Text(output)
                            .font(.system(.body, design: .default))
                            .textSelection(.enabled)
                            .frame(maxWidth: .infinity, alignment: .leading)
                            .padding(.vertical, 6)
                    }

                    GroupBox("Notes") {
                        VStack(alignment: .leading, spacing: 8) {
                            Text("• This app is a baseline demo: minimal UI, single request, single output.")
                            Text("• Demo-only: Direct API calls from mobile are not secure for real distribution.")
                            Text("• Real architecture: call your own proxy/server; keep the API key off-device.")
                            Text("• Do not enter sensitive student data.")
                        }
                        .font(.footnote)
                        .foregroundColor(.secondary)
                        .padding(.vertical, 6)
                    }
                }
                .padding()
            }
            .navigationTitle("Lesson Plan Builder")
        }
    }

    private var header: some View {
        VStack(alignment: .leading, spacing: 6) {
            Text("AI Lesson Plan Builder")
                .font(.title2).bold()
            Text("Enter inputs and generate a teacher-ready plan.")
                .foregroundColor(.secondary)
        }
    }

    private func generate() {
        errorText = ""
        output = "Working..."
        isBusy = true

        let minutes = Int(minutesText.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 45
        let topicClean = topic.trimmingCharacters(in: .whitespacesAndNewlines)

        Task {
            do {
                let plan = try await client.generateLessonPlan(
                    grade: grade,
                    topic: topicClean,
                    minutes: max(10, min(minutes, 180)),
                    standards: standards,
                    constraints: constraints
                )
                await MainActor.run {
                    output = plan
                    isBusy = false
                }
            } catch {
                await MainActor.run {
                    errorText = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
                    output = "Failed."
                    isBusy = false
                }
            }
        }
    }
}

Target Info mapping (required for this config approach)

This workshop maps Secrets.xcconfig values into runtime-accessible values via the Target’s Info keys. This is still not secure for distribution, but it reduces workshop friction and keeps code clean.

Target → Info Add two keys
// In Xcode: select your TARGET → Info tab → add these rows:

OPENAI_API_KEY  (String)  $(OPENAI_API_KEY)
OPENAI_MODEL    (String)  $(OPENAI_MODEL)
If you do not see an Info.plist file:

That is normal. Xcode often manages it automatically. Use Target → Info to add custom keys.

.gitignore (if you use Git)

If you initialize Git, do this immediately before you accidentally commit secrets.

File: .gitignore Do not leak secrets
# Never commit secrets
Secrets.xcconfig

Recommended: Proxy architecture (keeps the API key off the phone)

This is the professional architecture: the iPhone app calls your proxy; the proxy calls OpenAI. The proxy holds the OpenAI key as a secret (environment variable) and can enforce rate limits and logging rules.

Why proxy:

Any secret shipped to a mobile device is recoverable. Proxy prevents key theft and enables control and monitoring.

Proxy Cloudflare Worker JavaScript
// Cloudflare Worker: lesson-plan-proxy.js
// Store OPENAI_API_KEY as a Worker secret (not in code).
// This worker exposes: POST /lessonplan
// Client sends: { model, instructions, input, max_output_tokens }
// Worker forwards to: https://api.openai.com/v1/responses

export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    if (url.pathname !== "/lessonplan") {
      return new Response("Not found", { status: 404 });
    }
    if (request.method !== "POST") {
      return new Response("Method not allowed", { status: 405 });
    }

    let body;
    try {
      body = await request.json();
    } catch {
      return new Response("Bad JSON", { status: 400 });
    }

    // Minimal validation
    const model = (body.model || "gpt-5.2").toString();
    const input = (body.input || "").toString();
    const instructions = (body.instructions || "").toString();
    const max_output_tokens = Number(body.max_output_tokens || 900);

    if (!input.trim()) {
      return new Response(JSON.stringify({ error: "Missing input" }), {
        status: 400,
        headers: { "Content-Type": "application/json" },
      });
    }

    const openaiResp = await fetch("https://api.openai.com/v1/responses", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "Authorization": `Bearer ${env.OPENAI_API_KEY}`,
      },
      body: JSON.stringify({
        model,
        instructions: instructions || undefined,
        input,
        max_output_tokens,
      }),
    });

    const text = await openaiResp.text();
    return new Response(text, {
      status: openaiResp.status,
      headers: { "Content-Type": "application/json" },
    });
  },
};
iPhone app change for proxy: Replace the OpenAI endpoint URL with your Worker URL (or server URL), and remove API key handling from the app entirely.
Do not skip in real life:

If you ship without a proxy, you are shipping credentials. That is not a “maybe”; it is guaranteed.

Test in iOS Simulator (debug checklist)

Use this to diagnose failures quickly in a live workshop. Most failures are configuration and billing, not Swift code.

Pass criteria

  • App launches and UI is responsive
  • Tap Generate → “Working…” shows
  • Within ~5–20s, lesson plan text appears
  • Text is selectable (copy/paste)
  • Errors show in red instead of crashing

Common failures (and fixes)

  • “Missing OPENAI_API_KEY”: Target → Info keys missing or not mapped to $(OPENAI_API_KEY).
  • 401 Unauthorized: API key invalid/expired or not passed; verify Secrets.xcconfig and mapping.
  • 429 / quota / billing errors: project not funded or rate-limited; fix billing/limits.
  • Decode error: response schema unexpected; look at the raw body in the error text.
  • Nothing returned: model access/permissions; try a different model you have access to.
Fastest debugging move:

Copy the raw HTTP body from the error and inspect it. It usually tells you exactly what is wrong.

Distribution rule:

If you plan to distribute (TestFlight or App Store), switch to Proxy. Otherwise, this teaches and ships the wrong security pattern.

Copied