Stream into a Next.js route
The SDK is server-only. To stream the agent's reply down to a browser, run a thin Next.js route on your side that proxies the RunStream straight through. RunStream.toResponse() does this in one line.
// app/api/chat/route.ts
import { NovoAgents } from 'novoagents';
const novo = new NovoAgents();
// Your route is itself a long-lived serverless function. Without this, most
// hosts (Vercel included) cap it at ~300s and cut the connection mid-run: your
// browser would stop receiving chunks at ~5 minutes even though the run is
// still going. Raise it to your platform's ceiling. See "Long runs" below.
export const maxDuration = 1800;
export async function POST(req: Request) {
const { threadId, input } = await req.json();
const stream = await novo.threads.stream(threadId, { input });
return stream.toResponse();
}
That's it. toResponse() returns a Response with Content-Type: text/event-stream, the Novo-Run-Id / Novo-Thread-Id / Novo-Agent-Id headers preserved, and the SSE body piped through.
Hono, Bun, and Deno: toResponse() is a standard Web Response, so return stream.toResponse() works unchanged in any framework that speaks Web responses. For Express (Node response objects), see Stream into an Express route.
Long runs (read this if a run can exceed your function timeout)
maxDuration raises the ceiling but does not remove it: a run that outlives your
function's timeout will still have its connection cut, and a proxy that only
forwards the stream once will silently truncate: the browser stops at the cut,
never sees the data-novo-terminal chunk, and the run looks stuck at
status: "running" even though it completes and bills server-side.
For runs that can exceed the timeout, treat the POST route as a live view, not a
guaranteed drain. Persist or return stream.runId, and expose a resume route
that proxies novo.runs.stream(runId) with the browser's last SSE event ID:
// app/api/novo-runs/[runId]/stream/route.ts
export const maxDuration = 1800;
export async function GET(
req: Request,
{ params }: { params: Promise<{ runId: string }> },
) {
const { runId } = await params;
const lastEventId = req.headers.get('Last-Event-Id') ?? undefined;
const replay = await novo.runs.stream(runId);
const stream = lastEventId
? await replay.reconnect({ lastEventId })
: replay;
if (stream !== replay) replay.close();
return stream.toResponse();
}
The run itself never stops when a connection drops; only your view of it does. See Reconnect a dropped stream for the full reconnect-to-terminal pattern, and Streaming for the chunk contract.
On the browser
Prefer the first-class clients: novoagents/browser ships the SSE decoder and
the chunk → message reducer (decodeSseStream, reconnect state machine), and
@novoagents/react wraps them in headless
useNovoRun / useNovoInteractions hooks pointed at this same proxy route:
no hand-rolled parsing, and the fiddly cases (reused block ids,
terminal-vs-EOF, reconnect dedup) are already handled.
If you want the low-level loop anyway, consume the response as Server-Sent
Events. If you want a parser, use eventsource-parser:
'use client';
import { useState } from 'react';
export function Chat({ threadId }: { threadId: string }) {
const [text, setText] = useState('');
async function send(input: string) {
setText('');
const res = await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ threadId, input }),
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
// Parse SSE frames here, e.g. with `eventsource-parser`.
setText((t) => t + decoder.decode(value));
}
}
// …
}
If you pipe the frames into the AI SDK (parseJsonEventStream /
uiMessageChunkSchema), remember the Novo wire is a superset of that schema:
the data-novo-* chunks ride the generic data-${string} branch, so branch on
them first rather than validating every chunk against the vanilla strict schema.
See Streaming › AI SDK compatibility.
Why a server proxy at all
- The SDK refuses to construct in a browser bundle (
NovoConfigurationErrorwithbrowser_runtime). You'd accidentally leakNOVO_AGENTS_API_KEYto the page. - You probably want to authenticate or rate-limit the request before paying for a run. The proxy is where that lives.
- You may want to attach per-user metadata, log the run ID, or save it to your DB before returning.
Identifying the run before the body starts
stream.runId is a synchronous string the moment threads.stream() resolves, fed by the Novo-Run-Id response header that fires before the first body byte. Use it to log or persist before forwarding:
const stream = await novo.threads.stream(threadId, { input });
await db.runs.insert({ runId: stream.runId, userId });
return stream.toResponse();
Idempotency on retries
If the browser retries the POST (auto-retry on transient errors), pass an idempotencyKey so two POSTs with the same key don't kick two runs:
const stream = await novo.threads.stream(threadId, {
input,
idempotencyKey: requestUuid,
});
The second POST replays the first run's stream instead of starting a new one.