> ## Documentation Index
> Fetch the complete documentation index at: https://docs.theacornkit.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Add an AI chat in 3 steps

> Stream completions into your app with the model key kept server-side.

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.

<Note>
  **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.
</Note>

## 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]`.

```ts supabase/functions/chat/index.ts theme={null}
// 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-…`.

<Note>
  **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.
</Note>

<Warning>
  **Rate-limit it** (e.g. a `usage` table keyed by user) so a leaked client can't run up
  your bill.
</Warning>

## Part B — App wiring (3 steps)

<Steps>
  <Step title="Pick a provider">
    The template's Assistant tab calls `AppConfig.makeChat()`. Edit that one function:

    ```swift theme={null}
    @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
    }
    ```
  </Step>

  <Step title="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`.
  </Step>

  <Step title="Show the screen">
    Already done in the template — the **Assistant** tab in `RootTabView` is:

    ```swift theme={null}
    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`.
  </Step>
</Steps>

### Supply the key (keep it out of source)

Mirror the PostHog/RevenueCat pattern — read from Info.plist:

```swift theme={null}
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.

<Warning>
  **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`).
</Warning>
