Skip to main content
Two parts: A. the one-time backend proxy that keeps your model key server-side; B. the 3-step app wiring. The example Assistant tab ships in the template already — this is how you point it at a real model.
Architecture recap: ChatProviding is the seam (in AcornCore), streaming-first (stream(_:)reply(to:) for free). Implementations:
  • EchoChatProvider — dependency-free default; streams a canned reply so the Assistant tab runs with zero config.
  • ProxyChatProvider — the production path; streams from your backend so the model key never ships. Provider-neutral (your proxy picks OpenAI/Anthropic).
  • OpenAIChatProvider / AnthropicChatProvider (in the optional AcornCoreAI product) — dev-direct, pure URLSession, key ships in the app → prototyping only.
The UI (ChatScreen + ChatScreenModel) and the seam expose no vendor types, so swapping providers never touches the screen.

Part A — The chat proxy (one-time, ~15 min)

Skip this if you only need the dev-direct providers for prototyping. For production, this Edge Function holds the key and streams completions back to the app. It speaks the ProxyChatProvider contract: accept POST {messages, stream} with the caller’s JWT, and stream Server-Sent Events where each data: line is {"delta":"<text>"}, terminated by data: [DONE].
supabase/functions/chat/index.ts
// Streaming chat proxy — keeps the model key server-side. The app POSTs
// {messages} with the signed-in user's JWT; we stream back `data: {"delta":"…"}`.
const OPENAI_KEY = Deno.env.get("OPENAI_API_KEY")!
const MODEL = Deno.env.get("CHAT_MODEL") ?? "gpt-4o-mini"

Deno.serve(async (req) => {
  if (req.method !== "POST") return new Response("Method not allowed", { status: 405 })
  // Supabase forwards the caller's JWT; require it so only signed-in users spend tokens.
  if (!req.headers.get("Authorization")) return new Response("Unauthorized", { status: 401 })

  const { messages } = await req.json()
  if (!Array.isArray(messages)) return new Response("Bad request", { status: 400 })

  const upstream = await fetch("https://api.openai.com/v1/chat/completions", {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${OPENAI_KEY}` },
    body: JSON.stringify({ model: MODEL, messages, stream: true }),
  })
  if (!upstream.ok || !upstream.body) return new Response(`Upstream error (${upstream.status})`, { status: 502 })

  // Re-emit the upstream SSE as our minimal {delta} contract.
  const stream = new ReadableStream({
    async start(controller) {
      const enc = new TextEncoder(), dec = new TextDecoder()
      const reader = upstream.body!.getReader()
      let buffer = ""
      for (;;) {
        const { value, done } = await reader.read()
        if (done) break
        buffer += dec.decode(value, { stream: true })
        const lines = buffer.split("\n")
        buffer = lines.pop() ?? ""
        for (const line of lines) {
          if (!line.startsWith("data:")) continue
          const payload = line.slice(5).trim()
          if (payload === "[DONE]") { controller.enqueue(enc.encode("data: [DONE]\n\n")); continue }
          try {
            const delta = JSON.parse(payload).choices?.[0]?.delta?.content
            if (delta) controller.enqueue(enc.encode(`data: ${JSON.stringify({ delta })}\n\n`))
          } catch { /* keep-alive / partial line */ }
        }
      }
      controller.close()
    },
  })
  return new Response(stream, {
    headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive" },
  })
})
Deploy: supabase functions deploy chat. Set the key: supabase secrets set OPENAI_API_KEY=sk-….
Anthropic instead? Call https://api.anthropic.com/v1/messages with headers x-api-key + anthropic-version: 2023-06-01, lift system out of messages, set max_tokens, and map the content_block_delta events to {"delta": text}. The app side is identical — ProxyChatProvider doesn’t care which model is behind the proxy.
Rate-limit it (e.g. a usage table keyed by user) so a leaked client can’t run up your bill.

Part B — App wiring (3 steps)

1

Pick a provider

The template’s Assistant tab calls AppConfig.makeChat(). Edit that one function:
@MainActor
static func makeChat() -> any ChatProviding {
    // Zero-config default — streams a canned reply:
    EchoChatProvider()

    // Production — keys server-side (recommended); pass the Supabase client you
    // already use for auth/data so the JWT is the signed-in user's:
    // ProxyChatProvider(
    //     endpoint: URL(string: "https://<ref>.supabase.co/functions/v1/chat")!,
    //     apiKey: AppConfig.supabaseAnonKey,
    //     authToken: { try? await client.auth.session.accessToken }
    // )

    // Dev-direct (key ships in the app — prototyping only) — needs the AcornCoreAI product:
    // OpenAIChatProvider(apiKey: AppConfig.openAIKey)              // import AcornCoreAI
    // AnthropicChatProvider(apiKey: AppConfig.anthropicKey)       // Claude
}
2

Add the dependency (only for dev-direct)

ProxyChatProvider, EchoChatProvider, and ChatScreen are in AcornCore — no extra dependency. For the dev-direct providers, add the AcornCoreAI product to the app target and import AcornCoreAI in AppConfig.
3

Show the screen

Already done in the template — the Assistant tab in RootTabView is:
ChatScreen(
    provider: AppConfig.makeChat(),
    systemPrompt: "You are a helpful assistant for <App>."
)
ChatScreen is a themed, streaming chat surface (transcript + input bar + stop). Want your own UI? Drive ChatScreenModel directly: model.send(), observe model.messages / model.streamingReply / model.isStreaming.

Supply the key (keep it out of source)

Mirror the PostHog/RevenueCat pattern — read from Info.plist:
static var openAIKey: String { Bundle.main.object(forInfoDictionaryKey: "OPENAI_API_KEY") as? String ?? "" }
For the proxy path the app holds no model key — only the Supabase anon key (safe to ship). The model key lives in Supabase secrets.

Pre-ship checklist

  • Decided the path: Echo (demo) / Proxy (prod) / dev-direct (prototype).
  • Production: chat Edge Function deployed; OPENAI_API_KEY (or Anthropic) in Supabase secrets; rate-limiting in place.
  • AppConfig.makeChat() returns the chosen provider; any client key read from Info.plist (never hardcoded).
  • Dev-direct only: AcornCoreAI product added; key is not shipped to the App Store build.
  • Live test: send a message → tokens stream into the transcript → stop button cancels mid-stream → an error (e.g. bad key) shows in the error bar.
  • Privacy: if chats leave the device, disclose it in App Privacy + your policy.
What swift test can’t cover — verify these on a real simulator/device:
  • swift test covers the seam, the SSE parsers, the request building, and ChatScreenModel against a mock — not a live network round-trip.
  • The proxy needs network + the deployed function + the secret set; a 401 means the JWT/Authorization header didn’t reach the function.
  • ChatError is not Equatable — match by case (e.g. if case .http(let code) = error).