novo agents

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 (NovoConfigurationError with browser_runtime). You'd accidentally leak NOVO_AGENTS_API_KEY to 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.