# Carmine Paolino > I build AI products, frameworks, developer tools, and native apps at Plenty. Creator of RubyLLM, Berlin.rb organizer, and founder of the Floppy Disco music collective. ## About Carmine Paolino is a software engineer, founder, and musician building AI products, frameworks, developer tools, and native apps through Plenty. He created RubyLLM, helps organize Berlin.rb, founded Floppy Disco, makes music as Crimson Lake, and previously co-founded Freshflow. - Role: Software engineer, founder, and musician ## Key Topics - Ruby - Ruby on Rails - Jekyll - AI applications - Large language models - LLM APIs - RubyLLM - Open source software - Async Ruby - Developer tooling - Product engineering - Native desktop applications - Linux - Startups - Music technology - House music ## Core Topics Ruby, Rails, Jekyll, AI applications, LLM APIs, open source, async Ruby, developer tooling, native desktop applications, Linux, product engineering, startups, music, and communities. ## Primary Projects RubyLLM, Chat with Work, Spotifast, ArchSpec, Schematist, MXC for Ruby, devhost, hyprmoncfg, Kamal Backup, ZapFast, Solco, TonePush, Cluster Headache Tracker, Jekyll VitePress, native-packages, fastframe, Omarchy plugins (Calendar for Omarchy, Files for Omarchy, MOZA for Omarchy, OmaStats, OmaTasks for Todoist, Ultimate Guitar Tabs, Lyrics Synced with Music, hyprmoncfg for Omarchy, Active Window + Icon, and Media Controls + Album Art), Omarchy Rescue, Omarchy themes (Plenty, RubyLLM, Chat with Work, and Solco, each in light and dark), Berlin.rb, Crimson Lake, Floppy Disco, and Mindscape Productions. Explore the directory at https://paolino.me/projects/. --- # RubyLLM 2.1: You Can Have It All URL: https://paolino.me/rubyllm-2-1/ Published: 2026-10-08 [RubyLLM](https://rubyllm.com) 2.1 is out. I released it on stage at Deccan Queen on Rails in Pune. With 2.1, RubyLLM does everything LangChain, LangGraph, and LangSmith do together, apart from two things it leaves out on purpose. Several things those frameworks leave to you or to their paid platform, such as per-user MCP OAuth, cancelling a run from another process, and provider batch APIs, are built in. [The comparison](#compared) has the details. There's also a [three-minute tour of 2.1](https://github.com/crmne/ruby_llm/releases/download/v2.1.0/rubyllm-tour.mp4). It builds on [2.0](/rubyllm-2-0/) and adds an MCP client, tool search, typed judgments, evaluations, tool progress, OpenTelemetry tracing, and a nineteenth provider. It's also faster, with no code changes. In this post: - [Compared with LangChain, LangGraph, and LangSmith](#compared) - [MCP client](#mcp) - [Tool search](#tool-search) - [Judgments](#judgments) - [Evaluations](#evaluations) - [Faster by default](#faster) - [Tool progress](#tool-progress) - [OpenTelemetry](#opentelemetry) - [Smaller changes](#smaller-changes) - [Upgrading from 2.0](#upgrading) ## Compared with LangChain, LangGraph, and LangSmith {#compared} RubyLLM 2.1 next to the three Python frameworks combined: | | RubyLLM 2.1 | LangChain + LangGraph + LangSmith | | --- | --- | --- | | Chat, streaming, tools, structured output | ✓ | ✓ | | Images, PDFs, audio as input | ✓ | ✓ | | Embeddings, reranking | ✓ | ✓ | | Image, video, speech, transcription, and OCR in one API | ✓ | Partial: one vendor tool per service | | Agents and the agentic loop | ✓ | ✓ | | MCP client | ✓ | ✓ | | Per-user MCP OAuth on a server | ✓ | Partial: you supply each user's token | | Tool search | ✓ | ✓ | | Approvals that survive a deploy | ✓ | ✓ | | Cancel a run from another process | ✓ | Paid platform (LangSmith Deployment) | | Cost per attempt, in your database | ✓ | Build it yourself (costs live in LangSmith) | | Evaluations, local and in CI | ✓ | ✓ | | Judgment models (Jev, Clef) | ✓ | Build it yourself | | OpenTelemetry | ✓ | ✓ | | Prompt caching, fallbacks | ✓ | ✓ | | Provider batch APIs | ✓ | Build it yourself (`.batch()` runs requests in parallel, at full price) | | MCP servers | Left out on purpose | Paid platform (Agent Server) | | Vector stores | Left out on purpose | ✓ | The LangChain column comes from [their documentation](https://docs.langchain.com). They have more integrations. RubyLLM has one API across nineteen providers, and it fits into Rails: approvals are a controller action and a job, evaluations live in `app/evals`, and costs are rows in your database. The two rows left out belong elsewhere. An MCP server is part of your application, so use the [mcp gem](https://github.com/modelcontextprotocol/ruby-sdk) or [fast-mcp](https://github.com/yjacquin/fast-mcp). For vector search, [neighbor](https://github.com/ankane/neighbor) and pgvector already do it well. ## An MCP Client Where You Decide What the Model Sees {#mcp} In RubyLLM 2.1 an MCP server is a Ruby class you own: it lives in your repo, and it says exactly which tools the model gets. ```ruby class Linear < RubyLLM::MCP url "https://mcp.linear.app/mcp" inputs :user oauth owner: :user only :list_issues, :get_issue, :create_issue # the model sees these three requires_approval :create_issue end chat = RubyLLM.chat.with_mcp(Linear.new(user: current_user)) # one connection per user chat.ask "What's blocking the release?" ``` Each user signs in to the server with their own account. RubyLLM handles OAuth discovery, registration, PKCE, and refresh, and stores each user's credentials encrypted with Active Record encryption. MCP tools that `requires_approval` use the same approval flow as your own tools, so in Rails the job finishes and another one continues after the user approves. The client follows the MCP 2026-07-28 specification, and the official conformance suite runs in CI. To try a server before shaping it, connect from the console. Every tool becomes a Ruby method: ```ruby >> docs = RubyLLM.mcp(url: "https://learn.microsoft.com/api/mcp") >> docs.microsoft_docs_search(query: "Azure Blob Storage").text => "..." ``` The same class can rename tools, rewrite their descriptions, fix arguments, and wrap results, which is how 2.1 follows the advice from April that your agent's [context window is not a junk drawer](/your-agents-context-window-is-not-a-junk-drawer/): prototype with MCP, then craft the tools you control. Before this, [ruby_llm-mcp](https://github.com/patvice/ruby_llm-mcp) by @patvice carried MCP for RubyLLM users for a long time. Thank you. If you use it, remove it before updating, since it defines the same `RubyLLM::MCP` constant. With MCP Apps, a server ships its own interface, such as a chart or a form, and RubyLLM hands it to your app to show next to the tool's results. The [MCP Client guide](https://rubyllm.com/mcp/) covers OAuth, input requests, resources, prompts, MCP Apps, and Tasks. ## Tool Search {#tool-search} A few MCP servers can put hundreds of tool definitions in front of the model on every turn. Defer them, and the model searches for the tools it needs instead: ```ruby chat.with_mcp(GitHub.new(user: current_user), Linear.new(user: current_user), defer: true) # tools stay out of the context chat.ask "Which open issues mention the flaky login spec?" ``` `with_tools` and agents take the same `defer:` option. Anthropic and OpenAI's Responses API search natively, and other providers receive deferred tools as ordinary tools, so the same code runs everywhere. See [Tool Search](https://rubyllm.com/tool-search/). ## Judgments {#judgments} Judgments answer questions about your own data (is this urgent, which team owns it) with probabilities a model measured, instead of a confidence number it wrote as text: ```ruby class TicketTriage < RubyLLM::Judge probability :urgent, "Does this need attention today?" choice :department, "Which team should handle this?" do billing "Payments and refunds" technical "Bugs and integrations" other "Everything else" end score :frustration, "How frustrated is the customer?", ["Calm", "Frustrated", "Angry"] end judgment = TicketTriage.judge("Please refund the duplicate charge today.") judgment.urgent.probability # => 0.96 judgment.department.choice # => :billing ``` `probability`, `choice`, and `score` questions are all asked over the same input in one request, and your code acts on them with thresholds you pick. Judges default to Jev models from TypeSafe, a new built-in provider. Kieran Klaassen added OpenAI's `gpt-6-luna` through the Decisions API ([#1008](https://github.com/crmne/ruby_llm/pull/1008)). To judge on your own machine, run Cloudflare's Clef through Ollama. See the [Judgments guide](https://rubyllm.com/judgments/). ## Evaluations {#evaluations} An evaluation runs your agent on cases with known answers and has a model grade each response, so you can tell whether a prompt or model change made things better. Cases live in a YAML file next to the class, and `bin/rails "ruby_llm:eval[DocsEvaluation]"` runs them and exits non-zero on failure, so it works in CI: ```ruby class DocsEvaluation < RubyLLM::Evaluation evaluation :correctness # keeps the default check evaluation :grounded, "Every claim is supported by the documents in metadata" evaluation :cites_sources, "The answer links to at least one document" def perform(question) DocsAgent.new.ask(question) end end ``` Evaluations can also grade tool calls with plain Ruby assertions, use an Agent or a Judge as the grader, and run as RSpec or Minitest tests. See the [Evaluations guide](https://rubyllm.com/evaluations/). ## Faster by Default {#faster} 2.1 does less work on every call, with no code changes. RubyLLM's own work, 2.0.0 against 2.1 on the same machine: | Workload | 2.0 | 2.1 | | --- | --- | --- | | Stream a 2 MB event that arrives in 16 KB pieces | 116 ms | 2.0 ms | | Stream 500 Perplexity chunks that each cite 20 sources | 137 ms | 17 ms | | Ask a Bedrock chat with 200 messages of history | 2.9 ms | 0.40 ms | | Memory kept by a streamed 40-turn chat with a 256 KB image | 28 MB | 0.48 MB | | Eight threads loading the model registry at once | 273 ms | 32 ms | Connections are now shared across calls, threads, and fibers. To keep them open, pick a persistent adapter (from the `faraday-net_http_persistent` gem): ```ruby config.faraday_adapter = :net_http_persistent # keeps connections open ``` The benchmarks need no API keys: clone RubyLLM and run `bundle exec rake "benchmark:compare[v2.0.0]"` to compare any version with your checkout. The [connection guide](https://rubyllm.com/configuration-connection/#connection-reuse) covers adapters. ## Tools That Report Progress {#tool-progress} A slow tool can now tell your user what it's doing instead of leaving them with a spinner: ```ruby class ReadReport < RubyLLM::Tool def execute(url:) pages = Scanner.pages(url) pages.each_with_index.map do |page, index| progress "Reading page #{index + 1} of #{pages.size}", value: index + 1, total: pages.size # report progress page.text end.join("\n") end end chat.with_tools(ReadReport).after_tool_progress do |tool_call, progress| # receives every update puts "#{tool_call.name}: #{progress.message}" end ``` MCP tools report through the same callback. See [Reporting Progress](https://rubyllm.com/tool-execution/#reporting-progress). ## OpenTelemetry Tracing {#opentelemetry} One line sends every model call, tool run, and workflow as a span to the tracing backend you already use: ```ruby RubyLLM::OpenTelemetry.enable ``` Spans join the current trace, so the HTTP call your tool makes nests under that tool. They carry metadata such as models, tokens, and tool names, and never prompts, responses, or tool arguments. The [OpenTelemetry guide](https://rubyllm.com/opentelemetry/) has the full span reference. ## Smaller Changes {#smaller-changes} - **Hetzner** is provider number nineteen: `RubyLLM.chat(model: "Qwen3.8-27B", provider: :hetzner)`. - **Switching providers mid-conversation** works even in the middle of a tool round: each model receives only the content it can read. - **Files a provider can't read**, such as Word documents, go through `convert_unsupported_attachments`, which sends your converted copy while the conversation keeps the original. - **Cache lifetimes per boundary**: `cache_until_here(ttl: "1h")` keeps a stable prefix cached longer than the rest of the chat. - **Prompt partials**: prompt templates `render` shared pieces the way Action View does. - **Per-tenant agents**: a `context` block can use the agent's inputs, so each workspace can bring its own API key. Thanks to mikemikimike (#903). - **A secondary database** can hold RubyLLM's supporting records next to your chats. - **Usage beyond chats**: in Rails, embeddings, transcriptions, and other one-shot calls go into the usage ledger too. - **`error.request_shape`** lists every turn's parts and sizes when a provider rejects a request. - **Provider uploads are reused** across processes in Rails, so a large PDF isn't uploaded again on every job. - **Perplexity chat runs on the Agent API**, since Sonar retires. - **Ruby 3.2 or later** is required, since 3.1 reached end of life. - **JSON 3 is allowed**, thanks to Filipe Kalicki (#968), who also made model lookups use an index (#981). ## Upgrading from 2.0 {#upgrading} ```ruby gem "ruby_llm", "~> 2.1.0" ``` ```bash bundle update ruby_llm bin/rails generate ruby_llm:upgrade bin/rails db:encryption:init # only for MCP OAuth, if your app has no encryption keys yet bin/rails db:migrate ``` Most 2.0 apps need no code changes. The few that do are listed in the [upgrade guide](https://rubyllm.com/upgrading/), and everything new is in [What's New in 2.1](https://rubyllm.com/whats-new-in-2-1/). Thanks to everyone who sent code for this release: Andrii Furmanets, Andrey Samsonov, Andy Wang, Anton Kopylov, Filipe Kalicki, Guilherme Lages Santos, Islam Gagiev, Kieran Klaassen, Marc Köhlbrugge, mikemikimike, Mikhail Topolskiy, Muhammad Zain Ul Abidin, Paul Arterburn, and Viktor Schmidt. --- # RubyLLM 2.0: Tool Approval, Video, Speech, Citations, and Cost Tracking URL: https://paolino.me/rubyllm-2-0/ Published: 2026-10-05 RubyLLM 2.0 is the biggest release since 1.0. It starts with tool approval: ```ruby class RefundOrder < RubyLLM::Tool description "Refunds an order" parameter :order_id, description: "ID of the order to refund" requires_approval # that's all it takes def execute(order_id:, tool_call: nil) Refunds.issue(order_id:, idempotency_key: tool_call.id) end end class SupportAgent < RubyLLM::Agent chat_model Chat tools RefundOrder end chat = SupportAgent.create! chat.ask "I was charged twice for order 42." # stops before refunding # later, when the user approves chat = SupportAgent.find(chat.id) chat.approve(chat.pending_approvals.first) chat.complete # issues the refund and answers ``` The pending call is saved in your database, so the approval can come tomorrow, from another process, after two deploys. 2.0 also generates video and speech, reads scanned documents, reranks search results, runs provider-hosted tools with typed citations, accounts for every provider attempt (including the ones that failed), and works across seventeen providers. There's also a [two-minute tour of 2.0](https://github.com/crmne/ruby_llm/releases/download/v2.0.0/rubyllm-tour.mp4). Each section below shows one feature and links to its guide. - [Tool approval and durable agents](#tool-approval-and-durable-agents) - [The agentic loop](#the-agentic-loop) - [New operations: video, OCR, reranking, tokens](#new-operations) - [Speech and transcription](#speech-and-transcription) - [Provider tools](#provider-tools) - [Citations](#citations) - [Files and attachments](#files-and-attachments) - [Cost and usage](#cost-and-usage) - [Fallbacks, cancellation, and errors](#fallbacks-cancellation-and-errors) - [Prompt caching](#prompt-caching) - [Batches](#batches) - [Owning the transcript](#owning-the-transcript) - [Providers and protocols](#providers-and-protocols) - [Rails table ownership](#rails-table-ownership) - [Prompt templates](#prompt-templates) - [Workflows and instrumentation](#workflows-and-instrumentation) - [Coding assistant skill](#coding-assistant-skill) - [Upgrading from 1.16](#upgrading) - [Get it](#get-it) ## Tool Approval and Durable Agents Declare `requires_approval` on a tool and the loop stops before running it. `chat.pending_approvals` lists the waiting calls with their arguments, and `approve` or `deny` decides. With `acts_as_chat`, the call and its decision are database rows, so the approval can come from any process: ```ruby class ApprovalsController < ApplicationController def create chat = Chat.find(params[:chat_id]) chat.approve(params[:tool_call_id]) # or chat.deny(...) CompleteJob.perform_later(chat.id) end end class CompleteJob < ApplicationJob def perform(chat_id) SupportAgent.find(chat_id).complete # resumes where it stopped end end ``` There's no workflow engine and no state machine to serialize: the transcript holds the state. The [tool approval guide](https://rubyllm.com/tool-execution/#requiring-approval) and [durable agents guide](https://rubyllm.com/durable-agents/) cover the full flow. ## The Agentic Loop `ask` still runs the whole agentic loop for you. When you want control, 2.0 breaks it into verbs (`ask_later`, `generate`, `run_tools`, `step`, `complete`, `cancel`), and each one reads the persisted messages to decide what to do next, so you can run one step per job: ```ruby chat = RubyLLM.chat.with_tools(SearchDocs).ask_later("How do I configure webhooks?") chat.step until chat.complete? || chat.awaiting_approval? ``` `halt` and `RubyLLM::Tool::Halt` are gone: your loop decides when to stop. I wrote about this in detail in [The Agentic Loop, Exposed](/rubyllm-2-0-agentic-loop/). ## New Operations 2.0 adds video, speech, OCR, reranking, multimodal embeddings, and tokenization. None of them needs a chat: ```ruby RubyLLM.animate("A red panda typing Ruby code").save("panda.mp4") RubyLLM.speak("Welcome to the Ruby study group.").save("welcome.mp3") RubyLLM.ocr("scanned-contract.pdf").markdown RubyLLM.rerank("How do I reset my password?", documents, model: "rerank-v3.5") RubyLLM.embed("A red panda", with: "panda.jpg", model: "gemini-embedding-2") RubyLLM.tokenize("Hello, Ruby!", model: "grok-4.3").ids ``` Each one has a guide: [video](https://rubyllm.com/video-generation/), [OCR](https://rubyllm.com/ocr/), [reranking](https://rubyllm.com/rerank/), [embeddings](https://rubyllm.com/embeddings/), and [tokenization](https://rubyllm.com/tokenization/). ## Speech and Transcription 2.0 adds text to speech with `RubyLLM.speak`, and transcription gains streaming, speaker labels, and word timestamps. Together they let a Rails action answer a voice message with audio: ```ruby def create audio = params[:audio] return head :bad_request unless audio.is_a?(ActionDispatch::Http::UploadedFile) question = RubyLLM.transcribe(audio).text answer = Current.user.support_chat.ask(question).content speech = RubyLLM.speak(answer) # text to speech send_data speech.to_blob, type: speech.mime_type, disposition: "inline" end ``` See the [text to speech guide](https://rubyllm.com/text-to-speech/) and the [transcription guide](https://rubyllm.com/audio-transcription/). ## Provider Tools `with_provider_tools` turns on tools the provider runs on its own infrastructure, like web search, code execution, file search, and remote MCP servers. The aliases are portable, so you can switch models without changing the code: ```ruby chat = RubyLLM.chat(model: "claude-sonnet-5").with_provider_tools(:web_search) # runs on Anthropic's side response = chat.ask "What changed in the latest Ruby release?" response.citations.each { |citation| puts "#{citation.title}: #{citation.url}" } ``` When OpenAI or Azure calls a tool on a remote MCP server, the call can wait in the same `pending_approvals` list as your Ruby tools. `RubyLLM.research_later` starts a hosted deep research agent and returns a job ID you can check from any process. The [provider tools guide](https://rubyllm.com/provider-tools/) and the [hosted research guide](https://rubyllm.com/hosted-research/) have the details. ## Citations Every provider formats citations differently. RubyLLM turns them all into `RubyLLM::Citation`, from documents and from web search, and your own RAG tools can return `RubyLLM::SearchResults` to make their results citable: ```ruby chat = RubyLLM.chat(model: "claude-sonnet-5").with_citations # document citations response = chat.ask "What are the termination conditions?", with: "contract.pdf" response.citations.each do |citation| puts "p. #{citation.start_page}: #{citation.cited_text}" end ``` The [citations guide](https://rubyllm.com/citations/) has a footnote renderer you can copy. ## Files and Attachments `RubyLLM.upload` sends a file to the provider once, and `with:` takes the result like a path. Files too big to send inline are uploaded for you, and tools can now return files too: ```ruby manual = RubyLLM.upload("manual.pdf", provider: :anthropic) # upload once chat = RubyLLM.chat(model: "claude-sonnet-5") chat.ask "Summarize the manual.", with: manual chat.ask "What does the warranty cover?" ``` See the [files guide](https://rubyllm.com/files/) and the [attachments guide](https://rubyllm.com/attachments/). ## Cost and Usage 2.0 records tokens and cost per provider attempt, because providers bill attempts: retries, fallbacks, and cancelled streams all count. In Rails, each attempt is a row in `ruby_llm_usages`, and an unknown price is `nil`, not zero: ```ruby chat = RubyLLM.chat.with_fallbacks("claude-sonnet-5") response = chat.ask "Explain Ruby fibers in one paragraph." response.tokens.input response.cost.total chat.cost.total # every attempt, including the ones that failed ``` The [Tokens and Costs guide](https://rubyllm.com/cost-and-usage-tracking/) explains what each total includes. ## Fallbacks, Cancellation, and Errors `with_fallbacks` retries a failed request on backup models, in order, with the same conversation, tools, and settings. `chat.cancel` stops a run, and in Rails it works from another process through the database, so a stop button can end a streaming job. Agents declare error handlers once with `rescue_from`. ```ruby chat = RubyLLM.chat(model: "claude-sonnet-5") .with_fallbacks("gpt-5.6", "gemini-3.7-flash") # tried in order if Claude fails response = chat.ask "Summarize this incident report." ``` The [error handling guide](https://rubyllm.com/error-handling/) covers fallbacks and errors, and [Rails streaming](https://rubyllm.com/rails-streaming/#cancelling-a-background-stream) covers the stop button. ## Prompt Caching `with_caching` gives prompt caching one API across providers, and `cache_until_here` marks an explicit cache boundary when you know where your reusable prefix ends: ```ruby chat = RubyLLM.chat(model: "claude-sonnet-5").with_caching(ttl: "1h") chat.with_instructions(analysis_prompt).cache_until_here # cache boundary chat.add_message(role: :user, content: contract_text).cache_until_here chat.ask "Does clause 14 conflict with the termination terms in clause 3?" chat.ask "Which clauses mention liability caps?" ``` The [prompt caching guide](https://rubyllm.com/prompt-caching/) has the details. ## Batches Batch APIs cost less than interactive requests. `RubyLLM.batch` submits ordinary chats to them, and in Rails the answers are saved through the same callbacks as a normal `ask`: ```ruby chats = tickets.map do |ticket| RubyLLM.chat(model: "claude-haiku-4-5") .with_instructions("Classify this ticket as billing, bug, or feature request.") .ask_later(ticket.body) end batch = RubyLLM.batch(chats) # submits them all at once batch.id # save this ``` Later, `RubyLLM::Batch.find` picks the batch up from any process. The [batch guide](https://rubyllm.com/batches/) covers polling, results, and setup. ## Owning the Transcript `chat.messages = messages_for_model` replaces the history you send, so you can summarize old turns or redact values, and `with_compaction` lets providers that support it compact long conversations themselves. `before_request` edits the final payload, and `render` shows it without calling the model: ```ruby chat = RubyLLM.chat(model: "gpt-5.6") .ask_later("Summarize the changes.") chat.before_request do |payload| payload[:metadata] = { review_id: "review-42" } end chat.render[:metadata] # => { review_id: "review-42" } chat.complete ``` Details are in the [request control guide](https://rubyllm.com/chat-request-control/) and the [persistence guide](https://rubyllm.com/rails-persistence/#separate-user-and-llm-transcripts). ## Providers and Protocols Cohere, Ollama Cloud, ElevenLabs, and Deepgram join, bringing the total to seventeen, and built-in support went from 170 provider-feature pairs to 395 ([coverage matrix](https://rubyllm.com/provider-coverage/)). Providers and protocols are now separate: a provider is the service you connect to, a protocol is the API it speaks. ```ruby RubyLLM.chat(model: 'gpt-5.4') # OpenAI Responses API RubyLLM.chat(model: 'gpt-5.4', protocol: :chat_completions) # same model, old API RubyLLM.chat(model: 'claude-opus-4-6', provider: :vertexai) # Vertex AI, Anthropic protocol ``` If the provider you need isn't built in, `ruby_llm provider-gem Acme` generates a gem for it, with tests, CI, and a model catalog. I covered the design in [Providers, Protocols, and Provider Gems](/rubyllm-2-0-providers-and-protocols/). ## Rails Table Ownership A fresh install now gives you two models instead of four. Chats and messages stay yours, and RubyLLM keeps models, tool calls, usage, and batches in its own `ruby_llm_` tables, the way Active Storage owns its records. The record classes are internal and the readers are public, so RubyLLM can change its storage without your app noticing: ```ruby # app/models/chat.rb class Chat < ApplicationRecord acts_as_chat belongs_to :user end # app/models/message.rb class Message < ApplicationRecord acts_as_message has_many_attached :attachments end ``` The [Rails persistence guide](https://rubyllm.com/rails-persistence/) shows every association and reader. ## Prompt Templates Prompts get a directory the way views do, `app/prompts`, and `RubyLLM.render_prompt` renders them. Agents pick up their instructions from it by convention, and Rails engines can ship their own prompts. Thanks to [@kryzhovnik](https://github.com/kryzhovnik), who extracted the renderer out of Agent's private methods, which made this possible. ```ruby instructions = RubyLLM.render_prompt( # renders app/prompts/support/instructions.txt.erb "support/instructions", product_name: "BillingHub", customer_name: current_user.name ) ``` The [prompt rendering guide](https://rubyllm.com/prompt-rendering/) has the details. ## Workflows and Instrumentation `RubyLLM.workflow` names a piece of work and tags every event inside it, so model calls, tool calls, and costs trace back to the run that caused them. Control flow stays plain Ruby, with no graph DSL. Instrumentation grew from five events to twenty. ```ruby RubyLLM.workflow("Write article", id: "article-42") do |workflow| # tags every event inside notes = workflow.step("Research") do ResearchAgent.new.ask(topic).content end workflow.step("Draft") do WriterAgent.new.ask(notes).content end end ``` The [instrumentation guide](https://rubyllm.com/instrumentation/) lists every event and payload field. ## Coding Assistant Skill The gem ships a RubyLLM skill for coding assistants, maintained alongside the code and guides. Install the copy that matches your bundle: ```bash npx skills add "$(bundle show ruby_llm)" --skill rubyllm ``` Your assistant stops writing 1.x method names from its training data and stops rebuilding things RubyLLM already does. [Setup details](https://rubyllm.com/ai-coding-assistants/). ## Upgrading Upgrade from 1.16; if you're on something older, step through the minor releases first. Pin the 2.0 series, because the 1.16 upgrade generator ships with 2.0 only, and finish this upgrade, cleanup included, before you move to 2.1: ```ruby gem "ruby_llm", "~> 2.0.0" ``` Most code changes are renames to fully spelled-out names, such as `desc` to `description`, `param` to `parameter`, and `with_params` to `with_provider_options`. For Rails persistence, `bin/rails generate ruby_llm:upgrade` writes the migrations. Rename mode, the default, moves your existing tables into RubyLLM's ownership while AI activity is paused. Copy mode (`--mode copy`) builds the 2.0 tables next to the old ones while 1.16 keeps serving traffic, and keeps a way back to 1.16. The [complete 2.0 upgrade guide](https://github.com/crmne/ruby_llm/blob/v2.0.0/docs/_reference/upgrading.md) has every rename and the full procedure. ## Get It ```bash bundle add ruby_llm ``` [What's New in 2.0](https://rubyllm.com/whats-new-in-2-0/) has working examples of everything above, the [upgrade guide](https://github.com/crmne/ruby_llm/blob/v2.0.0/docs/_reference/upgrading.md) walks a Rails app from 1.16, and the [documentation](https://rubyllm.com) covers the rest. Thanks to everyone who tested the release candidates, filed issues, and sent code, including 22 people who made their first contribution during this cycle. [RubyLLM 2.1](/rubyllm-2-1/) followed on October 8, at Deccan Queen on Rails in Pune. --- # Berlin.rb: A New Monthly Ruby Meetup in Berlin URL: https://paolino.me/berlin-rb/ Published: 2026-09-02 [Berlin.rb](https://berlinrb.org) is a new monthly meetup for people who write Ruby, or are curious about it. A few of us from Berlin's Ruby community are organizing talks, project show-and-tells, and time to meet other people who work in the same language. It's free and open to anyone, whether you've been writing Ruby for fifteen years or installed it last week. **The first one is Tuesday, September 8, 18:45, at Potsdamer Straße 121. Use the Grover entrance next to Rossmann and head to the third floor.** The evening is hosted and sponsored by [ablefy](https://ablefy.io), with drinks and snacks provided. After that, the second Tuesday of every month. [Register here.](https://luma.com/lnmj7085) If you can't make it to Berlin, there's a [livestream](https://luma.com/psvh08jx) and it'll be on [YouTube](https://www.youtube.com/@berlinrb_org) afterwards. ## The first meetup I'm giving the first talk anywhere on [RubyLLM](https://rubyllm.com) 2.0: **RubyLLM 2.0: Beyond Agents**. **Paweł Strzałkowski**, CTO at Visuality, is giving the second: **HiFuMi: The Story of Building an Online Rails Apps Generator**. ## Why another one Berlin already has [RUG::B](https://www.rug-b.de), a long-running Ruby user group. Berlin is big enough for both, and more Ruby events are a good thing. Berlin.rb adds a predictable rhythm: second Tuesday, every month. Put it in your calendar once and stop checking. ## Bigger than Berlin We don't want this to be just a room in Berlin. 1. We're livestreaming the meetup and publishing the talks on [YouTube](https://www.youtube.com/@berlinrb_org) afterwards, so joining Berlin.rb doesn't require being in Berlin. 2. Berlin.rb is part of [Ruby Europe](https://rubyeurope.com), the umbrella bringing together Ruby communities across the continent and moving speakers between cities. ## Come talk We want speakers! A deep technical talk, a lightning talk about something you built last weekend, a story about something that went badly in production. If you've never given a talk before, this is a good place for the first one. Send a sentence or two to [hello@berlinrb.org](mailto:hello@berlinrb.org) and we'll take it from there. ## Host or sponsor us We want to move around the city. Different neighbourhoods, different offices, so it's easy to reach wherever you happen to live, and so you get to see where other Berlin Ruby teams actually work. If your company has a space free on a weekday evening, or wants to cover food and drinks, we'd love to hear from you. Sponsorship is what keeps this free and open to everyone, and hosting is the easiest way to put your team in front of Berlin's Ruby community. [hello@berlinrb.org](mailto:hello@berlinrb.org) ## The people Berlin.rb is put together by a group of us from around Berlin's Ruby community. The current organizers are listed on [berlinrb.org](https://berlinrb.org/#faq). [berlinrb.org](https://berlinrb.org) · [Luma](https://luma.com/berlinrb) · [YouTube](https://www.youtube.com/@berlinrb_org) · [X](https://x.com/berlinrb_org) · [Bluesky](https://bsky.app/profile/berlinrb.bsky.social) · [LinkedIn](https://www.linkedin.com/company/berlinrb) See you on the 8th. --- # RubyLLM 2.0: The Agentic Loop, Exposed URL: https://paolino.me/rubyllm-2-0-agentic-loop/ Published: 2026-08-28 Strip any agent framework down and you find the same loop: call the model, run the tools it asked for, call the model again, stop when it answers without wanting a tool. RubyLLM has run that loop inside `ask` since 1.0. In 2.0, you can also control each step. ```ruby # Run the agentic loop automatically response = RubyLLM.chat(model: "claude-sonnet-4-6") .with_tools(Weather) .ask("What's the weather in Paris?") # => # "Here's the current weather in **Paris, France**:\n\n- 🌡️ **Tempera... ``` `ask` still runs the loop for you, stopping when the answer is ready or a tool needs human approval. You can also call each part yourself: * `ask_later` stages your message without sending anything. * `generate` makes one model call and appends the response. * `run_tools` executes pending tool calls and appends their results without calling the model. * `step` runs pending tools if any are unanswered, otherwise it calls the model. * `complete?` tells you when the conversation is settled: the model answered without calling a tool. * `complete` steps until done or awaiting approval. `ask` is `ask_later` followed by `complete`. This lets you set an iteration budget, batch the next generation, wait for approval, or save progress and continue in another job. Your code can check what happened between calls. ## One Step Per Job Each verb decides what to do next by reading the persisted messages. That means the loop doesn't need to live in one process, or one machine, or one deploy: ```ruby class AgentTurnJob < ApplicationJob def perform(chat_id) chat = Chat.find(chat_id).with_tools(Weather) chat.step AgentTurnJob.perform_later(chat_id) unless chat.complete? || chat.awaiting_approval? end end ``` Each step gets its own job and retry boundary. A long sequence can release the worker between steps; an individual provider request or tool still takes as long as it takes. The loop is resumable mid-tool-round too. `run_tools` skips calls whose results have been saved. If a process dies after saving one result out of three, reloading the chat and calling `step` executes only the remaining two. If it dies after an external action succeeds but before saving its result, the tool can run again. Use `tool_call.id` as an idempotency key for writes. On Rails 8.1 and later, you can use ActiveJob Continuations to build on this: checkpoint after each move and an agent run survives a redeploy, resuming from the persisted messages with no cursor to manage. Batches are the same idea at scale: a batch is `generate` deferred for many chats at once, with `run_tools` run locally between rounds. ## Cancelable generation `chat.cancel` cancels a run from another thread. At the next checkpoint, before a model call, before a tool executes, or between streamed chunks, the run raises `RubyLLM::CancelledError` and clears the flag so the chat can be reused. In Rails, `acts_as_chat` stores the cancellation request on the chat record, so the signal travels through the database. A stop button in your web process halts a background job mid-stream: ```ruby class ChatsController < ApplicationController def cancel current_user.chats.find(params[:id]).cancel head :no_content end end ``` The job checks the chat record at cancellation checkpoints. It cannot interrupt arbitrary Ruby code inside a running tool; the next checkpoint observes the request. ## Halt Is Gone RubyLLM 1.x let a tool terminate the loop from the inside: return `halt("done")` and the conversation ended. That put control flow inside a return value, and it's gone in 2.0, along with `RubyLLM::Tool::Halt`. Tools return results. Stopping belongs to the caller: ```ruby until chat.complete? || chat.awaiting_approval? chat.step break if handed_off? # application-specific stopping condition end ``` If what you want is one tool call per model response rather than a condition, `chat.with_tool_options(calls: :one)` does that. For a total round budget, count `step` or `generate` calls in the loop you control. The full guide, including the workflow patterns built on these verbs, is at https://rubyllm.com/agentic-workflows/. --- # RubyLLM 2.0: Providers, Protocols, and Provider Gems URL: https://paolino.me/rubyllm-2-0-providers-and-protocols/ Published: 2026-08-27 RubyLLM 2.0 is almost ready. It isn't out yet, but it will be soon, and I have been looking forward to showing you what is in it. There is a lot in this release. Too much for one enormous announcement, and most of it deserves more than a bullet point. So this is the first in a series of posts about what's coming in RubyLLM 2.0. Let's start with providers and protocols. In 2.0, OpenAI uses the Responses API by default. Providers and protocols are separate things. Four new providers bring the total to seventeen. And if the provider you need is still missing, a new generator gives you a complete provider gem to start from. ```ruby RubyLLM.chat(model: 'gpt-5.4') # OpenAI Responses API RubyLLM.chat(model: 'gpt-5.4', protocol: :chat_completions) # same model, old API RubyLLM.chat(model: 'claude-opus-4-6', provider: :vertexai) # Vertex AI, Anthropic protocol ``` ## A provider is not a protocol A provider is the service you connect to: OpenAI, Mistral, Vertex AI, or one of the others. It knows the host, credentials, configuration, and model catalog. A protocol is the API it speaks: Chat Completions, Responses, Anthropic, Gemini, Bedrock Converse, or Cohere. It knows how to build a request, parse the response, and handle streaming. Mistral, DeepSeek, Perplexity, Ollama, and most self-hosted services all speak some version of OpenAI's Chat Completions API. In 1.x, I handled that with inheritance. `Mistral < OpenAI` reused the request code, but it also inherited a pile of OpenAI assumptions and then had to undo the ones that did not apply. That was only half the problem. One protocol can be used by many providers, but one provider can also speak many protocols. OpenAI speaks Chat Completions and Responses; Vertex AI speaks Gemini, Claude, Mistral, and open models, all through different API shapes. The old design had no good way to express that. In 2.0, providers register the protocols they speak and choose one for each model. Here is the chat-routing part of the Vertex AI provider, leaving its other operations out: ```ruby class VertexAI < Provider protocol :gemini, VertexAI::Gemini protocol :anthropic, VertexAI::Anthropic protocol :mistral, VertexAI::Mistral protocol :chat_completions, VertexAI::ChatCompletions def protocol_for(model, **) case model.id when %r{/} then protocols[:chat_completions] when /\Aclaude/ then protocols[:anthropic] when VertexAI::Mistral::MODELS then protocols[:mistral] else super end end end ``` So Claude on Vertex AI speaks Anthropic. `meta/llama-3.3-70b-instruct-maas` speaks Chat Completions. Gemini speaks Gemini. The model changes, the wire format changes, and your application does not. ## OpenAI uses Responses by default OpenAI now has two chat APIs. RubyLLM 2.0 supports both, but defaults to Responses. Responses gives RubyLLM access to encrypted reasoning content, provider-run tools, and newer OpenAI features. The application can use those features through the same chat API. If you still need Chat Completions, choose it for one chat: ```ruby chat = RubyLLM.chat(model: 'gpt-5.4', protocol: :chat_completions) ``` Or keep it as the default for OpenAI: ```ruby RubyLLM.configure do |config| config.openai_protocol = :chat_completions end ``` An explicit `protocol:` wins over configuration, and configuration wins over the provider default. The same protocol code is useful outside OpenAI. xAI's primary API now looks like Responses, so its RubyLLM provider gets streaming, encrypted reasoning, and server tools from the same implementation. Azure and DeepSeek can opt into Responses too. ## One Bedrock endpoint, three APIs AWS Bedrock Mantle makes the difference between a provider and a protocol very obvious. You connect to one AWS service, but the API changes with the model. Claude uses Anthropic's Messages API. Other models use OpenAI's Responses or Chat Completions formats. They share the same catalog, authentication, and host; the model metadata tells RubyLLM which protocol to use. Even the model catalogs disagree. Mantle spells some model IDs differently from Bedrock Converse and includes models that Converse does not know about at all. Without protocol routing, switching models can also mean switching request formats. RubyLLM reads both catalogs, records which protocol each model speaks, and routes the request for you. The `RubyLLM.chat` API stays the same. You can even switch models in the middle of a conversation with `.with_model`, and RubyLLM switches protocols with it. ## Four new providers RubyLLM 2.0 will ship with seventeen providers, up from thirteen in 1.16: * **Cohere** with native chat, embeddings, reranking, and transcription. * **Ollama Cloud** with Ollama's API against its hosted model catalog. No local server required. * **ElevenLabs** for speech, transcription, and image and video generation. * **Deepgram** for speech and transcription. ## A complete provider in one small file Once the protocol handles the wire format, a provider can be very small. Here is the entire Mistral provider in RubyLLM 2.0: ```ruby module RubyLLM module Providers class Mistral < Provider protocol :chat_completions, ChatCompletions, batches: Mistral::ChatCompletions::Batches protocol :conversations, Conversations protocol :files, Protocols::Mistral::Files def protocol_for(model, operation: nil, **) return Conversations if operation == :paint super end def api_base @config.mistral_api_base || 'https://api.mistral.ai/v1' end def headers { 'Authorization' => "Bearer #{@config.mistral_api_key}" } end def batch_cost_multiplier(**) = 0.5 class << self def capabilities Mistral::Capabilities end def models_dev_alias(...) Mistral::Models.models_dev_alias(...) end def configuration_options %i[mistral_api_key mistral_api_base] end def configuration_requirements %i[mistral_api_key] end end end end end ``` That's the whole file. Request formatting and parsing live in the protocol classes, so this adapter can concentrate on endpoints, authentication, and choosing the right protocol. ## Build the next provider yourself The provider and protocol split pays off outside RubyLLM too. The most common feature request is another provider. In 2.0, you do not have to wait for me to add one. ```bash ruby_llm provider-gem Acme --api-base https://api.acme.ai/v1 ``` That command creates `ruby_llm-providers-acme`, initializes Git, installs the bundle, and gives you an ignored `.env`, provider registration, a model-catalog task, live-recording specs, RuboCop, Flay, ArchSpec, and CI across every supported Ruby version. It gets the same kind of care as RubyLLM itself, already wired up. The important part is what you do not have to build. If Acme speaks Chat Completions, it reuses RubyLLM's existing protocol for requests, responses, streaming, tools, and errors. If it speaks another familiar API, pass `--dialect responses`, `anthropic`, `gemini`, `converse`, or `ollama`. The provider only needs to supply its host, authentication, model catalog, and any real quirks it has. That is what the split makes possible. A new or smaller provider no longer needs another OpenAI-compatible implementation, another Anthropic-compatible implementation, or a growing pile of API-base switches and provider exceptions inside RubyLLM. It gets to reuse the protocol and remain a small adapter. When it is ready, tell your users to add it to their Gemfile: ```ruby gem 'ruby_llm-providers-acme', require: 'ruby_llm/providers/acme' ``` With their API key configured, that is it. The gem registers itself, brings its model catalog, and works through the same `RubyLLM.chat` API as a built-in provider. Once 2.0 is out, go ahead and try it yourself. I'll save the complete tutorial for another post. ## A model registry applications can rely on The model registry that RubyLLM uses for capabilities and pricing is now published at [rubyllm.com/models.json](https://rubyllm.com/models.json). For the first time, anyone can download, inspect, or build on the same catalog RubyLLM uses itself. [models.dev](https://models.dev) is an excellent source, but RubyLLM needs more than a copy of it. Every six hours, RubyLLM rebuilds its registry from models.dev and the providers' own APIs, reconciles aliases, fills gaps, applies the few provider-specific corrections that remain, validates the result, and refuses suspicious regressions before publishing it. RubyLLM applications use the same registry to validate model names, choose protocols, check capabilities, and price provider usage. That last part demands precision: if a price, modality, or capability is wrong, the answer your application gets is wrong too. I will cover the cost ledger in another post, but the registry is what makes it possible. ```ruby RubyLLM.models.refresh ``` `refresh` fetches the latest main registry and persists it. New models, prices, context windows, and capabilities can reach your application without waiting for the next gem release. Provider gems can ship their own `models.json` too. RubyLLM loads it as a read-only fallback behind the main registry, so installing a provider gem is enough to use its models normally. The provider gem's models appear in `RubyLLM.models` and can be selected with the normal `model:` and `provider:` keywords. The global `refresh` never refreshes or rewrites provider gem catalogs. Their authors update them with `rake models` inside the provider gem. The important part is that none of this makes the public API more complicated. `RubyLLM.chat`, `embed`, `paint`, and the Rails integration still work the same way. Most applications get broader provider coverage through the APIs they already use. The new `protocol:` option is there for the times when you want to choose. That is the first piece of RubyLLM 2.0. Next up: the agentic loop, and how 2.0 lets you stop it, resume it, and run it one step at a time. The full guide to writing providers and protocols is at [rubyllm.com/custom-providers](https://rubyllm.com/custom-providers/). --- # kamal-backup 1.0: A Backup Is Only Real After You Restore It URL: https://paolino.me/kamal-backup-1-0/ Published: 2026-08-27 I released [kamal-backup](https://kamal-backup.dev) 1.0 today. Not because the backup command works. That part worked months ago. I called it 1.0 because I used it to move [Chat with Work](https://chatwithwork.com), the application that pays my bills, to a new Hetzner instance. The old host took the backup. The new host restored it under a temporary hostname. I opened the real application, checked the real data and files, and only then moved DNS. A backup is only real after you restore it. ## The migration that made 1.0 The move found two bugs that a green backup log never could. First, a fresh backup accessory on the new host could start its schedule before the restore and write a perfectly valid, completely empty snapshot into the same repository. Ask for `latest` during the migration and you could restore that one. Second, Kamal had already run Rails' `db:prepare` on the new host. PostgreSQL's normal `pg_restore --clean` path could not reliably replace that prepared schema when target-only foreign keys got in the way. Drops failed, creates failed because objects still existed, data never loaded, and `pg_restore` could still look more successful than the database it left behind. Those problems became the restore model in 1.0. A replacement host can boot its accessory with scheduled backups disabled. PostgreSQL restores remove the target's user schemas first, recreate `public`, restore with a client matching the server, and fail if `pg_restore` reports ignored errors. The workflow is now written down in the [host migration guide](https://kamal-backup.dev/migrating-hosts/): restore under a temporary hostname, verify the application, stop writes to the old host, take and restore a final backup if necessary, then move traffic. ## Exact means exact A restore should leave the target looking like the snapshot, not like the snapshot layered over whatever happened to be there already. That promise now applies to every supported database: - PostgreSQL removes every non-system schema before restoring the custom-format dump. - MySQL and MariaDB remove existing views, tables, sequences, routines, functions, and events before importing. - SQLite uses its native `.backup` and `.restore` APIs, checks the downloaded database before touching the target, and runs `quick_check` again afterward. The file restore runs before the database restore. That matters when a Rails SQLite database and file-backed Active Storage live on the same volume: the clean SQLite backup stays separate from the raw database, WAL, and shared-memory files, and restoring the files cannot delete the database that was just restored. Production restores require the application, jobs, and other database writers to be stopped. Restore drills use scratch databases, scratch SQLite files, and scratch file paths instead of touching the live targets. The point is not to make destructive operations feel casual. It is to make the safe procedure obvious and repeatable. ## Tested beyond PostgreSQL The Chat with Work migration was PostgreSQL. I do not have a production MySQL or SQLite migration story to pretend otherwise. What 1.0 has instead is an exact backup-and-restore matrix that builds the real accessory image and exercises: - PostgreSQL 14, 15, 16, 17, and 18; - MySQL 8.0 and 8.4; - MariaDB 10.11, 11.4, and 11.8; - SQLite in WAL mode, including backup and restore through restic's rclone backend. The matrix creates data, views, routines, functions, triggers, events, custom PostgreSQL schemas, and MariaDB sequences. It adds objects that exist only in the restore target, restores the snapshot, and checks that the stored objects work and the target-only objects are gone. The accessory image ships for amd64 and arm64. It includes matching PostgreSQL clients, the MariaDB client tools used for MySQL and MariaDB, SQLite, restic, rclone, and SSH. Restic's native repositories still work, SFTP works with a dedicated SSH key, and rclone opens the rest of its storage providers without turning kamal-backup into a storage abstraction of its own. ## The same small Kamal accessory The shape of the project has not changed since [I first released it](/kamal-backup/): a Ruby gem gives your Rails repository a CLI, and a Docker image runs the scheduled backups beside your application as a Kamal accessory. ```ruby group :development do gem "kamal-backup", "~> 1.0" end ``` ```sh bundle install bundle exec kamal-backup init bundle exec kamal-backup validate bin/kamal accessory boot backup bundle exec kamal-backup backup --force bundle exec kamal-backup drill production latest ``` It backs up one or more PostgreSQL, MySQL, MariaDB, or SQLite databases, plus the file paths you explicitly configure for file-backed Active Storage. Restic handles encryption, deduplication, retention, and repository integrity. `kamal-backup evidence` turns the configuration, latest snapshots, checks, drills, retention, and tool versions into redacted JSON for the next person who asks whether the backups actually work. Version 1.0 does not mean backups are finished. It means the interface and the restore promises are ready to depend on. Read the [documentation](https://kamal-backup.dev), look through the [restore guide](https://kamal-backup.dev/restore/), and get the source on [GitHub](https://github.com/crmne/kamal-backup). Then run a restore drill. The green backup log is the beginning of the test, not the end. --- # ArchSpec 1.0: Executable Architecture Specification for Ruby's Agentic Coding Era URL: https://paolino.me/archspec/ Published: 2026-08-20 More and more code is written by a model. Tests still tell you it works. RuboCop still tells you it's tidy. Nothing tells you it still follows your architecture. I released [ArchSpec](https://archspecrb.dev) 1.0 today. It's an architecture linter for Ruby and Rails. You declare your components and boundaries in one file, and every change gets checked, whether a person or an agent wrote it. _This is part of my push towards making Ruby one of the best languages to build with AI. [RubyLLM](https://rubyllm.com) is one piece. [Schematist](/schematist/) is another. [Making the default Rails job queue fiber-based](/solid-queue-doesnt-need-a-thread-per-job/) is another._ ## People and AIs take shortcuts An agent or a person that's in a hurry or doesn't fully understand your architecture takes shortcuts. The shortcuts work. They pass tests, implement features, and _cross your boundaries_. Months later, you realise your beautifully crafted architecture is now a patched mess. Or, perhaps, you're starting a new project and you want to ensure that the agent is using a _good_ architecture, without having to police it at every step. So you write it down in prompts and `AGENTS.md`. It helps, but it gets buried in its context window and something slips. Again, then again. RuboCop reads your code and enforces a style. [Herb](https://herb-tools.dev) does it for templates. Nothing did it for architecture. Until now. ## ArchSpec: your architecture in one file Declare your components and their rules in an `Archspec.rb` at the root of the project: ```ruby component :models, in: "app/models/**/*.rb" component :controllers, in: "app/controllers/**/*.rb" component :services, in: "app/services/**/*.rb" models.cannot_use :controllers services.cannot_call :render, :redirect_to, receiver: :none controllers.can_only_use :models, :services ``` Then `archspec check` verifies every change. Models can't reach into controllers. Domain code can't touch adapters. Query objects can't call `save!`. A pack exposes a public API and keeps everything else private. A directory has to stay empty, and says why. If you'd rather not write rules at all, start from a preset: ```ruby architecture :vanilla_rails ``` That one line is the 37signals playbook: rich models, no service objects, no form objects, no policy objects, and `app/services` fails the build with a reason if anything shows up in it. There are presets for Rails, layered, hexagonal, clean architecture, modular monoliths, CQRS, and event-driven too. ## Static Analysis, Not AI I'm the author of RubyLLM so you'd think this uses AI. Nope. _ArchSpec doesn't use AI_. Here's how it works: Prism parses your Ruby, then ArchSpec extracts facts, references, inheritance, mixins, calls, definitions, and evaluates your rules against them. It's deterministic, it's offline, and it's fast enough that you'll leave it on: the full Discourse app, 1,899 files, was checked in 2.5 seconds, without booting the app. Prism is its only runtime dependency. No Rails, no ActiveSupport, nothing else, so it works on any Ruby codebase. RubyLLM is a plain gem and it's been the main proving ground since June. It also won't guess. ArchSpec doesn't try to infer the "true" design pattern of arbitrary Ruby. You describe the architecture you want, and it tells you whether the code still matches. The AI is on the other side of the loop, writing the code that gets checked. ## Failures an Agent Can Act On When a rule breaks, you get this: ```text [error] models must not depend on controllers [dependencies.forbid] app/models/user.rb:3:5 2 │ def admin_path → 3 │ UsersController.admin_path_for(self) │ ^~~~~~~~~~~~~~~ 4 │ end note: User references UsersController 1 architecture violation found. ``` The format is a deliberate homage to clang and to Herb. Exact location, the offending span underlined, the evidence as a note, the rule id in brackets so you can suppress it narrowly. A human reads it at a glance. An agent gets everything it needs to fix its own mistake without asking you: the file, the line, the rule, and why. ## What It Caught in RubyLLM I added ArchSpec checks to [RubyLLM](https://rubyllm.com) [2 months ago](https://github.com/crmne/ruby_llm/commit/f1cf3b0e92e3e9244a94a2c1b5c7d4f2716d2aae), in CI and as a pre-commit hook, and it has been instrumental in the big Protocol/Provider separation that's coming in RubyLLM 2.0. A protocol is a wire format, like Chat Completions, Responses, Anthropic's Messages API, Gemini, or Bedrock Converse. A provider is an account you can talk to, like OpenAI, Azure, DeepSeek, or Ollama. DeepSeek speaks Chat Completions. VertexAI speaks four: Gemini, Anthropic, Mistral, and Chat Completions. Writing each Protocol once is the reason the gem supports as many providers as it does. That distinction is easy to state and easy to erode. The shortcut is to put a piece of wire format inside the provider that needs it, because right now that's the only provider that needs it. Not on my watch: ```ruby providers.cannot_reference_constants 'RubyLLM::Protocol' ``` A provider can subclass a protocol family to change an endpoint or work around a quirk. Subclassing the bare `Protocol` means it's inventing a wire format inside an adapter: ```text [error] providers must not reference RubyLLM::Protocol [constants.forbid] lib/ruby_llm/providers/elevenlabs/audio.rb:9:21 8 │ # image endpoints, so those seams are left unimplemented. → 9 │ class Audio < Protocol │ ^~~~~~~~ 10 │ include ElevenLabs::Models note: RubyLLM::Providers::ElevenLabs::Audio inherits from Protocol 1 architecture violation found. ``` This actually happened while I was developing RubyLLM 2.0. The ElevenLabs audio API and the AWS InvokeModel embedding family had become complete wire formats hiding inside provider adapters. Moving them out also deleted extra code that only existed to paper over the misplacement. Win-win. Here are some more examples from RubyLLM: ### Make contracts only static analysis can actually see ```ruby chat_protocol_families.must_implement :render_payload, :completion_url, :parse_completion_body ``` Every protocol family that speaks chat implements those three seams. The base class declares them abstract with `define_method`, so Ruby only raises at runtime and nothing catches it earlier. ArchSpec sees the definitions. A family that skips a seam fails the build instead of a request. ### Keep your naming conventions ```ruby protocols.method_names.matching(/\A(serialize|deserialize|to_wire|from_wire)_/) .forbidden(because: 'serialize with render_*, deserialize with parse_*') ``` In RubyLLM, serialization methods are called `render_*`, deserialization `parse_*`. Exactly the kind of convention an agent breaks, because `serialize_payload` is a perfectly reasonable name and your rule is 200 lines in `AGENTS.md`. ### Stop slowly decaying API parity ```ruby chat.method_names.matching(/\Awith_(?