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.
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.
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 nextCODESPAR_API_KEY=csk_test_your_key_hereThe 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:
| 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.
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.
"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.
Agent with a Wallet
Walkthrough of Programmable Wallets: create a wallet, bind an Asaas funding source, fund via sandbox Pix, and see what the mandate-gated execute path does today. ~15 minutes.
Webhook Listener
React to payment webhooks with a deterministic loop. On commerce.payment.succeeded, automatically issue an NF-e, create shipping, and send WhatsApp. No agent, no LLM, no surprise.