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.
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)
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
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)
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)
If the API returns quota or billing errors, your app can be correct and still fail. Fix account funding first.
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
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
After the baseline is running, use ChatGPT to propose incremental enhancements (new files, code edits), then test each change in Simulator before moving on.
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 Xcode → File → New → Project…
- Select: iOS → App → Next
- 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.xcconfigand 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.
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 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 Target → Info tab
- Add two custom rows:
OPENAI_API_KEY(String) =$(OPENAI_API_KEY)OPENAI_MODEL(String) =$(OPENAI_MODEL)
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
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)
LessonPlanBuilder (Xcode project) LessonPlanBuilderApp.swift ContentView.swift (replace) OpenAIClient.swift (new) OpenAIModels.swift (new) Secrets.xcconfig (Direct demo path; local only)
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.
// 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
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
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
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
}
}
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.
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.
// In Xcode: select your TARGET → Info tab → add these rows: OPENAI_API_KEY (String) $(OPENAI_API_KEY) OPENAI_MODEL (String) $(OPENAI_MODEL)
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.
# 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.
Any secret shipped to a mobile device is recoverable. Proxy prevents key theft and enables control and monitoring.
// 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" },
});
},
};
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.xcconfigand 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.
Copy the raw HTTP body from the error and inspect it. It usually tells you exactly what is wrong.
If you plan to distribute (TestFlight or App Store), switch to Proxy. Otherwise, this teaches and ships the wrong security pattern.