<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://sinaptia.dev/feed.xml" rel="self" type="application/atom+xml" /><link href="https://sinaptia.dev/" rel="alternate" type="text/html" /><updated>2026-07-09T03:10:39+00:00</updated><id>https://sinaptia.dev/feed.xml</id><title type="html">SINAPTIA</title><author><name>SINAPTIA</name></author><entry><title type="html">Building an AI-powered commercial intelligence dashboard</title><link href="https://sinaptia.dev/posts/building-an-ai-powered-commercial-intelligence-dashboard/" rel="alternate" type="text/html" title="Building an AI-powered commercial intelligence dashboard" /><published>2026-07-08T00:00:00+00:00</published><updated>2026-07-08T00:00:00+00:00</updated><id>https://sinaptia.dev/posts/building-an-ai-powered-commercial-intelligence-dashboard</id><content type="html" xml:base="https://sinaptia.dev/posts/building-an-ai-powered-commercial-intelligence-dashboard/"><![CDATA[<p>One of our all-time clients owns a multi-brand marketplace. Earlier this year, he had an idea to revolutionize his platform by observing how his users behave and turning that information into actionable insights for his customers (the brands). The core idea was to track user events, turn each user’s events into a single, computed profile that identifies them (internally called a fingerprint), and use this information to build a brand brief, including user segments and actionable insights.</p>

<p>For this project, our client wanted to use AI throughout the development process: he built the plan with AI assistance, designed the UI with AI, and planned to use AI for most of the features he wanted to build. This presented many challenges: creating a plan with the AI, without having the project in its context, allowed incorrect assumptions to shape the plan, ultimately causing it to drift away from the original business requirements. Our job was to validate and shape the plan against the actual state of the project, and assess whether AI was fit for every feature where it was planned to be used.</p>

<p>The project consisted roughly of 5 phases:</p>

<ul>
  <li>tracking events</li>
  <li>updating the fingerprints</li>
  <li>synthetic data generation (for demos)</li>
  <li>updating the brand briefings</li>
  <li>building the briefing dashboard</li>
</ul>

<p>Let’s dissect each one of them.</p>

<h2 id="tracking-events">Tracking events</h2>

<p>The platform was already tracking some events, but they weren’t exactly in the shape we needed for the project. In the past, some events were tracked as part of a proof of concept, and they were left “just in case we need them”. The first task, then, was to assess which events we needed, based on the shape of the fingerprint and the information we needed to aggregate. Once this was done, it was just a matter of tracking them and testing.</p>

<h2 id="updating-the-fingerprints">Updating the fingerprints</h2>

<p>As mentioned above, fingerprints are a computed profile for a user, derived from their behavior and affinities. It is used to build the briefings and estimate their value within the platform. Initially, our client wanted to use AI to compute these fingerprints, but we assessed that a good old deterministic algorithm was a better choice. There are many things to consider:</p>

<ol>
  <li>computing thousand of users would be slow and potentially prohibitely expensive.</li>
  <li>the input size depends on how many events were tracked during the period we’re observing.</li>
  <li>LLMs are not always good at counting, and the fingerprint is heavily designed around counts.</li>
</ol>

<p>Our client came up with the fingerprint specification (how to compute each field), created with the help of AI. With the event catalog at hand, plus the information he wanted to compute, the AI came up with a pretty solid specification that a priori made a lot of sense. In the next section, we’ll see it wasn’t exactly like that.</p>

<h2 id="synthetic-data-generation">Synthetic data generation</h2>

<p>The client also needed a way to show the project to potential clients and investors. In order to show a realistic picture of how the system would work under a realistic amount of data, our client designed several user archetypes (a high-level behavioral type of user) and variants (a sub-group inside an archetype). Then, these synthetic users would be distributed across different countries and would show different activity levels (stronger engagement signals with the platform).</p>

<p>They looked really good on paper, but having worked on the fingerprints, we could sense that some of the archetypes weren’t going to be feasible. After a thorough investigation, we understood what the problem with the fingerprint was: a fingerprint is created from several signals (user behavior), the AI treated them as independent, but in reality, a single event could affect multiple signals simultaneously.</p>

<p>Since having many archetypes and variants was key to showing meaningful user segments and actionable insights, we decided to change the way the fingerprint is computed so one event can’t affect many signals.</p>

<p>With this key change, synthetic data generation was possible. One of the hardest parts was over; now onto the most fun part.</p>

<h2 id="updating-the-brand-briefings">Updating the brand briefings</h2>

<p>AI was definitely right for this part of the project. The whole point of the briefings was to identify up to 10 custom user segments based on the fingerprints associated in one way or another with the brand, considering behavior, affinities, geographic localization, etc. For example, a user segment may be “Users who engaged with this brand but haven’t converted”, “Emerging fans of this brand in South America”, etc. This kind of creativity can’t be done deterministically. This is the kind of problem a human can solve by looking at an analytics tool and running different reports manually. Creatively, instinctively. If we went the deterministic way, we’d spend a lot of time building a segmentation engine that would never look right.</p>

<p>With 10k synthetic users, we had plenty of information to show the brands. We knew we were going to iterate on this step, as coming up with the perfect prompt is hard (at best, you can come up with a good-enough prompt).</p>

<h3 id="getting-started">Getting started</h3>

<p>We needed to begin with something, so we asked GPT:</p>

<blockquote>
  <p>Our platform does this, a fingerprint is this, and these are all of the fingerprints associated with this brand, give me up to 10 user segments and up to 3 actionable insights for each one of the user segments.</p>
</blockquote>

<p>Done. Easy, right?</p>

<p>The problem with this prompt is that most brands had no fewer than 5k synthetic users associated with them. This happens because associations are created on behavior (visited a brand, seen a product) and affinities (liked a brand, liked a product). And attaching 5k fingerprints would hit the ITM (Input Tokens per Minute) limit very soon. Testing the prompt in the chat agent is one thing, and testing it via the API is a different thing. The chat agent doesn’t even complain about the 5k-line CSV and responds as if it were a simple question, while the API has more usage restrictions and limits.</p>

<h3 id="from-prompt-to-agent">From prompt to agent</h3>

<p>The prompt above is what we call a “one-shot”: we ask a question and wait until we get a response. That’s all.</p>

<p>The actual prompt was more complex. Instead of asking for segments directly, we asked the model to behave like a commercial analyst: research the brand’s market, compare those external signals with the brand’s internal audience summary, package the matches into cohorts, and write the commercial case for each one. From there, it had to produce tactics, call out the most urgent opportunity, write an executive summary, and return the whole thing in the structured shape the dashboard expected.</p>

<p>We could obviously one-shot this flow by doing the web search and computing the brand summary beforehand and sending that information as part of the prompt, but <a href="https://rubyllm.com">RubyLLM</a> makes it so much easier, trivial: just create a <code class="language-plaintext highlighter-rouge">WebSearch</code> and a <code class="language-plaintext highlighter-rouge">FetchFingerprintsSummary</code> tool, and allow the agent to do a multi-turn response.</p>

<h2 id="building-the-briefing-dashboard">Building the briefing dashboard</h2>

<p>The briefing dashboard, as mentioned before, was designed with AI. The design is acceptable, and the time it took to implement it was just a fraction of what it would’ve been without AI. With the design exported, it was just a matter of instructing the coding agent to implement it and just refine some rough edges. Much more customized and professional than a UI built out of components from a stock UI kit like we used to do before. This was a huge win.</p>

<h2 id="conclusion">Conclusion</h2>

<p>This was one of the most fun AI projects we’ve worked on this year, mostly because AI wasn’t confined to one corner of the work. It shaped the client’s planning, the fingerprint spec, the synthetic data, the briefing design, and eventually the briefing generation itself.</p>

<p>That also meant we couldn’t treat “use AI” as the default answer. Part of the job was deciding where it made sense and where it didn’t. Fingerprints needed deterministic code. Synthetic data needed careful modeling. Briefings, on the other hand, needed exactly the kind of synthesis AI is good at: reading behavior, finding patterns, and turning them into commercial narratives.</p>

<p>On our side, the agentic development workflow made the project move much faster than it would have otherwise. Without AI, the briefing feature probably wouldn’t have been viable in this shape. Without coding agents, the rest of the project would have taken much longer to build, test, and iterate.</p>

<p>That was the real lesson for us: AI worked best when we were selective about it. Not as a blanket solution, but as another tool in the system: sometimes the runtime, sometimes the assistant, and sometimes the thing we deliberately chose not to use.</p>]]></content><author><name>Patricio Mac Adden</name></author><category term="Ruby on Rails" /><category term="AI" /><summary type="html"><![CDATA[We used AI ubiquitously in this project, from specs through implementation and in production. Learn how we did it.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sinaptia.dev/assets/images/logo-black.png" /><media:content medium="image" url="https://sinaptia.dev/assets/images/logo-black.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Ruby Argentina May meetup</title><link href="https://sinaptia.dev/posts/ruby-argentina-may-2026-meetup/" rel="alternate" type="text/html" title="Ruby Argentina May meetup" /><published>2026-05-19T00:00:00+00:00</published><updated>2026-05-19T00:00:00+00:00</updated><id>https://sinaptia.dev/posts/ruby-argentina-may-2026-meetup</id><content type="html" xml:base="https://sinaptia.dev/posts/ruby-argentina-may-2026-meetup/"><![CDATA[<p>We gathered again at Working &amp; Co in Buenos Aires for another <a href="https://ruby.com.ar/">Ruby Argentina</a> meetup. As always, the event was streamed live on <a href="https://www.youtube.com/watch?v=MKF4ZstPauw">RubySur’s YouTube channel</a>, and our sponsors (<strong>SINAPTIA</strong>, <strong>Rootstrap</strong>, <strong>OmbuLabs</strong>, and <strong>Crunchloop</strong>) kept us fed and hydrated throughout the night.</p>

<h2 id="vacas-rails-y-blockchain">Vacas, Rails y Blockchain</h2>

<p>Ignacio Cesarani presented his thesis project: a cattle trading platform built with Ruby on Rails and blockchain. The interesting part is the architecture: it uses <strong>Ethereum RSK</strong> as a sidechain to record transactions immutably, while keeping operational data in a traditional database.</p>

<p align="center" width="100%">
  <img class="w-[70%]" alt="Ignacio's hybrid architecture slide" src="/assets/images/posts/ruby-argentina-may-2026-meetup/1.webp" />
</p>

<h2 id="break">Break</h2>

<p>After the first talk, we had time to eat, drink, and chat. The sponsors put together a good spread, and the atmosphere was the classic meetup vibe: people sharing what they’re building, making connections, and discussing both old and new problems.</p>

<h2 id="de-heroku-a-kamal">De Heroku a Kamal</h2>

<p>Fernando Silva Jacquier showed how he migrated a multi-tenant Rails app from Heroku to a VPS (Virtual Private Server) with Kamal. The most interesting part of the talk wasn’t the migration itself, but how he did it. Fernando shared that he worked with a Claude Code to perform the migration. And he didn’t show only the happy path: he shared all the problems he encountered, the failed attempts, the fixes, and the real experience of migrating infrastructure with AI assistance.</p>

<p align="center" width="100%">
  <img class="w-[70%]" alt="Fernando's talk title slide" src="/assets/images/posts/ruby-argentina-may-2026-meetup/2.webp" />
</p>

<hr />

<p>This meetup was possible thanks to the organizers, the sponsors (<a href="https://sinaptia.dev/">SINAPTIA</a>, Rootstrap, Ombulabs, and Crunchloop), and everyone who showed up. We’re already looking forward to June’s online meetup.</p>]]></content><author><name>SINAPTIA</name></author><category term="Ruby" /><category term="Community" /><summary type="html"><![CDATA[Ruby Argentina's May meetup brought together two very different topics: cattle trading on blockchain and migrating from Heroku to Kamal (with coding agent help).]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sinaptia.dev/assets/images/logo-black.png" /><media:content medium="image" url="https://sinaptia.dev/assets/images/logo-black.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Accelerating legacy systems onboarding with AI</title><link href="https://sinaptia.dev/posts/accelerating-legacy-systems-onboarding-with-ai/" rel="alternate" type="text/html" title="Accelerating legacy systems onboarding with AI" /><published>2026-04-30T00:00:00+00:00</published><updated>2026-04-30T00:00:00+00:00</updated><id>https://sinaptia.dev/posts/accelerating-legacy-systems-onboarding-with-ai</id><content type="html" xml:base="https://sinaptia.dev/posts/accelerating-legacy-systems-onboarding-with-ai/"><![CDATA[<p>For years, our way of taking over legacy systems was simple: start small.</p>

<p>A client would come to us with an existing application and a list of things they needed done. Sometimes that list was mostly bugs. Sometimes it was features. Sometimes it was a mix of vague product wishes, or urgent production issues.</p>

<p>So we would begin with the safest tickets.</p>

<p>Fix a small bug. Change a minor behavior. Add a low-risk feature. Read the code around it. Ask questions. Ship. Repeat.</p>

<p>Starting with low-risk work lets us deliver value to the client while we build confidence and learn the codebase we’re working on.</p>

<p>As the weeks went by, we would move toward more complex parts of the system. By then, we had seen enough of the data model, the workflows, the weird edge cases, and the business rules to make better calls. After one or two months, we usually had enough confidence to work almost anywhere in the application.</p>

<p>That approach still works. But it has a limitation: it assumes the application can be explored gradually.</p>

<p>Sometimes it can’t.</p>

<h2 id="the-old-approach-depended-on-the-app-working">The old approach depended on the app working</h2>

<p>A big part of our traditional onboarding was using the application itself.</p>

<p>We would log in, click around, follow user journeys, compare what the client said with what the product actually did, and then connect those screens back to the code. That gave us a practical map of the system.</p>

<p>But recently we had a case where that was impossible.</p>

<p>A few months ago, Joseph came to us with an application he had spent roughly eight months building. The product featured AI agents and workflows, had a meaningful business idea behind it, and already had a lot of code written. The problem was that it barely worked.</p>

<p>Login was broken or unreliable. Core flows failed. The parts we needed to inspect were hidden behind errors. The usual “let’s use the app and learn from it” path was blocked almost immediately.</p>

<p>Fixing it meant investing time into a codebase that might later be discarded. Rebuilding it meant accepting the cost of starting over. Both options could be right. Both could be wasteful.</p>

<h2 id="our-new-ai-assisted-onboarding-process">Our new AI-assisted onboarding process</h2>

<p>This is where our onboarding process changed. Instead of relying only on tickets and manual exploration, we started using AI-assisted analysis to generate a first layer of documentation from the codebase.</p>

<p>We used AI to help us build structured documents around questions like:</p>

<ul>
  <li>What are the main user journeys?</li>
  <li>Which roles exist in the platform?</li>
  <li>What are the core entities?</li>
  <li>Which parts of the application are connected to each other?</li>
  <li>Where does the business logic actually live?</li>
  <li>Which flows appear incomplete, duplicated, or fragile?</li>
  <li>What external services does the system depend on?</li>
  <li>Which architectural choices are going to make future work expensive?</li>
</ul>

<p>With the old methodology, we reduced risk by starting with small tasks and slowly building confidence. With the new methodology, we can build a much better map before we touch production behavior, and we can get there in much less time.</p>

<h2 id="what-we-learned-from-josephs-app">What we learned from Joseph’s app</h2>

<p>In Joseph’s case, the AI-assisted documents gave us enough context to evaluate the product without first making the app usable.</p>

<p>If we had started by fixing bugs one by one, we would have spent paid hours inside a codebase that might not survive the assessment. Worse, every fix would have created momentum toward keeping it. Once you invest enough time patching a system, rewriting it starts to feel like admitting waste, even when rewriting is the better option.</p>

<p>The architecture had enough deep problems that fixing the existing codebase would likely take about as long as rebuilding the first version properly. Our estimate was that a rebuild would take somewhere between two weeks and a month. Trying to stabilize the existing application could land in the same range, with more uncertainty and less confidence at the end.</p>

<p>So our recommendation was to rebuild it.</p>

<h2 id="the-point-is-reaching-confidence-faster">The point is reaching confidence faster</h2>

<p>The value we provide during onboarding has not changed: we want to deliver useful work early, while we build enough confidence to take care of the system responsibly.</p>

<p>What changed is how quickly we can get there.</p>

<p>Legacy systems are full of unknowns. Some parts are safe to touch. Some are risky. Some look harmless until you understand what depends on them. The sooner we can tell the difference, the sooner we can choose the right first tickets and start producing visible results.</p>

<p>AI gives us an initial map before the traditional onboarding loop begins. It helps us see which areas matter, which changes are likely to be low-risk, and which parts of the system deserve more care before anyone touches them.</p>

<p>The confidence we used to build in one or two months can now start to appear in one or two weeks.</p>

<p>That is the real change. We still like earning confidence through shipped work. But now we can reach that confidence much earlier, before we decide where the first tickets should be.</p>]]></content><author><name>Patricio Mac Adden</name></author><category term="AI" /><category term="Methodology" /><summary type="html"><![CDATA[Our old legacy onboarding worked by starting with low-risk tickets and learning the system gradually. AI changed that: now we can reach the same level of confidence faster, even when the application is too broken to explore normally.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sinaptia.dev/assets/images/logo-black.png" /><media:content medium="image" url="https://sinaptia.dev/assets/images/logo-black.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">AI4Devs April meetup</title><link href="https://sinaptia.dev/posts/ai4devs-april-meetup/" rel="alternate" type="text/html" title="AI4Devs April meetup" /><published>2026-04-28T00:00:00+00:00</published><updated>2026-04-28T00:00:00+00:00</updated><id>https://sinaptia.dev/posts/ai4devs-april-meetup</id><content type="html" xml:base="https://sinaptia.dev/posts/ai4devs-april-meetup/"><![CDATA[<p>The meetup took place last Friday, April 24th, at the FaHCE (Facultad de Humanidades y Ciencias de la Educación) in La Plata. The talks came from different places: empirical software engineering, custom coding agents, and multi-agent systems. The conversations continued afterward over beers and food provided by the organization.</p>

<h2 id="empirical-software-engineering-the-scientific-compass-in-the-age-of-llms">Empirical software engineering: the scientific compass in the age of LLMs</h2>

<p>The first talk was <strong>“Ingeniería de software empírica: La brújula científica en la era de los LLMs”</strong> (<strong>“Empirical software engineering: the scientific compass in the age of LLMs”</strong>) by Florencia Riva, a sociologist working at LIFIA.</p>

<p>Florencia’s talk pushed against a common temptation: asking LLMs for answers when what we actually need is a reproducible method. Shaped by her background in sociology, she argued that LLMs can be useful assistants, but we should not rely on them to do the actual math for us. Instead, they can help us reach deterministic methods (like code) that perform calculations and analysis in a reproducible way.</p>

<p>For empirical software engineering, that distinction matters. The work still depends on careful observation, measurement, and verifiable results.</p>

<p align="center" width="100%">
  <img class="w-[70%]" alt="Florencia's talk" src="/assets/images/posts/ai4devs-april-meetup/1.webp" />
</p>

<h2 id="a-custom-made-coding-agent">A custom-made coding agent</h2>

<p>The second talk was <strong>“Agente de código hecho a medida”</strong> (<strong>“A custom-made coding agent”</strong>) by Fernando Martínez from SINAPTIA.</p>

<p>Fernando presented <strong>Detritus</strong>, a deliberately tiny coding agent built in Ruby. The point was not to build the most capable assistant, but almost the opposite: to make the agent as simple as possible in order to understand what really makes these tools useful. His talk separated the problem into three parts—the model, the harness, and the workflow—and argued that when the model and harness are intentionally small, the workflow becomes much easier to see and reason about.</p>

<p>That experiment helped him test which practices survive across tools and models. If a workflow works with Detritus, a weak and minimal agent, it will probably work with more powerful agents too. The takeaway was less about replacing existing tools and more about gaining freedom: understanding the harness, avoiding vendor lock-in, and building a small personal lab for experimentation.</p>

<p align="center" width="100%">
  <img class="w-[70%]" alt="Fernando's talk" src="/assets/images/posts/ai4devs-april-meetup/2.webp" />
</p>

<h2 id="multi-agent-architectures-methodology-and-practice">Multi-agent architectures: methodology and practice</h2>

<p>The final talk was <strong>“Arquitecturas multiagente: metodología y práctica”</strong> (<strong>“Multi-agent architectures: methodology and practice”</strong>) by Lara González from Lumen Lab.</p>

<p>Lara focused on the parts of multi-agent systems that get hard quickly: deciding what each agent owns, how they coordinate, and where the handoffs can fail. She covered both methodology and practice, including how responsibilities should be split and what trade-offs appear when multiple AI components have to work together toward a goal.</p>

<p align="center" width="100%">
  <img class="w-[70%]" alt="Lara's talk" src="/assets/images/posts/ai4devs-april-meetup/3.webp" />
</p>

<h2 id="community-sponsors-and-thanks">Community, sponsors, and thanks</h2>

<p>After the talks, there was time to keep talking over beers and food. These community spaces make it easier to compare notes, ask questions, and see how other people are applying AI in real projects.</p>

<p>Huge thanks to the AI4Devs organizers, FaHCE for hosting the event, and the sponsors who made the meetup possible: SINAPTIA, Fudo, Purrfect AI, NaNLABS, Genom IT, SEDICI, and FaHCE.</p>

<p>The useful part of meetups like this is not the slide deck. It’s hearing how other people are testing the same tools, where they’re skeptical, and what they’ve managed to make work.</p>]]></content><author><name>SINAPTIA</name></author><category term="AI" /><category term="Community" /><summary type="html"><![CDATA[A recap of the AI4Devs meetup held at FaHCE (UNLP) on April 24th, 2026: empirical software engineering, custom coding agents, and multi-agent architectures.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sinaptia.dev/assets/images/logo-black.png" /><media:content medium="image" url="https://sinaptia.dev/assets/images/logo-black.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Ruby Argentina March meetup</title><link href="https://sinaptia.dev/posts/ruby-argentina-march-2026-meetup/" rel="alternate" type="text/html" title="Ruby Argentina March meetup" /><published>2026-03-31T00:00:00+00:00</published><updated>2026-03-31T00:00:00+00:00</updated><id>https://sinaptia.dev/posts/ruby-argentina-march-2026-meetup</id><content type="html" xml:base="https://sinaptia.dev/posts/ruby-argentina-march-2026-meetup/"><![CDATA[<p><a href="https://ruby.com.ar/">Ruby Argentina</a> opened its 2026 meetup season at Eryx’s office in Buenos Aires, and the night set the tone quickly: pay attention to Ruby’s warnings, then make room for a few lightning talks.</p>

<p>The event kicked off with Ariel Juodziukynas’ talk <strong>“Warnings en Ruby: NO LOS IGNORES!!!”</strong>. Ariel focused on an easy part of everyday Ruby work to ignore: warnings. His talk was a reminder that the yellow text in our terminal often points to real problems. He showed practical examples of common warnings and how to fix them.</p>

<p align="center" width="100%">
  <img class="w-[70%]" alt="Ariel's talk" src="/assets/images/posts/ruby-argentina-march-2026-meetup/1.webp" />
</p>

<p>After Ariel’s talk, we took a break to catch up, meet new people, and keep the conversations going over drinks and empanadas.</p>

<p>The lightning talks took the night in a looser direction:</p>

<ul>
  <li><strong>Simon</strong> shared how he fixed a tricky performance issue with a fulltext search, walking us through his debugging process and the solution he found.</li>
  <li><strong>Viktor</strong> presented <strong>aj</strong>, a tool he built to improve his AI workflow, showing how Ruby developers can build utilities to streamline their daily work.</li>
  <li><strong>Santiago</strong> taught us “how to do juggling”. Yes, actual juggling with balls, not code.</li>
  <li><strong>Ariel</strong> came back for a second round with “how to tie your shoes”. It was even better watching everyone try Ariel’s method on the spot.</li>
</ul>

<p align="center" width="100%">
  <img class="w-[70%]" alt="Simon's talk" src="/assets/images/posts/ruby-argentina-march-2026-meetup/2.webp" />
</p>

<p>Thanks to the organizers, the sponsors (<a href="https://sinaptia.dev/">SINAPTIA</a>, Rootstrap, Ombulabs, and Eryx), and everyone who showed up. We’re already looking forward to April’s online meetup.</p>]]></content><author><name>SINAPTIA</name></author><category term="Ruby" /><category term="Community" /><summary type="html"><![CDATA[Ruby Argentina opened its 2026 meetup season with a night that went from Ruby warnings to juggling lessons.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sinaptia.dev/assets/images/logo-black.png" /><media:content medium="image" url="https://sinaptia.dev/assets/images/logo-black.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">NO AI CODE IN PRODUCTION DIRECTIVE</title><link href="https://sinaptia.dev/posts/no-ai-code-in-production-directive/" rel="alternate" type="text/html" title="NO AI CODE IN PRODUCTION DIRECTIVE" /><published>2026-03-17T00:00:00+00:00</published><updated>2026-03-17T00:00:00+00:00</updated><id>https://sinaptia.dev/posts/no-ai-code-in-production-directive</id><content type="html" xml:base="https://sinaptia.dev/posts/no-ai-code-in-production-directive/"><![CDATA[<p>At SINAPTIA, we started enforcing a no “AI code in production” directive.</p>

<p>LOL, no. We are not the next tier of Luddites! We started using AI-assisted code generation a little over a year ago, and from the looks of it, we are going to use it even more in the near future. AI is here to stay, and programming will never be the same, what a time to be alive and so on, and so on…</p>

<p>But also, in a couple of years, we’ll all lose our jobs. <em>What a time to be alive, indeed.</em></p>

<p>We continually ask ourselves: what’s the AI capable of? Can it really replace us? Can it change the way we work? Will it make our problems simpler? Or there will be another kind of problems, more problems? Are we attending the death of software engineering?</p>

<p>There’s only one way to find out.</p>

<p>Initially, we were somewhat skeptical about coding agents. We tried them, but the autocomplete feature was usually annoying, and the chat mode wasn’t smart enough to understand the context of the code. But then, at some point, Claude Code and Cursor became smart enough to be of massive help while building features: they could explain things easily in the context they were in, and they could edit text nicely.</p>

<p>Since then LLMs and coding agents became a key part of our daily routine and we started building more and more <a href="https://sinaptia.dev/blog/tags/ai">features using AI</a>, and growing tools for the RubyLLM ecosystem (such as <a href="https://github.com/sinaptia/ruby_llm-instrumentation">RubyLLM::Instrumentation</a>, <a href="https://github.com/sinaptia/ruby_llm-monitoring">RubyLLM::Monitoring</a>, and <a href="https://github.com/sinaptia/ruby_llm-evals">RubyLLM::Evals</a>) that we use in our projects and we open-sourced for the larger community in hope to make Ruby one of the top languages for building with AI.</p>

<h2 id="the-problem-with-ai-generated-code">The problem with AI-generated code</h2>

<p>I bet at some point you stumbled upon a piece of code that achieved something, but it was hard to understand, poorly thought out, or just ugly (eg, for us, Rubyists, seeing Ruby code that doesn’t feel Ruby). That happened a lot in the StackOverflow era.</p>

<p>With AI-generated code, it’s the same as with any code you didn’t write yourself. You might find AI-generated code hard to understand, poorly thought out, or even solving problems that no one asked to solve.</p>

<p>We’ve seen coding agents working without oversight and proper feedback going down paths that ended up in code that no one would be able to manage. Neither the coding agents nor the humans. Discard and regenerate is a possibility, yes, but tokens are not actually free, and budgeting and financials are something coding agents cannot fix either.</p>

<p>Models and agents will become better over time, and the barrier might be farther away each time, but the problem will always be there.</p>

<h2 id="the-hard-parts">The hard parts</h2>

<p>After more than a year of using coding agents for our daily work, developing AI-powered features, and running several experiments, we feel that AI is truly a multiplier force, but it isn’t changing the most fundamental bits of programming and software production: the hard parts of software production are still hard.</p>

<p>The power of solving a complex problem with a simple and elegant solution/architecture is what makes a good engineer a great one. And AI can help one do so, but is not able to come up with such architectural decisions on its own (at least not yet).</p>

<p>But in the areas where the AI excels, we should try to leverage it. I, honestly, cannot care less if you wrote the code we just deployed to production by punching holes in a card, taping keys in a keyboard letter by letter, copying-pasting snippets from StackOverflow, or you used a high-rate probabilistic word predictor that can produce hundreds of words per second. Tools are not at trial here. But, regardless of how that code came to life, I do care you don’t fall prey to the laziness of not curating the code you produce, of validating it and understanding it (There are many ways to do this, and they are changing, but isn’t this what engineering is about?)</p>

<p>Striving for quality and good architectural decisions is still central. Simplicity is still the only way software remains workable in the long run. For humans, yes, but for AI agents too.</p>

<p>Simple was hard in the pre-AI era and is still hard today (perhaps even harder), but what’s simpler for humans is also simpler for agents. And it is still worth all the effort.</p>

<p>So we say: There is no such thing as AI code. We just have <em>code</em>: good, bad, simple, or complex. And we have tools and processes to deal with it. Like we always did.</p>

<p>So, long live power tools. Long live software engineering.</p>

<hr />

<p><em>At SINAPTIA, <a href="/posts/building-intelligent-applications-with-rails">we specialize in helping businesses implement AI solutions</a> that deliver real value. If you’re facing challenges with prompt engineering or AI integration, we’d love to help.</em></p>]]></content><author><name>Fernando Martinez</name></author><category term="AI" /><summary type="html"><![CDATA[AI is here to stay. Programming will never be the same, or... will it?]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sinaptia.dev/assets/images/logo-black.png" /><media:content medium="image" url="https://sinaptia.dev/assets/images/logo-black.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Storing multi-valued enum fields in ActiveRecord</title><link href="https://sinaptia.dev/posts/storing-multi-valued-enum-fields-in-activerecord/" rel="alternate" type="text/html" title="Storing multi-valued enum fields in ActiveRecord" /><published>2026-03-03T00:00:00+00:00</published><updated>2026-03-03T00:00:00+00:00</updated><id>https://sinaptia.dev/posts/storing-multi-valued-enum-fields-in-activerecord</id><content type="html" xml:base="https://sinaptia.dev/posts/storing-multi-valued-enum-fields-in-activerecord/"><![CDATA[<p>A few weeks ago, we ran into an interesting problem in one of our projects. We had a <code class="language-plaintext highlighter-rouge">reports</code> table with a <code class="language-plaintext highlighter-rouge">reason</code> column that used the classic Rails enum approach:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/XXXXXXXXXXXXXX_create_reports.rb</span>
<span class="k">class</span> <span class="nc">CreateReports</span> <span class="o">&lt;</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Migration</span><span class="p">[</span><span class="mf">7.1</span><span class="p">]</span>
  <span class="n">create_table</span> <span class="ss">:reports</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
    <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:title</span>
    <span class="n">t</span><span class="p">.</span><span class="nf">integer</span> <span class="ss">:reason</span><span class="p">,</span> <span class="ss">default: </span><span class="mi">0</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="c1"># app/models/report.rb</span>
<span class="k">class</span> <span class="nc">Report</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">enum</span> <span class="ss">:reason</span><span class="p">,</span> <span class="p">{</span>
    <span class="ss">spam: </span><span class="mi">0</span><span class="p">,</span>
    <span class="ss">harassment: </span><span class="mi">1</span><span class="p">,</span>
    <span class="ss">inappropriate_content: </span><span class="mi">2</span><span class="p">,</span>
    <span class="ss">copyright: </span><span class="mi">3</span><span class="p">,</span>
    <span class="ss">misinformation: </span><span class="mi">4</span>
  <span class="p">},</span> <span class="ss">prefix: </span><span class="kp">true</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Everything worked fine until the client requested the ability to select multiple reasons for a report. A user could report content for being spam and containing misinformation. The set of possible reasons is fixed and defined by developers.</p>

<p>We tried four approaches.</p>

<h2 id="four-ways-to-solve-it">Four ways to solve it</h2>

<ul>
  <li><strong>Bitwise Operations</strong>: store multiple values in a single integer using bit-level flags.</li>
  <li><strong>PostgreSQL Array</strong>: use native PostgreSQL array columns with enum-like syntax.</li>
  <li><strong>JSONB</strong>: store reasons as a JSON array inside a JSONB column.</li>
  <li><strong>HABTM</strong>: the classic many-to-many approach with a join table.</li>
</ul>

<h3 id="1-bitwise-operations">1. Bitwise Operations</h3>

<p>They use bit-level operations to store multiple values in a single integer. Each reason occupies a specific bit. The <a href="https://github.com/kenn/active_flag">active_flag</a> gem provides a clean DSL for this:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/XXXXXXXXXXXXXX_add_reasons_to_reports.rb</span>
<span class="k">class</span> <span class="nc">AddReasonsToReports</span> <span class="o">&lt;</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Migration</span><span class="p">[</span><span class="mf">7.1</span><span class="p">]</span>
  <span class="k">def</span> <span class="nf">change</span>
    <span class="n">add_column</span> <span class="ss">:reports</span><span class="p">,</span> <span class="ss">:reasons</span><span class="p">,</span> <span class="ss">:bigint</span><span class="p">,</span> <span class="ss">default: </span><span class="mi">0</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>

    <span class="c1"># migrate data to new format</span>

    <span class="n">remove_column</span> <span class="ss">:reports</span><span class="p">,</span> <span class="ss">:reason</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="c1"># app/models/report.rb</span>
<span class="k">class</span> <span class="nc">Report</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">flag</span> <span class="ss">:reasons</span><span class="p">,</span> <span class="p">[</span><span class="ss">:spam</span><span class="p">,</span> <span class="ss">:harassment</span><span class="p">,</span> <span class="ss">:inappropriate_content</span><span class="p">,</span> <span class="ss">:copyright</span><span class="p">,</span> <span class="ss">:misinformation</span><span class="p">]</span>
<span class="k">end</span>

<span class="c1"># Usage</span>
<span class="n">report</span> <span class="o">=</span> <span class="no">Report</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="ss">reasons: </span><span class="p">[</span><span class="ss">:spam</span><span class="p">,</span> <span class="ss">:misinformation</span><span class="p">])</span>
<span class="n">report</span><span class="p">.</span><span class="nf">reasons</span> <span class="c1"># =&gt; [:spam, :misinformation]</span>
<span class="n">report</span><span class="p">.</span><span class="nf">reasons</span><span class="p">.</span><span class="nf">spam?</span>   <span class="c1"># =&gt; true</span>
<span class="n">report</span><span class="p">.</span><span class="nf">reasons</span> <span class="o">=</span> <span class="p">[</span><span class="ss">:spam</span><span class="p">]</span>
<span class="n">report</span><span class="p">.</span><span class="nf">save!</span>

<span class="c1"># Read: check if spam is included</span>
<span class="n">report</span><span class="p">.</span><span class="nf">reasons</span><span class="p">.</span><span class="nf">spam?</span> <span class="c1"># =&gt; true</span>

<span class="c1"># Validation: invalid values raise ArgumentError</span>
<span class="n">report</span><span class="p">.</span><span class="nf">reasons</span> <span class="o">=</span> <span class="p">[</span><span class="ss">:invalid</span><span class="p">]</span> <span class="c1"># =&gt; raises ArgumentError</span>

<span class="c1"># Query: find all reports with spam AND misinformation</span>
<span class="no">Report</span><span class="p">.</span><span class="nf">where_reasons</span><span class="p">(</span><span class="ss">:spam</span><span class="p">,</span> <span class="ss">:misinformation</span><span class="p">)</span>
</code></pre></div></div>

<p>The benefits here are clear: extremely compact storage, very fast boolean checks, and no GIN index required, just a standard integer index. But the trade-offs become apparent quickly. Database values are inexpressive (what does value 4 mean?). You’re limited to 64 values with <code class="language-plaintext highlighter-rouge">bigint</code>. And query operations are less intuitive than standard ActiveRecord.</p>

<p>Note: Most Ruby gems (including active_flag) are based on integers with a 64-bit limit. While PostgreSQL supports the bit string data type, which can store many more flags without this limitation, the Ruby ecosystem doesn’t have widely adopted gems for this approach.</p>

<h3 id="2-postgresql-array-field-multivalued-column">2. PostgreSQL Array Field (Multivalued Column)</h3>

<p>PostgreSQL has native array support. We can store reason strings directly in a string array column without any gem:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/XXXXXXXXXXXXXX_add_reasons_to_reports.rb</span>
<span class="k">class</span> <span class="nc">AddReasonsToReports</span> <span class="o">&lt;</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Migration</span><span class="p">[</span><span class="mf">7.1</span><span class="p">]</span>
  <span class="k">def</span> <span class="nf">change</span>
    <span class="n">add_column</span> <span class="ss">:reports</span><span class="p">,</span> <span class="ss">:reasons</span><span class="p">,</span> <span class="ss">:string</span><span class="p">,</span> <span class="ss">array: </span><span class="kp">true</span><span class="p">,</span> <span class="ss">default: </span><span class="p">[]</span>
    <span class="n">add_index</span> <span class="ss">:reports</span><span class="p">,</span> <span class="ss">:reasons</span><span class="p">,</span> <span class="ss">using: </span><span class="s1">'gin'</span>

    <span class="c1"># migrate data to new format</span>

    <span class="n">remove_column</span> <span class="ss">:reports</span><span class="p">,</span> <span class="ss">:reason</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="c1"># app/models/report.rb</span>
<span class="k">class</span> <span class="nc">Report</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="no">VALID_REASONS</span> <span class="o">=</span> <span class="sx">%w[spam harassment inappropriate_content copyright misinformation]</span><span class="p">.</span><span class="nf">freeze</span>

  <span class="n">validate</span> <span class="ss">:reasons_must_be_valid</span>

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

  <span class="k">def</span> <span class="nf">reasons_must_be_valid</span>
    <span class="k">return</span> <span class="k">if</span> <span class="n">reasons</span><span class="p">.</span><span class="nf">blank?</span> <span class="o">||</span> <span class="n">reasons</span><span class="p">.</span><span class="nf">all?</span> <span class="p">{</span> <span class="o">|</span><span class="n">r</span><span class="o">|</span> <span class="no">VALID_REASONS</span><span class="p">.</span><span class="nf">include?</span><span class="p">(</span><span class="n">r</span><span class="p">)</span> <span class="p">}</span>

    <span class="n">errors</span><span class="p">.</span><span class="nf">add</span><span class="p">(</span><span class="ss">:reasons</span><span class="p">,</span> <span class="s2">"contain invalid values"</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="c1"># Usage</span>
<span class="n">report</span> <span class="o">=</span> <span class="no">Report</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span><span class="ss">title: </span><span class="s2">"Report 1"</span><span class="p">,</span> <span class="ss">reasons: </span><span class="p">[</span><span class="s2">"spam"</span><span class="p">,</span> <span class="s2">"misinformation"</span><span class="p">])</span>

<span class="c1"># Read: check if spam is included</span>
<span class="n">report</span><span class="p">.</span><span class="nf">reasons</span><span class="p">.</span><span class="nf">include?</span><span class="p">(</span><span class="s2">"spam"</span><span class="p">)</span> <span class="c1"># =&gt; true</span>

<span class="c1"># Query: find all reports with spam AND misinformation</span>
<span class="no">Report</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="s2">"reasons @&gt; ARRAY[?]::varchar[]"</span><span class="p">,</span> <span class="p">[</span><span class="s2">"spam"</span><span class="p">,</span> <span class="s2">"misinformation"</span><span class="p">])</span>
</code></pre></div></div>

<p>The upside: no gem dependency, human-readable values in the database, and easy to expand. The downside: it’s PostgreSQL-specific, validations require manual implementation, and it’s less familiar to developers who don’t use PostgreSQL regularly.</p>

<h3 id="3-jsonb-multivalued-column">3. JSONB (Multivalued Column)</h3>

<p>We store reasons as a JSON array inside a JSONB column.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/XXXXXXXXXXXXXX_add_reasons_to_reports.rb</span>
<span class="k">class</span> <span class="nc">AddReasonsToReports</span> <span class="o">&lt;</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Migration</span><span class="p">[</span><span class="mf">7.1</span><span class="p">]</span>
  <span class="k">def</span> <span class="nf">change</span>
    <span class="n">add_column</span> <span class="ss">:reports</span><span class="p">,</span> <span class="ss">:reasons</span><span class="p">,</span> <span class="ss">:jsonb</span><span class="p">,</span> <span class="ss">default: </span><span class="p">[],</span> <span class="ss">null: </span><span class="kp">false</span>
    <span class="n">add_index</span> <span class="ss">:reports</span><span class="p">,</span> <span class="ss">:reasons</span><span class="p">,</span> <span class="ss">using: </span><span class="s1">'gin'</span>

    <span class="c1"># migrate data to new format</span>

    <span class="n">remove_column</span> <span class="ss">:reports</span><span class="p">,</span> <span class="ss">:reason</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="c1"># app/models/report.rb</span>
<span class="k">class</span> <span class="nc">Report</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="no">VALID_REASONS</span> <span class="o">=</span> <span class="sx">%w[spam harassment inappropriate_content copyright misinformation]</span><span class="p">.</span><span class="nf">freeze</span>

  <span class="n">validate</span> <span class="ss">:reasons_must_be_valid</span>

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

  <span class="k">def</span> <span class="nf">reasons_must_be_valid</span>
    <span class="k">return</span> <span class="k">if</span> <span class="n">reasons</span><span class="p">.</span><span class="nf">blank?</span> <span class="o">||</span> <span class="n">reasons</span><span class="p">.</span><span class="nf">all?</span> <span class="p">{</span> <span class="o">|</span><span class="n">r</span><span class="o">|</span> <span class="no">VALID_REASONS</span><span class="p">.</span><span class="nf">include?</span><span class="p">(</span><span class="n">r</span><span class="p">)</span> <span class="p">}</span>

    <span class="n">errors</span><span class="p">.</span><span class="nf">add</span><span class="p">(</span><span class="ss">:reasons</span><span class="p">,</span> <span class="s2">"contain invalid values"</span><span class="p">)</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="c1"># Usage</span>
<span class="n">report</span> <span class="o">=</span> <span class="no">Report</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span><span class="ss">title: </span><span class="s2">"Report 1"</span><span class="p">,</span> <span class="ss">reasons: </span><span class="p">[</span><span class="s2">"spam"</span><span class="p">,</span> <span class="s2">"misinformation"</span><span class="p">])</span>

<span class="c1"># Read: check if spam is included</span>
<span class="n">report</span><span class="p">.</span><span class="nf">reasons</span><span class="p">.</span><span class="nf">include?</span><span class="p">(</span><span class="s2">"spam"</span><span class="p">)</span> <span class="c1"># =&gt; true</span>

<span class="c1"># Query: find all reports with spam AND misinformation</span>
<span class="no">Report</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="s2">"reasons @&gt; ?"</span><span class="p">,</span> <span class="s1">'["spam", "misinformation"]'</span><span class="p">)</span>
</code></pre></div></div>

<p>The benefit is GIN indexing for fast searches. The cost: the flexible structure requires stronger validations to enforce the expected format, it’s slightly more verbose than alternatives, and there’s JSON parsing overhead in some cases.</p>

<h3 id="4-habtm-table">4. HABTM Table</h3>

<p>The classic many-to-many approach with a join table.</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># db/migrate/XXXXXXXXXXXXXX_create_reasons_and_join_table.rb</span>
<span class="k">class</span> <span class="nc">CreateReasonsAndJoinTable</span> <span class="o">&lt;</span> <span class="no">ActiveRecord</span><span class="o">::</span><span class="no">Migration</span><span class="p">[</span><span class="mf">7.1</span><span class="p">]</span>
  <span class="k">def</span> <span class="nf">change</span>
    <span class="n">create_table</span> <span class="ss">:reasons</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">string</span> <span class="ss">:name</span><span class="p">,</span> <span class="ss">null: </span><span class="kp">false</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">timestamps</span>
    <span class="k">end</span>
    <span class="n">add_index</span> <span class="ss">:reasons</span><span class="p">,</span> <span class="ss">:name</span><span class="p">,</span> <span class="ss">unique: </span><span class="kp">true</span>

    <span class="n">create_join_table</span> <span class="ss">:reports</span><span class="p">,</span> <span class="ss">:reasons</span> <span class="k">do</span> <span class="o">|</span><span class="n">t</span><span class="o">|</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">index</span> <span class="p">[</span><span class="ss">:report_id</span><span class="p">,</span> <span class="ss">:reason_id</span><span class="p">]</span>
      <span class="n">t</span><span class="p">.</span><span class="nf">index</span> <span class="p">[</span><span class="ss">:reason_id</span><span class="p">,</span> <span class="ss">:report_id</span><span class="p">]</span>
    <span class="k">end</span>

    <span class="c1"># migrate data to new format</span>

    <span class="n">remove_column</span> <span class="ss">:reports</span><span class="p">,</span> <span class="ss">:reason</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="c1"># app/models/report.rb</span>
<span class="k">class</span> <span class="nc">Report</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">has_and_belongs_to_many</span> <span class="ss">:reasons</span>
<span class="k">end</span>

<span class="c1"># app/models/reason.rb</span>
<span class="k">class</span> <span class="nc">Reason</span> <span class="o">&lt;</span> <span class="no">ApplicationRecord</span>
  <span class="n">has_and_belongs_to_many</span> <span class="ss">:reports</span>

  <span class="n">validates</span> <span class="ss">:name</span><span class="p">,</span> <span class="ss">inclusion: </span><span class="p">{</span> <span class="ss">in: </span><span class="sx">%w[spam harassment inappropriate_content copyright  misinformation]</span> <span class="p">}</span>
<span class="k">end</span>

<span class="c1"># Usage</span>
<span class="n">report</span> <span class="o">=</span> <span class="no">Report</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span><span class="ss">title: </span><span class="s2">"Report 1"</span><span class="p">,</span> <span class="ss">reasons: </span><span class="no">Reason</span><span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">name: </span><span class="p">[</span><span class="s2">"spam"</span><span class="p">,</span> <span class="s2">"misinformation"</span><span class="p">]))</span>

<span class="c1"># Read: check if spam is included</span>
<span class="n">report</span><span class="p">.</span><span class="nf">reasons</span><span class="p">.</span><span class="nf">exists?</span><span class="p">(</span><span class="ss">name: </span><span class="s2">"spam"</span><span class="p">)</span> <span class="c1"># =&gt; true</span>

<span class="c1"># Query: find all reports with spam AND misinformation</span>
<span class="no">Report</span><span class="p">.</span><span class="nf">joins</span><span class="p">(</span><span class="ss">:reasons</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">where</span><span class="p">(</span><span class="ss">reasons: </span><span class="p">{</span> <span class="ss">name: </span><span class="p">[</span><span class="s2">"spam"</span><span class="p">,</span> <span class="s2">"misinformation"</span><span class="p">]</span> <span class="p">})</span>
      <span class="p">.</span><span class="nf">group</span><span class="p">(</span><span class="ss">:id</span><span class="p">)</span>
      <span class="p">.</span><span class="nf">having</span><span class="p">(</span><span class="s2">"COUNT(DISTINCT reasons.id) = 2"</span><span class="p">)</span>
</code></pre></div></div>

<p>Benefits include total flexibility to add additional metadata, extreme familiarity for Rails developers, and unlimited scalability. The drawback: slower writes (create/update) and the need for more queries or joins to read data.</p>

<h2 id="comparison">Comparison</h2>

<p>Here’s a summary of how each approach compares across the attributes that matter most:</p>

<table>
  <thead>
    <tr>
      <th>Attribute</th>
      <th>Bitwise</th>
      <th>PostgreSQL Array</th>
      <th>JSONB</th>
      <th>HABTM</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>Write Performance</strong></td>
      <td>Excellent</td>
      <td>Very Good</td>
      <td>Very Good</td>
      <td>Fair</td>
    </tr>
    <tr>
      <td><strong>Read Performance</strong></td>
      <td>Very Good</td>
      <td>Good</td>
      <td>Good</td>
      <td>Good</td>
    </tr>
    <tr>
      <td><strong>Query Simplicity</strong></td>
      <td>Excellent</td>
      <td>Excellent</td>
      <td>Excellent</td>
      <td>Fair</td>
    </tr>
    <tr>
      <td><strong>Default Values</strong></td>
      <td>Very Good</td>
      <td>Excellent</td>
      <td>Excellent</td>
      <td>Poor</td>
    </tr>
    <tr>
      <td><strong>Database-Level Validation</strong></td>
      <td>Very Good</td>
      <td>Good</td>
      <td>Good</td>
      <td>Excellent</td>
    </tr>
    <tr>
      <td><strong>Extensibility</strong></td>
      <td>Poor</td>
      <td>Good</td>
      <td>Very Good</td>
      <td>Excellent</td>
    </tr>
    <tr>
      <td><strong>Familiarity</strong></td>
      <td>Poor</td>
      <td>Good</td>
      <td>Good</td>
      <td>Excellent</td>
    </tr>
    <tr>
      <td><strong>Ecosystem Support</strong></td>
      <td>Poor</td>
      <td>Fair</td>
      <td>Fair</td>
      <td>Excellent</td>
    </tr>
    <tr>
      <td><strong>DB Compatibility</strong></td>
      <td>Excellent</td>
      <td>Poor</td>
      <td>Excellent</td>
      <td>Excellent</td>
    </tr>
    <tr>
      <td><strong>Scalability</strong></td>
      <td>64 values</td>
      <td>Limited</td>
      <td>Limited</td>
      <td>Unlimited</td>
    </tr>
    <tr>
      <td><strong>Property vs Entity</strong></td>
      <td>property</td>
      <td>property</td>
      <td>property</td>
      <td>entity</td>
    </tr>
  </tbody>
</table>

<h3 id="property-vs-entity">Property vs Entity</h3>

<p>In data modeling terms, reasons are a property of a report, not an entity with its own identity and lifecycle. Nobody queries “show me all attributes of the spam reason”. That makes the multivalued column approaches (array, JSONB, bitwise) more true to the conceptual model, even though they break First Normal Form. The HABTM is the more purely relational approach, but it treats a property more as if it were an entity.</p>

<h3 id="performance">Performance</h3>

<p>We ran benchmarks with 1000 operations for create, find, and update scenarios. You can find the full benchmark code and results in the <a href="https://github.com/sinaptia/multivalued_attributes">multivalued_attributes</a> repository.</p>

<p>The HABTM is ~4x slower on creates and ~3-5x slower on updates compared to the other methods. However, it’s worth noting that most performance issues related to HABTM and JOINs aren’t actually caused by the JOIN itself. Databases like PostgreSQL are highly optimized for these operations. The real culprits are usually N+1 queries, missing indexes, or poorly designed queries. With proper indexing and eager loading, the read performance gap narrows significantly. The first three approaches (bitwise, array, and jsonb) have very similar performance. On reads, the difference is marginal (only 2-3 extra seconds over 1000 queries).</p>

<h3 id="query-simplicity">Query Simplicity</h3>

<p>One often overlooked aspect is query complexity at the call site. If your codebase filters by these values extensively, in scopes, serializers, admin interfaces, and column-based approaches, keep queries simple and on a single table. With array_enum or bitwise, you can do:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">Report</span><span class="p">.</span><span class="nf">with_reason</span><span class="p">(</span><span class="ss">:spam</span><span class="p">)</span>
<span class="no">Report</span><span class="p">.</span><span class="nf">where</span><span class="p">.</span><span class="nf">not</span><span class="p">(</span><span class="ss">reasons: </span><span class="p">[])</span>
</code></pre></div></div>

<p>With a HABTM, every call site would need a <code class="language-plaintext highlighter-rouge">.joins(:reasons)</code>, adding complexity and increasing the risk of N+1 queries if eager loading is forgotten. While you can mitigate this with default scopes or associations, it adds friction that column-based approaches simply don’t have.</p>

<h3 id="default-values">Default Values</h3>

<p>With array or JSONB columns, you can set default values directly in the migration:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">t</span><span class="p">.</span><span class="nf">integer</span> <span class="ss">:reasons</span><span class="p">,</span> <span class="ss">array: </span><span class="kp">true</span><span class="p">,</span> <span class="ss">default: </span><span class="p">[</span><span class="s2">"spam"</span><span class="p">,</span> <span class="s2">"harassment"</span><span class="p">]</span>
</code></pre></div></div>

<p>New records automatically get these values. No callbacks required. With a HABTM, you’d need a callback to seed join table rows, adding an extra step that can be forgotten or misconfigured.</p>

<h3 id="database-level-validation">Database-Level Validation</h3>

<p>With a HABTM, foreign keys enforce valid references automatically:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">INSERT</span> <span class="k">INTO</span> <span class="n">reasons_reports</span> <span class="p">(</span><span class="n">report_id</span><span class="p">,</span> <span class="n">reason_id</span><span class="p">)</span>
<span class="k">VALUES</span> <span class="p">(</span><span class="s1">'some-uuid'</span><span class="p">,</span> <span class="mi">999</span><span class="p">);</span>
<span class="c1">-- ERROR: Key (reason_id)=(999) is not present in table "reasons"</span>
</code></pre></div></div>

<p>You also get cascading behavior. <code class="language-plaintext highlighter-rouge">ON DELETE CASCADE</code> automatically cleans up join table rows when a reason is removed.</p>

<p>With array or JSONB, you can use PostgreSQL CHECK constraints to enforce valid values at the database level:</p>

<div class="language-sql highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">ALTER</span> <span class="k">TABLE</span> <span class="n">reports</span>
<span class="k">ADD</span> <span class="k">CONSTRAINT</span> <span class="n">valid_reasons</span>
<span class="k">CHECK</span> <span class="p">(</span><span class="n">reasons</span> <span class="o">&lt;@</span> <span class="n">ARRAY</span><span class="p">[</span><span class="s1">'spam'</span><span class="p">,</span> <span class="s1">'harassment'</span><span class="p">,</span> <span class="s1">'inappropriate_content'</span><span class="p">,</span> <span class="s1">'copyright'</span><span class="p">,</span> <span class="s1">'misinformation'</span><span class="p">]);</span>

<span class="k">UPDATE</span> <span class="n">reports</span>
<span class="k">SET</span> <span class="n">reasons</span> <span class="o">=</span> <span class="n">ARRAY</span><span class="p">[</span><span class="s1">'spam'</span><span class="p">,</span> <span class="s1">'harassment'</span><span class="p">,</span> <span class="s1">'oops_typo'</span><span class="p">];</span>
<span class="c1">-- ERROR: new row for relation "reports" violates check constraint "valid_reasons"</span>
</code></pre></div></div>

<p>Both approaches can enforce valid values at the database level. The difference is ergonomics: foreign keys handle this naturally, while CHECK constraints require explicit maintenance when adding new values.</p>

<h3 id="extensibility">Extensibility</h3>

<ul>
  <li><strong>Bitwise</strong>: Limited to 64 values. To add more, you need to change to <code class="language-plaintext highlighter-rouge">bigint</code> or use bit strings.</li>
  <li><strong>Array</strong>: Easy to expand, but storing many values (dozens+) becomes unwieldy. Row size grows, and <a href="https://www.postgresql.org/docs/current/gin.html">GIN</a> indexes start having performance issues.</li>
  <li><strong>JSONB</strong>: Flexible, but the same problem as arrays when storing many values.</li>
  <li><strong>HABTM</strong>: The most extensible. You can add additional columns (e.g., <code class="language-plaintext highlighter-rouge">reason_details</code>, <code class="language-plaintext highlighter-rouge">reported_at</code> in the join table). Also ideal for dynamic or user-defined values.</li>
</ul>

<h3 id="familiarity">Familiarity</h3>

<ul>
  <li><strong>Bitwise</strong>: Strange for most. Requires explaining bit-level operations.</li>
  <li><strong>Array</strong>: Intuitive if you know Ruby/PostgreSQL.</li>
  <li><strong>JSONB</strong>: Familiar to those who use modern REST APIs.</li>
  <li><strong>HABTM</strong>: The most idiomatic. Any Rails developer immediately understands <code class="language-plaintext highlighter-rouge">has_and_belongs_to_many</code>.</li>
</ul>

<h3 id="ecosystem">Ecosystem</h3>

<ul>
  <li><strong>Bitwise/Array/JSONB</strong>: Require custom form inputs, serializers, and manual validations. Some support from gems.</li>
  <li><strong>HABTM</strong>: Works out-of-the-box with ActiveAdmin, RailsAdmin, nested forms, and bulk operations.</li>
</ul>

<h3 id="database-compatibility">Database Compatibility</h3>

<p>PostgreSQL arrays are PostgreSQL-specific. Bitwise and HABTM work across any database. JSON works across MySQL, SQLite, and PostgreSQL (though with different syntax and index types. MySQL uses GIN indexes similarly to PostgreSQL, while SQLite has the JSON1 extension).</p>

<h2 id="conclusion">Conclusion</h2>

<p>If your value set is static and small, <strong>PostgreSQL array</strong> is the better choice over bitwise. They offer nearly identical performance without introducing unfamiliar bit-level operations that confuse most Rails developers.</p>

<p>But don’t dismiss HABTM. For most applications, the write overhead is irrelevant compared to the cost of maintaining “clever” code that the next developer doesn’t understand.</p>

<p><strong>In our specific case</strong>, a reporting system where users can select multiple reasons, we chose <strong>HABTM</strong>. Not because it was the fastest, it wasn’t. But because it didn’t need justification. Any new developer on the team immediately understands it, and that clarity is worth more than the performance gains we’d rarely notice in practice.</p>

<p>The best technical choice is the one that doesn’t need a justification.</p>]]></content><author><name>Nazareno Moresco</name></author><category term="Ruby on Rails" /><category term="Performance" /><summary type="html"><![CDATA[Rails' enum DSL is great for single values, but what about multiple? We compared 4 approaches across performance, extensibility, and maintainability to find the best fit.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sinaptia.dev/assets/images/logo-black.png" /><media:content medium="image" url="https://sinaptia.dev/assets/images/logo-black.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Evaluating LLM prompts in Rails</title><link href="https://sinaptia.dev/posts/evaluating-llm-prompts-in-rails/" rel="alternate" type="text/html" title="Evaluating LLM prompts in Rails" /><published>2026-02-17T00:00:00+00:00</published><updated>2026-02-17T00:00:00+00:00</updated><id>https://sinaptia.dev/posts/evaluating-llm-prompts-in-rails</id><content type="html" xml:base="https://sinaptia.dev/posts/evaluating-llm-prompts-in-rails/"><![CDATA[<p>We’ve built several AI features in Rails by now: <a href="/posts/scaling-image-classification-with-ai">image classification</a>, <a href="/posts/upscaling-images-with-ai">image upscaling</a>, <a href="/posts/improving-a-similarity-search-with-ai">similarity search</a>, 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.</p>

<p>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?</p>

<p>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?</p>

<h2 id="the-pragmatic-choice-spreadsheets">The pragmatic choice: spreadsheets</h2>

<p>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…</p>

<h2 id="spreadsheets-dont-scale">Spreadsheets don’t scale</h2>

<p>We knew the limits going in, but they became harder to ignore over time:</p>

<ul>
  <li><strong>They fragment.</strong> People make copies. When you’re sharing with non-technical collaborators, you end up with multiple sources of truth.</li>
  <li><strong>No enforced structure.</strong> 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.</li>
  <li><strong>Hard to compare.</strong> Eyeballing results across configurations isn’t intuitive, and people get confused.</li>
  <li><strong>No regression baseline.</strong> Once you settle on a configuration, how do you catch regressions later?</li>
  <li><strong>Prompts drift.</strong> Someone edits the spreadsheet and forgets to update the code. Nobody notices until something breaks.</li>
  <li><strong>Disconnected from code.</strong> Prompts and evaluations should live where the application lives.</li>
</ul>

<p>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.</p>

<p>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.</p>

<p>So we built <a href="https://github.com/sinaptia/ruby_llm-evals">RubyLLM::Evals</a>, a Rails engine for testing, comparing, and improving LLM prompts directly inside your application.</p>

<h2 id="rubyllmevals">RubyLLM::Evals</h2>

<p>Since we’re using <a href="https://github.com/crmne/ruby_llm">RubyLLM</a>, it made sense to build on top of it.</p>

<p>The core abstractions are <strong>prompts</strong> and <strong>samples</strong>. 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.</p>

<p>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 <em>good enough</em>. 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.</p>

<p>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.</p>

<h3 id="real-application-data">Real application data</h3>

<p>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:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">prompt</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Evals</span><span class="o">::</span><span class="no">Prompt</span><span class="p">.</span><span class="nf">find_by</span><span class="p">(</span><span class="ss">slug: </span><span class="s2">"image-categorization"</span><span class="p">)</span>

<span class="no">Image</span><span class="p">.</span><span class="nf">uncategorized</span><span class="p">.</span><span class="nf">limit</span><span class="p">(</span><span class="mi">50</span><span class="p">).</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">image</span><span class="o">|</span>
  <span class="n">sample</span> <span class="o">=</span> <span class="n">prompt</span><span class="p">.</span><span class="nf">samples</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span><span class="ss">eval_type: :human</span><span class="p">)</span>
  <span class="n">sample</span><span class="p">.</span><span class="nf">files</span><span class="p">.</span><span class="nf">attach</span><span class="p">(</span><span class="n">image</span><span class="p">.</span><span class="nf">attachment</span><span class="p">.</span><span class="nf">blob</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Now you’re iterating on your prompt with actual production data, not synthetic examples.</p>

<p>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.</p>

<h3 id="in-production">In production</h3>

<p>Once you’re happy with a prompt, you can use it directly in your application:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">response</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Evals</span><span class="o">::</span><span class="no">Prompt</span><span class="p">.</span><span class="nf">execute</span><span class="p">(</span>
  <span class="s2">"image-categorization"</span><span class="p">,</span>
  <span class="ss">files: </span><span class="p">[</span><span class="n">image</span><span class="p">.</span><span class="nf">attachment</span><span class="p">.</span><span class="nf">blob</span><span class="p">]</span>
<span class="p">)</span>
<span class="n">response</span><span class="p">.</span><span class="nf">content</span>  <span class="c1"># =&gt; "deck"</span>
</code></pre></div></div>

<p>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.</p>

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

<p>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 can 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.</p>

<p>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.</p>

<p><a href="https://github.com/sinaptia/ruby_llm-evals">RubyLLM::Evals</a> and <a href="https://github.com/sinaptia/ruby_llm-monitoring">RubyLLM::Monitoring</a> are how we go from concept to production. Both are open source and built for Rails.</p>

<hr />

<p><em>At SINAPTIA, <a href="/posts/building-intelligent-applications-with-rails">we specialize in helping businesses implement AI solutions</a> that deliver real value. If you’re facing challenges with prompt engineering or AI integration, we’d love to help.</em></p>]]></content><author><name>Patricio Mac Adden</name></author><category term="Ruby on Rails" /><category term="AI" /><summary type="html"><![CDATA[Finding the right model and prompt for your AI feature is harder than it looks. Spreadsheets help, until they don't. So we did something about it.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sinaptia.dev/assets/images/logo-black.png" /><media:content medium="image" url="https://sinaptia.dev/assets/images/logo-black.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">AI agents in Ruby: Why is it so easy?</title><link href="https://sinaptia.dev/posts/ai-agents-in-ruby-why-is-it-so-easy/" rel="alternate" type="text/html" title="AI agents in Ruby: Why is it so easy?" /><published>2026-02-09T00:00:00+00:00</published><updated>2026-02-09T00:00:00+00:00</updated><id>https://sinaptia.dev/posts/ai-agents-in-ruby-why-is-it-so-easy</id><content type="html" xml:base="https://sinaptia.dev/posts/ai-agents-in-ruby-why-is-it-so-easy/"><![CDATA[<p>Scott Werner (founder of Sublayer and organizer of <a href="https://www.artificialruby.ai/">Artificial Ruby</a>) told me something that stuck with me:</p>

<blockquote>
  <p><em>“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…”</em></p>
</blockquote>

<p>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 <a href="https://github.com/sinaptia/detritus">Detritus</a>, after Lance Constable Detritus of the Ankh-Morpork City Watch from <a href="https://en.wikipedia.org/wiki/Discworld">Discworld</a>
(thanks for so much and so many, Sir Terry).</p>

<p>Detritus is built in just <strong>250 lines of code</strong>, 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.</p>

<p>While building this basic agent, we confirmed, firsthand, what Scott had said. And I kept wondering:</p>

<p><strong><em>Why?</em></strong>  <strong>What makes it <em>so</em> easy?</strong></p>

<p>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?</p>

<p>After giving it some thought and talking about this with teammates, we converged on two key factors:</p>

<h2 id="the-first-key-general-availability-of-llms">The first key: general availability of LLMs</h2>

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

<p>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.</p>

<p>What used to be a research problem is now an integration problem. The problem migrated from the lab to the workshop.</p>

<h2 id="the-second-key-rubys-power">The second key: Ruby’s power</h2>

<p>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 <em>why</em>.</p>

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

<p>Originally, the idea of “powerful programming languages” came to me via Amir Rajan, creator of DragonRuby, when he shared this article from Paul Graham, <a href="https://paulgraham.com/avg.html">“Beating the averages”</a>. 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.</p>

<p>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 <em>powerfully invested</em> than the other.</p>

<p>I think power in programming languages is not just capability, but <strong>the relation between using the capability and the effort the developer has to invest in wielding it</strong>.</p>

<p>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.</p>

<p>If you take a look at Detritus’ source code, this is how you set up the agent:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">create_chat</span><span class="p">(</span><span class="ss">instructions: </span><span class="n">state</span><span class="p">.</span><span class="nf">instructions</span><span class="p">,</span> <span class="ss">tools: </span><span class="p">[</span><span class="no">EditFile</span><span class="p">,</span> <span class="no">Bash</span><span class="p">,</span> <span class="no">WebSearch</span><span class="p">,</span> <span class="no">SubAgent</span><span class="p">],</span> <span class="ss">persist: </span><span class="kp">true</span><span class="p">)</span>
  <span class="n">chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="o">::</span><span class="no">Chat</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="ss">model: </span><span class="n">state</span><span class="p">.</span><span class="nf">model</span><span class="p">,</span> <span class="ss">provider: </span><span class="vg">$state</span><span class="p">.</span><span class="nf">provider</span><span class="p">)</span>
  <span class="n">chat</span><span class="p">.</span><span class="nf">with_instructions</span><span class="p">(</span><span class="n">instructions</span><span class="p">)</span> <span class="k">if</span> <span class="n">instructions</span>
  <span class="n">chat</span><span class="p">.</span><span class="nf">on_end_message</span> <span class="p">{</span> <span class="o">|</span><span class="n">msg</span><span class="o">|</span> <span class="n">save_chat</span> <span class="p">}</span> <span class="k">if</span> <span class="n">persist</span>
  <span class="n">chat</span><span class="p">.</span><span class="nf">with_tools</span><span class="p">(</span><span class="o">*</span><span class="n">tools</span><span class="p">)</span>
<span class="k">end</span>
</code></pre></div></div>
<p>Five lines of RubyLLM set the model, system prompt, and tools. That’s all you need to set the agentic loop ready to go.</p>

<p>And the rest of the code is the same: chat persistence is <code class="language-plaintext highlighter-rouge">Marshal.dump</code>. The CLI router is a case statement. The subagent is a tool that calls <code class="language-plaintext highlighter-rouge">create_chat</code>. 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.</p>

<p>Detritus’ history started when Thorsten Ball published <a href="https://ampcode.com/notes/how-to-build-an-agent">The Emperor Has No Clothes</a>, 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 <em>this</em> far.</p>

<h2 id="raised-to-the-power">Raised to the power</h2>

<p>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.</p>

<p>When you combine Ruby with LLMs, you get compounding power. Power * Power. Power squared.</p>

<p>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 <em>when</em> to use each one, <em>how</em> to combine them, that’s all the LLM.</p>

<p>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.</p>

<h2 id="the-opportunity">The Opportunity</h2>
<p>The Ruby AI ecosystem is young, but it’s growing fast. <a href="https://github.com/crmne/ruby_llm">RubyLLM</a>, the gem that powers Detritus, is already spawning its own ecosystem: MCP support, <a href="https://github.com/sinaptia/ruby_llm-monitoring">monitoring</a>, 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.</p>

<p>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.</p>

<p>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.</p>

<p>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?</p>]]></content><author><name>Fernando Martinez</name></author><category term="Ruby" /><category term="AI" /><summary type="html"><![CDATA[We found two keys to answer this question while building a full-featured coding agent in just 250 lines of Ruby code.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sinaptia.dev/assets/images/logo-black.png" /><media:content medium="image" url="https://sinaptia.dev/assets/images/logo-black.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">RubyLLM::Instrumentation: The foundation for RubyLLM monitoring</title><link href="https://sinaptia.dev/posts/ruby-llm-instrumentation-the-foundation-for-rubyllm-monitoring/" rel="alternate" type="text/html" title="RubyLLM::Instrumentation: The foundation for RubyLLM monitoring" /><published>2026-01-20T00:00:00+00:00</published><updated>2026-01-20T00:00:00+00:00</updated><id>https://sinaptia.dev/posts/ruby-llm-instrumentation-the-foundation-for-rubyllm-monitoring</id><content type="html" xml:base="https://sinaptia.dev/posts/ruby-llm-instrumentation-the-foundation-for-rubyllm-monitoring/"><![CDATA[<p>In our <a href="/posts/monitoring-llm-usage-in-rails-with-rubyllm-monitoring">last post</a>, we introduced <a href="https://github.com/sinaptia/ruby_llm-monitoring">RubyLLM::Monitoring</a>, 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.</p>

<p>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?</p>

<h2 id="rubyllm-event-handlers">RubyLLM event handlers</h2>

<p>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:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># Provided that you have gemini configured in config/initializers/ruby_llm.rb</span>
<span class="n">chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="p">.</span><span class="nf">chat</span> <span class="ss">provider: </span><span class="s2">"gemini"</span><span class="p">,</span> <span class="ss">model: </span><span class="s2">"gemini-2.5-flash"</span>

<span class="n">chat</span><span class="p">.</span><span class="nf">on_end_message</span> <span class="k">do</span> <span class="o">|</span><span class="n">message</span><span class="o">|</span>
  <span class="no">Event</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span>
    <span class="ss">provider: </span><span class="n">chat</span><span class="p">.</span><span class="nf">model</span><span class="p">.</span><span class="nf">provider</span><span class="p">,</span>
    <span class="ss">model: </span><span class="n">chat</span><span class="p">.</span><span class="nf">model</span><span class="p">.</span><span class="nf">id</span><span class="p">,</span>
    <span class="ss">input_tokens: </span><span class="n">message</span><span class="o">&amp;</span><span class="p">.</span><span class="nf">input_tokens</span> <span class="o">||</span> <span class="mi">0</span><span class="p">,</span>
    <span class="ss">output_tokens: </span><span class="n">message</span><span class="o">&amp;</span><span class="p">.</span><span class="nf">output_tokens</span> <span class="o">||</span> <span class="mi">0</span>
  <span class="p">)</span>
<span class="k">end</span>

<span class="n">response</span> <span class="o">=</span> <span class="n">chat</span><span class="p">.</span><span class="nf">ask</span><span class="p">(</span><span class="s2">"Write a short poem about Ruby"</span><span class="p">)</span>
</code></pre></div></div>

<p>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:</p>

<ul>
  <li>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.</li>
  <li>Your instrumentation code and your business logic are tightly coupled, which makes both harder to maintain.</li>
  <li>This only works for <code class="language-plaintext highlighter-rouge">RubyLLM::Chat</code> instances. What about embeddings, image generation, and other operations? You’d need different mechanisms for each.</li>
  <li>Tracking full request metrics like latency needs more complex and intrusive code.</li>
</ul>

<p>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.</p>

<h2 id="activesupportnotifications">ActiveSupport::Notifications</h2>

<p>ActiveSupport::Notifications is Rails’ instrumentation API. It’s what Rails uses internally to track things like database queries, view rendering, controller executions, and more.</p>

<p>Using it is simple: you make your code emit events by calling <code class="language-plaintext highlighter-rouge">ActiveSupport::Notifications#instrument(...)</code>, and subscribers can consume those events to do logging, monitoring, or whatever else you need. An interesting example is <a href="https://github.com/charkost/prosopite">Prosopite</a>, which hooks into <code class="language-plaintext highlighter-rouge">sql.active_record</code> events to detect N+1 queries.</p>

<p>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.</p>

<p>So, this is what we did in <a href="https://github.com/sinaptia/ruby_llm-instrumentation">RubyLLM::Instrumentation</a> 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.</p>

<h2 id="rubyllminstrumentation">RubyLLM::Instrumentation</h2>

<p>Instrumentation should be automatic and invisible. RubyLLM::Instrumentation achieves that: just add it to your <code class="language-plaintext highlighter-rouge">Gemfile</code>, run <code class="language-plaintext highlighter-rouge">bundle install</code>, and you’re done. RubyLLM will start emitting events for you to subscribe to.</p>

<p>Now, following the example above, the code becomes:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># in config/initializers/ruby_llm.rb</span>
<span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Notifications</span><span class="p">.</span><span class="nf">subscribe</span><span class="p">(</span><span class="sr">/ruby_llm/</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">event</span><span class="o">|</span>
  <span class="c1"># Do whatever you want with the event, in RubyLLM::Monitoring we store the event data in the database for later use</span>
  <span class="no">Event</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span>
    <span class="ss">provider: </span><span class="n">event</span><span class="p">.</span><span class="nf">payload</span><span class="p">[</span><span class="ss">:provider</span><span class="p">],</span>
    <span class="ss">model: </span><span class="n">event</span><span class="p">.</span><span class="nf">payload</span><span class="p">[</span><span class="ss">:model</span><span class="p">],</span>
    <span class="ss">input_tokens: </span><span class="n">event</span><span class="p">.</span><span class="nf">payload</span><span class="p">[</span><span class="ss">:input_tokens</span><span class="p">]</span> <span class="o">||</span> <span class="mi">0</span><span class="p">,</span>
    <span class="ss">output_tokens: </span><span class="n">event</span><span class="p">.</span><span class="nf">payload</span><span class="p">[</span><span class="ss">:output_tokens</span><span class="p">]</span> <span class="o">||</span> <span class="mi">0</span>
  <span class="p">)</span>
<span class="k">end</span>

<span class="c1"># Provided that you have gemini configured in config/initializers/ruby_llm.rb</span>
<span class="n">chat</span> <span class="o">=</span> <span class="no">RubyLLM</span><span class="p">.</span><span class="nf">chat</span> <span class="ss">provider: </span><span class="s2">"gemini"</span><span class="p">,</span> <span class="ss">model: </span><span class="s2">"gemini-2.5-flash"</span>

<span class="c1"># RubyLLM will emit the event, and it'll be captured by the subscriber above</span>
<span class="n">response</span> <span class="o">=</span> <span class="n">chat</span><span class="p">.</span><span class="nf">ask</span><span class="p">(</span><span class="s2">"Write a short poem about Ruby"</span><span class="p">)</span>
</code></pre></div></div>

<p>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.</p>

<p>In the example above, all <code class="language-plaintext highlighter-rouge">ruby_llm</code> events are captured, but you can subscribe to specific events. You can read more about the instrumented events and their payload in the <a href="https://github.com/sinaptia/ruby_llm-instrumentation">project’s repository</a>.</p>

<h2 id="wrapping-up">Wrapping up</h2>

<p>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.</p>

<p>Give it a try, send us feedback, and contribute if you want to!</p>

<hr />

<p>If you’re building AI-powered applications with Rails and need help with architecture, optimization, or observability, <a href="/contact-us/">get in touch</a>.</p>]]></content><author><name>Patricio Mac Adden</name></author><category term="Ruby on Rails" /><category term="AI" /><summary type="html"><![CDATA[While working on RubyLLM::Monitoring, we needed a way to instrument all RubyLLM operations. But we wanted to do it without changing RubyLLM. Read along to know how we did it.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://sinaptia.dev/assets/images/logo-black.png" /><media:content medium="image" url="https://sinaptia.dev/assets/images/logo-black.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>