novo agents

Stream into an Express route

stream.toResponse() returns a standard Web Response. Frameworks that speak Web Response natively (Next.js, Hono, Bun, Deno) can return it unchanged; see Stream into a Next.js route. Express predates the Web streams API, so the one real task here is bridging: copy the headers, then pipe the Web ReadableStream into Express's Node response.

The route

import { Readable } from 'node:stream';
import express from 'express';
import { NovoAgents } from 'novoagents';

const app = express();
app.use(express.json());

const novo = new NovoAgents();

app.post('/api/agent/:threadId', async (req, res) => {
  const { input } = req.body as { input: string };

  const stream = await novo.threads.stream(req.params.threadId, { input });
  const response = stream.toResponse();

  // Copy the SSE headers plus the Novo-*-Id headers (your client reads the
  // run id from them for reconnects).
  response.headers.forEach((value, key) => res.setHeader(key, value));
  res.status(response.status);

  // Bridge Web ReadableStream → Node stream.
  Readable.fromWeb(response.body!).pipe(res);
});

The traps

  • Compression middleware buffers SSE. If you use compression(), exclude this route (or set res.setHeader('Cache-Control', 'no-transform') and configure the middleware's filter to skip text/event-stream). A buffered SSE stream looks like a hung request that delivers everything at once.
  • Proxy timeouts. Whatever fronts Express (nginx, an ALB, a PaaS) needs its idle/response timeout raised for this route, or long runs get cut mid-stream. The client-side rule is unchanged either way: completion is the data-novo-terminal chunk, never EOF. A cut connection is a reconnect, not a finished run.
  • Client disconnects. If the browser goes away you can stop proxying (req.on('close', ...)), but remember the run keeps executing on Novo's side; reattach later with runs.stream(runId).

Consuming server-side instead

If Express is your final consumer (no browser), skip the bridge entirely:

const result = await stream.streamToCompletion();
res.json({ text: result.text, costCents: result.usage.costCents });