A Small Node.js Streaming Server for AI Agents

AI changed how we think about web servers.

For many years, a typical backend endpoint was simple:

  • receive a request
  • do some work
  • return JSON

That model is still useful. But AI applications often need something different.

A user sends a message, and the model starts working. It may produce partial text. It may call tools. It may need to show intermediate progress. It may take a few seconds, or longer. In this world, waiting for one final JSON response is not always the best user experience.

AI needs streaming.

Not always. Not everywhere. But for chat interfaces, agents, local LLMs, tool execution, reasoning traces, and long-running responses, streaming is a very natural fit.

And this is where the Vyriy server family becomes interesting.

Vyriy was already designed around small handlers, clear runtime boundaries, and calm architecture. The same handler idea can work in different places: Lambda, local Node.js, Docker, Fargate-style runtimes, and server-side tools.

With @vyriy/handler, @vyriy/server, and @vyriy/router, streaming is not a separate architecture. It is just another handler shape.

import { streamApi } from '@vyriy/handler';
import { streamServer } from '@vyriy/server';

const handler = streamApi((event, responseStream) => {
  responseStream.setContentType?.('text/plain');

  responseStream.write(`Path: ${event.path}\n`);
  responseStream.end('Done');
});

streamServer(handler);

This example is small, but the idea is important.

The server does not need to know whether the stream comes from a normal function, an AI model, a local LLM, a tool runner, or an agent workflow. The handler owns the response lifecycle and can write data as soon as it becomes available.

That makes it a good fit for AI.

Why streaming matters for AI

A thinking AI response is not always a single result.

It can be a sequence of events:

type AiStreamEvent =
  | { type: 'thinking'; text: string }
  | { type: 'delta'; text: string }
  | { type: 'tool_call'; name: string; input: unknown }
  | { type: 'tool_result'; name: string; output: unknown }
  | { type: 'final'; text: string }
  | { type: 'error'; message: string };

The user interface can react to each event.

It can show that the model has started working. It can render tokens as they arrive. It can display tool execution status. It can show the final answer when the run is complete.

This is useful for cloud AI APIs, but it is especially useful for local LLMs. When using tools like LM Studio, Ollama, or local MCP servers, streaming makes the system feel alive. The user does not wait in silence. The application can show progress immediately.

The Vyriy idea: handler first, runtime second

Many Node.js servers start with the framework.

You choose Express, Fastify, Hono, or NestJS, and then your application becomes shaped by that framework.

Vyriy takes a different route.

The important part is the handler.

The runtime is an adapter.

That means the same core logic can be reused in different environments.

For example, @vyriy/handler provides streamApi for response streaming handlers. The same stream handler can be used with AWS Lambda response streaming through awslambda.streamifyResponse, or locally through streamServer from @vyriy/server.

import { handler } from './handler.js';

export const main = awslambda.streamifyResponse(handler);

And locally:

import { streamServer } from '@vyriy/server';

import { handler } from './handler.js';

streamServer(handler);

This is a small architectural detail, but it matters.

For AI systems, you often want the same logic to work in several modes:

  • local development
  • Docker
  • Fargate-style long-running server
  • Lambda response streaming
  • internal agent runtime
  • MCP-style server
  • test/demo environment

Vyriy makes that separation explicit.

Streaming router

For a real application, one endpoint is not enough.

You may want:

  • /chat
  • /events
  • /agents/:id/run
  • /tools/:name
  • /healthcheck

This is where the stream router becomes useful.

import { createRouter as createStreamRouter } from '@vyriy/router/stream';

const streamRouter = createStreamRouter();

streamRouter.get('/events', ({ event, query }, responseStream) => {
  responseStream.setContentType?.('text/plain');

  responseStream.write(`path: ${event.path}\n`);
  responseStream.write(`cursor: ${query?.cursor ?? 'start'}\n`);

  responseStream.end('done');
});

streamRouter.fallback(({ event }, responseStream) => {
  responseStream.setContentType?.('application/json');

  responseStream.end(
    JSON.stringify({
      message: 'Not Found',
      path: event.path,
    }),
  );
});

export const handler = streamRouter.handle();

This keeps the AI server simple.

Routing is still routing. Streaming is still streaming. The handler remains the contract.

A small AI streaming example

Imagine a simple AI runtime that produces events.

type AiStreamEvent =
  { type: 'thinking'; text: string } | { type: 'delta'; text: string } | { type: 'final'; text: string };

async function* runAi(): AsyncGenerator<AiStreamEvent> {
  yield { type: 'thinking', text: 'Analyzing request...' };
  yield { type: 'delta', text: 'Hello' };
  yield { type: 'delta', text: ' from' };
  yield { type: 'delta', text: ' Vyriy' };
  yield { type: 'final', text: 'Hello from Vyriy' };
}

Now the stream handler can expose it:

import { streamApi } from '@vyriy/handler';

export const handler = streamApi(async (_event, responseStream) => {
  responseStream.setContentType?.('text/event-stream');

  for await (const item of runAi()) {
    responseStream.write(`event: ${item.type}\ndata: ${JSON.stringify(item)}\n\n`);
  }

  responseStream.end();
});

This is enough to build a small AI streaming endpoint.

The important part is that runAi() does not know about Lambda, Fargate, Node.js, HTTP, or SSE. It only yields events.

The transport layer decides how to deliver them.

That is calm architecture.

Lambda or Fargate?

AI streaming can run in both places, but they are not the same.

Lambda is useful when you want a small serverless endpoint. AWS Lambda response streaming works well with the streamApi shape because the handler writes directly to the response stream.

Fargate is useful when you want a long-running server, more control over networking, containers, local model proxies, or a more traditional service runtime.

The useful part is that the application code does not have to be rewritten from scratch.

You can keep the same core stream contract and change the adapter.

AI runtime
  ↓
AsyncGenerator<AiStreamEvent>
  ↓
Vyriy stream handler
  ↓
Lambda response streaming / local streamServer / container runtime

Why this matters

AI servers do not need to be huge.

A lot of AI infrastructure can start from a few simple ideas:

  • stream early
  • keep handlers small
  • separate the runtime from the application logic
  • make local development easy
  • keep cloud deployment boring
  • do not lock the application into one server framework too early

This is exactly where Vyriy fits.

It is not trying to be a large AI framework. It is not trying to hide the platform. It gives you small building blocks for clear server architecture.

For AI applications, that can be enough.

A lightweight streaming server is often better than a heavy framework when the main job is simple:

receive a request, run the model or agent, and stream events back to the client.

Final thought

The future of AI backends may not be one big framework.

It may be a set of small, composable contracts:

  • handler
  • router
  • stream
  • runtime adapter
  • tool execution
  • traces
  • deployment target

Vyriy already has the beginning of that shape.

And with streamApi, streamServer, and stream routing, it can be used as a small, fast Node.js server for AI streaming — locally, in containers, or in serverless environments.