---
title: Streaming Chat Agent
description: "Stream an agent turn to a browser: session.sendStream() behind a Next.js route handler, the six event types re-emitted as SSE, and a reader with no extra dependency."
---

<MetaStrip items={[
  { label: "TIME", value: "~20 min" },
  { label: "STACK", value: (<><ServerChip name="Next.js" accent /><span style={{ color: "var(--color-fd-muted-foreground)", fontSize: 12 }}>App Router · no AI SDK</span></>) },
  { label: "SERVERS", value: (<><ServerChip name="asaas" /><ServerChip name="any preset" /></>) },
]} />

A chat box where the user types "charge R$150 via Pix", the answer arrives word by word, and the tool calls show up as they happen instead of after. One route handler, one client component, no streaming library.

<StreamingSplit
  frontend={{
    filename: "app/chat/chat.tsx",
    hook: "readStream()",
    messages: [
      { role: "user", content: "Charge R$150 via Pix for order 8841" },
      { role: "agent", content: "Issuing the charge now…" },
      { role: "tool", content: "codespar_charge · create" },
      { role: "agent", content: "Done. Here is the copia-e-cola." },
    ],
  }}
  backend={{
    filename: "app/api/chat/route.ts",
    fn: "session.sendStream()",
    log: [
      { time: "0.00s", level: "info", text: "assistant_text · iteration 1" },
      { time: "0.41s", level: "tool", text: "tool_use · codespar_charge" },
      { time: "1.86s", level: "tool", text: "tool_result · settled" },
      { time: "2.02s", level: "done", text: "done · 1 iteration" },
    ],
  }}
  note="the API key never leaves the route handler · the browser talks only to your own origin"
/>

## Why the key stays on the server

`@codespar/sdk` authenticates with a `csk_` key, and that key can spend money. It belongs in the route handler and nowhere else — the browser talks to your origin, your origin talks to CodeSpar. This is the whole reason the handler exists; it is not indirection for its own sake.

## Prerequisites

```bash
npm install @codespar/sdk next
```

```bash title=".env.local"
CODESPAR_API_KEY=csk_test_your_key_here
```

## The six events, and which ones a UI cares about

`sendStream` yields one typed event per SSE frame. The full union is in the [SDK reference](/docs/api/sdk/session#sendstream); what a chat UI does with each one:

| Event | What it carries | What a chat UI does |
|---|---|---|
| `user_message` | the message you sent, echoed | usually nothing — you already rendered it |
| `assistant_text` | `content`, plus the `iteration` it came from | append to the current bubble |
| `tool_use` | `id`, `name`, `input` | show "calling `codespar_charge`" — this is the part that makes a 4-second turn feel alive |
| `tool_result` | the whole `ToolCallRecord` | resolve the pending tool row; the payment details are in here |
| `done` | `result`, the same `SendResult` that `send()` returns | close the bubble, stop the spinner |
| `error` | `error`, and sometimes `message` | render it — **this is a value, not a throw** |

That last row is the one that bites. An `error` event does not reject the iterator: the loop keeps going and your `for await` never sees an exception. A UI that only handles `done` will spin forever on a turn that failed.

## The route handler

It creates a session, iterates the SDK stream, and re-emits each event as an SSE frame on your own origin.

```typescript title="app/api/chat/route.ts"
import { CodeSpar } from "@codespar/sdk";

const cs = new CodeSpar({ apiKey: process.env.CODESPAR_API_KEY! });

export async function POST(req: Request) {
  const { message, userId } = (await req.json()) as { message: string; userId: string };

  const session = await cs.create(userId, { preset: "brazilian" });
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      try {
        for await (const event of session.sendStream(message)) {
          controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
        }
      } catch (err) {
        // A throw here is transport or a non-2xx: CodesparApiError or
        // TimeoutError. An `error` EVENT arrives through the loop above
        // instead, and never lands in this catch.
        const error = err instanceof Error ? err.message : "stream failed";
        controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: "error", error })}\n\n`));
      } finally {
        controller.close();
      }
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
    },
  });
}
```

<Callout type="warn" title="no-transform is not decoration">
Without `no-transform`, a proxy or CDN in front of your app is free to buffer the response and hand it over in one piece at the end. The stream still works and the streaming does not — and it breaks in production, where the proxy is, and not on your laptop.
</Callout>

## The client

No library. `fetch` gives a `ReadableStream`, and SSE frames are separated by a blank line.

```typescript title="app/chat/chat.tsx"
"use client";

import { useState } from "react";

type StreamEvent =
  | { type: "user_message"; content: string }
  | { type: "assistant_text"; content: string; iteration: number }
  | { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }
  | { type: "tool_result"; toolCall: { tool_name: string } }
  | { type: "done"; result: { message: string } }
  | { type: "error"; error: string; message?: string };

export function Chat({ userId }: { userId: string }) {
  const [text, setText] = useState("");
  const [tools, setTools] = useState<string[]>([]);
  const [status, setStatus] = useState<"idle" | "streaming" | "done" | "error">("idle");

  async function send(message: string) {
    setText("");
    setTools([]);
    setStatus("streaming");

    const res = await fetch("/api/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message, userId }),
    });

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

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

      // A frame is complete only at the blank line. Parsing on every chunk
      // is how you end up calling JSON.parse on half an object.
      const frames = buffer.split("\n\n");
      buffer = frames.pop() ?? "";

      for (const frame of frames) {
        const line = frame.split("\n").find((l) => l.startsWith("data: "));
        if (!line) continue;
        const event = JSON.parse(line.slice(6)) as StreamEvent;

        if (event.type === "assistant_text") setText((t) => t + event.content);
        if (event.type === "tool_use") setTools((s) => [...s, event.name]);
        if (event.type === "done") setStatus("done");
        if (event.type === "error") setStatus("error");
      }
    }

    // The stream can end without a `done` event if the connection drops.
    setStatus((s) => (s === "streaming" ? "error" : s));
  }

  return (
    <div>
      <div>{text}</div>
      {tools.map((name) => (
        <div key={name}>calling {name}</div>
      ))}
      <button onClick={() => send("Charge R$150 via Pix for order 8841")} disabled={status === "streaming"}>
        Send
      </button>
    </div>
  );
}
```

## Why not the Vercel AI SDK's `useChat`

You can use it, and it will not work by dropping this route in. `useChat` reads the AI SDK's own data-stream protocol; what this handler emits is CodeSpar's `StreamEvent` union, which is a different wire format with different field names. Going through `useChat` means writing an adapter that maps our six events onto its protocol — worth it if the rest of your app already speaks it, and pure overhead if not. The reader above is about thirty lines and has no version to keep in step with.

## What this does not do

**No reconnection.** A dropped connection ends the turn. SSE has `Last-Event-ID` for resuming, and this handler emits no ids, so there is nothing to resume from. For a turn that must survive a flaky network, poll [`paymentStatus`](/docs/api/sdk/status#paymentstatus) on the tool call id instead of relying on the stream.

**One session per message.** `cs.create` on every request is fine for a demo and wrong for a real chat: each session is a fresh context, so the agent forgets the previous turn. Keep the session id alongside your conversation and reuse it.

**Timeouts are idle timeouts.** The SDK resets the clock on every complete frame, so a long turn that keeps producing frames does not time out. A turn that goes quiet does, and it surfaces as a `TimeoutError` thrown from the `for await` — the `catch` in the handler above turns it into a final `error` frame so the browser is not left hanging.

<NextStepsGrid items={[
  { label: "REFERENCE", title: "sendStream", description: "The method, the event union, and what it throws.", href: "/docs/api/sdk/session#sendstream" },
  { label: "CONCEPT", title: "Sessions", description: "Lifecycle, presets, and the three ways to drive a session.", href: "/docs/concepts/sessions" },
  { label: "COOKBOOK", title: "Pix Payment Agent", description: "The same charge, without a UI in front of it.", href: "/docs/cookbooks/pix-payment-agent" },
]} />
