diff --git a/examples/README.md b/examples/README.md
index 08223c876..9217850fb 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -9,6 +9,7 @@ Choose an example based on your use case:
- **Want a full-stack TypeScript app?** → [TanStack Chat (ts-react-chat)](#tanstack-chat-ts-react-chat)
- **Need a vanilla JS frontend?** → [Vanilla Chat](#vanilla-chat)
- **Multi-User TypeScript chat app?** → [Group Chat (ts-group-chat)](#group-chat-ts-group-chat)
+- **Polyglot AG-UI backends (Go/Rust/PHP/Zig/Bash/Python)?** → [AG-UI Polyglot Echo (ag-ui)](#ag-ui-polyglot-echo-ag-ui)
## TypeScript Examples
@@ -122,6 +123,45 @@ pnpm start
---
+### AG-UI Polyglot Echo (ag-ui)
+
+A React SPA that connects to **Go, Rust, PHP, Zig, Bash, and Python chat servers** over the AG-UI SSE protocol, with each backend streaming OpenAI or Anthropic completions. Toolchain detection writes `public/servers.json`; unavailable backends show setup instructions in the UI.
+
+**Tech Stack:**
+
+- React + Vite (SPA)
+- `@tanstack/ai-react` + `@tanstack/ai-react-ui`
+- Go chat server (`net/http`, `:8001`)
+- Rust chat server (Axum, `:8002`)
+- PHP chat server (built-in server + curl, `:8003`)
+- Zig chat server (stdlib HTTP, `:8004`)
+- Bash chat server (socat + curl + jq, `:8005`)
+- Python chat server (stdlib HTTP + urllib, `:8006`)
+
+**Features:**
+
+- ✅ Backend picker (Go | Rust | PHP | Zig | Bash | Python)
+- ✅ Toolchain-gated `dev:all` + `servers.json` availability
+- ✅ Setup instructions when a runtime is missing (or disabled via `AGUI_DISABLE_SERVERS`)
+- ✅ Provider picker (OpenAI | Anthropic)
+- ✅ Hand-rolled AG-UI SSE in six languages
+- ✅ Streaming LLM responses via env API keys
+
+**Getting Started:**
+
+```bash
+cd examples/ag-ui
+pnpm install
+cp .env.example .env
+pnpm dev:all
+```
+
+Install whichever backends you want to run locally (Go, Rust, PHP, Zig, Bash, Python) plus provider API keys. The Bash server uses Bash 4+, curl, jq, and socat (`brew install bash jq socat`). To simulate missing runtimes: `AGUI_DISABLE_SERVERS=php,zig,bash,python pnpm dev:all`.
+
+📖 [Full Documentation](ag-ui/README.md)
+
+---
+
## Architecture Patterns
### Full-Stack TypeScript
diff --git a/examples/ag-ui/.env.example b/examples/ag-ui/.env.example
new file mode 100644
index 000000000..154165a59
--- /dev/null
+++ b/examples/ag-ui/.env.example
@@ -0,0 +1,10 @@
+# OpenAI
+OPENAI_API_KEY=your_openai_api_key_here
+
+# Anthropic
+ANTHROPIC_API_KEY=your_anthropic_api_key_here
+
+# Optional: comma-separated backend ids to treat as unavailable even when
+# the toolchain is installed (useful for testing setup instructions).
+# Example: AGUI_DISABLE_SERVERS=php,zig
+AGUI_DISABLE_SERVERS=
diff --git a/examples/ag-ui/.gitignore b/examples/ag-ui/.gitignore
new file mode 100644
index 000000000..25bdc3ee9
--- /dev/null
+++ b/examples/ag-ui/.gitignore
@@ -0,0 +1,8 @@
+dist
+node_modules
+servers/rust/target
+servers/zig/.zig-cache
+servers/zig/zig-out
+servers/python/__pycache__
+
+servers/go/go
diff --git a/examples/ag-ui/README.md b/examples/ag-ui/README.md
new file mode 100644
index 000000000..770997aaf
--- /dev/null
+++ b/examples/ag-ui/README.md
@@ -0,0 +1,122 @@
+# AG-UI Polyglot Chat
+
+A React SPA that talks to **Go, Rust, PHP, Zig, Bash, and Python AG-UI backends** over Server-Sent Events (SSE). Each backend streams simple chat completions from **OpenAI** or **Anthropic** — no TanStack packages on the server, just the AG-UI wire protocol.
+
+This example shows that any backend can serve `@tanstack/ai-react` clients as long as it speaks [AG-UI](https://docs.ag-ui.com/): accept `RunAgentInput` via POST, stream AG-UI events as SSE.
+
+## Tech stack
+
+| Layer | Stack |
+| ------------- | ------------------------------------------------------------------------ |
+| Client | React, Vite, `@tanstack/ai-react`, `@tanstack/ai-react-ui` |
+| Go server | `net/http`, hand-rolled AG-UI SSE, OpenAI/Anthropic streaming on `:8001` |
+| Rust server | Axum, hand-rolled AG-UI SSE, OpenAI/Anthropic streaming on `:8002` |
+| PHP server | Built-in PHP server + curl, hand-rolled AG-UI SSE on `:8003` |
+| Zig server | Stdlib HTTP + HTTPS client, hand-rolled AG-UI SSE on `:8004` |
+| Bash server | Bash + socat + curl + jq, hand-rolled AG-UI SSE on `:8005` |
+| Python server | Stdlib HTTP + urllib, hand-rolled AG-UI SSE on `:8006` |
+
+## Prerequisites
+
+- Node.js + pnpm (from repo root)
+- [Go 1.22+](https://go.dev/dl/) (optional — UI shows setup if missing)
+- [Rust stable](https://rustup.rs/) (optional)
+- [PHP 8.2+ CLI with curl](https://www.php.net/downloads) (optional)
+- [Zig](https://ziglang.org/download/) (optional)
+- Bash 4+, curl, jq, and socat (`brew install bash jq socat`) (optional)
+- [Python 3.9+](https://www.python.org/downloads/) (optional)
+- OpenAI and/or Anthropic API keys
+
+Only backends whose toolchains are detected on PATH are started by `pnpm dev:all`. The UI reads `public/servers.json` to show which servers are available and displays setup instructions for missing ones.
+
+## Quick start
+
+From the monorepo root:
+
+```bash
+pnpm install
+cd examples/ag-ui
+cp .env.example .env
+# Add OPENAI_API_KEY and/or ANTHROPIC_API_KEY
+pnpm dev:all
+```
+
+Open [http://localhost:3000](http://localhost:3000), pick a backend tab, choose a provider/model, and chat.
+
+### Simulate missing toolchains
+
+If your machine has every runtime installed but you want to test the setup UI:
+
+```bash
+AGUI_DISABLE_SERVERS=php,zig pnpm dev:all
+```
+
+PHP and Zig tabs will show install instructions; Go and Rust still connect normally.
+
+### Run pieces separately
+
+```bash
+# Terminal 1 — Vite dev server (proxies /api/* and serves servers.json)
+pnpm dev
+
+# Terminal 2+ — individual backends
+pnpm dev:go
+pnpm dev:rust
+pnpm dev:php
+pnpm dev:zig
+pnpm dev:bash
+pnpm dev:python
+```
+
+`pnpm dev:all` loads `.env`, writes `public/servers.json`, and starts the client plus every detected backend.
+
+Refresh availability without restarting everything:
+
+```bash
+pnpm detect-servers
+```
+
+## Architecture
+
+```
+React SPA (useChat + fetchServerSentEvents)
+ GET /servers.json ──► generated backend availability
+ POST /api/go ──► Vite proxy ──► Go :8001 ──► OpenAI / Anthropic
+ POST /api/rust ──► Vite proxy ──► Rust :8002 ──► OpenAI / Anthropic
+ POST /api/php ──► Vite proxy ──► PHP :8003 ──► OpenAI / Anthropic
+ POST /api/zig ──► Vite proxy ──► Zig :8004 ──► OpenAI / Anthropic
+ POST /api/bash ──► Vite proxy ──► Bash :8005 ──► OpenAI / Anthropic
+ POST /api/python ──► Vite proxy ──► Python :8006 ──► OpenAI / Anthropic
+```
+
+The client sends AG-UI `RunAgentInput` with `forwardedProps: { provider, model }`. Each server converts simple text messages, streams the provider response, and emits:
+
+```
+RUN_STARTED
+TEXT_MESSAGE_START
+TEXT_MESSAGE_CONTENT (streamed)
+TEXT_MESSAGE_END
+RUN_FINISHED
+data: [DONE]
+```
+
+## Project layout
+
+```
+examples/ag-ui/
+├── public/servers.json generated backend availability (fallback committed)
+├── scripts/ detect toolchains, write servers.json, dev:all
+├── src/ React SPA
+├── servers/go/ Go AG-UI + LLM server
+├── servers/rust/ Rust AG-UI + LLM server
+├── servers/php/ PHP AG-UI + LLM server
+├── servers/zig/ Zig AG-UI + LLM server
+├── servers/bash/ Bash AG-UI + LLM server
+└── servers/python/ Python AG-UI + LLM server
+```
+
+## Related docs
+
+- [AG-UI compliance migration](../../docs/migration/ag-ui-compliance.md)
+- [Connection adapters](../../docs/chat/connection-adapters.md)
+- [Chat architecture](../../packages/ai/docs/chat-architecture.md)
diff --git a/examples/ag-ui/bash-server.png b/examples/ag-ui/bash-server.png
new file mode 100644
index 000000000..7e488dfc3
Binary files /dev/null and b/examples/ag-ui/bash-server.png differ
diff --git a/examples/ag-ui/index.html b/examples/ag-ui/index.html
new file mode 100644
index 000000000..fbfbc195f
--- /dev/null
+++ b/examples/ag-ui/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ TanStack AI - AG-UI
+
+
+
+
+
+
diff --git a/examples/ag-ui/package.json b/examples/ag-ui/package.json
new file mode 100644
index 000000000..593810f58
--- /dev/null
+++ b/examples/ag-ui/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "ag-ui",
+ "private": true,
+ "type": "module",
+ "version": "0.0.1",
+ "scripts": {
+ "detect-servers": "node scripts/detect-servers.mjs",
+ "predev": "node scripts/detect-servers.mjs",
+ "dev": "vite --port 3000",
+ "dev:go": "go run -C servers/go .",
+ "dev:rust": "cargo run --manifest-path servers/rust/Cargo.toml",
+ "dev:php": "php -S 127.0.0.1:8003 -t servers/php servers/php/router.php",
+ "dev:zig": "zig build run --build-file servers/zig/build.zig",
+ "dev:bash": "bash servers/bash/server.sh",
+ "dev:python": "python3 servers/python/server.py",
+ "dev:all": "node scripts/dev-all.mjs",
+ "build": "vite build",
+ "preview": "vite preview",
+ "test:types": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@tanstack/ai-client": "workspace:*",
+ "@tanstack/ai-react": "workspace:*",
+ "@tanstack/ai-react-ui": "workspace:*",
+ "react": "^19.2.3",
+ "react-dom": "^19.2.3"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.1.18",
+ "@types/react": "^19.2.7",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^5.1.2",
+ "concurrently": "^9.1.2",
+ "dotenv": "^17.2.3",
+ "tailwindcss": "^4.1.18",
+ "typescript": "5.9.3",
+ "vite": "^7.3.3"
+ }
+}
diff --git a/examples/ag-ui/public/servers.json b/examples/ag-ui/public/servers.json
new file mode 100644
index 000000000..e4e991c0f
--- /dev/null
+++ b/examples/ag-ui/public/servers.json
@@ -0,0 +1,83 @@
+{
+ "generatedAt": "2026-07-20T00:20:05.964Z",
+ "servers": [
+ {
+ "id": "go",
+ "label": "Go",
+ "port": 8001,
+ "available": true,
+ "disabledByEnv": false,
+ "setup": {
+ "summary": "Install Go 1.22+ and ensure `go` is on PATH.",
+ "installUrl": "https://go.dev/dl/",
+ "verify": "go version",
+ "run": "pnpm dev:go"
+ }
+ },
+ {
+ "id": "rust",
+ "label": "Rust",
+ "port": 8002,
+ "available": true,
+ "disabledByEnv": false,
+ "setup": {
+ "summary": "Install Rust stable via rustup and ensure `cargo` is on PATH.",
+ "installUrl": "https://rustup.rs/",
+ "verify": "cargo --version",
+ "run": "pnpm dev:rust"
+ }
+ },
+ {
+ "id": "php",
+ "label": "PHP",
+ "port": 8003,
+ "available": true,
+ "disabledByEnv": false,
+ "setup": {
+ "summary": "Install PHP 8.2+ CLI and ensure `php` is on PATH.",
+ "installUrl": "https://www.php.net/downloads",
+ "verify": "php -v",
+ "run": "pnpm dev:php"
+ }
+ },
+ {
+ "id": "zig",
+ "label": "Zig",
+ "port": 8004,
+ "available": true,
+ "disabledByEnv": false,
+ "setup": {
+ "summary": "Install Zig and ensure `zig` is on PATH.",
+ "installUrl": "https://ziglang.org/download/",
+ "verify": "zig version",
+ "run": "pnpm dev:zig"
+ }
+ },
+ {
+ "id": "bash",
+ "label": "Bash",
+ "port": 8005,
+ "available": true,
+ "disabledByEnv": false,
+ "setup": {
+ "summary": "Install Bash 4+, curl, jq, and socat and ensure they are on PATH.",
+ "installUrl": "https://brew.sh/",
+ "verify": "bash --version && curl --version && jq --version && socat -V",
+ "run": "pnpm dev:bash"
+ }
+ },
+ {
+ "id": "python",
+ "label": "Python",
+ "port": 8006,
+ "available": true,
+ "disabledByEnv": false,
+ "setup": {
+ "summary": "Install Python 3.9+ and ensure `python3` is on PATH.",
+ "installUrl": "https://www.python.org/downloads/",
+ "verify": "python3 --version",
+ "run": "pnpm dev:python"
+ }
+ }
+ ]
+}
diff --git a/examples/ag-ui/python-server.png b/examples/ag-ui/python-server.png
new file mode 100644
index 000000000..624d9f84d
Binary files /dev/null and b/examples/ag-ui/python-server.png differ
diff --git a/examples/ag-ui/scripts/detect-servers.mjs b/examples/ag-ui/scripts/detect-servers.mjs
new file mode 100644
index 000000000..a39e9dfcc
--- /dev/null
+++ b/examples/ag-ui/scripts/detect-servers.mjs
@@ -0,0 +1,10 @@
+import {
+ detectServers,
+ logDetectionSummary,
+ outputPath,
+ writeServersJson,
+} from './servers.mjs'
+
+const { servers } = writeServersJson()
+logDetectionSummary(detectServers())
+console.log(`[ag-ui] wrote ${outputPath}`)
diff --git a/examples/ag-ui/scripts/dev-all.mjs b/examples/ag-ui/scripts/dev-all.mjs
new file mode 100644
index 000000000..1cf64141e
--- /dev/null
+++ b/examples/ag-ui/scripts/dev-all.mjs
@@ -0,0 +1,62 @@
+import { spawn } from 'node:child_process'
+import { config as loadEnv } from 'dotenv'
+import {
+ detectServers,
+ exampleDir,
+ logDetectionSummary,
+ writeServersJson,
+} from './servers.mjs'
+
+loadEnv({ path: `${exampleDir}/.env`, quiet: true })
+
+const detected = detectServers()
+writeServersJson()
+logDetectionSummary(detected)
+
+const available = detected.filter((server) => server.available)
+const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
+
+const names = ['client', ...available.map((server) => server.id)]
+const colors = [
+ 'cyan',
+ 'green',
+ 'yellow',
+ 'magenta',
+ 'blue',
+ 'red',
+ 'white',
+].slice(0, names.length)
+const commands = [
+ 'pnpm dev',
+ ...available.map((server) => `pnpm ${server.devScript}`),
+]
+
+const args = [
+ 'exec',
+ 'concurrently',
+ '-n',
+ names.join(','),
+ '-c',
+ colors.join(','),
+]
+for (const command of commands) {
+ args.push(command)
+}
+
+console.log(
+ `[ag-ui] starting ${commands.length} process(es): ${names.join(', ')}`,
+)
+
+const child = spawn(pnpm, args, {
+ cwd: exampleDir,
+ env: process.env,
+ stdio: 'inherit',
+ shell: process.platform === 'win32',
+})
+
+child.on('exit', (code) => {
+ process.exit(code ?? 0)
+})
+
+process.on('SIGINT', () => child.kill('SIGINT'))
+process.on('SIGTERM', () => child.kill('SIGTERM'))
diff --git a/examples/ag-ui/scripts/servers.mjs b/examples/ag-ui/scripts/servers.mjs
new file mode 100644
index 000000000..5adf7c91d
--- /dev/null
+++ b/examples/ag-ui/scripts/servers.mjs
@@ -0,0 +1,176 @@
+import { execFileSync } from 'node:child_process'
+import { mkdirSync, writeFileSync } from 'node:fs'
+import path from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const exampleDir = path.resolve(fileURLToPath(new URL('..', import.meta.url)))
+const outputPath = path.join(exampleDir, 'public', 'servers.json')
+
+/** @type {const} */
+export const SERVER_CATALOG = [
+ {
+ id: 'go',
+ label: 'Go',
+ port: 8001,
+ detect: [['go', 'version']],
+ devScript: 'dev:go',
+ setup: {
+ summary: 'Install Go 1.22+ and ensure `go` is on PATH.',
+ installUrl: 'https://go.dev/dl/',
+ verify: 'go version',
+ run: 'pnpm dev:go',
+ },
+ },
+ {
+ id: 'rust',
+ label: 'Rust',
+ port: 8002,
+ detect: [['cargo', '--version']],
+ devScript: 'dev:rust',
+ setup: {
+ summary: 'Install Rust stable via rustup and ensure `cargo` is on PATH.',
+ installUrl: 'https://rustup.rs/',
+ verify: 'cargo --version',
+ run: 'pnpm dev:rust',
+ },
+ },
+ {
+ id: 'php',
+ label: 'PHP',
+ port: 8003,
+ detect: [['php', '-v']],
+ devScript: 'dev:php',
+ setup: {
+ summary: 'Install PHP 8.2+ CLI and ensure `php` is on PATH.',
+ installUrl: 'https://www.php.net/downloads',
+ verify: 'php -v',
+ run: 'pnpm dev:php',
+ },
+ },
+ {
+ id: 'zig',
+ label: 'Zig',
+ port: 8004,
+ detect: [['zig', 'version']],
+ devScript: 'dev:zig',
+ setup: {
+ summary: 'Install Zig and ensure `zig` is on PATH.',
+ installUrl: 'https://ziglang.org/download/',
+ verify: 'zig version',
+ run: 'pnpm dev:zig',
+ },
+ },
+ {
+ id: 'bash',
+ label: 'Bash',
+ port: 8005,
+ detect: [
+ ['bash', '-c', '(( BASH_VERSINFO[0] >= 4 ))'],
+ ['curl', '--version'],
+ ['jq', '--version'],
+ ['socat', '-V'],
+ ],
+ devScript: 'dev:bash',
+ setup: {
+ summary:
+ 'Install Bash 4+, curl, jq, and socat and ensure they are on PATH.',
+ installUrl: 'https://brew.sh/',
+ verify: 'bash --version && curl --version && jq --version && socat -V',
+ run: 'pnpm dev:bash',
+ },
+ },
+ {
+ id: 'python',
+ label: 'Python',
+ port: 8006,
+ detect: [['python3', '--version']],
+ devScript: 'dev:python',
+ setup: {
+ summary: 'Install Python 3.9+ and ensure `python3` is on PATH.',
+ installUrl: 'https://www.python.org/downloads/',
+ verify: 'python3 --version',
+ run: 'pnpm dev:python',
+ },
+ },
+]
+
+function parseDisabledServers(env) {
+ const raw = env.AGUI_DISABLE_SERVERS ?? ''
+ return new Set(
+ raw
+ .split(',')
+ .map((item) => item.trim().toLowerCase())
+ .filter(Boolean),
+ )
+}
+
+function hasToolchain(detectCommands) {
+ return detectCommands.every(([command, ...args]) => {
+ try {
+ execFileSync(command, args, { stdio: 'ignore' })
+ return true
+ } catch {
+ return false
+ }
+ })
+}
+
+export function detectServers(env = process.env) {
+ const disabled = parseDisabledServers(env)
+
+ return SERVER_CATALOG.map((server) => {
+ const disabledByEnv = disabled.has(server.id)
+ const toolchainPresent = hasToolchain(server.detect)
+ const available = toolchainPresent && !disabledByEnv
+
+ return {
+ id: server.id,
+ label: server.label,
+ port: server.port,
+ available,
+ disabledByEnv,
+ toolchainPresent,
+ devScript: server.devScript,
+ setup: server.setup,
+ }
+ })
+}
+
+export function writeServersJson(env = process.env) {
+ const servers = detectServers(env).map(
+ ({ devScript: _devScript, toolchainPresent: _toolchainPresent, ...rest }) =>
+ rest,
+ )
+
+ const payload = {
+ generatedAt: new Date().toISOString(),
+ servers,
+ }
+
+ mkdirSync(path.dirname(outputPath), { recursive: true })
+ writeFileSync(outputPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
+
+ return { payload, outputPath, servers }
+}
+
+export function logDetectionSummary(servers) {
+ for (const server of servers) {
+ if (server.available) {
+ console.log(`[ag-ui] ${server.label} available on :${server.port}`)
+ continue
+ }
+
+ if (server.disabledByEnv) {
+ console.log(
+ `[ag-ui] ${server.label} disabled via AGUI_DISABLE_SERVERS (toolchain ${server.toolchainPresent ? 'present' : 'missing'})`,
+ )
+ continue
+ }
+
+ console.log(
+ `[ag-ui] ${server.label} unavailable — ${server.setup.verify} not found`,
+ )
+ }
+}
+
+export { exampleDir, outputPath }
diff --git a/examples/ag-ui/servers/bash/README.md b/examples/ag-ui/servers/bash/README.md
new file mode 100644
index 000000000..acc726a8c
--- /dev/null
+++ b/examples/ag-ui/servers/bash/README.md
@@ -0,0 +1,35 @@
+# Bash AG-UI server
+
+A deliberately minimal AG-UI server written in Bash. It uses:
+
+- `socat` to accept concurrent HTTP connections
+- `curl` to stream OpenAI or Anthropic responses
+- `jq` to translate JSON and emit AG-UI SSE events
+
+## Prerequisites
+
+On macOS:
+
+```bash
+brew install bash jq socat
+```
+
+The server expects Bash 4+ and also requires `curl`.
+
+## Run
+
+From `examples/ag-ui`:
+
+```bash
+OPENAI_API_KEY=... pnpm dev:bash
+```
+
+The server listens on `http://127.0.0.1:8005`. Its endpoints are:
+
+- `GET /health`
+- `POST /` with an AG-UI `RunAgentInput` body
+- `OPTIONS /` for CORS preflight
+
+This server exists to demonstrate the portability of the AG-UI wire protocol.
+It is intentionally a local development example, not a production HTTP
+server.
diff --git a/examples/ag-ui/servers/bash/server.sh b/examples/ag-ui/servers/bash/server.sh
new file mode 100644
index 000000000..1bd1fa9e0
--- /dev/null
+++ b/examples/ag-ui/servers/bash/server.sh
@@ -0,0 +1,424 @@
+#!/usr/bin/env bash
+
+set -uo pipefail
+
+PORT="${PORT:-8005}"
+SCRIPT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
+DEFAULT_OPENAI_MODEL="gpt-4o"
+DEFAULT_ANTHROPIC_MODEL="claude-sonnet-4-6"
+
+json_event() {
+ jq -nc "$@"
+}
+
+write_event() {
+ printf 'data: %s\n\n' "$1"
+}
+
+write_done() {
+ printf 'data: [DONE]\n\n'
+}
+
+write_error_response() {
+ local status="$1"
+ local message="$2"
+ printf 'HTTP/1.1 %s\r\n' "$status"
+ printf 'Content-Type: text/plain; charset=utf-8\r\n'
+ printf 'Access-Control-Allow-Origin: *\r\n'
+ printf 'Connection: close\r\n'
+ printf '\r\n'
+ printf '%s' "$message"
+}
+
+normalize_messages() {
+ jq -c '
+ def message_text:
+ if (.parts? | type) == "array" then
+ [.parts[]? |
+ select(
+ type == "object" and
+ .type == "text" and
+ (.content | type) == "string"
+ ) |
+ .content
+ ] | join("")
+ elif (.content? | type) == "string" then
+ .content
+ elif (.content? | type) == "array" then
+ [.content[]? |
+ select(
+ type == "object" and
+ .type == "text" and
+ (.text | type) == "string"
+ ) |
+ .text
+ ] | join("")
+ else
+ ""
+ end;
+
+ [.messages[]? |
+ select(type == "object") |
+ (if .role == "developer" then "system" else (.role // "") end) as $role |
+ message_text as $content |
+ select(
+ ($role == "system" or $role == "user" or $role == "assistant") and
+ ($content | length) > 0
+ ) |
+ { role: $role, content: $content }
+ ]
+ '
+}
+
+process_openai_stream() {
+ local message_id="$1"
+ local line data event
+
+ while IFS= read -r line || [[ -n "$line" ]]; do
+ line="${line%$'\r'}"
+ [[ "$line" == "data: "* ]] || continue
+ data="${line#data: }"
+ [[ "$data" == "[DONE]" ]] && continue
+
+ event="$(
+ jq -c --arg messageId "$message_id" '
+ .choices[0].delta.content? as $delta |
+ select(($delta | type) == "string" and ($delta | length) > 0) |
+ {
+ type: "TEXT_MESSAGE_CONTENT",
+ messageId: $messageId,
+ delta: $delta
+ }
+ ' <<<"$data" 2>/dev/null
+ )" || continue
+
+ [[ -n "$event" ]] && write_event "$event"
+ done
+}
+
+process_anthropic_stream() {
+ local message_id="$1"
+ local line data event
+
+ while IFS= read -r line || [[ -n "$line" ]]; do
+ line="${line%$'\r'}"
+ [[ "$line" == "data: "* ]] || continue
+ data="${line#data: }"
+
+ event="$(
+ jq -c --arg messageId "$message_id" '
+ select(
+ .type == "content_block_delta" and
+ .delta.type == "text_delta" and
+ (.delta.text | type) == "string" and
+ (.delta.text | length) > 0
+ ) |
+ {
+ type: "TEXT_MESSAGE_CONTENT",
+ messageId: $messageId,
+ delta: .delta.text
+ }
+ ' <<<"$data" 2>/dev/null
+ )" || continue
+
+ [[ -n "$event" ]] && write_event "$event"
+ done
+}
+
+stream_openai() {
+ local payload="$1"
+ local message_id="$2"
+
+ curl --silent --show-error --no-buffer --fail \
+ https://api.openai.com/v1/chat/completions \
+ -H "Authorization: Bearer ${OPENAI_API_KEY}" \
+ -H 'Content-Type: application/json' \
+ --data-binary "$payload" |
+ process_openai_stream "$message_id"
+
+ return "${PIPESTATUS[0]}"
+}
+
+stream_anthropic() {
+ local payload="$1"
+ local message_id="$2"
+
+ curl --silent --show-error --no-buffer --fail \
+ https://api.anthropic.com/v1/messages \
+ -H "x-api-key: ${ANTHROPIC_API_KEY}" \
+ -H 'anthropic-version: 2023-06-01' \
+ -H 'Content-Type: application/json' \
+ --data-binary "$payload" |
+ process_anthropic_stream "$message_id"
+
+ return "${PIPESTATUS[0]}"
+}
+
+handle_chat() {
+ local body="$1"
+ local thread_id run_id provider model normalized system chat message_id payload
+ local stream_status
+
+ thread_id="$(jq -r 'if (.threadId? | type) == "string" then .threadId else "" end' <<<"$body")"
+ run_id="$(jq -r 'if (.runId? | type) == "string" then .runId else "" end' <<<"$body")"
+
+ if [[ -z "$thread_id" || -z "$run_id" ]]; then
+ write_error_response '400 Bad Request' 'threadId and runId are required'
+ return
+ fi
+
+ provider="$(
+ jq -r '
+ (.forwardedProps // .data // {}) as $props |
+ if ($props.provider? | type) == "string" and ($props.provider | length) > 0
+ then $props.provider
+ else "openai"
+ end
+ ' <<<"$body"
+ )"
+ model="$(
+ jq -r '
+ (.forwardedProps // .data // {}) as $props |
+ if ($props.model? | type) == "string" then $props.model else "" end
+ ' <<<"$body"
+ )"
+
+ normalized="$(normalize_messages <<<"$body")"
+ system="$(
+ jq -r '[.[] | select(.role == "system") | .content] | join("\n\n")' \
+ <<<"$normalized"
+ )"
+ chat="$(jq -c '[.[] | select(.role == "user" or .role == "assistant")]' <<<"$normalized")"
+
+ printf 'HTTP/1.1 200 OK\r\n'
+ printf 'Content-Type: text/event-stream\r\n'
+ printf 'Cache-Control: no-cache\r\n'
+ printf 'Connection: close\r\n'
+ printf 'Access-Control-Allow-Origin: *\r\n'
+ printf '\r\n'
+
+ message_id="msg-${run_id}"
+ write_event "$(
+ json_event \
+ --arg threadId "$thread_id" \
+ --arg runId "$run_id" \
+ '{ type: "RUN_STARTED", threadId: $threadId, runId: $runId }'
+ )"
+
+ if [[ "$(jq 'length' <<<"$chat")" -eq 0 ]]; then
+ write_event "$(
+ json_event \
+ --arg threadId "$thread_id" \
+ --arg runId "$run_id" \
+ --arg message 'no user or assistant messages to send' \
+ '{
+ type: "RUN_ERROR",
+ threadId: $threadId,
+ runId: $runId,
+ error: { message: $message }
+ }'
+ )"
+ write_done
+ return
+ fi
+
+ write_event "$(
+ json_event \
+ --arg messageId "$message_id" \
+ '{ type: "TEXT_MESSAGE_START", messageId: $messageId, role: "assistant" }'
+ )"
+
+ case "$provider" in
+ openai)
+ if [[ -z "${OPENAI_API_KEY:-}" ]]; then
+ stream_status=1
+ else
+ [[ -n "$model" ]] || model="$DEFAULT_OPENAI_MODEL"
+ payload="$(
+ jq -nc \
+ --arg model "$model" \
+ --arg system "$system" \
+ --argjson messages "$chat" '
+ {
+ model: $model,
+ messages:
+ (if ($system | length) > 0
+ then [{ role: "system", content: $system }] + $messages
+ else $messages
+ end),
+ stream: true
+ }
+ '
+ )"
+ stream_openai "$payload" "$message_id"
+ stream_status=$?
+ fi
+ ;;
+ anthropic)
+ if [[ -z "${ANTHROPIC_API_KEY:-}" ]]; then
+ stream_status=1
+ else
+ [[ -n "$model" ]] || model="$DEFAULT_ANTHROPIC_MODEL"
+ payload="$(
+ jq -nc \
+ --arg model "$model" \
+ --arg system "$system" \
+ --argjson messages "$chat" '
+ {
+ model: $model,
+ max_tokens: 4096,
+ messages: $messages,
+ stream: true
+ } +
+ (if ($system | length) > 0 then { system: $system } else {} end)
+ '
+ )"
+ stream_anthropic "$payload" "$message_id"
+ stream_status=$?
+ fi
+ ;;
+ *)
+ stream_status=1
+ ;;
+ esac
+
+ if [[ "$stream_status" -ne 0 ]]; then
+ local error_message
+ case "$provider" in
+ openai)
+ error_message="${OPENAI_API_KEY:+OpenAI request failed}"
+ error_message="${error_message:-OPENAI_API_KEY is not set}"
+ ;;
+ anthropic)
+ error_message="${ANTHROPIC_API_KEY:+Anthropic request failed}"
+ error_message="${error_message:-ANTHROPIC_API_KEY is not set}"
+ ;;
+ *)
+ error_message="unsupported provider \"${provider}\" (expected openai or anthropic)"
+ ;;
+ esac
+
+ write_event "$(
+ json_event \
+ --arg threadId "$thread_id" \
+ --arg runId "$run_id" \
+ --arg message "$error_message" \
+ '{
+ type: "RUN_ERROR",
+ threadId: $threadId,
+ runId: $runId,
+ error: { message: $message }
+ }'
+ )"
+ write_done
+ return
+ fi
+
+ write_event "$(
+ json_event \
+ --arg messageId "$message_id" \
+ '{ type: "TEXT_MESSAGE_END", messageId: $messageId }'
+ )"
+ write_event "$(
+ json_event \
+ --arg threadId "$thread_id" \
+ --arg runId "$run_id" \
+ '{
+ type: "RUN_FINISHED",
+ threadId: $threadId,
+ runId: $runId,
+ finishReason: "stop"
+ }'
+ )"
+ write_done
+}
+
+handle_connection() {
+ local request_line method target version line header_name header_value
+ local content_length=0 body=''
+
+ IFS= read -r request_line || return
+ request_line="${request_line%$'\r'}"
+ read -r method target version <<<"$request_line"
+
+ while IFS= read -r line; do
+ line="${line%$'\r'}"
+ [[ -z "$line" ]] && break
+
+ header_name="${line%%:*}"
+ header_value="${line#*:}"
+ header_value="${header_value#"${header_value%%[![:space:]]*}"}"
+ if [[ "${header_name,,}" == 'content-length' ]]; then
+ content_length="$header_value"
+ fi
+ done
+
+ if ! [[ "$content_length" =~ ^[0-9]+$ ]]; then
+ write_error_response '400 Bad Request' 'invalid Content-Length'
+ return
+ fi
+
+ if [[ "$content_length" -gt 0 ]]; then
+ body="$(dd bs=1 count="$content_length" 2>/dev/null)"
+ fi
+
+ target="${target%%\?*}"
+
+ if [[ "$method" == 'OPTIONS' ]]; then
+ printf 'HTTP/1.1 204 No Content\r\n'
+ printf 'Access-Control-Allow-Origin: *\r\n'
+ printf 'Access-Control-Allow-Methods: POST, OPTIONS\r\n'
+ printf 'Access-Control-Allow-Headers: Content-Type\r\n'
+ printf 'Connection: close\r\n'
+ printf '\r\n'
+ return
+ fi
+
+ if [[ "$method" == 'GET' && "$target" == '/health' ]]; then
+ printf 'HTTP/1.1 200 OK\r\n'
+ printf 'Content-Type: text/plain; charset=utf-8\r\n'
+ printf 'Content-Length: 2\r\n'
+ printf 'Connection: close\r\n'
+ printf '\r\n'
+ printf 'ok'
+ return
+ fi
+
+ if [[ "$method" != 'POST' ]]; then
+ write_error_response '405 Method Not Allowed' 'method not allowed'
+ return
+ fi
+
+ if [[ "$target" != '/' ]]; then
+ write_error_response '404 Not Found' 'not found'
+ return
+ fi
+
+ if ! jq -e 'type == "object"' >/dev/null 2>&1 <<<"$body"; then
+ write_error_response '400 Bad Request' 'invalid JSON body'
+ return
+ fi
+
+ handle_chat "$body"
+}
+
+check_dependencies() {
+ local dependency
+ for dependency in bash curl jq socat; do
+ if ! command -v "$dependency" >/dev/null 2>&1; then
+ printf '[bash] missing dependency: %s\n' "$dependency" >&2
+ return 1
+ fi
+ done
+}
+
+if [[ "${1:-}" == '--handle' ]]; then
+ handle_connection
+ exit 0
+fi
+
+check_dependencies || exit 1
+printf '[bash] listening on http://127.0.0.1:%s\n' "$PORT"
+exec socat \
+ "TCP-LISTEN:${PORT},bind=127.0.0.1,reuseaddr,fork" \
+ "SYSTEM:exec bash '${SCRIPT_PATH}' --handle"
diff --git a/examples/ag-ui/servers/go/README.md b/examples/ag-ui/servers/go/README.md
new file mode 100644
index 000000000..b7371df0a
--- /dev/null
+++ b/examples/ag-ui/servers/go/README.md
@@ -0,0 +1,35 @@
+# Go AG-UI chat server
+
+AG-UI SSE server using the Go standard library. Streams simple chat completions from OpenAI or Anthropic.
+
+## Run
+
+From `examples/ag-ui`:
+
+```bash
+export OPENAI_API_KEY=...
+export ANTHROPIC_API_KEY=...
+pnpm dev:go
+# or
+go run -C servers/go .
+```
+
+Listens on `http://127.0.0.1:8001`.
+
+## Protocol
+
+- **Endpoint:** `POST /`
+- **Request body:** AG-UI `RunAgentInput` JSON
+- **Provider selection:** `forwardedProps.provider` (`openai` | `anthropic`) and optional `forwardedProps.model`
+- **Response:** `Content-Type: text/event-stream`
+
+Supports simple `user` / `assistant` / `system` messages with string `content` or TanStack-style text `parts`. Tool and reasoning fan-out entries are ignored.
+
+## Environment
+
+| Variable | Required when |
+| ------------------- | --------------------- |
+| `OPENAI_API_KEY` | `provider: openai` |
+| `ANTHROPIC_API_KEY` | `provider: anthropic` |
+
+Default models: `gpt-4o`, `claude-sonnet-4-6`.
diff --git a/examples/ag-ui/servers/go/agui.go b/examples/ag-ui/servers/go/agui.go
new file mode 100644
index 000000000..e8f10912a
--- /dev/null
+++ b/examples/ag-ui/servers/go/agui.go
@@ -0,0 +1,211 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+)
+
+const listenAddr = ":8001"
+
+const (
+ defaultOpenAIModel = "gpt-4o"
+ defaultAnthropicModel = "claude-sonnet-4-6"
+)
+
+type runAgentInput struct {
+ ThreadID string `json:"threadId"`
+ RunID string `json:"runId"`
+ Messages []incomingMessage `json:"messages"`
+ ForwardedProps map[string]any `json:"forwardedProps"`
+ Data map[string]any `json:"data"`
+}
+
+type incomingMessage struct {
+ Role string `json:"role"`
+ Content json.RawMessage `json:"content"`
+ Parts []messagePart `json:"parts"`
+}
+
+type messagePart struct {
+ Type string `json:"type"`
+ Content string `json:"content"`
+}
+
+type chatMessage struct {
+ Role string
+ Content string
+}
+
+type providerConfig struct {
+ Provider string
+ Model string
+}
+
+type sseStream struct {
+ w http.ResponseWriter
+ flusher http.Flusher
+ threadID string
+ runID string
+ messageID string
+ started bool
+ textOpened bool
+}
+
+func propsFromInput(input runAgentInput) map[string]any {
+ if len(input.ForwardedProps) > 0 {
+ return input.ForwardedProps
+ }
+ return input.Data
+}
+
+func providerFromInput(input runAgentInput) providerConfig {
+ props := propsFromInput(input)
+ provider := "openai"
+ model := ""
+
+ if raw, ok := props["provider"].(string); ok && raw != "" {
+ provider = raw
+ }
+ if raw, ok := props["model"].(string); ok && raw != "" {
+ model = raw
+ }
+
+ switch provider {
+ case "anthropic":
+ if model == "" {
+ model = defaultAnthropicModel
+ }
+ default:
+ provider = "openai"
+ if model == "" {
+ model = defaultOpenAIModel
+ }
+ }
+
+ return providerConfig{Provider: provider, Model: model}
+}
+
+func beginSSE(w http.ResponseWriter) (*sseStream, bool) {
+ flusher, ok := w.(http.Flusher)
+ if !ok {
+ return nil, false
+ }
+
+ w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set("Cache-Control", "no-cache")
+ w.Header().Set("Connection", "keep-alive")
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.WriteHeader(http.StatusOK)
+
+ return &sseStream{w: w, flusher: flusher}, true
+}
+
+func (s *sseStream) initRun(threadID, runID string) {
+ s.threadID = threadID
+ s.runID = runID
+ s.messageID = fmt.Sprintf("msg-%s", runID)
+}
+
+func (s *sseStream) writeEvent(payload map[string]any) error {
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+ _, err = fmt.Fprintf(s.w, "data: %s\n\n", data)
+ if err != nil {
+ return err
+ }
+ s.flusher.Flush()
+ return nil
+}
+
+func (s *sseStream) writeDone() {
+ _, _ = fmt.Fprint(s.w, "data: [DONE]\n\n")
+ s.flusher.Flush()
+}
+
+func (s *sseStream) startRun() error {
+ if s.started {
+ return nil
+ }
+ s.started = true
+ return s.writeEvent(map[string]any{
+ "type": "RUN_STARTED",
+ "threadId": s.threadID,
+ "runId": s.runID,
+ })
+}
+
+func (s *sseStream) startText() error {
+ if s.textOpened {
+ return nil
+ }
+ s.textOpened = true
+ return s.writeEvent(map[string]any{
+ "type": "TEXT_MESSAGE_START",
+ "messageId": s.messageID,
+ "role": "assistant",
+ })
+}
+
+func (s *sseStream) writeDelta(delta string) error {
+ if delta == "" {
+ return nil
+ }
+ return s.writeEvent(map[string]any{
+ "type": "TEXT_MESSAGE_CONTENT",
+ "messageId": s.messageID,
+ "delta": delta,
+ })
+}
+
+func (s *sseStream) finishSuccess() error {
+ if s.textOpened {
+ if err := s.writeEvent(map[string]any{
+ "type": "TEXT_MESSAGE_END",
+ "messageId": s.messageID,
+ }); err != nil {
+ return err
+ }
+ }
+ return s.writeEvent(map[string]any{
+ "type": "RUN_FINISHED",
+ "threadId": s.threadID,
+ "runId": s.runID,
+ "finishReason": "stop",
+ })
+}
+
+func (s *sseStream) finishError(message string) error {
+ if !s.started {
+ if err := s.startRun(); err != nil {
+ return err
+ }
+ }
+ return s.writeEvent(map[string]any{
+ "type": "RUN_ERROR",
+ "threadId": s.threadID,
+ "runId": s.runID,
+ "error": map[string]any{
+ "message": message,
+ },
+ })
+}
+
+func (s *sseStream) streamText(stream func(emit func(string) error) error) error {
+ if err := s.startRun(); err != nil {
+ return err
+ }
+ if err := s.startText(); err != nil {
+ return err
+ }
+ if err := stream(s.writeDelta); err != nil {
+ return err
+ }
+ if err := s.finishSuccess(); err != nil {
+ return err
+ }
+ s.writeDone()
+ return nil
+}
diff --git a/examples/ag-ui/servers/go/anthropic.go b/examples/ag-ui/servers/go/anthropic.go
new file mode 100644
index 000000000..f6af1d3e6
--- /dev/null
+++ b/examples/ag-ui/servers/go/anthropic.go
@@ -0,0 +1,105 @@
+package main
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+)
+
+func streamAnthropic(
+ ctx context.Context,
+ model string,
+ system string,
+ messages []chatMessage,
+ emit func(string) error,
+) error {
+ apiKey := os.Getenv("ANTHROPIC_API_KEY")
+ if apiKey == "" {
+ return fmt.Errorf("ANTHROPIC_API_KEY is not set")
+ }
+
+ anthropicMessages := make([]map[string]string, 0, len(messages))
+ for _, msg := range messages {
+ anthropicMessages = append(anthropicMessages, map[string]string{
+ "role": msg.Role,
+ "content": msg.Content,
+ })
+ }
+
+ body := map[string]any{
+ "model": model,
+ "max_tokens": 4096,
+ "messages": anthropicMessages,
+ "stream": true,
+ }
+ if system != "" {
+ body["system"] = system
+ }
+
+ payload, err := json.Marshal(body)
+ if err != nil {
+ return err
+ }
+
+ req, err := http.NewRequestWithContext(
+ ctx,
+ http.MethodPost,
+ "https://api.anthropic.com/v1/messages",
+ bytes.NewReader(payload),
+ )
+ if err != nil {
+ return err
+ }
+ req.Header.Set("x-api-key", apiKey)
+ req.Header.Set("anthropic-version", "2023-06-01")
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode >= 400 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("anthropic request failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
+ }
+
+ scanner := bufio.NewScanner(resp.Body)
+ for scanner.Scan() {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+
+ line := scanner.Text()
+ if !strings.HasPrefix(line, "data: ") {
+ continue
+ }
+
+ data := strings.TrimPrefix(line, "data: ")
+ var event struct {
+ Type string `json:"type"`
+ Delta struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ } `json:"delta"`
+ }
+ if err := json.Unmarshal([]byte(data), &event); err != nil {
+ continue
+ }
+ if event.Type != "content_block_delta" || event.Delta.Type != "text_delta" {
+ continue
+ }
+ if err := emit(event.Delta.Text); err != nil {
+ return err
+ }
+ }
+
+ return scanner.Err()
+}
diff --git a/examples/ag-ui/servers/go/go.mod b/examples/ag-ui/servers/go/go.mod
new file mode 100644
index 000000000..e2195649a
--- /dev/null
+++ b/examples/ag-ui/servers/go/go.mod
@@ -0,0 +1,3 @@
+module github.com/tanstack/ai/examples/ag-ui/servers/go
+
+go 1.22
diff --git a/examples/ag-ui/servers/go/llm.go b/examples/ag-ui/servers/go/llm.go
new file mode 100644
index 000000000..9eb20381b
--- /dev/null
+++ b/examples/ag-ui/servers/go/llm.go
@@ -0,0 +1,23 @@
+package main
+
+import (
+ "context"
+ "fmt"
+)
+
+func streamCompletion(
+ ctx context.Context,
+ config providerConfig,
+ system string,
+ messages []chatMessage,
+ emit func(string) error,
+) error {
+ switch config.Provider {
+ case "openai":
+ return streamOpenAI(ctx, config.Model, system, messages, emit)
+ case "anthropic":
+ return streamAnthropic(ctx, config.Model, system, messages, emit)
+ default:
+ return fmt.Errorf("unsupported provider %q (expected openai or anthropic)", config.Provider)
+ }
+}
diff --git a/examples/ag-ui/servers/go/main.go b/examples/ag-ui/servers/go/main.go
new file mode 100644
index 000000000..228d3769c
--- /dev/null
+++ b/examples/ag-ui/servers/go/main.go
@@ -0,0 +1,79 @@
+package main
+
+import (
+ "encoding/json"
+ "io"
+ "log"
+ "net/http"
+)
+
+func main() {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", handleChat)
+ mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("ok"))
+ })
+
+ log.Printf("AG-UI Go server listening on http://127.0.0.1%s", listenAddr)
+ if err := http.ListenAndServe(listenAddr, mux); err != nil {
+ log.Fatal(err)
+ }
+}
+
+func handleChat(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodOptions {
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS")
+ w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
+ w.WriteHeader(http.StatusNoContent)
+ return
+ }
+
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ http.Error(w, "failed to read body", http.StatusBadRequest)
+ return
+ }
+ defer r.Body.Close()
+
+ var input runAgentInput
+ if err := json.Unmarshal(body, &input); err != nil {
+ http.Error(w, "invalid JSON body", http.StatusBadRequest)
+ return
+ }
+
+ if input.ThreadID == "" || input.RunID == "" {
+ http.Error(w, "threadId and runId are required", http.StatusBadRequest)
+ return
+ }
+
+ stream, ok := beginSSE(w)
+ if !ok {
+ http.Error(w, "streaming unsupported", http.StatusInternalServerError)
+ return
+ }
+ stream.initRun(input.ThreadID, input.RunID)
+
+ config := providerFromInput(input)
+ system, messages := toChatMessages(input.Messages)
+ if len(messages) == 0 {
+ _ = stream.finishError("no user or assistant messages to send")
+ stream.writeDone()
+ return
+ }
+
+ err = stream.streamText(func(emit func(string) error) error {
+ return streamCompletion(r.Context(), config, system, messages, emit)
+ })
+ if err != nil {
+ log.Printf("[go] chat error: %v", err)
+ _ = stream.finishError(err.Error())
+ stream.writeDone()
+ }
+}
diff --git a/examples/ag-ui/servers/go/messages.go b/examples/ag-ui/servers/go/messages.go
new file mode 100644
index 000000000..6a2facdc6
--- /dev/null
+++ b/examples/ag-ui/servers/go/messages.go
@@ -0,0 +1,73 @@
+package main
+
+import (
+ "encoding/json"
+ "strings"
+)
+
+func textFromMessage(msg incomingMessage) string {
+ if len(msg.Parts) > 0 {
+ var parts []string
+ for _, part := range msg.Parts {
+ if part.Type == "text" && part.Content != "" {
+ parts = append(parts, part.Content)
+ }
+ }
+ if len(parts) > 0 {
+ return strings.Join(parts, "")
+ }
+ }
+
+ if len(msg.Content) == 0 {
+ return ""
+ }
+
+ var asString string
+ if err := json.Unmarshal(msg.Content, &asString); err == nil {
+ return asString
+ }
+
+ var asArray []map[string]any
+ if err := json.Unmarshal(msg.Content, &asArray); err == nil {
+ var parts []string
+ for _, item := range asArray {
+ if item["type"] == "text" {
+ if text, ok := item["text"].(string); ok {
+ parts = append(parts, text)
+ }
+ }
+ }
+ return strings.Join(parts, "")
+ }
+
+ return strings.TrimSpace(string(msg.Content))
+}
+
+func toChatMessages(messages []incomingMessage) (system string, chat []chatMessage) {
+ for _, msg := range messages {
+ role := msg.Role
+ switch role {
+ case "developer":
+ role = "system"
+ case "tool", "reasoning":
+ continue
+ }
+
+ text := textFromMessage(msg)
+ if text == "" {
+ continue
+ }
+
+ switch role {
+ case "system":
+ if system != "" {
+ system += "\n\n"
+ }
+ system += text
+ case "user", "assistant":
+ chat = append(chat, chatMessage{Role: role, Content: text})
+ }
+ }
+
+ return system, chat
+}
diff --git a/examples/ag-ui/servers/go/openai.go b/examples/ag-ui/servers/go/openai.go
new file mode 100644
index 000000000..c73c54491
--- /dev/null
+++ b/examples/ag-ui/servers/go/openai.go
@@ -0,0 +1,108 @@
+package main
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+)
+
+func streamOpenAI(
+ ctx context.Context,
+ model string,
+ system string,
+ messages []chatMessage,
+ emit func(string) error,
+) error {
+ apiKey := os.Getenv("OPENAI_API_KEY")
+ if apiKey == "" {
+ return fmt.Errorf("OPENAI_API_KEY is not set")
+ }
+
+ openAIMessages := make([]map[string]string, 0, len(messages)+1)
+ if system != "" {
+ openAIMessages = append(openAIMessages, map[string]string{
+ "role": "system",
+ "content": system,
+ })
+ }
+ for _, msg := range messages {
+ openAIMessages = append(openAIMessages, map[string]string{
+ "role": msg.Role,
+ "content": msg.Content,
+ })
+ }
+
+ payload, err := json.Marshal(map[string]any{
+ "model": model,
+ "messages": openAIMessages,
+ "stream": true,
+ })
+ if err != nil {
+ return err
+ }
+
+ req, err := http.NewRequestWithContext(
+ ctx,
+ http.MethodPost,
+ "https://api.openai.com/v1/chat/completions",
+ bytes.NewReader(payload),
+ )
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+apiKey)
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode >= 400 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("openai request failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(body)))
+ }
+
+ scanner := bufio.NewScanner(resp.Body)
+ for scanner.Scan() {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+
+ line := scanner.Text()
+ if !strings.HasPrefix(line, "data: ") {
+ continue
+ }
+
+ data := strings.TrimPrefix(line, "data: ")
+ if data == "[DONE]" {
+ break
+ }
+
+ var chunk struct {
+ Choices []struct {
+ Delta struct {
+ Content string `json:"content"`
+ } `json:"delta"`
+ } `json:"choices"`
+ }
+ if err := json.Unmarshal([]byte(data), &chunk); err != nil {
+ continue
+ }
+ if len(chunk.Choices) == 0 {
+ continue
+ }
+ if err := emit(chunk.Choices[0].Delta.Content); err != nil {
+ return err
+ }
+ }
+
+ return scanner.Err()
+}
diff --git a/examples/ag-ui/servers/php/README.md b/examples/ag-ui/servers/php/README.md
new file mode 100644
index 000000000..72e5ce444
--- /dev/null
+++ b/examples/ag-ui/servers/php/README.md
@@ -0,0 +1,28 @@
+# PHP AG-UI server
+
+Hand-rolled AG-UI SSE server on `http://127.0.0.1:8003`.
+
+## Run
+
+From `examples/ag-ui`:
+
+```bash
+pnpm dev:php
+```
+
+Requires PHP 8.2+ with the `curl` extension enabled.
+
+## Environment
+
+- `OPENAI_API_KEY`
+- `ANTHROPIC_API_KEY`
+
+## Endpoints
+
+| Method | Path | Description |
+| ------- | --------- | ------------------- |
+| POST | `/` | AG-UI chat (SSE) |
+| GET | `/health` | Health check (`ok`) |
+| OPTIONS | `/` | CORS preflight |
+
+The client sends `forwardedProps: { provider, model }` in the AG-UI `RunAgentInput` body.
diff --git a/examples/ag-ui/servers/php/lib/Agui.php b/examples/ag-ui/servers/php/lib/Agui.php
new file mode 100644
index 000000000..b00705b7f
--- /dev/null
+++ b/examples/ag-ui/servers/php/lib/Agui.php
@@ -0,0 +1,169 @@
+ 'anthropic',
+ 'model' => $model !== '' ? $model : DEFAULT_ANTHROPIC_MODEL,
+ ];
+ }
+
+ return [
+ 'provider' => 'openai',
+ 'model' => $model !== '' ? $model : DEFAULT_OPENAI_MODEL,
+ ];
+}
+
+function sse_event(array $payload): string
+{
+ return 'data: ' . json_encode($payload, JSON_UNESCAPED_UNICODE) . "\n\n";
+}
+
+function sse_done(): string
+{
+ return "data: [DONE]\n\n";
+}
+
+function run_started(string $threadId, string $runId): string
+{
+ return sse_event([
+ 'type' => 'RUN_STARTED',
+ 'threadId' => $threadId,
+ 'runId' => $runId,
+ ]);
+}
+
+function text_message_start(string $messageId): string
+{
+ return sse_event([
+ 'type' => 'TEXT_MESSAGE_START',
+ 'messageId' => $messageId,
+ 'role' => 'assistant',
+ ]);
+}
+
+function text_message_content(string $messageId, string $delta): string
+{
+ return sse_event([
+ 'type' => 'TEXT_MESSAGE_CONTENT',
+ 'messageId' => $messageId,
+ 'delta' => $delta,
+ ]);
+}
+
+function text_message_end(string $messageId): string
+{
+ return sse_event([
+ 'type' => 'TEXT_MESSAGE_END',
+ 'messageId' => $messageId,
+ ]);
+}
+
+function run_finished(string $threadId, string $runId): string
+{
+ return sse_event([
+ 'type' => 'RUN_FINISHED',
+ 'threadId' => $threadId,
+ 'runId' => $runId,
+ 'finishReason' => 'stop',
+ ]);
+}
+
+function run_error(string $threadId, string $runId, string $message): string
+{
+ return sse_event([
+ 'type' => 'RUN_ERROR',
+ 'threadId' => $threadId,
+ 'runId' => $runId,
+ 'error' => ['message' => $message],
+ ]);
+}
+
+function begin_sse(): void
+{
+ header('Content-Type: text/event-stream');
+ header('Cache-Control: no-cache');
+ header('Connection: keep-alive');
+ header('Access-Control-Allow-Origin: *');
+ http_response_code(200);
+}
+
+function write_sse(string $frame): void
+{
+ echo $frame;
+ if (ob_get_level() > 0) {
+ ob_flush();
+ }
+ flush();
+}
+
+function cors_preflight(): void
+{
+ header('Access-Control-Allow-Origin: *');
+ header('Access-Control-Allow-Methods: POST, OPTIONS');
+ header('Access-Control-Allow-Headers: Content-Type');
+ http_response_code(204);
+}
+
+function stream_text(
+ string $threadId,
+ string $runId,
+ callable $stream
+): void {
+ begin_sse();
+ $messageId = 'msg-' . $runId;
+
+ write_sse(run_started($threadId, $runId));
+ write_sse(text_message_start($messageId));
+
+ $emit = static function (string $delta) use ($messageId): void {
+ if ($delta === '') {
+ return;
+ }
+ write_sse(text_message_content($messageId, $delta));
+ };
+
+ try {
+ $stream($emit);
+ write_sse(text_message_end($messageId));
+ write_sse(run_finished($threadId, $runId));
+ write_sse(sse_done());
+ } catch (Throwable $error) {
+ error_log('[php] chat error: ' . $error->getMessage());
+ write_sse(run_error($threadId, $runId, $error->getMessage()));
+ write_sse(sse_done());
+ }
+}
+
+function stream_error_only(string $threadId, string $runId, string $message): void
+{
+ begin_sse();
+ write_sse(run_started($threadId, $runId));
+ write_sse(run_error($threadId, $runId, $message));
+ write_sse(sse_done());
+}
diff --git a/examples/ag-ui/servers/php/lib/Messages.php b/examples/ag-ui/servers/php/lib/Messages.php
new file mode 100644
index 000000000..a6cc3e2e7
--- /dev/null
+++ b/examples/ag-ui/servers/php/lib/Messages.php
@@ -0,0 +1,85 @@
+ $role, 'content' => $text];
+ break;
+ }
+ }
+
+ return [$system, $chat];
+}
diff --git a/examples/ag-ui/servers/php/lib/Providers.php b/examples/ag-ui/servers/php/lib/Providers.php
new file mode 100644
index 000000000..e6e36764c
--- /dev/null
+++ b/examples/ag-ui/servers/php/lib/Providers.php
@@ -0,0 +1,190 @@
+ 'system', 'content' => $system];
+ }
+ foreach ($messages as $message) {
+ $openaiMessages[] = [
+ 'role' => $message['role'],
+ 'content' => $message['content'],
+ ];
+ }
+
+ $payload = json_encode([
+ 'model' => $model,
+ 'messages' => $openaiMessages,
+ 'stream' => true,
+ ], JSON_UNESCAPED_UNICODE);
+
+ if ($payload === false) {
+ throw new RuntimeException('failed to encode OpenAI request');
+ }
+
+ stream_provider_sse(
+ 'https://api.openai.com/v1/chat/completions',
+ [
+ 'Authorization: Bearer ' . $apiKey,
+ 'Content-Type: application/json',
+ ],
+ $payload,
+ static function (array $parsed) use ($emit): void {
+ $delta = $parsed['choices'][0]['delta']['content'] ?? null;
+ if (is_string($delta) && $delta !== '') {
+ $emit($delta);
+ }
+ }
+ );
+}
+
+function stream_anthropic(
+ string $model,
+ string $system,
+ array $messages,
+ callable $emit
+): void {
+ $apiKey = getenv('ANTHROPIC_API_KEY') ?: '';
+ if ($apiKey === '') {
+ throw new RuntimeException('ANTHROPIC_API_KEY is not set');
+ }
+
+ $anthropicMessages = [];
+ foreach ($messages as $message) {
+ $anthropicMessages[] = [
+ 'role' => $message['role'],
+ 'content' => $message['content'],
+ ];
+ }
+
+ $body = [
+ 'model' => $model,
+ 'max_tokens' => 4096,
+ 'messages' => $anthropicMessages,
+ 'stream' => true,
+ ];
+ if ($system !== '') {
+ $body['system'] = $system;
+ }
+
+ $payload = json_encode($body, JSON_UNESCAPED_UNICODE);
+ if ($payload === false) {
+ throw new RuntimeException('failed to encode Anthropic request');
+ }
+
+ stream_provider_sse(
+ 'https://api.anthropic.com/v1/messages',
+ [
+ 'x-api-key: ' . $apiKey,
+ 'anthropic-version: 2023-06-01',
+ 'Content-Type: application/json',
+ ],
+ $payload,
+ static function (array $parsed) use ($emit): void {
+ if (($parsed['type'] ?? '') !== 'content_block_delta') {
+ return;
+ }
+ $delta = $parsed['delta'] ?? null;
+ if (!is_array($delta) || ($delta['type'] ?? '') !== 'text_delta') {
+ return;
+ }
+ $text = $delta['text'] ?? null;
+ if (is_string($text) && $text !== '') {
+ $emit($text);
+ }
+ }
+ );
+}
+
+function stream_completion(
+ array $config,
+ string $system,
+ array $messages,
+ callable $emit
+): void {
+ switch ($config['provider']) {
+ case 'openai':
+ stream_openai($config['model'], $system, $messages, $emit);
+ return;
+ case 'anthropic':
+ stream_anthropic($config['model'], $system, $messages, $emit);
+ return;
+ default:
+ throw new RuntimeException(
+ 'unsupported provider "' . $config['provider'] . '" (expected openai or anthropic)'
+ );
+ }
+}
+
+function stream_provider_sse(
+ string $url,
+ array $headers,
+ string $payload,
+ callable $handleEvent
+): void {
+ $buffer = '';
+
+ $ch = curl_init($url);
+ if ($ch === false) {
+ throw new RuntimeException('failed to initialize curl');
+ }
+
+ curl_setopt_array($ch, [
+ CURLOPT_POST => true,
+ CURLOPT_HTTPHEADER => $headers,
+ CURLOPT_POSTFIELDS => $payload,
+ CURLOPT_RETURNTRANSFER => false,
+ CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use (&$buffer, $handleEvent): int {
+ $buffer .= $chunk;
+
+ while (($lineEnd = strpos($buffer, "\n")) !== false) {
+ $line = rtrim(substr($buffer, 0, $lineEnd), "\r");
+ $buffer = substr($buffer, $lineEnd + 1);
+
+ if (!str_starts_with($line, 'data: ')) {
+ continue;
+ }
+
+ $data = substr($line, 6);
+ if ($data === '[DONE]') {
+ return strlen($chunk);
+ }
+
+ $parsed = json_decode($data, true);
+ if (!is_array($parsed)) {
+ continue;
+ }
+
+ $handleEvent($parsed);
+ }
+
+ return strlen($chunk);
+ },
+ ]);
+
+ $ok = curl_exec($ch);
+ if ($ok === false) {
+ $error = curl_error($ch);
+ curl_close($ch);
+ throw new RuntimeException($error !== '' ? $error : 'provider request failed');
+ }
+
+ $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
+ curl_close($ch);
+
+ if ($status >= 400) {
+ throw new RuntimeException('provider request failed (' . $status . ')');
+ }
+}
diff --git a/examples/ag-ui/servers/php/router.php b/examples/ag-ui/servers/php/router.php
new file mode 100644
index 000000000..5a745ed65
--- /dev/null
+++ b/examples/ag-ui/servers/php/router.php
@@ -0,0 +1,68 @@
+ str:
+ parts = message.get("parts")
+ if isinstance(parts, list):
+ text_parts = [
+ part["content"]
+ for part in parts
+ if isinstance(part, dict)
+ and part.get("type") == "text"
+ and isinstance(part.get("content"), str)
+ ]
+ if text_parts:
+ return "".join(text_parts)
+
+ content = message.get("content")
+ if isinstance(content, str):
+ return content
+ if isinstance(content, list):
+ return "".join(
+ item["text"]
+ for item in content
+ if isinstance(item, dict)
+ and item.get("type") == "text"
+ and isinstance(item.get("text"), str)
+ )
+ return ""
+
+
+def to_chat_messages(
+ messages: Any,
+) -> tuple[str, list[dict[str, str]]]:
+ system_parts: list[str] = []
+ chat: list[dict[str, str]] = []
+
+ if not isinstance(messages, list):
+ return "", chat
+
+ for message in messages:
+ if not isinstance(message, dict):
+ continue
+
+ role = message.get("role")
+ if role == "developer":
+ role = "system"
+ if role not in ("system", "user", "assistant"):
+ continue
+
+ text = text_from_message(message)
+ if not text:
+ continue
+
+ if role == "system":
+ system_parts.append(text)
+ else:
+ chat.append({"role": role, "content": text})
+
+ return "\n\n".join(system_parts), chat
+
+
+def props_from_input(input_data: dict[str, Any]) -> dict[str, Any]:
+ forwarded = input_data.get("forwardedProps")
+ if isinstance(forwarded, dict) and forwarded:
+ return forwarded
+
+ data = input_data.get("data")
+ if isinstance(data, dict):
+ return data
+
+ return {}
+
+
+def provider_from_input(input_data: dict[str, Any]) -> tuple[str, str]:
+ props = props_from_input(input_data)
+ provider = props.get("provider", "openai")
+ model = props.get("model", "")
+
+ if not isinstance(provider, str) or not provider:
+ provider = "openai"
+ if not isinstance(model, str):
+ model = ""
+
+ if provider == "openai":
+ return provider, model or DEFAULT_OPENAI_MODEL
+ if provider == "anthropic":
+ return provider, model or DEFAULT_ANTHROPIC_MODEL
+ raise RuntimeError(
+ f'unsupported provider "{provider}" (expected openai or anthropic)'
+ )
+
+
+def stream_provider_sse(
+ url: str,
+ headers: dict[str, str],
+ payload: dict[str, Any],
+) -> Iterable[dict[str, Any]]:
+ request = urllib.request.Request(
+ url,
+ data=json.dumps(payload).encode("utf-8"),
+ headers=headers,
+ method="POST",
+ )
+
+ try:
+ with urllib.request.urlopen(request, timeout=120) as response:
+ for raw_line in response:
+ line = raw_line.decode("utf-8").rstrip("\r\n")
+ if not line.startswith("data: "):
+ continue
+ data = line[6:]
+ if data == "[DONE]":
+ return
+ try:
+ parsed = json.loads(data)
+ except json.JSONDecodeError:
+ continue
+ if isinstance(parsed, dict):
+ yield parsed
+ except urllib.error.HTTPError as error:
+ raise RuntimeError(
+ f"provider request failed ({error.code})"
+ ) from error
+ except urllib.error.URLError as error:
+ raise RuntimeError(f"provider request failed: {error.reason}") from error
+
+
+def stream_openai(
+ model: str,
+ system: str,
+ messages: list[dict[str, str]],
+ emit: Callable[[str], None],
+) -> None:
+ api_key = os.environ.get("OPENAI_API_KEY", "")
+ if not api_key:
+ raise RuntimeError("OPENAI_API_KEY is not set")
+
+ openai_messages = list(messages)
+ if system:
+ openai_messages.insert(0, {"role": "system", "content": system})
+
+ for event in stream_provider_sse(
+ "https://api.openai.com/v1/chat/completions",
+ {
+ "Authorization": f"Bearer {api_key}",
+ "Content-Type": "application/json",
+ },
+ {
+ "model": model,
+ "messages": openai_messages,
+ "stream": True,
+ },
+ ):
+ choices = event.get("choices")
+ if not isinstance(choices, list) or not choices:
+ continue
+ choice = choices[0]
+ if not isinstance(choice, dict):
+ continue
+ delta = choice.get("delta")
+ if not isinstance(delta, dict):
+ continue
+ content = delta.get("content")
+ if isinstance(content, str) and content:
+ emit(content)
+
+
+def stream_anthropic(
+ model: str,
+ system: str,
+ messages: list[dict[str, str]],
+ emit: Callable[[str], None],
+) -> None:
+ api_key = os.environ.get("ANTHROPIC_API_KEY", "")
+ if not api_key:
+ raise RuntimeError("ANTHROPIC_API_KEY is not set")
+
+ payload: dict[str, Any] = {
+ "model": model,
+ "max_tokens": 4096,
+ "messages": messages,
+ "stream": True,
+ }
+ if system:
+ payload["system"] = system
+
+ for event in stream_provider_sse(
+ "https://api.anthropic.com/v1/messages",
+ {
+ "x-api-key": api_key,
+ "anthropic-version": "2023-06-01",
+ "Content-Type": "application/json",
+ },
+ payload,
+ ):
+ if event.get("type") != "content_block_delta":
+ continue
+ delta = event.get("delta")
+ if not isinstance(delta, dict) or delta.get("type") != "text_delta":
+ continue
+ text = delta.get("text")
+ if isinstance(text, str) and text:
+ emit(text)
+
+
+def stream_completion(
+ provider: str,
+ model: str,
+ system: str,
+ messages: list[dict[str, str]],
+ emit: Callable[[str], None],
+) -> None:
+ if provider == "openai":
+ stream_openai(model, system, messages, emit)
+ return
+ if provider == "anthropic":
+ stream_anthropic(model, system, messages, emit)
+ return
+ raise RuntimeError(
+ f'unsupported provider "{provider}" (expected openai or anthropic)'
+ )
+
+
+class AguiHandler(BaseHTTPRequestHandler):
+ protocol_version = "HTTP/1.1"
+
+ def log_message(self, format: str, *args: Any) -> None:
+ sys.stderr.write(f"[python] {format % args}\n")
+
+ def send_text(self, status: int, message: str) -> None:
+ body = message.encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", "text/plain; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_OPTIONS(self) -> None:
+ self.send_response(204)
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
+ self.send_header("Access-Control-Allow-Headers", "Content-Type")
+ self.send_header("Content-Length", "0")
+ self.end_headers()
+
+ def do_GET(self) -> None:
+ if self.path.split("?", 1)[0] == "/health":
+ self.send_text(200, "ok")
+ return
+ self.send_text(405, "method not allowed")
+
+ def do_POST(self) -> None:
+ if self.path.split("?", 1)[0] != "/":
+ self.send_text(404, "not found")
+ return
+
+ try:
+ content_length = int(self.headers.get("Content-Length", "0"))
+ input_data = json.loads(self.rfile.read(content_length))
+ except (ValueError, json.JSONDecodeError, UnicodeDecodeError):
+ self.send_text(400, "invalid JSON body")
+ return
+
+ if not isinstance(input_data, dict):
+ self.send_text(400, "invalid JSON body")
+ return
+
+ thread_id = input_data.get("threadId")
+ run_id = input_data.get("runId")
+ if not isinstance(thread_id, str) or not thread_id or not isinstance(
+ run_id, str
+ ) or not run_id:
+ self.send_text(400, "threadId and runId are required")
+ return
+
+ system, messages = to_chat_messages(input_data.get("messages"))
+ self.begin_sse()
+ self.write_event(
+ {"type": "RUN_STARTED", "threadId": thread_id, "runId": run_id}
+ )
+
+ if not messages:
+ self.write_run_error(
+ thread_id, run_id, "no user or assistant messages to send"
+ )
+ self.write_done()
+ return
+
+ message_id = f"msg-{run_id}"
+ self.write_event(
+ {
+ "type": "TEXT_MESSAGE_START",
+ "messageId": message_id,
+ "role": "assistant",
+ }
+ )
+
+ try:
+ provider, model = provider_from_input(input_data)
+ stream_completion(
+ provider,
+ model,
+ system,
+ messages,
+ lambda delta: self.write_event(
+ {
+ "type": "TEXT_MESSAGE_CONTENT",
+ "messageId": message_id,
+ "delta": delta,
+ }
+ ),
+ )
+ self.write_event(
+ {"type": "TEXT_MESSAGE_END", "messageId": message_id}
+ )
+ self.write_event(
+ {
+ "type": "RUN_FINISHED",
+ "threadId": thread_id,
+ "runId": run_id,
+ "finishReason": "stop",
+ }
+ )
+ self.write_done()
+ except Exception as error:
+ self.log_message("chat error: %s", error)
+ self.write_run_error(thread_id, run_id, str(error))
+ self.write_done()
+
+ def begin_sse(self) -> None:
+ self.send_response(200)
+ self.send_header("Content-Type", "text/event-stream")
+ self.send_header("Cache-Control", "no-cache")
+ self.send_header("Access-Control-Allow-Origin", "*")
+ self.send_header("Connection", "close")
+ self.end_headers()
+
+ def write_event(self, event: dict[str, Any]) -> None:
+ frame = f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
+ self.wfile.write(frame.encode("utf-8"))
+ self.wfile.flush()
+
+ def write_run_error(
+ self, thread_id: str, run_id: str, message: str
+ ) -> None:
+ self.write_event(
+ {
+ "type": "RUN_ERROR",
+ "threadId": thread_id,
+ "runId": run_id,
+ "error": {"message": message},
+ }
+ )
+
+ def write_done(self) -> None:
+ self.wfile.write(b"data: [DONE]\n\n")
+ self.wfile.flush()
+
+
+def main() -> None:
+ server = ThreadingHTTPServer((HOST, PORT), AguiHandler)
+ print(
+ f"[python] listening on http://{HOST}:{PORT}",
+ flush=True,
+ )
+ try:
+ server.serve_forever()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ server.server_close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/ag-ui/servers/rust/Cargo.lock b/examples/ag-ui/servers/rust/Cargo.lock
new file mode 100644
index 000000000..a3915898f
--- /dev/null
+++ b/examples/ag-ui/servers/rust/Cargo.lock
@@ -0,0 +1,1451 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "ag-ui-echo-rust"
+version = "0.1.0"
+dependencies = [
+ "async-stream",
+ "axum",
+ "futures-util",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "tokio",
+ "tokio-stream",
+]
+
+[[package]]
+name = "async-stream"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
+dependencies = [
+ "async-stream-impl",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-stream-impl"
+version = "0.3.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "cc"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "chacha20"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "rand_core",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
+
+[[package]]
+name = "futures-io"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
+
+[[package]]
+name = "futures-task"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
+
+[[package]]
+name = "futures-util"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi",
+ "rand_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "http"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hyper"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mio"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quinn"
+version = "0.11.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
+dependencies = [
+ "bytes",
+ "getrandom 0.4.3",
+ "lru-slab",
+ "rand",
+ "rand_pcg",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.3",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
+[[package]]
+name = "rand_pcg"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
+dependencies = [
+ "rand_core",
+]
+
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tokio-util",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+ "webpki-roots",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustls"
+version = "0.23.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.0",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2fac314a64dc9a36e61a9eb4261a5e9bbfbc922b27e518af97bc32b926cf967"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.0",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-stream"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.76"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-streams"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/examples/ag-ui/servers/rust/Cargo.toml b/examples/ag-ui/servers/rust/Cargo.toml
new file mode 100644
index 000000000..3cd620fb3
--- /dev/null
+++ b/examples/ag-ui/servers/rust/Cargo.toml
@@ -0,0 +1,14 @@
+[package]
+name = "ag-ui-echo-rust"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+async-stream = "0.3"
+axum = "0.8"
+futures-util = "0.3"
+reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
+tokio-stream = "0.1"
diff --git a/examples/ag-ui/servers/rust/README.md b/examples/ag-ui/servers/rust/README.md
new file mode 100644
index 000000000..93055d0c2
--- /dev/null
+++ b/examples/ag-ui/servers/rust/README.md
@@ -0,0 +1,35 @@
+# Rust AG-UI chat server
+
+AG-UI SSE server using Axum. Streams simple chat completions from OpenAI or Anthropic.
+
+## Run
+
+From `examples/ag-ui`:
+
+```bash
+export OPENAI_API_KEY=...
+export ANTHROPIC_API_KEY=...
+pnpm dev:rust
+# or
+cargo run --manifest-path servers/rust/Cargo.toml
+```
+
+Listens on `http://127.0.0.1:8002`.
+
+## Protocol
+
+- **Endpoint:** `POST /`
+- **Request body:** AG-UI `RunAgentInput` JSON
+- **Provider selection:** `forwardedProps.provider` (`openai` | `anthropic`) and optional `forwardedProps.model`
+- **Response:** `Content-Type: text/event-stream`
+
+Supports simple `user` / `assistant` / `system` messages with string `content` or TanStack-style text `parts`. Tool and reasoning fan-out entries are ignored.
+
+## Environment
+
+| Variable | Required when |
+| ------------------- | --------------------- |
+| `OPENAI_API_KEY` | `provider: openai` |
+| `ANTHROPIC_API_KEY` | `provider: anthropic` |
+
+Default models: `gpt-4o`, `claude-sonnet-4-6`.
diff --git a/examples/ag-ui/servers/rust/src/agui.rs b/examples/ag-ui/servers/rust/src/agui.rs
new file mode 100644
index 000000000..3ecdf1660
--- /dev/null
+++ b/examples/ag-ui/servers/rust/src/agui.rs
@@ -0,0 +1,134 @@
+use serde::Deserialize;
+use serde_json::{json, Value};
+
+pub const DEFAULT_OPENAI_MODEL: &str = "gpt-4o";
+pub const DEFAULT_ANTHROPIC_MODEL: &str = "claude-sonnet-4-6";
+
+#[derive(Debug, Deserialize)]
+pub struct RunAgentInput {
+ #[serde(rename = "threadId")]
+ pub thread_id: String,
+ #[serde(rename = "runId")]
+ pub run_id: String,
+ pub messages: Vec,
+ #[serde(default)]
+ #[serde(rename = "forwardedProps")]
+ pub forwarded_props: Value,
+ #[serde(default)]
+ pub data: Value,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct IncomingMessage {
+ pub role: String,
+ pub content: Option,
+ pub parts: Option>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct MessagePart {
+ #[serde(rename = "type")]
+ pub part_type: String,
+ pub content: Option,
+}
+
+#[derive(Debug, Clone)]
+pub struct ChatMessage {
+ pub role: String,
+ pub content: String,
+}
+
+#[derive(Debug, Clone)]
+pub struct ProviderConfig {
+ pub provider: String,
+ pub model: String,
+}
+
+pub fn provider_from_input(input: &RunAgentInput) -> ProviderConfig {
+ let props = if input.forwarded_props.is_object() {
+ &input.forwarded_props
+ } else {
+ &input.data
+ };
+
+ let provider = props
+ .get("provider")
+ .and_then(Value::as_str)
+ .unwrap_or("openai")
+ .to_string();
+
+ let model = props
+ .get("model")
+ .and_then(Value::as_str)
+ .map(str::to_string);
+
+ match provider.as_str() {
+ "anthropic" => ProviderConfig {
+ provider,
+ model: model.unwrap_or_else(|| DEFAULT_ANTHROPIC_MODEL.to_string()),
+ },
+ _ => ProviderConfig {
+ provider: "openai".to_string(),
+ model: model.unwrap_or_else(|| DEFAULT_OPENAI_MODEL.to_string()),
+ },
+ }
+}
+
+pub fn sse_event(payload: Value) -> String {
+ format!("data: {}\n\n", payload)
+}
+
+pub fn sse_done() -> String {
+ "data: [DONE]\n\n".to_string()
+}
+
+pub fn run_started(thread_id: &str, run_id: &str) -> String {
+ sse_event(json!({
+ "type": "RUN_STARTED",
+ "threadId": thread_id,
+ "runId": run_id,
+ }))
+}
+
+pub fn text_message_start(message_id: &str) -> String {
+ sse_event(json!({
+ "type": "TEXT_MESSAGE_START",
+ "messageId": message_id,
+ "role": "assistant",
+ }))
+}
+
+pub fn text_message_content(message_id: &str, delta: &str) -> String {
+ sse_event(json!({
+ "type": "TEXT_MESSAGE_CONTENT",
+ "messageId": message_id,
+ "delta": delta,
+ }))
+}
+
+pub fn text_message_end(message_id: &str) -> String {
+ sse_event(json!({
+ "type": "TEXT_MESSAGE_END",
+ "messageId": message_id,
+ }))
+}
+
+pub fn run_finished(thread_id: &str, run_id: &str) -> String {
+ sse_event(json!({
+ "type": "RUN_FINISHED",
+ "threadId": thread_id,
+ "runId": run_id,
+ "finishReason": "stop",
+ }))
+}
+
+pub fn run_error(thread_id: &str, run_id: &str, message: &str) -> String {
+ sse_event(json!({
+ "type": "RUN_ERROR",
+ "threadId": thread_id,
+ "runId": run_id,
+ "error": {
+ "message": message,
+ },
+ }))
+}
diff --git a/examples/ag-ui/servers/rust/src/main.rs b/examples/ag-ui/servers/rust/src/main.rs
new file mode 100644
index 000000000..8451ffb29
--- /dev/null
+++ b/examples/ag-ui/servers/rust/src/main.rs
@@ -0,0 +1,170 @@
+mod agui;
+mod messages;
+mod providers;
+
+use agui::{
+ provider_from_input, run_error, run_finished, run_started, sse_done, text_message_content,
+ text_message_end, text_message_start, RunAgentInput,
+};
+use async_stream::stream;
+use axum::{
+ body::{Body, Bytes},
+ extract::{Request, State},
+ http::{header, Method, StatusCode},
+ response::{IntoResponse, Response},
+ routing::{get, post},
+ Router,
+};
+use futures_util::stream;
+use messages::to_chat_messages;
+use providers::stream_completion;
+use reqwest::Client;
+use std::convert::Infallible;
+use tokio::sync::mpsc;
+
+const LISTEN_ADDR: &str = "127.0.0.1:8002";
+
+#[tokio::main]
+async fn main() {
+ let client = Client::new();
+ let app = Router::new()
+ .route("/", post(handle_chat))
+ .route("/health", get(|| async { "ok" }))
+ .with_state(client);
+
+ let listener = tokio::net::TcpListener::bind(LISTEN_ADDR)
+ .await
+ .expect("failed to bind rust server");
+
+ println!("AG-UI Rust server listening on http://{LISTEN_ADDR}");
+
+ axum::serve(listener, app)
+ .await
+ .expect("rust server failed");
+}
+
+async fn handle_chat(State(client): State, request: Request) -> Response {
+ if request.method() == Method::OPTIONS {
+ return options_response();
+ }
+
+ let body = axum::body::to_bytes(request.into_body(), 1024 * 1024)
+ .await
+ .unwrap_or_default();
+
+ let input: RunAgentInput = match serde_json::from_slice(&body) {
+ Ok(value) => value,
+ Err(_) => {
+ return (StatusCode::BAD_REQUEST, "invalid JSON body").into_response();
+ }
+ };
+
+ if input.thread_id.is_empty() || input.run_id.is_empty() {
+ return (
+ StatusCode::BAD_REQUEST,
+ "threadId and runId are required",
+ )
+ .into_response();
+ }
+
+ let config = provider_from_input(&input);
+ let message_id = format!("msg-{}", input.run_id);
+ let (system, messages) = to_chat_messages(&input.messages);
+
+ if messages.is_empty() {
+ let frames = vec![
+ run_started(&input.thread_id, &input.run_id),
+ run_error(
+ &input.thread_id,
+ &input.run_id,
+ "no user or assistant messages to send",
+ ),
+ sse_done(),
+ ];
+ return sse_response(stream::iter(
+ frames
+ .into_iter()
+ .map(|frame| Ok::(Bytes::from(frame))),
+ ));
+ }
+
+ let thread_id = input.thread_id.clone();
+ let run_id = input.run_id.clone();
+ let provider = config.provider.clone();
+ let model = config.model.clone();
+ let (tx, mut rx) = mpsc::unbounded_channel::();
+
+ let llm_client = client.clone();
+ let llm_message_id = message_id.clone();
+ let llm_system = system.clone();
+ let llm_messages = messages.clone();
+
+ tokio::spawn(async move {
+ let result = stream_completion(
+ &llm_client,
+ &provider,
+ &model,
+ &llm_system,
+ &llm_messages,
+ |delta| {
+ if delta.is_empty() {
+ return Ok(());
+ }
+ tx.send(text_message_content(&llm_message_id, &delta))
+ .map_err(|_| "client disconnected".to_string())
+ },
+ )
+ .await;
+
+ let _ = match result {
+ Ok(()) => tx.send(format!(
+ "{}{}{}",
+ text_message_end(&llm_message_id),
+ run_finished(&thread_id, &run_id),
+ sse_done()
+ )),
+ Err(message) => {
+ eprintln!("[rust] chat error: {message}");
+ tx.send(format!(
+ "{}{}",
+ run_error(&thread_id, &run_id, &message),
+ sse_done()
+ ))
+ }
+ };
+ });
+
+ let body_stream = stream! {
+ yield Ok(Bytes::from(run_started(&input.thread_id, &input.run_id)));
+ yield Ok(Bytes::from(text_message_start(&message_id)));
+
+ while let Some(frame) = rx.recv().await {
+ yield Ok(Bytes::from(frame));
+ }
+ };
+
+ sse_response(body_stream)
+}
+
+fn sse_response(
+ body_stream: impl futures_util::Stream- > + Send + 'static,
+) -> Response {
+ Response::builder()
+ .status(StatusCode::OK)
+ .header(header::CONTENT_TYPE, "text/event-stream")
+ .header(header::CACHE_CONTROL, "no-cache")
+ .header(header::CONNECTION, "keep-alive")
+ .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
+ .body(Body::from_stream(body_stream))
+ .unwrap()
+}
+
+fn options_response() -> Response {
+ Response::builder()
+ .status(StatusCode::NO_CONTENT)
+ .header(header::ACCESS_CONTROL_ALLOW_ORIGIN, "*")
+ .header(header::ACCESS_CONTROL_ALLOW_METHODS, "POST, OPTIONS")
+ .header(header::ACCESS_CONTROL_ALLOW_HEADERS, "Content-Type")
+ .body(Body::empty())
+ .unwrap()
+}
diff --git a/examples/ag-ui/servers/rust/src/messages.rs b/examples/ag-ui/servers/rust/src/messages.rs
new file mode 100644
index 000000000..ddb164fd9
--- /dev/null
+++ b/examples/ag-ui/servers/rust/src/messages.rs
@@ -0,0 +1,77 @@
+use crate::agui::{ChatMessage, IncomingMessage};
+
+pub fn to_chat_messages(messages: &[IncomingMessage]) -> (String, Vec) {
+ let mut system = String::new();
+ let mut chat = Vec::new();
+
+ for message in messages {
+ let mut role = message.role.as_str();
+ match role {
+ "developer" => role = "system",
+ "tool" | "reasoning" => continue,
+ _ => {}
+ }
+
+ let text = text_from_message(message);
+ if text.is_empty() {
+ continue;
+ }
+
+ match role {
+ "system" => {
+ if !system.is_empty() {
+ system.push_str("\n\n");
+ }
+ system.push_str(&text);
+ }
+ "user" | "assistant" => chat.push(ChatMessage {
+ role: role.to_string(),
+ content: text,
+ }),
+ _ => {}
+ }
+ }
+
+ (system, chat)
+}
+
+fn text_from_message(message: &IncomingMessage) -> String {
+ if let Some(parts) = &message.parts {
+ let joined = parts
+ .iter()
+ .filter(|part| part.part_type == "text")
+ .filter_map(|part| part.content.as_deref())
+ .collect::>()
+ .join("");
+ if !joined.is_empty() {
+ return joined;
+ }
+ }
+
+ let Some(content) = &message.content else {
+ return String::new();
+ };
+
+ if let Some(text) = content.as_str() {
+ return text.to_string();
+ }
+
+ if let Some(items) = content.as_array() {
+ let joined = items
+ .iter()
+ .filter_map(|item| {
+ if item.get("type")?.as_str()? == "text" {
+ item.get("text")?.as_str().map(str::to_string)
+ } else {
+ None
+ }
+ })
+ .collect::>()
+ .join("");
+ if !joined.is_empty() {
+ return joined;
+ }
+ }
+
+ content.to_string()
+}
diff --git a/examples/ag-ui/servers/rust/src/providers.rs b/examples/ag-ui/servers/rust/src/providers.rs
new file mode 100644
index 000000000..27f19e412
--- /dev/null
+++ b/examples/ag-ui/servers/rust/src/providers.rs
@@ -0,0 +1,189 @@
+use crate::agui::ChatMessage;
+use futures_util::StreamExt;
+use reqwest::Client;
+use serde_json::{json, Value};
+use std::env;
+
+pub async fn stream_openai(
+ client: &Client,
+ model: &str,
+ system: &str,
+ messages: &[ChatMessage],
+ mut emit: impl FnMut(String) -> Result<(), String>,
+) -> Result<(), String> {
+ let api_key = env::var("OPENAI_API_KEY")
+ .map_err(|_| "OPENAI_API_KEY is not set".to_string())?;
+
+ let mut openai_messages: Vec = Vec::new();
+ if !system.is_empty() {
+ openai_messages.push(json!({
+ "role": "system",
+ "content": system,
+ }));
+ }
+ for message in messages {
+ openai_messages.push(json!({
+ "role": message.role,
+ "content": message.content,
+ }));
+ }
+
+ let response = client
+ .post("https://api.openai.com/v1/chat/completions")
+ .bearer_auth(api_key)
+ .json(&json!({
+ "model": model,
+ "messages": openai_messages,
+ "stream": true,
+ }))
+ .send()
+ .await
+ .map_err(|error| error.to_string())?;
+
+ let status = response.status();
+ if !status.is_success() {
+ let body = response.text().await.unwrap_or_default();
+ return Err(format!(
+ "openai request failed ({status}): {}",
+ body.trim()
+ ));
+ }
+
+ let mut stream = response.bytes_stream();
+ let mut buffer = String::new();
+
+ while let Some(chunk) = stream.next().await {
+ let chunk = chunk.map_err(|error| error.to_string())?;
+ buffer.push_str(&String::from_utf8_lossy(&chunk));
+
+ while let Some(line_end) = buffer.find('\n') {
+ let line = buffer[..line_end].trim_end_matches('\r').to_string();
+ buffer = buffer[line_end + 1..].to_string();
+
+ if !line.starts_with("data: ") {
+ continue;
+ }
+ let data = line.trim_start_matches("data: ");
+ if data == "[DONE]" {
+ return Ok(());
+ }
+
+ let parsed: Value = match serde_json::from_str(data) {
+ Ok(value) => value,
+ Err(_) => continue,
+ };
+ if let Some(delta) = parsed
+ .pointer("/choices/0/delta/content")
+ .and_then(Value::as_str)
+ {
+ emit(delta.to_string())?;
+ }
+ }
+ }
+
+ Ok(())
+}
+
+pub async fn stream_anthropic(
+ client: &Client,
+ model: &str,
+ system: &str,
+ messages: &[ChatMessage],
+ mut emit: impl FnMut(String) -> Result<(), String>,
+) -> Result<(), String> {
+ let api_key = env::var("ANTHROPIC_API_KEY")
+ .map_err(|_| "ANTHROPIC_API_KEY is not set".to_string())?;
+
+ let anthropic_messages: Vec = messages
+ .iter()
+ .map(|message| {
+ json!({
+ "role": message.role,
+ "content": message.content,
+ })
+ })
+ .collect();
+
+ let mut body = json!({
+ "model": model,
+ "max_tokens": 4096,
+ "messages": anthropic_messages,
+ "stream": true,
+ });
+ if !system.is_empty() {
+ body["system"] = json!(system);
+ }
+
+ let response = client
+ .post("https://api.anthropic.com/v1/messages")
+ .header("x-api-key", api_key)
+ .header("anthropic-version", "2023-06-01")
+ .json(&body)
+ .send()
+ .await
+ .map_err(|error| error.to_string())?;
+
+ let status = response.status();
+ if !status.is_success() {
+ let body = response.text().await.unwrap_or_default();
+ return Err(format!(
+ "anthropic request failed ({status}): {}",
+ body.trim()
+ ));
+ }
+
+ let mut stream = response.bytes_stream();
+ let mut buffer = String::new();
+
+ while let Some(chunk) = stream.next().await {
+ let chunk = chunk.map_err(|error| error.to_string())?;
+ buffer.push_str(&String::from_utf8_lossy(&chunk));
+
+ while let Some(line_end) = buffer.find('\n') {
+ let line = buffer[..line_end].trim_end_matches('\r').to_string();
+ buffer = buffer[line_end + 1..].to_string();
+
+ if !line.starts_with("data: ") {
+ continue;
+ }
+ let data = line.trim_start_matches("data: ");
+ let parsed: Value = match serde_json::from_str(data) {
+ Ok(value) => value,
+ Err(_) => continue,
+ };
+
+ if parsed.get("type").and_then(Value::as_str) != Some("content_block_delta") {
+ continue;
+ }
+ if parsed
+ .pointer("/delta/type")
+ .and_then(Value::as_str)
+ != Some("text_delta")
+ {
+ continue;
+ }
+ if let Some(text) = parsed.pointer("/delta/text").and_then(Value::as_str) {
+ emit(text.to_string())?;
+ }
+ }
+ }
+
+ Ok(())
+}
+
+pub async fn stream_completion(
+ client: &Client,
+ provider: &str,
+ model: &str,
+ system: &str,
+ messages: &[ChatMessage],
+ emit: impl FnMut(String) -> Result<(), String>,
+) -> Result<(), String> {
+ match provider {
+ "openai" => stream_openai(client, model, system, messages, emit).await,
+ "anthropic" => stream_anthropic(client, model, system, messages, emit).await,
+ other => Err(format!(
+ "unsupported provider {other:?} (expected openai or anthropic)"
+ )),
+ }
+}
diff --git a/examples/ag-ui/servers/zig/README.md b/examples/ag-ui/servers/zig/README.md
new file mode 100644
index 000000000..1bd95c8f5
--- /dev/null
+++ b/examples/ag-ui/servers/zig/README.md
@@ -0,0 +1,28 @@
+# Zig AG-UI server
+
+Hand-rolled AG-UI SSE server on `http://127.0.0.1:8004`.
+
+## Run
+
+From `examples/ag-ui`:
+
+```bash
+pnpm dev:zig
+```
+
+Requires Zig with stdlib HTTP client support (tested with Zig 0.16).
+
+## Environment
+
+- `OPENAI_API_KEY`
+- `ANTHROPIC_API_KEY`
+
+## Endpoints
+
+| Method | Path | Description |
+| ------- | --------- | ------------------- |
+| POST | `/` | AG-UI chat (SSE) |
+| GET | `/health` | Health check (`ok`) |
+| OPTIONS | `/` | CORS preflight |
+
+The client sends `forwardedProps: { provider, model }` in the AG-UI `RunAgentInput` body.
diff --git a/examples/ag-ui/servers/zig/build.zig b/examples/ag-ui/servers/zig/build.zig
new file mode 100644
index 000000000..802fe4f63
--- /dev/null
+++ b/examples/ag-ui/servers/zig/build.zig
@@ -0,0 +1,23 @@
+const std = @import("std");
+
+pub fn build(b: *std.Build) void {
+ const target = b.standardTargetOptions(.{});
+ const optimize = b.standardOptimizeOption(.{});
+
+ const root_module = b.createModule(.{
+ .root_source_file = b.path("src/main.zig"),
+ .target = target,
+ .optimize = optimize,
+ });
+
+ const exe = b.addExecutable(.{
+ .name = "ag-ui-zig",
+ .root_module = root_module,
+ });
+
+ b.installArtifact(exe);
+
+ const run_step = b.addRunArtifact(exe);
+ const run_cmd = b.step("run", "Run the AG-UI Zig server");
+ run_cmd.dependOn(&run_step.step);
+}
diff --git a/examples/ag-ui/servers/zig/src/agui.zig b/examples/ag-ui/servers/zig/src/agui.zig
new file mode 100644
index 000000000..5f954326e
--- /dev/null
+++ b/examples/ag-ui/servers/zig/src/agui.zig
@@ -0,0 +1,152 @@
+const std = @import("std");
+
+pub const DEFAULT_OPENAI_MODEL = "gpt-4o";
+pub const DEFAULT_ANTHROPIC_MODEL = "claude-sonnet-4-6";
+
+pub const RunAgentInput = struct {
+ threadId: []const u8,
+ runId: []const u8,
+ messages: []IncomingMessage,
+ forwardedProps: std.json.Value = .null,
+ data: std.json.Value = .null,
+};
+
+pub const IncomingMessage = struct {
+ role: []const u8,
+ content: ?std.json.Value = null,
+ parts: ?[]MessagePart = null,
+};
+
+pub const MessagePart = struct {
+ @"type": []const u8,
+ content: ?[]const u8 = null,
+};
+
+pub const ChatMessage = struct {
+ role: []const u8,
+ content: []const u8,
+};
+
+pub const ProviderConfig = struct {
+ provider: []const u8,
+ model: []const u8,
+};
+
+pub fn providerFromInput(input: *const RunAgentInput, allocator: std.mem.Allocator) !ProviderConfig {
+ const props = if (input.forwardedProps == .object) input.forwardedProps else input.data;
+
+ var provider: []const u8 = "openai";
+ var model: ?[]const u8 = null;
+
+ if (props == .object) {
+ if (props.object.get("provider")) |value| {
+ if (value == .string and value.string.len > 0) {
+ provider = value.string;
+ }
+ }
+ if (props.object.get("model")) |value| {
+ if (value == .string) {
+ model = value.string;
+ }
+ }
+ }
+
+ if (std.mem.eql(u8, provider, "anthropic")) {
+ return ProviderConfig{
+ .provider = "anthropic",
+ .model = if (model) |m| try allocator.dupe(u8, m) else try allocator.dupe(u8, DEFAULT_ANTHROPIC_MODEL),
+ };
+ }
+
+ return ProviderConfig{
+ .provider = "openai",
+ .model = if (model) |m| try allocator.dupe(u8, m) else try allocator.dupe(u8, DEFAULT_OPENAI_MODEL),
+ };
+}
+
+fn sseEvent(allocator: std.mem.Allocator, payload: anytype) ![]u8 {
+ const json = try std.json.Stringify.valueAlloc(allocator, payload, .{});
+ defer allocator.free(json);
+ return std.fmt.allocPrint(allocator, "data: {s}\n\n", .{json});
+}
+
+pub fn sseDone(allocator: std.mem.Allocator) ![]u8 {
+ return try allocator.dupe(u8, "data: [DONE]\n\n");
+}
+
+pub fn runStarted(allocator: std.mem.Allocator, thread_id: []const u8, run_id: []const u8) ![]u8 {
+ return sseEvent(allocator, .{
+ .type = "RUN_STARTED",
+ .threadId = thread_id,
+ .runId = run_id,
+ });
+}
+
+pub fn textMessageStart(allocator: std.mem.Allocator, message_id: []const u8) ![]u8 {
+ return sseEvent(allocator, .{
+ .type = "TEXT_MESSAGE_START",
+ .messageId = message_id,
+ .role = "assistant",
+ });
+}
+
+pub fn textMessageContent(allocator: std.mem.Allocator, message_id: []const u8, delta: []const u8) ![]u8 {
+ return sseEvent(allocator, .{
+ .type = "TEXT_MESSAGE_CONTENT",
+ .messageId = message_id,
+ .delta = delta,
+ });
+}
+
+pub fn textMessageEnd(allocator: std.mem.Allocator, message_id: []const u8) ![]u8 {
+ return sseEvent(allocator, .{
+ .type = "TEXT_MESSAGE_END",
+ .messageId = message_id,
+ });
+}
+
+pub fn runFinished(allocator: std.mem.Allocator, thread_id: []const u8, run_id: []const u8) ![]u8 {
+ return sseEvent(allocator, .{
+ .type = "RUN_FINISHED",
+ .threadId = thread_id,
+ .runId = run_id,
+ .finishReason = "stop",
+ });
+}
+
+pub fn runError(allocator: std.mem.Allocator, thread_id: []const u8, run_id: []const u8, message: []const u8) ![]u8 {
+ const RunErrorPayload = struct {
+ type: []const u8,
+ threadId: []const u8,
+ runId: []const u8,
+ err: struct { message: []const u8 },
+
+ pub fn jsonStringify(self: @This(), jw: anytype) !void {
+ try jw.beginObject();
+ try jw.objectField("type");
+ try jw.write(self.type);
+ try jw.objectField("threadId");
+ try jw.write(self.threadId);
+ try jw.objectField("runId");
+ try jw.write(self.runId);
+ try jw.objectField("error");
+ try jw.beginObject();
+ try jw.objectField("message");
+ try jw.write(self.err.message);
+ try jw.endObject();
+ try jw.endObject();
+ }
+ };
+
+ return sseEvent(allocator, RunErrorPayload{
+ .type = "RUN_ERROR",
+ .threadId = thread_id,
+ .runId = run_id,
+ .err = .{ .message = message },
+ });
+}
+
+pub fn writeSse(response: *std.http.BodyWriter, frame: []const u8) !void {
+ try response.writer.writeAll(frame);
+ try response.flush();
+}
diff --git a/examples/ag-ui/servers/zig/src/main.zig b/examples/ag-ui/servers/zig/src/main.zig
new file mode 100644
index 000000000..be736807b
--- /dev/null
+++ b/examples/ag-ui/servers/zig/src/main.zig
@@ -0,0 +1,258 @@
+const std = @import("std");
+const Io = std.Io;
+
+const agui = @import("agui.zig");
+const messages = @import("messages.zig");
+const providers = @import("providers.zig");
+
+const LISTEN_ADDR = "127.0.0.1";
+const LISTEN_PORT: u16 = 8004;
+
+pub fn main(init: std.process.Init) !void {
+ const allocator = init.gpa;
+ const io = init.io;
+
+ const address = try Io.net.IpAddress.parse(LISTEN_ADDR, LISTEN_PORT);
+ var listener = try address.listen(io, .{ .reuse_address = true });
+ defer listener.socket.close(io);
+
+ std.debug.print("AG-UI Zig server listening on http://127.0.0.1:{d}\n", .{LISTEN_PORT});
+
+ while (true) {
+ const stream = try listener.accept(io);
+ handleConnection(allocator, io, stream) catch |err| {
+ std.log.err("connection error: {}", .{err});
+ };
+ }
+}
+
+fn handleConnection(allocator: std.mem.Allocator, io: Io, stream: Io.net.Stream) !void {
+ defer stream.close(io);
+
+ var recv_buffer: [8192]u8 = undefined;
+ var send_buffer: [8192]u8 = undefined;
+ var conn_reader = stream.reader(io, &recv_buffer);
+ var conn_writer = stream.writer(io, &send_buffer);
+ var server = std.http.Server.init(&conn_reader.interface, &conn_writer.interface);
+
+ while (server.reader.state == .ready) {
+ var request = server.receiveHead() catch |err| switch (err) {
+ error.HttpConnectionClosing => return,
+ else => return err,
+ };
+
+ try serveRequest(allocator, io, &request);
+ }
+}
+
+fn serveRequest(allocator: std.mem.Allocator, io: Io, request: *std.http.Server.Request) !void {
+ if (request.head.method == .OPTIONS) {
+ try sendOptions(request);
+ return;
+ }
+
+ if (request.head.method == .GET and std.mem.eql(u8, request.head.target, "/health")) {
+ try request.respond("ok", .{
+ .extra_headers = &.{
+ .{ .name = "content-type", .value = "text/plain" },
+ .{ .name = "access-control-allow-origin", .value = "*" },
+ },
+ });
+ return;
+ }
+
+ if (request.head.method == .POST and std.mem.eql(u8, request.head.target, "/")) {
+ try handleChat(allocator, io, request);
+ return;
+ }
+
+ if (request.head.method == .POST) {
+ try sendPlainError(request, .not_found, "not found");
+ return;
+ }
+
+ try sendPlainError(request, .method_not_allowed, "method not allowed");
+}
+
+fn sendOptions(request: *std.http.Server.Request) !void {
+ try request.respond("", .{
+ .status = .no_content,
+ .extra_headers = &.{
+ .{ .name = "access-control-allow-origin", .value = "*" },
+ .{ .name = "access-control-allow-methods", .value = "POST, OPTIONS" },
+ .{ .name = "access-control-allow-headers", .value = "Content-Type" },
+ },
+ });
+}
+
+fn sendPlainError(request: *std.http.Server.Request, status: std.http.Status, message: []const u8) !void {
+ try request.respond(message, .{
+ .status = status,
+ .extra_headers = &.{
+ .{ .name = "content-type", .value = "text/plain" },
+ },
+ });
+}
+
+const EmitCtx = struct {
+ allocator: std.mem.Allocator,
+ response: *std.http.BodyWriter,
+ message_id: []const u8,
+
+ fn emit(ctx: *anyopaque, delta: []const u8) !void {
+ const self: *EmitCtx = @ptrCast(@alignCast(ctx));
+ if (delta.len == 0) return;
+ const frame = try agui.textMessageContent(self.allocator, self.message_id, delta);
+ defer self.allocator.free(frame);
+ try agui.writeSse(self.response, frame);
+ }
+};
+
+fn handleChat(allocator: std.mem.Allocator, io: Io, request: *std.http.Server.Request) !void {
+ var body_reader_buffer: [4096]u8 = undefined;
+ const body_reader = request.readerExpectContinue(&body_reader_buffer) catch {
+ try sendPlainError(request, .bad_request, "failed to read body");
+ return;
+ };
+
+ const body = body_reader.allocRemaining(allocator, .limited(1024 * 1024)) catch {
+ try sendPlainError(request, .bad_request, "failed to read body");
+ return;
+ };
+ defer allocator.free(body);
+
+ const parsed = std.json.parseFromSlice(agui.RunAgentInput, allocator, body, .{
+ .ignore_unknown_fields = true,
+ }) catch {
+ try sendPlainError(request, .bad_request, "invalid JSON body");
+ return;
+ };
+ defer parsed.deinit();
+
+ const input = parsed.value;
+ if (input.threadId.len == 0 or input.runId.len == 0) {
+ try sendPlainError(request, .bad_request, "threadId and runId are required");
+ return;
+ }
+
+ const config = agui.providerFromInput(&input, allocator) catch {
+ try sendPlainError(request, .internal_server_error, "failed to parse provider config");
+ return;
+ };
+ defer allocator.free(config.model);
+
+ const chat_messages = messages.toChatMessages(allocator, input.messages) catch {
+ try sendPlainError(request, .internal_server_error, "failed to parse messages");
+ return;
+ };
+ defer messages.freeChatMessages(allocator, chat_messages.system, chat_messages.chat);
+
+ if (chat_messages.chat.len == 0) {
+ try streamErrorOnly(allocator, request, input.threadId, input.runId, "no user or assistant messages to send");
+ return;
+ }
+
+ var send_buffer: [4096]u8 = undefined;
+ var response = request.respondStreaming(&send_buffer, .{
+ .respond_options = .{
+ .extra_headers = &.{
+ .{ .name = "content-type", .value = "text/event-stream" },
+ .{ .name = "cache-control", .value = "no-cache" },
+ .{ .name = "connection", .value = "keep-alive" },
+ .{ .name = "access-control-allow-origin", .value = "*" },
+ },
+ },
+ }) catch {
+ try sendPlainError(request, .internal_server_error, "streaming unsupported");
+ return;
+ };
+
+ const message_id = try std.fmt.allocPrint(allocator, "msg-{s}", .{input.runId});
+ defer allocator.free(message_id);
+
+ const started = try agui.runStarted(allocator, input.threadId, input.runId);
+ defer allocator.free(started);
+ try agui.writeSse(&response, started);
+
+ const text_start = try agui.textMessageStart(allocator, message_id);
+ defer allocator.free(text_start);
+ try agui.writeSse(&response, text_start);
+
+ var emit_ctx = EmitCtx{
+ .allocator = allocator,
+ .response = &response,
+ .message_id = message_id,
+ };
+
+ providers.streamCompletion(
+ allocator,
+ io,
+ config,
+ chat_messages.system,
+ chat_messages.chat,
+ &emit_ctx,
+ EmitCtx.emit,
+ ) catch |err| {
+ const message = switch (err) {
+ error.MissingOpenAIKey => "OPENAI_API_KEY is not set",
+ error.MissingAnthropicKey => "ANTHROPIC_API_KEY is not set",
+ error.UnsupportedProvider => "unsupported provider (expected openai or anthropic)",
+ error.UnsupportedCompressionMethod => "unsupported provider compression",
+ else => "provider request failed",
+ };
+ const frame = try agui.runError(allocator, input.threadId, input.runId, message);
+ defer allocator.free(frame);
+ try agui.writeSse(&response, frame);
+ const done = try agui.sseDone(allocator);
+ defer allocator.free(done);
+ try agui.writeSse(&response, done);
+ try response.end();
+ return;
+ };
+
+ const text_end = try agui.textMessageEnd(allocator, message_id);
+ defer allocator.free(text_end);
+ try agui.writeSse(&response, text_end);
+
+ const finished = try agui.runFinished(allocator, input.threadId, input.runId);
+ defer allocator.free(finished);
+ try agui.writeSse(&response, finished);
+
+ const done = try agui.sseDone(allocator);
+ defer allocator.free(done);
+ try agui.writeSse(&response, done);
+ try response.end();
+}
+
+fn streamErrorOnly(
+ allocator: std.mem.Allocator,
+ request: *std.http.Server.Request,
+ thread_id: []const u8,
+ run_id: []const u8,
+ message: []const u8,
+) !void {
+ var send_buffer: [4096]u8 = undefined;
+ var response = try request.respondStreaming(&send_buffer, .{
+ .respond_options = .{
+ .extra_headers = &.{
+ .{ .name = "content-type", .value = "text/event-stream" },
+ .{ .name = "cache-control", .value = "no-cache" },
+ .{ .name = "connection", .value = "keep-alive" },
+ .{ .name = "access-control-allow-origin", .value = "*" },
+ },
+ },
+ });
+
+ const started = try agui.runStarted(allocator, thread_id, run_id);
+ defer allocator.free(started);
+ try agui.writeSse(&response, started);
+
+ const frame = try agui.runError(allocator, thread_id, run_id, message);
+ defer allocator.free(frame);
+ try agui.writeSse(&response, frame);
+
+ const done = try agui.sseDone(allocator);
+ defer allocator.free(done);
+ try agui.writeSse(&response, done);
+ try response.end();
+}
diff --git a/examples/ag-ui/servers/zig/src/messages.zig b/examples/ag-ui/servers/zig/src/messages.zig
new file mode 100644
index 000000000..42163a020
--- /dev/null
+++ b/examples/ag-ui/servers/zig/src/messages.zig
@@ -0,0 +1,105 @@
+const std = @import("std");
+const agui = @import("agui.zig");
+
+pub fn textFromMessageAlloc(allocator: std.mem.Allocator, message: *const agui.IncomingMessage) ![]const u8 {
+ if (message.parts) |parts| {
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(allocator);
+ for (parts) |part| {
+ if (std.mem.eql(u8, part.@"type", "text")) {
+ if (part.content) |content| {
+ if (content.len > 0) {
+ try out.appendSlice(allocator, content);
+ }
+ }
+ }
+ }
+ if (out.items.len > 0) {
+ return out.toOwnedSlice(allocator);
+ }
+ }
+
+ if (message.content) |content| {
+ switch (content) {
+ .string => |text| return try allocator.dupe(u8, text),
+ .array => |items| {
+ var out: std.ArrayList(u8) = .empty;
+ errdefer out.deinit(allocator);
+ for (items.items) |item| {
+ if (item != .object) continue;
+ if (item.object.get("type")) |type_value| {
+ if (type_value != .string or !std.mem.eql(u8, type_value.string, "text")) continue;
+ } else continue;
+ if (item.object.get("text")) |text_value| {
+ if (text_value == .string) {
+ try out.appendSlice(allocator, text_value.string);
+ }
+ }
+ }
+ if (out.items.len > 0) {
+ return out.toOwnedSlice(allocator);
+ }
+ },
+ else => {},
+ }
+ }
+
+ return try allocator.dupe(u8, "");
+}
+
+pub fn toChatMessages(
+ allocator: std.mem.Allocator,
+ messages: []const agui.IncomingMessage,
+) !struct { system: []const u8, chat: []agui.ChatMessage } {
+ var system_parts: std.ArrayList([]const u8) = .empty;
+ defer {
+ for (system_parts.items) |part| allocator.free(part);
+ system_parts.deinit(allocator);
+ }
+
+ var chat: std.ArrayList(agui.ChatMessage) = .empty;
+ errdefer chat.deinit(allocator);
+
+ for (messages) |message| {
+ var role = message.role;
+ if (std.mem.eql(u8, role, "developer")) {
+ role = "system";
+ } else if (std.mem.eql(u8, role, "tool") or std.mem.eql(u8, role, "reasoning")) {
+ continue;
+ }
+
+ const text = try textFromMessageAlloc(allocator, &message);
+ defer allocator.free(text);
+ if (text.len == 0) continue;
+
+ if (std.mem.eql(u8, role, "system")) {
+ try system_parts.append(allocator, try allocator.dupe(u8, text));
+ } else if (std.mem.eql(u8, role, "user") or std.mem.eql(u8, role, "assistant")) {
+ try chat.append(allocator, .{
+ .role = try allocator.dupe(u8, role),
+ .content = try allocator.dupe(u8, text),
+ });
+ }
+ }
+
+ var system: std.ArrayList(u8) = .empty;
+ errdefer system.deinit(allocator);
+ for (system_parts.items, 0..) |part, index| {
+ if (index > 0) try system.appendSlice(allocator, "\n\n");
+ try system.appendSlice(allocator, part);
+ }
+
+ return .{
+ .system = try system.toOwnedSlice(allocator),
+ .chat = try chat.toOwnedSlice(allocator),
+ };
+}
+
+pub fn freeChatMessages(allocator: std.mem.Allocator, system: []const u8, chat: []agui.ChatMessage) void {
+ allocator.free(system);
+ for (chat) |message| {
+ allocator.free(message.role);
+ allocator.free(message.content);
+ }
+ allocator.free(chat);
+}
diff --git a/examples/ag-ui/servers/zig/src/providers.zig b/examples/ag-ui/servers/zig/src/providers.zig
new file mode 100644
index 000000000..7a2b4536f
--- /dev/null
+++ b/examples/ag-ui/servers/zig/src/providers.zig
@@ -0,0 +1,297 @@
+const std = @import("std");
+const agui = @import("agui.zig");
+
+pub const MissingOpenAIKey = error.MissingOpenAIKey;
+pub const MissingAnthropicKey = error.MissingAnthropicKey;
+pub const UnsupportedProvider = error.UnsupportedProvider;
+pub const ProviderRequestFailed = error.ProviderRequestFailed;
+pub const UnsupportedCompressionMethod = error.UnsupportedCompressionMethod;
+
+const EmitFn = *const fn (ctx: *anyopaque, delta: []const u8) anyerror!void;
+const HandleDataLineFn = *const fn (allocator: std.mem.Allocator, data: []const u8, ctx: *anyopaque, emit: EmitFn) anyerror!void;
+
+const OpenAIChunk = struct {
+ choices: []struct {
+ delta: struct {
+ content: ?[]const u8 = null,
+ },
+ },
+};
+
+const AnthropicChunk = struct {
+ type: []const u8,
+ delta: ?struct {
+ type: []const u8,
+ text: ?[]const u8 = null,
+ } = null,
+};
+
+fn shiftLine(remaining: *std.ArrayList(u8), line_end: usize) void {
+ const remainder_start = line_end + 1;
+ const remainder_len = remaining.items.len - remainder_start;
+ if (remainder_len > 0) {
+ std.mem.copyForwards(u8, remaining.items[0..remainder_len], remaining.items[remainder_start..]);
+ }
+ remaining.items.len = remainder_len;
+}
+
+fn handleOpenAILine(allocator: std.mem.Allocator, data: []const u8, ctx: *anyopaque, emit: EmitFn) !void {
+ var parsed = std.json.parseFromSlice(OpenAIChunk, allocator, data, .{
+ .ignore_unknown_fields = true,
+ }) catch return;
+ defer parsed.deinit();
+ if (parsed.value.choices.len == 0) return;
+ if (parsed.value.choices[0].delta.content) |content| {
+ if (content.len > 0) try emit(ctx, content);
+ }
+}
+
+fn handleAnthropicLine(allocator: std.mem.Allocator, data: []const u8, ctx: *anyopaque, emit: EmitFn) !void {
+ var parsed = std.json.parseFromSlice(AnthropicChunk, allocator, data, .{
+ .ignore_unknown_fields = true,
+ }) catch return;
+ defer parsed.deinit();
+ if (!std.mem.eql(u8, parsed.value.type, "content_block_delta")) return;
+ const delta = parsed.value.delta orelse return;
+ if (!std.mem.eql(u8, delta.type, "text_delta")) return;
+ if (delta.text) |text| {
+ if (text.len > 0) try emit(ctx, text);
+ }
+}
+
+fn processSseLine(
+ allocator: std.mem.Allocator,
+ line: []const u8,
+ ctx: *anyopaque,
+ emit: EmitFn,
+ handleDataLine: HandleDataLineFn,
+) !bool {
+ if (!std.mem.startsWith(u8, line, "data: ")) return false;
+ const data = std.mem.trim(u8, line[6..], " \t");
+ if (std.mem.eql(u8, data, "[DONE]")) return true;
+ try handleDataLine(allocator, data, ctx, emit);
+ return false;
+}
+
+fn processSseBuffer(
+ allocator: std.mem.Allocator,
+ remaining: *std.ArrayList(u8),
+ ctx: *anyopaque,
+ emit: EmitFn,
+ handleDataLine: HandleDataLineFn,
+) !bool {
+ while (std.mem.indexOfScalar(u8, remaining.items, '\n')) |line_end| {
+ const line = std.mem.trimEnd(u8, remaining.items[0..line_end], "\r");
+ if (try processSseLine(allocator, line, ctx, emit, handleDataLine)) return true;
+ shiftLine(remaining, line_end);
+ }
+ return false;
+}
+
+fn streamProviderSse(
+ allocator: std.mem.Allocator,
+ io: std.Io,
+ url: []const u8,
+ headers: []const std.http.Header,
+ payload: []const u8,
+ handleDataLine: HandleDataLineFn,
+ ctx: *anyopaque,
+ emit: EmitFn,
+) !void {
+ var client: std.http.Client = .{
+ .allocator = allocator,
+ .io = io,
+ };
+ defer client.deinit();
+
+ const uri = try std.Uri.parse(url);
+ var req = try std.http.Client.request(&client, .POST, uri, .{
+ .extra_headers = headers,
+ });
+ defer req.deinit();
+
+ req.transfer_encoding = .{ .content_length = payload.len };
+ var body = try req.sendBodyUnflushed(&.{});
+ try body.writer.writeAll(payload);
+ try body.end();
+ try req.connection.?.flush();
+
+ var response = try req.receiveHead(&.{});
+ if (response.head.status.class() != .success) {
+ var error_buffer: [4096]u8 = undefined;
+ var transfer_buffer: [4096]u8 = undefined;
+ const reader = response.reader(&transfer_buffer);
+ const error_len = reader.readSliceShort(&error_buffer) catch 0;
+ if (error_len > 0) {
+ std.log.err("[zig] provider request failed ({d}): {s}", .{
+ @intFromEnum(response.head.status),
+ error_buffer[0..error_len],
+ });
+ }
+ return error.ProviderRequestFailed;
+ }
+
+ var transfer_buffer: [4096]u8 = undefined;
+ var decompress: std.http.Decompress = undefined;
+ const decompress_buffer: []u8 = switch (response.head.content_encoding) {
+ .identity => &.{},
+ .zstd => try allocator.alloc(u8, std.compress.zstd.default_window_len),
+ .deflate, .gzip => try allocator.alloc(u8, std.compress.flate.max_window_len),
+ .compress => return error.UnsupportedCompressionMethod,
+ };
+ defer if (decompress_buffer.len > 0) allocator.free(decompress_buffer);
+
+ const reader = response.readerDecompressing(&transfer_buffer, &decompress, decompress_buffer);
+
+ var carry: std.ArrayList(u8) = .empty;
+ defer carry.deinit(allocator);
+
+ var read_buffer: [4096]u8 = undefined;
+ while (true) {
+ const n = reader.readSliceShort(&read_buffer) catch |err| switch (err) {
+ error.ReadFailed => return response.bodyErr().?,
+ };
+ if (n == 0) break;
+ try carry.appendSlice(allocator, read_buffer[0..n]);
+
+ if (try processSseBuffer(allocator, &carry, ctx, emit, handleDataLine)) return;
+ }
+
+ _ = try processSseBuffer(allocator, &carry, ctx, emit, handleDataLine);
+}
+
+fn encodeChatPayload(
+ allocator: std.mem.Allocator,
+ model: []const u8,
+ system: []const u8,
+ messages: []const agui.ChatMessage,
+ provider: []const u8,
+) ![]const u8 {
+ const OpenAIMessage = struct { role: []const u8, content: []const u8 };
+ var openai_messages: std.ArrayList(OpenAIMessage) = .empty;
+ defer openai_messages.deinit(allocator);
+
+ if (system.len > 0) {
+ try openai_messages.append(allocator, .{ .role = "system", .content = system });
+ }
+ for (messages) |message| {
+ try openai_messages.append(allocator, .{
+ .role = message.role,
+ .content = message.content,
+ });
+ }
+
+ if (std.mem.eql(u8, provider, "anthropic")) {
+ const Payload = struct {
+ model: []const u8,
+ max_tokens: u16 = 4096,
+ messages: []OpenAIMessage,
+ stream: bool = true,
+ system: ?[]const u8 = null,
+ };
+ return std.json.Stringify.valueAlloc(allocator, Payload{
+ .model = model,
+ .messages = openai_messages.items,
+ .system = if (system.len > 0) system else null,
+ }, .{});
+ }
+
+ const Payload = struct {
+ model: []const u8,
+ messages: []OpenAIMessage,
+ stream: bool = true,
+ };
+ return std.json.Stringify.valueAlloc(allocator, Payload{
+ .model = model,
+ .messages = openai_messages.items,
+ }, .{});
+}
+
+fn requireEnv(comptime name: [:0]const u8, missing: anyerror) ![:0]const u8 {
+ const value = std.c.getenv(name) orelse return missing;
+ return std.mem.span(value);
+}
+
+pub fn streamOpenAI(
+ allocator: std.mem.Allocator,
+ io: std.Io,
+ model: []const u8,
+ system: []const u8,
+ messages: []const agui.ChatMessage,
+ ctx: *anyopaque,
+ emit: EmitFn,
+) !void {
+ const api_key = try requireEnv("OPENAI_API_KEY", error.MissingOpenAIKey);
+
+ const payload = try encodeChatPayload(allocator, model, system, messages, "openai");
+ defer allocator.free(payload);
+
+ const auth_header = try std.fmt.allocPrint(allocator, "Bearer {s}", .{api_key});
+ defer allocator.free(auth_header);
+
+ const headers = [_]std.http.Header{
+ .{ .name = "authorization", .value = auth_header },
+ .{ .name = "content-type", .value = "application/json" },
+ };
+
+ try streamProviderSse(
+ allocator,
+ io,
+ "https://api.openai.com/v1/chat/completions",
+ &headers,
+ payload,
+ handleOpenAILine,
+ ctx,
+ emit,
+ );
+}
+
+pub fn streamAnthropic(
+ allocator: std.mem.Allocator,
+ io: std.Io,
+ model: []const u8,
+ system: []const u8,
+ messages: []const agui.ChatMessage,
+ ctx: *anyopaque,
+ emit: EmitFn,
+) !void {
+ const api_key = try requireEnv("ANTHROPIC_API_KEY", error.MissingAnthropicKey);
+
+ const payload = try encodeChatPayload(allocator, model, system, messages, "anthropic");
+ defer allocator.free(payload);
+
+ const headers = [_]std.http.Header{
+ .{ .name = "x-api-key", .value = api_key },
+ .{ .name = "anthropic-version", .value = "2023-06-01" },
+ .{ .name = "content-type", .value = "application/json" },
+ };
+
+ try streamProviderSse(
+ allocator,
+ io,
+ "https://api.anthropic.com/v1/messages",
+ &headers,
+ payload,
+ handleAnthropicLine,
+ ctx,
+ emit,
+ );
+}
+
+pub fn streamCompletion(
+ allocator: std.mem.Allocator,
+ io: std.Io,
+ config: agui.ProviderConfig,
+ system: []const u8,
+ messages: []const agui.ChatMessage,
+ ctx: *anyopaque,
+ emit: EmitFn,
+) !void {
+ if (std.mem.eql(u8, config.provider, "openai")) {
+ return streamOpenAI(allocator, io, config.model, system, messages, ctx, emit);
+ }
+ if (std.mem.eql(u8, config.provider, "anthropic")) {
+ return streamAnthropic(allocator, io, config.model, system, messages, ctx, emit);
+ }
+ return error.UnsupportedProvider;
+}
diff --git a/examples/ag-ui/src/App.tsx b/examples/ag-ui/src/App.tsx
new file mode 100644
index 000000000..8fca0f93a
--- /dev/null
+++ b/examples/ag-ui/src/App.tsx
@@ -0,0 +1,311 @@
+import { useEffect, useMemo, useState } from 'react'
+import { fetchServerSentEvents } from '@tanstack/ai-react'
+import type { UIMessage } from '@tanstack/ai-react'
+import {
+ Chat,
+ ChatInput,
+ ChatMessage,
+ ChatMessages,
+} from '@tanstack/ai-react-ui'
+
+type Backend = 'go' | 'rust' | 'php' | 'zig' | 'bash' | 'python'
+type Provider = 'openai' | 'anthropic'
+
+type ServerSetup = {
+ summary: string
+ installUrl: string
+ verify: string
+ run: string
+}
+
+type ServerInfo = {
+ id: Backend
+ label: string
+ port: number
+ available: boolean
+ disabledByEnv: boolean
+ setup: ServerSetup
+}
+
+type ServersManifest = {
+ generatedAt: string
+ servers: ServerInfo[]
+}
+
+const PROVIDERS: Array<{
+ id: Provider
+ label: string
+ defaultModel: string
+}> = [
+ { id: 'openai', label: 'OpenAI', defaultModel: 'gpt-4o' },
+ {
+ id: 'anthropic',
+ label: 'Anthropic',
+ defaultModel: 'claude-sonnet-4-6',
+ },
+]
+
+function SetupInstructions({ server }: { server: ServerInfo }) {
+ return (
+
+
+
+
+ Setup required
+
+
+ {server.label} server unavailable
+
+
{server.setup.summary}
+ {server.disabledByEnv ? (
+
+ This backend is disabled on this machine via{' '}
+
+ AGUI_DISABLE_SERVERS
+
+ . Remove{' '}
+
+ {server.id}
+ {' '}
+ from that variable and restart{' '}
+
+ pnpm dev:all
+
+ .
+
+ ) : null}
+
+
+
+
+
+
+
Verify
+
+ {server.setup.verify}
+
+
+
+
+
Run this backend
+
+ {server.setup.run}
+
+
+
+
+
Refresh availability
+
+ node scripts/detect-servers.mjs
+
+
+
+
+
+ )
+}
+
+export function App() {
+ const [servers, setServers] = useState([])
+ const [serversLoading, setServersLoading] = useState(true)
+ const [backend, setBackend] = useState('go')
+ const [provider, setProvider] = useState('openai')
+ const [model, setModel] = useState(PROVIDERS[0].defaultModel)
+
+ useEffect(() => {
+ let cancelled = false
+
+ async function loadServers() {
+ try {
+ const response = await fetch('/servers.json')
+ if (!response.ok) {
+ throw new Error(`Failed to load servers.json (${response.status})`)
+ }
+ const manifest = (await response.json()) as ServersManifest
+ if (cancelled) return
+ setServers(manifest.servers)
+ if (manifest.servers.length > 0) {
+ setBackend((current) =>
+ manifest.servers.some((server) => server.id === current)
+ ? current
+ : manifest.servers[0].id,
+ )
+ }
+ } catch (error) {
+ console.error('[ag-ui] failed to load servers.json', error)
+ } finally {
+ if (!cancelled) {
+ setServersLoading(false)
+ }
+ }
+ }
+
+ void loadServers()
+
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ const active =
+ servers.find((item) => item.id === backend) ??
+ servers[0] ??
+ ({
+ id: 'go',
+ label: 'Go',
+ port: 8001,
+ available: false,
+ disabledByEnv: false,
+ setup: {
+ summary: 'Install Go 1.22+ and ensure `go` is on PATH.',
+ installUrl: 'https://go.dev/dl/',
+ verify: 'go version',
+ run: 'pnpm dev:go',
+ },
+ } satisfies ServerInfo)
+
+ const activeProvider =
+ PROVIDERS.find((item) => item.id === provider) ?? PROVIDERS[0]
+
+ const connection = useMemo(
+ () =>
+ active.available
+ ? fetchServerSentEvents(`/api/${backend}`, () => ({
+ body: { provider, model },
+ }))
+ : null,
+ [active.available, backend, provider, model],
+ )
+
+ const handleProviderChange = (nextProvider: Provider) => {
+ setProvider(nextProvider)
+ const next = PROVIDERS.find((item) => item.id === nextProvider)
+ if (next) {
+ setModel(next.defaultModel)
+ }
+ }
+
+ const subtitle = active.available
+ ? `${active.label} server on :${active.port} → ${activeProvider.label}`
+ : `${active.label} not available — setup required`
+
+ return (
+
+
+
+
+ {serversLoading ? (
+
+ Loading backend availability…
+
+ ) : active.available && connection ? (
+
+
+ Chat with {active.label} over AG-UI SSE using{' '}
+ {activeProvider.label}.
+
+ }
+ >
+ {(message: UIMessage) => }
+
+
+
+
+
+ ) : (
+
+ )}
+
+
+ )
+}
diff --git a/examples/ag-ui/src/main.tsx b/examples/ag-ui/src/main.tsx
new file mode 100644
index 000000000..8661d46e9
--- /dev/null
+++ b/examples/ag-ui/src/main.tsx
@@ -0,0 +1,10 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { App } from './App'
+import './styles.css'
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+)
diff --git a/examples/ag-ui/src/styles.css b/examples/ag-ui/src/styles.css
new file mode 100644
index 000000000..0599b3a3a
--- /dev/null
+++ b/examples/ag-ui/src/styles.css
@@ -0,0 +1,11 @@
+@import 'tailwindcss';
+
+@layer base {
+ html {
+ @apply bg-slate-950;
+ }
+
+ body {
+ @apply min-h-screen antialiased;
+ }
+}
diff --git a/examples/ag-ui/tsconfig.json b/examples/ag-ui/tsconfig.json
new file mode 100644
index 000000000..ff180cf6c
--- /dev/null
+++ b/examples/ag-ui/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "types": ["vite/client"],
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
+}
diff --git a/examples/ag-ui/vite.config.ts b/examples/ag-ui/vite.config.ts
new file mode 100644
index 000000000..3b5ff6ad4
--- /dev/null
+++ b/examples/ag-ui/vite.config.ts
@@ -0,0 +1,30 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
+
+const backendProxies = [
+ { id: 'go', port: 8001 },
+ { id: 'rust', port: 8002 },
+ { id: 'php', port: 8003 },
+ { id: 'zig', port: 8004 },
+ { id: 'bash', port: 8005 },
+ { id: 'python', port: 8006 },
+] as const
+
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ server: {
+ port: 3000,
+ proxy: Object.fromEntries(
+ backendProxies.map(({ id, port }) => [
+ `/api/${id}`,
+ {
+ target: `http://127.0.0.1:${port}`,
+ changeOrigin: true,
+ rewrite: (path) =>
+ path.replace(new RegExp(`^/api/${id}/?$`), '/') || '/',
+ },
+ ]),
+ ),
+ },
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5d92af110..e570c8946 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -111,6 +111,52 @@ importers:
specifier: ^4.0.14
version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.10.3)(@vitest/coverage-v8@4.0.14)(happy-dom@20.0.11)(jsdom@27.3.0(postcss@8.5.15))(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+ examples/ag-ui:
+ dependencies:
+ '@tanstack/ai-client':
+ specifier: workspace:*
+ version: link:../../packages/ai-client
+ '@tanstack/ai-react':
+ specifier: workspace:*
+ version: link:../../packages/ai-react
+ '@tanstack/ai-react-ui':
+ specifier: workspace:*
+ version: link:../../packages/ai-react-ui
+ react:
+ specifier: ^19.2.3
+ version: 19.2.3
+ react-dom:
+ specifier: ^19.2.3
+ version: 19.2.3(react@19.2.3)
+ devDependencies:
+ '@tailwindcss/vite':
+ specifier: ^4.1.18
+ version: 4.1.18(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+ '@types/react':
+ specifier: ^19.2.7
+ version: 19.2.7
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.7)
+ '@vitejs/plugin-react':
+ specifier: ^5.1.2
+ version: 5.1.2(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))
+ concurrently:
+ specifier: ^9.1.2
+ version: 9.2.1
+ dotenv:
+ specifier: ^17.2.3
+ version: 17.2.3
+ tailwindcss:
+ specifier: ^4.1.18
+ version: 4.1.18
+ typescript:
+ specifier: 5.9.3
+ version: 5.9.3
+ vite:
+ specifier: ^7.3.3
+ version: 7.3.3(@types/node@24.10.3)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)
+
examples/sandbox-cloudflare:
dependencies:
'@cloudflare/sandbox':
@@ -16905,7 +16951,7 @@ snapshots:
'@babel/helper-optimise-call-expression': 7.27.1
'@babel/helper-replace-supers': 7.28.6(@babel/core@7.28.5)
'@babel/helper-skip-transparent-expression-wrappers': 7.27.1
- '@babel/traverse': 7.29.0
+ '@babel/traverse': 7.29.7
semver: 6.3.1
transitivePeerDependencies:
- supports-color
@@ -16918,7 +16964,7 @@ snapshots:
'@babel/helper-optimise-call-expression': 7.27.1
'@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0)
'@babel/helper-skip-transparent-expression-wrappers': 7.27.1
- '@babel/traverse': 7.29.0
+ '@babel/traverse': 7.29.7
semver: 6.3.1
transitivePeerDependencies:
- supports-color
@@ -16947,7 +16993,7 @@ snapshots:
dependencies:
'@babel/core': 7.29.0
'@babel/helper-compilation-targets': 7.29.7
- '@babel/helper-plugin-utils': 7.28.6
+ '@babel/helper-plugin-utils': 7.29.7
debug: 4.4.3
lodash.debounce: 4.0.8
resolve: 1.22.11
@@ -16995,7 +17041,7 @@ snapshots:
'@babel/core': 7.28.5
'@babel/helper-module-imports': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
- '@babel/traverse': 7.28.5
+ '@babel/traverse': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -17004,7 +17050,7 @@ snapshots:
'@babel/core': 7.29.0
'@babel/helper-module-imports': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
- '@babel/traverse': 7.28.5
+ '@babel/traverse': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -17087,8 +17133,8 @@ snapshots:
'@babel/helper-skip-transparent-expression-wrappers@7.27.1':
dependencies:
- '@babel/traverse': 7.28.5
- '@babel/types': 7.29.0
+ '@babel/traverse': 7.29.7
+ '@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -17125,8 +17171,8 @@ snapshots:
'@babel/helpers@7.28.4':
dependencies:
- '@babel/template': 7.27.2
- '@babel/types': 7.29.0
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.7
'@babel/helpers@7.29.7':
dependencies:
@@ -17135,7 +17181,7 @@ snapshots:
'@babel/parser@7.28.5':
dependencies:
- '@babel/types': 7.29.0
+ '@babel/types': 7.29.7
'@babel/parser@7.29.0':
dependencies:
@@ -17167,7 +17213,7 @@ snapshots:
'@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
- '@babel/helper-plugin-utils': 7.28.6
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-export-default-from@7.29.7(@babel/core@7.29.0)':
dependencies:
@@ -17177,12 +17223,12 @@ snapshots:
'@babel/plugin-syntax-flow@7.28.6(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.28.6
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-flow@7.28.6(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
- '@babel/helper-plugin-utils': 7.28.6
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)':
dependencies:
@@ -17202,12 +17248,12 @@ snapshots:
'@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
- '@babel/helper-plugin-utils': 7.28.6
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
- '@babel/helper-plugin-utils': 7.28.6
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-typescript@7.27.1(@babel/core@7.28.5)':
dependencies:
@@ -17294,13 +17340,13 @@ snapshots:
'@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.28.5)':
dependencies:
'@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.28.5)
'@babel/plugin-transform-flow-strip-types@7.27.1(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-syntax-flow': 7.28.6(@babel/core@7.29.0)
'@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.0)':
@@ -17422,25 +17468,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)':
- dependencies:
- '@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.27.1
-
'@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
- '@babel/helper-plugin-utils': 7.27.1
-
- '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)':
- dependencies:
- '@babel/core': 7.28.5
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)':
dependencies:
'@babel/core': 7.29.0
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/plugin-transform-react-jsx@7.29.7(@babel/core@7.29.0)':
dependencies:
@@ -17476,7 +17512,7 @@ snapshots:
'@babel/core': 7.28.5
'@babel/helper-annotate-as-pure': 7.29.7
'@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.28.5)
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/helper-skip-transparent-expression-wrappers': 7.27.1
'@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.28.5)
transitivePeerDependencies:
@@ -17487,7 +17523,7 @@ snapshots:
'@babel/core': 7.29.0
'@babel/helper-annotate-as-pure': 7.29.7
'@babel/helper-create-class-features-plugin': 7.28.5(@babel/core@7.29.0)
- '@babel/helper-plugin-utils': 7.27.1
+ '@babel/helper-plugin-utils': 7.29.7
'@babel/helper-skip-transparent-expression-wrappers': 7.27.1
'@babel/plugin-syntax-typescript': 7.27.1(@babel/core@7.29.0)
transitivePeerDependencies:
@@ -23299,24 +23335,24 @@ snapshots:
'@types/babel__core@7.20.5':
dependencies:
- '@babel/parser': 7.28.5
- '@babel/types': 7.28.5
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
'@types/babel__generator': 7.27.0
'@types/babel__template': 7.4.4
'@types/babel__traverse': 7.28.0
'@types/babel__generator@7.27.0':
dependencies:
- '@babel/types': 7.29.0
+ '@babel/types': 7.29.7
'@types/babel__template@7.4.4':
dependencies:
- '@babel/parser': 7.29.0
- '@babel/types': 7.29.0
+ '@babel/parser': 7.29.7
+ '@babel/types': 7.29.7
'@types/babel__traverse@7.28.0':
dependencies:
- '@babel/types': 7.29.0
+ '@babel/types': 7.29.7
'@types/braces@3.0.5': {}
@@ -23760,9 +23796,9 @@ snapshots:
'@vitejs/plugin-react@5.1.2(vite@7.3.3(@types/node@24.10.3)(jiti@2.6.1)(less@4.6.6)(lightningcss@1.30.2)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
- '@babel/core': 7.28.5
- '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5)
- '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5)
+ '@babel/core': 7.29.0
+ '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0)
+ '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0)
'@rolldown/pluginutils': 1.0.0-beta.53
'@types/babel__core': 7.20.5
react-refresh: 0.18.0
@@ -29134,7 +29170,7 @@ snapshots:
parse-json@5.2.0:
dependencies:
- '@babel/code-frame': 7.27.1
+ '@babel/code-frame': 7.29.7
error-ex: 1.3.4
json-parse-even-better-errors: 2.3.1
lines-and-columns: 1.2.4