Adding a responsive chat UI to a Rails app doesn’t require React or a custom websocket server. With rails hotwire chat n4n, you get server-rendered Turbo updates and an OpenAI-compatible inference endpoint that handles provider fallback. This tutorial builds a persistent multi-turn chat from scratch, using Turbo Streams to append messages and a small service object to call the model.
Prerequisites
- Ruby 3.2+, Rails 7.1+ with Hotwire installed (
turbo-rails,stimulus-railsship by default in new apps). - An API key from n4n.ai (or similar OpenAI-compatible gateway). Store it as
n4n_api_keyin Rails credentials. - The
openaigem (v0.20+) for a typed HTTP client. - Familiarity with Rails MVC, ERB, and basic Turbo Stream mechanics.
If you’re starting greenfield:
rails new chat_app --minimal --skip-test
cd chat_app
rails hotwire:install
Scaffold the data model
A single messages table is enough for a single-conversation demo. Production systems would add a conversation_id and user foreign keys, but the Turbo pattern stays identical.
rails generate model Message role:string content:text
rails db:migrate
Add validation so we never persist a malformed role:
# app/models/message.rb
class Message < ApplicationRecord
validates :role, inclusion: { in: %w[user assistant] }
validates :content, presence: true
scope :chronological, -> { order(created_at: :asc) }
end
For a real app, add an index on created_at or conversation_id. SQLite is fine for local dev.
Configure the LLM client
Add the client gem:
# Gemfile
gem 'openai'
bundle install
n4n.ai exposes an OpenAI-compatible endpoint and automatically falls back when a provider is rate-limited or degraded, so we point the client at its base URL. The client behaves exactly like the OpenAI Ruby SDK.
# config/initializers/llm.rb
OpenAI.configure do |config|
config.access_token = Rails.application.credentials.n4n_api_key
config.uri_base = "https://api.n4n.ai/v1"
end
You can also instantiate per-call if you prefer explicit dependencies:
client = OpenAI::Client.new(
access_token: Rails.application.credentials.n4n_api_key,
uri_base: "https://api.n4n.ai/v1"
)
The gateway addresses 240+ models behind one path; you select via the model parameter.
Service object for chat completion
Keep the HTTP call out of the controller. A plain Ruby service object makes it testable and swappable.
# app/services/llm_chat.rb
class LlmChat
# Any model identifier the gateway supports
MODEL = "openai/gpt-4o-mini"
def self.complete(messages)
client = OpenAI::Client.new(
access_token: Rails.application.credentials.n4n_api_key,
uri_base: "https://api.n4n.ai/v1"
)
response = client.chat(
parameters: {
model: MODEL,
messages: messages.map { |m| { role: m.role, content: m.content } },
temperature: 0.7
}
)
response.dig("choices", 0, "message", "content").to_s
end
end
The gateway forwards provider cache-control hints and honors client routing directives, but a basic chat just sends the full message history. Note that response is a plain hash; we guard with to_s so empty completions don’t raise.
Routes and controller
Generate the controller:
rails generate controller Messages index create
# config/routes.rb
Rails.application.routes.draw do
resources :messages, only: [:index, :create]
root "messages#index"
end
The controller creates the user message, calls the service, and creates the assistant message. It then responds with a Turbo Stream when requested.
# app/controllers/messages_controller.rb
class MessagesController < ApplicationController
def index
@messages = Message.chronological
end
def create
@user_message = Message.create!(role: "user", content: params[:content])
begin
content = LlmChat.complete(Message.chronological)
@assistant_message = Message.create!(role: "assistant", content: content)
rescue StandardError => e
@assistant_message = Message.new(role: "assistant", content: "Error: #{e.message}")
end
respond_to do |format|
format.turbo_stream
format.html { redirect_to messages_path }
end
end
end
Wrapping the LLM call in a rescue prevents a provider outage from blowing up the request cycle. Because the gateway performs automatic fallback across providers, a single provider’s rate limit usually doesn’t surface as an error, but network timeouts still happen.
Views and Turbo Streams
The index view renders existing messages and a form. form_with defaults to Turbo, so submission is intercepted and sent as a Turbo request.
<%# app/views/messages/index.html.erb %>
<div id="messages" data-controller="messages" style="overflow-y: auto; height: 400px;">
<%= render @messages %>
</div>
<%= form_with url: messages_path, method: :post, data: { turbo_frame: "_top" } do |f| %>
<%= f.text_field :content, placeholder: "Type a message…", autocomplete: "off" %>
<%= f.submit "Send" %>
<% end %>
Partial for a single message:
<%# app/views/messages/_message.html.erb %>
<div class="message <%= message.role %>">
<strong><%= message.role %>:</strong>
<span><%= message.content %></span>
</div>
The Turbo Stream template appends both new records to #messages:
<%# app/views/messages/create.turbo_stream.erb %>
<%= turbo_stream.append "messages" do %>
<%= render @user_message %>
<%= render @assistant_message %>
<% end %>
No custom JavaScript is needed to insert the DOM nodes. Expected HTML after a successful POST:
<div class="message user">
<strong>user:</strong>
<span>What is Hotwire?</span>
</div>
<div class="message assistant">
<strong>assistant:</strong>
<span>Hotwire is a set of frameworks (Turbo, Stimulus) for building modern web apps with minimal custom JS.</span>
</div>
Autoscroll with Stimulus
Appending content doesn’t scroll the container. A tiny Stimulus controller fixes that.
rails generate stimulus messages
// app/javascript/controllers/messages_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.observer = new MutationObserver(() => this.scrollToBottom())
this.observer.observe(this.element, { childList: true })
}
disconnect() {
this.observer.disconnect()
}
scrollToBottom() {
this.element.scrollTop = this.element.scrollHeight
}
}
The data-controller="messages" attribute is already on the #messages div. Any child list change triggers a scroll to the bottom.
Checkpoint: run the app
rails server
Visit http://localhost:3000. The page loads with an empty (or previously seeded) message list. Type Explain Rails Hotwire in one sentence. and submit.
You should observe:
- The form submits without a full page reload (check the Network tab:
turbo_streamresponse type). - Both the user message and the assistant reply appear at the bottom.
- The container auto-scrolls so the newest message is visible.
- The messages persist across refreshes because they’re stored in the database.
If you see a Rails error, verify N4N_API_KEY is set and the migration ran.
Why this shape works
Turbo Streams push DOM mutations from the server, which fits LLM chat perfectly: the server is the source of truth for message order and completion content. You avoid client-side state synchronization. The service object isolates the external API, so you can later swap models or add retry logic without touching the view layer.
For multi-turn conversations with several users, scope messages by conversation_id and broadcast streamed updates via Turbo::StreamsChannel from a background job. The gateway’s per-token usage metering lets you attribute cost per conversation if you log the response headers.
Extending to token streaming
The current implementation blocks until the full completion returns. To stream tokens, pass stream: true to client.chat and broadcast partial turbo_stream.replace or append operations from a worker as chunks arrive. The message model would need a “partial” flag or you’d update the last assistant message in place. That’s a natural next step but not required for a functional rails hotwire chat n4n feature.
You now have a working integration with persisted history, graceful error handling, and no frontend build step.