Skip to main content
Cookbooks

Streaming Chat Agent

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.

2 min read
View MarkdownEdit on GitHub
TIME
~20 min
STACK
Next.jsApp Router · no AI SDK
SERVERS
asaasany 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.

STREAMING ARCHITECTURE
Tokens flow frontend ⇄ backend in real time
Frontend · app/chat/chat.tsx
readStream()
Charge R$150 via Pix for order 8841
Issuing the charge now…
codespar_charge · create
Done. Here is the copia-e-cola.
HTTP POST
SSE stream
Backend · app/api/chat/route.ts
session.sendStream()
0.00sINFOassistant_text · iteration 1
0.41sTOOLtool_use · codespar_charge
1.86sTOOLtool_result · settled
2.02sDONEdone · 1 iteration
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

npm install @codespar/sdk next
.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; what a chat UI does with each one:

EventWhat it carriesWhat a chat UI does
user_messagethe message you sent, echoedusually nothing — you already rendered it
assistant_textcontent, plus the iteration it came fromappend to the current bubble
tool_useid, name, inputshow "calling codespar_charge" — this is the part that makes a 4-second turn feel alive
tool_resultthe whole ToolCallRecordresolve the pending tool row; the payment details are in here
doneresult, the same SendResult that send() returnsclose the bubble, stop the spinner
errorerror, and sometimes messagerender 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.

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",
    },
  });
}

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.

The client

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

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

Streaming Chat Agent | CodeSpar