Skip to content
Blog

Build an Interactive CLI Chat Loop with Flue and Node.js

Step-by-step tutorial to build an interactive terminal chat application using the Flue runtime with tool calling, skills, and SQLite persistence.

Published on September 18, 2026

AI Assistant

This tutorial walks you through building an interactive chat loop in the terminal using the Flue runtime. You’ll have a fully functional CLI chat app with tool calling, skills, and conversation persistence by the end.

Architecture Overview

The app connects a terminal readline loop to a Flue agent:

graph TD
    A["Terminal (chat.ts)"] --> B[readline input loop]
    B --> C["init(Assistant)"]
    C --> D[agent instance]
    D --> E["agent.dispatch(message)"]
    E --> F["agent.read(receipt)"]
    F --> G["src/agents/assistant.ts"]
    G --> H["useModel('gemini-2.5-flash')"]
    G --> I["useTool(weather)"]
    G --> J["useSkill(itPolicy)"]

    style A fill:#f9f,stroke:#333,stroke-width:2px
    style D fill:#bbf,stroke:#333,stroke-width:2px
    style G fill:#bfb,stroke:#333,stroke-width:2px

The data flow between user, CLI, and agent:

sequenceDiagram
    participant U as User (Terminal)
    participant C as CLI (chat.ts)
    participant A as Agent

    U->>C: "You: " + message
    C->>A: agent.dispatch(message)
    A-->>C: receipt
    C->>A: agent.read(receipt)
    A-->>C: SSE stream + tool calls
    C-->>U: "Assistant: " + reply.text

Step 1 — Project Setup

Install Dependencies

npm create flue-app my-cli-chat
cd my-cli-chat
npm install

Required dependencies in package.json:

{
  "dependencies": {
    "@flue/runtime": "^2.0.5",
    "@flue/sdk": "^2.0.8",
    "hono": "^4.13.8"
  },
  "devDependencies": {
    "@flue/cli": "^2.0.5",
    "@flue/vite": "^2.0.6",
    "cross-env": "^10.1.0",
    "tsx": "^4.23.13",
    "typescript": "^7.0.2",
    "vite": "^8.3.0"
  }
}

Add Scripts to package.json

{
  "scripts": {
    "chat": "cross-env NODE_OPTIONS=--no-warnings tsx src/chat.ts",
    "dev": "vite dev",
    "build": "vite build",
    "check:types": "tsc --noEmit"
  }
}

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"]
}

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`;
}

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
  `.trim(),
});

Step 3 — Build the Chat Loop

Create src/chat.ts:

import { init } from '@flue/runtime';
import { sqlite, start } from '@flue/runtime/node';
import * as readline from 'node:readline/promises';
import { stdin as input, stdout as output } from 'node:process';
import { Assistant } from './agents/assistant.ts';

const conversationId = process.argv[2] ?? `chat-${Date.now()}`;

const flue = await start({
  agents: [Assistant],
  db: sqlite('./data/flue.db'),
});

const agent = init(Assistant, { id: conversationId });
const rl = readline.createInterface({ input, output });

console.log(`Chat started (conversation: ${conversationId})`);
console.log('Type "quit" or press Ctrl+C to exit.\n');

try {
  while (true) {
    const message = await rl.question('You: ');
    const trimmed = message.trim();

    if (!trimmed) continue;
    if (trimmed === 'quit' || trimmed === 'exit') break;

    const receipt = await agent.dispatch(trimmed);
    const reply = await agent.read(receipt);
    console.log(`\nAssistant: ${reply.text}\n`);
  }
} catch (err: any) {
  if (err.code !== 'SIGINT' && err.code !== 'ERR_USE_AFTER_CLOSE') throw err;
} finally {
  rl.close();
  await flue.stop();
  console.log('\nGoodbye!');
}

Code Breakdown

1. Imports and Runtime Setup

import { init } from '@flue/runtime';
import { sqlite, start } from '@flue/runtime/node';
  • init() — Creates an agent instance from the agent function
  • start() — Starts the Flue runtime (registers agents, opens DB connection)
  • sqlite() — Persistence adapter that stores conversations in SQLite

2. Conversation ID

const conversationId = process.argv[2] ?? `chat-${Date.now()}`;
  • Accepts a conversation ID from the command line, or generates a new one
  • Enables resuming old conversations: npm run chat -- my-conv-id

3. Start the Runtime

const flue = await start({
  agents: [Assistant],
  db: sqlite('./data/flue.db'),
});
  • agents — Registers the agent functions to use
  • db — Persistence adapter (stores data in ./data/flue.db)

4. Create the Agent Instance

const agent = init(Assistant, { id: conversationId });
  • init(Assistant, { id }) — Creates an instance of the Assistant agent
  • id — Conversation identifier for persisting state

5. Readline Interface

const rl = readline.createInterface({ input, output });
  • Creates an interface for reading user input from the terminal

6. The Chat Loop

while (true) {
  const message = await rl.question('You: ');
  const trimmed = message.trim();

  if (!trimmed) continue;                        // Skip empty input
  if (trimmed === 'quit' || trimmed === 'exit') break; // Exit loop

  const receipt = await agent.dispatch(trimmed);  // Send message to agent
  const reply = await agent.read(receipt);        // Read response
  console.log(`\nAssistant: ${reply.text}\n`);    // Display result
}

Internal flow of each iteration:

flowchart TD
    A["rl.question('You: ')"] --> B{Empty input?}
    B -->|Yes| A
    B -->|No| C{quit/exit?}
    C -->|Yes| D[Break loop]
    C -->|No| E["agent.dispatch(trimmed)"]
    E --> F["agent.read(receipt)"]
    F --> G["reply.text → print"]
    G --> A

    style E fill:#bbf,stroke:#333,stroke-width:2px
    style G fill:#bfb,stroke:#333,stroke-width:2px

7. Error Handling

catch (err: any) {
  if (err.code !== 'SIGINT' && err.code !== 'ERR_USE_AFTER_CLOSE') throw err;
}
  • SIGINT — User pressed Ctrl+C
  • ERR_USE_AFTER_CLOSE — Readline was already closed

8. Cleanup

finally {
  rl.close();         // Close readline interface
  await flue.stop();  // Stop runtime + DB connection
  console.log('\nGoodbye!');
}

Run the Chat Loop

npm run chat

Example Session

$ npm run chat

Chat started (conversation: chat-1726684800000)
Type "quit" or press Ctrl+C to exit.

You: What's the weather in Bangkok?

Assistant: Bangkok is currently 32°C with partly cloudy skies. Humidity is at 78% with winds at 8 km/h from the south.

You: What's our company's VPN policy?

Assistant: According to company IT policy, VPN (WireGuard) is required when accessing internal resources. Always use VPN on public or home networks.

You: quit

Goodbye!

Resume a Previous Conversation

npm run chat -- chat-1726684800000

Project Structure

graph TD
    A["my-cli-chat/"] --> B[package.json]
    A --> C[tsconfig.json]
    A --> D["src/"]
    D --> E["chat.ts — CLI chat loop entry"]
    D --> F["agents/"]
    F --> G["assistant.ts — Agent definition"]
    D --> H["skills/"]
    H --> I["it-policy.ts — Knowledge base"]
    D --> J["tools/"]
    J --> K["weather.ts — Weather tool"]

    style A fill:#f9f,stroke:#333,stroke-width:2px
    style E fill:#bbf,stroke:#333,stroke-width:2px
    style G fill:#bfb,stroke:#333,stroke-width:2px
    style K fill:#fbf,stroke:#333,stroke-width:2px

Full Execution Flow

When a user sends a message, here’s what happens inside the Flue runtime:

flowchart TD
    A[User input] --> B["readline.question()"]
    B --> C["agent.dispatch(text)"]
    C --> D["Flue Runtime"]
    D --> E["System prompt + User message"]
    E --> F[LLM inference]
    F --> G{Tool call needed?}
    G -->|Yes| H[Execute tool]
    H --> I[Return result to LLM]
    I --> F
    G -->|No| J[Final response]
    J --> K["agent.read(receipt)"]
    K --> L["console.log(reply.text)"]

    style D fill:#e6f3ff,stroke:#333,stroke-width:2px
    style F fill:#bbf,stroke:#333,stroke-width:2px
    style H fill:#bfb,stroke:#333,stroke-width:2px
    style J fill:#fbf,stroke:#333,stroke-width:2px

Key APIs Reference

APIPurpose
start({ agents, db })Start the Flue runtime
init(Agent, { id })Create an agent instance
agent.dispatch(message)Send a message to the agent, returns a receipt
agent.read(receipt)Read the response from a receipt
reply.textThe text content of the agent’s reply
flue.stop()Shut down the runtime and clean up
defineTool({ ... })Define a tool for the agent
defineSkill({ ... })Define a skill (knowledge base)
useModel('provider/model')Set the LLM model
useTool(tool)Register a tool with the agent
useSkill(skill)Register a skill with the agent

Next Steps

From here you can extend the CLI app by:

  • Adding more tools (database queries, API calls, file operations)
  • Creating additional skills for different knowledge domains
  • Implementing streaming output for token-by-token responses
  • Adding command-line flags for model selection or debug mode
  • Exporting conversation history to JSON or Markdown

The Flue runtime handles agent execution, tool orchestration, and conversation persistence. Your job is to define what the agent can do and wire up the terminal input/output.