<?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://msg.samsonov.io/feed.xml" rel="self" type="application/atom+xml" /><link href="https://msg.samsonov.io/" rel="alternate" type="text/html" /><updated>2026-05-21T21:05:58+00:00</updated><id>https://msg.samsonov.io/feed.xml</id><title type="html">Commit Messages</title><subtitle>Reflections on code, craft, and the future of software</subtitle><author><name>Andrey Samsonov</name></author><entry><title type="html">Generative UI in Rails with RubyLLM</title><link href="https://msg.samsonov.io/2026-05-21-generative-ui-ruby-llm/" rel="alternate" type="text/html" title="Generative UI in Rails with RubyLLM" /><published>2026-05-21T00:00:00+00:00</published><updated>2026-05-21T00:00:00+00:00</updated><id>https://msg.samsonov.io/generative-ui-ruby-llm</id><content type="html" xml:base="https://msg.samsonov.io/2026-05-21-generative-ui-ruby-llm/"><![CDATA[<p>Chat is a natural interface for LLMs, and a lot of things work well inside it. But visual responses — cards, buttons, choices, inline widgets — have already become a baseline user expectation: tables and charts in ChatGPT answers, quick replies in support bots, forms and confirmations in banking assistants.</p>

<p>I want to give the model that same freedom of form in my Rails app — without letting it generate the HTML directly. Let the LLM choose <em>what</em> to show; the application still decides <em>how to render it</em>.</p>

<h2 id="plain-chat-text">Plain chat: text</h2>

<p>Let’s spin up a chat on RubyLLM and give it one ordinary tool right away — a weather data source:</p>

<div class="row">
<div class="row-text">

    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>rails new chat_app <span class="nt">--css</span> tailwind
<span class="nb">cd </span>chat_app
bundle add ruby_llm
bin/rails generate ruby_llm:install
bin/rails db:migrate
bin/rails generate ruby_llm:chat_ui
bin/rails g ruby_llm:tool Weather
</code></pre></div>    </div>

    <div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">WeatherTool</span> <span class="o">&lt;</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Tool</span>
  <span class="n">description</span> <span class="s2">"Get current weather for a location."</span>

  <span class="n">params</span> <span class="k">do</span>
    <span class="n">number</span> <span class="ss">:latitude</span>
    <span class="n">number</span> <span class="ss">:longitude</span>
    <span class="n">string</span> <span class="ss">:location</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">execute</span><span class="p">(</span><span class="n">latitude</span><span class="p">:,</span> <span class="n">longitude</span><span class="p">:,</span> <span class="n">location</span><span class="p">:)</span>
    <span class="no">Weather</span><span class="o">::</span><span class="no">OpenMeteo</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="n">latitude</span><span class="p">:,</span> <span class="n">longitude</span><span class="p">:,</span> <span class="n">location</span><span class="p">:).</span><span class="nf">to_json</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div>    </div>

  </div>
<div class="row-figure">
  <figure>
    <img src="/assets/images/posts/generative-ui-ruby-llm/01-tool-text-answer.png" alt="Weather tool call, JSON result, and a bulleted text answer" />
  </figure>
</div>
</div>

<p>Now the chat can fetch data — and by default it answers with text.</p>

<p>The most direct way to make that answer more visual is to render the <em>tool result</em> nicely. That’s how many chat-based agents work: instead of raw JSON the user sees a card with the result of the tool the model called.</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/messages/tool_results/_weather.html.erb %&gt;</span>
<span class="cp">&lt;%</span> <span class="n">weather</span> <span class="o">=</span> <span class="no">JSON</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="n">tool</span><span class="p">.</span><span class="nf">content</span><span class="p">).</span><span class="nf">symbolize_keys</span> <span class="cp">%&gt;</span>
<span class="cp">&lt;%=</span> <span class="n">render</span> <span class="s2">"components/weather"</span><span class="p">,</span> <span class="o">**</span><span class="n">weather</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<blockquote>
  <p><em>Tool-result UI</em> answers the question “what did the agent just do?”.
<em>Generative UI</em> answers “what form of response is most useful to the user right now?”</p>
</blockquote>

<p>Showing tool results is useful: the user sees which tools the model called and what data it worked with. But the more internal steps an agent takes, the more a detailed log of its work starts to crowd the main interface. Generative UI solves a different problem: instead of painting in the whole execution trail, it lets the model choose the form of the final answer.</p>

<p>What if, instead of visualizing the result of <code class="language-plaintext highlighter-rouge">WeatherTool</code>, we wanted the model itself to ask for a weather card to be shown?</p>

<h2 id="generative-ui-with-per-component-tools">Generative UI with Per-Component Tools</h2>

<p>For the view layer to render a component, it needs two things: a component name and attributes (often called props). A tool call already has both: the tool name and its arguments. That’s the first important shift — the data for the UI lives not in the tool’s result, but in the arguments of the call itself. Which means we can introduce a dedicated tool for this component: one that computes nothing, and just serves as a render signal.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">WeatherWidgetTool</span> <span class="o">&lt;</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Tool</span>
  <span class="n">description</span> <span class="s2">"Render a weather widget inline in the chat."</span>

  <span class="n">params</span> <span class="k">do</span>
    <span class="n">string</span> <span class="ss">:location</span>
    <span class="n">number</span> <span class="ss">:temperature</span>
    <span class="n">string</span> <span class="ss">:unit</span><span class="p">,</span> <span class="ss">required: </span><span class="kp">false</span>
    <span class="n">number</span> <span class="ss">:wind</span><span class="p">,</span> <span class="ss">required: </span><span class="kp">false</span>
    <span class="n">number</span> <span class="ss">:humidity</span><span class="p">,</span> <span class="ss">required: </span><span class="kp">false</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">execute</span><span class="p">(</span><span class="n">location</span><span class="p">:,</span> <span class="n">temperature</span><span class="p">:,</span> <span class="ss">unit: </span><span class="s2">"c"</span><span class="p">,</span> <span class="ss">wind: </span><span class="kp">nil</span><span class="p">,</span> <span class="ss">humidity: </span><span class="kp">nil</span><span class="p">)</span>
    <span class="n">errors</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="c1"># validate attributes...</span>

    <span class="n">errors</span><span class="p">.</span><span class="nf">any?</span> <span class="p">?</span> <span class="p">{</span> <span class="ss">status: </span><span class="s2">"invalid"</span><span class="p">,</span> <span class="ss">errors: </span><span class="p">}.</span><span class="nf">to_json</span> <span class="p">:</span> <span class="p">{</span> <span class="ss">status: </span><span class="s2">"ok"</span> <span class="p">}.</span><span class="nf">to_json</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">tool result</code> here is deliberately short. Its job isn’t to carry UI — only to tell the model whether the arguments were accepted. If you put HTML or the full component data into the <code class="language-plaintext highlighter-rouge">tool result</code>, the model will see it on the next step and is very likely to start paraphrasing what’s already on screen. <code class="language-plaintext highlighter-rouge">halt</code> looks tempting, but it pulls the conversation into a different problem: the history ends up with an “assistant message” that the assistant never actually wrote.</p>

<p>The view’s only job left is to read the arguments of the parent tool call and render an allow-listed component:</p>

<div class="row row--top">
<div class="row-text">

    <div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/messages/tool_results/_weather_widget.html.erb %&gt;</span>
<span class="cp">&lt;%</span> <span class="n">result</span> <span class="o">=</span> <span class="no">JSON</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="n">tool</span><span class="p">.</span><span class="nf">content</span><span class="p">.</span><span class="nf">to_s</span><span class="p">)</span> <span class="k">rescue</span> <span class="p">{}</span> <span class="cp">%&gt;</span>
<span class="cp">&lt;%</span> <span class="n">args</span> <span class="o">=</span> <span class="n">tool</span><span class="p">.</span><span class="nf">parent_tool_call</span><span class="p">.</span><span class="nf">arguments</span><span class="p">.</span><span class="nf">to_h</span><span class="p">.</span><span class="nf">symbolize_keys</span> <span class="cp">%&gt;</span>

<span class="cp">&lt;%</span> <span class="k">if</span> <span class="n">result</span><span class="p">[</span><span class="s2">"status"</span><span class="p">]</span> <span class="o">==</span> <span class="s2">"ok"</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">render</span> <span class="s2">"components/weather"</span><span class="p">,</span>
        <span class="o">**</span><span class="n">args</span><span class="p">.</span><span class="nf">slice</span><span class="p">(</span><span class="ss">:location</span><span class="p">,</span> <span class="ss">:temperature</span><span class="p">,</span> <span class="ss">:unit</span><span class="p">,</span> <span class="ss">:wind</span><span class="p">,</span> <span class="ss">:humidity</span><span class="p">)</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>

  </div>
<div class="row-figure">
  <figure>
    <img src="/assets/images/posts/generative-ui-ruby-llm/02-widget-belgrade.png" alt="User asks for weather in Belgrade and gets a weather widget; an arrow labels the widget as the Weather Widget tool call" />
  </figure>
</div>
</div>

<p>One small catch: even with a minimal tool result, the model still tends to add a prose reply under the rendered widget. The cleanest fix is to tell it in system instructions that the widget tool call <em>is</em> the answer — we’ll come back to the same trick with <code class="language-plaintext highlighter-rouge">generate_ui</code> below.</p>

<p>For a single component this already works well: the UI component lives outside the model, and the model just picks it and fills in the attributes. But the approach hits a ceiling fast.</p>

<h2 id="the-limits-of-per-component-tools">The Limits of Per-Component Tools</h2>

<p>The weather card is useful on its own, but the next step is more interesting: the model can pick on the fly between cards, containers, buttons, forms, and confirmations — and assemble a response shaped to the task. The same primitives can combine into useful scenarios the developer never planned for. For that you need a shared UI vocabulary, not a separate tool for every new scenario.</p>

<p>Ask the model to compare the weather in two cities, and it calls <code class="language-plaintext highlighter-rouge">WeatherWidgetTool</code> twice and shows two cards. But comparison as a structure never appears: the cards just stack on top of each other.</p>

<div class="row row--top">
<div class="row-figure">
  <figure>
    <figcaption>Actual: just widgets</figcaption>
    <img src="/assets/images/posts/generative-ui-ruby-llm/03-comparison-failure.png" alt="Actual: two independent weather cards stacked vertically" />
  </figure>
</div>
<div class="row-figure">
  <figure>
    <figcaption>Target: composed components</figcaption>
    <img src="/assets/images/posts/generative-ui-ruby-llm/04-composition-target.png" alt="Target: two weather cards composed side-by-side inside a single comparison container" />
  </figure>
</div>
</div>

<p>Another familiar case is clarification. A user asks the assistant: “What is the weather in Springfield?” The assistant thinks, calls some tools, and then instead of answering writes: “Which Springfield did you mean?” — forcing the user to type the city or state all over again. Springfield is ambiguous, so the clarification has to happen. The real question is how the user answers it — typing the city or state again, or picking from options with a single click. The interface can do better here: show a picker right away, where the user’s next turn is one tap. The renderer just shows the buttons; turning a click into a new message is the application’s job.</p>

<div class="row row--top">
<div class="row-figure">
  <figure>
    <figcaption>Actual: just text</figcaption>
    <img src="/assets/images/posts/generative-ui-ruby-llm/05-springfield-text-clarification.png" alt="Actual: model asks 'Which Springfield do you mean?' and lists the options in plain text" />
  </figure>
</div>
<div class="row-figure">
  <figure>
    <figcaption>Target: interactive picker</figcaption>
    <img src="/assets/images/posts/generative-ui-ruby-llm/06-springfield-picker-target.png" alt="Target: a picker component with clickable options for the ambiguous city name" />
  </figure>
</div>
</div>

<p>Both cases can be solved with dedicated tools: <code class="language-plaintext highlighter-rouge">WeatherComparisonTool</code> for comparison, <code class="language-plaintext highlighter-rouge">CityPickerTool</code> for clarification, then <code class="language-plaintext highlighter-rouge">ConfirmationTool</code> for confirmations. For a simple product this is a perfectly fine path. The limit shows up later: the catalog starts to grow not by the number of reusable primitives, but by the number of scenarios. Every new UX gesture demands a new top-level tool.</p>

<h2 id="one-tool-to-compose-them-all">One Tool to Compose Them All</h2>

<p>This points to a different move: keep one general-purpose tool for generative UI, and pass the entire response structure as its argument instead of a single component. Let’s call it <code class="language-plaintext highlighter-rouge">generate_ui</code>. Same idea — tool call as UI payload — only now the arguments hold not one card, but the whole component tree.</p>

<p>The structure is easiest to pass flat: a single <code class="language-plaintext highlighter-rouge">components</code> array, one component with <code class="language-plaintext highlighter-rouge">id: "root"</code>, and connections between components as plain id references. The component catalog shows up directly in the payload: each node names its own component and carries the attributes declared for it.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
  </span><span class="nl">"components"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"root"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"component"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Comparison"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"items"</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">"novi-sad"</span><span class="p">,</span><span class="w"> </span><span class="s2">"belgrade"</span><span class="p">]</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"novi-sad"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"component"</span><span class="p">:</span><span class="w"> </span><span class="s2">"WeatherCard"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"location"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Novi Sad"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"temperature"</span><span class="p">:</span><span class="w"> </span><span class="mi">22</span><span class="p">,</span><span class="w">
      </span><span class="nl">"unit"</span><span class="p">:</span><span class="w"> </span><span class="s2">"c"</span><span class="w">
    </span><span class="p">},</span><span class="w">
    </span><span class="p">{</span><span class="w">
      </span><span class="nl">"id"</span><span class="p">:</span><span class="w"> </span><span class="s2">"belgrade"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"component"</span><span class="p">:</span><span class="w"> </span><span class="s2">"WeatherCard"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"location"</span><span class="p">:</span><span class="w"> </span><span class="s2">"Belgrade"</span><span class="p">,</span><span class="w">
      </span><span class="nl">"temperature"</span><span class="p">:</span><span class="w"> </span><span class="mi">24</span><span class="p">,</span><span class="w">
      </span><span class="nl">"unit"</span><span class="p">:</span><span class="w"> </span><span class="s2">"c"</span><span class="w">
    </span><span class="p">}</span><span class="w">
  </span><span class="p">]</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>This is still a tree in normalized form: one root, references only by id, no cycles, no orphans, no child reused in two places. But now the link between the wire format and the catalog is direct. If a node says <code class="language-plaintext highlighter-rouge">component: "WeatherCard"</code>, it carries that component’s attribute schema. If <code class="language-plaintext highlighter-rouge">Comparison.items</code> is declared as a list of component references, the application can check not just the array type but that the items inside really are weather cards.</p>

<p>With this shape, both earlier cases become compositions rather than new top-level tools: <code class="language-plaintext highlighter-rouge">Comparison</code> wraps two <code class="language-plaintext highlighter-rouge">WeatherCard</code>s, and <code class="language-plaintext highlighter-rouge">Picker</code> wraps several <code class="language-plaintext highlighter-rouge">QuickReply</code>s.</p>

<div class="row row--top">
<div class="row-figure">
  <figure>
    <img src="/assets/images/posts/generative-ui-ruby-llm/07-springfield-picker-real.png" alt="The Springfield clarification rendered as three quick_reply buttons inside a vertical container" />
    <figcaption>`Picker` + three `QuickReply`s give a clarification flow without a separate `CityPickerTool`.</figcaption>
  </figure>
</div>
<div class="row-figure">
  <figure>
    <img src="/assets/images/posts/generative-ui-ruby-llm/08-comparison-render-ui.png" alt="A side-by-side weather comparison rendered via generate_ui" />
    <figcaption>`Comparison` + two `WeatherCard`s give a side-by-side comparison of two cities.</figcaption>
  </figure>
</div>
</div>

<p>The simplest hand-rolled version looks like a single presentation tool. It fetches nothing and has no side effects. It accepts a UI tree, validates it, and returns a short status.</p>

<p>In the example below, <code class="language-plaintext highlighter-rouge">params</code> describes the shape of individual components, and <code class="language-plaintext highlighter-rouge">UiTree.validate</code> would be the application’s validator for whole-tree invariants: exactly one <code class="language-plaintext highlighter-rouge">root</code>, all references resolve, no cycles, no orphans, no child reused in two places. <code class="language-plaintext highlighter-rouge">COMPONENT_CATALOG</code> here is the same kind of hand-rolled Ruby structure that lists the available components and their relationships. The code of the validator and that structure isn’t what matters here; what matters is the boundary itself — the schema helps the model hit the expected shape, but the application still treats the result as an external payload before rendering.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">GenerateUiTool</span> <span class="o">&lt;</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Tool</span>
  <span class="n">description</span> <span class="o">&lt;&lt;~</span><span class="no">TEXT</span><span class="sh">
    Render inline UI from the available component catalog.

    Arguments:
    - components is a flat array of component instances.
    - One component must have id="root".
    - Component reference fields point to other component ids.
    - The accepted payload must form one rooted tree.

    Available components:
    - WeatherCard: show current weather for one location.
    - Comparison: compare several weather cards side by side.
    - Picker: ask the user to choose between ambiguous options.
    - QuickReply: clickable option that sends a short reply back into the chat.
</span><span class="no">  TEXT</span>

  <span class="n">params</span> <span class="k">do</span>
    <span class="n">array</span> <span class="ss">:components</span> <span class="k">do</span>
      <span class="n">any_of</span> <span class="k">do</span>
        <span class="n">object</span> <span class="k">do</span>
          <span class="n">string</span> <span class="ss">:id</span>
          <span class="n">string</span> <span class="ss">:component</span><span class="p">,</span> <span class="ss">enum: </span><span class="p">[</span><span class="s2">"WeatherCard"</span><span class="p">]</span>
          <span class="n">string</span> <span class="ss">:location</span>
          <span class="n">number</span> <span class="ss">:temperature</span>
          <span class="n">string</span> <span class="ss">:unit</span><span class="p">,</span> <span class="ss">enum: </span><span class="sx">%w[c f]</span>
        <span class="k">end</span>

        <span class="n">object</span> <span class="k">do</span>
          <span class="n">string</span> <span class="ss">:id</span>
          <span class="n">string</span> <span class="ss">:component</span><span class="p">,</span> <span class="ss">enum: </span><span class="p">[</span><span class="s2">"Comparison"</span><span class="p">]</span>
          <span class="n">array</span> <span class="ss">:items</span><span class="p">,</span> <span class="ss">of: :string</span><span class="p">,</span> <span class="ss">min_items: </span><span class="mi">2</span>
        <span class="k">end</span>

        <span class="c1"># ...and the same kind of schema for Picker and QuickReply.</span>
      <span class="k">end</span>
    <span class="k">end</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">execute</span><span class="p">(</span><span class="o">**</span><span class="n">args</span><span class="p">)</span>
    <span class="n">errors</span> <span class="o">=</span> <span class="no">UiTree</span><span class="p">.</span><span class="nf">validate</span><span class="p">(</span>
      <span class="n">args</span><span class="p">.</span><span class="nf">fetch</span><span class="p">(</span><span class="ss">:components</span><span class="p">),</span>
      <span class="ss">catalog: </span><span class="no">COMPONENT_CATALOG</span> <span class="c1"># same catalog, now used by the server validator</span>
    <span class="p">)</span>

    <span class="n">errors</span><span class="p">.</span><span class="nf">any?</span> <span class="p">?</span> <span class="p">{</span> <span class="ss">status: </span><span class="s2">"invalid"</span><span class="p">,</span> <span class="ss">errors: </span><span class="p">}.</span><span class="nf">to_json</span> <span class="p">:</span> <span class="p">{</span> <span class="ss">status: </span><span class="s2">"ok"</span> <span class="p">}.</span><span class="nf">to_json</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>If the tree is invalid, the model gets a short list of errors and can correct itself on the next step. If the tree is valid, the application can take the arguments of that call and render them as the response to the user.</p>

<p>This sketch shows the mechanics — and at the same time exposes what’s missing: a single place where the catalog is assembled. The catalog is needed by several parts of the system at once:</p>

<ul>
  <li>the LLM — so it knows from system instructions which components are available and when to use them;</li>
  <li>the provider — to derive the JSON Schema for the <code class="language-plaintext highlighter-rouge">generate_ui</code> arguments;</li>
  <li>the server — to validate the tree, component references, and attributes before rendering;</li>
  <li>the view layer — to know how each component should be rendered.</li>
</ul>

<p>If these four views live separately, the UI tool quickly turns into a set of conventions you have to keep in sync by hand. So we need a single Ruby catalog from which instructions, schema, validation, and render targets are all derived.</p>

<p>Same UX trick as with the widget tool earlier: the system instructions should tell the model that calling <code class="language-plaintext highlighter-rouge">generate_ui</code> <em>is</em> the answer — no final text needed. The wiring is shown in the system prompt below.</p>

<h2 id="the-generativeui-gem">The <code class="language-plaintext highlighter-rouge">GenerativeUI</code> gem</h2>

<p><a href="https://github.com/kryzhovnik/generative_ui"><code class="language-plaintext highlighter-rouge">GenerativeUI</code></a> starts from a single idea: describe UI components once in a catalog, and derive everything else from it. In the current Rails/RubyLLM integration this catalog is wired up through the <code class="language-plaintext highlighter-rouge">generate_ui</code> tool, but the heart of the library isn’t the transport — it’s the catalog DSL. The <a href="https://github.com/kryzhovnik/generative_ui-demo">demo app</a> shows everything wired together end-to-end.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">ApplicationGenerativeCatalog</span> <span class="o">&lt;</span> <span class="no">GenerativeUI</span><span class="o">::</span><span class="no">Catalog</span>
  <span class="n">component</span> <span class="s2">"WeatherCard"</span> <span class="k">do</span>
    <span class="n">desc</span> <span class="s2">"Show current weather for one location."</span>

    <span class="n">attributes</span> <span class="k">do</span>
      <span class="n">string</span> <span class="ss">:location</span>
      <span class="n">number</span> <span class="ss">:temperature</span>
      <span class="n">string</span> <span class="ss">:unit</span><span class="p">,</span> <span class="ss">enum: </span><span class="sx">%w[c f]</span>
      <span class="n">string</span> <span class="ss">:condition</span><span class="p">,</span> <span class="ss">required: </span><span class="kp">false</span>
      <span class="n">number</span> <span class="ss">:wind</span><span class="p">,</span> <span class="ss">required: </span><span class="kp">false</span>
      <span class="n">number</span> <span class="ss">:humidity</span><span class="p">,</span> <span class="ss">required: </span><span class="kp">false</span>
    <span class="k">end</span>

    <span class="n">present_with</span> <span class="ss">:partial</span><span class="p">,</span> <span class="s2">"generative_ui/weather_card"</span>
  <span class="k">end</span>

  <span class="n">component</span> <span class="s2">"Comparison"</span> <span class="k">do</span>
    <span class="n">desc</span> <span class="s2">"Compare several weather cards side by side."</span>

    <span class="n">attributes</span> <span class="k">do</span>
      <span class="n">many_components</span> <span class="ss">:items</span><span class="p">,</span> <span class="ss">only: </span><span class="s2">"WeatherCard"</span><span class="p">,</span> <span class="ss">min_items: </span><span class="mi">2</span>
    <span class="k">end</span>

    <span class="n">present_with</span> <span class="ss">:partial</span><span class="p">,</span> <span class="s2">"generative_ui/comparison"</span>
  <span class="k">end</span>

  <span class="n">component</span> <span class="s2">"QuickReply"</span> <span class="k">do</span>
    <span class="n">desc</span> <span class="s2">"Clickable option that sends a short reply back into the chat."</span>

    <span class="n">attributes</span> <span class="k">do</span>
      <span class="n">string</span> <span class="ss">:label</span>
      <span class="n">string</span> <span class="ss">:value</span>
    <span class="k">end</span>

    <span class="n">present_with</span> <span class="ss">:partial</span><span class="p">,</span> <span class="s2">"generative_ui/quick_reply"</span>
  <span class="k">end</span>

  <span class="n">component</span> <span class="s2">"Picker"</span> <span class="k">do</span>
    <span class="n">desc</span> <span class="s2">"Ask the user to choose between ambiguous options."</span>

    <span class="n">attributes</span> <span class="k">do</span>
      <span class="n">string</span> <span class="ss">:prompt</span>
      <span class="n">many_components</span> <span class="ss">:options</span><span class="p">,</span> <span class="ss">only: </span><span class="s2">"QuickReply"</span>
    <span class="k">end</span>

    <span class="n">present_with</span> <span class="ss">:partial</span><span class="p">,</span> <span class="s2">"generative_ui/picker"</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>This single declaration captures everything we used to keep in sync by hand:</p>

<ul>
  <li>the component name in the payload (<code class="language-plaintext highlighter-rouge">WeatherCard</code>, <code class="language-plaintext highlighter-rouge">Comparison</code>, <code class="language-plaintext highlighter-rouge">Picker</code>);</li>
  <li>the description for the LLM (<code class="language-plaintext highlighter-rouge">desc</code>);</li>
  <li>the attribute schema (<code class="language-plaintext highlighter-rouge">attributes</code>);</li>
  <li>the structural relationships between components (<code class="language-plaintext highlighter-rouge">one_component</code>, <code class="language-plaintext highlighter-rouge">many_components</code>);</li>
  <li>the render target for the application: convention-over-configuration by default, with <code class="language-plaintext highlighter-rouge">present_with</code> to point at a specific partial or component.</li>
</ul>

<p>From this catalog the gem derives component descriptions for the LLM, the schema for <code class="language-plaintext highlighter-rouge">generate_ui</code> arguments, tree validation, and render targets.</p>

<p>The catalog can be registered as the default so you don’t have to pass it explicitly to every tool and render call:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/initializers/generative_ui.rb</span>
<span class="no">GenerativeUI</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">catalog</span> <span class="ss">:default</span><span class="p">,</span> <span class="s2">"ApplicationGenerativeCatalog"</span>
<span class="k">end</span>
</code></pre></div></div>

<p>After that, the chat gets a catalog-bound tool:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">tool</span> <span class="o">=</span> <span class="no">GenerativeUI</span><span class="o">::</span><span class="no">Tool</span><span class="p">.</span><span class="nf">new</span>

<span class="n">chat</span> <span class="o">=</span> <span class="no">Chat</span><span class="p">.</span><span class="nf">create!</span>
<span class="n">chat</span><span class="p">.</span><span class="nf">with_instructions</span><span class="p">(</span><span class="o">&lt;&lt;~</span><span class="no">PROMPT</span><span class="p">)</span><span class="sh">
  You are a helpful weather assistant.

  Tool guidance:
  - Use generate_ui when the answer should be shown as UI.
  - IMPORTANT: after calling generate_ui, do not add a final text answer.
    The tool call itself is the user-visible UI response.
</span><span class="no">PROMPT</span>
<span class="n">chat</span><span class="p">.</span><span class="nf">with_tool</span><span class="p">(</span><span class="n">tool</span><span class="p">)</span>

<span class="n">chat</span><span class="p">.</span><span class="nf">ask</span><span class="p">(</span><span class="s2">"Compare the weather in Novi Sad and Belgrade."</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">GenerativeUI::Tool</code> takes the selected catalog, merges its description into the tool description, and compiles the provider-facing JSON Schema for the arguments. At call time the tool validates the tree and returns a short result: <code class="language-plaintext highlighter-rouge">{ "status": "ok" }</code> or <code class="language-plaintext highlighter-rouge">{ "status": "invalid", "errors": ... }</code>.</p>

<p>In Rails views, the user-facing UI is rendered from the tool call’s arguments:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/messages/tool_calls/_generate_ui.html.erb %&gt;</span>
<span class="cp">&lt;%</span> <span class="k">begin</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%=</span> <span class="n">render_generative_ui</span> <span class="n">tool_call</span><span class="p">.</span><span class="nf">arguments</span> <span class="cp">%&gt;</span>
<span class="cp">&lt;%</span> <span class="k">rescue</span> <span class="no">GenerativeUI</span><span class="o">::</span><span class="no">InvalidComponentTreeError</span> <span class="o">=&gt;</span> <span class="n">e</span> <span class="cp">%&gt;</span>
  <span class="cp">&lt;%</span> <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Notifications</span><span class="p">.</span><span class="nf">instrument</span><span class="p">(</span>
       <span class="s2">"invalid_tree.generative_ui"</span><span class="p">,</span>
       <span class="ss">error: </span><span class="n">e</span><span class="p">,</span>
       <span class="ss">tool_call: </span><span class="n">tool_call</span>
     <span class="p">)</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>The tool’s status result is best hidden from the user:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/messages/tool_results/_generate_ui.html.erb %&gt;</span>
<span class="c">&lt;%# intentionally empty: { "status": "ok" } is control data, not UI %&gt;</span>
</code></pre></div></div>

<p>By this point the tree has already passed the catalog + validator check. The partial doesn’t decide whether the component can be trusted; it just receives the validated attributes and turns them into HTML.</p>

<p>A leaf component gets plain locals:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/generative_ui/_weather_card.html.erb %&gt;</span>
<span class="nt">&lt;article</span> <span class="na">class=</span><span class="s">"rounded-2xl border p-4"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;h3&gt;</span><span class="cp">&lt;%=</span> <span class="n">location</span> <span class="cp">%&gt;</span><span class="nt">&lt;/h3&gt;</span>
  <span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"text-3xl font-semibold"</span><span class="nt">&gt;</span><span class="cp">&lt;%=</span> <span class="n">temperature</span> <span class="cp">%&gt;</span>°<span class="cp">&lt;%=</span> <span class="n">unit</span><span class="p">.</span><span class="nf">upcase</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="n">condition</span><span class="p">.</span><span class="nf">present?</span> <span class="cp">%&gt;</span>
    <span class="nt">&lt;p&gt;</span><span class="cp">&lt;%=</span> <span class="n">condition</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;/article&gt;</span>
</code></pre></div></div>

<p>A container component receives children that are already rendered:</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/generative_ui/_comparison.html.erb %&gt;</span>
<span class="nt">&lt;div</span> <span class="na">class=</span><span class="s">"grid gap-4 md:grid-cols-2"</span><span class="nt">&gt;</span>
  <span class="cp">&lt;%</span> <span class="n">items</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">item</span><span class="o">|</span> <span class="cp">%&gt;</span>
    <span class="cp">&lt;%=</span> <span class="n">item</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>

<p>The partial is just one of several render paths. The gem can also render through ViewComponent or return a JSON representation of the tree; if you need something else, the application can register its own renderer.</p>

<p>One caveat worth naming: even with explicit system instructions, models sometimes still add a short prose answer to a turn that already produced a <code class="language-plaintext highlighter-rouge">generate_ui</code> or widget tool call. The behavior varies between providers, models, and even individual requests. If the duplication matters for your product, the application can handle it on the view side — for example, by suppressing trailing assistant text on turns that already rendered UI. It’s a small ergonomics layer, not a structural problem with the approach.</p>

<h2 id="where-this-leaves-us">Where this leaves us</h2>

<p>Generative UI today means very different things to different people: from polished rendering of <code class="language-plaintext highlighter-rouge">tool result</code>s to an interface that rebuilds itself in real time around the user’s intent.</p>

<p>I tried to focus on something more down-to-earth: what you can do right now in an ordinary chat-based application — without a custom runtime, without HTML generated by the model, and without rebuilding the product from scratch. All it takes is to give the LLM not the whole screen, but a strictly described yet composable catalog of components.</p>

<p>In this approach the model doesn’t draw the interface directly. It picks from allowed primitives and assembles a response tree out of them. The application validates that tree and renders it with its own renderers. Because of that, the final UI isn’t tied to one platform: the same generative payload can be shown on the web through Rails partials, in a mobile app through native components, and in Telegram or WhatsApp — through their buttons, lists, and messages.</p>

<p>Generative UI is no longer just “a chat with widgets,” but it hasn’t settled into one canonical pattern yet either. A good way to find out where its real shape lies is to assemble a small catalog of components and try it out in a live conversation.</p>]]></content><author><name>Andrey Samsonov</name></author><category term="rails" /><category term="ruby-llm" /><category term="llm" /><category term="generative-ui" /><summary type="html"><![CDATA[A walk through the design choices for showing rich UI in an LLM chat app instead of plain text bubbles. Tools, schemas, and a tiny gem for generative UI on top of RubyLLM.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://msg.samsonov.io/assets/images/og/posts/generative-ui-ruby-llm.png" /><media:content medium="image" url="https://msg.samsonov.io/assets/images/og/posts/generative-ui-ruby-llm.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Share Extension Auth in iOS 18: Four Approaches Compared</title><link href="https://msg.samsonov.io/2026-01-14-ios-18-share-extension-for-unauthenticated-user/" rel="alternate" type="text/html" title="Share Extension Auth in iOS 18: Four Approaches Compared" /><published>2026-01-14T00:00:00+00:00</published><updated>2026-01-14T00:00:00+00:00</updated><id>https://msg.samsonov.io/ios-18-share-extension-for-unauthenticated-user</id><content type="html" xml:base="https://msg.samsonov.io/2026-01-14-ios-18-share-extension-for-unauthenticated-user/"><![CDATA[<p>For years, the Share icon on mobile was just visual noise to me — something that kept appearing everywhere but I never used. Then something clicked: Share is like a Unix pipe. You take content from one app and send it to another in one action. No copy-paste, no saving files, no searching for “import” — just pick the next tool and continue the chain. The only difference is that instead of a stream, Share passes a package (a link, text, a file, or several photos).</p>

<figure>
  <img src="/assets/images/diagrams/share-as-pipe.svg" alt="Share as Unix pipe" />
  <figcaption>share ≈ pipe</figcaption>
</figure>

<p>The problem is that Unix pipes live inside a single user environment, while Share crosses app boundaries: separate sandboxes, separate processes, separate security rules. So the task “pass data” quickly becomes “pass data in the user’s context”: the receiver needs to know who the current user is and where to put this package. In my case: a user shares a link to my app, but processing happens on the server, so the user needs to be authenticated first. What do you do when a Share request arrives but there’s no session — or the session isn’t available right now?</p>

<h2 id="the-naive-solution-just-open-the-app">The naive solution: “just open the app”</h2>

<p>The first idea that comes to mind: the extension detects that the user is not logged in and opens the main app. The user logs in, goes back to Safari, taps Share again — and now everything works.</p>

<p>In code, it looks simple:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">extensionContext</span><span class="p">?</span><span class="o">.</span><span class="nf">open</span><span class="p">(</span><span class="kt">URL</span><span class="p">(</span><span class="nv">string</span><span class="p">:</span> <span class="s">"dropkind://login"</span><span class="p">)</span><span class="o">!</span><span class="p">)</span> <span class="p">{</span> <span class="n">success</span> <span class="k">in</span>
    <span class="k">self</span><span class="o">.</span><span class="n">extensionContext</span><span class="p">?</span><span class="o">.</span><span class="nf">completeRequest</span><span class="p">(</span><span class="nv">returningItems</span><span class="p">:</span> <span class="kc">nil</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Or via Universal Links:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">extensionContext</span><span class="p">?</span><span class="o">.</span><span class="nf">open</span><span class="p">(</span><span class="kt">URL</span><span class="p">(</span><span class="nv">string</span><span class="p">:</span> <span class="s">"https://dropkind.app/auth"</span><span class="p">)</span><span class="o">!</span><span class="p">)</span>
</code></pre></div></div>

<p>This pattern worked for years. Share Extension acted as a launcher: it detected a problem, passed control to the main app, and closed.</p>

<p>In iOS 18, this stopped working.</p>

<h2 id="what-apple-broke-and-why-its-not-a-bug">What Apple broke (and why it’s not a bug)</h2>

<p>When you try to open an app from a Share Extension in iOS 18, you get an error:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>LSApplicationWorkspaceErrorDomain Code=115
</code></pre></div></div>

<p>This is not a bug or a temporary regression. Apple <a href="https://developer.apple.com/forums/thread/764570">explicitly states</a> that app extensions are not allowed to open URLs directly; runtime workarounds are being blocked. If you need the user’s attention — use a local notification.</p>

<h3 id="cold-start--warm-start">Cold start / Warm start</h3>

<p>In my experience, <code class="language-plaintext highlighter-rouge">extensionContext.open(...)</code> sometimes works when the app is already in memory — but you can’t control or predict that, and it’s not documented. The user might have closed the app an hour ago, and the call will silently fail.</p>

<h3 id="what-apple-recommends-instead-of-openurl">What Apple recommends instead of openURL</h3>

<p>On the same forum, Quinn writes:</p>

<blockquote>
  <p>“If your app extension needs to get the user’s attention, do that by posting a local notification.”</p>
</blockquote>

<p>The idea is that an extension should not be a trampoline to the main app — it should handle the task on its own. If it can’t — it should just tell the user via a local notification.</p>

<h3 id="old-hacks-no-longer-work">Old hacks no longer work</h3>

<p>If you googled this problem before, you probably saw the UIResponder chain “hack”:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// THIS NO LONGER WORKS</span>
<span class="k">var</span> <span class="nv">responder</span><span class="p">:</span> <span class="kt">UIResponder</span><span class="p">?</span> <span class="o">=</span> <span class="k">self</span>
<span class="k">while</span> <span class="n">responder</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">{</span>
    <span class="k">if</span> <span class="k">let</span> <span class="nv">application</span> <span class="o">=</span> <span class="n">responder</span> <span class="k">as?</span> <span class="kt">UIApplication</span> <span class="p">{</span>
        <span class="n">application</span><span class="o">.</span><span class="nf">perform</span><span class="p">(</span><span class="k">#selector</span><span class="p">(</span><span class="nf">openURL</span><span class="p">(</span><span class="nv">_</span><span class="p">:)),</span> <span class="nv">with</span><span class="p">:</span> <span class="n">url</span><span class="p">)</span>
        <span class="k">break</span>
    <span class="p">}</span>
    <span class="n">responder</span> <span class="o">=</span> <span class="n">responder</span><span class="p">?</span><span class="o">.</span><span class="n">next</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Starting with iOS 18, this code throws a sandbox error (<code class="language-plaintext highlighter-rouge">NSOSStatusErrorDomain Code=-54</code>). The system checks the call stack and blocks attempts to bypass restrictions.</p>

<h2 id="how-apples-own-apps-do-it">How Apple’s own apps do it</h2>

<figure class="aside-right">
  <img src="/assets/images/posts/ios-18-share-extension/notes-share-sheet.png" alt="Notes Share Extension" />
  <figcaption>Notes Share Extension</figcaption>
</figure>

<p>It’s interesting to see how Apple solves this problem in their own apps. Here’s what I found:</p>

<p><strong>Notes:</strong> when sharing a link to Notes, the extension shows a folder picker, you tap “Save” and… you stay in Safari. The note is saved via background sync, and you only find out when you open Notes.</p>

<p><strong>Photos:</strong> same approach — the extension saves to a shared container, sync happens in the background.</p>

<p><strong>Messages:</strong> if you select a contact from “suggestions” in the Share Sheet, the system itself opens Messages. This is a system-level path, not openURL from an extension.</p>

<p>So Apple’s apps either don’t open the main app at all, or they use privileged system mechanisms that are not available to third-party developers.</p>

<h2 id="working-solutions">Working solutions</h2>

<h3 id="solution-a-shared-keychain--the-extension-handles-auth-on-its-own">Solution A: Shared Keychain — the extension handles auth on its own</h3>

<figure>
  <img src="/assets/images/diagrams/solution-a-shared-keychain.svg" alt="Solution A: Shared Keychain flow" />
  <figcaption>Main App saves token to Shared Keychain; Share Extension reads it directly</figcaption>
</figure>

<p>The best solution is to make the extension autonomous. If the extension has access to the auth token, it can send data to the server by itself, without touching the main app.</p>

<p>The idea is simple: the main app saves the token to Keychain with a shared access group when the user logs in, and the Share Extension reads it and makes the API request itself.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// In the main app during login:</span>
<span class="k">let</span> <span class="nv">query</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">:</span> <span class="kt">Any</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span>
    <span class="n">kSecClass</span> <span class="k">as</span> <span class="kt">String</span><span class="p">:</span> <span class="n">kSecClassGenericPassword</span><span class="p">,</span>
    <span class="n">kSecAttrAccount</span> <span class="k">as</span> <span class="kt">String</span><span class="p">:</span> <span class="s">"authToken"</span><span class="p">,</span>
    <span class="n">kSecAttrAccessGroup</span> <span class="k">as</span> <span class="kt">String</span><span class="p">:</span> <span class="s">"group.com.dropkind.shared"</span><span class="p">,</span>
    <span class="n">kSecValueData</span> <span class="k">as</span> <span class="kt">String</span><span class="p">:</span> <span class="n">token</span><span class="o">.</span><span class="nf">data</span><span class="p">(</span><span class="nv">using</span><span class="p">:</span> <span class="o">.</span><span class="n">utf8</span><span class="p">)</span><span class="o">!</span>
<span class="p">]</span>
<span class="kt">SecItemAdd</span><span class="p">(</span><span class="n">query</span> <span class="k">as</span> <span class="kt">CFDictionary</span><span class="p">,</span> <span class="kc">nil</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// In Share Extension:</span>
<span class="k">let</span> <span class="nv">query</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">:</span> <span class="kt">Any</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span>
    <span class="n">kSecClass</span> <span class="k">as</span> <span class="kt">String</span><span class="p">:</span> <span class="n">kSecClassGenericPassword</span><span class="p">,</span>
    <span class="n">kSecAttrAccount</span> <span class="k">as</span> <span class="kt">String</span><span class="p">:</span> <span class="s">"authToken"</span><span class="p">,</span>
    <span class="n">kSecAttrAccessGroup</span> <span class="k">as</span> <span class="kt">String</span><span class="p">:</span> <span class="s">"group.com.dropkind.shared"</span><span class="p">,</span>
    <span class="n">kSecReturnData</span> <span class="k">as</span> <span class="kt">String</span><span class="p">:</span> <span class="kc">true</span>
<span class="p">]</span>
<span class="k">var</span> <span class="nv">result</span><span class="p">:</span> <span class="kt">AnyObject</span><span class="p">?</span>
<span class="kt">SecItemCopyMatching</span><span class="p">(</span><span class="n">query</span> <span class="k">as</span> <span class="kt">CFDictionary</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">result</span><span class="p">)</span>
</code></pre></div></div>

<p>The main advantage is perfect UX: the user taps “Save” and stays where they were. You just need to set up Keychain Sharing between targets and make sure the token is available (not expired, not revoked).</p>

<p><strong>Important detail:</strong> Keychain can survive app deletion, so you can’t rely on automatic cleanup. Imagine this scenario: a user logged in, deleted the app, created a new account a year later (for example, in the web version of your service), installed the app, and immediately used Share — the extension would find the old token and send data to the wrong user.</p>

<p>The fix: App Group UserDefaults, unlike Keychain, gets deleted with the app. Store <code class="language-plaintext highlighter-rouge">currentUserId</code> in UserDefaults and check for it before using the token:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// In Share Extension:</span>
<span class="k">let</span> <span class="nv">shared</span> <span class="o">=</span> <span class="kt">UserDefaults</span><span class="p">(</span><span class="nv">suiteName</span><span class="p">:</span> <span class="s">"group.com.dropkind.shared"</span><span class="p">)</span>
<span class="k">guard</span> <span class="n">shared</span><span class="p">?</span><span class="o">.</span><span class="nf">string</span><span class="p">(</span><span class="nv">forKey</span><span class="p">:</span> <span class="s">"currentUserId"</span><span class="p">)</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="k">else</span> <span class="p">{</span>
    <span class="c1">// UserDefaults is empty → app was reinstalled → require login</span>
    <span class="k">return</span>
<span class="p">}</span>
<span class="c1">// Only now trust the token from Keychain</span>
</code></pre></div></div>

<h3 id="solution-b-app-entry-via-local-notification">Solution B: App entry via local notification</h3>

<figure>
  <img src="/assets/images/diagrams/solution-b-local-notification.svg" alt="Solution B: Local notification flow" />
  <figcaption>Share Extension saves data and schedules a notification; Main App completes the flow when user taps</figcaption>
</figure>

<p>If the extension can’t open the app programmatically, let the user do it by tapping a local notification:</p>

<ol>
  <li>Extension detects there’s no session</li>
  <li>Saves data to temporary storage (App Group UserDefaults)</li>
  <li>Shows a local notification: “Tap to log in and save the link”</li>
  <li>User taps — this is a legitimate action, the system allows the app to launch</li>
</ol>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// In Share Extension:</span>
<span class="kd">func</span> <span class="nf">showLoginNotification</span><span class="p">(</span><span class="nv">pendingURL</span><span class="p">:</span> <span class="kt">URL</span><span class="p">)</span> <span class="p">{</span>
    <span class="c1">// Save data</span>
    <span class="k">let</span> <span class="nv">defaults</span> <span class="o">=</span> <span class="kt">UserDefaults</span><span class="p">(</span><span class="nv">suiteName</span><span class="p">:</span> <span class="s">"group.com.dropkind.shared"</span><span class="p">)</span>
    <span class="n">defaults</span><span class="p">?</span><span class="o">.</span><span class="nf">set</span><span class="p">(</span><span class="n">pendingURL</span><span class="o">.</span><span class="n">absoluteString</span><span class="p">,</span> <span class="nv">forKey</span><span class="p">:</span> <span class="s">"pendingShare"</span><span class="p">)</span>

    <span class="c1">// Schedule notification</span>
    <span class="k">let</span> <span class="nv">content</span> <span class="o">=</span> <span class="kt">UNMutableNotificationContent</span><span class="p">()</span>
    <span class="n">content</span><span class="o">.</span><span class="n">title</span> <span class="o">=</span> <span class="s">"Login required"</span>
    <span class="n">content</span><span class="o">.</span><span class="n">body</span> <span class="o">=</span> <span class="s">"Tap to log in to DropKind and save your link"</span>
    <span class="n">content</span><span class="o">.</span><span class="n">userInfo</span> <span class="o">=</span> <span class="p">[</span><span class="s">"action"</span><span class="p">:</span> <span class="s">"completeShare"</span><span class="p">]</span>

    <span class="k">let</span> <span class="nv">request</span> <span class="o">=</span> <span class="kt">UNNotificationRequest</span><span class="p">(</span>
        <span class="nv">identifier</span><span class="p">:</span> <span class="s">"loginRequired"</span><span class="p">,</span>
        <span class="nv">content</span><span class="p">:</span> <span class="n">content</span><span class="p">,</span>
        <span class="nv">trigger</span><span class="p">:</span> <span class="kc">nil</span> <span class="c1">// Show immediately</span>
    <span class="p">)</span>
    <span class="kt">UNUserNotificationCenter</span><span class="o">.</span><span class="nf">current</span><span class="p">()</span><span class="o">.</span><span class="nf">add</span><span class="p">(</span><span class="n">request</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The implementation is simple and works reliably. The downside is an extra step for the user, and you need notification permission.</p>

<h4 id="in-practice-robust-api-flaky-ux">In practice: robust API, flaky UX</h4>

<p>I tried this approach and found it unreliable in practice:</p>

<ul>
  <li>Users deny notification permission reflexively</li>
  <li>Focus Mode / Do Not Disturb suppresses notifications silently</li>
  <li>Even delivered notifications get dismissed without reading</li>
  <li>Too many steps between “tap Share” and “complete the action”</li>
</ul>

<p>Local notifications are robust from iOS’s standpoint — Apple recommends it, the API is stable and documented. But they’re flaky from a UX standpoint. Too many points of failure for the user to actually complete the share.</p>

<h3 id="solution-c-oauth-inside-the-extension">Solution C: OAuth inside the extension</h3>

<figure>
  <img src="/assets/images/diagrams/solution-c-oauth-extension.svg" alt="Solution C: OAuth inside extension" />
  <figcaption>Share Extension handles OAuth flow internally; token saved for future use</figcaption>
</figure>

<p>The most complex option: implement full OAuth login right in the Share Extension using <code class="language-plaintext highlighter-rouge">ASWebAuthenticationSession</code>. The user authenticates without leaving the Share Sheet, the token is saved to Shared Keychain, and future shares work autonomously.</p>

<p>The UX is seamless — but the implementation is a lot of work. Not all OAuth providers play nice with extensions, and <code class="language-plaintext highlighter-rouge">ASWebAuthenticationSession</code> has quirks when running outside the main app context.</p>

<h3 id="solution-d-uiwindowsceneopen-via-responder-chain">Solution D: UIWindowScene.open() via responder chain</h3>

<figure>
  <img src="/assets/images/diagrams/solution-d-windowscene.svg" alt="Solution D: UIWindowScene responder chain" />
  <figcaption>Share Extension walks the responder chain to find UIWindowScene and calls open()</figcaption>
</figure>

<p>Remember the old UIResponder chain hack that Apple blocked? It turns out there’s a variation that still works on iOS 18+. Instead of walking up to <code class="language-plaintext highlighter-rouge">UIApplication</code>, you walk up to <code class="language-plaintext highlighter-rouge">UIWindowScene</code> and call its <code class="language-plaintext highlighter-rouge">open(_:options:completionHandler:)</code> method:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">static</span> <span class="kd">func</span> <span class="nf">openViaResponderChain</span><span class="p">(</span>
    <span class="n">from</span> <span class="nv">viewController</span><span class="p">:</span> <span class="kt">UIViewController</span><span class="p">,</span>
    <span class="nv">url</span><span class="p">:</span> <span class="kt">URL</span><span class="p">,</span>
    <span class="nv">completion</span><span class="p">:</span> <span class="p">((</span><span class="kt">Bool</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Void</span><span class="p">)?</span> <span class="o">=</span> <span class="kc">nil</span>
<span class="p">)</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">responder</span><span class="p">:</span> <span class="kt">UIResponder</span><span class="p">?</span> <span class="o">=</span> <span class="n">viewController</span>

    <span class="k">while</span> <span class="k">let</span> <span class="nv">current</span> <span class="o">=</span> <span class="n">responder</span> <span class="p">{</span>
        <span class="k">if</span> <span class="k">let</span> <span class="nv">scene</span> <span class="o">=</span> <span class="n">current</span> <span class="k">as?</span> <span class="kt">UIWindowScene</span> <span class="p">{</span>
            <span class="n">scene</span><span class="o">.</span><span class="nf">open</span><span class="p">(</span><span class="n">url</span><span class="p">,</span> <span class="nv">options</span><span class="p">:</span> <span class="kc">nil</span><span class="p">)</span> <span class="p">{</span> <span class="n">success</span> <span class="k">in</span>
                <span class="nf">completion</span><span class="p">?(</span><span class="n">success</span><span class="p">)</span>
            <span class="p">}</span>
            <span class="k">return</span>
        <span class="p">}</span>
        <span class="n">responder</span> <span class="o">=</span> <span class="n">current</span><span class="o">.</span><span class="n">next</span>
    <span class="p">}</span>
    <span class="nf">completion</span><span class="p">?(</span><span class="kc">false</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>This works because <code class="language-plaintext highlighter-rouge">UIWindowScene.open()</code> isn’t subject to the same restrictions as <code class="language-plaintext highlighter-rouge">UIApplication.open()</code>. The system doesn’t block it — at least not yet.</p>

<p><strong>Caveat</strong>: This is undocumented behavior. Apple could block it in a future iOS version, just like they blocked the <code class="language-plaintext highlighter-rouge">UIApplication</code> approach. Always have a fallback ready.</p>

<h2 id="what-i-chose">What I chose</h2>

<p><a href="https://dropkind.app">DropKind</a> is a simple app that sends articles and text to your Kindle. You find something interesting while browsing — share it to DropKind, and it lands on your e-reader. The Share Extension is the main entry point: most users discover content in Safari, not in the app itself. So a broken or clunky share flow means a broken product.</p>

<p>For DropKind, I chose a combination of solutions A and D, with a manual fallback. The main path is Shared Keychain: if the user is already logged in to the app, the extension picks up the token and works autonomously. If there’s no token — we try to open the app via <code class="language-plaintext highlighter-rouge">UIWindowScene</code>, and if that fails, we show an in-extension prompt.</p>

<p>Implementation details:</p>

<p>1) <strong>Separate share-token in Keychain.</strong> The main app gets a separate share-token and saves it to Shared Keychain. Share Extension uses this token for direct POST to the API.</p>

<p>2) <strong>user_id in App Group.</strong> Along with the token, we save <code class="language-plaintext highlighter-rouge">user_id</code> to App Group UserDefaults. This serves two purposes: the server verifies the token owner, and the presence of <code class="language-plaintext highlighter-rouge">user_id</code> itself is a marker that the app wasn’t reinstalled (UserDefaults gets deleted on uninstall, unlike Keychain).</p>

<p>3) <strong>If there’s no token</strong> — the extension saves data to App Group and attempts <code class="language-plaintext highlighter-rouge">UIWindowScene.open()</code> via the responder chain. This has worked reliably for me on iOS 18.</p>

<p>4) <strong>If UIWindowScene.open() fails</strong> — the extension shows an in-extension prompt asking the user to open the app manually. This is the final fallback, no notifications involved.</p>

<p>5) <strong>The main app finishes the job.</strong> On launch, the app checks for pending share data in the App Group, guides the user through login if needed, and completes the share.</p>

<div class="screenshot-gallery">
  <figure>
    <img src="/assets/images/posts/ios-18-share-extension/share-sheet-authenticated.png" alt="Authenticated" />
    <figcaption>Authenticated user</figcaption>
  </figure>
  <figure>
    <img src="/assets/images/posts/ios-18-share-extension/share-sheet-unauthenticated.png" alt="Unauthenticated" />
    <figcaption>Unauthenticated: prompts to open app</figcaption>
  </figure>
  <figure>
    <img src="/assets/images/posts/ios-18-share-extension/app-pending-share.png" alt="Pending share" />
    <figcaption>App completes the pending share</figcaption>
  </figure>
</div>

<p>This approach gives the best UX for most users (those already logged in), but doesn’t break for new users.</p>

<p>Going back to the pipe analogy: <code class="language-plaintext highlighter-rouge">grep</code> doesn’t ask you to configure anything — it just works with what it has. A Share Extension should aim for the same, handling auth silently whenever possible.</p>]]></content><author><name>Andrey Samsonov</name></author><category term="ios" /><category term="swift" /><category term="share-extension" /><summary type="html"><![CDATA[Share Extensions can no longer open your app directly. A comparison of four approaches to handle authentication in iOS 18.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://msg.samsonov.io/assets/images/og/posts/ios-18-share-extension-for-unauthenticated-user.png" /><media:content medium="image" url="https://msg.samsonov.io/assets/images/og/posts/ios-18-share-extension-for-unauthenticated-user.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Dictionary-Quality Word Pronunciation Without Dictionary APIs</title><link href="https://msg.samsonov.io/2025-12-19-dictionary-quality-word-pronunciation/" rel="alternate" type="text/html" title="Dictionary-Quality Word Pronunciation Without Dictionary APIs" /><published>2025-12-19T00:00:00+00:00</published><updated>2025-12-19T00:00:00+00:00</updated><id>https://msg.samsonov.io/dictionary-quality-word-pronunciation</id><content type="html" xml:base="https://msg.samsonov.io/2025-12-19-dictionary-quality-word-pronunciation/"><![CDATA[<p>When I started building word pronunciation features for my language learning app, the obvious first idea was to pull audio files from “reputable” dictionaries — Oxford, Cambridge, Collins, etc.</p>

<p>But I quickly ran into limitations:</p>

<ul>
  <li><strong>Access.</strong> Getting an API key can be a hassle even for testing.</li>
  <li><strong>Rate limits.</strong> Restrictions on requests and pricing.</li>
  <li><strong>Caching.</strong> Storing audio locally is often prohibited — which is a dealbreaker for a learning app.</li>
  <li><strong>Vendor lock-in.</strong> Once you commit to a specific dictionary (its article structure, response formats, definition markup, pronunciation quirks), adding other languages becomes painful. Each language has its own dictionaries and formats, and stitching them together cleanly gets messy fast.</li>
</ul>

<p>So I ended up with a solution I’d been avoiding: LLM-based Text-to-Speech. I’d tried similar things a year or so ago — back then, the quality wasn’t good enough for “dictionary-grade” pronunciation. But there’s been noticeable progress in both the models and available APIs since then: with the right setup, the results are now quite practical.</p>

<h3 id="system-instructions-a-separate-channel-for-style-control">System Instructions: A Separate Channel for Style Control</h3>

<p>APIs let you pass system instructions separately from the text. This is useful because you can treat them as a “contract” for pronunciation style:</p>

<ul>
  <li>accent variant: British / American;</li>
  <li>delivery: clear, neutral, steady pace — closer to a dictionary narrator than a voice actor.</li>
</ul>

<p>That’s enough to get consistent “educational” audio instead of “theatrical line readings.”</p>

<h3 id="heteronyms-the-model-will-be-wrong-sometimes-but-you-need-never">Heteronyms: The Model Will Be Wrong “Sometimes,” but You Need “Never”</h3>

<p>Then came a less obvious problem — heteronyms: words spelled the same but pronounced differently depending on context (part of speech, meaning, tense).</p>

<p>The classic example is <em>read</em>:</p>

<ul>
  <li>present: /riːd/</li>
  <li>past: /rɛd/</li>
</ul>

<p>You can try to tweak the system instructions so the model always picks the right variant from context — but reliability will still be hit or miss. And in a learning app, mispronunciation is a bad experience: the user will memorize the wrong pattern.</p>

<h3 id="ipa-substitution-explicit-phonetics-instead-of-guessing">IPA Substitution: Explicit Phonetics Instead of Guessing</h3>

<p>The most practical trick turned out to be simple: replace the ambiguous word with IPA (International Phonetic Alphabet).</p>

<p>Example:</p>

<ul>
  <li>We read (present) → We /riːd/</li>
  <li>We read (past) → We /rɛd/</li>
</ul>

<p>You turn ambiguous spelling into unambiguous phonetics, and TTS no longer “guesses” — it just pronounces exactly what you’ve specified.</p>

<h3 id="ipa-on-demand">IPA on Demand</h3>

<p>You don’t want to generate IPA for all text all the time: it’s more expensive and more complex. So here’s the approach:</p>

<ol>
  <li>Check the text: does it contain any words from a small list of heteronyms (a candidate dictionary)?</li>
  <li>If not — send it straight to TTS.</li>
  <li>If yes — make an additional request to an LLM tuned for the short task “pick the correct pronunciation”:
    <ul>
      <li>word + sentence/context;</li>
      <li>(optionally) structural hints from your context, e.g.: <code class="language-plaintext highlighter-rouge">{pos: "verb", tense: "past"}</code>;</li>
      <li>output: IPA or a choice between variants.</li>
    </ul>
  </li>
  <li>Replace the word in the text with <code class="language-plaintext highlighter-rouge">/ipa/</code>.</li>
  <li>Send the “phonetic-ready” text to TTS.</li>
</ol>

<p>The key idea: the extra request only fires when there’s a real risk of mispronunciation.</p>

<h3 id="the-final-pipeline">The Final Pipeline</h3>

<p><strong>Before:</strong></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>text → TTS LLM → audio
</code></pre></div></div>

<p><strong>After:</strong></p>
<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>text + context + user-prefs → heteronyms → IPA → system instructions → cache → TTS LLM → audio
</code></pre></div></div>

<p>More steps, but they give you full control over exactly what makes dictionary APIs seem “more reliable”: style, pronunciation, and caching.</p>

<h3 id="on-quality">On Quality</h3>

<p>The best part: quality turned out better than I expected.</p>

<p>I tested the results on Russian — not exactly a top-tier target language for TTS products. There’s an accent, but it’s barely noticeable: far less than any non-native speaker, and even less than many bilinguals. For second-language learning, that’s more than good enough.</p>]]></content><author><name>Andrey Samsonov</name></author><summary type="html"><![CDATA[How to build reliable word pronunciation for language learning apps using LLM-based TTS instead of dictionary APIs. Covers handling heteronyms with IPA substitution and building a practical pronunciation pipeline.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://msg.samsonov.io/assets/images/og/posts/dictionary-quality-word-pronunciation.png" /><media:content medium="image" url="https://msg.samsonov.io/assets/images/og/posts/dictionary-quality-word-pronunciation.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Diving into Fizzy’s Routes: Rails’ resolve and direct</title><link href="https://msg.samsonov.io/2025-12-15-fizzy-direct-resolve/" rel="alternate" type="text/html" title="Diving into Fizzy’s Routes: Rails’ resolve and direct" /><published>2025-12-15T00:00:00+00:00</published><updated>2025-12-15T00:00:00+00:00</updated><id>https://msg.samsonov.io/fizzy-direct-resolve</id><content type="html" xml:base="https://msg.samsonov.io/2025-12-15-fizzy-direct-resolve/"><![CDATA[<p>37signals <a href="https://x.com/dhh/status/1995895084789772629">open-sourced</a> their latest product last week. I cloned it and started where I always start when exploring a new Rails app: config/routes.rb.</p>

<p>I think <code class="language-plaintext highlighter-rouge">config/routes.rb</code> is the best place to crack open any Rails codebase. It’s the table of contents — you instantly see what resources exist, how they’re nested, and the overall shape of the domain. In Fizzy’s case: Accounts, Boards, Cards, Columns, Comments, Webhooks, Notifications.</p>

<p>But then I spotted something I’d honestly never used in production:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">direct</span> <span class="ss">:published_board</span> <span class="k">do</span> <span class="o">|</span><span class="n">board</span><span class="p">,</span> <span class="n">options</span><span class="o">|</span>
  <span class="n">route_for</span> <span class="ss">:public_board</span><span class="p">,</span> <span class="n">board</span><span class="p">.</span><span class="nf">publication</span><span class="p">.</span><span class="nf">key</span>
<span class="k">end</span>

<span class="n">direct</span> <span class="ss">:published_card</span> <span class="k">do</span> <span class="o">|</span><span class="n">card</span><span class="p">,</span> <span class="n">options</span><span class="o">|</span>
  <span class="n">route_for</span> <span class="ss">:public_board_card</span><span class="p">,</span> <span class="n">card</span><span class="p">.</span><span class="nf">board</span><span class="p">.</span><span class="nf">publication</span><span class="p">.</span><span class="nf">key</span><span class="p">,</span> <span class="n">card</span>
<span class="k">end</span>

<span class="n">resolve</span> <span class="s2">"Comment"</span> <span class="k">do</span> <span class="o">|</span><span class="n">comment</span><span class="p">,</span> <span class="n">options</span><span class="o">|</span>
  <span class="n">options</span><span class="p">[</span><span class="ss">:anchor</span><span class="p">]</span> <span class="o">=</span> <span class="no">ActionView</span><span class="o">::</span><span class="no">RecordIdentifier</span><span class="p">.</span><span class="nf">dom_id</span><span class="p">(</span><span class="n">comment</span><span class="p">)</span>
  <span class="n">route_for</span> <span class="ss">:card</span><span class="p">,</span> <span class="n">comment</span><span class="p">.</span><span class="nf">card</span><span class="p">,</span> <span class="n">options</span>
<span class="k">end</span>

<span class="n">resolve</span> <span class="s2">"Mention"</span> <span class="k">do</span> <span class="o">|</span><span class="n">mention</span><span class="p">,</span> <span class="n">options</span><span class="o">|</span>
  <span class="n">polymorphic_url</span><span class="p">(</span><span class="n">mention</span><span class="p">.</span><span class="nf">source</span><span class="p">,</span> <span class="n">options</span><span class="p">)</span>
<span class="k">end</span>

<span class="n">resolve</span> <span class="s2">"Notification"</span> <span class="k">do</span> <span class="o">|</span><span class="n">notification</span><span class="p">,</span> <span class="n">options</span><span class="o">|</span>
  <span class="n">polymorphic_url</span><span class="p">(</span><span class="n">notification</span><span class="p">.</span><span class="nf">notifiable_target</span><span class="p">,</span> <span class="n">options</span><span class="p">)</span>
<span class="k">end</span>

<span class="n">resolve</span> <span class="s2">"Event"</span> <span class="k">do</span> <span class="o">|</span><span class="n">event</span><span class="p">,</span> <span class="n">options</span><span class="o">|</span>
  <span class="n">polymorphic_url</span><span class="p">(</span><span class="n">event</span><span class="p">.</span><span class="nf">eventable</span><span class="p">,</span> <span class="n">options</span><span class="p">)</span>
<span class="k">end</span>

<span class="n">resolve</span> <span class="s2">"Webhook"</span> <span class="k">do</span> <span class="o">|</span><span class="n">webhook</span><span class="p">,</span> <span class="n">options</span><span class="o">|</span>
  <span class="n">route_for</span> <span class="ss">:board_webhook</span><span class="p">,</span> <span class="n">webhook</span><span class="p">.</span><span class="nf">board</span><span class="p">,</span> <span class="n">webhook</span><span class="p">,</span> <span class="n">options</span>
<span class="k">end</span>
</code></pre></div></div>

<p>What are <code class="language-plaintext highlighter-rouge">direct</code> and <code class="language-plaintext highlighter-rouge">resolve</code>?</p>

<h3 id="custom-url-helpers-with-direct">Custom URL Helpers with <code class="language-plaintext highlighter-rouge">direct</code></h3>

<p><code class="language-plaintext highlighter-rouge">direct</code> creates custom named URL helpers. Fizzy boards can be published publicly with a shareable link — but the public URL uses <code class="language-plaintext highlighter-rouge">publication.key</code> instead of the board’s ID. Rather than building this URL manually every time, direct gives you published_board_url(board) and published_card_url(card).</p>

<div class="language-erb highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">&lt;%# app/views/public/cards/show.html.erb %&gt;</span>
<span class="cp">&lt;%=</span> <span class="n">tag</span><span class="p">.</span><span class="nf">meta</span> <span class="ss">property: </span><span class="s2">"og:url"</span><span class="p">,</span> <span class="ss">content: </span><span class="n">published_card_url</span><span class="p">(</span><span class="vi">@card</span><span class="p">)</span> <span class="cp">%&gt;</span>
</code></pre></div></div>

<p>You could achieve the same with a helper method. Here’s the comparison:</p>

<p><strong>Using <code class="language-plaintext highlighter-rouge">direct</code> in routes.rb:</strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">direct</span> <span class="ss">:published_board</span> <span class="k">do</span> <span class="o">|</span><span class="n">board</span><span class="p">,</span> <span class="n">options</span><span class="o">|</span>
  <span class="n">route_for</span> <span class="ss">:public_board</span><span class="p">,</span> <span class="n">board</span><span class="p">.</span><span class="nf">publication</span><span class="p">.</span><span class="nf">key</span><span class="p">,</span> <span class="n">options</span>
<span class="k">end</span>
</code></pre></div></div>

<p><strong>Traditional helper in app/helpers/:</strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/helpers/boards_helper.rb (hypothetical alternative)</span>
<span class="k">module</span> <span class="nn">BoardsHelper</span>
  <span class="k">def</span> <span class="nf">published_board_path</span><span class="p">(</span><span class="n">board</span><span class="p">,</span> <span class="n">options</span> <span class="o">=</span> <span class="p">{})</span>
    <span class="n">public_board_path</span><span class="p">(</span><span class="n">board</span><span class="p">.</span><span class="nf">publication</span><span class="p">.</span><span class="nf">key</span><span class="p">,</span> <span class="n">options</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="k">def</span> <span class="nf">published_board_url</span><span class="p">(</span><span class="n">board</span><span class="p">,</span> <span class="n">options</span> <span class="o">=</span> <span class="p">{})</span>
    <span class="n">public_board_url</span><span class="p">(</span><span class="n">board</span><span class="p">.</span><span class="nf">publication</span><span class="p">.</span><span class="nf">key</span><span class="p">,</span> <span class="n">options</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">direct</code> version defines both <code class="language-plaintext highlighter-rouge">_path</code> and <code class="language-plaintext highlighter-rouge">_url</code> automatically from a single block (though for public shareable links, you’d only ever need <code class="language-plaintext highlighter-rouge">_url</code>). Honestly, the helper version looks simpler and more straightforward. The advantage of <code class="language-plaintext highlighter-rouge">direct</code> is locality: all URL-generation logic lives in <code class="language-plaintext highlighter-rouge">routes.rb</code>.</p>

<p>Another bonus: <code class="language-plaintext highlighter-rouge">direct</code> helpers are automatically available in <code class="language-plaintext highlighter-rouge">Rails.application.routes.url_helpers</code>, so you can use them in models, background jobs, or anywhere outside controllers and views:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">Rails</span><span class="p">.</span><span class="nf">application</span><span class="p">.</span><span class="nf">routes</span><span class="p">.</span><span class="nf">url_helpers</span><span class="p">.</span><span class="nf">published_board_url</span><span class="p">(</span><span class="n">board</span><span class="p">)</span>
</code></pre></div></div>

<p>One thing that confused me at first: <code class="language-plaintext highlighter-rouge">direct</code> and <code class="language-plaintext highlighter-rouge">resolve</code> routes don’t appear in <code class="language-plaintext highlighter-rouge">rails routes</code> output. This is by design — they’re URL <em>generation</em> helpers, not HTTP endpoints. A <code class="language-plaintext highlighter-rouge">direct</code> can even point to an external URL:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">direct</span> <span class="ss">:homepage</span> <span class="k">do</span>
  <span class="s2">"https://rubyonrails.org"</span>  <span class="c1"># Not a route in your app!</span>
<span class="k">end</span>
</code></pre></div></div>

<h3 id="customizing-polymorphic-urls-with-resolve">Customizing Polymorphic URLs with <code class="language-plaintext highlighter-rouge">resolve</code></h3>

<p>The <a href="https://api.rubyonrails.org/classes/ActionDispatch/Routing/Mapper/CustomUrls.html#method-i-resolve">Rails docs</a> dedicate about two sentences to <code class="language-plaintext highlighter-rouge">resolve</code>: “Define custom polymorphic mappings of models to URLs” and a brief example with a Basket model.</p>

<p>You know how <code class="language-plaintext highlighter-rouge">link_to @post</code> generates <code class="language-plaintext highlighter-rouge">/posts/123</code>? That’s <code class="language-plaintext highlighter-rouge">polymorphic_url</code> under the hood — Rails introspects the model and finds the matching route.</p>

<p>But what happens when a model doesn’t have its own route? Comments in Fizzy don’t live at /comments/:id — they’re displayed on their parent Card. Events are polymorphic wrappers around other actions. Notifications point to something else the user should see.</p>

<p>Without <code class="language-plaintext highlighter-rouge">resolve</code>, you’d write helpers like this everywhere:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># app/helpers/comments_helper.rb</span>
<span class="k">def</span> <span class="nf">comment_url</span><span class="p">(</span><span class="n">comment</span><span class="p">)</span>
  <span class="n">card_url</span><span class="p">(</span><span class="n">comment</span><span class="p">.</span><span class="nf">card</span><span class="p">,</span> <span class="ss">anchor: </span><span class="n">dom_id</span><span class="p">(</span><span class="n">comment</span><span class="p">))</span>
<span class="k">end</span>
</code></pre></div></div>

<p>And then remember to call <code class="language-plaintext highlighter-rouge">comment_url(comment)</code> instead of <code class="language-plaintext highlighter-rouge">url_for(comment)</code>. The <code class="language-plaintext highlighter-rouge">resolve</code> DSL fixes this — it teaches Rails how to generate URLs for specific model classes, keeping route logic in routes.rb where you’d naturally look for it.</p>

<p>The block receives:</p>
<ol>
  <li>The model instance</li>
  <li>An options hash (anchors, format, etc.)</li>
</ol>

<p>It returns whatever route_for or polymorphic_url can handle.</p>

<p>Both live in the same <a href="https://api.rubyonrails.org/classes/ActionDispatch/Routing/Mapper/CustomUrls.html">CustomUrls module</a>, both take a block that returns something <code class="language-plaintext highlighter-rouge">url_for</code> can handle.</p>

<div class="collapsible">
<div class="collapsible-header">
  <p class="collapsible-title">Under the Hood: How resolve Actually Works</p>
  <p class="collapsible-subtitle">Step-by-step source code walkthrough</p>
</div>
<div class="collapsible-content">

    <p>The docs are sparse, so let’s read the source. When you write:</p>

    <div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">resolve</span> <span class="s2">"Comment"</span> <span class="k">do</span> <span class="o">|</span><span class="n">comment</span><span class="p">,</span> <span class="n">options</span><span class="o">|</span>
  <span class="n">route_for</span> <span class="ss">:card</span><span class="p">,</span> <span class="n">comment</span><span class="p">.</span><span class="nf">card</span><span class="p">,</span> <span class="n">options</span>
<span class="k">end</span>
</code></pre></div>    </div>

    <p>Here’s what Rails does at boot time.</p>

    <p><strong>Step 1: The DSL method</strong> (<a href="https://github.com/rails/rails/blob/main/actionpack/lib/action_dispatch/routing/mapper.rb#L2426">mapper.rb</a>)</p>

    <div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">resolve</span><span class="p">(</span><span class="o">*</span><span class="n">args</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">block</span><span class="p">)</span>
  <span class="k">unless</span> <span class="vi">@scope</span><span class="p">.</span><span class="nf">root?</span>
    <span class="k">raise</span> <span class="no">RuntimeError</span><span class="p">,</span> <span class="s2">"The resolve method can't be used inside a routes scope block"</span>
  <span class="k">end</span>

  <span class="n">options</span> <span class="o">=</span> <span class="n">args</span><span class="p">.</span><span class="nf">extract_options!</span>
  <span class="n">args</span> <span class="o">=</span> <span class="n">args</span><span class="p">.</span><span class="nf">flatten</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>

  <span class="n">args</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">klass</span><span class="o">|</span>
    <span class="vi">@set</span><span class="p">.</span><span class="nf">add_polymorphic_mapping</span><span class="p">(</span><span class="n">klass</span><span class="p">,</span> <span class="n">options</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">block</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div>    </div>

    <p>It validates you’re at the root level (not inside a <code class="language-plaintext highlighter-rouge">namespace</code> or <code class="language-plaintext highlighter-rouge">scope</code>), then registers your block for each class name.</p>

    <p><strong>Step 2: Store the mapping</strong> (<a href="https://github.com/rails/rails/blob/main/actionpack/lib/action_dispatch/routing/route_set.rb#L674">route_set.rb</a>)</p>

    <div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">add_polymorphic_mapping</span><span class="p">(</span><span class="n">klass</span><span class="p">,</span> <span class="n">options</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">block</span><span class="p">)</span>
  <span class="vi">@polymorphic_mappings</span><span class="p">[</span><span class="n">klass</span><span class="p">]</span> <span class="o">=</span> <span class="no">CustomUrlHelper</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="n">klass</span><span class="p">,</span> <span class="n">options</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">block</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div>    </div>

    <p>Your block gets wrapped in a <code class="language-plaintext highlighter-rouge">CustomUrlHelper</code> and stored in a hash:
<code class="language-plaintext highlighter-rouge">{ "Comment"=&gt; [helper instance], ... }</code>.</p>

    <p><strong>Step 3: The lookup</strong> (<a href="https://github.com/rails/rails/blob/main/actionpack/lib/action_dispatch/routing/polymorphic_routes.rb">polymorphic_routes.rb</a>)</p>

    <p>When you call <code class="language-plaintext highlighter-rouge">link_to(@comment)</code> or <code class="language-plaintext highlighter-rouge">url_for(@comment)</code>, Rails eventually hits <code class="language-plaintext highlighter-rouge">polymorphic_url</code>:</p>

    <div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">polymorphic_url</span><span class="p">(</span><span class="n">record_or_hash_or_array</span><span class="p">,</span> <span class="n">options</span> <span class="o">=</span> <span class="p">{})</span>
  <span class="k">if</span> <span class="n">mapping</span> <span class="o">=</span> <span class="n">polymorphic_mapping</span><span class="p">(</span><span class="n">record_or_hash_or_array</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">mapping</span><span class="p">.</span><span class="nf">call</span><span class="p">(</span><span class="nb">self</span><span class="p">,</span> <span class="p">[</span><span class="n">record_or_hash_or_array</span><span class="p">,</span> <span class="n">options</span><span class="p">],</span> <span class="kp">false</span><span class="p">)</span>
  <span class="k">end</span>
  <span class="c1"># ... default polymorphic resolution</span>
<span class="k">end</span>

<span class="k">def</span> <span class="nf">polymorphic_mapping</span><span class="p">(</span><span class="n">record</span><span class="p">)</span>
  <span class="n">_routes</span><span class="p">.</span><span class="nf">polymorphic_mappings</span><span class="p">[</span><span class="n">record</span><span class="p">.</span><span class="nf">to_model</span><span class="p">.</span><span class="nf">model_name</span><span class="p">.</span><span class="nf">name</span><span class="p">]</span>
<span class="k">end</span>
</code></pre></div>    </div>

    <p>It checks the hash using the model’s class name. If found, it calls your block instead of the default route resolution.</p>

    <p><strong>Step 4: Execute the block</strong> (<a href="https://github.com/rails/rails/blob/main/actionpack/lib/action_dispatch/routing/route_set.rb#L165">route_set.rb</a>)</p>

    <div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">class</span> <span class="nc">CustomUrlHelper</span>
  <span class="k">def</span> <span class="nf">call</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">args</span><span class="p">,</span> <span class="n">only_path</span> <span class="o">=</span> <span class="kp">false</span><span class="p">)</span>
    <span class="n">options</span> <span class="o">=</span> <span class="n">args</span><span class="p">.</span><span class="nf">extract_options!</span>
    <span class="n">url</span> <span class="o">=</span> <span class="n">t</span><span class="p">.</span><span class="nf">full_url_for</span><span class="p">(</span><span class="n">eval_block</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">args</span><span class="p">,</span> <span class="n">options</span><span class="p">))</span>
    <span class="n">only_path</span> <span class="p">?</span> <span class="s2">"/"</span> <span class="o">+</span> <span class="n">url</span><span class="p">.</span><span class="nf">partition</span><span class="p">(</span><span class="sr">%r{(?&lt;!/)/(?!/)}</span><span class="p">).</span><span class="nf">last</span> <span class="p">:</span> <span class="n">url</span>
  <span class="k">end</span>

  <span class="kp">private</span>
    <span class="k">def</span> <span class="nf">eval_block</span><span class="p">(</span><span class="n">t</span><span class="p">,</span> <span class="n">args</span><span class="p">,</span> <span class="n">options</span><span class="p">)</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">instance_exec</span><span class="p">(</span><span class="o">*</span><span class="n">args</span><span class="p">,</span> <span class="n">merge_defaults</span><span class="p">(</span><span class="n">options</span><span class="p">),</span> <span class="o">&amp;</span><span class="n">block</span><span class="p">)</span>
    <span class="k">end</span>
<span class="k">end</span>
</code></pre></div>    </div>

    <p>The helper runs your block via <code class="language-plaintext highlighter-rouge">instance_exec</code>, passing the model and options. Whatever you return gets passed to <code class="language-plaintext highlighter-rouge">full_url_for</code> to generate the final URL string.</p>

    <p><strong>The complete flow:</strong></p>

    <div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>link_to(@comment)
  → url_for(@comment)
    → polymorphic_url(@comment)
      → polymorphic_mapping(@comment)
        → @polymorphic_mappings["Comment"]  # Your CustomUrlHelper
      → helper.call(self, [@comment, {}], false)
        → instance_exec(@comment, {}, &amp;block)
          → route_for(:card, comment.card, anchor: "comment_123")
        → full_url_for([:card, card, {anchor: "comment_123"}])
          → "/cards/abc#comment_123"
</code></pre></div>    </div>

    <p>Result: <code class="language-plaintext highlighter-rouge">link_to(@comment)</code> → <code class="language-plaintext highlighter-rouge">"/cards/abc#comment_123"</code></p>
  </div>
</div>

<h3 id="fizzys-patterns">Fizzy’s Patterns</h3>

<p><strong>Notification → Whatever It’s About</strong></p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># config/routes.rb</span>
<span class="n">resolve</span> <span class="s2">"Notification"</span> <span class="k">do</span> <span class="o">|</span><span class="n">notification</span><span class="p">,</span> <span class="n">options</span><span class="o">|</span>
  <span class="n">polymorphic_url</span><span class="p">(</span><span class="n">notification</span><span class="p">.</span><span class="nf">notifiable_target</span><span class="p">,</span> <span class="n">options</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Notifications wrap Events or Mentions. Rather than linking to a “notification show page” (boring), this links directly to the thing you’re being notified about. The <code class="language-plaintext highlighter-rouge">notifiable_target</code> method is delegated to source:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># notification.rb</span>
<span class="n">delegate</span> <span class="ss">:notifiable_target</span><span class="p">,</span> <span class="ss">to: :source</span>

<span class="c1"># event.rb</span>
<span class="k">def</span> <span class="nf">notifiable_target</span>
  <span class="n">eventable</span>  <span class="c1"># Card, Comment, etc.</span>
<span class="k">end</span>

<span class="c1"># mention.rb</span>
<span class="k">def</span> <span class="nf">notifiable_target</span>
  <span class="n">source</span>  <span class="c1"># The Card or Comment containing the @mention</span>
<span class="k">end</span>

<span class="c1"># user.rb</span>
<span class="k">def</span> <span class="nf">notifiable_target</span>
  <span class="nb">self</span>  <span class="c1"># "New user joined" → links to their profile</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Now <code class="language-plaintext highlighter-rouge">link_to(@notification)</code> in the notification tray just works:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># notifications_helper.rb</span>
<span class="n">link_to</span><span class="p">(</span><span class="n">notification</span><span class="p">,</span> <span class="ss">class: </span><span class="s2">"card card--notification"</span><span class="p">,</span> <span class="o">...</span><span class="p">)</span>
</code></pre></div></div>

<h3 id="why-this-matters">Why This Matters</h3>

<p>Fizzy has many “indirect” models — objects users interact with through their parents:</p>

<ul>
  <li>Comments live on Cards</li>
  <li>Events describe actions on Cards/Comments</li>
  <li>Notifications wrap Events/Mentions</li>
  <li>Mentions point to Cards/Comments</li>
</ul>

<p>The <code class="language-plaintext highlighter-rouge">direct</code> and <code class="language-plaintext highlighter-rouge">resolve</code> blocks centralize URL generation logic in routes.rb rather than burying it in helpers. You write <code class="language-plaintext highlighter-rouge">link_to(@notification)</code> and trust the router to figure it out. When someone asks “how do URLs work in this app?” — there’s exactly one file to check.</p>

<p>It’s one of those Rails features that’s been there since Rails 5, hiding in plain sight. I’ve walked past it a hundred times in the docs. Seeing it used by the Rails creators themselves? Now I get it.</p>

<p>The <a href="https://api.rubyonrails.org/classes/ActionDispatch/Routing/Mapper/CustomUrls.html#method-i-resolve">official docs</a> are sparse, but Fizzy’s <a href="https://github.com/basecamp/fizzy/blob/main/config/routes.rb">config/routes.rb</a> is a good example of real-world use cases.</p>]]></content><author><name>Andrey Samsonov</name></author><summary type="html"><![CDATA[Exploring two underused Rails routing features — direct and resolve — through 37signals' newly open-sourced Fizzy codebase. Learn how to create custom URL helpers and teach Rails to generate polymorphic URLs for models without their own routes.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://msg.samsonov.io/assets/images/og/posts/fizzy-direct-resolve.png" /><media:content medium="image" url="https://msg.samsonov.io/assets/images/og/posts/fizzy-direct-resolve.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>