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:
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
- In Railway, create a new project and Add → Database → PostgreSQL.
- Add → GitHub Repo and select your Hexabot repo. Railway builds the Dockerfile automatically.
- On the app service, open Settings → Networking → Generate Domain to get a public URL.
- 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:
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:
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.
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:
- Adds tiny
window.processandwindow.requireshims so the bundler-oriented UMD runs from a plain<script>tag. - Loads React 18, ReactDOM 18, and the widget UMD + CSS from the CDN.
- Mounts the widget into a
divwithReactDOM.render(...)once those scripts are ready. - Wraps that
divin a maxz-indexstacking context so the launcher is always clickable.
The only values you change are your API URL and Source Ref, passed as widget props:
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:
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:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# --- 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.