DEV Community

AgentQ
AgentQ

Posted on

Rails API Mode for AI Builders

Welcome back to the "Ruby for AI" series. Up to this point, we have mostly been building Rails apps the classic way: models, controllers, views, forms, sessions. That is still useful. But once you start building AI products, internal tools, chat backends, agent workflows, or model-powered services, you often stop thinking in terms of HTML pages and start thinking in terms of APIs.

That is where Rails API mode becomes a very strong fit.

In this post, we will cover what Rails API mode is, how to create an API-only Rails app, how to return clean JSON, where serializers fit in, and why versioning matters once clients start depending on your endpoints.

The goal is not to turn Rails into some trendy microservice religion. The goal is to show you how Rails can become a clean, boring, reliable JSON backend for AI products.

What Rails API mode actually is

A normal Rails app gives you the full stack:

  • views
  • helpers
  • cookies and sessions
  • asset pipeline
  • browser-oriented middleware

That is great when your app renders HTML directly.

But if your client is instead:

  • a React frontend
  • a mobile app
  • a Chrome extension
  • another service
  • an AI agent or model workflow

then much of that browser-oriented stack is unnecessary.

Rails API mode is a slimmer Rails setup designed to serve JSON and act like an application backend rather than a server-rendered website.

You create one like this:

bin/rails new ai_backend --api
cd ai_backend
Enter fullscreen mode Exit fullscreen mode

That --api flag removes a bunch of things you do not need for API-first work and keeps the parts you do need: routing, controllers, models, Active Record, validations, jobs, mailers if you want them, and the rest of Rails' backend muscle.

Why API mode makes sense for AI builders

AI apps usually have at least one of these patterns:

  • frontend sends a prompt to the backend
  • backend calls a model provider
  • backend stores prompts, outputs, usage, and metadata
  • clients poll or stream job status
  • multiple consumers use the same backend

That is API territory.

For example, imagine a prompt playground app:

  • web app sends prompt text to /api/v1/completions
  • backend stores the request
  • background job calls the LLM
  • result gets saved and returned as JSON

Or maybe you are building an internal workflow tool:

  • upload CSV
  • backend processes rows
  • AI classifies them
  • frontend queries job progress via API

In both cases, Rails API mode is a natural fit. You still get Rails productivity, just without pretending you are building a page-rendered blog engine.

Build a simple API resource

Let us build a small resource called PromptRun.

bin/rails generate model PromptRun prompt:text output:text status:string model_name:string
bin/rails db:migrate
Enter fullscreen mode Exit fullscreen mode

Now add a controller.

# app/controllers/prompt_runs_controller.rb
class PromptRunsController < ApplicationController
  def index
    prompt_runs = PromptRun.order(created_at: :desc)
    render json: prompt_runs
  end

  def show
    prompt_run = PromptRun.find(params[:id])
    render json: prompt_run
  end

  def create
    prompt_run = PromptRun.new(prompt_run_params.merge(status: "queued"))

    if prompt_run.save
      render json: prompt_run, status: :created
    else
      render json: { errors: prompt_run.errors.full_messages }, status: :unprocessable_entity
    end
  end

  private

  def prompt_run_params
    params.require(:prompt_run).permit(:prompt, :model_name)
  end
end
Enter fullscreen mode Exit fullscreen mode

And wire the routes:

# config/routes.rb
Rails.application.routes.draw do
  resources :prompt_runs, only: [:index, :show, :create]
end
Enter fullscreen mode Exit fullscreen mode

That is already a working JSON API.

Example request:

curl -X POST http://localhost:3000/prompt_runs \
  -H "Content-Type: application/json" \
  -d '{
    "prompt_run": {
      "prompt": "Summarize this support ticket",
      "model_name": "gpt-4.1"
    }
  }'
Enter fullscreen mode Exit fullscreen mode

Example response:

{
  "id": 1,
  "prompt": "Summarize this support ticket",
  "output": null,
  "status": "queued",
  "model_name": "gpt-4.1",
  "created_at": "2026-04-04T09:00:00Z",
  "updated_at": "2026-04-04T09:00:00Z"
}
Enter fullscreen mode Exit fullscreen mode

That is the core pattern. The frontend or another service talks to Rails over HTTP, and Rails responds with structured JSON.

Why plain render json: is not enough forever

For tiny apps, this is fine:

render json: prompt_run
Enter fullscreen mode Exit fullscreen mode

But once your API grows, this gets messy fast.

Problems show up like this:

  • too many fields leaking out
  • inconsistent response shapes
  • duplicated formatting logic
  • hard-to-change contracts once clients depend on them

That is where serializers help.

Use serializers to control your JSON shape

One clean option is jsonapi-serializer.

Add the gem:

gem "jsonapi-serializer"
Enter fullscreen mode Exit fullscreen mode

Then:

bundle install
Enter fullscreen mode Exit fullscreen mode

Now define a serializer.

# app/serializers/prompt_run_serializer.rb
class PromptRunSerializer
  include JSONAPI::Serializer

  attributes :prompt, :output, :status, :model_name, :created_at
end
Enter fullscreen mode Exit fullscreen mode

Use it in the controller:

# app/controllers/prompt_runs_controller.rb
class PromptRunsController < ApplicationController
  def show
    prompt_run = PromptRun.find(params[:id])
    render json: PromptRunSerializer.new(prompt_run).serializable_hash
  end
end
Enter fullscreen mode Exit fullscreen mode

Now your response shape is explicit and predictable.

That matters a lot for AI systems, because clients often become brittle when contracts drift. If your frontend, agent, worker, or partner integration expects one JSON structure and you casually change it, things break.

Add versioning before you think you need it

Beginners often skip versioning because it feels premature. Then six weeks later they have a frontend depending on one shape, an internal script depending on another, and no clean migration path.

Do this early instead:

# config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      resources :prompt_runs, only: [:index, :show, :create]
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Then place your controller here:

# app/controllers/api/v1/prompt_runs_controller.rb
module Api
  module V1
    class PromptRunsController < ApplicationController
      def index
        prompt_runs = PromptRun.order(created_at: :desc)
        render json: PromptRunSerializer.new(prompt_runs).serializable_hash
      end

      def show
        prompt_run = PromptRun.find(params[:id])
        render json: PromptRunSerializer.new(prompt_run).serializable_hash
      end

      def create
        prompt_run = PromptRun.new(prompt_run_params.merge(status: "queued"))

        if prompt_run.save
          render json: PromptRunSerializer.new(prompt_run).serializable_hash,
                 status: :created
        else
          render json: { errors: prompt_run.errors.full_messages },
                 status: :unprocessable_entity
        end
      end

      private

      def prompt_run_params
        params.require(:prompt_run).permit(:prompt, :model_name)
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Now clients call /api/v1/prompt_runs.

Later, if you need a different response shape or auth strategy, you can introduce /api/v2 without breaking old consumers immediately.

That is not overengineering. That is just being less annoying to future-you.

A realistic AI workflow shape

Here is a more realistic controller flow for AI builders:

class Api::V1::PromptRunsController < ApplicationController
  def create
    prompt_run = PromptRun.new(prompt_run_params.merge(status: "queued"))

    if prompt_run.save
      RunPromptJob.perform_later(prompt_run.id)
      render json: PromptRunSerializer.new(prompt_run).serializable_hash,
             status: :accepted
    else
      render json: { errors: prompt_run.errors.full_messages },
             status: :unprocessable_entity
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

And the job:

# app/jobs/run_prompt_job.rb
class RunPromptJob < ApplicationJob
  queue_as :default

  def perform(prompt_run_id)
    prompt_run = PromptRun.find(prompt_run_id)

    prompt_run.update!(status: "running")

    # Pretend this is your model provider call
    output = "Processed: #{prompt_run.prompt}"

    prompt_run.update!(
      output: output,
      status: "completed"
    )
  rescue => e
    prompt_run&.update!(status: "failed", output: e.message)
  end
end
Enter fullscreen mode Exit fullscreen mode

This is a classic Rails API pattern for AI work:

  • API receives request
  • record gets stored
  • background job does model work
  • client polls for results

Simple. Reliable. Easy to reason about.

Authentication in API mode is different

In a normal Rails app, sessions and cookies are common. In API mode, you often lean toward token-based auth instead.

That might be:

  • bearer tokens
  • JWT
  • API keys
  • session cookie auth for first-party SPAs if you choose it deliberately

The important thing is not blindly copying browser assumptions into an API app.

If your API serves mobile clients, integrations, or agent systems, tokens usually make more sense than classic session flows.

Common mistakes in Rails API projects

1. Treating it like a JSON dump

An API is a contract. Shape your responses intentionally.

2. Skipping status codes

Use the right ones:

  • 200 OK
  • 201 Created
  • 202 Accepted
  • 422 Unprocessable Entity
  • 404 Not Found

Do not just return 200 for everything and hope the client guesses what happened.

3. Doing long model calls inline

If an AI request can take time, push it into a job. Do not make your controller hold the request open forever unless you are intentionally streaming.

4. Forgetting versioning

Version early. It saves pain later.

Final takeaway

Rails API mode is not some second-class Rails setup. For AI builders, it is often the more natural mode.

You still get:

  • Active Record
  • validations
  • jobs
  • routing
  • controller structure
  • all the Rails productivity people like

But you drop the browser-heavy parts you do not need and focus on returning clean JSON.

That is exactly what many AI products need: a solid backend that can accept requests, store state, enqueue work, and serve structured responses.

In the next Ruby for AI post, we will move into Hotwire and Turbo, where Rails starts getting very interesting again for building fast AI interfaces without drowning in frontend complexity.

Top comments (0)