Skip to content
Blog

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

FilePurpose
assistant.tsDefines the agent — model, tools, skills, and system prompt
app.tsHTTP server — mounts the agent router with createAgentRouter()
chat.tsxReact component — uses useFlueAgent() hook to connect to the backend
weather.tsTool — lets the agent fetch real-time weather data
it-policy.tsSkill — 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 agent
  • agent.sendMessage(text) — Send a user message
  • agent.messages — Array of all messages in the conversation
  • agent.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.