n4nAI

Building a chat feature in Rails with Hotwire and n4n

Hands-on tutorial: build a Rails Hotwire chat feature backed by n4n's OpenAI-compatible LLM gateway. Models, controllers, Turbo streams, and code.

n4n Team3 min read709 words

Audio narration

Coming soon — every post will get a voice note here.

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-rails ship by default in new apps).
  • An API key from n4n.ai (or similar OpenAI-compatible gateway). Store it as n4n_api_key in Rails credentials.
  • The openai gem (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:

  1. The form submits without a full page reload (check the Network tab: turbo_stream response type).
  2. Both the user message and the assistant reply appear at the bottom.
  3. The container auto-scrolls so the newest message is visible.
  4. 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.

Tagsrailshotwiren4nchat-feature

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All ruby on rails llm integration posts →