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
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:
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 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:
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).