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 setres.setHeader('Cache-Control', 'no-transform')and configure the middleware'sfilterto skiptext/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-terminalchunk, 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 withruns.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 });