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

# Build a streaming chat UI

> Proxy Planck streaming through a Next.js or FastAPI backend without exposing your API key.

A production chat UI should call Planck from your backend. The browser sends messages to your application, your server adds the Planck API key and opens the upstream stream, and your server forwards SSE bytes back to the browser.

```mermaid theme={null}
sequenceDiagram
  participant UI as Browser UI
  participant App as Your backend
  participant Planck as Planck AI Gateway
  UI->>App: POST messages
  App->>Planck: POST /v1/chat/completions, stream=true
  Planck-->>App: SSE deltas
  App-->>UI: Forward SSE deltas
  UI->>App: Abort when user stops
  App-xPlanck: Cancel upstream request
```

<Warning>
  Never put `PLANCK_API_KEY` in browser code, a `NEXT_PUBLIC_*` variable, a
  mobile bundle, or a response sent to the client.
</Warning>

## Next.js App Router

### Server route

Create `app/api/chat/route.ts`. This validates the incoming shape, starts a Planck stream, and forwards the upstream body without buffering it.

```typescript app/api/chat/route.ts theme={null}
export const runtime = "nodejs";

export async function POST(request: Request) {
  const body = await request.json();

  if (!Array.isArray(body.messages) || body.messages.length === 0) {
    return Response.json(
      { error: { message: "messages must be a non-empty array" } },
      { status: 400 },
    );
  }

  const upstream = await fetch(
    "https://api.inquantum.ai/v1/chat/completions",
    {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.PLANCK_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        model: "gpt-4o-mini",
        messages: body.messages,
        stream: true,
      }),
      signal: request.signal,
    },
  );

  if (!upstream.ok || !upstream.body) {
    const error = await upstream.text();
    return new Response(error, {
      status: upstream.status,
      headers: { "Content-Type": "application/json" },
    });
  }

  return new Response(upstream.body, {
    status: 200,
    headers: {
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      "X-Accel-Buffering": "no",
    },
  });
}
```

Forwarding `request.signal` allows a browser cancellation to propagate to Planck when the runtime supports request abort signals.

### Client component

This component sends conversation history, decodes SSE frames, renders text deltas, handles stream errors, and exposes a **Stop generating** button.

```tsx app/chat/page.tsx theme={null}
"use client";

import { FormEvent, useRef, useState } from "react";

type Message = { role: "user" | "assistant"; content: string };

export default function ChatPage() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState("");
  const [streaming, setStreaming] = useState(false);
  const abortRef = useRef<AbortController | null>(null);

  async function submit(event: FormEvent) {
    event.preventDefault();
    if (!input.trim() || streaming) return;

    const userMessage: Message = { role: "user", content: input.trim() };
    const nextMessages = [...messages, userMessage];
    setMessages([...nextMessages, { role: "assistant", content: "" }]);
    setInput("");
    setStreaming(true);

    const controller = new AbortController();
    abortRef.current = controller;

    try {
      const response = await fetch("/api/chat", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ messages: nextMessages }),
        signal: controller.signal,
      });

      if (!response.ok || !response.body) {
        throw new Error(await response.text());
      }

      const reader = response.body.getReader();
      const decoder = new TextDecoder();
      let buffer = "";

      while (true) {
        const { value, done } = await reader.read();
        buffer += decoder.decode(value, { stream: !done });

        const events = buffer.split(/\r?\n\r?\n/);
        buffer = events.pop() ?? "";

        for (const sseEvent of events) {
          for (const line of sseEvent.split(/\r?\n/)) {
            if (!line.startsWith("data:")) continue;

            const data = line.slice(5).trimStart();
            if (data === "[DONE]") continue;

            const chunk = JSON.parse(data);
            if (chunk.error) throw new Error(chunk.error.message);

            const text = chunk.choices?.[0]?.delta?.content;
            if (!text) continue;

            setMessages((current) =>
              current.map((message, index) =>
                index === current.length - 1 && message.role === "assistant"
                  ? { ...message, content: message.content + text }
                  : message,
              ),
            );
          }
        }

        if (done) break;
      }
    } catch (error) {
      if (!(error instanceof DOMException && error.name === "AbortError")) {
        console.error(error);
      }
    } finally {
      abortRef.current = null;
      setStreaming(false);
    }
  }

  return (
    <main>
      <section aria-live="polite">
        {messages.map((message, index) => (
          <article key={index} data-role={message.role}>
            <strong>{message.role === "user" ? "You" : "Assistant"}</strong>
            <p>{message.content || "…"}</p>
          </article>
        ))}
      </section>

      <form onSubmit={submit}>
        <label htmlFor="message">Message</label>
        <textarea
          id="message"
          value={input}
          onChange={(event) => setInput(event.target.value)}
          disabled={streaming}
        />
        <button type="submit" disabled={streaming || !input.trim()}>
          Send
        </button>
        {streaming && (
          <button type="button" onClick={() => abortRef.current?.abort()}>
            Stop generating
          </button>
        )}
      </form>
    </main>
  );
}
```

For a real application, use stable message IDs instead of array indexes and apply your existing design system, authentication, rate limits, and persistence.

## FastAPI proxy

Install the server dependencies:

```bash theme={null}
pip install fastapi httpx uvicorn
```

The proxy opens the upstream response before returning `StreamingResponse`, which lets it preserve non-2xx errors instead of incorrectly starting a successful stream.

```python app.py theme={null}
import os
from collections.abc import AsyncIterator

import httpx
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, Response, StreamingResponse

app = FastAPI()
PLANCK_URL = "https://api.inquantum.ai/v1/chat/completions"


@app.post("/api/chat")
async def chat(request: Request):
    body = await request.json()
    messages = body.get("messages")

    if not isinstance(messages, list) or not messages:
        return JSONResponse(
            {"error": {"message": "messages must be a non-empty array"}},
            status_code=400,
        )

    client = httpx.AsyncClient(timeout=None)
    upstream_request = client.build_request(
        "POST",
        PLANCK_URL,
        headers={
            "Authorization": f"Bearer {os.environ['PLANCK_API_KEY']}",
            "Content-Type": "application/json",
        },
        json={
            "model": "gpt-4o-mini",
            "messages": messages,
            "stream": True,
        },
    )
    upstream = await client.send(upstream_request, stream=True)

    if upstream.is_error:
        content = await upstream.aread()
        await upstream.aclose()
        await client.aclose()
        return Response(
            content=content,
            status_code=upstream.status_code,
            media_type="application/json",
        )

    async def forward() -> AsyncIterator[bytes]:
        try:
            async for chunk in upstream.aiter_raw():
                if await request.is_disconnected():
                    break
                yield chunk
        finally:
            await upstream.aclose()
            await client.aclose()

    return StreamingResponse(
        forward(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache, no-transform",
            "X-Accel-Buffering": "no",
        },
    )
```

Run the server with:

```bash theme={null}
uvicorn app:app --reload
```

The same browser SSE parser from the Next.js example can consume this `/api/chat` route.

## Production hardening

| Concern              | Recommendation                                                            |
| -------------------- | ------------------------------------------------------------------------- |
| Authentication       | Authenticate your own users before opening the upstream request.          |
| Authorization        | Apply organization and model policy on the server.                        |
| API keys             | Read `PLANCK_API_KEY` only from server-side secrets.                      |
| Input limits         | Limit message count, content length, image size, and total request body.  |
| Abuse                | Add per-user rate limits and a stable `Planck-User-Id`.                   |
| Conversation history | Trim or summarize history before reaching the model context limit.        |
| Cancellation         | Abort upstream work when the browser disconnects.                         |
| Timeouts             | Set connection and total-generation limits appropriate for your product.  |
| Errors               | Handle initial non-2xx responses and in-stream error events separately.   |
| Rendering            | Render model text as plain text unless sanitized Markdown is intentional. |

## Useful request metadata

Add server-controlled headers when proxying to make production traffic easier to inspect:

```typescript theme={null}
headers: {
  Authorization: `Bearer ${process.env.PLANCK_API_KEY}`,
  "Content-Type": "application/json",
  "Planck-User-Id": authenticatedUser.id,
  "Planck-Session-Id": conversation.id,
  "Planck-Property-Environment": process.env.NODE_ENV,
}
```

Never trust a browser-supplied user or organization identifier without checking it against the authenticated session.

<CardGroup cols={2}>
  <Card title="Streaming protocol" icon="messages-square" href="/gateway/streaming">
    Review SSE chunks, usage, cancellation, and stream errors.
  </Card>

  <Card title="Tool calling" icon="wrench" href="/gateway/tool-calling">
    Add safe server-side functions to the chat loop.
  </Card>

  <Card title="Sessions" icon="link" href="/features/sessions">
    Group all turns from one conversation.
  </Card>

  <Card title="Custom properties" icon="tags" href="/features/advanced-usage/custom-properties">
    Attribute requests to environments, releases, or product features.
  </Card>
</CardGroup>
