<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://antoninoscaffidi.github.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://antoninoscaffidi.github.io/" rel="alternate" type="text/html" /><updated>2026-08-20T13:31:40+00:00</updated><id>https://antoninoscaffidi.github.io/feed.xml</id><title type="html">Antonino Scaffidi Chiarello</title><subtitle>A blog about Ruby on Rails, web development, and AI — tutorials, deep dives, and project series like VicinoTe.</subtitle><entry xml:lang="en"><title type="html">VicinoTe: Services and Categories</title><link href="https://antoninoscaffidi.github.io/vicinote-services-and-categories/" rel="alternate" type="text/html" title="VicinoTe: Services and Categories" /><published>2026-08-20T05:00:00+00:00</published><updated>2026-08-20T05:00:00+00:00</updated><id>https://antoninoscaffidi.github.io/vicinote-services-and-categories</id><content type="html" xml:base="https://antoninoscaffidi.github.io/vicinote-services-and-categories/"><![CDATA[<p><a href="/vicinote-authentication-with-rails-8/">Episode 2</a> got accounts working end to end — sign up, sign in, sign out, password reset — and left it there deliberately: nothing touched <code class="language-plaintext highlighter-rouge">Service</code> or <code class="language-plaintext highlighter-rouge">Booking</code> yet. This episode is where the marketplace actually starts being a marketplace: a signed-in user can list something they offer, and anyone can browse what’s listed.</p>

<p>It’s also where the <code class="language-plaintext highlighter-rouge">has_many :services</code> association from <a href="/vicinote-project-setup-and-domain/">episode 1</a>’s domain sketch finally gets written into the <code class="language-plaintext highlighter-rouge">User</code> model. It’s been sitting in a blog post as a design decision for two episodes; today it becomes real code.</p>

<p>This is a long one on purpose — the goal is that nothing in the diff is left unexplained: every generated file, every line we added by hand, every option passed to every method.</p>

<p>Code is tagged <a href="https://github.com/AntoninoScaffidi/vicinote-tutorial/tree/episode-3"><code class="language-plaintext highlighter-rouge">episode-3</code></a> in the <a href="https://github.com/AntoninoScaffidi/vicinote-tutorial">vicinote-tutorial</a> repo.</p>

<h2 id="what-this-episode-touches-at-a-glance">What this episode touches, at a glance</h2>

<p>Before going file by file, here’s the map. Two new models, one changed model, one new controller, two new views, a routing change, a seed file, and a one-line translation fix:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>db/migrate/..._create_categories.rb   new  — the categories table
db/migrate/..._create_services.rb     new  — the services table
app/models/category.rb                new  — Category: has_many :services, validates :name
app/models/service.rb                 new  — Service: belongs_to :user/:category, validations, price accessor
app/models/user.rb                    edit — adds has_many :services
app/controllers/services_controller.rb new — index (public), new, create (both signed-in only)
app/views/services/index.html.erb     new  — the listing page
app/views/services/new.html.erb       new  — the "offer a service" form
config/routes.rb                      edit — resources :services, only: [:index, :new, :create]
config/locales/en.yml                 edit — fixes a leaked internal attribute name in error messages, sets the default currency to euros
db/seeds.rb                           edit — the fixed list of categories
app/views/pages/home.html.erb         edit — the two placeholder buttons now link somewhere
</code></pre></div></div>

<h2 id="generating-the-two-models">Generating the two models</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails generate model Category <span class="s2">"name:string:uniq"</span>
bin/rails generate model Service title:string description:text price_cents:integer user:references category:references
</code></pre></div></div>

<p>Every <code class="language-plaintext highlighter-rouge">field:type</code> pair after the model name becomes a migration column. The syntax has a few extra tricks worth spelling out, since both commands use them:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">name:string:uniq</code> — the third segment, <code class="language-plaintext highlighter-rouge">:uniq</code>, isn’t a type. It tells the generator to also add a unique index on that column, so it writes an <code class="language-plaintext highlighter-rouge">add_index</code> call for us instead of us remembering to add one later.</li>
  <li><code class="language-plaintext highlighter-rouge">user:references</code> and <code class="language-plaintext highlighter-rouge">category:references</code> — <code class="language-plaintext highlighter-rouge">references</code> isn’t a column type either, it’s a generator shorthand meaning “this model belongs to that one.” It expands into a <code class="language-plaintext highlighter-rouge">t.references</code> call in the migration (a foreign-key integer column plus an index), <em>and</em> it makes the generator write a <code class="language-plaintext highlighter-rouge">belongs_to :user</code> / <code class="language-plaintext highlighter-rouge">belongs_to :category</code> line directly into the generated model file. That’s why <code class="language-plaintext highlighter-rouge">Service</code>’s model already has both associations in it the moment the generator finishes — we didn’t type <code class="language-plaintext highlighter-rouge">belongs_to</code> ourselves.</li>
</ul>

<p>Each command prints what it created:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>      create    db/migrate/20260820055929_create_categories.rb
      create    app/models/category.rb
      invoke    test_unit
      create      test/models/category_test.rb
      create      test/fixtures/categories.yml
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>      create    db/migrate/20260820055946_create_services.rb
      create    app/models/service.rb
      invoke    test_unit
      create      test/models/service_test.rb
      create      test/fixtures/services.yml
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">test_unit</code> files are Rails’ default test scaffolding (an empty test class and an empty fixture file) — this series isn’t using them, so they’re left as generated and not discussed further.</p>

<h2 id="the-categories-migration-line-by-line">The categories migration, line by line</h2>

<p>Generated, then hand-edited to add one word:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/..._create_categories.rb</span>
<span class="k">class</span> <span class="nc">CreateCategories</span> <span class="o">&lt;</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Migration</span><span class="p">[</span><span class="mf">8.1</span><span class="p">]</span>
  <span class="k">def</span> <span class="nf">change</span>
    <span class="n">create_table</span> <span class="ss">:categories</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:name</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>

      <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
    <span class="k">end</span>
    <span class="n">add_index</span> <span class="ss">:categories</span><span class="p">,</span> <span class="ss">:name</span><span class="p">,</span> <span class="ss">unique: </span><span class="kp">true</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">class CreateCategories &lt; ActiveRecord::Migration[8.1]</code> — every migration is a subclass of <code class="language-plaintext highlighter-rouge">ActiveRecord::Migration</code>, versioned to the Rails release that generated it (<code class="language-plaintext highlighter-rouge">[8.1]</code>). That version pin is what lets Rails change migration DSL behavior across major versions without silently breaking migrations written for an older one.</li>
  <li><code class="language-plaintext highlighter-rouge">def change</code> — the one method Rails needs. For operations it can reverse automatically (creating a table, adding a column, adding an index), <code class="language-plaintext highlighter-rouge">change</code> is enough; Rails infers how to undo it if you ever roll the migration back. Irreversible operations would need separate <code class="language-plaintext highlighter-rouge">up</code>/<code class="language-plaintext highlighter-rouge">down</code> methods instead — nothing here needs that.</li>
  <li><code class="language-plaintext highlighter-rouge">create_table :categories do |t|</code> — opens the table definition block; <code class="language-plaintext highlighter-rouge">t</code> is the object every column gets defined on.</li>
  <li><code class="language-plaintext highlighter-rouge">t.string :name, null: false</code> — a <code class="language-plaintext highlighter-rouge">VARCHAR</code> column. The generator wrote <code class="language-plaintext highlighter-rouge">t.string :name</code>; the <code class="language-plaintext highlighter-rouge">null: false</code> is the one word we added by hand, matching the same rigor episode 2 put into the <code class="language-plaintext highlighter-rouge">users</code> table. Without it, Postgres would happily store a category with no name at all, and that’s not a state the app has any use for.</li>
  <li><code class="language-plaintext highlighter-rouge">t.timestamps</code> — shorthand for two columns, <code class="language-plaintext highlighter-rouge">created_at</code> and <code class="language-plaintext highlighter-rouge">updated_at</code>, both <code class="language-plaintext highlighter-rouge">datetime</code>, both filled in automatically by ActiveRecord on create/update. Almost every table in this series has this line; <code class="language-plaintext highlighter-rouge">Category</code> is no exception.</li>
  <li><code class="language-plaintext highlighter-rouge">add_index :categories, :name, unique: true</code> — this is what <code class="language-plaintext highlighter-rouge">:uniq</code> in the generator command produced. It’s a <em>database-level</em> uniqueness guarantee, enforced by Postgres itself, not just by a Rails validation that could in theory be bypassed by two simultaneous requests racing each other. <code class="language-plaintext highlighter-rouge">Category</code> also validates uniqueness in the model (below) — belt and suspenders: the model validation gives a friendly error in the normal case, the index guarantees correctness even under a race.</li>
</ul>

<h2 id="the-services-migration-line-by-line">The services migration, line by line</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/..._create_services.rb</span>
<span class="k">class</span> <span class="nc">CreateServices</span> <span class="o">&lt;</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Migration</span><span class="p">[</span><span class="mf">8.1</span><span class="p">]</span>
  <span class="k">def</span> <span class="nf">change</span>
    <span class="n">create_table</span> <span class="ss">:services</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:title</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">text</span> <span class="ss">:description</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">integer</span> <span class="ss">:price_cents</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">references</span> <span class="ss">:user</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span><span class="p">,</span> <span class="ss">foreign_key: </span><span class="kp">true</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">references</span> <span class="ss">:category</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span><span class="p">,</span> <span class="ss">foreign_key: </span><span class="kp">true</span>

      <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">t.string :title, null: false</code> — same shape as <code class="language-plaintext highlighter-rouge">Category#name</code>: a required short text field.</li>
  <li><code class="language-plaintext highlighter-rouge">t.text :description, null: false</code> — <code class="language-plaintext highlighter-rouge">text</code> instead of <code class="language-plaintext highlighter-rouge">string</code>. In Postgres this is really a formality (both map to the same unbounded <code class="language-plaintext highlighter-rouge">text</code> type under the hood; Rails’ <code class="language-plaintext highlighter-rouge">string</code> vs <code class="language-plaintext highlighter-rouge">text</code> distinction is mostly a Rails-level hint, not a Postgres storage difference), but it signals intent: this column holds a paragraph, not a label, and some form helpers (<code class="language-plaintext highlighter-rouge">form.text_area</code> instead of <code class="language-plaintext highlighter-rouge">form.text_field</code>) key off exactly this type later.</li>
  <li><code class="language-plaintext highlighter-rouge">t.integer :price_cents, null: false</code> — a plain integer. There’s a whole section below on why this is an integer and not a <code class="language-plaintext highlighter-rouge">decimal</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">t.references :user, null: false, foreign_key: true</code> — this line is what <code class="language-plaintext highlighter-rouge">user:references</code> on the command line generated, and it’s doing three things at once:
    <ol>
      <li>Adding an integer column named <code class="language-plaintext highlighter-rouge">user_id</code> (the <code class="language-plaintext highlighter-rouge">_id</code> suffix and the pluralization-to-singular are both Rails convention, not something we typed).</li>
      <li><code class="language-plaintext highlighter-rouge">foreign_key: true</code> — adds an actual Postgres foreign key constraint from <code class="language-plaintext highlighter-rouge">services.user_id</code> to <code class="language-plaintext highlighter-rouge">users.id</code>. The database itself will now refuse to insert a <code class="language-plaintext highlighter-rouge">Service</code> row whose <code class="language-plaintext highlighter-rouge">user_id</code> doesn’t match a real user, and refuses to delete a <code class="language-plaintext highlighter-rouge">User</code> row that still has services pointing at it (unless the association says otherwise — more on that below).</li>
      <li>An index on <code class="language-plaintext highlighter-rouge">user_id</code>, added automatically, because a foreign-key column that isn’t indexed makes every query that joins through it slow as the table grows. This one wasn’t optional or something we asked for — <code class="language-plaintext highlighter-rouge">t.references</code> always indexes.
<code class="language-plaintext highlighter-rouge">null: false</code> here means every service <em>must</em> belong to somebody; there’s no such thing as a service with no provider.</li>
    </ol>
  </li>
  <li><code class="language-plaintext highlighter-rouge">t.references :category, null: false, foreign_key: true</code> — identical shape, for the other side of the relationship.</li>
</ul>

<p>Notice neither migration says anything about <code class="language-plaintext highlighter-rouge">belongs_to</code> or <code class="language-plaintext highlighter-rouge">has_many</code> — migrations only describe the <em>database schema</em> (tables, columns, constraints, indexes). Associations are a separate, Ruby-level concept declared in the models, which is the next section.</p>

<h2 id="running-the-migrations">Running the migrations</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails db:migrate
</code></pre></div></div>

<p>This runs both pending migrations in timestamp order (categories, then services — services’ foreign key to categories needs the categories table to already exist) and rewrites <code class="language-plaintext highlighter-rouge">db/schema.rb</code> to reflect the new state. <code class="language-plaintext highlighter-rouge">schema.rb</code> isn’t something to edit by hand; it’s Rails’ cached snapshot of “what the database currently looks like,” regenerated every time you migrate, and it’s what a teammate’s <code class="language-plaintext highlighter-rouge">bin/rails db:setup</code> reads to build a fresh database that matches yours without replaying every migration ever written.</p>

<h2 id="the-generated-models-before-any-edits">The generated models, before any edits</h2>

<p>Right after the generator ran, before we touched anything:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/category.rb</span>
<span class="k">class</span> <span class="nc">Category</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/service.rb</span>
<span class="k">class</span> <span class="nc">Service</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">belongs_to</span> <span class="ss">:user</span>
  <span class="n">belongs_to</span> <span class="ss">:category</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Category</code> is empty because it has no <code class="language-plaintext highlighter-rouge">references</code> columns — there was nothing for the generator to infer an association from. <code class="language-plaintext highlighter-rouge">Service</code> already has both <code class="language-plaintext highlighter-rouge">belongs_to</code> lines because of the <code class="language-plaintext highlighter-rouge">:references</code> fields we passed on the command line, as explained above. Both inherit from <code class="language-plaintext highlighter-rouge">ApplicationRecord</code>, the abstract base class every model in a Rails app shares (itself a thin subclass of <code class="language-plaintext highlighter-rouge">ActiveRecord::Base</code>), which is what gives them <code class="language-plaintext highlighter-rouge">.find</code>, <code class="language-plaintext highlighter-rouge">.create</code>, <code class="language-plaintext highlighter-rouge">.where</code>, validations, and everything else ActiveRecord provides — none of that is written in either file, it’s inherited.</p>

<p>One thing worth naming explicitly: as of Rails 5, <code class="language-plaintext highlighter-rouge">belongs_to</code> is <strong>required by default</strong>. Writing <code class="language-plaintext highlighter-rouge">belongs_to :user</code> doesn’t just declare the association, it also implicitly adds a presence validation — a <code class="language-plaintext highlighter-rouge">Service</code> without a <code class="language-plaintext highlighter-rouge">user</code> fails validation, on top of the database already refusing it via the <code class="language-plaintext highlighter-rouge">null: false, foreign_key: true</code> from the migration. Two independent layers enforcing the same rule, at two different levels (Ruby validation vs. SQL constraint), which is exactly the “Category must exist” error message you’ll see later in this post — that sentence is Rails’ default wording for a failed <code class="language-plaintext highlighter-rouge">belongs_to</code> presence check on <code class="language-plaintext highlighter-rouge">category</code>.</p>

<h2 id="category-filled-in">Category, filled in</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/category.rb</span>
<span class="k">class</span> <span class="nc">Category</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">has_many</span> <span class="ss">:services</span>

  <span class="n">validates</span> <span class="ss">:name</span><span class="p">,</span> <span class="ss">presence: </span><span class="kp">true</span><span class="p">,</span> <span class="ss">uniqueness: </span><span class="kp">true</span>
<span class="k">end</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">has_many :services</code> — the other half of <code class="language-plaintext highlighter-rouge">Service belongs_to :category</code>. Rails associations are declared on both ends by hand; there’s no way to declare just one side and have the other inferred. This is what makes <code class="language-plaintext highlighter-rouge">some_category.services</code> work — the association looks up every <code class="language-plaintext highlighter-rouge">Service</code> row whose <code class="language-plaintext highlighter-rouge">category_id</code> matches, via the foreign key the migration created.</li>
  <li><code class="language-plaintext highlighter-rouge">validates :name, presence: true, uniqueness: true</code> — <code class="language-plaintext highlighter-rouge">presence: true</code> duplicates what the <code class="language-plaintext highlighter-rouge">null: false</code> in the migration already guarantees at the database level, but for a different audience: a failed database constraint raises an ugly <code class="language-plaintext highlighter-rouge">ActiveRecord::NotNullViolation</code> exception, while a failed presence validation gives <code class="language-plaintext highlighter-rouge">@category.errors</code> a friendly message a view can render. <code class="language-plaintext highlighter-rouge">uniqueness: true</code> is the model-level half of the belt-and-suspenders pair with the migration’s unique index — this one runs a <code class="language-plaintext highlighter-rouge">SELECT</code> before saving and gives a clean error; the index is what actually stops a duplicate from ever reaching the table if two requests race each other past the validation at the same instant.</li>
</ul>

<h2 id="service-filled-in">Service, filled in</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/service.rb</span>
<span class="k">class</span> <span class="nc">Service</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">belongs_to</span> <span class="ss">:user</span>
  <span class="n">belongs_to</span> <span class="ss">:category</span>

  <span class="n">validates</span> <span class="ss">:title</span><span class="p">,</span> <span class="ss">presence: </span><span class="kp">true</span>
  <span class="n">validates</span> <span class="ss">:description</span><span class="p">,</span> <span class="ss">presence: </span><span class="kp">true</span>
  <span class="n">validates</span> <span class="ss">:price_cents</span><span class="p">,</span> <span class="ss">presence: </span><span class="kp">true</span><span class="p">,</span> <span class="ss">numericality: </span><span class="p">{</span> <span class="ss">greater_than: </span><span class="mi">0</span><span class="p">,</span> <span class="ss">only_integer: </span><span class="kp">true</span> <span class="p">}</span>

  <span class="c1"># price_cents is what's stored and compared (no float rounding surprises),</span>
  <span class="c1"># but nobody wants to type cents into a form. This speaks euros on the</span>
  <span class="c1"># way in and out, so the form field can just be "price".</span>
  <span class="k">def</span> <span class="nf">price</span>
    <span class="n">price_cents</span> <span class="o">&amp;&amp;</span> <span class="n">price_cents</span> <span class="o">/</span> <span class="mf">100.0</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">price</span><span class="o">=</span><span class="p">(</span><span class="n">value</span><span class="p">)</span>
    <span class="nb">self</span><span class="p">.</span><span class="nf">price_cents</span> <span class="o">=</span> <span class="n">value</span><span class="p">.</span><span class="nf">present?</span> <span class="p">?</span> <span class="p">(</span><span class="n">value</span><span class="p">.</span><span class="nf">to_f</span> <span class="o">*</span> <span class="mi">100</span><span class="p">).</span><span class="nf">round</span> <span class="p">:</span> <span class="kp">nil</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">validates :title, presence: true</code> / <code class="language-plaintext highlighter-rouge">validates :description, presence: true</code> — same reasoning as <code class="language-plaintext highlighter-rouge">Category#name</code>: the migration’s <code class="language-plaintext highlighter-rouge">null: false</code> is the last line of defense, this is the friendly one that runs first.</li>
  <li><code class="language-plaintext highlighter-rouge">validates :price_cents, presence: true, numericality: { greater_than: 0, only_integer: true }</code> — three checks bundled into one line. <code class="language-plaintext highlighter-rouge">presence: true</code> rejects <code class="language-plaintext highlighter-rouge">nil</code>. <code class="language-plaintext highlighter-rouge">numericality:</code> on its own would just reject anything that isn’t a number at all (a string like <code class="language-plaintext highlighter-rouge">"abc"</code>); the <code class="language-plaintext highlighter-rouge">greater_than: 0</code> option adds the business rule that a free service isn’t a thing this validation allows, and <code class="language-plaintext highlighter-rouge">only_integer: true</code> rejects fractional cents (<code class="language-plaintext highlighter-rouge">4250.5</code>), which shouldn’t be reachable anyway since <code class="language-plaintext highlighter-rouge">price_cents</code> is a database <code class="language-plaintext highlighter-rouge">integer</code> column, but the validation makes the rule explicit rather than relying on the column type alone to enforce it before the value ever reaches the database.</li>
  <li><code class="language-plaintext highlighter-rouge">def price</code> / <code class="language-plaintext highlighter-rouge">def price=</code> — covered in full in the next section; this is the piece that lets a form talk in euros while the column underneath stays cents.</li>
</ul>

<h2 id="a-decision-worth-slowing-down-on-price_cents-not-price">A decision worth slowing down on: price_cents, not price</h2>

<p>The generator wrote <code class="language-plaintext highlighter-rouge">price_cents:integer</code>, not <code class="language-plaintext highlighter-rouge">price:decimal</code>, on the command line — that phrasing was chosen deliberately going in, and it’s the same category of decision episode 1 made about <code class="language-plaintext highlighter-rouge">Booking</code> storing its own price rather than reading <code class="language-plaintext highlighter-rouge">service.price</code> live: money handled as a float, or even a naive <code class="language-plaintext highlighter-rouge">decimal</code>, eventually produces a rounding error that shows up as a few cents off on an invoice, at the worst possible moment. Storing the amount as an integer number of cents sidesteps the whole class of bug — there’s no fractional part to round, because there’s no fraction at all. €42.50 is stored as the integer <code class="language-plaintext highlighter-rouge">4250</code>, full stop. Naming it <code class="language-plaintext highlighter-rouge">price_cents</code> rather than, say, <code class="language-plaintext highlighter-rouge">price_usd_cents</code> is deliberate too — “cents” is the minor unit of euros just as much as it is of dollars, so nothing about the column itself is tied to a currency; only the formatting layer, covered later in this post, has any idea which one is in use.</p>

<p>The cost is that nobody wants to type “4250” into a form and mean €42.50. So <code class="language-plaintext highlighter-rouge">Service</code> gets a small virtual accessor — two plain Ruby methods, not a database column — that translates euros to cents and back:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">price</span>
  <span class="n">price_cents</span> <span class="o">&amp;&amp;</span> <span class="n">price_cents</span> <span class="o">/</span> <span class="mf">100.0</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">price</span><span class="o">=</span><span class="p">(</span><span class="n">value</span><span class="p">)</span>
  <span class="nb">self</span><span class="p">.</span><span class="nf">price_cents</span> <span class="o">=</span> <span class="n">value</span><span class="p">.</span><span class="nf">present?</span> <span class="p">?</span> <span class="p">(</span><span class="n">value</span><span class="p">.</span><span class="nf">to_f</span> <span class="o">*</span> <span class="mi">100</span><span class="p">).</span><span class="nf">round</span> <span class="p">:</span> <span class="kp">nil</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Walking through both:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">def price</code> — reads for display. <code class="language-plaintext highlighter-rouge">price_cents &amp;&amp; price_cents / 100.0</code> uses Ruby’s <code class="language-plaintext highlighter-rouge">&amp;&amp;</code> for its short-circuit behavior, not as a boolean check: if <code class="language-plaintext highlighter-rouge">price_cents</code> is <code class="language-plaintext highlighter-rouge">nil</code> (a brand-new, unsaved <code class="language-plaintext highlighter-rouge">Service</code>), the whole expression short-circuits to <code class="language-plaintext highlighter-rouge">nil</code> without attempting <code class="language-plaintext highlighter-rouge">nil / 100.0</code>, which would raise. If it’s a real integer, <code class="language-plaintext highlighter-rouge">&amp;&amp;</code> evaluates and returns the right-hand side — the division. Dividing by <code class="language-plaintext highlighter-rouge">100.0</code> (a float literal, not <code class="language-plaintext highlighter-rouge">100</code>) forces Ruby to do floating-point division rather than integer division, so <code class="language-plaintext highlighter-rouge">4250 / 100.0</code> gives <code class="language-plaintext highlighter-rouge">42.5</code>, not <code class="language-plaintext highlighter-rouge">42</code> truncated.</li>
  <li><code class="language-plaintext highlighter-rouge">def price=(value)</code> — the setter, called automatically whenever something does <code class="language-plaintext highlighter-rouge">service.price = "42.50"</code> or, just as automatically, whenever a form submits a field named <code class="language-plaintext highlighter-rouge">price</code> through mass assignment (<code class="language-plaintext highlighter-rouge">Service.new(price: "42.50", ...)</code>) — Rails calls the setter method for every permitted attribute, it doesn’t care whether that method backs a real column or not. <code class="language-plaintext highlighter-rouge">value.present?</code> guards against blank input (an empty string from a cleared form field) rather than trying to convert <code class="language-plaintext highlighter-rouge">""</code> into a number. When there <em>is</em> a value, <code class="language-plaintext highlighter-rouge">value.to_f * 100</code> converts to euros-as-a-float and multiplies by 100 to get cents, and <code class="language-plaintext highlighter-rouge">.round</code> turns that back into a whole number — <code class="language-plaintext highlighter-rouge">.to_f</code> on user input can produce things like <code class="language-plaintext highlighter-rouge">42.499999999999996</code> due to ordinary floating-point imprecision, and <code class="language-plaintext highlighter-rouge">.round</code> is what cleans that back up to exactly <code class="language-plaintext highlighter-rouge">4250</code> before it ever reaches <code class="language-plaintext highlighter-rouge">price_cents=</code>.</li>
</ul>

<p>Because <code class="language-plaintext highlighter-rouge">price=</code> is a normal Ruby method and not an ActiveRecord-backed attribute, it runs immediately when attributes are assigned — before <code class="language-plaintext highlighter-rouge">save</code>, before any validation. By the time <code class="language-plaintext highlighter-rouge">validates :price_cents, ...</code> runs, <code class="language-plaintext highlighter-rouge">price_cents</code> has already been populated by this setter. The form field discussed later in this post is just <code class="language-plaintext highlighter-rouge">form.text_field :price</code> — the view never has any idea cents exist.</p>

<h2 id="categories-are-seeded-not-created">Categories are seeded, not created</h2>

<p>A marketplace where anyone can invent a new category ends up with fifty categories that mean the same thing, spelled five different ways, and browsing-by-category stops being useful. VicinoTe curates a fixed list instead, in <code class="language-plaintext highlighter-rouge">db/seeds.rb</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/seeds.rb</span>
<span class="p">[</span>
  <span class="s2">"Home Repair"</span><span class="p">,</span>
  <span class="s2">"Tutoring"</span><span class="p">,</span>
  <span class="s2">"Cleaning"</span><span class="p">,</span>
  <span class="s2">"Gardening"</span><span class="p">,</span>
  <span class="s2">"Pet Care"</span><span class="p">,</span>
  <span class="s2">"Moving Help"</span><span class="p">,</span>
  <span class="s2">"Tech Support"</span><span class="p">,</span>
  <span class="s2">"Beauty &amp; Wellness"</span>
<span class="p">].</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="nb">name</span><span class="o">|</span>
  <span class="no">Category</span><span class="p">.</span><span class="nf">find_or_create_by!</span><span class="p">(</span><span class="ss">name: </span><span class="nb">name</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<ul>
  <li>The literal array of eight strings <em>is</em> the list of categories, in plain Ruby, right there in the seed file — no admin UI, no separate config format, just a source-controlled array a future episode could extend by adding a ninth string.</li>
  <li><code class="language-plaintext highlighter-rouge">.each do |name| ... end</code> iterates the array once per category.</li>
  <li><code class="language-plaintext highlighter-rouge">Category.find_or_create_by!(name: name)</code> — this single method does two jobs depending on what it finds: if a <code class="language-plaintext highlighter-rouge">Category</code> with that <code class="language-plaintext highlighter-rouge">name</code> already exists, it returns it untouched; if not, it builds and saves a new one. The trailing <code class="language-plaintext highlighter-rouge">!</code> means it raises <code class="language-plaintext highlighter-rouge">ActiveRecord::RecordInvalid</code> on a validation failure instead of silently returning an unsaved, invalid record — which matters here, because a seed file failing loudly is much easier to debug than one that fails quietly and leaves the database half-seeded.</li>
</ul>

<p>That combination — a fixed array plus <code class="language-plaintext highlighter-rouge">find_or_create_by!</code> — is what makes the file safe to run more than once. Running <code class="language-plaintext highlighter-rouge">db:seed</code> again after adding a ninth category to the array later creates only the new one; the first eight are matched by name and left alone, not duplicated.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails db:migrate
bin/rails db:seed
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">db:seed</code> just executes <code class="language-plaintext highlighter-rouge">db/seeds.rb</code> as a plain Ruby script inside the app’s environment — nothing more mysterious than that.</p>

<h2 id="writing-the-association-episode-1-only-sketched">Writing the association episode 1 only sketched</h2>

<p>Episode 1’s domain design showed this code as an illustration of the “role emerges from the association” decision — the whole point being that a <code class="language-plaintext highlighter-rouge">User</code> isn’t tagged with a <code class="language-plaintext highlighter-rouge">role: "provider"</code> column, it’s a provider <em>because</em> it has services. It was never actually in the <code class="language-plaintext highlighter-rouge">User</code> model, though; episode 2 was about authentication and didn’t touch it. It goes in now:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/user.rb</span>
<span class="k">class</span> <span class="nc">User</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">has_secure_password</span>
  <span class="n">has_many</span> <span class="ss">:sessions</span><span class="p">,</span> <span class="ss">dependent: :destroy</span>
  <span class="n">has_many</span> <span class="ss">:services</span><span class="p">,</span> <span class="ss">dependent: :destroy</span>

  <span class="n">normalizes</span> <span class="ss">:email_address</span><span class="p">,</span> <span class="ss">with: </span><span class="o">-&gt;</span><span class="p">(</span><span class="n">e</span><span class="p">)</span> <span class="p">{</span> <span class="n">e</span><span class="p">.</span><span class="nf">strip</span><span class="p">.</span><span class="nf">downcase</span> <span class="p">}</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Only one line changed — <code class="language-plaintext highlighter-rouge">has_many :services, dependent: :destroy</code> was added, everything else (<code class="language-plaintext highlighter-rouge">has_secure_password</code>, the <code class="language-plaintext highlighter-rouge">sessions</code> association, the email normalization) is untouched from episode 2.</p>

<p><code class="language-plaintext highlighter-rouge">dependent: :destroy</code> matches what <code class="language-plaintext highlighter-rouge">sessions</code> already does on the line above it, for the same reason: without it, deleting a <code class="language-plaintext highlighter-rouge">User</code> would either fail outright (the database’s <code class="language-plaintext highlighter-rouge">foreign_key: true</code> constraint from the migration would reject the delete, since <code class="language-plaintext highlighter-rouge">services</code> rows would still point at a <code class="language-plaintext highlighter-rouge">user_id</code> that’s about to stop existing) or, if the constraint were relaxed, leave orphaned <code class="language-plaintext highlighter-rouge">Service</code> rows in the table forever, pointing at nobody. <code class="language-plaintext highlighter-rouge">dependent: :destroy</code> tells Rails to delete every associated <code class="language-plaintext highlighter-rouge">Service</code> first, automatically, whenever a <code class="language-plaintext highlighter-rouge">User</code> is destroyed — the cleanup is one word, not something every future call site has to remember to do by hand.</p>

<h2 id="the-controller-public-index-protected-everything-else">The controller: public index, protected everything else</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/services_controller.rb</span>
<span class="k">class</span> <span class="nc">ServicesController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="n">allow_unauthenticated_access</span> <span class="ss">only: :index</span>

  <span class="k">def</span> <span class="nf">index</span>
    <span class="vi">@services</span> <span class="o">=</span> <span class="no">Service</span><span class="p">.</span><span class="nf">includes</span><span class="p">(</span><span class="ss">:category</span><span class="p">,</span> <span class="ss">:user</span><span class="p">).</span><span class="nf">order</span><span class="p">(</span><span class="ss">created_at: :desc</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">new</span>
    <span class="vi">@service</span> <span class="o">=</span> <span class="no">Current</span><span class="p">.</span><span class="nf">user</span><span class="p">.</span><span class="nf">services</span><span class="p">.</span><span class="nf">new</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">create</span>
    <span class="vi">@service</span> <span class="o">=</span> <span class="no">Current</span><span class="p">.</span><span class="nf">user</span><span class="p">.</span><span class="nf">services</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">service_params</span><span class="p">)</span>

    <span class="k">if</span> <span class="vi">@service</span><span class="p">.</span><span class="nf">save</span>
      <span class="n">redirect_to</span> <span class="n">services_path</span><span class="p">,</span> <span class="ss">notice: </span><span class="s2">"Your service is live."</span>
    <span class="k">else</span>
      <span class="n">render</span> <span class="ss">:new</span><span class="p">,</span> <span class="ss">status: :unprocessable_entity</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">service_params</span>
    <span class="n">params</span><span class="p">.</span><span class="nf">require</span><span class="p">(</span><span class="ss">:service</span><span class="p">).</span><span class="nf">permit</span><span class="p">(</span><span class="ss">:title</span><span class="p">,</span> <span class="ss">:description</span><span class="p">,</span> <span class="ss">:price</span><span class="p">,</span> <span class="ss">:category_id</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Line by line:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">class ServicesController &lt; ApplicationController</code> — every controller in a Rails app inherits from <code class="language-plaintext highlighter-rouge">ApplicationController</code>, which is where episode 2’s <code class="language-plaintext highlighter-rouge">Authentication</code> concern is included. That’s what makes the next line meaningful.</li>
  <li><code class="language-plaintext highlighter-rouge">allow_unauthenticated_access only: :index</code> — episode 2’s <code class="language-plaintext highlighter-rouge">Authentication</code> concern runs <code class="language-plaintext highlighter-rouge">before_action :require_authentication</code> for every action on every controller by default, which means <em>without this line</em>, an anonymous visitor hitting any action here would be redirected straight to the sign-in page. That’s correct for <code class="language-plaintext highlighter-rouge">new</code> and <code class="language-plaintext highlighter-rouge">create</code> — nobody should be able to list a service anonymously — but wrong for <code class="language-plaintext highlighter-rouge">index</code>: browsing the marketplace has to work for someone who hasn’t signed up yet, or there’s no reason to sign up in the first place. <code class="language-plaintext highlighter-rouge">only: :index</code> opts that one action back out of the requirement while leaving <code class="language-plaintext highlighter-rouge">new</code> and <code class="language-plaintext highlighter-rouge">create</code> protected. This is the exact same mechanism episode 2 used, with no argument at all (<code class="language-plaintext highlighter-rouge">allow_unauthenticated_access</code>, no <code class="language-plaintext highlighter-rouge">only:</code>), to make the entire <code class="language-plaintext highlighter-rouge">PagesController</code> public — here we only want one of three actions exempted, so <code class="language-plaintext highlighter-rouge">only:</code> narrows it.</li>
  <li><code class="language-plaintext highlighter-rouge">def index</code> / <code class="language-plaintext highlighter-rouge">@services = Service.includes(:category, :user).order(created_at: :desc)</code> — <code class="language-plaintext highlighter-rouge">Service.includes(:category, :user)</code> isn’t filtering anything; <code class="language-plaintext highlighter-rouge">includes</code> is ActiveRecord’s eager-loading method. Without it, a view that loops over <code class="language-plaintext highlighter-rouge">@services</code> and calls <code class="language-plaintext highlighter-rouge">service.category.name</code> and <code class="language-plaintext highlighter-rouge">service.user.email_address</code> for each one would fire one additional <code class="language-plaintext highlighter-rouge">SELECT</code> per service, per association — the classic N+1 query problem, where displaying 50 services silently means 101 queries (1 for the list, plus 50 for categories, plus 50 for users) instead of 3. <code class="language-plaintext highlighter-rouge">includes</code> loads the categories and users for every service up front, in a small fixed number of extra queries, regardless of how many services there are. <code class="language-plaintext highlighter-rouge">.order(created_at: :desc)</code> sorts newest-first, so a new listing shows up at the top of the page rather than the bottom.</li>
  <li><code class="language-plaintext highlighter-rouge">def new</code> / <code class="language-plaintext highlighter-rouge">@service = Current.user.services.new</code> — this is the payoff of wiring <code class="language-plaintext highlighter-rouge">has_many :services</code> onto <code class="language-plaintext highlighter-rouge">User</code> a section ago. <code class="language-plaintext highlighter-rouge">Current.user</code> is the currently authenticated user (from episode 2’s <code class="language-plaintext highlighter-rouge">Current</code> model, an <code class="language-plaintext highlighter-rouge">ActiveSupport::CurrentAttributes</code> subclass). Calling <code class="language-plaintext highlighter-rouge">.services.new</code> <em>through</em> the association, rather than <code class="language-plaintext highlighter-rouge">Service.new</code> on its own, pre-fills the new record’s <code class="language-plaintext highlighter-rouge">user_id</code> with <code class="language-plaintext highlighter-rouge">Current.user.id</code> automatically — the instance this line builds already knows who it belongs to before a single form field has been filled in.</li>
  <li><code class="language-plaintext highlighter-rouge">def create</code> — <code class="language-plaintext highlighter-rouge">Current.user.services.new(service_params)</code> does the same association-scoped build as <code class="language-plaintext highlighter-rouge">new</code>, but this time passing in the submitted form data as well. Because the <code class="language-plaintext highlighter-rouge">user</code> side of the association is set by <code class="language-plaintext highlighter-rouge">Current.user.services</code>, not by anything in <code class="language-plaintext highlighter-rouge">service_params</code>, there’s no <code class="language-plaintext highlighter-rouge">user_id</code> field anywhere in the permitted params below for a malicious visitor to tamper with and claim a listing on someone else’s behalf — the provider is whoever <code class="language-plaintext highlighter-rouge">Current.user</code> says it is, full stop, not whatever a hidden form field might claim.</li>
  <li><code class="language-plaintext highlighter-rouge">if @service.save</code> — <code class="language-plaintext highlighter-rouge">save</code> runs validations and, if they all pass, performs the <code class="language-plaintext highlighter-rouge">INSERT</code> and returns <code class="language-plaintext highlighter-rouge">true</code>; if any validation fails, it does nothing to the database and returns <code class="language-plaintext highlighter-rouge">false</code> — no exception raised, which is why this is a plain <code class="language-plaintext highlighter-rouge">if</code>, not a <code class="language-plaintext highlighter-rouge">begin/rescue</code>.</li>
  <li><code class="language-plaintext highlighter-rouge">redirect_to services_path, notice: "Your service is live."</code> — on success, a real HTTP redirect to the index (this is the same Post/Redirect/Get pattern episode 3 of <em>ai-with-ruby</em> discusses in more depth: redirecting after a state-changing POST means refreshing the result page never re-submits the form). <code class="language-plaintext highlighter-rouge">notice:</code> stores a one-time message in the <code class="language-plaintext highlighter-rouge">flash</code> — it survives exactly one redirect and then clears itself, which is how “Your service is live.” shows up on the very next page load and is gone if you refresh again.</li>
  <li><code class="language-plaintext highlighter-rouge">render :new, status: :unprocessable_entity</code> — on failure, re-render the <em>same</em> form (not redirect anywhere) so <code class="language-plaintext highlighter-rouge">@service</code> — now populated with both what the user typed and the validation errors attached to it — can be shown back to them with their input intact and the specific problems called out. <code class="language-plaintext highlighter-rouge">status: :unprocessable_entity</code> sets the HTTP status code to 422 instead of the default 200; the browser still renders the HTML either way, but a 422 correctly tells any tooling watching the response (browser dev tools, Turbo, a test suite) that this was a failed submission, not a successful page load that happens to contain a form.</li>
  <li><code class="language-plaintext highlighter-rouge">private</code> — everything below this line is only callable from within the controller itself, not reachable as a route.</li>
  <li><code class="language-plaintext highlighter-rouge">def service_params</code> / <code class="language-plaintext highlighter-rouge">params.require(:service).permit(:title, :description, :price, :category_id)</code> — Rails’ strong parameters. <code class="language-plaintext highlighter-rouge">params.require(:service)</code> raises immediately if the submitted form data has no top-level <code class="language-plaintext highlighter-rouge">service</code> key at all (a malformed or missing request), and <code class="language-plaintext highlighter-rouge">.permit(...)</code> is an allowlist: only these four keys are allowed through; anything else present in the raw request params — including, say, a <code class="language-plaintext highlighter-rouge">user_id</code> someone tried to inject by editing the form’s HTML in their browser before submitting — is silently stripped and never reaches <code class="language-plaintext highlighter-rouge">Service.new</code>. Notice this permits <code class="language-plaintext highlighter-rouge">:price</code>, not <code class="language-plaintext highlighter-rouge">:price_cents</code> — the controller talks to the same euros-facing interface the form does; it has no idea <code class="language-plaintext highlighter-rouge">price_cents</code> exists either.</li>
</ul>

<h2 id="routes-and-the-two-placeholder-buttons">Routes and the two placeholder buttons</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">resources</span> <span class="ss">:services</span><span class="p">,</span> <span class="ss">only: </span><span class="p">[</span><span class="ss">:index</span><span class="p">,</span> <span class="ss">:new</span><span class="p">,</span> <span class="ss">:create</span><span class="p">]</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">resources :services</code> is Rails’ RESTful routing shorthand — on its own it would generate all seven conventional routes (<code class="language-plaintext highlighter-rouge">index</code>, <code class="language-plaintext highlighter-rouge">new</code>, <code class="language-plaintext highlighter-rouge">create</code>, <code class="language-plaintext highlighter-rouge">show</code>, <code class="language-plaintext highlighter-rouge">edit</code>, <code class="language-plaintext highlighter-rouge">update</code>, <code class="language-plaintext highlighter-rouge">destroy</code>). <code class="language-plaintext highlighter-rouge">only: [:index, :new, :create]</code> narrows that down to exactly the three this episode implements. Running <code class="language-plaintext highlighter-rouge">bin/rails routes -g services</code> shows exactly what got generated:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>     Prefix Verb URI Pattern             Controller#Action
   services GET  /services(.:format)     services#index
            POST /services(.:format)     services#create
new_service GET  /services/new(.:format) services#new
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">Prefix</code> column is where path helper names like <code class="language-plaintext highlighter-rouge">services_path</code> and <code class="language-plaintext highlighter-rouge">new_service_path</code> — used throughout the controller and views in this episode — come from: Rails derives them automatically from the prefix plus <code class="language-plaintext highlighter-rouge">_path</code> or <code class="language-plaintext highlighter-rouge">_url</code>. There’s no <code class="language-plaintext highlighter-rouge">show</code> route yet, on purpose — there’s no individual service page to link to until a later episode builds one.</p>

<p>Episode 1’s landing page shipped with two buttons that did nothing on purpose, styled to look disabled:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;span</span> <span class="na">class=</span><span class="s">"rounded-md bg-indigo-600 px-4 py-2 text-white font-medium opacity-60 cursor-not-allowed"</span><span class="nt">&gt;</span>
  Browse services
<span class="nt">&lt;/span&gt;</span>
<span class="nt">&lt;span</span> <span class="na">class=</span><span class="s">"rounded-md border border-gray-300 px-4 py-2 text-gray-700 font-medium opacity-60 cursor-not-allowed"</span><span class="nt">&gt;</span>
  Offer a service
<span class="nt">&lt;/span&gt;</span>
</code></pre></div></div>

<p>They finally go somewhere:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%=</span> <span class="n">link_to</span> <span class="s2">"Browse services"</span><span class="p">,</span> <span class="n">services_path</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"rounded-md bg-indigo-600 px-4 py-2 text-white font-medium hover:bg-indigo-700"</span> <span class="cp">%&gt;</span>
<span class="cp">&lt;%=</span> <span class="n">link_to</span> <span class="s2">"Offer a service"</span><span class="p">,</span> <span class="n">authenticated?</span> <span class="p">?</span> <span class="n">new_service_path</span> <span class="p">:</span> <span class="n">new_registration_path</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"rounded-md border border-gray-300 px-4 py-2 text-gray-700 font-medium hover:bg-gray-50"</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p>Both <code class="language-plaintext highlighter-rouge">&lt;span&gt;</code> placeholders became <code class="language-plaintext highlighter-rouge">link_to</code> calls, and the <code class="language-plaintext highlighter-rouge">opacity-60 cursor-not-allowed</code> classes that visually signalled “not clickable yet” are gone along with the disabled state itself.</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">link_to "Browse services", services_path, ...</code> — unconditional. The index is public (<code class="language-plaintext highlighter-rouge">allow_unauthenticated_access only: :index</code> from the controller), so this link always makes sense, signed in or not.</li>
  <li><code class="language-plaintext highlighter-rouge">link_to "Offer a service", authenticated? ? new_service_path : new_registration_path, ...</code> — the destination itself is a ternary. <code class="language-plaintext highlighter-rouge">authenticated?</code> is the <code class="language-plaintext highlighter-rouge">helper_method</code> episode 2’s <code class="language-plaintext highlighter-rouge">Authentication</code> concern exposes to views (it’s just <code class="language-plaintext highlighter-rouge">resume_session</code>, reused to answer “is anyone signed in” without triggering a redirect the way <code class="language-plaintext highlighter-rouge">require_authentication</code> would). Signed in, the link goes straight to <code class="language-plaintext highlighter-rouge">new_service_path</code> — the form. Signed out, it goes to <code class="language-plaintext highlighter-rouge">new_registration_path</code> — sign-up — instead of straight to the service form, which would just immediately bounce them to the sign-in page anyway via <code class="language-plaintext highlighter-rouge">require_authentication</code>. Same eventual destination either way; one fewer redirect for the common case of a visitor who isn’t signed in yet.</li>
</ul>

<h2 id="the-offer-a-service-form">The “offer a service” form</h2>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/services/new.html.erb %&gt;</span>
<span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"max-w-xl mx-auto"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;h1</span> <span class="na">class=</span><span class="s">"text-3xl font-bold text-gray-900"</span><span class="nt">&gt;</span>Offer a service<span class="nt">&lt;/h1&gt;</span>
  <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"mt-2 text-gray-600"</span><span class="nt">&gt;</span>Tell your neighbours what you can help with.<span class="nt">&lt;/p&gt;</span>

  <span class="cp">&lt;%=</span> <span class="n">form_with</span> <span class="ss">model: </span><span class="vi">@service</span><span class="p">,</span> <span class="ss">url: </span><span class="n">services_path</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"mt-8 space-y-5"</span> <span class="k">do</span> <span class="o">|</span><span class="n">form</span><span class="o">|</span> <span class="cp">%&gt;</span>
    <span class="cp">&lt;%</span> <span class="k">if</span> <span class="vi">@service</span><span class="p">.</span><span class="nf">errors</span><span class="p">.</span><span class="nf">any?</span> <span class="cp">%&gt;</span>
      <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"rounded-md bg-red-50 px-4 py-3 text-sm text-red-700"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;ul</span> <span class="na">class=</span><span class="s">"list-disc list-inside"</span><span class="nt">&gt;</span>
          <span class="cp">&lt;%</span> <span class="vi">@service</span><span class="p">.</span><span class="nf">errors</span><span class="p">.</span><span class="nf">full_messages</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">message</span><span class="o">|</span> <span class="cp">%&gt;</span>
            <span class="nt">&lt;li&gt;</span><span class="cp">&lt;%=</span> <span class="n">message</span> <span class="cp">%&gt;</span><span class="nt">&lt;/li&gt;</span>
          <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
        <span class="nt">&lt;/ul&gt;</span>
      <span class="nt">&lt;/div&gt;</span>
    <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>

    <span class="nt">&lt;div&gt;</span>
      <span class="cp">&lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">label</span> <span class="ss">:title</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"block text-sm font-medium text-gray-700"</span> <span class="cp">%&gt;</span>
      <span class="cp">&lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">text_field</span> <span class="ss">:title</span><span class="p">,</span> <span class="ss">placeholder: </span><span class="s2">"Guitar lessons for beginners"</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;/div&gt;</span>

    <span class="nt">&lt;div&gt;</span>
      <span class="cp">&lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">label</span> <span class="ss">:category_id</span><span class="p">,</span> <span class="s2">"Category"</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"block text-sm font-medium text-gray-700"</span> <span class="cp">%&gt;</span>
      <span class="cp">&lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">collection_select</span> <span class="ss">:category_id</span><span class="p">,</span> <span class="no">Category</span><span class="p">.</span><span class="nf">order</span><span class="p">(</span><span class="ss">:name</span><span class="p">),</span> <span class="ss">:id</span><span class="p">,</span> <span class="ss">:name</span><span class="p">,</span> <span class="p">{</span> <span class="ss">prompt: </span><span class="s2">"Choose a category"</span> <span class="p">},</span> <span class="ss">class: </span><span class="s2">"mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;/div&gt;</span>

    <span class="nt">&lt;div&gt;</span>
      <span class="cp">&lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">label</span> <span class="ss">:description</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"block text-sm font-medium text-gray-700"</span> <span class="cp">%&gt;</span>
      <span class="cp">&lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">text_area</span> <span class="ss">:description</span><span class="p">,</span> <span class="ss">rows: </span><span class="mi">4</span><span class="p">,</span> <span class="ss">placeholder: </span><span class="s2">"What you offer, your experience, anything a neighbour should know."</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;/div&gt;</span>

    <span class="nt">&lt;div&gt;</span>
      <span class="cp">&lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">label</span> <span class="ss">:price</span><span class="p">,</span> <span class="s2">"Price (EUR)"</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"block text-sm font-medium text-gray-700"</span> <span class="cp">%&gt;</span>
      <span class="cp">&lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">text_field</span> <span class="ss">:price</span><span class="p">,</span> <span class="ss">placeholder: </span><span class="s2">"45.00"</span><span class="p">,</span> <span class="ss">inputmode: </span><span class="s2">"decimal"</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"mt-1 block w-40 rounded-md border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;/div&gt;</span>

    <span class="cp">&lt;%=</span> <span class="n">form</span><span class="p">.</span><span class="nf">submit</span> <span class="s2">"Publish"</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"rounded-md bg-indigo-600 px-4 py-2 text-white font-medium hover:bg-indigo-700 cursor-pointer"</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">form_with model: @service, url: services_path, ...</code> — <code class="language-plaintext highlighter-rouge">model: @service</code> is what makes this one form work for both a brand-new, unsaved <code class="language-plaintext highlighter-rouge">Service</code> (from <code class="language-plaintext highlighter-rouge">ServicesController#new</code>) and, later, one that failed to save and is being re-rendered with errors attached (from <code class="language-plaintext highlighter-rouge">#create</code>’s <code class="language-plaintext highlighter-rouge">render :new</code>) — Rails inspects whether the record is persisted to decide the form’s HTTP method, though since the routes only define <code class="language-plaintext highlighter-rouge">create</code>, this form only ever needs to POST. <code class="language-plaintext highlighter-rouge">url: services_path</code> is explicit here rather than left to be inferred, pointing the submission at the <code class="language-plaintext highlighter-rouge">services#create</code> route regardless.</li>
  <li><code class="language-plaintext highlighter-rouge">&lt;% if @service.errors.any? %&gt; ... @service.errors.full_messages.each ... &lt;% end %&gt;</code> — after a failed <code class="language-plaintext highlighter-rouge">create</code>, <code class="language-plaintext highlighter-rouge">@service</code> carries whatever the user typed <em>and</em> the specific validation failures attached to it by <code class="language-plaintext highlighter-rouge">save</code>. <code class="language-plaintext highlighter-rouge">errors.full_messages</code> turns those into ready-to-read strings like “Title can’t be blank” — this is exactly the block that rendered the error lists shown further down this post.</li>
  <li><code class="language-plaintext highlighter-rouge">form.label :title, ...</code> / <code class="language-plaintext highlighter-rouge">form.text_field :title, ...</code> — a standard labeled text input. <code class="language-plaintext highlighter-rouge">form.label :title</code> without an explicit label text infers “Title” from the attribute name automatically (via the same <code class="language-plaintext highlighter-rouge">humanize</code> mechanism discussed in the I18n section below); <code class="language-plaintext highlighter-rouge">placeholder:</code> is just an HTML attribute passed straight through.</li>
  <li><code class="language-plaintext highlighter-rouge">form.collection_select :category_id, Category.order(:name), :id, :name, { prompt: "Choose a category" }, class: "..."</code> — this is the one helper in the form worth slowing down on, because it takes five separate arguments before the HTML options hash:
    <ol>
      <li><code class="language-plaintext highlighter-rouge">:category_id</code> — the attribute being set on submit (matches <code class="language-plaintext highlighter-rouge">belongs_to :category</code>’s foreign key column).</li>
      <li><code class="language-plaintext highlighter-rouge">Category.order(:name)</code> — the collection of records to build <code class="language-plaintext highlighter-rouge">&lt;option&gt;</code> tags from, alphabetized so the dropdown doesn’t list categories in whatever order they happened to be seeded.</li>
      <li><code class="language-plaintext highlighter-rouge">:id</code> — the <em>value</em> method: for each <code class="language-plaintext highlighter-rouge">Category</code> in the collection, call <code class="language-plaintext highlighter-rouge">.id</code> to get what actually gets submitted as <code class="language-plaintext highlighter-rouge">category_id</code>.</li>
      <li><code class="language-plaintext highlighter-rouge">:name</code> — the <em>text</em> method: call <code class="language-plaintext highlighter-rouge">.name</code> to get what’s displayed to a human inside the dropdown.</li>
      <li><code class="language-plaintext highlighter-rouge">{ prompt: "Choose a category" }</code> — options for the select itself; <code class="language-plaintext highlighter-rouge">prompt:</code> inserts a disabled, unselected placeholder option with that text, so the dropdown doesn’t silently default to the first category in the list if someone submits without touching it.
Only after that fifth argument does the ordinary <code class="language-plaintext highlighter-rouge">class: "..."</code> HTML-attributes hash appear — a <code class="language-plaintext highlighter-rouge">collection_select</code> call always separates “how to build the options” from “what HTML attributes to put on the <code class="language-plaintext highlighter-rouge">&lt;select&gt;</code> tag” this way.</li>
    </ol>
  </li>
  <li><code class="language-plaintext highlighter-rouge">form.label :price, "Price (EUR)", ...</code> — here the label text <em>is</em> given explicitly (“Price (EUR)”), overriding what <code class="language-plaintext highlighter-rouge">humanize</code>-from-attribute-name would have produced (“Price”), because the form needs to communicate the currency and the model attribute name has no way to carry that on its own.</li>
  <li><code class="language-plaintext highlighter-rouge">form.text_field :price, placeholder: "45.00", inputmode: "decimal", ...</code> — this is the field that calls <code class="language-plaintext highlighter-rouge">Service#price=</code> on submit, not <code class="language-plaintext highlighter-rouge">price_cents=</code> — the view genuinely never mentions cents anywhere. <code class="language-plaintext highlighter-rouge">inputmode: "decimal"</code> is a plain HTML attribute (nothing Rails-specific) that hints mobile keyboards to show a numeric keypad with a decimal point instead of the full alphabetic keyboard.</li>
  <li><code class="language-plaintext highlighter-rouge">form.submit "Publish", ...</code> — renders an <code class="language-plaintext highlighter-rouge">&lt;input type="submit"&gt;</code> with the given label; <code class="language-plaintext highlighter-rouge">cursor-pointer</code> is purely cosmetic, since a submit button is clickable by default but browsers don’t always render the pointer cursor on it without being told to.</li>
</ul>

<h2 id="the-services-listing">The services listing</h2>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/services/index.html.erb %&gt;</span>
<span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"max-w-3xl mx-auto w-full"</span><span class="nt">&gt;</span>
  <span class="cp">&lt;%</span> <span class="k">if</span> <span class="n">notice</span> <span class="o">=</span> <span class="n">flash</span><span class="p">[</span><span class="ss">:notice</span><span class="p">]</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"py-2 px-3 bg-green-50 mb-5 text-green-700 font-medium rounded-lg inline-block"</span> <span class="na">id=</span><span class="s">"notice"</span><span class="nt">&gt;</span><span class="cp">&lt;%=</span> <span class="n">notice</span> <span class="cp">%&gt;</span><span class="nt">&lt;/p&gt;</span>
  <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>

  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"flex items-center justify-between"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;h1</span> <span class="na">class=</span><span class="s">"text-3xl font-bold text-gray-900"</span><span class="nt">&gt;</span>Services near you<span class="nt">&lt;/h1&gt;</span>
    <span class="cp">&lt;%</span> <span class="k">if</span> <span class="n">authenticated?</span> <span class="cp">%&gt;</span>
      <span class="cp">&lt;%=</span> <span class="n">link_to</span> <span class="s2">"Offer a service"</span><span class="p">,</span> <span class="n">new_service_path</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"rounded-md bg-indigo-600 px-4 py-2 text-white font-medium hover:bg-indigo-700"</span> <span class="cp">%&gt;</span>
    <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;/div&gt;</span>

  <span class="cp">&lt;%</span> <span class="k">if</span> <span class="vi">@services</span><span class="p">.</span><span class="nf">none?</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"mt-8 text-gray-500"</span><span class="nt">&gt;</span>No services yet — be the first to offer one.<span class="nt">&lt;/p&gt;</span>
  <span class="cp">&lt;%</span> <span class="k">else</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"mt-8 space-y-4"</span><span class="nt">&gt;</span>
      <span class="cp">&lt;%</span> <span class="vi">@services</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">service</span><span class="o">|</span> <span class="cp">%&gt;</span>
        <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"rounded-lg border border-gray-200 p-5"</span><span class="nt">&gt;</span>
          <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"flex items-start justify-between gap-4"</span><span class="nt">&gt;</span>
            <span class="nt">&lt;div&gt;</span>
              <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"text-xs font-semibold uppercase tracking-wide text-indigo-600"</span><span class="nt">&gt;</span><span class="cp">&lt;%=</span> <span class="n">service</span><span class="p">.</span><span class="nf">category</span><span class="p">.</span><span class="nf">name</span> <span class="cp">%&gt;</span><span class="nt">&lt;/p&gt;</span>
              <span class="nt">&lt;h2</span> <span class="na">class=</span><span class="s">"mt-1 text-lg font-semibold text-gray-900"</span><span class="nt">&gt;</span><span class="cp">&lt;%=</span> <span class="n">service</span><span class="p">.</span><span class="nf">title</span> <span class="cp">%&gt;</span><span class="nt">&lt;/h2&gt;</span>
              <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"mt-1 text-sm text-gray-500"</span><span class="nt">&gt;</span>by <span class="cp">&lt;%=</span> <span class="n">service</span><span class="p">.</span><span class="nf">user</span><span class="p">.</span><span class="nf">email_address</span> <span class="cp">%&gt;</span><span class="nt">&lt;/p&gt;</span>
            <span class="nt">&lt;/div&gt;</span>
            <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"whitespace-nowrap text-lg font-semibold text-gray-900"</span><span class="nt">&gt;</span>
              <span class="cp">&lt;%=</span> <span class="n">number_to_currency</span><span class="p">(</span><span class="n">service</span><span class="p">.</span><span class="nf">price</span><span class="p">)</span> <span class="cp">%&gt;</span>
            <span class="nt">&lt;/p&gt;</span>
          <span class="nt">&lt;/div&gt;</span>
          <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"mt-3 text-gray-600"</span><span class="nt">&gt;</span><span class="cp">&lt;%=</span> <span class="n">service</span><span class="p">.</span><span class="nf">description</span> <span class="cp">%&gt;</span><span class="nt">&lt;/p&gt;</span>
        <span class="nt">&lt;/div&gt;</span>
      <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;/div&gt;</span>
  <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<ul>
  <li><code class="language-plaintext highlighter-rouge">&lt;% if notice = flash[:notice] %&gt; ... &lt;% end %&gt;</code> — this is a single <code class="language-plaintext highlighter-rouge">=</code> on purpose, an assignment used as a condition, not a <code class="language-plaintext highlighter-rouge">==</code> comparison. <code class="language-plaintext highlighter-rouge">flash[:notice]</code> is read once, assigned to a local <code class="language-plaintext highlighter-rouge">notice</code>, and that same local is reused inside the block — this is the exact pattern already used in <code class="language-plaintext highlighter-rouge">sessions/new.html.erb</code> from episode 2, kept consistent here rather than introducing a different idiom. It’s what makes the “Your service is live.” message from the controller’s <code class="language-plaintext highlighter-rouge">redirect_to ..., notice: "..."</code> actually show up on screen — nothing renders <code class="language-plaintext highlighter-rouge">flash</code> automatically anywhere in this layout, each view that wants to show it does so explicitly.</li>
  <li><code class="language-plaintext highlighter-rouge">&lt;% if authenticated? %&gt; ... &lt;% end %&gt;</code> around the “Offer a service” button — a signed-out visitor browsing the public index sees the listings but not an invitation to post one; the button only appears once someone is actually signed in.</li>
  <li><code class="language-plaintext highlighter-rouge">&lt;% if @services.none? %&gt; ... &lt;% else %&gt; ... &lt;% end %&gt;</code> — <code class="language-plaintext highlighter-rouge">.none?</code> is a plain ActiveRecord/Enumerable query, true when the relation has zero records. This is the empty-state message, shown instead of an empty page with no explanation the first time the app runs with no listings yet.</li>
  <li><code class="language-plaintext highlighter-rouge">&lt;% @services.each do |service| %&gt;</code> — iterates the eager-loaded relation from the controller. Because <code class="language-plaintext highlighter-rouge">Service.includes(:category, :user)</code> already pulled every category and user into memory alongside the services themselves, <code class="language-plaintext highlighter-rouge">service.category.name</code> and <code class="language-plaintext highlighter-rouge">service.user.email_address</code> inside this loop don’t trigger any additional queries — this is the payoff of the <code class="language-plaintext highlighter-rouge">includes</code> call discussed in the controller section landing here, in the view, where the N+1 would otherwise actually happen.</li>
  <li><code class="language-plaintext highlighter-rouge">service.category.name</code>, <code class="language-plaintext highlighter-rouge">service.title</code>, <code class="language-plaintext highlighter-rouge">service.user.email_address</code>, <code class="language-plaintext highlighter-rouge">service.description</code> — straightforward attribute and association reads.</li>
  <li><code class="language-plaintext highlighter-rouge">number_to_currency(service.price)</code> — a Rails view helper (from <code class="language-plaintext highlighter-rouge">ActionView::Helpers::NumberHelper</code>) that formats a plain number as currency: <code class="language-plaintext highlighter-rouge">42.5</code> becomes <code class="language-plaintext highlighter-rouge">"€42.50"</code> — the correct two decimal places and currency symbol, not whatever number of digits the float happens to have. This calls <code class="language-plaintext highlighter-rouge">service.price</code>, the euros-facing virtual reader defined on the model — not <code class="language-plaintext highlighter-rouge">service.price_cents</code> — so the number on screen is genuinely <code class="language-plaintext highlighter-rouge">42.5</code>, formatted, never <code class="language-plaintext highlighter-rouge">4250</code>.</li>
</ul>

<p>Left completely unconfigured, <code class="language-plaintext highlighter-rouge">number_to_currency</code> defaults to US dollars — VicinoTe’s currency is euros, so that default needed overriding once, globally, rather than passing <code class="language-plaintext highlighter-rouge">unit: "€"</code> at every call site:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/locales/en.yml</span>
<span class="na">en</span><span class="pi">:</span>
  <span class="na">number</span><span class="pi">:</span>
    <span class="na">currency</span><span class="pi">:</span>
      <span class="na">format</span><span class="pi">:</span>
        <span class="na">unit</span><span class="pi">:</span> <span class="s2">"</span><span class="s">€"</span>
        <span class="na">format</span><span class="pi">:</span> <span class="s2">"</span><span class="s">%u%n"</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">unit:</code> is the symbol itself. <code class="language-plaintext highlighter-rouge">format:</code> is the template that arranges it relative to the number — <code class="language-plaintext highlighter-rouge">%u</code> is the unit, <code class="language-plaintext highlighter-rouge">%n</code> is the formatted number, so <code class="language-plaintext highlighter-rouge">"%u%n"</code> means “symbol, then number, no space,” which is what produces <code class="language-plaintext highlighter-rouge">€42.50</code> rather than <code class="language-plaintext highlighter-rouge">€ 42.50</code> or <code class="language-plaintext highlighter-rouge">42.50€</code>. Both <code class="language-plaintext highlighter-rouge">number_to_currency</code> calls in the app — there’s only the one, in <code class="language-plaintext highlighter-rouge">index.html.erb</code> — pick this up automatically with no argument changes needed, because the default itself moved, not the call site.</p>

<h2 id="a-views-only-gotcha-the-error-said-price-cents">A views-only gotcha: the error said “price cents”</h2>

<p>Submitting the form empty for the first time, before fixing this, produced:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Category must exist
Title can't be blank
Description can't be blank
Price cents can't be blank
Price cents is not a number
</code></pre></div></div>

<p>Everything else reads naturally — “Title can’t be blank” — because Rails derives the human-readable label from the attribute name via <code class="language-plaintext highlighter-rouge">humanize</code>. But the attribute actually being validated is <code class="language-plaintext highlighter-rouge">price_cents</code>, not <code class="language-plaintext highlighter-rouge">price</code>, so that’s the name that leaked into the message. A user filling out this form has never heard of <code class="language-plaintext highlighter-rouge">price_cents</code>; the form field right below the error just says “Price (EUR)”.</p>

<p>The fix is a one-line I18n override, not a code change to the model — the validation is correct, only its rendered name was wrong:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/locales/en.yml</span>
<span class="na">en</span><span class="pi">:</span>
  <span class="na">activerecord</span><span class="pi">:</span>
    <span class="na">attributes</span><span class="pi">:</span>
      <span class="na">service</span><span class="pi">:</span>
        <span class="na">price_cents</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Price"</span>
</code></pre></div></div>

<p>This is Rails’ I18n lookup for <code class="language-plaintext highlighter-rouge">human_attribute_name</code>: before falling back to auto-<code class="language-plaintext highlighter-rouge">humanize</code>-ing an attribute name, ActiveRecord checks <code class="language-plaintext highlighter-rouge">activerecord.attributes.&lt;model&gt;.&lt;attribute&gt;</code> in the locale files, and uses whatever string is found there verbatim, for every message that mentions that attribute — validation errors, <code class="language-plaintext highlighter-rouge">form.label</code> with no explicit text, anywhere.</p>

<p>One detail that cost a second pass: writing the override as <code class="language-plaintext highlighter-rouge">price</code> (lowercase) produced “price can’t be blank” — lowercase, inconsistent with “Title” and “Description” right next to it. Rails’ default <code class="language-plaintext highlighter-rouge">humanize</code> capitalizes the first letter automatically as part of what it does; a custom I18n string is used exactly as written, with no capitalization applied on top of it. The fix was just capitalizing the override itself, <code class="language-plaintext highlighter-rouge">"Price"</code>.</p>

<h2 id="trying-it">Trying it</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/dev
</code></pre></div></div>

<p>Sign up (or sign in), click “Offer a service”, fill in a title, pick a category, write a description, and a price like <code class="language-plaintext highlighter-rouge">42.50</code>. Submit, and it redirects to <code class="language-plaintext highlighter-rouge">/services</code> with “Your service is live.” in a flash banner, the listing right below it — category, title, whoever posted it, description, and <code class="language-plaintext highlighter-rouge">€42.50</code>, not <code class="language-plaintext highlighter-rouge">4250</code>. Sign out and visit <code class="language-plaintext highlighter-rouge">/services</code> directly: still there, still public. Try <code class="language-plaintext highlighter-rouge">/services/new</code> while signed out and it redirects to sign-in, same as any other protected page. Submit the form with everything blank and every field’s own validation message shows up, in plain, correctly-capitalized English, “price_cents” nowhere in sight.</p>

<h2 id="whats-next">What’s next</h2>

<p>Episode 4 builds <code class="language-plaintext highlighter-rouge">Booking</code> — the record of an agreement between two users, and the flow that actually lets someone book a service that’s been listed.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Episode 2 got accounts working end to end — sign up, sign in, sign out, password reset — and left it there deliberately: nothing touched Service or Booking yet. This episode is where the marketplace actually starts being a marketplace: a signed-in user can list something they offer, and anyone can browse what’s listed.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://antoninoscaffidi.github.io/assets/images/vicinote-ep3-banner.png" /><media:content medium="image" url="https://antoninoscaffidi.github.io/assets/images/vicinote-ep3-banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">Streaming RubyLLM Responses with Turbo Streams</title><link href="https://antoninoscaffidi.github.io/streaming-responses-with-turbo-streams/" rel="alternate" type="text/html" title="Streaming RubyLLM Responses with Turbo Streams" /><published>2026-08-19T05:00:00+00:00</published><updated>2026-08-19T05:00:00+00:00</updated><id>https://antoninoscaffidi.github.io/streaming-responses-with-turbo-streams</id><content type="html" xml:base="https://antoninoscaffidi.github.io/streaming-responses-with-turbo-streams/"><![CDATA[<p>In <a href="/persisting-conversations-with-activerecord/">episode 3</a> conversations started surviving a page refresh, but the request itself was still a black box: you hit Send, the whole page just sat there — no spinner, nothing — until the entire reply had come back from the model, and only then did the page redirect and show it, all at once. For a one-sentence answer that’s a second or two of nothing. For a longer one, it can be five, six seconds of a page that looks frozen.</p>

<p>This episode fixes that: the reply now types itself onto the page as the model generates it, token by token, without a full page reload. Along the way we’re also closing a TODO left over from episode 3 — there was no way to start a fresh conversation; <code class="language-plaintext highlighter-rouge">session[:conversation_id]</code> just kept the same one forever.</p>

<p>Code is tagged <a href="https://github.com/AntoninoScaffidi/ai-with-ruby-demo/tree/episode-4"><code class="language-plaintext highlighter-rouge">episode-4</code></a> in the <a href="https://github.com/AntoninoScaffidi/ai-with-ruby-demo">ai-with-ruby-demo</a> repo. Fair warning up front: this episode hit two real bugs while building it, and I’m leaving both in the post with the exact error messages and the exact reasoning that led to the fix — that debugging trail is arguably more useful than the final code on its own.</p>

<h2 id="how-rubyllm-streams-a-response-from-the-inside">How RubyLLM streams a response, from the inside</h2>

<p>Before touching any of our own code, it’s worth opening RubyLLM’s source and reading <code class="language-plaintext highlighter-rouge">ask</code> and <code class="language-plaintext highlighter-rouge">complete</code>, because everything we build in this episode leans on their exact behavior. Here’s the relevant part of <a href="https://github.com/crmne/ruby_llm"><code class="language-plaintext highlighter-rouge">chat_methods.rb</code></a>, the module <code class="language-plaintext highlighter-rouge">acts_as_chat</code> mixes into <code class="language-plaintext highlighter-rouge">Conversation</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">ask</span><span class="p">(</span><span class="n">message</span> <span class="o">=</span> <span class="kp">nil</span><span class="p">,</span> <span class="ss">with: </span><span class="kp">nil</span><span class="p">,</span> <span class="o">&amp;</span><span class="p">)</span>
  <span class="n">add_message</span><span class="p">(</span><span class="ss">role: :user</span><span class="p">,</span> <span class="ss">content: </span><span class="n">build_content</span><span class="p">(</span><span class="n">message</span><span class="p">,</span> <span class="n">with</span><span class="p">))</span>
  <span class="n">complete</span><span class="p">(</span><span class="o">&amp;</span><span class="p">)</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">complete</span><span class="p">(</span><span class="o">...</span><span class="p">)</span>
  <span class="n">to_llm</span><span class="p">.</span><span class="nf">complete</span><span class="p">(</span><span class="o">...</span><span class="p">)</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">setup_persistence_callbacks</span>
  <span class="k">return</span> <span class="vi">@chat</span> <span class="k">if</span> <span class="vi">@chat</span><span class="p">.</span><span class="nf">instance_variable_get</span><span class="p">(</span><span class="ss">:@_persistence_callbacks_setup</span><span class="p">)</span>

  <span class="vi">@chat</span><span class="p">.</span><span class="nf">before_message</span> <span class="p">{</span> <span class="n">persist_new_message</span> <span class="p">}</span>
  <span class="vi">@chat</span><span class="p">.</span><span class="nf">after_message</span> <span class="p">{</span> <span class="o">|</span><span class="n">msg</span><span class="o">|</span> <span class="n">persist_message_completion</span><span class="p">(</span><span class="n">msg</span><span class="p">)</span> <span class="p">}</span>

  <span class="vi">@chat</span><span class="p">.</span><span class="nf">instance_variable_set</span><span class="p">(</span><span class="ss">:@_persistence_callbacks_setup</span><span class="p">,</span> <span class="kp">true</span><span class="p">)</span>
  <span class="vi">@chat</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">persist_new_message</span>
  <span class="vi">@message</span> <span class="o">=</span> <span class="n">messages_association</span><span class="p">.</span><span class="nf">create!</span><span class="p">(</span><span class="ss">role: :assistant</span><span class="p">,</span> <span class="ss">content: </span><span class="s1">''</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Four things fall out of this that matter a lot for what we’re about to build:</p>

<ol>
  <li><strong><code class="language-plaintext highlighter-rouge">ask</code> persists the user’s message itself</strong>, synchronously, before doing anything else. If your own code <em>also</em> inserts a user <code class="language-plaintext highlighter-rouge">Message</code> row before calling <code class="language-plaintext highlighter-rouge">ask</code>, you get two rows for one question — we’ll see exactly this mistake below.</li>
  <li><strong>The empty assistant message is created before any text exists.</strong> <code class="language-plaintext highlighter-rouge">persist_new_message</code> runs in a <code class="language-plaintext highlighter-rouge">before_message</code> callback and creates a <code class="language-plaintext highlighter-rouge">Message</code> with <code class="language-plaintext highlighter-rouge">content: ''</code>. So by the time the model starts talking, there’s already a row in the database — with an id — waiting to be filled in.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">ask</code>/<code class="language-plaintext highlighter-rouge">complete</code> accept a block</strong>, and that block is RubyLLM’s streaming hook — it’s invoked once per chunk of text as the provider streams it back, well before the reply is complete.</li>
  <li><strong>The final content is written once, at the end</strong>, by <code class="language-plaintext highlighter-rouge">persist_message_completion</code>, in an <code class="language-plaintext highlighter-rouge">after_message</code> callback — a normal <code class="language-plaintext highlighter-rouge">UPDATE</code> on the same row that was created empty.</li>
</ol>

<p>Put together: one <code class="language-plaintext highlighter-rouge">.ask(content, &amp;block)</code> call creates two rows (user, then empty assistant), then calls your block repeatedly as chunks arrive, then does one final <code class="language-plaintext highlighter-rouge">UPDATE</code> with the complete text and the token counts. Nothing about persistence changes if you pass a block — the only thing a block adds is a callback fired per chunk. Streaming, in other words, isn’t a different code path; it’s the exact same <code class="language-plaintext highlighter-rouge">ask</code> we’ve used since episode 3, with a block attached.</p>

<h2 id="the-plan-a-background-job-not-a-slower-controller-action">The plan: a background job, not a slower controller action</h2>

<p>The request/response cycle is fundamentally the wrong shape for this. An HTTP response has to finish before the browser can render it — you can’t dribble a Rails view out a few words at a time over a normal <code class="language-plaintext highlighter-rouge">render</code>. So the LLM call has to move <em>off</em> the request entirely, into a background job, and the job has to push each chunk to the browser through a side channel as it arrives. In a Rails 8 app, that side channel is Turbo Streams delivered over Action Cable — no separate JavaScript framework, no manual WebSocket wiring.</p>

<p>Rather than inventing this pattern by hand, I went to see how RubyLLM itself recommends doing it. The gem ships a generator, <code class="language-plaintext highlighter-rouge">ruby_llm:chat_ui</code>, built for exactly this. Running it with <code class="language-plaintext highlighter-rouge">--pretend</code> (nothing gets written to disk) against our own app, remapped to our actual model name:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails generate ruby_llm:chat_ui chat:Conversation <span class="nt">--pretend</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>create  app/views/conversations/{index,new,show,_conversation,_form}.html.erb
create  app/views/messages/{_assistant,_user,_system,_tool,_error,_content,_form}.html.erb
create  app/views/messages/tool_calls/_default.html.erb
create  app/views/messages/tool_results/_default.html.erb
create  app/views/messages/create.turbo_stream.erb
create  app/views/models/{index,show,_model}.html.erb
create  app/controllers/conversations_controller.rb
create  app/controllers/messages_controller.rb
create  app/controllers/models_controller.rb
create  app/jobs/conversation_response_job.rb
insert  app/models/message.rb
 route  resources :conversations { resources :messages, only: [:create] }
 route  resources :models, only: [:index, :show] { collection { post :refresh } }
</code></pre></div></div>

<p>That’s a full multi-conversation CRUD scaffold — an index and a show page per conversation, a controller for browsing available <code class="language-plaintext highlighter-rouge">Model</code> records, views for tool calls and tool results (for a tool-calling episode we haven’t written yet). Our app deliberately isn’t shaped like that: since episode 3 there’s exactly one implicit conversation per browser session, no listing, no separate show page. Adopting the whole scaffold would mean rewriting the app’s shape to fit the generator, not the other way around.</p>

<p>So instead of running it, I read the templates and pulled out the three pieces that are actually about streaming, adapted to keep episode 3’s session-based, single-conversation design:</p>

<ul>
  <li>A model concern that lets a <code class="language-plaintext highlighter-rouge">Message</code> broadcast itself over Turbo Streams.</li>
  <li>A background job that calls <code class="language-plaintext highlighter-rouge">ask</code> with a block and forwards each chunk to the browser.</li>
  <li>A controller that enqueues the job and gets out of the way immediately.</li>
</ul>

<h2 id="making-message-broadcast-itself">Making Message broadcast itself</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/message.rb</span>
<span class="k">class</span> <span class="nc">Message</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">acts_as_message</span> <span class="ss">chat: :conversation</span>

  <span class="n">broadcasts_to</span> <span class="o">-&gt;</span><span class="p">(</span><span class="n">message</span><span class="p">)</span> <span class="p">{</span> <span class="s2">"conversation_</span><span class="si">#{</span><span class="n">message</span><span class="p">.</span><span class="nf">conversation_id</span><span class="si">}</span><span class="s2">"</span> <span class="p">},</span> <span class="ss">inserts_by: :append</span>

  <span class="k">def</span> <span class="nf">broadcast_append_chunk</span><span class="p">(</span><span class="n">content</span><span class="p">)</span>
    <span class="n">broadcast_append_to</span> <span class="s2">"conversation_</span><span class="si">#{</span><span class="n">conversation_id</span><span class="si">}</span><span class="s2">"</span><span class="p">,</span>
      <span class="ss">target: </span><span class="s2">"message_</span><span class="si">#{</span><span class="nb">id</span><span class="si">}</span><span class="s2">_content"</span><span class="p">,</span>
      <span class="ss">content: </span><span class="no">ERB</span><span class="o">::</span><span class="no">Util</span><span class="p">.</span><span class="nf">html_escape</span><span class="p">(</span><span class="n">content</span><span class="p">.</span><span class="nf">to_s</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">broadcasts_to</code> is Turbo Streams’ own ActiveRecord integration (from the <code class="language-plaintext highlighter-rouge">turbo-rails</code> gem, not RubyLLM), and it’s doing more than the one line suggests. Its actual definition, in <a href="https://github.com/hotwired/turbo-rails"><code class="language-plaintext highlighter-rouge">turbo/broadcastable.rb</code></a>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">broadcasts_to</span><span class="p">(</span><span class="n">stream</span><span class="p">,</span> <span class="ss">inserts_by: :append</span><span class="p">,</span> <span class="ss">target: </span><span class="n">broadcast_target_default</span><span class="p">,</span> <span class="o">**</span><span class="n">rendering</span><span class="p">)</span>
  <span class="n">after_create_commit</span>  <span class="o">-&gt;</span> <span class="p">{</span> <span class="n">broadcast_action_later_to</span><span class="p">(</span><span class="n">stream</span><span class="p">.</span><span class="nf">try</span><span class="p">(</span><span class="ss">:call</span><span class="p">,</span> <span class="nb">self</span><span class="p">)</span> <span class="o">||</span> <span class="nb">send</span><span class="p">(</span><span class="n">stream</span><span class="p">),</span> <span class="ss">action: </span><span class="n">inserts_by</span><span class="p">,</span> <span class="ss">target: </span><span class="n">target</span><span class="p">.</span><span class="nf">try</span><span class="p">(</span><span class="ss">:call</span><span class="p">,</span> <span class="nb">self</span><span class="p">)</span> <span class="o">||</span> <span class="n">target</span><span class="p">,</span> <span class="o">**</span><span class="n">rendering</span><span class="p">)</span> <span class="p">}</span>
  <span class="n">after_update_commit</span>  <span class="o">-&gt;</span> <span class="p">{</span> <span class="n">broadcast_replace_later_to</span><span class="p">(</span><span class="n">stream</span><span class="p">.</span><span class="nf">try</span><span class="p">(</span><span class="ss">:call</span><span class="p">,</span> <span class="nb">self</span><span class="p">)</span> <span class="o">||</span> <span class="nb">send</span><span class="p">(</span><span class="n">stream</span><span class="p">),</span> <span class="o">**</span><span class="n">rendering</span><span class="p">)</span> <span class="p">}</span>
  <span class="n">after_destroy_commit</span> <span class="o">-&gt;</span> <span class="p">{</span> <span class="n">broadcast_remove_to</span><span class="p">(</span><span class="n">stream</span><span class="p">.</span><span class="nf">try</span><span class="p">(</span><span class="ss">:call</span><span class="p">,</span> <span class="nb">self</span><span class="p">)</span> <span class="o">||</span> <span class="nb">send</span><span class="p">(</span><span class="n">stream</span><span class="p">))</span> <span class="p">}</span>
<span class="k">end</span>
</code></pre></div></div>

<p>So one call to <code class="language-plaintext highlighter-rouge">broadcasts_to</code> wires up <em>three</em> callbacks, not one:</p>

<ul>
  <li><strong>On create</strong>, append a rendered copy of the message into the stream, at <code class="language-plaintext highlighter-rouge">target:</code> (defaulting to <code class="language-plaintext highlighter-rouge">model_name.plural</code>, i.e. <code class="language-plaintext highlighter-rouge">"messages"</code> — the id of a container element the page needs to provide).</li>
  <li><strong>On update</strong>, replace the message’s own element (default target: itself, <code class="language-plaintext highlighter-rouge">dom_id(message)</code>) with a freshly rendered copy.</li>
  <li><strong>On destroy</strong>, remove it.</li>
</ul>

<p>That middle one matters a lot here, and it’s easy to miss on a first read: every time a <code class="language-plaintext highlighter-rouge">Message</code> row is updated — including the final <code class="language-plaintext highlighter-rouge">persist_message_completion</code> update that writes the complete text — the <em>whole message bubble</em> gets replaced with a fresh render. That’s a nice safety net: it means the very last update always overwrites whatever the incremental appends left behind with an authoritative, freshly-rendered copy from the database. It also means an incidental update (as we’ll see below) can trigger a broadcast you didn’t ask for.</p>

<p><code class="language-plaintext highlighter-rouge">broadcast_append_chunk</code> is ours, not Turbo’s — it’s not appending a new <em>message</em>, it’s appending raw text into a message that already exists, targeting a specific inner <code class="language-plaintext highlighter-rouge">&lt;div&gt;</code> (<code class="language-plaintext highlighter-rouge">message_#{id}_content</code>) rather than the outer message container. <code class="language-plaintext highlighter-rouge">ERB::Util.html_escape</code> matters here: chunk content comes straight from the model, unescaped, and this is rendering raw HTML into the page — skip the escape and a reply containing <code class="language-plaintext highlighter-rouge">&lt;</code> or <code class="language-plaintext highlighter-rouge">&amp;</code> would corrupt the markup (or, worse, become an injection vector if the model ever echoed back something a user typed).</p>

<h2 id="the-background-job">The background job</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/jobs/conversation_response_job.rb</span>
<span class="k">class</span> <span class="nc">ConversationResponseJob</span> <span class="o">&lt;</span> <span class="no">ApplicationJob</span>
  <span class="k">def</span> <span class="nf">perform</span><span class="p">(</span><span class="n">conversation_id</span><span class="p">,</span> <span class="n">content</span><span class="p">)</span>
    <span class="n">conversation</span> <span class="o">=</span> <span class="no">Conversation</span><span class="p">.</span><span class="nf">find</span><span class="p">(</span><span class="n">conversation_id</span><span class="p">)</span>
    <span class="n">assistant_message</span> <span class="o">=</span> <span class="kp">nil</span>

    <span class="n">conversation</span><span class="p">.</span><span class="nf">ask</span><span class="p">(</span><span class="n">content</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">chunk</span><span class="o">|</span>
      <span class="k">next</span> <span class="k">if</span> <span class="n">chunk</span><span class="p">.</span><span class="nf">content</span><span class="p">.</span><span class="nf">blank?</span>

      <span class="n">assistant_message</span> <span class="o">||=</span> <span class="n">conversation</span><span class="p">.</span><span class="nf">messages</span><span class="p">.</span><span class="nf">last</span>
      <span class="n">assistant_message</span><span class="p">.</span><span class="nf">broadcast_append_chunk</span><span class="p">(</span><span class="n">chunk</span><span class="p">.</span><span class="nf">content</span><span class="p">)</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>This is almost the generator’s own job, with one deliberate change. The generator’s version calls <code class="language-plaintext highlighter-rouge">conversation.messages.last</code> <em>inside</em> the block, on every single chunk — a fresh SQL query per chunk, for a message that never changes across the whole call. We already know from the <code class="language-plaintext highlighter-rouge">ask</code>/<code class="language-plaintext highlighter-rouge">complete</code> walkthrough above exactly <em>when</em> that assistant message is created: in the <code class="language-plaintext highlighter-rouge">before_message</code> callback, which fires once, before the first chunk is ever yielded. So the row exists and its id is fixed before our block runs even once — there’s nothing to look up again after the first chunk. <code class="language-plaintext highlighter-rouge">assistant_message ||= conversation.messages.last</code> fetches it once and reuses the same in-memory record for every subsequent <code class="language-plaintext highlighter-rouge">broadcast_append_chunk</code> call.</p>

<p><code class="language-plaintext highlighter-rouge">chunk.content.blank?</code> guards against chunks that carry metadata but no text (some providers stream a final chunk with usage stats and an empty <code class="language-plaintext highlighter-rouge">content</code>) — nothing to append, and nothing to broadcast.</p>

<h2 id="the-controller-enqueue-then-get-out-of-the-way">The controller: enqueue, then get out of the way</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/chats_controller.rb</span>
<span class="k">class</span> <span class="nc">ChatsController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="k">def</span> <span class="nf">new</span>
    <span class="vi">@conversation</span> <span class="o">=</span> <span class="n">current_conversation</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">create</span>
    <span class="no">ConversationResponseJob</span><span class="p">.</span><span class="nf">perform_later</span><span class="p">(</span><span class="n">current_conversation</span><span class="p">.</span><span class="nf">id</span><span class="p">,</span> <span class="n">params</span><span class="p">[</span><span class="ss">:message</span><span class="p">])</span>

    <span class="n">respond_to</span> <span class="k">do</span> <span class="o">|</span><span class="nb">format</span><span class="o">|</span>
      <span class="nb">format</span><span class="p">.</span><span class="nf">turbo_stream</span>
      <span class="nb">format</span><span class="p">.</span><span class="nf">html</span> <span class="p">{</span> <span class="n">redirect_to</span> <span class="n">new_chat_path</span> <span class="p">}</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">destroy</span>
    <span class="n">session</span><span class="p">.</span><span class="nf">delete</span><span class="p">(</span><span class="ss">:conversation_id</span><span class="p">)</span>
    <span class="n">redirect_to</span> <span class="n">new_chat_path</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">current_conversation</span>
    <span class="no">Conversation</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">id: </span><span class="n">session</span><span class="p">[</span><span class="ss">:conversation_id</span><span class="p">])</span> <span class="o">||</span> <span class="n">create_conversation</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">create_conversation</span>
    <span class="n">conversation</span> <span class="o">=</span> <span class="no">Conversation</span><span class="p">.</span><span class="nf">create!</span>
    <span class="n">session</span><span class="p">[</span><span class="ss">:conversation_id</span><span class="p">]</span> <span class="o">=</span> <span class="n">conversation</span><span class="p">.</span><span class="nf">id</span>
    <span class="n">conversation</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The first version of this method I wrote also created the user’s <code class="language-plaintext highlighter-rouge">Message</code> row directly in the controller, before enqueuing the job — the mistake mentioned at the very top of this post. <code class="language-plaintext highlighter-rouge">ask</code> already does that internally, so the row I was adding by hand was a genuine second, duplicate user message for the same question. The fix was just deleting that line; <code class="language-plaintext highlighter-rouge">ConversationResponseJob.perform_later</code> is enough, since <code class="language-plaintext highlighter-rouge">conversation.ask(content)</code> inside the job creates it.</p>

<p><code class="language-plaintext highlighter-rouge">create</code> no longer redirects on success. Episode 3’s Post/Redirect/Get pattern doesn’t disappear — it’s still there as the <code class="language-plaintext highlighter-rouge">format.html</code> fallback for a plain, non-JS request — but with Turbo doing its job, the request that submits the form gets a <code class="language-plaintext highlighter-rouge">format.turbo_stream</code> response instead, and the page itself never reloads. Everything the user sees afterward — their own message appearing, the assistant bubble appearing empty, the text typing itself in — arrives later, over the already-open Action Cable connection, not as part of this response.</p>

<p><code class="language-plaintext highlighter-rouge">destroy</code> is the whole fix for episode 3’s leftover TODO: forget the conversation id in the session and go back to <code class="language-plaintext highlighter-rouge">new</code>, which creates a fresh one. Wiring it into the routes needed one word:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">resource</span> <span class="ss">:chat</span><span class="p">,</span> <span class="ss">only: </span><span class="p">[</span><span class="ss">:new</span><span class="p">,</span> <span class="ss">:create</span><span class="p">,</span> <span class="ss">:destroy</span><span class="p">]</span>
</code></pre></div></div>

<h2 id="the-turbo_stream-response-clearing-the-form">The turbo_stream response: clearing the form</h2>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/chats/create.turbo_stream.erb %&gt;</span>
<span class="cp">&lt;%=</span> <span class="n">turbo_stream</span><span class="p">.</span><span class="nf">replace</span> <span class="s2">"new_message"</span> <span class="k">do</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">render</span> <span class="s2">"form"</span> <span class="cp">%&gt;</span>
<span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p>This is the <em>entire</em> HTTP response body to a <code class="language-plaintext highlighter-rouge">POST /chat</code> now. It doesn’t touch the conversation or the messages at all — its only job is to swap in a fresh, empty form, so the textarea clears itself after sending. Everything else happens later, over the cable connection, from the background job.</p>

<p>The form itself got pulled into its own partial so this response (and the initial page) can both render it:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/chats/_form.html.erb %&gt;</span>
<span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"new_message"</span><span class="nt">&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">form_with</span> <span class="ss">url: </span><span class="n">chat_path</span><span class="p">,</span> <span class="ss">method: :post</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"flex flex-col gap-3"</span> <span class="k">do</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;textarea</span>
      <span class="na">name=</span><span class="s">"message"</span>
      <span class="na">rows=</span><span class="s">"3"</span>
      <span class="na">placeholder=</span><span class="s">"Ask something..."</span>
      <span class="na">class=</span><span class="s">"border border-gray-300 rounded-md p-3 focus:outline-none focus:ring-2 focus:ring-indigo-500"</span>
    <span class="nt">&gt;&lt;/textarea&gt;</span>

    <span class="nt">&lt;button</span>
      <span class="na">type=</span><span class="s">"submit"</span>
      <span class="na">class=</span><span class="s">"self-start bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700"</span>
    <span class="nt">&gt;</span>
      Send
    <span class="nt">&lt;/button&gt;</span>
  <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">id="new_message"</code> on the wrapping <code class="language-plaintext highlighter-rouge">div</code> is what <code class="language-plaintext highlighter-rouge">turbo_stream.replace "new_message"</code> targets.</p>

<h2 id="bug-1-rendering-messages-by-role-and-a-variable-name-that-changes-underneath-you">Bug #1: rendering messages by role, and a variable name that changes underneath you</h2>

<p>Episode 3’s view looped over <code class="language-plaintext highlighter-rouge">@conversation.messages</code> by hand and branched on <code class="language-plaintext highlighter-rouge">message.role</code> inline to pick styling. That doesn’t work once other code — <code class="language-plaintext highlighter-rouge">broadcasts_to</code> — needs to render a <code class="language-plaintext highlighter-rouge">Message</code> on its own, without our view’s loop around it. So this episode moves rendering into partials, and <code class="language-plaintext highlighter-rouge">render @conversation.messages</code> uses Rails’ standard partial-per-record convention: for a <code class="language-plaintext highlighter-rouge">Message</code>, that would normally mean <code class="language-plaintext highlighter-rouge">messages/_message.html.erb</code>. I wrote exactly that partial first, and got this:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>ActionView::MissingTemplate in Chats#new
Missing partial messages/_user with {locale: [:en], formats: [:html], ...}
</code></pre></div></div>

<p>Not <code class="language-plaintext highlighter-rouge">_message</code> — <code class="language-plaintext highlighter-rouge">_user</code>. The reason is in RubyLLM’s own <code class="language-plaintext highlighter-rouge">Message</code> concern, <a href="https://github.com/crmne/ruby_llm"><code class="language-plaintext highlighter-rouge">message_methods.rb</code></a>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">to_partial_path</span>
  <span class="n">partial_prefix</span> <span class="o">=</span> <span class="nb">self</span><span class="p">.</span><span class="nf">class</span><span class="p">.</span><span class="nf">name</span><span class="p">.</span><span class="nf">underscore</span><span class="p">.</span><span class="nf">pluralize</span>
  <span class="n">role_partial</span> <span class="o">=</span> <span class="k">if</span> <span class="n">to_llm</span><span class="p">.</span><span class="nf">tool_call?</span>
                   <span class="s1">'tool_calls'</span>
                 <span class="k">elsif</span> <span class="n">role</span><span class="p">.</span><span class="nf">to_s</span> <span class="o">==</span> <span class="s1">'tool'</span>
                   <span class="s1">'tool'</span>
                 <span class="k">else</span>
                   <span class="n">role</span><span class="p">.</span><span class="nf">to_s</span><span class="p">.</span><span class="nf">presence</span> <span class="o">||</span> <span class="s1">'assistant'</span>
                 <span class="k">end</span>
  <span class="s2">"</span><span class="si">#{</span><span class="n">partial_prefix</span><span class="si">}</span><span class="s2">/</span><span class="si">#{</span><span class="n">role_partial</span><span class="si">}</span><span class="s2">"</span>
<span class="k">end</span>
</code></pre></div></div>

<p>RubyLLM overrides <code class="language-plaintext highlighter-rouge">to_partial_path</code> so a <code class="language-plaintext highlighter-rouge">Message</code> picks its own partial by role — <code class="language-plaintext highlighter-rouge">messages/user</code>, <code class="language-plaintext highlighter-rouge">messages/assistant</code>, and so on. That’s exactly why the official generator ships separate <code class="language-plaintext highlighter-rouge">_user.html.erb</code>, <code class="language-plaintext highlighter-rouge">_assistant.html.erb</code>, <code class="language-plaintext highlighter-rouge">_system.html.erb</code>, <code class="language-plaintext highlighter-rouge">_tool.html.erb</code> partials instead of one generic one: it has to, this override forces it. So the fix was renaming the file — <code class="language-plaintext highlighter-rouge">messages/_user.html.erb</code> and <code class="language-plaintext highlighter-rouge">messages/_assistant.html.erb</code> — not changing anything about the render call.</p>

<p>That fixed the missing-template error, but not the page. Reloading gave a different one:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>NameError in Chats#new
undefined local variable or method 'user' for an instance of #&lt;Class:0x...&gt;
</code></pre></div></div>

<p>I’d written the partial expecting a local called <code class="language-plaintext highlighter-rouge">message</code> (the natural name, matching the variable everywhere else in this app):</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"</span><span class="cp">&lt;%=</span> <span class="n">dom_id</span><span class="p">(</span><span class="n">message</span><span class="p">)</span> <span class="cp">%&gt;</span><span class="s">"</span> <span class="na">class=</span><span class="s">"text-right"</span><span class="nt">&gt;</span>
  ...
</code></pre></div></div>

<p>But when Rails renders a partial resolved through <code class="language-plaintext highlighter-rouge">to_partial_path</code>, it names the local after the <em>partial</em>, not the class — for <code class="language-plaintext highlighter-rouge">messages/_user.html.erb</code>, that’s <code class="language-plaintext highlighter-rouge">user</code>, not <code class="language-plaintext highlighter-rouge">message</code>. I fixed the name and moved on, confident that was the whole story. It wasn’t.</p>

<h2 id="bug-2-the-same-partial-rendered-two-different-ways-with-two-different-local-names">Bug #2: the same partial, rendered two different ways, with two different local names</h2>

<p>Everything looked right after that fix — until I actually sent a message and watched the <em>live</em> broadcasts (the ones fired by <code class="language-plaintext highlighter-rouge">broadcasts_to</code>, not the initial page render) blow up in the server log:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Error performing Turbo::Streams::ActionBroadcastJob ...:
ActionView::Template::Error (undefined local variable or method 'user' for an instance of #&lt;Class:0x...&gt;):
app/views/messages/_user.html.erb:2
</code></pre></div></div>

<p>Same error, same file — but the initial page render, moments earlier in the very same log, had worked fine with the exact same partial. Two different code paths were rendering <code class="language-plaintext highlighter-rouge">messages/_user.html.erb</code> with two different sets of locals. <code class="language-plaintext highlighter-rouge">render @conversation.messages</code> (a collection) infers the local’s name from the resolved partial path — <code class="language-plaintext highlighter-rouge">user</code>. But <code class="language-plaintext highlighter-rouge">broadcasts_to</code>’s <code class="language-plaintext highlighter-rouge">after_update_commit</code> callback (the one triggered by that incidental <code class="language-plaintext highlighter-rouge">content_raw</code> update inside <code class="language-plaintext highlighter-rouge">add_message</code>, mentioned above) calls <code class="language-plaintext highlighter-rouge">broadcast_replace_later_to</code>, which renders the same partial with an explicit <code class="language-plaintext highlighter-rouge">locals: {message: ...}</code> — the local is named after the <em>model class</em>, not the partial.</p>

<p>Same partial file, two callers, two different local variable names for the exact same object. The fix is a defensive first line, reading whichever one is actually present:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/messages/_user.html.erb %&gt;</span>
<span class="cp">&lt;%</span> <span class="n">user</span> <span class="o">=</span> <span class="n">local_assigns</span><span class="p">[</span><span class="ss">:user</span><span class="p">]</span> <span class="o">||</span> <span class="n">local_assigns</span><span class="p">[</span><span class="ss">:message</span><span class="p">]</span> <span class="cp">%&gt;</span>
<span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"</span><span class="cp">&lt;%=</span> <span class="n">dom_id</span><span class="p">(</span><span class="n">user</span><span class="p">)</span> <span class="cp">%&gt;</span><span class="s">"</span> <span class="na">class=</span><span class="s">"text-right"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"text-xs text-gray-500 mb-1"</span><span class="nt">&gt;</span>user<span class="nt">&lt;/p&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"</span><span class="cp">&lt;%=</span> <span class="n">dom_id</span><span class="p">(</span><span class="n">user</span><span class="p">)</span> <span class="cp">%&gt;</span><span class="s">_content"</span> <span class="na">class=</span><span class="s">"inline-block rounded-md px-3 py-2 bg-indigo-600 text-white"</span><span class="nt">&gt;</span>
    <span class="cp">&lt;%=</span> <span class="n">user</span><span class="p">.</span><span class="nf">content</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/messages/_assistant.html.erb %&gt;</span>
<span class="cp">&lt;%</span> <span class="n">assistant</span> <span class="o">=</span> <span class="n">local_assigns</span><span class="p">[</span><span class="ss">:assistant</span><span class="p">]</span> <span class="o">||</span> <span class="n">local_assigns</span><span class="p">[</span><span class="ss">:message</span><span class="p">]</span> <span class="cp">%&gt;</span>
<span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"</span><span class="cp">&lt;%=</span> <span class="n">dom_id</span><span class="p">(</span><span class="n">assistant</span><span class="p">)</span> <span class="cp">%&gt;</span><span class="s">"</span> <span class="na">class=</span><span class="s">"text-left"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"text-xs text-gray-500 mb-1"</span><span class="nt">&gt;</span>assistant<span class="nt">&lt;/p&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"</span><span class="cp">&lt;%=</span> <span class="n">dom_id</span><span class="p">(</span><span class="n">assistant</span><span class="p">)</span> <span class="cp">%&gt;</span><span class="s">_content"</span> <span class="na">class=</span><span class="s">"inline-block rounded-md px-3 py-2 bg-gray-100"</span><span class="nt">&gt;</span>
    <span class="cp">&lt;%=</span> <span class="n">assistant</span><span class="p">.</span><span class="nf">content</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;/div&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p>This is exactly the shape of the fallback in RubyLLM’s own generated partial (<code class="language-plaintext highlighter-rouge">assistant ||= local_assigns[:message]</code>) — I hadn’t noticed why it was there until I hit the same wall myself. Reading generated code before you need it and reading it <em>after</em> it just broke your page teach very different lessons; this was the second kind.</p>

<p>The container the initial <code class="language-plaintext highlighter-rouge">render @conversation.messages</code> needs, and the one <code class="language-plaintext highlighter-rouge">broadcasts_to</code>’s create-time append (target defaults to <code class="language-plaintext highlighter-rouge">model_name.plural</code>, <code class="language-plaintext highlighter-rouge">"messages"</code>) needs to find already on the page:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/chats/new.html.erb — excerpt %&gt;</span>
<span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"messages"</span> <span class="na">class=</span><span class="s">"space-y-4 mb-8"</span><span class="nt">&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">render</span> <span class="vi">@conversation</span><span class="p">.</span><span class="nf">messages</span> <span class="cp">%&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<h2 id="bug-3-the-real-one-two-different-names-for-the-same-stream">Bug #3 (the real one): two different names for the same stream</h2>

<p>With both partials fixed, I reloaded, sent a message, and — nothing. No error anywhere. The form cleared (so the <code class="language-plaintext highlighter-rouge">turbo_stream</code> response from <code class="language-plaintext highlighter-rouge">create</code> had worked). But no user bubble, no assistant bubble, not even after several seconds, not even after the background job had clearly finished (I could see the full reply, correctly saved, by refreshing the page).</p>

<p>The Rails log told a <em>completely clean</em> story: the job ran, <code class="language-plaintext highlighter-rouge">conversation.ask</code> created both rows, every <code class="language-plaintext highlighter-rouge">Turbo::Streams::ActionBroadcastJob</code> performed successfully, and line after line of <code class="language-plaintext highlighter-rouge">[ActionCable] Broadcasting to conversation_7: ...</code> showed every single chunk going out, right down to the very last one. Server-side, everything worked. The browser just never got any of it.</p>

<p>I checked the page itself with a bit of JavaScript, from the browser console:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">el</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">'</span><span class="s1">turbo-cable-stream-source</span><span class="dl">'</span><span class="p">);</span>
<span class="nx">el</span><span class="p">.</span><span class="nf">hasAttribute</span><span class="p">(</span><span class="dl">'</span><span class="s1">connected</span><span class="dl">'</span><span class="p">)</span>   <span class="c1">// true</span>
<span class="nx">el</span><span class="p">.</span><span class="nx">subscription</span>                <span class="c1">// present</span>
</code></pre></div></div>

<p>Connected, subscribed, no console errors — and still nothing arriving. At this point the two sides looked individually correct and mutually unreachable, which is exactly what happens when they’re each listening to / broadcasting on a <em>different</em> stream that merely looks the same to a human reading the code.</p>

<p>The subscription lives in the view:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%=</span> <span class="n">turbo_stream_from</span> <span class="vi">@conversation</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p>I’d written this the way you’d write it if you’d only ever seen <code class="language-plaintext highlighter-rouge">turbo_stream_from</code> used with a plain ActiveRecord object — pass the record, done. And it <em>is</em> valid — <code class="language-plaintext highlighter-rouge">turbo_stream_from</code> accepts any “streamable”, including a model instance, and derives a stream name from it (based on its GlobalID). The problem is that <code class="language-plaintext highlighter-rouge">@conversation</code> was never the identifier used anywhere else in this episode. <code class="language-plaintext highlighter-rouge">broadcasts_to</code> and <code class="language-plaintext highlighter-rouge">broadcast_append_chunk</code>, back in the <code class="language-plaintext highlighter-rouge">Message</code> model, both build the stream name from a plain string:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="s2">"conversation_</span><span class="si">#{</span><span class="n">message</span><span class="p">.</span><span class="nf">conversation_id</span><span class="si">}</span><span class="s2">"</span>
</code></pre></div></div>

<p>A string and a model object don’t hash to the same signed stream name just because a human reads them as “the same conversation.” <code class="language-plaintext highlighter-rouge">turbo_stream_from @conversation</code> and <code class="language-plaintext highlighter-rouge">broadcasts_to -&gt;(message) { "conversation_#{message.conversation_id}" }</code> were quietly subscribing to and broadcasting on two entirely different channels — no error on either side, because neither side does anything wrong in isolation; they just never meet.</p>

<p>The generator’s own view template settled it for me — <a href="https://github.com/crmne/ruby_llm"><code class="language-plaintext highlighter-rouge">chats/show.html.erb.tt</code></a>:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%%</span><span class="o">=</span> <span class="n">turbo_stream_from</span> <span class="s2">"&lt;%= chat_variable_name </span><span class="cp">%&gt;</span>_#{@<span class="cp">&lt;%=</span><span class="s2"> chat_variable_name </span><span class="cp">%&gt;</span>.id}" %&gt;
</code></pre></div></div>

<p>A string, built the same way as the model’s. Matching that:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%=</span> <span class="n">turbo_stream_from</span> <span class="s2">"conversation_</span><span class="si">#{</span><span class="vi">@conversation</span><span class="p">.</span><span class="nf">id</span><span class="si">}</span><span class="s2">"</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p>One line, and every broadcast that had been silently going nowhere started arriving instantly. The lesson underneath the bug: <code class="language-plaintext highlighter-rouge">turbo_stream_from</code> and <code class="language-plaintext highlighter-rouge">broadcasts_to</code> don’t have to agree on <em>how</em> you name a stream — object or string, doesn’t matter which — but every subscriber and every broadcaster absolutely have to agree with each other, exactly, because there’s no error path when they don’t. It fails by going quiet, not by raising.</p>

<p>The complete, working view:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/chats/new.html.erb %&gt;</span>
<span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"max-w-xl mx-auto mt-16 px-4"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"flex items-center justify-between mb-6"</span><span class="nt">&gt;</span>
    <span class="nt">&lt;h1</span> <span class="na">class=</span><span class="s">"text-2xl font-semibold"</span><span class="nt">&gt;</span>RubyLLM chat demo<span class="nt">&lt;/h1&gt;</span>
    <span class="cp">&lt;%=</span> <span class="n">button_to</span> <span class="s2">"New conversation"</span><span class="p">,</span> <span class="n">chat_path</span><span class="p">,</span> <span class="ss">method: :delete</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"text-sm text-gray-500 hover:text-gray-700"</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;/div&gt;</span>

  <span class="cp">&lt;%=</span> <span class="n">turbo_stream_from</span> <span class="s2">"conversation_</span><span class="si">#{</span><span class="vi">@conversation</span><span class="p">.</span><span class="nf">id</span><span class="si">}</span><span class="s2">"</span> <span class="cp">%&gt;</span>

  <span class="nt">&lt;div</span> <span class="na">id=</span><span class="s">"messages"</span> <span class="na">class=</span><span class="s">"space-y-4 mb-8"</span><span class="nt">&gt;</span>
    <span class="cp">&lt;%=</span> <span class="n">render</span> <span class="vi">@conversation</span><span class="p">.</span><span class="nf">messages</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;/div&gt;</span>

  <span class="cp">&lt;%=</span> <span class="n">render</span> <span class="s2">"form"</span> <span class="cp">%&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<h2 id="trying-it-in-the-browser">Trying it in the browser</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/dev
</code></pre></div></div>

<p>Open <code class="language-plaintext highlighter-rouge">http://127.0.0.1:3000</code>, ask something, and this time the reply doesn’t just appear — it fills in progressively, the way a chat app is supposed to feel. Here’s a real exchange from testing this episode, captured mid-session:</p>

<p><img src="/assets/images/ai-with-ruby-ep4-chat-pep-talk.png" alt="Two Q&amp;A exchanges in the chat demo: asking for a five-year-old's explanation of Turbo Streams, and a two-line pep talk for debugging ActionCable at midnight — the reply mentions checking that &quot;the stream name matches exactly,&quot; which is exactly the bug this episode walks through." /></p>

<p><em>Unplanned, but fitting: I asked for a pep talk about debugging ActionCable, and the reply landed on “check that the stream name matches exactly” — the exact bug from the section above.</em></p>

<p>A longer session, showing the “New conversation” link (top right) that closes out episode 3’s TODO, and multiple exchanges accumulating in the same conversation:</p>

<p><img src="/assets/images/ai-with-ruby-ep4-chat-full.png" alt="The same chat demo after a third exchange: a short story about a WebSocket message traveling from server to browser, with all three question/answer pairs stacked in order." /></p>

<h2 id="a-caveat-worth-knowing-broadcast-ordering-isnt-guaranteed">A caveat worth knowing: broadcast ordering isn’t guaranteed</h2>

<p>While capturing that second screenshot, I hit something worth flagging rather than quietly editing around. <code class="language-plaintext highlighter-rouge">broadcasts_to</code>’s <code class="language-plaintext highlighter-rouge">after_create_commit</code> callback doesn’t broadcast synchronously — it calls <code class="language-plaintext highlighter-rouge">broadcast_action_later_to</code>, which enqueues <em>another</em> background job (<code class="language-plaintext highlighter-rouge">Turbo::Streams::ActionBroadcastJob</code>) to do the actual rendering and broadcasting. So a single <code class="language-plaintext highlighter-rouge">.ask</code> call, under the hood, enqueues several jobs in quick succession: one to append the user message, one (from the incidental <code class="language-plaintext highlighter-rouge">content_raw</code> update) to replace it again, one to append the empty assistant message, plus one more at the very end to replace it with the finished text.</p>

<p>With Rails’ default <code class="language-plaintext highlighter-rouge">:async</code> queue adapter — a small in-process thread pool — these aren’t strictly guaranteed to finish in the order they were enqueued. In one live test I watched an assistant’s reply render <em>before</em> the user question it was answering, purely because that append job happened to finish on a different thread a few milliseconds sooner. Refreshing the page immediately showed the correct order — messages are always fetched with <code class="language-plaintext highlighter-rouge">ORDER BY created_at</code>, so the database is never wrong, only a specific live-updating page can briefly show things out of sequence. In production, with a real queue (Solid Queue, already configured in this app) this is far less likely to be visible at normal typing-and-reading speed, but it isn’t structurally impossible either. Not something this episode fixes — just something worth knowing is there if a message ever seems to jump the queue on screen.</p>

<h2 id="whats-next">What’s next</h2>

<p>Episode 5 covers semantic search: turning <code class="language-plaintext highlighter-rouge">Message</code> content into embeddings, and letting a user search across past conversations by meaning, not just exact words.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[In episode 3 conversations started surviving a page refresh, but the request itself was still a black box: you hit Send, the whole page just sat there — no spinner, nothing — until the entire reply had come back from the model, and only then did the page redirect and show it, all at once. For a one-sentence answer that’s a second or two of nothing. For a longer one, it can be five, six seconds of a page that looks frozen.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://antoninoscaffidi.github.io/assets/images/ai-with-ruby-ep4-banner.png" /><media:content medium="image" url="https://antoninoscaffidi.github.io/assets/images/ai-with-ruby-ep4-banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">WhatsApp with Rails: Sending a Real Message via Twilio</title><link href="https://antoninoscaffidi.github.io/whatsapp-rails-sending-via-twilio/" rel="alternate" type="text/html" title="WhatsApp with Rails: Sending a Real Message via Twilio" /><published>2026-08-15T07:00:00+00:00</published><updated>2026-08-15T07:00:00+00:00</updated><id>https://antoninoscaffidi.github.io/whatsapp-rails-sending-via-twilio</id><content type="html" xml:base="https://antoninoscaffidi.github.io/whatsapp-rails-sending-via-twilio/"><![CDATA[<p><a href="/whatsapp-rails-setup-and-contacts/">Episode 1</a> got us a <code class="language-plaintext highlighter-rouge">Contact</code> model and a form to add people. Nothing talked to Twilio yet. This episode closes that gap end to end: a form to compose a message, a real API call, and a message that actually lands on a real phone over WhatsApp — which I tested for real while writing this post, including the ways it fails.</p>

<p>Code is tagged <a href="https://github.com/AntoninoScaffidi/whatsapp-with-rails/tree/episode-2"><code class="language-plaintext highlighter-rouge">episode-2</code></a> in the <a href="https://github.com/AntoninoScaffidi/whatsapp-with-rails">whatsapp-with-rails</a> repo. This is a long one — the goal is to leave nothing unexplained: every gem, every line of every migration, every line of the model and controller, and the exact errors you’ll hit and why.</p>

<h2 id="what-the-whatsapp-sandbox-actually-is-and-why-you-need-it">What the WhatsApp Sandbox actually is, and why you need it</h2>

<p>Sending a WhatsApp message through the real WhatsApp Business Platform requires a phone number registered and approved through Meta — a process with review steps and waiting time. Twilio’s <strong>Sandbox</strong> exists so you don’t have to go through that just to write and test code. It’s a shared, pre-approved Twilio number (<code class="language-plaintext highlighter-rouge">+14155238886</code> for everyone using Twilio’s sandbox) that can send and receive WhatsApp messages immediately, with one restriction: it will only talk to phone numbers that have explicitly <em>joined</em> it.</p>

<p>Joining means sending a specific message — <code class="language-plaintext highlighter-rouge">join &lt;two-word-code&gt;</code>, e.g. <code class="language-plaintext highlighter-rouge">join vowel-purpose</code>, a code Twilio generates per account — from WhatsApp, from the phone number you want to test with, to that sandbox number. You can do this by hand from WhatsApp, or by opening the link/QR code Twilio’s console shows you (Console → Messaging → Try it out → Send a WhatsApp message). Once a number joins, Twilio can send it messages via the API; a number that never joined will reject them, every time, with an error we’ll walk through further down.</p>

<p>Two details worth knowing, because you’ll run into both eventually:</p>

<ul>
  <li><strong>The join lasts 3 days of inactivity</strong>, not forever. If nobody sends anything to the sandbox from that number for 3 days, it has to <code class="language-plaintext highlighter-rouge">join</code> again.</li>
  <li><strong>This is strictly a testing mechanism.</strong> In production you send from a WhatsApp-enabled sender you own (still set up through Twilio, but without the “only pre-joined numbers” restriction) — the sandbox is for development, not for talking to real customers.</li>
</ul>

<h2 id="the-two-gems">The two gems</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Gemfile</span>
<span class="n">gem</span> <span class="s2">"twilio-ruby"</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">group</span> <span class="ss">:development</span> <span class="k">do</span>
  <span class="n">gem</span> <span class="s2">"web-console"</span>
  <span class="n">gem</span> <span class="s2">"dotenv-rails"</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">twilio-ruby</code> is Twilio’s official Ruby client — a wrapper around Twilio’s REST API, giving you <code class="language-plaintext highlighter-rouge">Twilio::REST::Client.new(...).messages.create(...)</code> instead of hand-building HTTP requests and parsing JSON. <code class="language-plaintext highlighter-rouge">dotenv-rails</code>, same as in the <a href="/wiring-rubyllm-into-rails/">ai-with-ruby series</a>, loads a <code class="language-plaintext highlighter-rouge">.env</code> file into <code class="language-plaintext highlighter-rouge">ENV</code> in development, so credentials live outside the codebase.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle <span class="nb">install</span>
</code></pre></div></div>

<h2 id="credentials-env-envexample-and-gitignore">Credentials: <code class="language-plaintext highlighter-rouge">.env</code>, <code class="language-plaintext highlighter-rouge">.env.example</code>, and <code class="language-plaintext highlighter-rouge">.gitignore</code></h2>

<p>Three real values are needed, all secrets: your Twilio Account SID, your Auth Token, and the sandbox WhatsApp number. Rails 8’s default <code class="language-plaintext highlighter-rouge">.gitignore</code> already excludes <code class="language-plaintext highlighter-rouge">.env*</code>, so a real <code class="language-plaintext highlighter-rouge">.env</code> file never gets committed. We commit a template instead:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># .env.example
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_AUTH_TOKEN=your-auth-token-here
TWILIO_WHATSAPP_NUMBER=+14155238886
</code></pre></div></div>

<p>Because <code class="language-plaintext highlighter-rouge">.gitignore</code>’s <code class="language-plaintext highlighter-rouge">/.env*</code> pattern is broad enough to also catch <code class="language-plaintext highlighter-rouge">.env.example</code>, it needs an explicit exception — this exact gotcha showed up already in the ai-with-ruby series:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code># .gitignore
/.env*
!/.env.example
</code></pre></div></div>

<p>To actually run this app, copy the template and fill in real values:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cp</span> .env.example .env
</code></pre></div></div>

<p><strong>On the Account SID and Auth Token specifically</strong>: these are your Twilio account’s master credentials — anyone with them can send messages (and be billed) as you. They’re found on the main Twilio Console dashboard the moment you log in. If you ever paste one somewhere public by accident, Twilio lets you regenerate the Auth Token from the console; the old one stops working immediately.</p>

<h2 id="the-twilio-client-one-initializer-one-line">The Twilio client: one initializer, one line</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/initializers/twilio.rb</span>
<span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">config</span><span class="p">.</span><span class="nf">x</span><span class="p">.</span><span class="nf">twilio_client</span> <span class="o">=</span> <span class="no">Twilio</span><span class="o">::</span><span class="no">REST</span><span class="o">::</span><span class="no">Client</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span>
  <span class="no">ENV</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="s2">"TWILIO_ACCOUNT_SID"</span><span class="p">),</span>
  <span class="no">ENV</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="s2">"TWILIO_AUTH_TOKEN"</span><span class="p">)</span>
<span class="p">)</span>
</code></pre></div></div>

<p>Same reasoning as the RubyLLM initializer in the AI with Ruby series: files in <code class="language-plaintext highlighter-rouge">config/initializers/</code> run once, at boot, before any request — the right place to construct something that talks to a third-party API and hand it your credentials.</p>

<p>Two things worth being precise about here. First, <code class="language-plaintext highlighter-rouge">ENV.fetch("TWILIO_ACCOUNT_SID")</code> — no default given as a second argument — <strong>raises</strong> if the variable is missing, rather than silently continuing with <code class="language-plaintext highlighter-rouge">nil</code>. That’s deliberate: a misconfigured Twilio client that quietly does nothing is much harder to debug than an app that refuses to boot with a clear <code class="language-plaintext highlighter-rouge">KeyError</code>. Second, <code class="language-plaintext highlighter-rouge">Rails.application.config.x</code> is Rails’ built-in namespace for custom application configuration — the <code class="language-plaintext highlighter-rouge">x</code> stands for “custom”, and it exists specifically so you’re not tempted to stash app-specific config in global constants or <code class="language-plaintext highlighter-rouge">Rails.application.config</code> directly (which Rails itself uses). Anywhere in the app, <code class="language-plaintext highlighter-rouge">Rails.application.config.x.twilio_client</code> gets you the same configured client.</p>

<h2 id="the-message-model-and-the-migration-behind-it">The Message model, and the migration behind it</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails generate model Message contact:references body:text twilio_sid:string status:string
</code></pre></div></div>

<p>The generator produced a migration that I then edited before running it — worth going through both versions, because the edit is where the real thinking is:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/..._create_messages.rb — as generated</span>
<span class="n">create_table</span> <span class="ss">:messages</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">references</span> <span class="ss">:contact</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span><span class="p">,</span> <span class="ss">foreign_key: </span><span class="kp">true</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">text</span> <span class="ss">:body</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:twilio_sid</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:status</span>

  <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/..._create_messages.rb — as run</span>
<span class="n">create_table</span> <span class="ss">:messages</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">references</span> <span class="ss">:contact</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span><span class="p">,</span> <span class="ss">foreign_key: </span><span class="kp">true</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">text</span> <span class="ss">:body</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:twilio_sid</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:status</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span><span class="p">,</span> <span class="ss">default: </span><span class="s2">"queued"</span>

  <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Going column by column:</p>

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">t.references :contact, null: false, foreign_key: true</code></strong> — this is what the <code class="language-plaintext highlighter-rouge">contact:references</code> argument to the generator produces on its own: an integer <code class="language-plaintext highlighter-rouge">contact_id</code> column, a database-level index on it, a database-level foreign key constraint (<code class="language-plaintext highlighter-rouge">foreign_key: true</code>) so the database itself refuses to let a <code class="language-plaintext highlighter-rouge">Message</code> point at a <code class="language-plaintext highlighter-rouge">Contact</code> that doesn’t exist, and <code class="language-plaintext highlighter-rouge">null: false</code> because a message with no contact makes no sense.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">t.text :body, null: false</code></strong> — <code class="language-plaintext highlighter-rouge">text</code> rather than <code class="language-plaintext highlighter-rouge">string</code> because a message body has no natural short length limit the way a name does; I added <code class="language-plaintext highlighter-rouge">null: false</code> by hand because the generator doesn’t add presence constraints on its own, and an empty message is never something we want to send.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">t.string :twilio_sid</code></strong> — deliberately nullable. This gets filled in <em>after</em> Twilio accepts the message (more on this below); before that point, there’s a real <code class="language-plaintext highlighter-rouge">Message</code> row with no SID yet.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">t.string :status, null: false, default: "queued"</code></strong> — the one substantive change. I added <code class="language-plaintext highlighter-rouge">null: false, default: "queued"</code> so every <code class="language-plaintext highlighter-rouge">Message</code> has a sensible status the instant it’s created, before we’ve even talked to Twilio. Why this column exists at all is the more interesting question, covered in the next section.</li>
</ul>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/message.rb</span>
<span class="k">class</span> <span class="nc">Message</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">belongs_to</span> <span class="ss">:contact</span>

  <span class="n">validates</span> <span class="ss">:body</span><span class="p">,</span> <span class="ss">presence: </span><span class="kp">true</span>

  <span class="k">def</span> <span class="nf">deliver!</span>
    <span class="n">twilio_message</span> <span class="o">=</span> <span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">config</span><span class="p">.</span><span class="nf">x</span><span class="p">.</span><span class="nf">twilio_client</span><span class="p">.</span><span class="nf">messages</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span>
      <span class="ss">from: </span><span class="s2">"whatsapp:</span><span class="si">#{</span><span class="no">ENV</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="s1">'TWILIO_WHATSAPP_NUMBER'</span><span class="p">)</span><span class="si">}</span><span class="s2">"</span><span class="p">,</span>
      <span class="ss">to: </span><span class="s2">"whatsapp:</span><span class="si">#{</span><span class="n">contact</span><span class="p">.</span><span class="nf">whatsapp_number</span><span class="si">}</span><span class="s2">"</span><span class="p">,</span>
      <span class="ss">body: </span><span class="n">body</span>
    <span class="p">)</span>

    <span class="n">update!</span><span class="p">(</span><span class="ss">twilio_sid: </span><span class="n">twilio_message</span><span class="p">.</span><span class="nf">sid</span><span class="p">,</span> <span class="ss">status: </span><span class="n">twilio_message</span><span class="p">.</span><span class="nf">status</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">belongs_to :contact</code> is enough on its own to require a contact — Rails 5+ makes <code class="language-plaintext highlighter-rouge">belongs_to</code> associations required by default, so this line alone gives us most of what <code class="language-plaintext highlighter-rouge">null: false</code> in the migration was already doing, at the application level.</p>

<p><code class="language-plaintext highlighter-rouge">validates :body, presence: true</code> mirrors the <code class="language-plaintext highlighter-rouge">null: false</code> on <code class="language-plaintext highlighter-rouge">body</code> — one is the database refusing invalid data no matter what inserts it, the other is a friendly validation error before we even try, which is what lets <code class="language-plaintext highlighter-rouge">MessagesController</code> show “can’t be blank” in the form instead of a database-level crash.</p>

<p><code class="language-plaintext highlighter-rouge">deliver!</code> is the one method actually doing something. Look closely at <code class="language-plaintext highlighter-rouge">from:</code> and <code class="language-plaintext highlighter-rouge">to:</code> — WhatsApp numbers in the Twilio API are always prefixed with the literal string <code class="language-plaintext highlighter-rouge">whatsapp:</code>, e.g. <code class="language-plaintext highlighter-rouge">whatsapp:+14155238886</code>. This is how Twilio’s unified messaging API tells the difference between sending the same number as a WhatsApp message versus a plain SMS — the prefix, not a separate endpoint. Forget it on either side and the call either errors or silently tries to send a regular text message instead.</p>

<p>The naming convention — <code class="language-plaintext highlighter-rouge">deliver!</code>, with a bang — mirrors Rails’ own convention for methods that do something with real, possibly-failing side effects (<code class="language-plaintext highlighter-rouge">save!</code>, <code class="language-plaintext highlighter-rouge">create!</code>), as opposed to a quiet query. Sending a WhatsApp message is about as real a side effect as a method can have.</p>

<h2 id="why-twilio_sid-and-status-exist-whatsapp-sending-is-asynchronous">Why <code class="language-plaintext highlighter-rouge">twilio_sid</code> and <code class="language-plaintext highlighter-rouge">status</code> exist: WhatsApp sending is asynchronous</h2>

<p>This is worth being explicit about, because it’s easy to assume a successful API call means the message arrived — it doesn’t. When <code class="language-plaintext highlighter-rouge">messages.create</code> returns without raising, all it means is: <em>Twilio accepted the request and queued it for delivery.</em> The <code class="language-plaintext highlighter-rouge">status</code> Twilio returns at that point is typically <code class="language-plaintext highlighter-rouge">"queued"</code>, not <code class="language-plaintext highlighter-rouge">"delivered"</code>. What actually happens to the message after that — sent, delivered, read, or failed — happens asynchronously, and by default your Rails app never hears about it again unless it asks.</p>

<p>That’s the whole reason <code class="language-plaintext highlighter-rouge">twilio_sid</code> and <code class="language-plaintext highlighter-rouge">status</code> are columns on <code class="language-plaintext highlighter-rouge">Message</code> rather than being thrown away after the API call: <code class="language-plaintext highlighter-rouge">twilio_sid</code> is the identifier Twilio gives back (<code class="language-plaintext highlighter-rouge">SM...</code>), and it’s how you look the message up again later to check what actually happened to it. I did exactly that while testing this episode:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">fresh</span> <span class="o">=</span> <span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">config</span><span class="p">.</span><span class="nf">x</span><span class="p">.</span><span class="nf">twilio_client</span><span class="p">.</span><span class="nf">messages</span><span class="p">(</span><span class="n">message</span><span class="p">.</span><span class="nf">twilio_sid</span><span class="p">).</span><span class="nf">fetch</span>
<span class="n">fresh</span><span class="p">.</span><span class="nf">status</span>        <span class="c1"># =&gt; "failed" — updated after the fact, not from the original response</span>
<span class="n">fresh</span><span class="p">.</span><span class="nf">error_code</span>     <span class="c1"># =&gt; 63015</span>
</code></pre></div></div>

<p>The <em>right</em> way to keep <code class="language-plaintext highlighter-rouge">status</code> current without polling by hand is a <strong>status callback webhook</strong> — a URL you give Twilio that it POSTs to every time a message’s status changes. That’s genuinely more machinery (a public endpoint, a route, request verification) than belongs in an episode about the first successful send, so it’s out of scope here — but it’s worth knowing <code class="language-plaintext highlighter-rouge">status</code> exists on this model specifically because that future webhook will have something to update.</p>

<h2 id="wiring-it-into-the-app-routes-controller-contact-list">Wiring it into the app: routes, controller, contact list</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">resources</span> <span class="ss">:contacts</span><span class="p">,</span> <span class="ss">only: </span><span class="p">[</span><span class="ss">:index</span><span class="p">,</span> <span class="ss">:new</span><span class="p">,</span> <span class="ss">:create</span><span class="p">]</span> <span class="k">do</span>
  <span class="n">resources</span> <span class="ss">:messages</span><span class="p">,</span> <span class="ss">only: </span><span class="p">[</span><span class="ss">:new</span><span class="p">,</span> <span class="ss">:create</span><span class="p">]</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Nested resources here aren’t decoration — <code class="language-plaintext highlighter-rouge">messages</code> genuinely doesn’t make sense without a <code class="language-plaintext highlighter-rouge">contact</code> in context; there’s no “compose a message” screen that isn’t already about a specific person. This generates paths like <code class="language-plaintext highlighter-rouge">new_contact_message_path(contact)</code> and <code class="language-plaintext highlighter-rouge">contact_messages_path(contact)</code>, both carrying the contact’s id.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/messages_controller.rb</span>
<span class="k">class</span> <span class="nc">MessagesController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="n">before_action</span> <span class="ss">:set_contact</span>

  <span class="k">def</span> <span class="nf">new</span>
    <span class="vi">@message</span> <span class="o">=</span> <span class="vi">@contact</span><span class="p">.</span><span class="nf">messages</span><span class="p">.</span><span class="nf">new</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">create</span>
    <span class="vi">@message</span> <span class="o">=</span> <span class="vi">@contact</span><span class="p">.</span><span class="nf">messages</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">message_params</span><span class="p">)</span>

    <span class="k">if</span> <span class="vi">@message</span><span class="p">.</span><span class="nf">save</span>
      <span class="k">begin</span>
        <span class="vi">@message</span><span class="p">.</span><span class="nf">deliver!</span>
        <span class="n">redirect_to</span> <span class="n">contacts_path</span><span class="p">,</span> <span class="ss">notice: </span><span class="s2">"Message sent to </span><span class="si">#{</span><span class="vi">@contact</span><span class="p">.</span><span class="nf">name</span><span class="si">}</span><span class="s2">."</span>
      <span class="k">rescue</span> <span class="no">Twilio</span><span class="o">::</span><span class="no">REST</span><span class="o">::</span><span class="no">RestError</span> <span class="o">=&gt;</span> <span class="n">e</span>
        <span class="n">redirect_to</span> <span class="n">contacts_path</span><span class="p">,</span> <span class="ss">alert: </span><span class="s2">"Twilio couldn't send the message: </span><span class="si">#{</span><span class="n">e</span><span class="p">.</span><span class="nf">error_message</span><span class="si">}</span><span class="s2">"</span>
      <span class="k">end</span>
    <span class="k">else</span>
      <span class="n">render</span> <span class="ss">:new</span><span class="p">,</span> <span class="ss">status: :unprocessable_entity</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">set_contact</span>
    <span class="vi">@contact</span> <span class="o">=</span> <span class="no">Contact</span><span class="p">.</span><span class="nf">find</span><span class="p">(</span><span class="n">params</span><span class="p">[</span><span class="ss">:contact_id</span><span class="p">])</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">message_params</span>
    <span class="n">params</span><span class="p">.</span><span class="nf">require</span><span class="p">(</span><span class="ss">:message</span><span class="p">).</span><span class="nf">permit</span><span class="p">(</span><span class="ss">:body</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">before_action :set_contact</code> runs before both actions, loading <code class="language-plaintext highlighter-rouge">@contact</code> from <code class="language-plaintext highlighter-rouge">params[:contact_id]</code> — the nested route parameter, not <code class="language-plaintext highlighter-rouge">:id</code> (that would be the message’s own id, which doesn’t exist yet for <code class="language-plaintext highlighter-rouge">new</code>/<code class="language-plaintext highlighter-rouge">create</code>).</p>

<p><code class="language-plaintext highlighter-rouge">create</code> does three things in order: build the <code class="language-plaintext highlighter-rouge">Message</code> (not yet saved), save it to the database, <em>then</em> attempt delivery. This ordering matters — the message exists as a database row, with <code class="language-plaintext highlighter-rouge">status: "queued"</code> from the column default, even before we know whether Twilio will accept it. If the process crashed between save and deliver, there’d still be a record of an intended message, not silence.</p>

<h2 id="the-synchronous-failure-case-twiliorestresterror">The synchronous failure case: <code class="language-plaintext highlighter-rouge">Twilio::REST::RestError</code></h2>

<p>Here’s something I only found by actually breaking it while testing: <code class="language-plaintext highlighter-rouge">deliver!</code> can fail in two completely different ways, and only one of them is visible where you’d expect.</p>

<p><strong>Asynchronous failure</strong> — the number never joined the sandbox, the message gets rejected somewhere downstream — doesn’t raise anything in Rails at all. <code class="language-plaintext highlighter-rouge">messages.create</code> returns successfully with <code class="language-plaintext highlighter-rouge">status: "queued"</code>; the failure only shows up later if you go back and check, exactly as described above. I hit this directly: sending to a contact whose number had never sent <code class="language-plaintext highlighter-rouge">join &lt;code&gt;</code> to the sandbox came back as a normal, non-raising <code class="language-plaintext highlighter-rouge">queued</code> response from the app’s point of view, and only turned out to be <code class="language-plaintext highlighter-rouge">status: "failed"</code>, <code class="language-plaintext highlighter-rouge">error_code: 63015</code> when I fetched the message back from Twilio afterward. <strong>Error 63015, specifically, means “this recipient hasn’t joined this sandbox”</strong> — the single most common thing you’ll hit while testing this integration, and worth recognizing on sight.</p>

<p><strong>Synchronous failure</strong> is different: bad credentials, a malformed request, anything Twilio’s API rejects immediately. That <em>does</em> raise, as <code class="language-plaintext highlighter-rouge">Twilio::REST::RestError</code> — I confirmed this directly, deliberately constructing a client with a wrong Account SID and Auth Token and watching it raise on the API call:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">bad_client</span> <span class="o">=</span> <span class="no">Twilio</span><span class="o">::</span><span class="no">REST</span><span class="o">::</span><span class="no">Client</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="s2">"AC0000000000000000000000000000000"</span><span class="p">,</span> <span class="s2">"wrongtoken"</span><span class="p">)</span>
<span class="n">bad_client</span><span class="p">.</span><span class="nf">messages</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span><span class="ss">from: </span><span class="s2">"whatsapp:+14155238886"</span><span class="p">,</span> <span class="ss">to: </span><span class="s2">"whatsapp:+391234567890"</span><span class="p">,</span> <span class="ss">body: </span><span class="s2">"test"</span><span class="p">)</span>
<span class="c1"># raises Twilio::REST::RestError</span>
<span class="c1"># e.status_code    =&gt; 401</span>
<span class="c1"># e.error_message  =&gt; "Authentication Error - invalid username"</span>
</code></pre></div></div>

<p>Before adding the <code class="language-plaintext highlighter-rouge">rescue</code> you see in the controller above, this class of error would have bubbled straight up through <code class="language-plaintext highlighter-rouge">deliver!</code>, through <code class="language-plaintext highlighter-rouge">create</code>, into an unhandled <code class="language-plaintext highlighter-rouge">500</code>, in what’s meant to be a demo shown to a client. <code class="language-plaintext highlighter-rouge">rescue Twilio::REST::RestError =&gt; e</code> catches it and redirects with a readable <code class="language-plaintext highlighter-rouge">alert</code> instead — <code class="language-plaintext highlighter-rouge">e.error_message</code> is the human-readable string Twilio itself sends back, so the message shown is Twilio’s own explanation, not a guess on our part.</p>

<p>What this episode does <em>not</em> do is turn the asynchronous 63015 case into a nice UI message — that’s not possible from inside <code class="language-plaintext highlighter-rouge">create</code> at all, since the app has already gotten a “successful”, <code class="language-plaintext highlighter-rouge">queued</code> response by the time the real failure happens. Handling that properly means the status callback webhook mentioned above; noting it here so it’s clear this is a known, deliberate gap, not an oversight.</p>

<h2 id="trying-it">Trying it</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/dev
</code></pre></div></div>

<p>Add a contact with a WhatsApp number that has actually joined your sandbox (see the join instructions earlier in this post — a number that hasn’t joined will accept the send from the app’s perspective and then fail invisibly, exactly as described above). Click “Message” next to them, write something, send it.</p>

<p>Here’s the real result from testing this for the post — an actual message, sent from this actual code, received on an actual phone:</p>

<blockquote>
  <p><strong>Twilio:</strong> Test from whatsapp-with-rails episode 2 🎉</p>
</blockquote>

<p>If you try sending to a contact whose number never joined the sandbox, you’ll get <code class="language-plaintext highlighter-rouge">queued</code> in the app and nothing on the phone — that’s error 63015 waiting to be discovered if you check the message status afterward, not a bug in this code.</p>

<h2 id="whats-next">What’s next</h2>

<p>Episode 3 does the same job a different way: calling Meta’s WhatsApp Cloud API directly, with no Twilio in between, so the trade-off between the two approaches — Twilio’s simplicity versus one less service in the middle — shows up in actual code, not just in the abstract.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Episode 1 got us a Contact model and a form to add people. Nothing talked to Twilio yet. This episode closes that gap end to end: a form to compose a message, a real API call, and a message that actually lands on a real phone over WhatsApp — which I tested for real while writing this post, including the ways it fails.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://antoninoscaffidi.github.io/assets/images/whatsapp-with-rails-ep2-banner.png" /><media:content medium="image" url="https://antoninoscaffidi.github.io/assets/images/whatsapp-with-rails-ep2-banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">VicinoTe: Authentication with Rails 8’s Built-In Generator</title><link href="https://antoninoscaffidi.github.io/vicinote-authentication-with-rails-8/" rel="alternate" type="text/html" title="VicinoTe: Authentication with Rails 8’s Built-In Generator" /><published>2026-08-13T07:00:00+00:00</published><updated>2026-08-13T07:00:00+00:00</updated><id>https://antoninoscaffidi.github.io/vicinote-authentication-with-rails-8</id><content type="html" xml:base="https://antoninoscaffidi.github.io/vicinote-authentication-with-rails-8/"><![CDATA[<p><a href="/vicinote-project-setup-and-domain/">Episode 1</a> ended with a design decision and nothing to log into: one <code class="language-plaintext highlighter-rouge">User</code> model, no role column, provider and customer both emerging from associations we hadn’t written any code for yet. This episode writes that model — and, with it, the whole authentication system around it.</p>

<p>Code is tagged <a href="https://github.com/AntoninoScaffidi/vicinote-tutorial/tree/episode-2"><code class="language-plaintext highlighter-rouge">episode-2</code></a> in the <a href="https://github.com/AntoninoScaffidi/vicinote-tutorial">vicinote-tutorial</a> repo.</p>

<h2 id="not-devise">Not Devise</h2>

<p>Every past version of “add authentication to a Rails app” started with <code class="language-plaintext highlighter-rouge">gem "devise"</code>. Rails 8 changed that: there’s now a built-in generator, <code class="language-plaintext highlighter-rouge">bin/rails generate authentication</code>, that writes plain, ordinary Rails code — models, controllers, a concern — directly into your app. No gem, no engine, no generated code living somewhere in a gem you can’t easily read.</p>

<p>That distinction matters more than it sounds. With Devise, understanding “how sign-in actually works” means reading the gem’s source. With the Rails 8 generator, the code it writes <em>is</em> your code, sitting in <code class="language-plaintext highlighter-rouge">app/</code> like everything else, ready to be read, modified, and — as we’ll see partway through this episode — extended, because the generator deliberately doesn’t do everything.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails generate authentication
</code></pre></div></div>

<p>Here’s the full list of what that one command produced:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>create  app/views/passwords/new.html.erb
create  app/views/passwords/edit.html.erb
create  app/views/sessions/new.html.erb
create  app/models/session.rb
create  app/models/user.rb
create  app/models/current.rb
create  app/controllers/sessions_controller.rb
create  app/controllers/concerns/authentication.rb
create  app/controllers/passwords_controller.rb
create  app/mailers/passwords_mailer.rb
create  app/views/passwords_mailer/reset.html.erb
create  app/views/passwords_mailer/reset.text.erb
insert  app/controllers/application_controller.rb
 route  resources :passwords, param: :token
 route  resource :session
  gsub  Gemfile
  create  db/migrate/..._create_users.rb
  create  db/migrate/..._create_sessions.rb
</code></pre></div></div>

<p>Worth reading every one of these before writing anything of our own, because — unlike a gem — we’re going to be looking directly at this code for the rest of the series.</p>

<h2 id="the-user-model">The User model</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/user.rb</span>
<span class="k">class</span> <span class="nc">User</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">has_secure_password</span>
  <span class="n">has_many</span> <span class="ss">:sessions</span><span class="p">,</span> <span class="ss">dependent: :destroy</span>

  <span class="n">normalizes</span> <span class="ss">:email_address</span><span class="p">,</span> <span class="ss">with: </span><span class="o">-&gt;</span><span class="p">(</span><span class="n">e</span><span class="p">)</span> <span class="p">{</span> <span class="n">e</span><span class="p">.</span><span class="nf">strip</span><span class="p">.</span><span class="nf">downcase</span> <span class="p">}</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">has_secure_password</code> is the one line doing the heavy lifting, and it’s not a Devise-style black box — it’s plain Rails, from <code class="language-plaintext highlighter-rouge">ActiveModel::SecurePassword</code>. It expects a <code class="language-plaintext highlighter-rouge">password_digest</code> column, adds <code class="language-plaintext highlighter-rouge">password=</code>/<code class="language-plaintext highlighter-rouge">password_confirmation=</code> virtual attributes that hash with bcrypt on assignment, and adds an <code class="language-plaintext highlighter-rouge">authenticate</code> instance method. We’ll come back to it below, because it does more than that — the password reset mechanism later in this post comes from the exact same line.</p>

<p>(Worth being precise about one thing: <code class="language-plaintext highlighter-rouge">has_secure_password</code> itself is Rails code, part of the <code class="language-plaintext highlighter-rouge">activemodel</code> gem — not part of <code class="language-plaintext highlighter-rouge">bcrypt</code>. The <code class="language-plaintext highlighter-rouge">bcrypt</code> gem in the Gemfile only supplies the <code class="language-plaintext highlighter-rouge">BCrypt::Password</code> class Rails uses internally to actually hash and compare passwords; the macro, the validations, and the reset-token logic all live in Rails itself, readable like any other framework code.)</p>

<p><code class="language-plaintext highlighter-rouge">normalizes :email_address</code> is a smaller but genuinely useful Rails feature: it guarantees <code class="language-plaintext highlighter-rouge">"Mario@Example.com "</code> and <code class="language-plaintext highlighter-rouge">"mario@example.com"</code> are treated as the same address everywhere — on save, and on every subsequent lookup — without us having to remember to call <code class="language-plaintext highlighter-rouge">.downcase.strip</code> by hand at every call site.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/..._create_users.rb</span>
<span class="n">create_table</span> <span class="ss">:users</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:email_address</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:password_digest</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
<span class="k">end</span>
<span class="n">add_index</span> <span class="ss">:users</span><span class="p">,</span> <span class="ss">:email_address</span><span class="p">,</span> <span class="ss">unique: </span><span class="kp">true</span>
</code></pre></div></div>

<p>Note what’s <em>not</em> here: no <code class="language-plaintext highlighter-rouge">name</code>, no role, nothing marketplace-specific. This is intentionally the minimum viable authenticatable record. Everything from episode 1’s domain design — <code class="language-plaintext highlighter-rouge">has_many :services</code>, <code class="language-plaintext highlighter-rouge">has_many :bookings</code> — gets added when we actually build <code class="language-plaintext highlighter-rouge">Service</code> and <code class="language-plaintext highlighter-rouge">Booking</code>, not now. Adding those associations today would technically work (Rails resolves association class names lazily), but it would be code referring to models that don’t exist yet, which is worse for anyone reading this repo top to bottom.</p>

<h2 id="sessions-and-current-how-whos-logged-in-is-tracked">Sessions and Current: how “who’s logged in” is tracked</h2>

<p>This is the part that looks the most different from what you’d expect coming from Devise, and it’s worth slowing down on.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/session.rb</span>
<span class="k">class</span> <span class="nc">Session</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">belongs_to</span> <span class="ss">:user</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/..._create_sessions.rb</span>
<span class="n">create_table</span> <span class="ss">:sessions</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">references</span> <span class="ss">:user</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span><span class="p">,</span> <span class="ss">foreign_key: </span><span class="kp">true</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:ip_address</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:user_agent</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
<span class="k">end</span>
</code></pre></div></div>

<p>A <code class="language-plaintext highlighter-rouge">Session</code> is a <strong>database row</strong>, not just an encrypted cookie. Every time someone signs in, a new <code class="language-plaintext highlighter-rouge">Session</code> record gets created, storing which user, from what IP, with what browser. The browser only ever holds the session’s <em>id</em>, signed inside a cookie so it can’t be tampered with — the actual session state lives server-side.</p>

<p>The practical upside: you can see every active session for a user (<code class="language-plaintext highlighter-rouge">user.sessions</code>), and revoke one individually — sign a specific device out — just by deleting that row. A pure cookie-based session can’t do that; you can only invalidate <em>all</em> sessions at once (e.g. by rotating a secret).</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/current.rb</span>
<span class="k">class</span> <span class="nc">Current</span> <span class="o">&lt;</span> <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">CurrentAttributes</span>
  <span class="n">attribute</span> <span class="ss">:session</span>
  <span class="n">delegate</span> <span class="ss">:user</span><span class="p">,</span> <span class="ss">to: :session</span><span class="p">,</span> <span class="ss">allow_nil: </span><span class="kp">true</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">ActiveSupport::CurrentAttributes</code> is a Rails mechanism for per-request global state — safer than a plain global variable because it’s automatically reset between requests (and between test cases), so there’s no risk of one request’s user leaking into the next. <code class="language-plaintext highlighter-rouge">Current.session</code> holds the current <code class="language-plaintext highlighter-rouge">Session</code> record for this request; <code class="language-plaintext highlighter-rouge">Current.user</code> is just <code class="language-plaintext highlighter-rouge">Current.session.user</code>, available anywhere in the app via <code class="language-plaintext highlighter-rouge">Current.user</code>, no need to pass it down through every method call.</p>

<h2 id="the-authentication-concern-secure-by-default">The Authentication concern: secure by default</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/concerns/authentication.rb</span>
<span class="k">module</span> <span class="nn">Authentication</span>
  <span class="kp">extend</span> <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Concern</span>

  <span class="n">included</span> <span class="k">do</span>
    <span class="n">before_action</span> <span class="ss">:require_authentication</span>
    <span class="n">helper_method</span> <span class="ss">:authenticated?</span>
  <span class="k">end</span>

  <span class="n">class_methods</span> <span class="k">do</span>
    <span class="k">def</span> <span class="nf">allow_unauthenticated_access</span><span class="p">(</span><span class="o">**</span><span class="n">options</span><span class="p">)</span>
      <span class="n">skip_before_action</span> <span class="ss">:require_authentication</span><span class="p">,</span> <span class="o">**</span><span class="n">options</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="kp">private</span>
    <span class="k">def</span> <span class="nf">resume_session</span>
      <span class="no">Current</span><span class="p">.</span><span class="nf">session</span> <span class="o">||=</span> <span class="n">find_session_by_cookie</span>
    <span class="k">end</span>

    <span class="k">def</span> <span class="nf">find_session_by_cookie</span>
      <span class="no">Session</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">id: </span><span class="n">cookies</span><span class="p">.</span><span class="nf">signed</span><span class="p">[</span><span class="ss">:session_id</span><span class="p">])</span> <span class="k">if</span> <span class="n">cookies</span><span class="p">.</span><span class="nf">signed</span><span class="p">[</span><span class="ss">:session_id</span><span class="p">]</span>
    <span class="k">end</span>

    <span class="k">def</span> <span class="nf">start_new_session_for</span><span class="p">(</span><span class="n">user</span><span class="p">)</span>
      <span class="n">user</span><span class="p">.</span><span class="nf">sessions</span><span class="p">.</span><span class="nf">create!</span><span class="p">(</span><span class="ss">user_agent: </span><span class="n">request</span><span class="p">.</span><span class="nf">user_agent</span><span class="p">,</span> <span class="ss">ip_address: </span><span class="n">request</span><span class="p">.</span><span class="nf">remote_ip</span><span class="p">).</span><span class="nf">tap</span> <span class="k">do</span> <span class="o">|</span><span class="n">session</span><span class="o">|</span>
        <span class="no">Current</span><span class="p">.</span><span class="nf">session</span> <span class="o">=</span> <span class="n">session</span>
        <span class="n">cookies</span><span class="p">.</span><span class="nf">signed</span><span class="p">.</span><span class="nf">permanent</span><span class="p">[</span><span class="ss">:session_id</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span> <span class="ss">value: </span><span class="n">session</span><span class="p">.</span><span class="nf">id</span><span class="p">,</span> <span class="ss">httponly: </span><span class="kp">true</span><span class="p">,</span> <span class="ss">same_site: :lax</span> <span class="p">}</span>
      <span class="k">end</span>
    <span class="k">end</span>
    <span class="c1"># ...</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/application_controller.rb</span>
<span class="k">class</span> <span class="nc">ApplicationController</span> <span class="o">&lt;</span> <span class="no">ActionController</span><span class="o">::</span><span class="no">Base</span>
  <span class="kp">include</span> <span class="no">Authentication</span>
  <span class="c1"># ...</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The line that matters most here is <code class="language-plaintext highlighter-rouge">before_action :require_authentication</code> — included into <code class="language-plaintext highlighter-rouge">ApplicationController</code> itself, which means <strong>every controller in the app requires a signed-in user by default</strong>, unless it explicitly opts out with <code class="language-plaintext highlighter-rouge">allow_unauthenticated_access</code>. This is the opposite of how most tutorials build auth (where you protect specific actions), and it’s a deliberate, sensible default for an app whose whole point is accounts: it’s much safer to have to remember to make a page public than to have to remember to protect it.</p>

<p>The signed cookie itself is worth reading closely too: <code class="language-plaintext highlighter-rouge">cookies.signed.permanent[...]</code> — signed, so the value can’t be forged (Rails verifies it against a secret before trusting it); <code class="language-plaintext highlighter-rouge">permanent</code>, so it’s set to expire 20 years out rather than at the end of the browser session; <code class="language-plaintext highlighter-rouge">httponly: true</code>, so client-side JavaScript can never read it (closing off a whole category of session-theft via XSS); <code class="language-plaintext highlighter-rouge">same_site: :lax</code>, a baseline CSRF protection that stops the cookie from being sent on cross-site requests except top-level navigation.</p>

<h3 id="a-gotcha-our-public-landing-page-just-broke">A gotcha: our public landing page just broke</h3>

<p>Because <code class="language-plaintext highlighter-rouge">require_authentication</code> is now the default everywhere, episode 1’s landing page — meant to be the first thing anyone sees, logged in or not — would redirect to the sign-in page the moment we ran the generator. <code class="language-plaintext highlighter-rouge">PagesController</code> needed an explicit opt-out:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/pages_controller.rb</span>
<span class="k">class</span> <span class="nc">PagesController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="n">allow_unauthenticated_access</span>

  <span class="k">def</span> <span class="nf">home</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>This is exactly the trade-off “secure by default” makes on purpose: you <em>will</em> hit this the first time you add the generator to an app with existing public pages, and the fix is one line, but you have to know to look for it.</p>

<h2 id="signing-in-authenticate_by-and-rate-limiting">Signing in: <code class="language-plaintext highlighter-rouge">authenticate_by</code> and rate limiting</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/sessions_controller.rb</span>
<span class="k">class</span> <span class="nc">SessionsController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="n">allow_unauthenticated_access</span> <span class="ss">only: </span><span class="sx">%i[ new create ]</span>
  <span class="n">rate_limit</span> <span class="ss">to: </span><span class="mi">10</span><span class="p">,</span> <span class="ss">within: </span><span class="mi">3</span><span class="p">.</span><span class="nf">minutes</span><span class="p">,</span> <span class="ss">only: :create</span><span class="p">,</span> <span class="ss">with: </span><span class="o">-&gt;</span> <span class="p">{</span> <span class="n">redirect_to</span> <span class="n">new_session_path</span><span class="p">,</span> <span class="ss">alert: </span><span class="s2">"Try again later."</span> <span class="p">}</span>

  <span class="k">def</span> <span class="nf">create</span>
    <span class="k">if</span> <span class="n">user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">authenticate_by</span><span class="p">(</span><span class="n">params</span><span class="p">.</span><span class="nf">permit</span><span class="p">(</span><span class="ss">:email_address</span><span class="p">,</span> <span class="ss">:password</span><span class="p">))</span>
      <span class="n">start_new_session_for</span> <span class="n">user</span>
      <span class="n">redirect_to</span> <span class="n">after_authentication_url</span>
    <span class="k">else</span>
      <span class="n">redirect_to</span> <span class="n">new_session_path</span><span class="p">,</span> <span class="ss">alert: </span><span class="s2">"Try another email address or password."</span>
    <span class="k">end</span>
  <span class="k">end</span>
  <span class="c1"># ...</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Two details here are genuinely worth understanding, not just copying.</p>

<p><strong><code class="language-plaintext highlighter-rouge">User.authenticate_by</code></strong> looks like it’s just <code class="language-plaintext highlighter-rouge">find_by(email_address:) + authenticate(password)</code> in one call, but it’s specifically designed to close a timing attack. Straight from the Rails source comment:</p>

<blockquote>
  <p>Regardless of whether a record is found, <code class="language-plaintext highlighter-rouge">authenticate_by</code> will cryptographically digest the given password attributes. This behavior helps mitigate timing-based enumeration attacks, wherein an attacker can determine if a passworded record exists even without knowing the password.</p>
</blockquote>

<p>Concretely: if you looked up the user first and only ran the bcrypt comparison when one was found, a request for a <em>nonexistent</em> email would return almost instantly (no bcrypt work), while a request for a <em>real</em> email with the wrong password would take the ~100ms bcrypt takes. That timing difference is enough for an attacker to enumerate which emails have accounts, purely by measuring response times. <code class="language-plaintext highlighter-rouge">authenticate_by</code> always does the expensive digest work, found or not, so both cases take the same time.</p>

<p><strong><code class="language-plaintext highlighter-rouge">rate_limit</code></strong> is a native Rails 8 feature (<code class="language-plaintext highlighter-rouge">ActionController::RateLimiting</code>, no gem) — ten attempts per three minutes on this action, backed by <code class="language-plaintext highlighter-rouge">Rails.cache</code>, redirecting with a message instead of just failing silently. Brute-forcing a password by trying thousands of combinations is meaningfully slowed down by this one line.</p>

<h2 id="the-missing-piece-sign-up">The missing piece: sign-up</h2>

<p>Try the generator’s routes and you’ll find <code class="language-plaintext highlighter-rouge">new_session_path</code> and <code class="language-plaintext highlighter-rouge">new_password_path</code> (reset), but nothing to create a <code class="language-plaintext highlighter-rouge">User</code> in the first place. That’s not an oversight — the generator can’t guess your app’s sign-up requirements (invite-only? email confirmation? OAuth?) so it leaves that entirely to you.</p>

<p>For VicinoTe, anyone should be able to sign up — it’s a marketplace, not an admin tool — so we add it ourselves, following the same shape as the generated code:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/registrations_controller.rb</span>
<span class="k">class</span> <span class="nc">RegistrationsController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="n">allow_unauthenticated_access</span>

  <span class="k">def</span> <span class="nf">new</span>
    <span class="vi">@user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">new</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">create</span>
    <span class="vi">@user</span> <span class="o">=</span> <span class="no">User</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">user_params</span><span class="p">)</span>

    <span class="k">if</span> <span class="vi">@user</span><span class="p">.</span><span class="nf">save</span>
      <span class="n">start_new_session_for</span> <span class="vi">@user</span>
      <span class="n">redirect_to</span> <span class="n">root_path</span><span class="p">,</span> <span class="ss">notice: </span><span class="s2">"Welcome to VicinoTe!"</span>
    <span class="k">else</span>
      <span class="n">render</span> <span class="ss">:new</span><span class="p">,</span> <span class="ss">status: :unprocessable_entity</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">user_params</span>
    <span class="n">params</span><span class="p">.</span><span class="nf">require</span><span class="p">(</span><span class="ss">:user</span><span class="p">).</span><span class="nf">permit</span><span class="p">(</span><span class="ss">:email_address</span><span class="p">,</span> <span class="ss">:password</span><span class="p">,</span> <span class="ss">:password_confirmation</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">resource</span> <span class="ss">:registration</span><span class="p">,</span> <span class="ss">only: </span><span class="p">[</span><span class="ss">:new</span><span class="p">,</span> <span class="ss">:create</span><span class="p">]</span>
</code></pre></div></div>

<p>Nothing here is new machinery — <code class="language-plaintext highlighter-rouge">start_new_session_for</code> is the same private method <code class="language-plaintext highlighter-rouge">SessionsController</code> uses, callable because it’s defined in the <code class="language-plaintext highlighter-rouge">Authentication</code> concern that’s mixed into every controller. Sign-up, in this design, is just “create a <code class="language-plaintext highlighter-rouge">User</code>, then do exactly what signing in does.”</p>

<h2 id="password-reset-without-a-reset-token-column">Password reset without a reset-token column</h2>

<p>This is the detail I found most worth digging into. <code class="language-plaintext highlighter-rouge">PasswordsController</code> calls <code class="language-plaintext highlighter-rouge">User.find_by_password_reset_token!(token)</code> — a method that doesn’t exist anywhere in <code class="language-plaintext highlighter-rouge">user.rb</code>. It isn’t hand-written, and there’s no <code class="language-plaintext highlighter-rouge">reset_password_token</code> column in the <code class="language-plaintext highlighter-rouge">users</code> table either. So where does it come from?</p>

<p>It comes from <code class="language-plaintext highlighter-rouge">has_secure_password</code> itself. Reading the Rails source (<code class="language-plaintext highlighter-rouge">ActiveModel::SecurePassword</code>):</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">has_secure_password</span><span class="p">(</span><span class="n">attribute</span> <span class="o">=</span> <span class="ss">:password</span><span class="p">,</span> <span class="ss">validations: </span><span class="kp">true</span><span class="p">,</span> <span class="ss">reset_token: </span><span class="kp">true</span><span class="p">)</span>
  <span class="c1"># ...</span>
  <span class="k">if</span> <span class="n">reset_token</span> <span class="o">&amp;&amp;</span> <span class="nb">respond_to?</span><span class="p">(</span><span class="ss">:generates_token_for</span><span class="p">)</span>
    <span class="n">generates_token_for</span> <span class="ss">:"</span><span class="si">#{</span><span class="n">attribute</span><span class="si">}</span><span class="ss">_reset"</span><span class="p">,</span> <span class="ss">expires_in: </span><span class="mi">15</span><span class="p">.</span><span class="nf">minutes</span> <span class="k">do</span>
      <span class="n">public_send</span><span class="p">(</span><span class="ss">:"</span><span class="si">#{</span><span class="n">attribute</span><span class="si">}</span><span class="ss">_salt"</span><span class="p">)</span><span class="o">&amp;</span><span class="p">.</span><span class="nf">last</span><span class="p">(</span><span class="mi">10</span><span class="p">)</span>
    <span class="k">end</span>
    <span class="c1"># defines find_by_password_reset_token / find_by_password_reset_token!</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">generates_token_for</code> is Rails’ general-purpose mechanism for generating a verifiable, expiring token <em>without storing it anywhere</em> — it’s a signed, encoded payload (via <code class="language-plaintext highlighter-rouge">ActiveSupport::MessageVerifier</code>), checked and decoded on the way back in. Here, <code class="language-plaintext highlighter-rouge">has_secure_password</code> uses it automatically, keyed off the last 10 characters of the password’s bcrypt salt.</p>

<p>That detail is the clever part: the salt changes every time the password changes (bcrypt generates a fresh one on every hash), which means <strong>a reset link is automatically invalidated the instant the password it was generated for actually changes</strong> — with no extra “used” flag, no token cleanup job, nothing to store. The token expires after 15 minutes either way, and <code class="language-plaintext highlighter-rouge">find_by_password_reset_token!</code> raises <code class="language-plaintext highlighter-rouge">ActiveSupport::MessageVerifier::InvalidSignature</code> on an expired or tampered token, which <code class="language-plaintext highlighter-rouge">PasswordsController</code> rescues into a friendly redirect.</p>

<p>We’re not building the full flow out today (that needs a real mailer setup, which will make more sense once VicinoTe is deployed somewhere), but it’s worth knowing this exists, fully wired, the moment <code class="language-plaintext highlighter-rouge">has_secure_password</code> is on the model — one line, and a working, secure password-reset mechanism came with it.</p>

<h2 id="a-small-nav-bar-so-any-of-this-is-reachable">A small nav bar, so any of this is reachable</h2>

<p>None of the above has a UI to get to unless something links to it, so the layout gets a minimal header:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%</span> <span class="k">if</span> <span class="n">authenticated?</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;span&gt;</span><span class="cp">&lt;%=</span> <span class="no">Current</span><span class="p">.</span><span class="nf">user</span><span class="p">.</span><span class="nf">email_address</span> <span class="cp">%&gt;</span><span class="nt">&lt;/span&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">button_to</span> <span class="s2">"Sign out"</span><span class="p">,</span> <span class="n">session_path</span><span class="p">,</span> <span class="ss">method: :delete</span> <span class="cp">%&gt;</span>
<span class="cp">&lt;%</span> <span class="k">else</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">link_to</span> <span class="s2">"Sign in"</span><span class="p">,</span> <span class="n">new_session_path</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">link_to</span> <span class="s2">"Sign up"</span><span class="p">,</span> <span class="n">new_registration_path</span> <span class="cp">%&gt;</span>
<span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">authenticated?</code> is the <code class="language-plaintext highlighter-rouge">helper_method</code> the <code class="language-plaintext highlighter-rouge">Authentication</code> concern exposes — it’s just <code class="language-plaintext highlighter-rouge">resume_session</code>, reused to answer “is anyone signed in” without redirecting.</p>

<h2 id="trying-it">Trying it</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/dev
</code></pre></div></div>

<p>Sign up with any email and password — you land back on the homepage, already signed in, your email in the top bar. Sign out, then sign back in; try a wrong password and you’ll get the friendly error rather than a stack trace. None of this touches <code class="language-plaintext highlighter-rouge">Service</code> or <code class="language-plaintext highlighter-rouge">Booking</code> yet — the point of this episode is that accounts work, end to end, before anything gets built on top of them.</p>

<h2 id="whats-next">What’s next</h2>

<p>Episode 3 builds <code class="language-plaintext highlighter-rouge">Service</code> and <code class="language-plaintext highlighter-rouge">Category</code>, and finally wires up the <code class="language-plaintext highlighter-rouge">has_many :services</code> association from episode 1’s design — the moment a signed-in user can actually list something they offer.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[Episode 1 ended with a design decision and nothing to log into: one User model, no role column, provider and customer both emerging from associations we hadn’t written any code for yet. This episode writes that model — and, with it, the whole authentication system around it.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://antoninoscaffidi.github.io/assets/images/vicinote-banner.png" /><media:content medium="image" url="https://antoninoscaffidi.github.io/assets/images/vicinote-banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">WhatsApp with Rails: Project Setup and Contacts</title><link href="https://antoninoscaffidi.github.io/whatsapp-rails-setup-and-contacts/" rel="alternate" type="text/html" title="WhatsApp with Rails: Project Setup and Contacts" /><published>2026-08-11T07:00:00+00:00</published><updated>2026-08-11T07:00:00+00:00</updated><id>https://antoninoscaffidi.github.io/whatsapp-rails-setup-and-contacts</id><content type="html" xml:base="https://antoninoscaffidi.github.io/whatsapp-rails-setup-and-contacts/"><![CDATA[<p>This is the first episode of <strong>WhatsApp with Rails</strong>, a short series that came out of real client work: a customer needed WhatsApp messaging integrated into a Rails app, and before building the full thing, I wanted a small, working demo to show them — and to make sure I actually understood the integration myself, not just followed a tutorial.</p>

<p>The scope here is deliberately narrow. No CRM, no campaigns, no message templates, no statistics — that’s the real project, and it isn’t what this series is about. This series is about one thing: getting a real WhatsApp message sent from a Rails app, understanding what’s actually happening when you do. We’ll build it two ways — through Twilio (this episode and the next), and then, as a separate episode, calling Meta’s WhatsApp Cloud API directly, without Twilio in between, so the two approaches can be compared.</p>

<p>Code is on GitHub, tagged <a href="https://github.com/AntoninoScaffidi/whatsapp-with-rails/tree/episode-1"><code class="language-plaintext highlighter-rouge">episode-1</code></a>, in the <a href="https://github.com/AntoninoScaffidi/whatsapp-with-rails">whatsapp-with-rails</a> repo.</p>

<h2 id="what-were-building-and-why-twilio-first">What we’re building, and why Twilio first</h2>

<p>Before writing any code, it’s worth knowing what Twilio actually is here, because “WhatsApp API” is a slightly confusing phrase — there are two ways to get at it.</p>

<p><strong>Meta owns WhatsApp</strong>, and Meta does offer a direct API (the WhatsApp Business Cloud API) to send and receive messages programmatically. You <em>can</em> integrate with it directly. But Meta’s API requires a Meta Business account, app review, phone number registration through Meta’s own system, and its authentication and webhook setup are Meta-specific.</p>

<p><strong>Twilio sits on top of that.</strong> It’s a communications platform that already has the Meta integration done, wrapped in a simpler, well-documented REST API (and a Ruby gem) that looks and feels the same whether you’re sending WhatsApp, SMS, or a voice call. You still need WhatsApp-approved sender registration either way, but Twilio’s sandbox mode lets you start sending test messages in minutes, with no waiting on Meta’s approval process.</p>

<p>That’s why episode 1 and 2 use Twilio: it’s the fastest path to a real message actually arriving on a phone, and it’s genuinely a legitimate production choice, not just a shortcut — plenty of real products run their WhatsApp messaging through Twilio permanently. Episode 3 then does the same job through Meta’s API directly, so the trade-off (simplicity and speed vs. one less service in the middle) is visible in actual code, not just in the abstract.</p>

<h2 id="creating-the-app">Creating the app</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rails new whatsapp-with-rails <span class="nt">-d</span> postgresql <span class="nt">--css</span> tailwind
</code></pre></div></div>

<p>Same reasoning as the other series on this blog: PostgreSQL because it’s a reasonable default for anything that might grow, Tailwind to keep the views readable without a separate stylesheet. Nothing here is Twilio-specific yet — this command is identical to how you’d start any small Rails app.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails db:create
</code></pre></div></div>

<h2 id="the-contact-model">The Contact model</h2>

<p>The real project has a full CRM — segments, tags, GDPR consent tracking, import/export. Here we need exactly two fields: who, and what number to message.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails generate model Contact name:string whatsapp_number:string
</code></pre></div></div>

<p>Two things worth doing before migrating: making both fields required, and validating the phone number format properly — because WhatsApp messaging <em>requires</em> a specific number format, and it’s much better to catch a malformed number in a form validation than in a failed API call later.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/..._create_contacts.rb</span>
<span class="n">create_table</span> <span class="ss">:contacts</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:name</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
  <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:whatsapp_number</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>

  <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/contact.rb</span>
<span class="k">class</span> <span class="nc">Contact</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">validates</span> <span class="ss">:name</span><span class="p">,</span> <span class="ss">presence: </span><span class="kp">true</span>
  <span class="n">validates</span> <span class="ss">:whatsapp_number</span><span class="p">,</span> <span class="ss">presence: </span><span class="kp">true</span><span class="p">,</span> <span class="ss">format: </span><span class="p">{</span>
    <span class="ss">with: </span><span class="sr">/\A\+[1-9]\d{6,14}\z/</span><span class="p">,</span>
    <span class="ss">message: </span><span class="s2">"must be in E.164 format, e.g. +391234567890"</span>
  <span class="p">}</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="why-e164-specifically">Why E.164, specifically</h3>

<p><strong>E.164</strong> is the ITU’s international standard for phone number formatting — the format that guarantees a number is unambiguous anywhere in the world. The shape is <code class="language-plaintext highlighter-rouge">+</code> followed by the country calling code, followed by the subscriber number, no spaces, no dashes, no parentheses, no leading zero on the local part where the country’s dialing plan would normally have one.</p>

<p>A concrete example: an Italian mobile number you’d normally write as <code class="language-plaintext highlighter-rouge">333 1234567</code> becomes <code class="language-plaintext highlighter-rouge">+393331234567</code> in E.164 — country code <code class="language-plaintext highlighter-rouge">39</code>, then the number as-is (Italian mobile numbers don’t carry a leading trunk zero to begin with).</p>

<p>Why this matters here specifically: Twilio’s WhatsApp API — and the underlying WhatsApp protocol itself — requires E.164. Without a single unambiguous format, <code class="language-plaintext highlighter-rouge">333-1234567</code> is meaningless out of context: is it missing a country code? Does it need a leading zero stripped? E.164 removes every one of those questions.</p>

<p>The regex mirrors the standard directly: <code class="language-plaintext highlighter-rouge">+</code>, then a digit <code class="language-plaintext highlighter-rouge">1</code>–<code class="language-plaintext highlighter-rouge">9</code> (country codes never start with <code class="language-plaintext highlighter-rouge">0</code>), then 6 to 14 more digits — matching E.164’s actual hard limit of 15 digits total after the <code class="language-plaintext highlighter-rouge">+</code>.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails db:migrate
</code></pre></div></div>

<h2 id="routes-controller-contacts-list">Routes, controller, contacts list</h2>

<p>Just enough REST to list contacts and add one — no edit, no delete, not needed for this demo:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">resources</span> <span class="ss">:contacts</span><span class="p">,</span> <span class="ss">only: </span><span class="p">[</span><span class="ss">:index</span><span class="p">,</span> <span class="ss">:new</span><span class="p">,</span> <span class="ss">:create</span><span class="p">]</span>
<span class="n">root</span> <span class="s2">"contacts#index"</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/contacts_controller.rb</span>
<span class="k">class</span> <span class="nc">ContactsController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="k">def</span> <span class="nf">index</span>
    <span class="vi">@contacts</span> <span class="o">=</span> <span class="no">Contact</span><span class="p">.</span><span class="nf">order</span><span class="p">(</span><span class="ss">:name</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">new</span>
    <span class="vi">@contact</span> <span class="o">=</span> <span class="no">Contact</span><span class="p">.</span><span class="nf">new</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">create</span>
    <span class="vi">@contact</span> <span class="o">=</span> <span class="no">Contact</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">contact_params</span><span class="p">)</span>

    <span class="k">if</span> <span class="vi">@contact</span><span class="p">.</span><span class="nf">save</span>
      <span class="n">redirect_to</span> <span class="n">contacts_path</span><span class="p">,</span> <span class="ss">notice: </span><span class="s2">"Contact added."</span>
    <span class="k">else</span>
      <span class="n">render</span> <span class="ss">:new</span><span class="p">,</span> <span class="ss">status: :unprocessable_entity</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">contact_params</span>
    <span class="n">params</span><span class="p">.</span><span class="nf">require</span><span class="p">(</span><span class="ss">:contact</span><span class="p">).</span><span class="nf">permit</span><span class="p">(</span><span class="ss">:name</span><span class="p">,</span> <span class="ss">:whatsapp_number</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Two things worth calling out, both carried over from lessons in earlier series on this blog: <code class="language-plaintext highlighter-rouge">create</code> <strong>redirects</strong> after a successful save (the Post/Redirect/Get pattern — see <a href="/persisting-conversations-with-activerecord/">episode 3 of AI with Ruby</a> for why that specifically matters for Turbo Drive), and a failed save <strong>renders with <code class="language-plaintext highlighter-rouge">status: :unprocessable_entity</code></strong> rather than the default <code class="language-plaintext highlighter-rouge">200 OK</code> — the correct HTTP status for “the request was understood but the data was invalid,” and something Turbo itself checks for when deciding whether to treat a form response as an error.</p>

<p>The views are a plain list and a plain form — nothing new here, so I won’t repeat them in full; they’re in the repo if you want to see the Tailwind markup.</p>

<h2 id="trying-it">Trying it</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/dev
</code></pre></div></div>

<p>Open <code class="language-plaintext highlighter-rouge">http://127.0.0.1:3000</code>: an empty contacts list, an “Add contact” button, a form. Try submitting a number without the <code class="language-plaintext highlighter-rouge">+</code> — the validation catches it, with the exact error message explaining what’s expected.</p>

<p>Nothing here talks to Twilio yet. That’s deliberate — episode 2 wires in the <code class="language-plaintext highlighter-rouge">twilio-ruby</code> gem and actually sends a message to one of these contacts.</p>

<h2 id="whats-next">What’s next</h2>

<p>Episode 2 adds the <code class="language-plaintext highlighter-rouge">twilio-ruby</code> gem, a form to compose a message, and the API call that sends it — plus what a Twilio WhatsApp sandbox actually is and why you need one before Meta approves your own sender.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[This is the first episode of WhatsApp with Rails, a short series that came out of real client work: a customer needed WhatsApp messaging integrated into a Rails app, and before building the full thing, I wanted a small, working demo to show them — and to make sure I actually understood the integration myself, not just followed a tutorial.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://antoninoscaffidi.github.io/assets/images/whatsapp-with-rails-banner.png" /><media:content medium="image" url="https://antoninoscaffidi.github.io/assets/images/whatsapp-with-rails-banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">Ruby Deep Dive: Variables and Basic Types</title><link href="https://antoninoscaffidi.github.io/ruby-variables-and-basic-types/" rel="alternate" type="text/html" title="Ruby Deep Dive: Variables and Basic Types" /><published>2026-08-09T07:00:00+00:00</published><updated>2026-08-09T07:00:00+00:00</updated><id>https://antoninoscaffidi.github.io/ruby-variables-and-basic-types</id><content type="html" xml:base="https://antoninoscaffidi.github.io/ruby-variables-and-basic-types/"><![CDATA[<p>This is the first episode of <strong>Ruby Deep Dive</strong>, a series that’s a bit different from the other two on this blog. VicinoTe and AI with Ruby are both about <em>building things</em>. This one is about <em>understanding the language itself</em> — Ruby, on its own, no Rails, no framework. It doesn’t run on a schedule; it gets written whenever there’s time to go through a concept properly.</p>

<p>Every episode comes with exercises in a companion repo: <a href="https://github.com/AntoninoScaffidi/ruby-deep-dive">ruby-deep-dive</a>, tagged <a href="https://github.com/AntoninoScaffidi/ruby-deep-dive/tree/episode-1"><code class="language-plaintext highlighter-rouge">episode-1</code></a>. Each one is a method stub to fill in, checked by a test. Read the episode, then go make the tests pass yourself — the exercises are deliberately left unsolved in the repo, because doing them is the point.</p>

<p>We’re starting at the real beginning: what a variable is, and Ruby’s basic types.</p>

<h2 id="what-a-variable-actually-is">What a variable actually is</h2>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">language</span> <span class="o">=</span> <span class="s2">"Ruby"</span>
</code></pre></div></div>

<p>This line does two things: it creates a <code class="language-plaintext highlighter-rouge">String</code> object holding the text <code class="language-plaintext highlighter-rouge">"Ruby"</code>, and it makes the name <code class="language-plaintext highlighter-rouge">language</code> point to it. That’s worth being precise about, because “variable” can be a misleading word if you’re coming from a language where variables are boxes that hold values directly. In Ruby, a variable is a <strong>label</strong>, and what it labels is an <strong>object</strong> living somewhere in memory. Two variables can point to the very same object:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">a</span> <span class="o">=</span> <span class="s2">"Ruby"</span>
<span class="n">b</span> <span class="o">=</span> <span class="n">a</span>
<span class="n">b</span><span class="p">.</span><span class="nf">upcase!</span>
<span class="n">a</span> <span class="c1">#=&gt; "RUBY"</span>
</code></pre></div></div>

<p>Changing <code class="language-plaintext highlighter-rouge">b</code> changed <code class="language-plaintext highlighter-rouge">a</code> too, because they were never two separate strings — they were two labels on the same one. <code class="language-plaintext highlighter-rouge">upcase!</code> (with the <code class="language-plaintext highlighter-rouge">!</code>) mutates the string in place, rather than returning a new one. This distinction — is a variable a label or a box — is the root of a lot of confusion later on with method arguments and mutation, so it’s worth having clearly in mind from episode 1.</p>

<p>Ruby doesn’t require you to declare a variable’s type. <code class="language-plaintext highlighter-rouge">language</code> isn’t “a string variable” — it’s just a name currently pointing at a <code class="language-plaintext highlighter-rouge">String</code>. Assign it something else, and it points at that instead:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">language</span> <span class="o">=</span> <span class="s2">"Ruby"</span>
<span class="n">language</span> <span class="o">=</span> <span class="mi">42</span>
</code></pre></div></div>

<p>Both lines are completely legal, one after the other. The variable didn’t change type — it just points somewhere new.</p>

<h2 id="local-variable-naming">Local variable naming</h2>

<p>A local variable name starts with a lowercase letter or an underscore, and by convention uses <code class="language-plaintext highlighter-rouge">snake_case</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">favorite_language</span> <span class="o">=</span> <span class="s2">"Ruby"</span>
<span class="n">_unused</span> <span class="o">=</span> <span class="s2">"starts with an underscore, often used to mark 'I know I'm not using this'"</span>
</code></pre></div></div>

<p>Ruby cares about this convention more than most languages: <code class="language-plaintext highlighter-rouge">CamelCase</code> names are reserved for constants and class/module names, so <code class="language-plaintext highlighter-rouge">FavoriteLanguage = "Ruby"</code> doesn’t create a local variable — it creates a <em>constant</em>, a different kind of thing entirely, one Ruby will warn you about if you reassign it.</p>

<h2 id="the-basic-types">The basic types</h2>

<h3 id="string">String</h3>

<p>Text, in double or single quotes. The difference matters: double-quoted strings support <strong>interpolation</strong> and escape sequences, single-quoted ones don’t.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">name</span> <span class="o">=</span> <span class="s2">"Antonino"</span>
<span class="s2">"Hello, </span><span class="si">#{</span><span class="nb">name</span><span class="si">}</span><span class="s2">!"</span>   <span class="c1">#=&gt; "Hello, Antonino!"</span>
<span class="s1">'Hello, #{name}!'</span>   <span class="c1">#=&gt; "Hello, #{name}!" (literally, no interpolation)</span>
</code></pre></div></div>

<p>Interpolation — <code class="language-plaintext highlighter-rouge">#{...}</code> inside a double-quoted string — evaluates whatever Ruby expression is inside the braces and inserts its string form. It’s the idiomatic way to build strings in Ruby; reach for it instead of <code class="language-plaintext highlighter-rouge">+</code> concatenation.</p>

<h3 id="symbol">Symbol</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="ss">:ruby</span>
</code></pre></div></div>

<p>A <code class="language-plaintext highlighter-rouge">Symbol</code> looks like a string with a colon in front, and it’s tempting to think of it as “a string that can’t change.” That’s close, but the more useful way to think about it: a <code class="language-plaintext highlighter-rouge">Symbol</code> is a <strong>name</strong>, used as an identifier, not as data to display or manipulate. The same symbol written twice is always the exact same object in memory:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="ss">:ruby</span><span class="p">.</span><span class="nf">object_id</span> <span class="o">==</span> <span class="ss">:ruby</span><span class="p">.</span><span class="nf">object_id</span>  <span class="c1">#=&gt; true</span>
<span class="s2">"ruby"</span><span class="p">.</span><span class="nf">object_id</span> <span class="o">==</span> <span class="s2">"ruby"</span><span class="p">.</span><span class="nf">object_id</span> <span class="c1">#=&gt; false</span>
</code></pre></div></div>

<p>That’s why symbols show up constantly as hash keys and method names in Ruby code — they’re cheap to compare (Ruby just checks if it’s the same object, not character-by-character) and cheap to store (no duplicate copies). The rule of thumb: if the value is something a human will read on screen, it’s probably a <code class="language-plaintext highlighter-rouge">String</code>. If it’s an internal label the program uses to refer to something, it’s probably a <code class="language-plaintext highlighter-rouge">Symbol</code>.</p>

<h3 id="integer-and-float">Integer and Float</h3>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="mi">42</span>        <span class="c1"># Integer</span>
<span class="mf">3.14</span>      <span class="c1"># Float</span>
</code></pre></div></div>

<p>One thing that surprises people coming from other languages: integer division truncates, it doesn’t round or raise an error.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="mi">7</span> <span class="o">/</span> <span class="mi">2</span>     <span class="c1">#=&gt; 3, not 3.5</span>
<span class="mi">7</span> <span class="o">/</span> <span class="mf">2.0</span>   <span class="c1">#=&gt; 3.5</span>
<span class="mf">7.0</span> <span class="o">/</span> <span class="mi">2</span>   <span class="c1">#=&gt; 3.5</span>
</code></pre></div></div>

<p>If either operand is a <code class="language-plaintext highlighter-rouge">Float</code>, the result is a <code class="language-plaintext highlighter-rouge">Float</code>. If both are <code class="language-plaintext highlighter-rouge">Integer</code>, you get integer division. This is exactly why <code class="language-plaintext highlighter-rouge">add_as_float</code> in this episode’s exercises asks you to convert before adding — <code class="language-plaintext highlighter-rouge">2 + 3</code> is <code class="language-plaintext highlighter-rouge">5</code>, an <code class="language-plaintext highlighter-rouge">Integer</code>, no matter how you write the method.</p>

<h3 id="nil-true-and-false">nil, true, and false</h3>

<p><code class="language-plaintext highlighter-rouge">nil</code> represents the absence of a value — not zero, not an empty string, genuinely <em>nothing</em>. <code class="language-plaintext highlighter-rouge">true</code> and <code class="language-plaintext highlighter-rouge">false</code> are the only two values of a separate type, <code class="language-plaintext highlighter-rouge">TrueClass</code> and <code class="language-plaintext highlighter-rouge">FalseClass</code> respectively (yes, <code class="language-plaintext highlighter-rouge">true</code> and <code class="language-plaintext highlighter-rouge">false</code> are each the sole instance of their own class — more on why that’s not as strange as it sounds when we get to “everything is an object” in episode 5).</p>

<p>Here’s the detail that trips up almost everyone arriving from another language: <strong>in Ruby, only <code class="language-plaintext highlighter-rouge">nil</code> and <code class="language-plaintext highlighter-rouge">false</code> are falsy.</strong> Everything else is truthy — including <code class="language-plaintext highlighter-rouge">0</code>, including <code class="language-plaintext highlighter-rouge">""</code> (empty string), including empty arrays and hashes.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="mi">0</span>
  <span class="nb">puts</span> <span class="s2">"this runs"</span>   <span class="c1"># it does! 0 is truthy in Ruby</span>
<span class="k">end</span>

<span class="k">if</span> <span class="s2">""</span>
  <span class="nb">puts</span> <span class="s2">"so does this"</span>  <span class="c1"># also runs</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Coming from JavaScript, Python, or PHP, where <code class="language-plaintext highlighter-rouge">0</code> and <code class="language-plaintext highlighter-rouge">""</code> are falsy, this is the single most common source of “why did my <code class="language-plaintext highlighter-rouge">if</code> do that” confusion. Keep it in mind and it’ll stop surprising you fast.</p>

<h2 id="the-exercises">The exercises</h2>

<p>Head to <a href="https://github.com/AntoninoScaffidi/ruby-deep-dive/blob/main/01-variables-and-basic-types/exercises.rb"><code class="language-plaintext highlighter-rouge">01-variables-and-basic-types/exercises.rb</code></a> in the repo. Seven methods, all empty, all covering something from this post: assignment, interpolation, string-to-integer conversion, symbols, <code class="language-plaintext highlighter-rouge">nil</code> checking, forcing a <code class="language-plaintext highlighter-rouge">Float</code> result, and — deliberately — finding “the other falsy value” without it being named for you directly in the exercise.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/AntoninoScaffidi/ruby-deep-dive.git
<span class="nb">cd </span>ruby-deep-dive
bundle <span class="nb">install
</span>ruby 01-variables-and-basic-types/exercises_test.rb
</code></pre></div></div>

<p>Every test will fail until you fill in the corresponding method. That’s the intended starting state — turn them green one at a time.</p>

<h2 id="whats-next">What’s next</h2>

<p>Episode 2 covers control flow: <code class="language-plaintext highlighter-rouge">if</code>/<code class="language-plaintext highlighter-rouge">unless</code>/<code class="language-plaintext highlighter-rouge">case</code>, loops, and a closer look at truthy/falsy now that the basic types are in place.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[This is the first episode of Ruby Deep Dive, a series that’s a bit different from the other two on this blog. VicinoTe and AI with Ruby are both about building things. This one is about understanding the language itself — Ruby, on its own, no Rails, no framework. It doesn’t run on a schedule; it gets written whenever there’s time to go through a concept properly.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://antoninoscaffidi.github.io/assets/images/ruby-deep-dive-banner.png" /><media:content medium="image" url="https://antoninoscaffidi.github.io/assets/images/ruby-deep-dive-banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">Persisting RubyLLM Conversations with ActiveRecord</title><link href="https://antoninoscaffidi.github.io/persisting-conversations-with-activerecord/" rel="alternate" type="text/html" title="Persisting RubyLLM Conversations with ActiveRecord" /><published>2026-08-09T04:00:00+00:00</published><updated>2026-08-09T04:00:00+00:00</updated><id>https://antoninoscaffidi.github.io/persisting-conversations-with-activerecord</id><content type="html" xml:base="https://antoninoscaffidi.github.io/persisting-conversations-with-activerecord/"><![CDATA[<p>In <a href="/wiring-rubyllm-into-rails/">episode 2</a> we got RubyLLM talking to Rails through a plain form: type a message, get a reply, refresh the page and it’s gone. Every <code class="language-plaintext highlighter-rouge">RubyLLM.chat</code> call created a brand new, in-memory session that lived only for the duration of that one request.</p>

<p>This episode fixes that. By the end, conversations survive a page refresh, live in the database, and the model actually remembers what was said earlier in the same conversation — because we’re sending it the real history, not just the latest message.</p>

<p>The code is tagged <a href="https://github.com/AntoninoScaffidi/ai-with-ruby-demo/tree/episode-3"><code class="language-plaintext highlighter-rouge">episode-3</code></a> in the <a href="https://github.com/AntoninoScaffidi/ai-with-ruby-demo">ai-with-ruby-demo</a> repo.</p>

<h2 id="two-ways-to-persist-a-chat">Two ways to persist a chat</h2>

<p>You could write <code class="language-plaintext highlighter-rouge">Conversation</code> and <code class="language-plaintext highlighter-rouge">Message</code> models by hand: a <code class="language-plaintext highlighter-rouge">has_many</code>/<code class="language-plaintext highlighter-rouge">belongs_to</code> pair, a controller that appends to an array of messages, some JSON serialization to store what the model said. It would work, but you’d be rebuilding something RubyLLM already ships as a first-class Rails integration, complete with a generator, migrations, and the <code class="language-plaintext highlighter-rouge">acts_as_chat</code> / <code class="language-plaintext highlighter-rouge">acts_as_message</code> pattern you’ll see in RubyLLM’s own documentation and examples.</p>

<p>We’re using the generator. Not because doing it by hand is wrong, but because the generated code <em>is</em> the idiomatic way to use this gem in Rails — reading it is as instructive as writing it yourself, and you end up with code that matches what you’ll find in RubyLLM’s docs and in other people’s projects.</p>

<h2 id="running-the-generator">Running the generator</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails generate ruby_llm:install chat:Conversation <span class="nt">--skip-active-storage</span>
</code></pre></div></div>

<p>Two things are non-default here, so let’s be explicit about both.</p>

<p><strong><code class="language-plaintext highlighter-rouge">chat:Conversation</code>.</strong> Left alone, the generator names the model that represents a chat session <code class="language-plaintext highlighter-rouge">Chat</code>. That’s a reasonable default, but “conversation” is the word we’ll actually use when we talk about this feature, so we tell the generator to call it <code class="language-plaintext highlighter-rouge">Conversation</code> instead. The generator’s own usage line spells out the syntax: <code class="language-plaintext highlighter-rouge">bin/rails g ruby_llm:install [chat:ChatName] [message:MessageName] ...</code> — you can rename any of the models it creates the same way.</p>

<p><strong><code class="language-plaintext highlighter-rouge">--skip-active-storage</code>.</strong> By default the generator also runs <code class="language-plaintext highlighter-rouge">active_storage:install</code> and adds <code class="language-plaintext highlighter-rouge">has_many_attached :attachments</code> to the message model, so a chat can have file uploads attached to it. We’re not building file attachments in this episode, so we skip it. Nothing stops you from adding it later — the flag only skips it <em>now</em>.</p>

<h2 id="what-the-generator-actually-creates">What the generator actually creates</h2>

<p>This is worth going through file by file, because it creates more than a <code class="language-plaintext highlighter-rouge">Conversation</code> and a <code class="language-plaintext highlighter-rouge">Message</code> — and it’s better to know why than to have unexplained files sitting in the app.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>create  db/migrate/..._create_conversations.rb
create  db/migrate/..._create_messages.rb
create  db/migrate/..._create_tool_calls.rb
create  db/migrate/..._create_models.rb
create  db/migrate/..._add_references_to_conversations_tool_calls_and_messages.rb
create  app/models/conversation.rb
create  app/models/message.rb
create  app/models/tool_call.rb
create  app/models/model.rb
force   config/initializers/ruby_llm.rb
create  app/agents/.gitkeep
create  app/tools/.gitkeep
create  app/schemas/.gitkeep
create  app/prompts/.gitkeep
</code></pre></div></div>

<p><strong><code class="language-plaintext highlighter-rouge">conversations</code> table.</strong> Just an empty shell plus timestamps — a conversation on its own doesn’t need to store anything, it just groups messages together. A <code class="language-plaintext highlighter-rouge">model_id</code> reference gets added later by the fifth migration, recording which LLM model that conversation is using.</p>

<p><strong><code class="language-plaintext highlighter-rouge">messages</code> table.</strong> This is where the substance lives: <code class="language-plaintext highlighter-rouge">role</code> (<code class="language-plaintext highlighter-rouge">"user"</code>, <code class="language-plaintext highlighter-rouge">"assistant"</code>, or <code class="language-plaintext highlighter-rouge">"system"</code>), <code class="language-plaintext highlighter-rouge">content</code>, and <code class="language-plaintext highlighter-rouge">content_raw</code> (a JSON column holding the full raw structure, needed for messages that aren’t plain text — like tool calls). There are also columns for extended thinking (<code class="language-plaintext highlighter-rouge">thinking_text</code>, <code class="language-plaintext highlighter-rouge">thinking_signature</code>, <code class="language-plaintext highlighter-rouge">thinking_tokens</code> — for models that expose their reasoning process) and token accounting (<code class="language-plaintext highlighter-rouge">input_tokens</code>, <code class="language-plaintext highlighter-rouge">output_tokens</code>, <code class="language-plaintext highlighter-rouge">cached_tokens</code>, <code class="language-plaintext highlighter-rouge">cache_creation_tokens</code>). We won’t touch the thinking or token columns in this episode, but it’s worth knowing they’re there: this same table is built to support features several episodes away.</p>

<p><strong><code class="language-plaintext highlighter-rouge">tool_calls</code> table.</strong> Not used yet — this is for a later episode on tool calling, when the model will be able to call into our own application code. The table stores which tool was called (<code class="language-plaintext highlighter-rouge">name</code>), with what (<code class="language-plaintext highlighter-rouge">arguments</code>, as JSON), and links back to the message that triggered it.</p>

<p><strong><code class="language-plaintext highlighter-rouge">models</code> table.</strong> A local cache of model metadata — pricing, context window size, which capabilities a given model supports. It’s populated by a separate rake task (<code class="language-plaintext highlighter-rouge">bin/rails ruby_llm:load_models</code>) that we’re not running in this episode, since nothing here depends on it yet. The <code class="language-plaintext highlighter-rouge">model_id</code> reference added to <code class="language-plaintext highlighter-rouge">conversations</code> and <code class="language-plaintext highlighter-rouge">messages</code> is optional (<code class="language-plaintext highlighter-rouge">foreign_key: true</code>, no <code class="language-plaintext highlighter-rouge">null: false</code>), so everything works fine without it.</p>

<p><strong>The generated models:</strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/conversation.rb</span>
<span class="k">class</span> <span class="nc">Conversation</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">acts_as_chat</span>
<span class="k">end</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/models/message.rb</span>
<span class="k">class</span> <span class="nc">Message</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">acts_as_message</span> <span class="ss">chat: :conversation</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">acts_as_chat</code> and <code class="language-plaintext highlighter-rouge">acts_as_message</code> are class methods RubyLLM adds to <code class="language-plaintext highlighter-rouge">ActiveRecord::Base</code>. They set up the <code class="language-plaintext highlighter-rouge">has_many</code>/<code class="language-plaintext highlighter-rouge">belongs_to</code> association between the two models and mix in the methods that make a <code class="language-plaintext highlighter-rouge">Conversation</code> behave like a RubyLLM chat session — most importantly, <code class="language-plaintext highlighter-rouge">.ask</code>. Because we renamed the model to <code class="language-plaintext highlighter-rouge">Conversation</code>, <code class="language-plaintext highlighter-rouge">acts_as_message</code> needed to know the association isn’t called <code class="language-plaintext highlighter-rouge">chat</code> anymore, hence <code class="language-plaintext highlighter-rouge">chat: :conversation</code>. The generator worked this out on its own from the <code class="language-plaintext highlighter-rouge">chat:Conversation</code> mapping we passed it.</p>

<p><strong>The convention directories</strong> (<code class="language-plaintext highlighter-rouge">app/agents</code>, <code class="language-plaintext highlighter-rouge">app/tools</code>, <code class="language-plaintext highlighter-rouge">app/schemas</code>, <code class="language-plaintext highlighter-rouge">app/prompts</code>, each with just a <code class="language-plaintext highlighter-rouge">.gitkeep</code> for now) are empty scaffolding for features later in this series — tools an agent can call, structured output schemas, reusable prompts. Nothing goes in them yet.</p>

<h2 id="a-gotcha-the-deprecated-legacy-api">A gotcha: the deprecated legacy API</h2>

<p>Here’s something that isn’t obvious from the README alone, and that we ran into directly. RubyLLM ships <em>two</em> implementations of the ActiveRecord integration: the current one (what we just described) and a legacy one, kept only for backward compatibility. Which one loads is controlled by a config flag, <code class="language-plaintext highlighter-rouge">use_new_acts_as</code>.</p>

<p>Without it, RubyLLM silently loads the legacy implementation and prints this at boot:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>!!! RubyLLM's legacy acts_as API is deprecated and will be removed in RubyLLM 2.0.0.
Please consult the migration guide at https://rubyllm.com/upgrading-to-1-7/
</code></pre></div></div>

<p>This is exactly the warning we saw back in episode 2, before we’d even touched persistence — it’s printed at boot as soon as ActiveRecord loads, regardless of whether you’re using <code class="language-plaintext highlighter-rouge">acts_as_chat</code> yet. The generator knows about this and sets the flag correctly on its own:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/initializers/ruby_llm.rb</span>
<span class="no">RubyLLM</span><span class="p">.</span><span class="nf">configure</span> <span class="k">do</span> <span class="o">|</span><span class="n">config</span><span class="o">|</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">openai_api_key</span> <span class="o">=</span> <span class="no">ENV</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="s2">"OPENAI_API_KEY"</span><span class="p">,</span> <span class="kp">nil</span><span class="p">)</span>

  <span class="c1"># Use the current, association-based acts_as API. Without this, RubyLLM</span>
  <span class="c1"># silently falls back to a deprecated implementation and warns about it.</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">use_new_acts_as</span> <span class="o">=</span> <span class="kp">true</span>
<span class="k">end</span>
</code></pre></div></div>

<p>If you ever add RubyLLM’s ActiveRecord integration to a project <em>without</em> using the generator, this is the one line most worth copying by hand — it’s easy to miss, and the two implementations aren’t identical, so code written against one doesn’t necessarily work against the other.</p>

<h2 id="running-the-migrations">Running the migrations</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails db:migrate
</code></pre></div></div>

<p>This creates all four tables described above, plus the fifth migration that wires up the foreign keys between them.</p>

<h2 id="trying-it-from-the-console-before-touching-the-controller">Trying it from the console, before touching the controller</h2>

<p>Worth doing once, to see persistence working with nothing else in the way:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails runner <span class="s1">'
conversation = Conversation.create!
response = conversation.ask "Reply with exactly one word: pong"
puts "RESPONSE: #{response.content}"
puts "MESSAGES IN DB: #{conversation.messages.count}"
puts "ROLES: #{conversation.messages.pluck(:role).join(%q{, })}"
'</span>
</code></pre></div></div>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>RESPONSE: pong
MESSAGES IN DB: 2
ROLES: user, assistant
</code></pre></div></div>

<p>One <code class="language-plaintext highlighter-rouge">.ask</code> call, two rows written: the user’s message and the model’s reply, both persisted automatically. No deprecation warning either — the flag is doing its job.</p>

<h2 id="wiring-it-into-the-controller">Wiring it into the controller</h2>

<p>Episode 2’s controller created a fresh, throwaway chat on every request. Now we need to find <em>the same</em> conversation across requests. For a single-user demo with no accounts yet, the simplest thing that’s still correct is to keep the conversation’s id in the Rails session — a small, signed cookie tied to the visitor’s browser.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/chats_controller.rb</span>
<span class="k">class</span> <span class="nc">ChatsController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="k">def</span> <span class="nf">new</span>
    <span class="vi">@conversation</span> <span class="o">=</span> <span class="n">current_conversation</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">create</span>
    <span class="n">current_conversation</span><span class="p">.</span><span class="nf">ask</span><span class="p">(</span><span class="n">params</span><span class="p">[</span><span class="ss">:message</span><span class="p">])</span>
    <span class="n">redirect_to</span> <span class="n">new_chat_path</span>
  <span class="k">end</span>

  <span class="kp">private</span>

  <span class="k">def</span> <span class="nf">current_conversation</span>
    <span class="no">Conversation</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">id: </span><span class="n">session</span><span class="p">[</span><span class="ss">:conversation_id</span><span class="p">])</span> <span class="o">||</span> <span class="n">create_conversation</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">create_conversation</span>
    <span class="n">conversation</span> <span class="o">=</span> <span class="no">Conversation</span><span class="p">.</span><span class="nf">create!</span>
    <span class="n">session</span><span class="p">[</span><span class="ss">:conversation_id</span><span class="p">]</span> <span class="o">=</span> <span class="n">conversation</span><span class="p">.</span><span class="nf">id</span>
    <span class="n">conversation</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">current_conversation</code> looks up the conversation by the id stored in the session; if there isn’t one yet (first visit, or the session expired), it creates one and remembers its id for next time. <code class="language-plaintext highlighter-rouge">create</code> no longer renders anything itself — it asks the question, then redirects back to <code class="language-plaintext highlighter-rouge">new</code>, which is now responsible for loading and displaying the conversation, messages included.</p>

<h2 id="showing-the-history">Showing the history</h2>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"max-w-xl mx-auto mt-16 px-4"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;h1</span> <span class="na">class=</span><span class="s">"text-2xl font-semibold mb-6"</span><span class="nt">&gt;</span>RubyLLM chat demo<span class="nt">&lt;/h1&gt;</span>

  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"space-y-4 mb-8"</span><span class="nt">&gt;</span>
    <span class="cp">&lt;%</span> <span class="vi">@conversation</span><span class="p">.</span><span class="nf">messages</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">message</span><span class="o">|</span> <span class="cp">%&gt;</span>
      <span class="cp">&lt;%</span> <span class="n">is_user</span> <span class="o">=</span> <span class="n">message</span><span class="p">.</span><span class="nf">role</span> <span class="o">==</span> <span class="s2">"user"</span> <span class="cp">%&gt;</span>
      <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"</span><span class="cp">&lt;%=</span> <span class="n">is_user</span> <span class="p">?</span> <span class="s2">"text-right"</span> <span class="p">:</span> <span class="s2">"text-left"</span> <span class="cp">%&gt;</span><span class="s">"</span><span class="nt">&gt;</span>
        <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"text-xs text-gray-500 mb-1"</span><span class="nt">&gt;</span><span class="cp">&lt;%=</span> <span class="n">message</span><span class="p">.</span><span class="nf">role</span> <span class="cp">%&gt;</span><span class="nt">&lt;/p&gt;</span>
        <span class="nt">&lt;p</span> <span class="na">class=</span><span class="s">"inline-block rounded-md px-3 py-2 </span><span class="cp">&lt;%=</span> <span class="n">is_user</span> <span class="p">?</span> <span class="s2">"bg-indigo-600 text-white"</span> <span class="p">:</span> <span class="s2">"bg-gray-100"</span> <span class="cp">%&gt;</span><span class="s">"</span><span class="nt">&gt;</span>
          <span class="cp">&lt;%=</span> <span class="n">message</span><span class="p">.</span><span class="nf">content</span> <span class="cp">%&gt;</span>
        <span class="nt">&lt;/p&gt;</span>
      <span class="nt">&lt;/div&gt;</span>
    <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;/div&gt;</span>

  <span class="cp">&lt;%=</span> <span class="n">form_with</span> <span class="ss">url: </span><span class="n">chat_path</span><span class="p">,</span> <span class="ss">method: :post</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"flex flex-col gap-3"</span> <span class="k">do</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;textarea</span> <span class="na">name=</span><span class="s">"message"</span> <span class="na">rows=</span><span class="s">"3"</span> <span class="na">placeholder=</span><span class="s">"Ask something..."</span>
      <span class="na">class=</span><span class="s">"border border-gray-300 rounded-md p-3 focus:outline-none focus:ring-2 focus:ring-indigo-500"</span><span class="nt">&gt;&lt;/textarea&gt;</span>
    <span class="nt">&lt;button</span> <span class="na">type=</span><span class="s">"submit"</span> <span class="na">class=</span><span class="s">"self-start bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700"</span><span class="nt">&gt;</span>
      Send
    <span class="nt">&lt;/button&gt;</span>
  <span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
<span class="nt">&lt;/div&gt;</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">@conversation.messages</code> comes for free from <code class="language-plaintext highlighter-rouge">acts_as_chat</code> — it’s the <code class="language-plaintext highlighter-rouge">has_many</code> association, already ordered oldest-first. We loop over it, and use <code class="language-plaintext highlighter-rouge">message.role</code> (a plain string column: <code class="language-plaintext highlighter-rouge">"user"</code> or <code class="language-plaintext highlighter-rouge">"assistant"</code>) to align and colour each bubble differently. There’s no helper method like <code class="language-plaintext highlighter-rouge">message.user?</code> — the role is just a string, so a direct comparison is all we need.</p>

<h2 id="closing-episode-2s-open-thread-turbo-drive-works-again">Closing episode 2’s open thread: Turbo Drive works again</h2>

<p>Episode 2 ended with <code class="language-plaintext highlighter-rouge">data: { turbo: false }</code> on the form, as a workaround: Turbo Drive intercepted the submission and expected either a Turbo Stream response or a redirect, and our controller was doing neither — it rendered plain HTML, which Turbo didn’t know how to handle, so the page silently failed to update.</p>

<p>Look at this episode’s <code class="language-plaintext highlighter-rouge">create</code> action again: it doesn’t render anything, it redirects. That’s the Post/Redirect/Get pattern — after a form submission that changes something, redirect to a page that shows the result, rather than rendering a result directly from the POST action. It’s good practice on its own (it stops a page refresh from re-submitting the form), and it also happens to be exactly what Turbo Drive expects. So the workaround is gone — the form in this episode has no <code class="language-plaintext highlighter-rouge">data: { turbo: false }</code> at all, and it works.</p>

<p>This is worth sitting with for a second: the fix in episode 2 wasn’t wrong, but it was treating a symptom. The real fix was adopting the pattern Rails (and Turbo) are built around in the first place.</p>

<h2 id="trying-it-in-the-browser">Trying it in the browser</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/dev
</code></pre></div></div>

<p>Open <code class="language-plaintext highlighter-rouge">http://127.0.0.1:3000</code>, ask something, and the reply appears below the form. Refresh the page — the conversation is still there. Ask a follow-up that depends on the first message (“what did I just ask you?”) and the model answers correctly, because the full history is sent with every request, not just the latest line.</p>

<h2 id="whats-next">What’s next</h2>

<p>Episode 4 covers streaming responses: instead of waiting for the full reply before showing anything, we’ll stream it in as the model generates it, using Turbo Streams properly this time.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[In episode 2 we got RubyLLM talking to Rails through a plain form: type a message, get a reply, refresh the page and it’s gone. Every RubyLLM.chat call created a brand new, in-memory session that lived only for the duration of that one request.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://antoninoscaffidi.github.io/assets/images/ai-with-ruby-banner.png" /><media:content medium="image" url="https://antoninoscaffidi.github.io/assets/images/ai-with-ruby-banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">VicinoTe: Setting Up a Rails Marketplace and Designing Its Domain</title><link href="https://antoninoscaffidi.github.io/vicinote-project-setup-and-domain/" rel="alternate" type="text/html" title="VicinoTe: Setting Up a Rails Marketplace and Designing Its Domain" /><published>2026-08-08T08:00:00+00:00</published><updated>2026-08-08T08:00:00+00:00</updated><id>https://antoninoscaffidi.github.io/vicinote-project-setup-and-domain</id><content type="html" xml:base="https://antoninoscaffidi.github.io/vicinote-project-setup-and-domain/"><![CDATA[<p>This is the first episode of <strong>VicinoTe</strong>, a series where we build a complete Rails application from an empty directory to something with real features — including, in the later episodes, an AI module powered by RubyLLM.</p>

<p>The name comes from the Italian <em>vicino a te</em>, “close to you”. VicinoTe is a marketplace for local services: you find someone nearby to fix a tap, teach guitar, walk a dog or renovate a bathroom — or you offer your own skills to the people around you.</p>

<p>In this episode we set up the project and, more importantly, decide what we’re actually building. The code is on GitHub, tagged <a href="https://github.com/AntoninoScaffidi/vicinote-tutorial/tree/episode-1"><code class="language-plaintext highlighter-rouge">episode-1</code></a> in the <a href="https://github.com/AntoninoScaffidi/vicinote-tutorial">vicinote-tutorial</a> repo, which will grow with every post.</p>

<h2 id="why-a-marketplace">Why a marketplace</h2>

<p>Tutorials often build a blog or a to-do list. Those are fine for learning syntax, but they’re too simple to run into the problems that make Rails interesting: the same record playing different roles depending on context, money, availability, permissions, search that has to actually be good.</p>

<p>A services marketplace hits all of them, and it does so gradually — you can have something working after two episodes and still have plenty left to build after ten.</p>

<h2 id="creating-the-app">Creating the app</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rails new vicinote-tutorial <span class="nt">-d</span> postgresql <span class="nt">--css</span> tailwind
</code></pre></div></div>

<p>Three decisions are baked into that line, so let’s be explicit about them.</p>

<p><strong><code class="language-plaintext highlighter-rouge">-d postgresql</code>.</strong> Rails 8 defaults to SQLite, which is genuinely a good default now. I’m choosing PostgreSQL anyway for one specific reason: the AI episodes later in this series need <strong>vector search</strong> to power semantic search over services. The standard way to do that in Postgres is the <code class="language-plaintext highlighter-rouge">pgvector</code> extension, and having it available without migrating the database mid-series is worth the small extra setup now.</p>

<p><strong><code class="language-plaintext highlighter-rouge">--css tailwind</code>.</strong> Personal preference, and it keeps the views readable without a separate stylesheet to maintain alongside the tutorial. Nothing in the series depends on Tailwind specifically — if you prefer plain CSS, the markup will still make sense.</p>

<p><strong>Rails 8 defaults we’re keeping.</strong> The generator also brings in Propshaft, Importmap, Turbo, Stimulus, and the Solid trio (Solid Queue, Solid Cache, Solid Cable). We’re not configuring any of them yet, but they’re the reason there’s no Redis and no Node build step in this project.</p>

<p>Then create the databases:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails db:create
</code></pre></div></div>

<p>If that command fails, PostgreSQL isn’t running or isn’t reachable — that’s the one piece of setup you have to sort out on your own machine before continuing.</p>

<h2 id="designing-the-domain">Designing the domain</h2>

<p>This is the part worth slowing down on. Getting the model right now saves a lot of painful migrations later.</p>

<p>Here’s the shape we’re aiming for:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>User            can offer services AND book them
 ├─ Service     something a user offers (title, description, price)
 │   └─ Category
 └─ Booking     a user books another user's service
     └─ Review  left after the booking is completed
</code></pre></div></div>

<h3 id="the-important-decision-one-user-two-roles">The important decision: one User, two roles</h3>

<p>The central question in any marketplace is how to represent the two sides of the transaction. There are three common answers.</p>

<p><strong>Option A — separate models.</strong> A <code class="language-plaintext highlighter-rouge">Provider</code> model and a <code class="language-plaintext highlighter-rouge">Customer</code> model, each with its own table.</p>

<p>This is the first idea most people have, and it’s usually wrong. You immediately duplicate everything that isn’t role-specific: email, password digest, name, avatar, phone number, address. Then you need authentication that works for both, which means either two login flows or a polymorphic mess. And the moment someone who offers guitar lessons wants to book a plumber, they need two accounts with two passwords — which is absurd, and exactly the situation a neighbourhood marketplace runs into constantly.</p>

<p><strong>Option B — a <code class="language-plaintext highlighter-rouge">role</code> column on User.</strong> One <code class="language-plaintext highlighter-rouge">users</code> table with <code class="language-plaintext highlighter-rouge">role: "provider"</code> or <code class="language-plaintext highlighter-rouge">role: "customer"</code>.</p>

<p>Better, but it encodes a false assumption: that being a provider or a customer is a permanent property of a person. It isn’t. It’s a property of <em>a particular relationship</em>. The same person is a provider in the booking where they teach guitar and a customer in the booking where they hire a plumber. A single <code class="language-plaintext highlighter-rouge">role</code> column can’t express that, and you’ll end up fighting it.</p>

<p><strong>Option C — role emerges from the association.</strong> One <code class="language-plaintext highlighter-rouge">User</code> model, no role column. You’re a provider <em>of the services you created</em>, and a customer <em>of the bookings you made</em>.</p>

<p>This is what we’ll use:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">User</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">has_many</span> <span class="ss">:services</span>                                        <span class="c1"># things I offer</span>
  <span class="n">has_many</span> <span class="ss">:bookings</span><span class="p">,</span> <span class="ss">foreign_key: :customer_id</span>             <span class="c1"># things I booked</span>
  <span class="n">has_many</span> <span class="ss">:received_bookings</span><span class="p">,</span> <span class="ss">through: :services</span><span class="p">,</span> <span class="ss">source: :bookings</span>
<span class="k">end</span>
</code></pre></div></div>

<p>No duplication, one login, and a user who does both is the normal case rather than an edge case. When we later need “provider-only” behaviour, it’s a question about the data (<code class="language-plaintext highlighter-rouge">user.services.any?</code>), not about a flag we have to keep in sync.</p>

<p>To be fair about the trade-off: this approach makes some queries slightly more involved. “Show me everything happening in my account” has to look at two associations instead of one. In exchange, we never have to answer “what happens when a customer becomes a provider” — because nothing does. That’s a good deal.</p>

<h3 id="bookings-hold-the-money">Bookings hold the money</h3>

<p>A <code class="language-plaintext highlighter-rouge">Booking</code> isn’t just a link between a user and a service. It’s the record of an agreement at a point in time: the date, the agreed price, the status. It needs its own price column rather than reading <code class="language-plaintext highlighter-rouge">service.price</code>, because the service’s price can change tomorrow and that must not silently rewrite what someone agreed to pay last week.</p>

<p>This kind of thing is easy to get wrong and painful to fix once real data exists, which is why we’re deciding it before writing a single migration.</p>

<h3 id="reviews-belong-to-bookings-not-to-services">Reviews belong to bookings, not to services</h3>

<p>It’s tempting to attach a <code class="language-plaintext highlighter-rouge">Review</code> directly to a <code class="language-plaintext highlighter-rouge">Service</code>. Attaching it to a <code class="language-plaintext highlighter-rouge">Booking</code> instead gives us something valuable for free: only someone who actually booked and completed a service can review it. The constraint is built into the shape of the data rather than enforced by validation logic we have to remember to write.</p>

<h2 id="a-landing-page">A landing page</h2>

<p>To finish with something visible, a static home page:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/rails generate controller Pages home <span class="nt">--skip-routes</span> <span class="nt">--no-helper</span> <span class="nt">--no-assets</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">root</span> <span class="s2">"pages#home"</span>
</code></pre></div></div>

<p>I used <code class="language-plaintext highlighter-rouge">--skip-routes</code> because the generator would otherwise add <code class="language-plaintext highlighter-rouge">get "pages/home"</code>, and we want this at the root instead. <code class="language-plaintext highlighter-rouge">--no-helper</code> and <code class="language-plaintext highlighter-rouge">--no-assets</code> just avoid creating files we won’t use.</p>

<p>The view itself is plain markup describing the project, with two buttons deliberately left inert — placeholders for the flows we build next.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bin/dev
</code></pre></div></div>

<p>Open <code class="language-plaintext highlighter-rouge">http://127.0.0.1:3000</code> and there it is: an empty app that knows what it wants to become.</p>

<h2 id="whats-next">What’s next</h2>

<p>Episode 2 turns the domain sketch into real code: the <code class="language-plaintext highlighter-rouge">User</code> model, authentication, and the first migrations. From there the marketplace starts taking shape — services, categories, and the flows that connect the two sides of a booking.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[This is the first episode of VicinoTe, a series where we build a complete Rails application from an empty directory to something with real features — including, in the later episodes, an AI module powered by RubyLLM.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://antoninoscaffidi.github.io/assets/images/vicinote-banner.png" /><media:content medium="image" url="https://antoninoscaffidi.github.io/assets/images/vicinote-banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">Wiring RubyLLM into Rails: A Minimal Chat Form</title><link href="https://antoninoscaffidi.github.io/wiring-rubyllm-into-rails/" rel="alternate" type="text/html" title="Wiring RubyLLM into Rails: A Minimal Chat Form" /><published>2026-08-07T10:00:00+00:00</published><updated>2026-08-07T10:00:00+00:00</updated><id>https://antoninoscaffidi.github.io/wiring-rubyllm-into-rails</id><content type="html" xml:base="https://antoninoscaffidi.github.io/wiring-rubyllm-into-rails/"><![CDATA[<p>In <a href="/introduction-to-rubyllm/">episode 1</a> we installed RubyLLM and made a single call from plain Ruby. This time we wire it into an actual Rails app: a form where you type a message and get a response back from the model. No database, no conversation history yet — that’s episode 3. The goal here is just to see RubyLLM working inside a real Rails request/response cycle.</p>

<p>The full code for this episode is on GitHub, tagged <a href="https://github.com/AntoninoScaffidi/ai-with-ruby-demo/tree/episode-2"><code class="language-plaintext highlighter-rouge">episode-2</code></a>, in a new companion repo — <a href="https://github.com/AntoninoScaffidi/ai-with-ruby-demo">ai-with-ruby-demo</a> — that will grow with each post in this series.</p>

<h2 id="setting-up-the-app">Setting up the app</h2>

<p>A fresh Rails 8 app with Tailwind CSS:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rails new ai-with-ruby-demo <span class="nt">--css</span> tailwind
</code></pre></div></div>

<p>Two gems go in the <code class="language-plaintext highlighter-rouge">Gemfile</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">gem</span> <span class="s2">"ruby_llm"</span>

<span class="n">group</span> <span class="ss">:development</span> <span class="k">do</span>
  <span class="n">gem</span> <span class="s2">"dotenv-rails"</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">ruby_llm</code> is the library itself. <code class="language-plaintext highlighter-rouge">dotenv-rails</code> loads a <code class="language-plaintext highlighter-rouge">.env</code> file into <code class="language-plaintext highlighter-rouge">ENV</code> in development — Rails doesn’t do this on its own.</p>

<h2 id="keeping-the-api-key-out-of-git">Keeping the API key out of git</h2>

<p>Rails 8’s default <code class="language-plaintext highlighter-rouge">.gitignore</code> already excludes <code class="language-plaintext highlighter-rouge">.env*</code>, so a <code class="language-plaintext highlighter-rouge">.env</code> file with your real key never gets committed. We commit an <code class="language-plaintext highlighter-rouge">.env.example</code> instead, as a template for anyone cloning the repo:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>OPENAI_API_KEY=sk-your-key-here
</code></pre></div></div>

<p>Copy it locally and fill in your real key:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cp</span> .env.example .env
</code></pre></div></div>

<h2 id="configuring-rubyllm">Configuring RubyLLM</h2>

<p>Files in <code class="language-plaintext highlighter-rouge">config/initializers/</code> run once, at boot, before any request is handled — the right place to configure a gem like this:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/initializers/ruby_llm.rb</span>
<span class="no">RubyLLM</span><span class="p">.</span><span class="nf">configure</span> <span class="k">do</span> <span class="o">|</span><span class="n">config</span><span class="o">|</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">openai_api_key</span> <span class="o">=</span> <span class="no">ENV</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="s2">"OPENAI_API_KEY"</span><span class="p">,</span> <span class="kp">nil</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<h2 id="routes-and-controller">Routes and controller</h2>

<p>A <code class="language-plaintext highlighter-rouge">resource</code> (singular) fits here — there’s no list of chats to index or a specific one to fetch by ID, just a single form:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">resource</span> <span class="ss">:chat</span><span class="p">,</span> <span class="ss">only: </span><span class="p">[</span><span class="ss">:new</span><span class="p">,</span> <span class="ss">:create</span><span class="p">]</span>
<span class="n">root</span> <span class="s2">"chats#new"</span>
</code></pre></div></div>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/controllers/chats_controller.rb</span>
<span class="k">class</span> <span class="nc">ChatsController</span> <span class="o">&lt;</span> <span class="no">ApplicationController</span>
  <span class="k">def</span> <span class="nf">new</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">create</span>
    <span class="vi">@message</span> <span class="o">=</span> <span class="n">params</span><span class="p">[</span><span class="ss">:message</span><span class="p">]</span>
    <span class="n">chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="p">.</span><span class="nf">chat</span>
    <span class="vi">@response</span> <span class="o">=</span> <span class="n">chat</span><span class="p">.</span><span class="nf">ask</span><span class="p">(</span><span class="vi">@message</span><span class="p">).</span><span class="nf">content</span>
    <span class="n">render</span> <span class="ss">:new</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">RubyLLM.chat</code> creates a new, in-memory chat session — nothing persisted anywhere, which is exactly why the conversation disappears on refresh right now. <code class="language-plaintext highlighter-rouge">.ask</code> sends the message and blocks until the model replies; <code class="language-plaintext highlighter-rouge">.content</code> is the reply text.</p>

<h2 id="the-view">The view</h2>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;%=</span> <span class="n">form_with</span> <span class="ss">url: </span><span class="n">chat_path</span><span class="p">,</span> <span class="ss">method: :post</span><span class="p">,</span> <span class="ss">data: </span><span class="p">{</span> <span class="ss">turbo: </span><span class="kp">false</span> <span class="p">}</span> <span class="k">do</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;textarea</span> <span class="na">name=</span><span class="s">"message"</span><span class="nt">&gt;</span><span class="cp">&lt;%=</span> <span class="vi">@message</span> <span class="cp">%&gt;</span><span class="nt">&lt;/textarea&gt;</span>
  <span class="nt">&lt;button</span> <span class="na">type=</span><span class="s">"submit"</span><span class="nt">&gt;</span>Send<span class="nt">&lt;/button&gt;</span>
<span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>

<span class="cp">&lt;%</span> <span class="k">if</span> <span class="vi">@response</span> <span class="cp">%&gt;</span>
  <span class="nt">&lt;p&gt;</span><span class="cp">&lt;%=</span> <span class="vi">@response</span> <span class="cp">%&gt;</span><span class="nt">&lt;/p&gt;</span>
<span class="cp">&lt;%</span> <span class="k">end</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<h2 id="the-turbo-gotcha">The Turbo gotcha</h2>

<p>The first version of this form didn’t have <code class="language-plaintext highlighter-rouge">data: { turbo: false }</code>, and submitting it did… nothing visible. The server logs showed a clean <code class="language-plaintext highlighter-rouge">200 OK</code>, but the page never updated.</p>

<p>The cause: Rails 8 ships with Turbo Drive by default, which intercepts every form submission and sends it as a background request, expecting either a redirect or a response in the Turbo Stream format. Our controller just rendered plain HTML — Turbo received it but didn’t know what to do with it, so it silently did nothing.</p>

<p><code class="language-plaintext highlighter-rouge">data: { turbo: false }</code> tells Turbo to leave this specific form alone and submit it the old-fashioned way: a real page load. That’s the right fix for now. When we get to streaming responses later in this series, we’ll actually want Turbo Streams — but for a first “does this work at all” version, disabling it is the simplest path.</p>

<h2 id="whats-next">What’s next</h2>

<p>Episode 3 adds persistence: <code class="language-plaintext highlighter-rouge">Conversation</code> and <code class="language-plaintext highlighter-rouge">Message</code> models, so the chat history survives a page refresh instead of living only inside a single request.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[In episode 1 we installed RubyLLM and made a single call from plain Ruby. This time we wire it into an actual Rails app: a form where you type a message and get a response back from the model. No database, no conversation history yet — that’s episode 3. The goal here is just to see RubyLLM working inside a real Rails request/response cycle.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://antoninoscaffidi.github.io/assets/images/ai-with-ruby-banner.png" /><media:content medium="image" url="https://antoninoscaffidi.github.io/assets/images/ai-with-ruby-banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry xml:lang="en"><title type="html">Introduction to RubyLLM: Bringing AI to Your Ruby Applications</title><link href="https://antoninoscaffidi.github.io/introduction-to-rubyllm/" rel="alternate" type="text/html" title="Introduction to RubyLLM: Bringing AI to Your Ruby Applications" /><published>2026-08-06T16:00:00+00:00</published><updated>2026-08-06T16:00:00+00:00</updated><id>https://antoninoscaffidi.github.io/introduction-to-rubyllm</id><content type="html" xml:base="https://antoninoscaffidi.github.io/introduction-to-rubyllm/"><![CDATA[<p>If you’ve built anything with Ruby on Rails over the last couple of years, you’ve probably felt the pull toward adding some kind of AI feature: a chatbot, a semantic search box, a tool that lets an LLM call into your app’s own logic. The Python ecosystem has had mature tooling for this for a while. Ruby, for a long time, did not.</p>

<p><a href="https://rubyllm.com">RubyLLM</a> changes that. It’s a gem that gives you a clean, idiomatic Ruby interface to large language models — OpenAI, Anthropic, Gemini, and others — without forcing you to hand-roll HTTP requests and JSON parsing every time you want to talk to a model.</p>

<p>This post kicks off a new series on this blog where we’ll explore AI in the context of Ruby and Rails applications. We’ll start simple and build up to more advanced patterns: streaming responses, tool calling, and semantic search over your own data. This first episode is just about getting RubyLLM installed and making your first call.</p>

<h2 id="why-rubyllm">Why RubyLLM</h2>

<p>A few things make RubyLLM a good fit for Rails apps specifically:</p>

<ul>
  <li><strong>One interface, many providers.</strong> You configure a provider and model once, and the calling code doesn’t need to change if you switch from, say, GPT-4 to Claude.</li>
  <li><strong>Rails-friendly.</strong> It plays well with ActiveJob, ActiveRecord, and the conventions you already know, instead of asking you to adopt a separate framework.</li>
  <li><strong>No boilerplate.</strong> No manual JSON building, no manually parsing streaming chunks — the gem handles that.</li>
</ul>

<h2 id="installation">Installation</h2>

<p>Add the gem to your <code class="language-plaintext highlighter-rouge">Gemfile</code>:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">gem</span> <span class="s2">"ruby_llm"</span>
</code></pre></div></div>

<p>Then install it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>bundle <span class="nb">install</span>
</code></pre></div></div>

<h2 id="configuration">Configuration</h2>

<p>RubyLLM needs at least one API key to talk to a provider. A common approach is an initializer:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/initializers/ruby_llm.rb</span>
<span class="no">RubyLLM</span><span class="p">.</span><span class="nf">configure</span> <span class="k">do</span> <span class="o">|</span><span class="n">config</span><span class="o">|</span>
  <span class="n">config</span><span class="p">.</span><span class="nf">openai_api_key</span> <span class="o">=</span> <span class="no">ENV</span><span class="p">[</span><span class="s2">"OPENAI_API_KEY"</span><span class="p">]</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Keep the actual key out of source control — use Rails credentials or an environment variable loaded via a <code class="language-plaintext highlighter-rouge">.env</code> file in development.</p>

<h2 id="your-first-call">Your First Call</h2>

<p>With configuration in place, talking to a model is a single method call:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="p">.</span><span class="nf">chat</span>
<span class="n">response</span> <span class="o">=</span> <span class="n">chat</span><span class="p">.</span><span class="nf">ask</span> <span class="s2">"What's a good name for a Ruby gem that talks to LLMs?"</span>

<span class="nb">puts</span> <span class="n">response</span><span class="p">.</span><span class="nf">content</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">RubyLLM.chat</code> gives you a chat session you can keep asking questions in, and it keeps track of the conversation history for you — so a follow-up question like “What about a shorter one?” will still have the earlier context.</p>

<h2 id="whats-next">What’s Next</h2>

<p>In the next episode of this series, we’ll wire RubyLLM into a real Rails app and build a simple chatbot backed by ActiveRecord-stored conversation history. Later episodes will cover semantic search and tool calling — letting the model call into your own application code to fetch data or take actions.</p>

<p>This blog will soon kick off a new series called <strong>VicinoTe</strong> — a from-scratch Rails tutorial building a local services marketplace. This AI series will eventually connect back to it: VicinoTe’s advanced module will use RubyLLM for exactly the features described above.</p>]]></content><author><name></name></author><summary type="html"><![CDATA[If you’ve built anything with Ruby on Rails over the last couple of years, you’ve probably felt the pull toward adding some kind of AI feature: a chatbot, a semantic search box, a tool that lets an LLM call into your app’s own logic. The Python ecosystem has had mature tooling for this for a while. Ruby, for a long time, did not.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://antoninoscaffidi.github.io/assets/images/ai-with-ruby-banner.png" /><media:content medium="image" url="https://antoninoscaffidi.github.io/assets/images/ai-with-ruby-banner.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>