Examples

Four small applications built with libfx. Each has a working demo, the source, and a prompt you can copy with all the code and setup instructions.

The source lives in the fx examples. These examples use libfx 0.0.8 and Node.js 24. You’ll need an AI Gateway API key to run them locally.

Node.js readline chat

A command-line chat built with Node.js and libfx. It keeps the conversation open for follow-up questions and prints replies as they arrive. The live demo is a single-prompt browser version.

Live demo · Source

chat.mjs

import { createInterface } from 'node:readline'
import { stdin, stdout } from 'node:process'
import { createFxAgent } from 'libfx'

const agent = await createFxAgent({
  apiKey: process.env.AI_GATEWAY_API_KEY,
  model: 'openai/gpt-4.1-nano',
})
const input = createInterface({ input: stdin, output: stdout, prompt: 'You: ' })

try {
  input.prompt()
  for await (const prompt of input) {
    if (prompt.trim() === '/exit') break
    if (!prompt.trim()) { input.prompt(); continue }
    stdout.write('Agent: ')
    const turn = agent.prompt(prompt)
    for await (const event of turn) {
      if (event.type === 'text_delta') stdout.write(event.delta)
    }
    await turn.result
    stdout.write('\n\n')
    input.prompt()
  }
} finally {
  input.close()
  await agent.close()
}
Setup and remaining files

Use Node.js 24. Keep the paths shown here, including examples/shared.

Create examples/node-chat/.env.local with your server-side AI_GATEWAY_API_KEY.

cd examples/node-chat
npm install
npm run chat

examples/node-chat/package.json

{
  "name": "fx-demo-node-chat",
  "private": true,
  "type": "module",
  "scripts": {
    "chat": "node --env-file-if-exists=.env.local chat.mjs",
    "dev": "node --env-file-if-exists=.env.local server.mjs"
  },
  "dependencies": {
    "libfx": "0.0.8"
  },
  "engines": {
    "node": "24.x"
  }
}

examples/node-chat/handler.mjs

import { createFxAgent } from 'libfx'
import { model } from '../shared/model.mjs'
import { errorResponse, gatewayFetch, readPrompt } from '../shared/gateway.mjs'

export async function POST(request) {
  try {
    const prompt = await readPrompt(request)
    const agent = await createFxAgent({ apiKey: process.env.AI_GATEWAY_API_KEY, model, fetch: gatewayFetch })
    async function* reply() {
      try {
        const turn = agent.prompt(prompt, { signal: request.signal })
        for await (const event of turn) {
          if (event.type === 'text_delta') yield event.delta
        }
        await turn.result
      } finally {
        await agent.close()
      }
    }
    return new Response(ReadableStream.from(reply()).pipeThrough(new TextEncoderStream()), {
      headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' },
    })
  } catch (error) {
    return errorResponse(error)
  }
}

examples/node-chat/server.mjs

import { createServer } from 'node:http'
import { readFile } from 'node:fs/promises'
import { Readable } from 'node:stream'
import { pipeline } from 'node:stream/promises'
import { POST } from './handler.mjs'

const html = await readFile(new URL('./index.html', import.meta.url))
const replyReader = await readFile(new URL('../shared/read-reply.mjs', import.meta.url))
createServer(async (incoming, outgoing) => {
  if (incoming.method === 'GET' && incoming.url === '/read-reply.mjs') {
    outgoing.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' }).end(replyReader)
    return
  }
  if (incoming.method === 'GET' && incoming.url === '/') {
    outgoing.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }).end(html)
    return
  }
  if (incoming.method !== 'POST' || incoming.url !== '/api/chat') {
    outgoing.writeHead(404).end('Not found')
    return
  }
  const controller = new AbortController()
  outgoing.on('close', () => controller.abort())
  try {
    const request = new Request('http://localhost/api/chat', {
      method: 'POST', headers: incoming.headers, body: Readable.toWeb(incoming),
      duplex: 'half', signal: controller.signal,
    })
    const response = await POST(request)
    outgoing.writeHead(response.status, Object.fromEntries(response.headers))
    await pipeline(Readable.fromWeb(response.body), outgoing)
  } catch {
    outgoing.destroy()
  }
}).listen(Number(process.env.PORT ?? 3000))

examples/node-chat/index.html

<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Node.js agent · fx examples</title>
<main>
  <h1>Node.js agent</h1>
  <p>A native libfx agent. Each request starts a new conversation.</p>
  <form>
    <label for="prompt">Prompt</label><br>
    <textarea id="prompt" name="prompt" rows="3" cols="30" required maxlength="2000">Explain closures in two sentences.</textarea><br>
    <button disabled>Send</button>
  </form>
  <p role="status" id="status"></p>
  <label for="reply">Reply</label><br>
  <textarea id="reply" rows="12" cols="30" readonly></textarea>
  <p><a href="https://github.com/vercel-labs/fx/blob/b9f8b733803f170d1a09cadf1bf5033e04bf44ed/examples/README.md#run-an-example">Code and setup</a></p>
</main>
<script type="module">
  import { readReply } from '/read-reply.mjs'

  const form = document.querySelector('form')
  const button = document.querySelector('button')
  const reply = document.querySelector('#reply')
  const status = document.querySelector('#status')
  form.addEventListener('submit', async (event) => {
    event.preventDefault()
    button.disabled = true
    reply.value = ''
    status.textContent = 'Replying…'
    try {
      const response = await fetch('/api/chat', {
        method: 'POST', headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ prompt: new FormData(form).get('prompt') }),
      })
      if (!response.ok) throw new Error(response.status === 429 ? 'Request limit reached. Try again later.' : await response.text())
      for await (const text of readReply(response)) reply.value += text
      status.textContent = 'Reply complete.'
    } catch (error) {
      status.textContent = error.message
    } finally {
      button.disabled = false
    }
  })
  button.disabled = false
</script>
</html>

examples/shared/model.mjs

export const model = 'openai/gpt-4.1-nano'

examples/shared/gateway.mjs

import { model } from './model.mjs'

function fail(status, message) {
  throw Object.assign(new Error(message), { status })
}

export async function readJson(request) {
  const reader = request.body?.getReader()
  if (!reader) fail(400, 'Send a JSON request body.')
  const chunks = []
  let size = 0
  try {
    while (true) {
      const { value, done } = await reader.read()
      if (done) break
      size += value.byteLength
      if (size > 32768) {
        await reader.cancel()
        fail(413, 'This conversation is too long. Start a new one.')
      }
      chunks.push(value)
    }
  } finally {
    reader.releaseLock()
  }
  const bytes = new Uint8Array(size)
  let offset = 0
  for (const chunk of chunks) {
    bytes.set(chunk, offset)
    offset += chunk.byteLength
  }
  try { return JSON.parse(new TextDecoder().decode(bytes)) }
  catch { fail(400, 'Send valid JSON.') }
}

export async function readPrompt(request) {
  const body = await readJson(request)
  const prompt = typeof body?.prompt === 'string' ? body.prompt.trim() : ''
  if (!prompt || prompt.length > 2000) fail(400, 'Enter a prompt of 1–2,000 characters.')
  return prompt
}

// All public examples share this transport policy; the agent code stays in each example.
export async function gatewayFetch(input, init) {
  const request = new Request(input, init)
  const url = new URL(request.url)
  const catalog = url.pathname === '/coding-agent/v1/models' && request.method === 'GET'
  const generation = url.pathname === '/v3/ai/language-model' && request.method === 'POST'
  if (url.origin !== 'https://ai-gateway.vercel.sh' || url.search || (!catalog && !generation)) {
    fail(404, 'Unknown model endpoint.')
  }
  const key = process.env.AI_GATEWAY_API_KEY
  if (!key) fail(503, 'The demo is not configured yet.')
  let body
  if (generation) {
    const input = await readJson(request)
    const prompt = input?.prompt
    if (!Array.isArray(prompt) || !prompt.length || prompt.length > 40) fail(400, 'Invalid conversation.')
    for (const message of prompt) {
      if (!message || !['system', 'user', 'assistant'].includes(message.role)) fail(400, 'Only text messages are supported.')
      if (message.role === 'system' && typeof message.content === 'string') continue
      if (!Array.isArray(message.content) || !message.content.every(
        (part) => part?.type === 'text' && typeof part.text === 'string',
      )) fail(400, 'Only text messages are supported.')
    }
    body = JSON.stringify({ prompt, maxOutputTokens: 512 })
  }
  const headers = new Headers({ authorization: `Bearer ${key}`, 'content-type': 'application/json' })
  if (generation) {
    headers.set('ai-language-model-id', model)
    headers.set('ai-language-model-streaming', 'true')
  }
  for (const name of ['ai-gateway-protocol-version', 'ai-language-model-specification-version']) {
    const value = request.headers.get(name)
    if (value) headers.set(name, value)
  }
  return fetch(new Request(url, {
    method: request.method, headers, body, redirect: 'error',
    signal: AbortSignal.any([request.signal, AbortSignal.timeout(30000)]),
  }))
}

export function errorResponse(error) {
  const status = Number.isInteger(error?.status) ? error.status : 502
  return new Response(status < 500 ? error.message : 'The demo could not respond. Try again shortly.', {
    status, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' },
  })
}

examples/shared/read-reply.mjs

export async function* readReply(response) {
  const reader = response.body.getReader()
  const decoder = new TextDecoder()
  let finished = false
  try {
    while (!finished) {
      const { value, done } = await reader.read()
      finished = done
      const text = decoder.decode(value, { stream: !done })
      if (text) yield text
    }
  } finally {
    if (!finished) await reader.cancel().catch(() => {})
    reader.releaseLock()
  }
}

Browser agent

A browser chat built with libfx and WebAssembly, using plain HTML without CSS or a UI framework. Use the free demo tokens or enter your own AI Gateway key to connect directly.

Live demo · Source

main.js

import { createFxAgent, supportsJspi } from 'libfx/browser'
import { model } from '../shared/model.mjs'

const form = document.querySelector('form')
const button = document.querySelector('button')
const reply = document.querySelector('#reply')
const status = document.querySelector('#status')
const keyInput = document.querySelector('#api-key')
let agent
let activeKey

if (!supportsJspi()) {
  status.textContent = 'This browser does not support WebAssembly JSPI. Try desktop Chrome or Edge.'
  button.disabled = true
}

form.addEventListener('submit', async (event) => {
  event.preventDefault()
  const apiKey = keyInput.value.trim()
  button.disabled = true
  keyInput.disabled = true
  reply.value = ''
  status.textContent = 'Replying…'
  try {
    if (apiKey !== activeKey) {
      await agent?.close()
      agent = undefined
    }
    agent ??= await createFxAgent({
      apiKey: apiKey || 'demo', model,
      fetch(url, init) {
        if (apiKey) return fetch(url, init)
        const path = new URL(url).pathname
        return fetch(`/api/gateway?path=${encodeURIComponent(path)}`, init)
      },
    })
    activeKey = apiKey
    const turn = agent.prompt(new FormData(form).get('prompt'))
    for await (const event of turn) {
      if (event.type === 'text_delta') reply.value += event.delta
    }
    const { stopReason } = await turn.result
    status.textContent = stopReason === 'refused' ? 'Request failed. See the reply for details.' : 'Reply complete.'
  } catch (error) {
    status.textContent = error.message === 'HostStreamFailed'
      ? 'Unable to connect to AI Gateway. Check your key and connection.'
      : error.message
  } finally {
    button.disabled = false
    keyInput.disabled = false
  }
})

window.addEventListener('pagehide', () => { void agent?.close() })
button.disabled = !supportsJspi()
Setup and remaining files

Use Node.js 24. Keep the paths shown here, including examples/shared.

Enter your AI Gateway API key in the page to connect directly. No server key is needed. To run the free-token proxy locally instead, set AI_GATEWAY_API_KEY in .env.local and use npx vercel dev.

cd examples/browser-agent
npm install
npm run dev

examples/browser-agent/package.json

{
  "name": "fx-demo-browser-agent",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build"
  },
  "dependencies": {
    "libfx": "0.0.8"
  },
  "devDependencies": {
    "vite": "8.2.2"
  },
  "engines": {
    "node": "24.x"
  }
}

examples/browser-agent/index.html

<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Browser agent · fx examples</title>
<main>
  <h1>Browser agent</h1>
  <p>libfx runs in this browser through WebAssembly. Reload to start a new conversation.</p>
  <label for="api-key">AI Gateway API key (optional)</label><br>
  <input id="api-key" type="password" size="30" autocomplete="off" autocapitalize="none" spellcheck="false" aria-describedby="key-help">
  <p id="key-help">Leave blank to use our proxy with some free tokens. Enter your own key to connect directly to AI Gateway using your balance. Your key is not saved. Changing it starts a new conversation.</p>
  <p><a href="https://vercel.com/docs/ai-gateway/authentication-and-byok/api-keys" target="_blank" rel="noreferrer">Get an AI Gateway API key</a></p>
  <form>
    <label for="prompt">Prompt</label><br>
    <textarea id="prompt" name="prompt" rows="3" cols="30" required maxlength="2000">Explain WebAssembly in two sentences.</textarea><br>
    <button disabled>Send</button>
  </form>
  <p role="status" id="status"></p>
  <label for="reply">Reply</label><br>
  <textarea id="reply" rows="12" cols="30" readonly></textarea>
  <p><a href="https://github.com/vercel-labs/fx/blob/8b8d12d2280556a23ae45667fe7ad157f2db882c/examples/README.md#run-an-example">Code and setup</a></p>
</main>
<script type="module" src="/main.js"></script>
</html>

examples/browser-agent/api/gateway.js

import { errorResponse, gatewayFetch } from '../../shared/gateway.mjs'

export default {
  async fetch(request) {
    try {
      const path = new URL(request.url).searchParams.get('path')
      const paths = ['/coding-agent/v1/models', '/v3/ai/language-model']
      if (!paths.includes(path)) return new Response('Not found', { status: 404 })
      const upstream = await gatewayFetch(new Request(`https://ai-gateway.vercel.sh${path}`, request))
      return new Response(upstream.body, {
        status: upstream.status,
        headers: { 'content-type': upstream.headers.get('content-type') ?? 'text/plain', 'cache-control': 'no-store' },
      })
    } catch (error) {
      return errorResponse(error)
    }
  },
}

examples/shared/model.mjs

export const model = 'openai/gpt-4.1-nano'

examples/shared/gateway.mjs

import { model } from './model.mjs'

function fail(status, message) {
  throw Object.assign(new Error(message), { status })
}

export async function readJson(request) {
  const reader = request.body?.getReader()
  if (!reader) fail(400, 'Send a JSON request body.')
  const chunks = []
  let size = 0
  try {
    while (true) {
      const { value, done } = await reader.read()
      if (done) break
      size += value.byteLength
      if (size > 32768) {
        await reader.cancel()
        fail(413, 'This conversation is too long. Start a new one.')
      }
      chunks.push(value)
    }
  } finally {
    reader.releaseLock()
  }
  const bytes = new Uint8Array(size)
  let offset = 0
  for (const chunk of chunks) {
    bytes.set(chunk, offset)
    offset += chunk.byteLength
  }
  try { return JSON.parse(new TextDecoder().decode(bytes)) }
  catch { fail(400, 'Send valid JSON.') }
}

export async function readPrompt(request) {
  const body = await readJson(request)
  const prompt = typeof body?.prompt === 'string' ? body.prompt.trim() : ''
  if (!prompt || prompt.length > 2000) fail(400, 'Enter a prompt of 1–2,000 characters.')
  return prompt
}

// All public examples share this transport policy; the agent code stays in each example.
export async function gatewayFetch(input, init) {
  const request = new Request(input, init)
  const url = new URL(request.url)
  const catalog = url.pathname === '/coding-agent/v1/models' && request.method === 'GET'
  const generation = url.pathname === '/v3/ai/language-model' && request.method === 'POST'
  if (url.origin !== 'https://ai-gateway.vercel.sh' || url.search || (!catalog && !generation)) {
    fail(404, 'Unknown model endpoint.')
  }
  const key = process.env.AI_GATEWAY_API_KEY
  if (!key) fail(503, 'The demo is not configured yet.')
  let body
  if (generation) {
    const input = await readJson(request)
    const prompt = input?.prompt
    if (!Array.isArray(prompt) || !prompt.length || prompt.length > 40) fail(400, 'Invalid conversation.')
    for (const message of prompt) {
      if (!message || !['system', 'user', 'assistant'].includes(message.role)) fail(400, 'Only text messages are supported.')
      if (message.role === 'system' && typeof message.content === 'string') continue
      if (!Array.isArray(message.content) || !message.content.every(
        (part) => part?.type === 'text' && typeof part.text === 'string',
      )) fail(400, 'Only text messages are supported.')
    }
    body = JSON.stringify({ prompt, maxOutputTokens: 512 })
  }
  const headers = new Headers({ authorization: `Bearer ${key}`, 'content-type': 'application/json' })
  if (generation) {
    headers.set('ai-language-model-id', model)
    headers.set('ai-language-model-streaming', 'true')
  }
  for (const name of ['ai-gateway-protocol-version', 'ai-language-model-specification-version']) {
    const value = request.headers.get(name)
    if (value) headers.set(name, value)
  }
  return fetch(new Request(url, {
    method: request.method, headers, body, redirect: 'error',
    signal: AbortSignal.any([request.signal, AbortSignal.timeout(30000)]),
  }))
}

export function errorResponse(error) {
  const status = Number.isInteger(error?.status) ? error.status : 502
  return new Response(status < 500 ? error.message : 'The demo could not respond. Try again shortly.', {
    status, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' },
  })
}

Next.js App Router

A small Next.js App Router app that streams replies from a libfx agent running on the server. A React form lets you send a prompt and read the response.

Live demo · Source

app/api/chat/route.js

import { createFxAgent } from 'libfx'
import { model } from '../../../../shared/model.mjs'
import { errorResponse, gatewayFetch, readPrompt } from '../../../../shared/gateway.mjs'

export async function POST(request) {
  try {
    const prompt = await readPrompt(request)
    const agent = await createFxAgent({ apiKey: process.env.AI_GATEWAY_API_KEY, model, fetch: gatewayFetch })
    async function* reply() {
      try {
        const turn = agent.prompt(prompt, { signal: request.signal })
        for await (const event of turn) {
          if (event.type === 'text_delta') yield event.delta
        }
        await turn.result
      } finally {
        await agent.close()
      }
    }
    return new Response(ReadableStream.from(reply()).pipeThrough(new TextEncoderStream()), {
      headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' },
    })
  } catch (error) {
    return errorResponse(error)
  }
}
Setup and remaining files

Use Node.js 24. Keep the paths shown here, including examples/shared.

Create examples/nextjs-agent/.env.local with your server-side AI_GATEWAY_API_KEY.

cd examples/nextjs-agent
npm install
npm run dev

examples/nextjs-agent/package.json

{
  "name": "fx-demo-nextjs-agent",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "libfx": "0.0.8",
    "next": "16.3.0",
    "react": "19.2.4",
    "react-dom": "19.2.4"
  },
  "engines": {
    "node": "24.x"
  }
}

examples/nextjs-agent/next.config.mjs

import { fileURLToPath } from 'node:url'

const examplesRoot = fileURLToPath(new URL('..', import.meta.url))
export default {
  turbopack: { root: examplesRoot },
  outputFileTracingRoot: examplesRoot,
}

examples/nextjs-agent/app/layout.js

export const metadata = { title: 'Next.js agent · fx examples' }

export default function Layout({ children }) {
  return <html lang="en"><body>{children}</body></html>
}

examples/nextjs-agent/app/page.js

'use client'

import { useEffect, useState } from 'react'
import { readReply } from '../../shared/read-reply.mjs'

export default function Page() {
  const [reply, setReply] = useState('')
  const [status, setStatus] = useState('')
  const [busy, setBusy] = useState(false)
  const [ready, setReady] = useState(false)
  useEffect(() => setReady(true), [])

  async function send(event) {
    event.preventDefault()
    const prompt = new FormData(event.currentTarget).get('prompt')
    setBusy(true)
    setReply('')
    setStatus('Replying…')
    try {
      const response = await fetch('/api/chat', {
        method: 'POST', headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ prompt }),
      })
      if (!response.ok) throw new Error(response.status === 429 ? 'Request limit reached. Try again later.' : await response.text())
      for await (const text of readReply(response)) setReply((previous) => previous + text)
      setStatus('Reply complete.')
    } catch (error) {
      setStatus(error.message)
    } finally {
      setBusy(false)
    }
  }

  return <main>
    <h1>Next.js agent</h1>
    <p>A native libfx agent in an App Router route. Each request starts a new conversation.</p>
    <form onSubmit={send}>
      <label htmlFor="prompt">Prompt</label><br />
      <textarea id="prompt" name="prompt" rows={3} cols={30} required maxLength={2000} defaultValue="Explain server components in two sentences." /><br />
      <button disabled={!ready || busy}>Send</button>
    </form>
    <p role="status">{status}</p>
    <label htmlFor="reply">Reply</label><br />
    <textarea id="reply" rows={12} cols={30} readOnly value={reply} />
    <p><a href="https://github.com/vercel-labs/fx/blob/b9f8b733803f170d1a09cadf1bf5033e04bf44ed/examples/README.md#run-an-example">Code and setup</a></p>
  </main>
}

examples/shared/model.mjs

export const model = 'openai/gpt-4.1-nano'

examples/shared/gateway.mjs

import { model } from './model.mjs'

function fail(status, message) {
  throw Object.assign(new Error(message), { status })
}

export async function readJson(request) {
  const reader = request.body?.getReader()
  if (!reader) fail(400, 'Send a JSON request body.')
  const chunks = []
  let size = 0
  try {
    while (true) {
      const { value, done } = await reader.read()
      if (done) break
      size += value.byteLength
      if (size > 32768) {
        await reader.cancel()
        fail(413, 'This conversation is too long. Start a new one.')
      }
      chunks.push(value)
    }
  } finally {
    reader.releaseLock()
  }
  const bytes = new Uint8Array(size)
  let offset = 0
  for (const chunk of chunks) {
    bytes.set(chunk, offset)
    offset += chunk.byteLength
  }
  try { return JSON.parse(new TextDecoder().decode(bytes)) }
  catch { fail(400, 'Send valid JSON.') }
}

export async function readPrompt(request) {
  const body = await readJson(request)
  const prompt = typeof body?.prompt === 'string' ? body.prompt.trim() : ''
  if (!prompt || prompt.length > 2000) fail(400, 'Enter a prompt of 1–2,000 characters.')
  return prompt
}

// All public examples share this transport policy; the agent code stays in each example.
export async function gatewayFetch(input, init) {
  const request = new Request(input, init)
  const url = new URL(request.url)
  const catalog = url.pathname === '/coding-agent/v1/models' && request.method === 'GET'
  const generation = url.pathname === '/v3/ai/language-model' && request.method === 'POST'
  if (url.origin !== 'https://ai-gateway.vercel.sh' || url.search || (!catalog && !generation)) {
    fail(404, 'Unknown model endpoint.')
  }
  const key = process.env.AI_GATEWAY_API_KEY
  if (!key) fail(503, 'The demo is not configured yet.')
  let body
  if (generation) {
    const input = await readJson(request)
    const prompt = input?.prompt
    if (!Array.isArray(prompt) || !prompt.length || prompt.length > 40) fail(400, 'Invalid conversation.')
    for (const message of prompt) {
      if (!message || !['system', 'user', 'assistant'].includes(message.role)) fail(400, 'Only text messages are supported.')
      if (message.role === 'system' && typeof message.content === 'string') continue
      if (!Array.isArray(message.content) || !message.content.every(
        (part) => part?.type === 'text' && typeof part.text === 'string',
      )) fail(400, 'Only text messages are supported.')
    }
    body = JSON.stringify({ prompt, maxOutputTokens: 512 })
  }
  const headers = new Headers({ authorization: `Bearer ${key}`, 'content-type': 'application/json' })
  if (generation) {
    headers.set('ai-language-model-id', model)
    headers.set('ai-language-model-streaming', 'true')
  }
  for (const name of ['ai-gateway-protocol-version', 'ai-language-model-specification-version']) {
    const value = request.headers.get(name)
    if (value) headers.set(name, value)
  }
  return fetch(new Request(url, {
    method: request.method, headers, body, redirect: 'error',
    signal: AbortSignal.any([request.signal, AbortSignal.timeout(30000)]),
  }))
}

export function errorResponse(error) {
  const status = Number.isInteger(error?.status) ? error.status : 502
  return new Response(status < 500 ? error.message : 'The demo could not respond. Try again shortly.', {
    status, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' },
  })
}

examples/shared/read-reply.mjs

export async function* readReply(response) {
  const reader = response.body.getReader()
  const decoder = new TextDecoder()
  let finished = false
  try {
    while (!finished) {
      const { value, done } = await reader.read()
      finished = done
      const text = decoder.decode(value, { stream: !done })
      if (text) yield text
    }
  } finally {
    if (!finished) await reader.cancel().catch(() => {})
    reader.releaseLock()
  }
}

Nuxt

A Nuxt app with a libfx agent running on the server. A Vue form sends prompts and shows replies as they arrive.

Live demo · Source

server/api/chat.post.js

import { createFxAgent } from 'libfx'
import { toWebRequest } from 'h3'
import { model } from '../../../shared/model.mjs'
import { errorResponse, gatewayFetch, readPrompt } from '../../../shared/gateway.mjs'

export default defineEventHandler(async (event) => {
  try {
    const request = toWebRequest(event)
    const prompt = await readPrompt(request)
    const agent = await createFxAgent({ apiKey: process.env.AI_GATEWAY_API_KEY, model, fetch: gatewayFetch })
    async function* reply() {
      try {
        const turn = agent.prompt(prompt, { signal: request.signal })
        for await (const event of turn) {
          if (event.type === 'text_delta') yield event.delta
        }
        await turn.result
      } finally {
        await agent.close()
      }
    }
    return new Response(ReadableStream.from(reply()).pipeThrough(new TextEncoderStream()), {
      headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' },
    })
  } catch (error) {
    return errorResponse(error)
  }
})
Setup and remaining files

Use Node.js 24. Keep the paths shown here, including examples/shared.

Create examples/nuxt-agent/.env.local with your server-side AI_GATEWAY_API_KEY.

cd examples/nuxt-agent
npm install
npm run dev

examples/nuxt-agent/package.json

{
  "name": "fx-demo-nuxt-agent",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "nuxt dev --dotenv .env.local",
    "build": "nuxt build",
    "start": "node .output/server/index.mjs"
  },
  "dependencies": {
    "libfx": "0.0.8",
    "nuxt": "4.5.2"
  },
  "engines": {
    "node": "24.x"
  }
}

examples/nuxt-agent/nuxt.config.ts

import { fileURLToPath } from 'node:url'

export default defineNuxtConfig({
  compatibilityDate: '2026-09-07',
  devtools: { enabled: false },
  nitro: {
    externals: { inline: [fileURLToPath(new URL('../shared/', import.meta.url))] },
  },
})

examples/nuxt-agent/app/app.vue

<script setup>
import { readReply } from '../../shared/read-reply.mjs'

const prompt = ref('Explain Vue reactivity in two sentences.')
const reply = ref('')
const status = ref('')
const busy = ref(false)
const ready = ref(false)
onMounted(() => { ready.value = true })
useHead({ title: 'Nuxt agent · fx examples', htmlAttrs: { lang: 'en' } })

async function send() {
  busy.value = true
  reply.value = ''
  status.value = 'Replying…'
  try {
    const response = await fetch('/api/chat', {
      method: 'POST', headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ prompt: prompt.value }),
    })
    if (!response.ok) throw new Error(response.status === 429 ? 'Request limit reached. Try again later.' : await response.text())
    for await (const text of readReply(response)) reply.value += text
    status.value = 'Reply complete.'
  } catch (error) {
    status.value = error.message
  } finally {
    busy.value = false
  }
}
</script>

<template>
  <main>
    <h1>Nuxt agent</h1>
    <p>A native libfx agent in a Nitro route. Each request starts a new conversation.</p>
    <form @submit.prevent="send">
      <label for="prompt">Prompt</label><br>
      <textarea id="prompt" v-model="prompt" name="prompt" rows="3" cols="30" required maxlength="2000" /><br>
      <button :disabled="!ready || busy">Send</button>
    </form>
    <p role="status">{{ status }}</p>
    <label for="reply">Reply</label><br>
    <textarea id="reply" :value="reply" rows="12" cols="30" readonly />
    <p><a href="https://github.com/vercel-labs/fx/blob/b9f8b733803f170d1a09cadf1bf5033e04bf44ed/examples/README.md#run-an-example">Code and setup</a></p>
  </main>
</template>

examples/shared/model.mjs

export const model = 'openai/gpt-4.1-nano'

examples/shared/gateway.mjs

import { model } from './model.mjs'

function fail(status, message) {
  throw Object.assign(new Error(message), { status })
}

export async function readJson(request) {
  const reader = request.body?.getReader()
  if (!reader) fail(400, 'Send a JSON request body.')
  const chunks = []
  let size = 0
  try {
    while (true) {
      const { value, done } = await reader.read()
      if (done) break
      size += value.byteLength
      if (size > 32768) {
        await reader.cancel()
        fail(413, 'This conversation is too long. Start a new one.')
      }
      chunks.push(value)
    }
  } finally {
    reader.releaseLock()
  }
  const bytes = new Uint8Array(size)
  let offset = 0
  for (const chunk of chunks) {
    bytes.set(chunk, offset)
    offset += chunk.byteLength
  }
  try { return JSON.parse(new TextDecoder().decode(bytes)) }
  catch { fail(400, 'Send valid JSON.') }
}

export async function readPrompt(request) {
  const body = await readJson(request)
  const prompt = typeof body?.prompt === 'string' ? body.prompt.trim() : ''
  if (!prompt || prompt.length > 2000) fail(400, 'Enter a prompt of 1–2,000 characters.')
  return prompt
}

// All public examples share this transport policy; the agent code stays in each example.
export async function gatewayFetch(input, init) {
  const request = new Request(input, init)
  const url = new URL(request.url)
  const catalog = url.pathname === '/coding-agent/v1/models' && request.method === 'GET'
  const generation = url.pathname === '/v3/ai/language-model' && request.method === 'POST'
  if (url.origin !== 'https://ai-gateway.vercel.sh' || url.search || (!catalog && !generation)) {
    fail(404, 'Unknown model endpoint.')
  }
  const key = process.env.AI_GATEWAY_API_KEY
  if (!key) fail(503, 'The demo is not configured yet.')
  let body
  if (generation) {
    const input = await readJson(request)
    const prompt = input?.prompt
    if (!Array.isArray(prompt) || !prompt.length || prompt.length > 40) fail(400, 'Invalid conversation.')
    for (const message of prompt) {
      if (!message || !['system', 'user', 'assistant'].includes(message.role)) fail(400, 'Only text messages are supported.')
      if (message.role === 'system' && typeof message.content === 'string') continue
      if (!Array.isArray(message.content) || !message.content.every(
        (part) => part?.type === 'text' && typeof part.text === 'string',
      )) fail(400, 'Only text messages are supported.')
    }
    body = JSON.stringify({ prompt, maxOutputTokens: 512 })
  }
  const headers = new Headers({ authorization: `Bearer ${key}`, 'content-type': 'application/json' })
  if (generation) {
    headers.set('ai-language-model-id', model)
    headers.set('ai-language-model-streaming', 'true')
  }
  for (const name of ['ai-gateway-protocol-version', 'ai-language-model-specification-version']) {
    const value = request.headers.get(name)
    if (value) headers.set(name, value)
  }
  return fetch(new Request(url, {
    method: request.method, headers, body, redirect: 'error',
    signal: AbortSignal.any([request.signal, AbortSignal.timeout(30000)]),
  }))
}

export function errorResponse(error) {
  const status = Number.isInteger(error?.status) ? error.status : 502
  return new Response(status < 500 ? error.message : 'The demo could not respond. Try again shortly.', {
    status, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' },
  })
}

examples/shared/read-reply.mjs

export async function* readReply(response) {
  const reader = response.body.getReader()
  const decoder = new TextDecoder()
  let finished = false
  try {
    while (!finished) {
      const { value, done } = await reader.read()
      finished = done
      const text = decoder.decode(value, { stream: !done })
      if (text) yield text
    }
  } finally {
    if (!finished) await reader.cancel().catch(() => {})
    reader.releaseLock()
  }
}

Public demo limits

The free proxy uses GPT-4.1 nano with a shared daily budget and per-visitor request limits. Replies through the proxy are limited to 512 output tokens. In the browser example, entering your own key bypasses the proxy and uses your AI Gateway balance.

For an app of your own, add authentication and choose limits that fit its users. The shared transport is the policy for these public demos, not a requirement of libfx.