Build a Realtime Chat Web App with Flue and React
Step-by-step tutorial to build a realtime AI chat web application using the Flue runtime, React frontend, and Hono server with streaming responses.
Published on • September 18, 2026
AI Assistant

This tutorial walks you through building a realtime chat web UI using the Flue runtime and React. By the end, you’ll have a fully functional AI chat app with streaming responses, tool calling, and a clean modern interface.
Architecture Overview
The app follows a clean separation between frontend, server, and agent:
graph TD
A[index.html] --> B[src/main.tsx]
B --> C[Chat component]
C --> D["@flue/react useFlueAgent()"]
D -->|HTTP SSE| E["/agents/assistant/:id"]
E --> F["createAgentRouter(Assistant)"]
F --> G["src/agents/assistant.ts"]
style A fill:#f9f,stroke:#333,stroke-width:2px
style C fill:#bbf,stroke:#333,stroke-width:2px
style F fill:#bfb,stroke:#333,stroke-width:2px
style G fill:#fbf,stroke:#333,stroke-width:2px
The data flow between browser, server, and agent:
sequenceDiagram
participant B as Browser (React)
participant S as Server (Hono)
participant A as Agent
B->>S: GET /agents/assistant/:id
S->>A: createAgentRouter
B->>S: POST message
S->>A: dispatch(message)
A-->>S: SSE stream
S-->>B: text chunks
A-->>S: tool call
S-->>B: tool result
A-->>S: final text
S-->>B: done
Step 1 — Project Setup
Install Dependencies
npm create flue-app my-chat-app
cd my-chat-app
npm install
Your package.json should include these core dependencies:
{
"dependencies": {
"@flue/runtime": "^2.0.5",
"@flue/react": "^2.0.8",
"@flue/sdk": "^2.0.8",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"hono": "^4.13.8"
},
"devDependencies": {
"@flue/vite": "^2.0.6",
"tsx": "^4.23.13",
"typescript": "^7.0.2",
"vite": "^8.3.0",
"cross-env": "^10.1.0"
}
}
Configure TypeScript
Create tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["node"],
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"strict": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}
Configure Vite
Create vite.config.ts:
import { flue } from '@flue/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [flue()],
});
Step 2 — Create the Agent
Create src/agents/assistant.ts:
'use agent';
import { useModel, useSandbox, useSkill, useTool } from '@flue/runtime';
import { local } from '@flue/runtime/node';
import { weather } from '../tools/weather.ts';
import { itPolicy } from '../skills/it-policy.ts';
export function Assistant() {
useModel('google/gemini-2.5-flash');
useSandbox(local());
useSkill(itPolicy);
useTool(weather);
return `You are a helpful AI assistant with access to real-time weather data and company IT policies.
## Capabilities
- Look up current weather for any location using the weather tool
- Answer questions about company IT policies, security guidelines, and best practices
## Response Style
- Be concise and direct — aim for 2-4 sentences unless detail is requested
- Use plain text formatting
- Cite sources when referencing IT policy information
- If a request falls outside your capabilities, say so clearly`;
}
Create the Weather Tool
Create src/tools/weather.ts:
import { defineTool } from '@flue/runtime/tool';
import * as v from 'valibot';
export const weather = defineTool({
name: 'get_weather',
description: 'Get the current weather and forecast for a location using wttr.in.',
input: v.object({
location: v.string(),
}),
async run({ data }) {
const res = await fetch(
`http://wttr.in/${encodeURIComponent(data.location)}?format=j1`,
);
if (!res.ok) {
return `Failed to fetch weather for "${data.location}". Status: ${res.status}`;
}
const json: any = await res.json();
const current = json.current_condition?.[0];
if (!current) return `No weather data for "${data.location}".`;
return [
`Location: ${json.nearest_area?.[0]?.areaName?.[0]?.value ?? data.location}`,
`Temperature: ${current.temp_C}°C`,
`Condition: ${current.weatherDesc?.[0]?.value ?? 'Unknown'}`,
`Humidity: ${current.humidity}%`,
`Wind: ${current.windspeedKmph} km/h`,
].join('\n');
},
});
Create the IT Policy Skill
Create src/skills/it-policy.ts:
import { defineSkill } from '@flue/runtime';
export const itPolicy = defineSkill({
name: 'it-policy',
description: 'IT and security policy knowledge base.',
instructions: `
## IT Policy
### Password & Authentication
- Minimum 12 characters with uppercase, lowercase, number, and symbol
- Enable MFA on all accounts that support it
### Device Policy
- Company-issued laptops only
- Full-disk encryption required
- Always use VPN on public networks
### Incident Response
1. Report immediately to security@company.com
2. Do not investigate yourself
3. Preserve evidence
`.trim(),
});
Step 3 — Set Up the Server (Hono)
Create src/app.ts:
import { createAgentRouter } from '@flue/runtime/routing';
import { Hono } from 'hono';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { Assistant } from './agents/assistant.ts';
const app = new Hono();
// Health check endpoint
app.get('/health', (c) => c.json({ status: 'ok' }));
// Mount agent router — handles POST /agents/assistant/:id
app.route('/agents/assistant', createAgentRouter(Assistant));
// Serve React SPA for non-API routes
app.get('*', (c) => {
const html = readFileSync(resolve('index.html'), 'utf-8');
return c.html(html);
});
export default app;
Configure Persistence
Create src/db.ts for SQLite-backed session storage:
import { sqlite } from '@flue/runtime/node';
export default sqlite('./data/flue.db');
Add Scripts to package.json
{
"scripts": {
"dev": "vite dev",
"build": "vite build",
"chat": "cross-env NODE_OPTIONS=--no-warnings tsx src/chat.ts"
}
}
Step 4 — Build the React Frontend
HTML Shell
Create index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flue Chat</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
React Entry Point
Create src/main.tsx:
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { Chat } from './components/chat.tsx';
import './index.css';
const conversationId = `web-${Date.now()}`;
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Chat conversationId={conversationId} />
</StrictMode>,
);
Chat Component
Create src/components/chat.tsx:
import { useFlueAgent } from '@flue/react';
import { useRef, useState } from 'react';
export function Chat({ conversationId }: { conversationId: string }) {
const [input, setInput] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const agent = useFlueAgent({
url: `/agents/assistant/${conversationId}`,
});
function scrollToBottom() {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}
async function submit(event: React.FormEvent) {
event.preventDefault();
const message = input.trim();
if (!message) return;
setInput('');
await agent.sendMessage(message);
scrollToBottom();
}
return (
<div className="chat">
<div className="messages">
{agent.messages.map((message) => (
<div
key={message.id}
className={`message message--${message.role}`}
>
<span className="message__role">
{message.role === 'user' ? 'You' : 'Assistant'}
</span>
<div className="message__bubble">
{message.parts.map((part, i) =>
part.type === 'text' ? (
<span key={`${message.id}-${i}`}>{part.text}</span>
) : null,
)}
</div>
</div>
))}
{agent.status === 'streaming' && (
<div className="message message--assistant">
<span className="message__role">Assistant</span>
<div className="message__bubble">
<div className="thinking">
<span className="thinking__dot" />
<span className="thinking__dot" />
<span className="thinking__dot" />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{agent.status === 'error' && (
<div className="status">Connection error — retrying...</div>
)}
<div className="input-bar">
<form className="input-bar__form" onSubmit={submit}>
<input
className="input-bar__input"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Send a message..."
disabled={agent.status === 'streaming'}
/>
<button
className="input-bar__send"
type="submit"
disabled={!input.trim() || agent.status === 'streaming'}
>
Send
</button>
</form>
</div>
</div>
);
}
Step 5 — Add Styles
Create src/index.css:
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--bg: #ffffff;
--bg-secondary: #f7f7f8;
--text: #171717;
--text-secondary: #6b6b6b;
--border: #e5e5e5;
--user-bubble: #171717;
--user-text: #ffffff;
--assistant-bubble: #f7f7f8;
--assistant-text: #171717;
--input-bg: #ffffff;
--input-border: #d4d4d4;
--accent: #171717;
--radius: 18px;
--max-width: 768px;
}
html,
body,
#root {
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
}
.chat {
display: flex;
flex-direction: column;
height: 100%;
max-width: var(--max-width);
margin: 0 auto;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 24px 16px;
display: flex;
flex-direction: column;
gap: 24px;
}
.message {
display: flex;
flex-direction: column;
max-width: 85%;
}
.message--user {
align-self: flex-end;
}
.message--assistant {
align-self: flex-start;
}
.message__role {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
margin-bottom: 6px;
padding: 0 12px;
}
.message__bubble {
padding: 12px 16px;
line-height: 1.6;
font-size: 15px;
white-space: pre-wrap;
word-break: break-word;
}
.message--user .message__bubble {
background: var(--user-bubble);
color: var(--user-text);
border-radius: var(--radius) var(--radius) 4px var(--radius);
}
.message--assistant .message__bubble {
background: var(--assistant-bubble);
color: var(--assistant-text);
border-radius: var(--radius) var(--radius) var(--radius) 4px;
}
.input-bar {
padding: 16px;
border-top: 1px solid var(--border);
background: var(--bg);
}
.input-bar__form {
display: flex;
gap: 8px;
max-width: var(--max-width);
margin: 0 auto;
}
.input-bar__input {
flex: 1;
padding: 12px 16px;
font-size: 15px;
font-family: inherit;
border: 1px solid var(--input-border);
border-radius: var(--radius);
background: var(--input-bg);
color: var(--text);
outline: none;
transition: border-color 0.15s;
}
.input-bar__input:focus {
border-color: var(--accent);
}
.input-bar__send {
padding: 12px 20px;
font-size: 15px;
font-family: inherit;
font-weight: 500;
border: none;
border-radius: var(--radius);
background: var(--accent);
color: var(--user-text);
cursor: pointer;
transition: opacity 0.15s;
}
.input-bar__send:hover:not(:disabled) {
opacity: 0.85;
}
.input-bar__send:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.status {
text-align: center;
font-size: 13px;
color: var(--text-secondary);
padding: 8px 0;
}
.thinking {
display: flex;
gap: 4px;
justify-content: center;
padding: 4px 0;
}
.thinking__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-secondary);
animation: blink 1.2s ease-in-out infinite;
}
.thinking__dot:nth-child(2) {
animation-delay: 0.2s;
}
.thinking__dot:nth-child(3) {
animation-delay: 0.4s;
}
@keyframes blink {
0%, 80%, 100% { opacity: 0.3; }
40% { opacity: 1; }
}
Step 6 — Run the App
npm run dev
Open http://localhost:5173 in your browser.
Project Structure
graph TD
A["my-chat-app/"] --> B[index.html]
A --> C[package.json]
A --> D[tsconfig.json]
A --> E[vite.config.ts]
A --> F["src/"]
F --> G[app.ts — Hono server]
F --> H[db.ts — SQLite adapter]
F --> I[main.tsx — React entry]
F --> J[index.css — Styles]
F --> K["agents/"]
K --> L[assistant.ts — Agent definition]
F --> M["components/"]
M --> N[chat.tsx — Chat UI]
F --> O["skills/"]
O --> P[it-policy.ts — Knowledge base]
F --> Q["tools/"]
Q --> R[weather.ts — Weather tool]
style A fill:#f9f,stroke:#333,stroke-width:2px
style L fill:#bbf,stroke:#333,stroke-width:2px
style G fill:#bfb,stroke:#333,stroke-width:2px
style N fill:#fbf,stroke:#333,stroke-width:2px
Key Concepts Summary
| File | Purpose |
|---|---|
assistant.ts | Defines the agent — model, tools, skills, and system prompt |
app.ts | HTTP server — mounts the agent router with createAgentRouter() |
chat.tsx | React component — uses useFlueAgent() hook to connect to the backend |
weather.ts | Tool — lets the agent fetch real-time weather data |
it-policy.ts | Skill — provides IT policy knowledge to the agent |
How the Web UI Flow Works
flowchart LR
A[User opens page] --> B["main.tsx renders Chat"]
B --> C["useFlueAgent() connects via SSE"]
C --> D[User types message]
D --> E["agent.sendMessage()"]
E --> F[Server dispatches to agent]
F --> G{Agent needs tool?}
G -->|Yes| H[Call tool / skill]
H --> I[Process result]
I --> J[Generate response]
G -->|No| J
J --> K[SSE stream back]
K --> L[UI updates in realtime]
Key APIs
useFlueAgent({ url })— React hook to connect to an agentagent.sendMessage(text)— Send a user messageagent.messages— Array of all messages in the conversationagent.status—'idle'|'streaming'|'error'createAgentRouter(Agent)— Creates a Hono router for the agent
Next Steps
From here you can extend the app by:
- Adding more tools (database queries, API calls, file operations)
- Creating additional skills for different knowledge domains
- Implementing user authentication
- Adding message history persistence with the SQLite adapter
- Deploying to a hosting platform like Vercel, Cloudflare Workers, or a VPS
The Flue runtime handles the agent execution, streaming, and tool orchestration. Your job is to define what the agent can do and build the UI your users interact with.