Skip to main content

What you’ll build

An authenticated chat where every Supabase user has their own Polpo session memory, analytics, and (later) billing pass-through. Your Next.js app verifies the Supabase session on the server, then forwards the user’s ID to Polpo on every chat call. You get:
  • Per-user chat memory (sessions auto-scoped to user.id)
  • Per-user analytics (filter dashboard by user)
  • Audit trail (every session, task, run carries the user id)
  • Foundation for per-user billing via the upcoming Autumn bridge

Prerequisites

1

Supabase project with auth enabled

Email/password, OAuth, magic links — any Supabase auth method works. You only need the server-side helper to read the current user.
2

Polpo project + API key

Follow the quickstart to create a project and grab a service-role API key (sk_live_...). Server-side only — never ship it to the browser.
3

Next.js 14+ App Router

Examples below use Route Handlers and Client Components. The same pattern works in Hono, Express, or any server you control.

How it fits together

Browser ──▶ Next.js Route Handler ──▶ Polpo
              │  verify Supabase session
              ▼  forward user.id
           supabase.auth.getUser()
Supabase Auth lives in your app. Polpo never sees the JWT. Your server is the trust boundary: it verifies the session, then calls Polpo with your API key and a user field.

Step 1 — Install

pnpm add @polpo-ai/sdk @supabase/ssr

Step 2 — Verify the session and call Polpo

Server-side route handler. Reads the Supabase session via cookies, rejects unauthenticated requests, and streams the chat completion back to the browser with user: user.id attached.
app/api/chat/route.ts
import { PolpoClient } from "@polpo-ai/sdk";
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

const polpo = new PolpoClient({
  baseUrl: "https://api.polpo.sh",
  apiKey: process.env.POLPO_API_KEY!,
});

export async function POST(req: Request) {
  const cookieStore = await cookies();
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll: () => { /* route handlers don't write cookies */ },
      },
    },
  );

  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return new Response("Unauthorized", { status: 401 });

  const { messages, agent } = await req.json();

  const stream = polpo.chatCompletionsStream({
    agent,
    messages,
    user: user.id,
  });

  return new Response(
    new ReadableStream({
      async start(controller) {
        const encoder = new TextEncoder();
        for await (const chunk of stream) {
          controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`));
        }
        controller.enqueue(encoder.encode("data: [DONE]\n\n"));
        controller.close();
      },
    }),
    { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" } },
  );
}
user.id is a UUID that’s stable for the lifetime of the Supabase user. Polpo treats it as an opaque string — pass anything that uniquely identifies the end-user (email, tenant id, internal user id) if you prefer.

Step 3 — Stream the response in your client

Plain fetch + the standard SSE parsing pattern. Replace this with @polpo-ai/react’s useChat hook once you wire PolpoProvider against /api/chat.
app/chat/page.tsx
"use client";

import { useState } from "react";

export default function ChatPage() {
  const [input, setInput] = useState("");
  const [reply, setReply] = useState("");

  async function send() {
    setReply("");
    const res = await fetch("/api/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        agent: "assistant",
        messages: [{ role: "user", content: input }],
      }),
    });
    const reader = res.body!.getReader();
    const decoder = new TextDecoder();
    let buf = "";
    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
      buf += decoder.decode(value, { stream: true });
      const lines = buf.split("\n");
      buf = lines.pop() ?? "";
      for (const line of lines) {
        if (!line.startsWith("data: ")) continue;
        const data = line.slice(6).trim();
        if (data === "[DONE]") return;
        try {
          const json = JSON.parse(data);
          const delta = json.choices?.[0]?.delta?.content ?? "";
          if (delta) setReply((r) => r + delta);
        } catch { /* keep buffering */ }
      }
    }
  }

  return (
    <div>
      <input value={input} onChange={(e) => setInput(e.target.value)} />
      <button onClick={send}>Send</button>
      <pre>{reply}</pre>
    </div>
  );
}

Step 4 (optional) — List a user’s past sessions

Polpo auto-creates a session per chat and tags it with the user field. You can list a single user’s sessions from your server.
The typed polpo.getSessions() helper doesn’t currently take a user filter parameter. Until it does, hit the underlying endpoint directly — GET /v1/chat/sessions?user=<id> does support the filter server-side. We’ll add a typed param to the SDK in a later release.
app/api/sessions/route.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function GET() {
  const cookieStore = await cookies();
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => cookieStore.getAll(),
        setAll: () => {},
      },
    },
  );

  const { data: { user } } = await supabase.auth.getUser();
  if (!user) return new Response("Unauthorized", { status: 401 });

  const url = new URL("https://api.polpo.sh/v1/chat/sessions");
  url.searchParams.set("user", user.id);

  const res = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.POLPO_API_KEY!}` },
  });
  return new Response(res.body, {
    status: res.status,
    headers: { "Content-Type": "application/json" },
  });
}

What this gives you

  • Per-user memory. Every session is keyed on user.id. The agent’s recall stays scoped to the right person.
  • Per-user analytics. Filter completions, tasks, and runs by user in the dashboard.
  • Audit trail. Every session, task, and run row stores the user id — useful for support, abuse review, and compliance.
  • Billing foundation. When the Autumn bridge ships, the same user field becomes the customer key for per-user metering and pass-through pricing.

What we deliberately don’t do

  • Polpo never verifies the Supabase JWT. Your API key is the trust anchor. Verify the session in your server, then forward user.id — Polpo trusts the caller because the caller has the API key.
  • We don’t compete with Supabase Auth. Bring your auth — we run the agents.

Troubleshooting

401 Unauthorized from Polpo

Check POLPO_API_KEY is set in the server environment and not prefixed with NEXT_PUBLIC_. The SDK sends it as Authorization: Bearer <key>.

user field shows up as null in the dashboard

The user field must be passed in the request body, not as a header. With the SDK, that’s polpo.chatCompletionsStream({ ..., user: user.id }). With raw fetch, include "user": "<id>" as a top-level body field.

Sessions aren’t being scoped per user

Confirm @polpo-ai/sdk >= 0.6.23 — earlier versions silently dropped the user field. Run pnpm why @polpo-ai/sdk to verify.

Next steps

  • Publishable keys (coming soon). A pk_* token will let you call Polpo directly from the browser, removing the route-handler hop for low-risk endpoints.
  • Autumn bridge (coming soon). Plug user.id into Autumn for per-user metering and Stripe billing pass-through.
  • Narrative version. For the “why” behind this pattern, read Per-user agents with Supabase Auth on the blog.