AI agents by framework / Ruby

How to add an AI agent to a Sinatra app

An AI agent in a Sinatra app is a route that calls a model with tools, runs the tool calls as the user in the session, and streams the reply with Sinatra's stream helper. RubyLLM runs the model loop; you supply the tools, a confirmation step for writes, CSRF protection and limits. If the agent only needs to do what your pages already do, you can embed one instead.

What an agent in a Sinatra app is made of

  • A route: a post block that reads the signed-in user from the session, calls the model and streams the reply. The model API key stays in the server environment.
  • Tools: Ruby classes the model may call, each wrapping a query or an action your app already has for one user.
  • The agent loop: the model calls a tool, receives the result and decides the next step until it can answer. Cap the number of steps.
  • A page and a small script: an ERB view with a form, and JavaScript that reads the stream and shows text and confirmation buttons.
  • Authorization and confirmation: tools act as the session user, and actions that change data go through the same signed-in, CSRF-protected routes as the rest of your app.

Ways to add an agent to Sinatra

ApproachGood forWhat you take on
RubyLLM in a routeA custom agent with your own tools and any major model provider, without adding a framework.Tool definitions, authorization in every tool, conversation storage, the chat UI, rate and cost limits.
An official provider SDK (the anthropic or openai gem)Using one provider's newest API features directly.The tool loop and streaming, plus everything in the row above.
Hosted answer-only chat widgetQuestions about your docs or help center.Keeping its content current. It does not act in your app.
Embedded in-app agent (Bunkhouse)Letting signed-in users get tasks done through the pages and API you already have.A script tag, an identity endpoint and hints for operations discovery cannot see.

Build an agent with RubyLLM

RubyLLM is a Ruby library for chats, tools and agents across many model providers, and it works without Rails. The examples below use RubyLLM 2.1 in a modular Sinatra 4 app served by Puma, with sign-in that stores the user ID in Sinatra's cookie session. Add the gem:

Terminal
bundle add ruby_llm --version 2.1.1

The examples call the model "claude-sonnet-5-5" from Anthropic with the key in ANTHROPIC_API_KEY. To use another provider, set its key in RubyLLM.configure and pick a model ID from RubyLLM's model registry.

1. Tools that act as the user

A RubyLLM tool is a class with a description, declared parameters and an execute method. Both tools receive the user ID in their constructor, so the model can choose an order ID but never whose orders it reads. Orders stands for your existing data layer.

RubyLLM can pause a tool until a person approves it (requires_approval), but resuming that chat in a later request needs the conversation stored somewhere, and RubyLLM's built-in storage is Active Record. Without it, the simplest safe pattern is that CancelOrder cancels nothing: it checks the order and asks the page to show a Cancel button. The cancellation itself goes through your normal endpoint, with the user's session and CSRF token.

tools.rb
require "ruby_llm"

class ListOrders < RubyLLM::Tool
  description "List the signed-in user's recent orders"
  parameter :status, description: "Only orders with this status: open, shipped or cancelled", required: false

  def initialize(user_id)
    @user_id = user_id
  end

  def execute(status: nil)
    status = nil unless %w[open shipped cancelled].include?(status)
    Orders.for_user(@user_id, status:)
  end
end

class CancelOrder < RubyLLM::Tool
  description "Ask the signed-in user to confirm cancelling one of their open orders. " \
              "Shows the user a Cancel button; it does not cancel anything itself."
  parameter :order_id, description: "ID of the open order"

  def initialize(user_id, &propose)
    @user_id = user_id
    @propose = propose
  end

  def execute(order_id:)
    order = Orders.find_for_user(@user_id, order_id)
    return { error: "The user has no open order #{order_id}" } unless order && order[:status] == "open"

    @propose.call(order)
    { status: "waiting_for_user", note: "The user was shown a button to confirm the cancellation." }
  end
end

2. The streaming route

The route authenticates before it streams: a signed-out request gets 401 and an empty or oversized message gets 422. Inside stream, the chat stages the message with ask_later and runs the loop one step at a time: a model response or a round of tool calls, at most eight. Each event goes to the browser as one line of JSON, and the loop stops early if the browser disconnects.

Sinatra enables Rack::Protection by default, but its default reaction to a failed check is to drop the session. The :protection setting adds the authenticity-token check and answers 403 instead, so every POST, including /agent, needs the session's CSRF token.

app.rb
require "json"
require "sinatra/base"
require "ruby_llm"
require_relative "tools"

RubyLLM.configure do |config|
  config.anthropic_api_key = ENV.fetch("ANTHROPIC_API_KEY")
end

class App < Sinatra::Base
  MAX_STEPS = 8

  set :session_secret, ENV.fetch("SESSION_SECRET") # 64+ random characters
  set :sessions, httponly: true, same_site: :lax, secure: production?
  # Require Rack::Protection's CSRF token on every unsafe request, and answer 403 when it is missing.
  set :protection, use: %i[authenticity_token], reaction: :deny

  helpers do
    def current_user = Users.find(session[:user_id]) # your existing sign-in
    def csrf_token = Rack::Protection::AuthenticityToken.token(session)
  end

  post "/agent" do
    user = current_user or halt 401
    message = params[:message].to_s.strip
    halt 422 if message.empty? || message.length > 4000

    content_type "application/x-ndjson"
    stream do |out|
      emit = ->(event) { out << "#{JSON.generate(event)}\n" unless out.closed? }
      chat = RubyLLM.chat(model: "claude-sonnet-5-5", provider: :anthropic)
                    .with_instructions("You help the signed-in user manage their orders. Use the tools; never guess order data.")
                    .with_tools(
                      ListOrders.new(user[:id]),
                      CancelOrder.new(user[:id]) { |order| emit.(type: "confirm", order_id: order[:id]) }
                    )
                    .ask_later(message)

      MAX_STEPS.times do
        chat.step { |chunk| emit.(type: "text", text: chunk.content) unless chunk.content.to_s.empty? }
        break if chat.complete? || out.closed?
      end
    rescue RubyLLM::Error
      emit.(type: "error", message: "The assistant is unavailable. Try again.")
    end
  end

  # Your existing endpoint: signed-in, CSRF-protected and scoped to the user.
  post "/orders/:id/cancel" do
    user = current_user or halt 401
    Orders.cancel(user[:id], params[:id]) ? 204 : 404
  end
end

3. The page and the client

The layout puts the CSRF token in a <meta> tag, and the page has a log and a message form. The script posts the form with the token in the X-CSRF-Token header and reads the response line by line. Text is added with append, so model output is never parsed as HTML. A confirm event becomes a button that posts to the existing cancel endpoint.

views/agent.erb
<%# In your layout's <head> %>
<meta name="csrf-token" content="<%= csrf_token %>">
<script src="/agent.js" defer><\/script>

<%# In the page %>
<div id="agent-log" aria-live="polite"></div>
<form id="agent-form">
  <input name="message" aria-label="Message" required maxlength="4000">
</form>
public/agent.js
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
const form = document.querySelector("#agent-form");
const log = document.querySelector("#agent-log");

form.addEventListener("submit", async (event) => {
  event.preventDefault();
  const reply = document.createElement("p");
  log.append(reply);
  const response = await fetch("/agent", {
    method: "POST",
    headers: { "X-CSRF-Token": csrfToken },
    body: new URLSearchParams(new FormData(form)),
  });
  form.reset();
  if (!response.ok) return reply.append(`Request failed (${response.status}).`);

  const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
  let buffered = "";
  for (;;) {
    const { value, done } = await reader.read();
    if (done) break;
    buffered += value;
    const lines = buffered.split("\n");
    buffered = lines.pop();
    for (const line of lines) show(JSON.parse(line), reply);
  }
});

function show(event, reply) {
  if (event.type === "text") reply.append(event.text);
  if (event.type === "error") reply.append(event.message);
  if (event.type === "confirm") {
    const button = document.createElement("button");
    button.textContent = `Cancel order ${event.order_id}`;
    button.addEventListener("click", async () => {
      const response = await fetch(`/orders/${encodeURIComponent(event.order_id)}/cancel`, {
        method: "POST",
        headers: { "X-CSRF-Token": csrfToken },
      });
      button.replaceWith(response.ok ? "Order cancelled." : "The order could not be cancelled.");
    });
    log.append(button);
  }
}

Production checklist for Sinatra

  • Authorize in every route and tool. Read the user from the session on the server, scope every query to that user, and never take a user ID from params or from the model.
  • Keep sessions and CSRF strict. Load session_secret from the environment (64 or more random characters, the same on every process), mark the cookie secure in production, and keep the authenticity-token check on for every unsafe request. See Using Sessions.
  • Treat tool input and tool results as untrusted. The model chooses tool arguments, and data a tool returns (user-written text, emails, web pages) can contain instructions. Validate arguments in the tool and send writes through endpoints that apply your normal checks.
  • Limit cost. Cap steps and message length, rate-limit /agent per user (for example with the rack-attack middleware), and set spending limits with your model provider.
  • Plan for long streams. On Puma, each open stream holds a server thread until the reply ends, so size threads and workers for concurrent conversations, and turn off response buffering for this route in any reverse proxy so the text arrives as it is written.
  • Store conversations on the server. This route answers one message at a time. For follow-up questions, save each user's messages in your database and replay them into the chat with add_message; do not accept history from the browser, where it can be edited.

Option: embed an in-app agent with Bunkhouse

If the agent's job is to help signed-in users do work your Sinatra app already supports, you may not need to build the tools, agent loop and chat UI yourself. Bunkhouse adds an Assistant pane to an existing web app with one script tag. When a user opens it, the assistant works with what the page already offers: buttons, links, form fields and tables, the API requests your app makes, a hints file you publish for operations it cannot see, and WebMCP tools your page declares.

The model writes small programs that run in a sandbox on the embed origin, not in your page. They can only call capabilities the assistant has discovered and your configuration allows. Write actions are shown to the user for review before they run, the model never sees credentials, and your Sinatra backend keeps enforcing its own authorization on every request.

In Sinatra, put the identity hook and the loader in the <head> of views/layout.erb, before your own scripts, so they load once per page. For signed-in users, add a GET /api/agent-identity route that signs a short-lived token for the user in the session and answers 401 when nobody is signed in; the signing secret stays in a server environment variable.

views/layout.erb (excerpt)
<head>
  <script src="/agent-identity.js"><\/script>
  <script src="https://embed.bunkhouse.ai/embed.js" data-public-key="pk_YOUR_SITE_PUBLIC_KEY"><\/script>
  <%# Your application's own scripts come after the loader. %>
</head>

The tested Sinatra integration guide has the complete code for each session mode (signed identity token, server mint and anonymous), CSRF headers and Content Security Policy settings.

When Bunkhouse fits, and when to build it yourself

  • Bunkhouse fits when users want tasks done inside your existing pages, such as filtering, editing or submitting through the controls and API you already have, and you would rather not write and maintain a tool definition for each action, a sandbox for model-written code, an approval UI and usage metering.
  • Build it yourself when the agent must run without a browser page (background jobs, email, chat platforms, scheduled work), when you need to choose the model provider, use your own keys or run inference on your own infrastructure, or when the conversation must be part of your own UI components.
  • Plan for the limits: discovery cannot see server-only routes, workflows the user has not visited, closed shadow roots or cross-origin frames (describe those in a hints file); some controls need a real user gesture; password, payment and file fields are never read, and you can mark other content private.
  • Cost: production use is $49 per month, including $20 of usage credit per paid billing period, then $1 per million input tokens and $3 per million output tokens. See billing.

Try it on your Sinatra app first

A coding agent such as Claude Code, Codex or Cursor can run a free, disposable agent trial on a dummy page of your Sinatra app. No account, card or model API key is needed: your coding agent acts as the assistant's model. A trial lasts 24 hours and allows 100 turns. Give your agent this instruction, changing the address if your app runs elsewhere:

Agent trialPrompt for your coding agent
Read https://api.bunkhouse.ai/agent-trial.md and follow it to run a Bunkhouse agent trial on my dummy Sinatra page at http://localhost:4567.

Use a page with disposable data: everything the assistant reads is sent to your agent.

The trial evaluation checklist shows how to test a read, a rejected write and an approved change, and what a trial does not prove about production use.