Build and Deploy a Free Customer Support AI Agent with Hexabot, OpenRouter, and Railway on a Live Website

A complete, hands-on walkthrough for building a free customer support AI agent with open-source Hexabot and a free OpenRouter model, deploying it on Railway with Postgres, grounding it in your own content with RAG, and embedding the live-chat widget on your live website.

Jun 30, 2026Updated Jun 30, 202612 min readFollow

Topics You Will Master

Standing up a free, open-source Hexabot customer support agent on Railway with Postgres
Powering it with a free OpenRouter model wired in as a credential
Turning a simple chatbot into a grounded RAG agent: retrieve → ground → answer → send
Loading a CMS knowledge base and fixing the silent indexing trap

Note

This is an end-to-end build of a customer support AI agent that answers from your own content and lives in a chat bubble on your website. The whole stack is free to start: Hexabot is open-source, OpenRouter offers free models, and Railway ships free trial credit. Every environment variable, gotcha, and fix below comes from a real production deployment.


What We Are Building

We are building a customer support AI agent: a bot that greets visitors on your website, reads their question, looks up the answer in your own documentation, and replies in plain language. The architecture has four moving parts, and every one of them has a free tier.

Layer Technology Cost Job
Backend Hexabot (@hexabot-ai/api) on Railway Open-source Workflow engine, CMS, and chat API
Database Railway Postgres Free trial credit Stores content, users, workflows, threads
Model OpenRouter (free model available) Free tier Generates the grounded answer
Frontend Your live website (Next.js) Your host Hosts the live-chat widget

The agent's logic inside Hexabot is a three-step workflow:

TEXT
user message  →  retrieve_rag_content  →  ai_generate_reply (grounded)  →  send_text_message

Note

What "free" means here. Hexabot is open-source software you self-host, OpenRouter exposes genuinely free models that work well for support FAQs, and Railway gives you trial credit to start. The only ongoing cost is keeping the Railway service funded after your trial credit runs out. For a small support agent that runs to a few dollars a month.


Prerequisites

  • A Hexabot project pushed to a GitHub repo (the official starter works as-is)
  • A Railway account
  • An OpenRouter API key
  • A Next.js website (this guide uses the App Router)

Part 1: Deploy Hexabot on Railway

  1. In Railway, create a new project and Add → Database → PostgreSQL.
  2. Add → GitHub Repo and select your Hexabot repo. Railway builds the Dockerfile automatically.
  3. On the app service, open Settings → Networking → Generate Domain to get a public URL.
  4. Open Variables and paste the full list from the reference section below.

The starter ships with a Dockerfile whose production stage runs node dist/main. Railway injects PORT automatically, so never hard-code it.

The five mistakes that crash a fresh deploy

Each of these produced a real crash loop on the first deploy. Set the variables right and you skip all of them.

Caution

1. The app boots into SQLite and dies. The starter defaults to DB_TYPE=sqlite. On Railway's ephemeral filesystem this throws SqliteError: unable to open database file. Fix: set DB_TYPE=postgres and DB_URL=${{Postgres.DATABASE_URL}} (a Railway reference variable that auto-links the Postgres service).

Caution

2. relation "public.translations" does not exist. A fresh Postgres has no tables. Hexabot builds the schema on boot via TypeORM synchronize, so you must set DB_SYNCHRONIZE=true for the first boot. With it off, the app crashes reading the translations table.

Caution

3. No admin account, cannot log in. Hexabot only seeds the SEED_ADMIN_* user in non-production environments. If you set NODE_ENV=production, the database builds fine but no admin is ever created. Keep NODE_ENV=development.

Caution

4. EACCES: permission denied, mkdir '/app/app/uploads'. UPLOAD_DIR is joined onto the /app working directory. Setting it to /app/uploads doubles to /app/app/uploads, which the non-root container user cannot create. Use UPLOAD_DIR=/uploads. It resolves to /app/uploads, which the image already creates and owns.

Caution

5. You are out of trial credit. Railway's free trial is small. If the service sleeps, the chatbot and its database go offline. Fund the project before going public.

When the deploy is green, visit your domain and log in with the SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD you set, then change the password.


Part 2: Add the OpenRouter Model Credential

In the Hexabot admin, go to Credentials and create one named OPENROUTER_API_KEY with your OpenRouter key as the value. Credentials are stored encrypted and are never exposed back through the API. The workflow references them by id.

Tip

Hexabot's model node speaks the OpenAI-compatible protocol, so any OpenRouter model works. Free models (e.g. nvidia/nemotron-3-ultra-550b-a55b:free) are great for testing but can return 503/non-JSON errors under load. For a public-facing bot, prefer a cheap paid model for reliability.


Part 3: Build the Base Support Agent Workflow

Create a conversational workflow. A minimal echo-of-the-LLM bot is three definitions and a flow:

YAML
defs:
  openrouter:
    kind: model
    settings:
      provider: openai-compatible
      model_id: "nvidia/nemotron-3-ultra-550b-a55b:free"
      base_url: https://openrouter.ai/api/v1
      api_key: <OPENROUTER_API_KEY credential id>   # selected on the model node
  reply:
    kind: task
    action: ai_generate_reply
    inputs:
      input_mode: prompt
      prompt: "=$input.text"
      system: "You are a friendly customer support agent. Answer clearly and concisely."
    bindings:
      model: openrouter
  send:
    kind: task
    action: send_text_message
    inputs:
      text: "=$output.reply.text"
flow:
  - do: reply
  - do: send

Publish it, send a test message in the console, and confirm you get a reply. Now we make it grounded.


Part 4: Upgrade to a RAG Agent

Step 1: Create a CMS content type

Create a content type (e.g. GenAI Articles) with three fields beyond the built-in title: body, url, and the status flag. Hexabot automatically builds a combined searchText field per record (title + url + body). Lexical retrieval searches that field.

Step 2: Ingest your articles

Add one CMS record per article (title, body, url, status: true). This is where a subtle trap lives:

Warning

Ingest content one record at a time, not in a fast parallel batch. On a small server, a rapid burst of inserts can skip the search-index hook, leaving retrieve_rag_content returning {"hits": []} even though the data is clearly stored. The fix is simple: create (or re-save) each record sequentially. A single update on an existing record re-fires the index hook.

You can verify the knowledge base is live by running a retrieval query in the admin and confirming it returns hits with "source": "lexical".

Step 3: Wire the RAG flow

Add a retrieve_rag_content step before the reply, and inject its output into the prompt so the model answers only from retrieved context:

YAML
defs:
  openrouter:
    kind: model
    settings:
      provider: openai-compatible
      model_id: "nvidia/nemotron-3-ultra-550b-a55b:free"
      base_url: https://openrouter.ai/api/v1
      api_key: <OPENROUTER_API_KEY credential id>
  retrieve:
    kind: task
    action: retrieve_rag_content
    inputs:
      query: "=$input.text"
    settings:
      mode: lexical
      limit: 4
      content_type_id: <GenAI Articles content type id>
      include_inactive: false
  reply:
    kind: task
    action: ai_generate_reply
    inputs:
      input_mode: prompt
      prompt: "=('Use ONLY the context below to answer. If it is empty or irrelevant, say you are not certain and point the user to the website.\n\nCONTEXT:\n' & $output.retrieve.text & '\n\nQUESTION: ' & $input.text)"
      system: "You are a customer support agent. Answer clearly and concisely, grounded only in the provided context."
    bindings:
      model: openrouter
  send:
    kind: task
    action: send_text_message
    inputs:
      text: "=$output.reply.text"
flow:
  - do: retrieve
  - do: reply
  - do: send

Note

lexical mode needs no embeddings provider and works out of the box. For semantic relevance, configure an embeddings model and switch the retrieve node to mode: embedding, then re-index your content.

Publish the workflow. Your customer support agent now answers from your own content.


Part 5: Embed the Widget on Your Next.js Site

Step 1: Create a Web source

In the admin, Integrations → Channels → Add → web. Set a name, add your website origins to Allowed domains (exact origins, with scheme), pick the default workflow, and copy the Source Ref.

TEXT
https://yourwebsite.com,https://www.yourwebsite.com,http://localhost:3000

Important

The widget connects over Socket.IO, so CORS is gated by two places: the Web source's Allowed domains and the server's FRONTEND_ORIGIN variable. Add every origin (including your local dev port) to both, or you will see Failed to establish WS Connection! in the browser console.

Step 2: Add the client component

The widget ships as a UMD bundle built against React 18, while a modern Next.js app runs React 19. Importing it into the bundle risks dual-React hook errors. The safer approach is to load it at runtime on its own isolated React 18, and mount it into its own div.

Create a small client component, src/components/HexabotChat.tsx.

Inside a useEffect, it does four things:

  1. Adds tiny window.process and window.require shims so the bundler-oriented UMD runs from a plain <script> tag.
  2. Loads React 18, ReactDOM 18, and the widget UMD + CSS from the CDN.
  3. Mounts the widget into a div with ReactDOM.render(...) once those scripts are ready.
  4. Wraps that div in a max z-index stacking context so the launcher is always clickable.

The only values you change are your API URL and Source Ref, passed as widget props:

TSX
ReactDOM.render(
  React.createElement(HexabotWidget, {
    apiUrl: 'https://your-app.up.railway.app/api',
    channel: 'web',
    sourceId: 'YOUR_SOURCE_REF',
    language: 'en',
    transport: 'ws',
  }),
  document.getElementById('hexabot-chat-widget'),
);

Then render the component once in src/app/layout.tsx so it appears on every page:

TSX
import HexabotChat from '@/components/HexabotChat';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>
        {children}
        <HexabotChat />
      </body>
    </html>
  );
}

Tip

If the bubble appears but does nothing when clicked, a site element is stacked above it. The position: relative; z-index: 2147483647 wrapper above fixes this without changing the bubble's fixed position (only transform/filter would alter a fixed element's containing block).


Troubleshooting Cheat Sheet

Symptom Cause Fix
SQLITE_CANTOPEN on boot DB_TYPE defaulted to sqlite DB_TYPE=postgres, DB_URL=${{Postgres.DATABASE_URL}}
relation "public.translations" does not exist Fresh DB, no schema DB_SYNCHRONIZE=true on first boot
Deploy is green but you cannot log in Admin only seeds when not production NODE_ENV=development
EACCES ... mkdir '/app/app/uploads' UPLOAD_DIR doubled onto /app UPLOAD_DIR=/uploads
retrieve_rag_content returns [] Index hook skipped on parallel insert Ingest/re-save records sequentially
Widget: process is not defined UMD expects a bundler Add a process.env shim
Widget: require is not defined UMD calls require("react") Add a require shim
Failed to establish WS Connection! Origin not whitelisted Add origin to FRONTEND_ORIGIN and the Web source
Bubble visible but not clickable A site element is on top High z-index stacking context on the mount

Complete Environment Variable Reference

Paste this into your Railway app service (use the Raw Editor). Railway injects PORT automatically, so do not add it.

Caution

The five *_SECRET values and SEED_ADMIN_PASSWORD below are placeholders. Generate your own and never commit or publish real secrets. Anyone who has them can forge auth tokens and log into your admin. Generate a strong secret with:

BASH
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
BASH
# --- Runtime ---
NODE_ENV="development"            # MUST be development, or the admin is never seeded
HTTPS_ENABLED="true"              # Railway terminates TLS in front of the app
API_IS_PRIMARY_NODE="true"

# --- Public networking (use YOUR Railway domain + your site) ---
APP_DOMAIN="your-app.up.railway.app"
API_ORIGIN="https://your-app.up.railway.app/api"
FRONTEND_BASE_URL="https://your-app.up.railway.app"
FRONTEND_ORIGIN="https://your-app.up.railway.app,https://yourwebsite.com,https://www.yourwebsite.com"

# --- Database ---
DB_TYPE="postgres"
DB_URL="${{Postgres.DATABASE_URL}}"   # Railway reference variable
DB_AUTO_MIGRATE="true"
DB_SYNCHRONIZE="true"             # required so a fresh Postgres builds its schema
DB_SCHEMA="public"
SESSION_LIMIT_SUBQUERY="true"

# --- Secrets (GENERATE YOUR OWN, do not reuse these placeholders) ---
SESSION_SECRET="<64-hex-chars>"
JWT_SECRET="<64-hex-chars>"
JWT_EXPIRES_IN="24h"
PASSWORD_RESET_SECRET="<64-hex-chars>"
CONFIRM_ACCOUNT_SECRET="<64-hex-chars>"
SIGNED_URL_SECRET="<64-hex-chars>"

# --- Seed admin (created on first boot) ---
SEED_ADMIN_EMAIL="you@example.com"
SEED_ADMIN_FIRST_NAME="Your"
SEED_ADMIN_LAST_NAME="Name"
SEED_ADMIN_PASSWORD="<a-strong-password>"

# --- MCP + uploads ---
MCP_ENABLED="true"
UPLOAD_DIR="/uploads"             # NOT /app/uploads (it doubles to /app/app/uploads)
STORAGE_MODE="disk"

Note

DB_URL="${{Postgres.DATABASE_URL}}" is Railway's reference syntax. Type it literally and Railway resolves it to the Postgres connection string. To persist uploaded files across redeploys, attach a Railway volume mounted at /app/uploads.


Wrapping Up

You now have a free, self-hosted customer support AI agent: deployed on Railway with Postgres, powered by a free OpenRouter model, answering from your own CMS content, and live in a chat bubble on your website. The tricky parts were never the happy path. They were the silent defaults (SQLite, production seeding, the doubled uploads path) and the RAG index getting skipped on parallel inserts. With the variable reference and cheat sheet above, you can rebuild the whole stack without hitting any of them.

All secrets shown in this guide are placeholders. Always generate fresh, unique secrets for your own deployment.

Found this useful? Keep building with me.

New tutorials every week on YouTube: or go deeper with a full structured course.

Find this tutorial useful?

Subscribe to our YouTube channels for more practical production walk-throughs.

Discussion & Comments