# Streaming

> Return Web Streams responses from functions: Server-Sent Events, streamed LLM output with ctx.ai, and NDJSON, with response streaming in production.

Functions stream responses, and consume streaming request bodies, with the Web Streams API (`ReadableStream`, `WritableStream`, `TransformStream`). Return a `Response` whose body is a `ReadableStream` and the runtime streams it to the client, both in local dev and in production. Chunks reach the client as they're produced instead of buffering in memory.

## Server-sent events

The most common case: push real-time updates to a client over one long-lived response.

```typescript
// functions/api/v1/events.get.ts, SSE stream
import { defineFunction } from '@trayai/helix-sdk';

export default defineFunction(async (ctx) => {
  const stream = new ReadableStream({
    start(controller) {
      const interval = setInterval(() => {
        const data = JSON.stringify({ time: Date.now(), status: 'ok' });
        controller.enqueue(`data: ${data}\n\n`);
      }, 1000);

      // Clean up when the client disconnects
      ctx.request.signal.addEventListener('abort', () => {
        clearInterval(interval);
        controller.close();
      });
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache',
      'Connection': 'keep-alive',
    },
  });
});
```

## Streaming an LLM response

Use `ctx.ai(alias)` with `streamText`. The platform injects the model provider's API key, so the credential never appears in your code, and the tokens reach the client as the model produces them:

```typescript
// functions/api/v1/chat.post.ts, stream an LLM response to the client
import { z } from 'zod';
import { defineFunction } from '@trayai/helix-sdk';

export const input = z.object({
  prompt: z.string().min(1),
});

export default defineFunction<typeof input>(async (ctx) => {
  const result = ctx.ai('openaiKey').streamText({
    model: 'gpt-4o',
    prompt: ctx.input.prompt,
  });

  return result.toUIMessageStreamResponse();
});
```

See [AI](/documentation/guides/ai/) for the providers and methods `ctx.ai` supports.

:::note{title="Authed HTTP calls are not streamed"}
A call made with `ctx.http.authed('alias')` returns only once the upstream response is complete, as `{ status, body, headers }`. There is no stream to pipe through, so a very large or slow response arrives all at once. For LLM output, use `ctx.ai` as above.
:::

### Transforming chunks

To inspect or modify chunks on the way through, read `result.textStream` and write into your own stream:

```typescript
// functions/api/v1/chat-with-logging.post.ts, stream and count chunks
import { z } from 'zod';
import { defineFunction } from '@trayai/helix-sdk';

export const input = z.object({
  prompt: z.string().min(1),
});

export default defineFunction<typeof input>(async (ctx) => {
  const result = ctx.ai('openaiKey').streamText({
    model: 'gpt-4o',
    prompt: ctx.input.prompt,
  });

  const encoder = new TextEncoder();
  let chunkCount = 0;

  const stream = new ReadableStream({
    async start(controller) {
      for await (const text of result.textStream) {
        chunkCount += 1;
        controller.enqueue(encoder.encode(text));
      }
      ctx.log.info('Stream complete', { chunkCount });
      controller.close();
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/plain; charset=utf-8',
      'Cache-Control': 'no-cache',
    },
  });
});
```

## NDJSON

Newline-delimited JSON streams structured data one complete JSON object per line:

```typescript
// functions/api/v1/export.get.ts, stream stored records as NDJSON
import { defineFunction } from '@trayai/helix-sdk';

export default defineFunction(async (ctx) => {
  const kv = ctx.kv();
  const ids = (await kv.getItem<string[]>('user:index')) ?? [];

  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();
      // Read one record at a time so the whole set is never held in memory
      for (const id of ids) {
        const user = await kv.getItem(`user:${id}`);
        if (user) controller.enqueue(encoder.encode(JSON.stringify(user) + '\n'));
      }
      controller.close();
    },
  });

  return new Response(stream, {
    headers: { 'Content-Type': 'application/x-ndjson' },
  });
});
```

## Streaming request bodies

Incoming requests stream too. `ctx.request.body` is a `ReadableStream` of the raw request, useful for large uploads you want to process chunk by chunk instead of holding in memory. Helix ships no file storage, so there is nowhere to write the bytes; what you can do today is consume each chunk as it arrives and keep the result:

```typescript
// functions/api/v1/upload.post.ts, hash a large upload chunk by chunk
import { createHash } from 'node:crypto';
import { defineFunction } from '@trayai/helix-sdk';

export default defineFunction(async (ctx) => {
  const reader = ctx.request.body.getReader();
  const hash = createHash('sha256');
  let totalBytes = 0;

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    totalBytes += value.byteLength;
    // Process each chunk as it arrives, never holding the whole body
    hash.update(value);
  }

  const sha256 = hash.digest('hex');
  await ctx.kv().setItem(`upload:${sha256}`, {
    bytes: totalBytes,
    receivedAt: new Date().toISOString(),
  });

  ctx.log.info('Upload complete', { totalBytes, sha256 });
  return { uploaded: true, bytes: totalBytes, sha256 };
});
```

## Client disconnects

`ctx.request.signal` is an `AbortSignal` that fires when the client disconnects, in local dev and in production alike. Listen for `abort` to stop timers, cancel upstream requests, and close stream controllers, as the SSE example above does. Without cleanup, a disconnected client leaves your function producing chunks nobody will receive.

## Streaming in production

Locally, `helix dev` serves streams over Node.js with no size limits. In production you return a `Response` with a `ReadableStream` body and the runtime streams it for you.

| Behavior | Production detail |
|----------|-------------------|
| Response size | Up to 20 MB streamed, versus 6 MB for buffered responses |
| First byte | Can reach the client before the function finishes |
| Timeout | The function timeout still applies (default 30 s) |
| Disconnects | `ctx.request.signal` fires on client disconnect |

:::note{title="Output schemas don't apply to streams"}
When a function returns a streaming `Response`, the `output` schema is not applied. A stream can't be validated before it has been fully produced, so output validation runs only on non-streaming responses (plain objects and JSON).
:::

---

Canonical: https://helix.tray.ai/documentation/guides/streaming/
Any link on this page is available as markdown by appending .md to its URL.
Full corpus: https://helix.tray.ai/documentation/llms-full.txt