Tutorial

Build an AI iOS app that can't leak your key.

In June, a network-traffic study caught 282 iOS apps exposing their LLM API keys — a third of them had built a backend proxy and forgot to put auth on it. Here's the architecture that can't make that mistake, as a complete tutorial: a real SwiftUI app, a real niche, zero backend code.

What we're building

Pull — an espresso dial-in journal with an AI barista. You log each shot (dose in, yield out, time, and how it tasted); the coach reads your history and tells you exactly what to change on the next one: "18g→52g in 22s tasting sour is a classic under-extraction — tighten the grind two steps, keep the dose."

It's deliberately niche. Niche is where solo iOS apps win, and it exercises everything that matters in an AI app: per-user state, streaming responses, real per-user costs, and a key that lives inside a binary anyone can decompile.

the whole app — three SwiftUI views and one streamed reply

0backend lines to write or host
~$0.001per coaching reply on haiku (from our benchmarks)
$5/dayhard spend cap built into the key, before you configure anything

The trap everyone falls into

The 282 leaked apps failed in three ways: 19% shipped the raw OpenAI/Anthropic key in the app, 33% built a proxy but left it unauthenticated (an open relay on their provider account), and 48% built the proxy with tokens but made the tokens replayable forever. The lesson isn't "be more careful" — it's that a token-vending backend is a real distributed-systems project, and most app builders shouldn't be writing one the weekend they ship.

The Cerver answer: your provider keys never go anywhere near the app. The app carries a publishable key (pk_…) that can do exactly two things — open a session and send it messages — with a daily budget and a rate limit welded on server-side. Decompile the binary, extract the key, and the worst you can do is have a polite conversation with an espresso coach, 60 requests a minute, until the $5 daily cap closes the tap.

1

Mint the key (one curl, once)

Create a project and a publishable key from your machine — your secret ck_ key stays on your machine:

curl -X POST https://gateway.cerver.ai/v2/auth/keys \
  -H "Authorization: Bearer $CERVER_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "label": "pull-ios", "project_slug": "pull", "kind": "publishable" }'

# → { "key": "pk_XXXX…", "kind": "publishable", … }

That pk_ key is scoped server-side to session-create and run-llm only. It cannot list sessions, read other users' transcripts, mint keys, or touch billing. It ships in the app on purpose.

2

The shot log (plain SwiftUI)

struct Shot: Codable, Identifiable {
    let id = UUID()
    var doseIn: Double      // grams of coffee
    var yieldOut: Double    // grams of espresso
    var seconds: Int
    var taste: String       // "sour" | "bitter" | "balanced" | notes
    var date = Date()
}

@Observable final class ShotLog {
    var shots: [Shot] = []          // persist with SwiftData/UserDefaults as you like
    var advice: String = ""
    var streaming = false
}
3

The Cerver client — one session per user

Each user gets one long-lived session, created on first launch and remembered. Two things fall out of that for free: the coach has memory (the transcript persists server-side, so it knows last week's shots), and every dollar this user costs you lands on their session in your dashboard, because we stamp app_user_id into the session metadata.

final class CerverClient {
    static let gateway = URL(string: "https://gateway.cerver.ai")!
    static let pk = "pk_XXXX…"                 // the publishable key — yes, in the app

    static func request(_ path: String, body: [String: Any]) -> URLRequest {
        var r = URLRequest(url: gateway.appendingPathComponent(path))
        r.httpMethod = "POST"
        r.setValue("Bearer \(pk)", forHTTPHeaderField: "Authorization")
        r.setValue("application/json", forHTTPHeaderField: "Content-Type")
        r.httpBody = try! JSONSerialization.data(withJSONObject: body)
        return r
    }

    // One session per user, created once, kept in UserDefaults.
    static func sessionID(for userID: String) async throws -> String {
        if let cached = UserDefaults.standard.string(forKey: "cerver.session") { return cached }
        let body: [String: Any] = [
            "compute": ["provider": "online"],
            "session_name": "pull \(userID.prefix(8))",
            "metadata": ["app_user_id": userID, "run_type": "espresso-coach"]
        ]
        let (data, _) = try await URLSession.shared.data(for: request("/v2/sessions", body: body))
        let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
        let sid = (json["session_id"] ?? json["sessionId"]) as! String
        UserDefaults.standard.set(sid, forKey: "cerver.session")
        return sid
    }
}
4

Stream the coach's reply (SSE, ~20 lines)

run-llm streams server-sent events. Swift's URLSession.bytes gives you an async line sequence, so parsing SSE is a two-branch loop — no dependency needed:

extension CerverClient {
    static func coach(shots: [Shot], userID: String,
                      onDelta: @escaping (String) -> Void) async throws {
        let sid = try await sessionID(for: userID)
        let history = shots.suffix(5).map {
            "\($0.doseIn)g in → \($0.yieldOut)g out in \($0.seconds)s — tasted \($0.taste)"
        }.joined(separator: "\n")

        let prompt = """
        You are an espresso dial-in coach. My last shots, newest last:
        \(history)
        Give me ONE concrete adjustment for the next shot (grind, dose, or time), \
        with one sentence of reasoning. Under 60 words.
        """

        let body: [String: Any] = ["input": prompt,
                                   "model": "claude-haiku-4-5-20251001",
                                   "harness": "claude"]
        let (bytes, _) = try await URLSession.shared.bytes(
            for: request("/v2/sessions/\(sid)/run-llm", body: body))

        var event = ""
        for try await line in bytes.lines {
            if line.hasPrefix("event: ") { event = String(line.dropFirst(7)) }
            else if line.hasPrefix("data: "), event == "text_delta",
                let d = try? JSONSerialization.jsonObject(
                    with: Data(line.dropFirst(6).utf8)) as? [String: Any],
                let chunk = d["content"] as? String {
                onDelta(chunk)
            }
        }
    }
}

Wire it to the UI and you have a streaming coach:

Button("What should I change?") {
    log.advice = ""; log.streaming = true
    Task {
        try await CerverClient.coach(shots: log.shots, userID: userID) { chunk in
            log.advice += chunk
        }
        log.streaming = false
    }
}
5

What you get without building it

Cost per user, by name. Open your Cerver dashboard → Sessions. Every user's session is there with its full transcript and its exact dollar cost, attributed via app_user_id. The day one user starts costing you $2/day, you'll know who — that's your pricing page writing itself.

Caps that hold. The publishable key ships with a $5/day budget and 60 requests/minute. A pulled key, a runaway shortcut automation, a bored teenager with a REST client — all of them hit the same wall. Your Anthropic bill cannot have a surprise in it, because the surprise gets refused at the gateway.

Model swaps as a config change. That model: line is the whole integration. We benchmarked cost-per-completed-task across 12 models — haiku finished every job for three cents total, which is why it's the default here. When something better ships next month, you change one string. No SDK migration, no new vendor account.

Memory. Because the session persists, "how has my espresso changed this month?" just works — the transcript is the user's history. You didn't build storage, sync, or context management.

The problems you don't know you have yet

This build pre-solves six of them. Short version here — the full stories have their own post.

day 1Your key leaks

An .ipa is a zip. Ship a capped pk_, not your account.

month 1One user = half your bill

See whales by name → that's your Pro tier.

month 2Your model repriced

It's one string here, not a migration.

any dayModels fail weird

Refusals happen. Transcripts let you see them.

some nightProvider outage

Fallback = swap the string. No emergency release.

month 3What do users even ask?

Your roadmap is in the transcripts you kept.

Honest limits

Publishable keys support an origin allowlist, which protects web embeds; a native binary doesn't send an Origin header, so on iOS your protections are the scope, the rate limit, and the budget — which is exactly the blast-radius math you want: the key in the binary is worth at most $5/day of espresso advice. If your app grows a server anyway, mint a secret key there and keep the same session API; nothing else changes.

The full pattern generalizes: swap "espresso shots" for climbing sessions, fermentation logs, guitar practice, aquarium chemistry — any niche where users accumulate structured history and an LLM can read it back as coaching. The backend is the same six curl-shaped calls, and it's already running.

Ship the coach this weekend.

Open a project, mint a publishable key, paste the client above. No card, $5 free tier — and your provider keys never leave your laptop.