---
title: "Streaming"
description: "Metered, persisted token streams — ai.stream on the server, the /ai/stream endpoint, and useAiStream on the client; reload mid-stream and the text continues."
canonical_url: "https://nuxt-backend.dev/docs/ai/streaming"
---
# Streaming

> Metered, persisted token streams — ai.stream on the server, the /ai/stream endpoint, and useAiStream on the client; reload mid-stream and the text continues.

`ai.stream(config)` is the streaming shape of a [metered action](https://nuxt-backend.dev/docs/ai/metered-actions): the same rate limit and credit reservation, but the handler pushes chunks to the browser over HTTP **while the text also persists server-side** (the upstream persistent-text-streaming component). Reload mid-stream — or open the same stream on another device — and the persisted body continues reactively. Credits settle only when the stream finishes; an error or disconnect releases them, so an interrupted stream never charges — and a stream that is started but never driven (the tab closed before the HTTP call) holds its reservation only until `streamTimeout` (default 5 minutes) releases it.

## Server

```ts [backend/ai.ts]
export const { start: startEcho, body: echoBody } = ai.stream({
  name: 'echo',            // registry key — unique per app; the HTTP dispatcher routes by it
  meter: 'credits',        // or false for limit-only
  cost: 1,                 // number, (args) => number, or { estimate, finalize }
  limit: 'ai',
  args: { prompt: v.string() },
  handler: async (ctx, { prompt }, { append }) => {
    for (const word of prompt.split(' ')) {
      await append(word + ' ')   // pushes the chunk to the client AND persists it
    }
  },
})
```

`cost` takes the same shapes as a metered action, including `{ estimate, finalize }` — `finalize` prices what the handler *returns*, so return the model's token usage from it (see [Token pricing](https://nuxt-backend.dev/docs/ai/metered-actions#token-pricing)); `allowOverage` lets a pay-as-you-go meter go negative, as on `meteredAction`.

`ai.stream` returns two functions to re-export from your module:

- **start** — a public action: rate-limits, reserves credits, creates the persisted stream, records the request (`components.backend.ai.createRequest`), arms the `streamTimeout` release, and returns `{ streamId }`.
- **body** — a public query taking `{ streamId }`: the persisted text and status, reactive.

The third piece is the HTTP endpoint `registerBackendRoutes({ ai })` mounts at **/ai/stream** (POST, plus OPTIONS for the browser's CORS preflight — `Access-Control-Allow-Origin` is `SITE_URL`). It receives `{ streamId }`, looks the request up (`404` unless it exists and is still `reserved`), runs the registered handler with `ctx.usage`, and streams `append`ed chunks back. Possession of the unguessable stream id is the capability: the authenticated `start` action handed it to this caller moments ago.

When the handler finishes the spend is **settled** and the request marked `settled`; when it throws (or the ingest fails) the reservation is **released** and the request marked `released`. A `start` never followed by the HTTP call holds its reservation until the `streamTimeout` timer (`setupAi`, default 5 minutes; `false` disables it) releases it — scheduled at `start`, cancelled on settle.

## Client: `useAiStream()`

```vue
<script setup lang="ts">
import { api } from '#backend/api'

const echo = useAiStream({ start: api.ai.startEcho, body: api.ai.echoBody })
const prompt = ref('')
</script>

<template>
  <input v-model="prompt">
  <button :disabled="echo.isStreaming.value" @click="echo.start({ prompt })">Generate</button>
  <button v-if="echo.isStreaming.value" @click="echo.stop()">Stop</button>
  <pre>{{ echo.text.value }}</pre>
  <small>{{ echo.status.value }} · {{ echo.streamId.value }}</small>
</template>
```

<field-group>
<field name="start(args?)" type="Promise<string>">

Runs the `start` action (credits reserved, rate limit checked), then drives the HTTP stream with a `fetch` to `<convex site url>/ai/stream`. Resolves with the stream id as soon as driving begins. A refused start rejects, and `status` ends in `'error'`.

</field>

<field name="error" type="ComputedRef<BackendError | null>">

The refusal of the last `start` — `insufficient_credits`, `rate_limited`, `unauthenticated` — read with [`getBackendError`](https://nuxt-backend.dev/docs/data/errors). Cleared when the next `start` begins.

</field>

<field name="text" type="ComputedRef<string>">

The streamed text so far — live from the driving fetch while this client drives, the persisted body otherwise.

</field>

<field name="status" type="'idle' | 'pending' | 'streaming' | 'done' | 'error' | 'timeout'">

Driving status, or the persisted stream's status when watching.

</field>

<field name="isStreaming / streamId">

Whether a stream is in flight, and the active stream's id — hand it to another client to watch along.

</field>

<field name="stop()">

Aborts the driving fetch. The server releases the unfinished spend.

</field>
</field-group>

Options: `streamId` (attach to an existing stream in viewer mode — after a reload, or on another device — instead of driving a new one), `url` (override the endpoint; default `<runtimeConfig.public.convex.siteUrl>/ai/stream`), `headers` (extra headers on the driving fetch).

### Resuming after a reload

Persist the id where it makes sense (route query, local state) and pass it back:

```vue
<script setup lang="ts">
const route = useRoute()
const echo = useAiStream(
  { start: api.ai.startEcho, body: api.ai.echoBody },
  { streamId: computed(() => route.query.stream as string | undefined) },
)
</script>
```

If the driving fetch fails mid-way (network drop), `useAiStream` logs it and **falls back to the persisted body** automatically — whatever the server already appended keeps rendering, and the server side decides settle-vs-release independently of the client.

## Customizing the route

`registerBackendRoutes({ ai, aiPath: '/streams' })` moves the endpoint; pass the matching `url` to `useAiStream`. Set `cors: { origin }` on `setupAi` when the app origin differs from `SITE_URL`.

<page-keys>

- Try it live in the playground: [/playground/platform/ai](https://nuxt-backend.dev/playground/platform/ai)
- API reference: [/docs/reference/api/use-ai-stream](https://nuxt-backend.dev/docs/reference/api/use-ai-stream)
- Source: [https://github.com/qruto/nuxt-backend/blob/main/src/runtime/vue/composables/use-ai-stream.ts](https://github.com/qruto/nuxt-backend/blob/main/src/runtime/vue/composables/use-ai-stream.ts)

</page-keys>


## Sitemap

See the full [sitemap](https://nuxt-backend.dev/sitemap.md) for all pages.
