Claude API Ruby on Rails Example: Full Integration
If you're searching for a Claude API Ruby on Rails example, you're probably trying to add AI features to an existing Rails app — a support assistant, content generator, or internal tool — without wrestling with raw HTTP calls scattered across your controllers. This article walks through a complete, production-shaped setup: a service object, a controller endpoint, background job handling for long requests, and streaming responses back to the browser.
Rails doesn't have an official Claude SDK, but that's fine. The API is plain HTTPS with JSON, and Ruby's Net::HTTP or the faraday gem handles it cleanly. The pattern below works whether you're calling Claude directly or through a proxy like SubToAPI that gives you an application API key instead of managing raw credentials per environment.
Setting Up the Gemfile
You only need an HTTP client. faraday is the most common choice in Rails apps:
# Gemfile
gem 'faraday'
gem 'faraday-retry'
Run bundle install, then store your key in Rails credentials or an environment variable — never commit it:
rails credentials:edit
claude:
api_key: sk-ant-xxxxxxxx
Building a Service Object
Keeping the API logic out of controllers makes it testable and reusable. Create app/services/claude_client.rb:
class ClaudeClient
BASE_URL = "https://api.anthropic.com/v1/messages"
def initialize(api_key: Rails.application.credentials.dig(:claude, :api_key))
@api_key = api_key
@conn = Faraday.new(url: BASE_URL) do |f|
f.request :json
f.response :json
f.request :retry, max: 2, interval: 0.5
f.adapter Faraday.default_adapter
end
end
def complete(prompt, model: "claude-sonnet-4-5", max_tokens: 1024)
response = @conn.post do |req|
req.headers["x-api-key"] = @api_key
req.headers["anthropic-version"] = "2023-06-01"
req.headers["content-type"] = "application/json"
req.body = {
model: model,
max_tokens: max_tokens,
messages: [{ role: "user", content: prompt }]
}
end
raise "Claude API error: #{response.status}" unless response.success?
response.body.dig("content", 0, "text")
end
end
This gives you a single, testable method: ClaudeClient.new.complete("Summarize this ticket").
Wiring It Into a Controller
A typical use case is generating a response for a model instance — say, drafting a reply to a support ticket:
class TicketRepliesController < ApplicationController
def create
ticket = Ticket.find(params[:ticket_id])
draft = ClaudeClient.new.complete(
"Draft a polite support reply for: #{ticket.body}"
)
render json: { draft: draft }
rescue => e
render json: { error: e.message }, status: :bad_gateway
end
end
Keep the controller thin. Validation, retries, and prompt construction belong in the service layer or a dedicated builder object if prompts get complex.
Handling Long-Running Requests with ActiveJob
Claude calls can take several seconds, which is too long for a synchronous request-response cycle in most Rails apps. Move it to a background job and notify the client via a webhook, polling endpoint, or ActionCable:
class GenerateReplyJob < ApplicationJob
queue_as :default
def perform(ticket_id)
ticket = Ticket.find(ticket_id)
draft = ClaudeClient.new.complete("Draft a reply for: #{ticket.body}")
ticket.update!(ai_draft: draft)
TicketChannel.broadcast_to(ticket, { draft: draft })
end
end
Trigger it from the controller with GenerateReplyJob.perform_later(ticket.id) and return a 202 immediately. This keeps your Rails workers from blocking on a slow upstream call — a common gotcha when teams first wire Claude into Sidekiq-backed apps.
Streaming Responses
For chat-style UIs, streaming tokens as they arrive feels far more responsive than waiting for the full response. Claude supports server-sent events natively, and Rails can relay them using ActionController::Live:
class StreamController < ApplicationController
include ActionController::Live
def show
response.headers["Content-Type"] = "text/event-stream"
conn = Faraday.new(url: "https://api.anthropic.com/v1/messages")
conn.post do |req|
req.headers["x-api-key"] = Rails.application.credentials.dig(:claude, :api_key)
req.headers["anthropic-version"] = "2023-06-01"
req.body = {
model: "claude-sonnet-4-5",
max_tokens: 1024,
stream: true,
messages: [{ role: "user", content: params[:prompt] }]
}.to_json
req.options.on_data = Proc.new do |chunk, _|
response.stream.write(chunk)
end
end
ensure
response.stream.close
end
end
This is where a lot of teams hit friction — raw SSE parsing, reconnect logic, and partial JSON chunks add real complexity. If you'd rather skip building that plumbing, SubToAPI exposes the same streaming behavior through a standard sub_live_ key with a simpler client contract; see /docs/streaming for the request format.
Simplifying the Setup with SubToAPI
The service object pattern above works regardless of which key you use. If you're managing multiple environments (staging, CI, per-developer sandboxes) and don't want to pass around a single shared Claude credential, SubToAPI lets you issue a scoped sub_live_ key per environment or team member, with usage visible per key in one dashboard. Swapping it into the ClaudeClient class above is a one-line change — point BASE_URL at https://api.subtoapi.app/v1/messages and use your SUBTOAPI_KEY. Check /docs/quickstart for the exact request shape and /pricing for plan details (Solo, Team, Scale).
Testing the Integration
Stub the HTTP layer in RSpec rather than hitting the real API in CI:
RSpec.describe ClaudeClient do
it "returns the completion text" do
stub_request(:post, ClaudeClient::BASE_URL)
.to_return(
status: 200,
body: { content: [{ text: "Hello" }] }.to_json,
headers: { "Content-Type" => "application/json" }
)
expect(ClaudeClient.new.complete("Hi")).to eq("Hello")
end
end
webmock keeps your test suite fast and avoids burning real API quota on every CI run.
questions
Does Rails need a dedicated Claude gem? No. The API is standard JSON over HTTPS, so faraday or Net::HTTP is sufficient. A thin service object keeps the integration organized without extra dependencies.
How do I avoid blocking web requests when calling Claude from Rails? Move the call into an ActiveJob (Sidekiq, GoodJob, etc.) and notify the client via ActionCable, polling, or a webhook once the job completes, rather than waiting synchronously in the controller.
Can I stream Claude responses to a Rails frontend? Yes, using ActionController::Live with Content-Type: text/event-stream, relaying chunks as they arrive from Claude's streaming endpoint. See /docs/streaming for details on handling SSE chunks correctly.