What this example includes
The example is a working full-stack chatbot built with React, Express, and ModelRiver:
| Feature | How it works |
|---|---|
| Session memory | The first request creates a session_id; later turns send it back so ModelRiver can inject relevant conversation history. |
| Asynchronous AI requests | The Node.js backend calls /v1/ai/async and immediately returns connection details to the browser. |
| Real-time delivery | The browser joins the request's Phoenix WebSocket channel with a single-use ws_token. |
| Structured responses | Replies include reply, summary, sentiment, confidence, topics, and action_items. |
| Backend callback | ModelRiver sends the webhook_received event to the backend, which can persist or enrich the result before calling back. |
The example keeps messages in memory for demonstration. Replace that storage with your database before using it in production.
Request flow
1React frontend2 │ POST /chat (message + optional session_id)3 ▼4Node.js backend5 │ POST /v1/ai/async6 ▼7ModelRiver → AI provider8 │9 ├─ returns channel_id, ws_token, websocket_url,10 │ websocket_channel, and session_id11 │12 └─ sends webhook_received to the Node.js backend13 │14 └─ backend calls callback_url15 │16 ▼17 final structured response is sent to React18 through the request's WebSocket channelPrerequisites
- Node.js 18 or newer
- A ModelRiver project
- A project API key
- Credentials for at least one supported AI provider
Step 1 — Get the application
git clone https://github.com/modelriver/chatbot-async-app.gitcd chatbot-async-app cd backendnpm install cd ../frontendnpm installStep 2 — Import the chatbot template
- Open the Session Memory Chatbot template.
- Click Download JSON.
- Open your ModelRiver project and use Import to upload the file.
- Review and confirm the import.
The template creates:
- Workflow:
mr_chatbot_workflow - Structured output:
chatbot_response - Session memory: enabled
- Backend pipeline event:
webhook_received - Primary model:
openai / gpt-5.6-luna - Backup model:
anthropic / claude-haiku-4-5-20251001
Connect the selected providers in your project before sending a live request. You can change the models on the template page before downloading it.
Required for session memory: Keep request-body logging enabled for the project. ModelRiver cannot build conversation memory when request bodies are disabled.
Step 3 — Create the API key and webhook
API key
Create a project API key and copy it into backend/.env as MODELRIVER_API_KEY.
Webhook
Create one enabled HTTP webhook for the project and copy its secret into WEBHOOK_SECRET.
When ModelRiver is running locally on port 4000, use:
1http://localhost:4001/webhook/modelriverWhen using hosted ModelRiver, the webhook must be publicly reachable. During local development you can use the ModelRiver CLI forward command or an HTTPS tunnel.
The project may contain a CLI webhook too, but it will show “No CLI client connected” when no CLI listener is running. That failure does not mean the HTTP webhook failed; disable unused CLI webhooks to keep request logs clear.
Step 4 — Configure environment variables
Copy the example files first:
cd backendcp .env.example .env cd ../frontendcp .env.example .envHosted ModelRiver
backend/.env:
PORT=4000MODELRIVER_API_KEY=mr_live_your_project_keyMODELRIVER_API_URL=https://api.modelriver.comBACKEND_PUBLIC_URL=https://your-backend-or-tunnel.example.comWEBHOOK_SECRET=your_webhook_secretfrontend/.env:
VITE_API_URL=http://localhost:4000ModelRiver running locally
ModelRiver already uses port 4000, so run the chatbot backend on 4001.
backend/.env:
PORT=4001MODELRIVER_API_KEY=mr_live_your_local_project_keyMODELRIVER_API_URL=http://127.0.0.1:4000/apiBACKEND_PUBLIC_URL=http://localhost:4001WEBHOOK_SECRET=your_local_webhook_secretfrontend/.env:
VITE_API_URL=http://localhost:4001The frontend development port is fixed to 3006 in frontend/vite.config.js; it is not read from the frontend .env file. BACKEND_PUBLIC_URL must point to the same backend that receives /webhook/modelriver. The application does not use an EVENT_NAME environment variable; its default event is webhook_received.
Step 5 — Run the chatbot
Open two terminals in the cloned repository.
Terminal 1 — backend:
cd backendnpm startTerminal 2 — frontend:
cd frontendnpm run devOpen http://localhost:3006.
If you use hosted ModelRiver with a CLI webhook, also run the CLI forwarding command in a third terminal using the port configured during modelriver login:
modelriver forward --port 4000For local ModelRiver with the direct HTTP webhook, no CLI process is required.
Step 6 — Test session memory
Send this as the first message:
1My name is Vishal. I have a bakery named Sunrise Bakes in Bangalore. Please remember this.Then send this in the same conversation:
1What is my name, business name, and city?The reply should recall Vishal, Sunrise Bakes, and Bangalore. The session indicator in the UI should remain the same across both turns.
Click New conversation, then ask the second question again. A new session_id should be created, and the chatbot should not claim to know details from the previous conversation.
How session_id is handled
The browser does not invent a ModelRiver session ID:
- The first
/chatrequest omitssession_id. - ModelRiver creates a session and returns
session_idin the async response body. - The backend returns that ID to React.
- React sends the same ID with every later message in that conversation.
- New conversation clears it so the next request creates another session.
The client-generated conversationId is application metadata. It must not be used in place of ModelRiver's session_id.
Example backend payload:
1const payload = {2 workflow: 'mr_chatbot_workflow',3 messages: [{ role: 'user', content: message }],4 delivery_method: 'websocket',5 webhook_url: `${BACKEND_PUBLIC_URL}/webhook/modelriver`,6 events: ['webhook_received'],7 metadata: {8 conversation_id: conversationId,9 message_id: messageId,10 original_prompt: message11 }12};13 14if (sessionId) payload.session_id = sessionId;15 16const { data } = await axios.post(17 `${MODELRIVER_API_URL}/v1/ai/async`,18 payload,19 { headers: { Authorization: `Bearer ${MODELRIVER_API_KEY}` } }20);How WebSocket delivery is handled
The app uses the Phoenix client directly because each ws_token is single-use. It deliberately disconnects after the final response so the client cannot retry with a consumed token.
1import { Socket } from 'phoenix';2 3const socket = new Socket(websocket_url, {4 params: { token: ws_token },5 reconnectAfterMs: () => 60_0006});7 8const channel = socket.channel(websocket_channel);9 10socket.onOpen(() => {11 channel.join();12});13 14channel.on('response', payload => {15 const status = payload.meta?.status || payload.status;16 renderResponse(payload);17 18 if (status === 'completed' || status === 'success') {19 channel.leave();20 socket.disconnect();21 }22});23 24socket.connect();Use the exact websocket_url and websocket_channel returned for that request. Do not store or reuse ws_token.
How the backend callback is handled
ModelRiver sends a task.ai_generated webhook for the webhook_received event. The backend:
- Verifies
X-ModelRiver-SignatureandX-ModelRiver-TimestampusingWEBHOOK_SECRET. - Reads the structured result from
ai_response.data. - Adds the application's message ID.
- POSTs the result to
callback_urlwith the project API key.
1const callbackPayload = {2 data: {3 ...webhook.ai_response.data,4 id: messageId5 },6 task_id: messageId,7 metadata: webhook.ai_response.meta || {}8};9 10await axios.post(webhook.callback_url, callbackPayload, {11 headers: {12 Authorization: `Bearer ${MODELRIVER_API_KEY}`,13 'Content-Type': 'application/json'14 }15});The final callback payload is then delivered to the browser's WebSocket channel.
Structured response
The imported chatbot_response format contains:
| Field | Type | Purpose |
|---|---|---|
reply | string | Main answer shown to the user |
summary | string | Short description of the turn |
sentiment | positive, neutral, negative, or mixed | Sentiment badge |
confidence | high, medium, or low | Confidence indicator |
topics | string array | Topic tags |
action_items | array of { task, priority } | Follow-up work, when present |
The frontend accepts both categorical and numeric confidence values, but the downloadable template uses high, medium, or low consistently.
Backend endpoints
| Endpoint | Method | Description |
|---|---|---|
/chat | POST | Starts an async request and returns WebSocket and session details |
/webhook/modelriver | POST | Verifies and processes ModelRiver webhook events |
/conversations/:id | GET | Returns the demo's in-memory conversation records |
/health | GET | Shows backend and ModelRiver configuration status |
POST /chat accepts:
1{2 "message": "What did I tell you about my business?",3 "workflow": "mr_chatbot_workflow",4 "conversationId": "optional-application-conversation-id",5 "session_id": "optional-modelriver-session-uuid",6 "events": ["webhook_received"]7}Only message is required. The React client manages session_id automatically.
Production checklist
Before deploying this example:
- Replace the in-memory maps with persistent storage.
- Require
WEBHOOK_SECRET; never allow unsigned production webhooks. - Restrict CORS to the deployed frontend origin.
- Use HTTPS for the backend and WebSocket endpoint.
- Associate session IDs with the authenticated user on your server.
- Add idempotency and retry handling around database writes and callbacks.
- Keep API keys and webhook secrets on the backend only.
- Monitor request logs, failed webhook deliveries, callbacks, and session summaries.
Troubleshooting
WebSocket connection error or HTTP 403
- Confirm the frontend uses the latest
ws_tokenand does not reconnect with it. - Confirm it uses the returned
websocket_channel, not a channel assembled in the browser. - When ModelRiver is local, prefer
127.0.0.1for the WebSocket host to avoid macOS IPv6 resolution problems. - Restart the frontend after changing its
.envfile.
The chatbot does not remember
- Confirm Session memory is enabled on
mr_chatbot_workflow. - Confirm request-body logging is enabled for the project.
- Confirm the second
/chatrequest sends exactly thesession_idreturned by the first. - Confirm New conversation was not selected between turns.
HTTP webhook succeeds but CLI webhook fails
An enabled cli://localhost webhook fails when no CLI client is connected. Disable it or run the CLI. The successful HTTP webhook can still complete the request.