Skip to content

Setup

We're going to build The Scoop — an ice cream company — a working support agent, from an empty Rails app to production, one capability at a time. By the end of this chapter, The Scoop's first agent, Sundae, exists, receives mail, and sends her first reply. (Making her useful — able to look things up in your app — is the next chapter.)

If you haven't met the cast, skim What is Protege? first. The Scoop's Rails app has its own domain models — Customer, Order, Flavor, Refund — the way any real app would; step 1 stubs them so every "Try it" in the coming chapters actually runs.

Prerequisites

  • Ruby ≥ 3.4, Rails ≥ 7.2 (Rails 8 recommended — it ships Solid Queue/Cache/Cable preconfigured).
  • An LLM provider key. The bundled provider targets OpenRouter.

1. Create the app and add the gem

bash
rails new the_scoop --database=sqlite3 --javascript=importmap
cd the_scoop
ruby
# Gemfile
gem "protege", "0.1.0.alpha.8"
bash
bundle install

(Protege is in alpha, so pin the exact version — bare gem "protege" won't resolve a prerelease. The CHANGELOG tracks breaking changes between alphas.)

Now stub The Scoop's domain — in your own app, your real models play this part:

Stub The Scoop's domain models and data
bash
bin/rails g model Customer name:string email:string
bin/rails g model Order number:string status:string total_cents:integer customer:references
bin/rails g model Refund order:references amount_cents:integer reason:string
bin/rails g model Flavor name:string in_stock:boolean seasonal:boolean
bin/rails db:migrate

A few small touches the later chapters lean on — associations, an Order#refunded? predicate, and the Order.recent / Flavor.available scopes:

ruby
# app/models/customer.rb
class Customer < ApplicationRecord
  has_many :orders
end

# app/models/order.rb
class Order < ApplicationRecord
  belongs_to :customer
  has_many :refunds

  scope :recent, -> { order(created_at: :desc).limit(5) }

  def refunded? = refunds.exists?
end

# app/models/flavor.rb
class Flavor < ApplicationRecord
  scope :available, -> { where(in_stock: true) }
end

And the data the tutorial's examples reference (order SCP-1042, a few flavors):

ruby
# db/seeds.rb
jane = Customer.create_with(name: "Jane").find_or_create_by!(email: "jane@acme.com")

Order.find_or_create_by!(number: "SCP-1042") do |order|
  order.customer    = jane
  order.status      = "shipped — arriving Thursday"
  order.total_cents = 2400
end

[["Pistachio", true, false], ["Strawberry", true, false], ["Maple Walnut", false, true]].each do |name, stocked, seasonal|
  Flavor.find_or_create_by!(name:) do |flavor|
    flavor.in_stock = stocked
    flavor.seasonal = seasonal
  end
end

Run bin/rails db:seed, and The Scoop is open for business.

2. Run the install generator

One command wires Protege into the app:

bash
bin/rails g protege:install

Here's what it does, so nothing is magic:

  • Installs Active Storage and Action Mailbox — attachments and inbound mail. (This also creates app/mailboxes/application_mailbox.rb.)
  • Writes config/initializers/protege.rb — a fully-documented initializer with ENV-backed defaults.
  • Mounts the engine at /protege, leaving a loud TODO to wrap it in your own auth — the engine ships none.
  • Wires the scheduler's tick into config/recurring.yml (once a minute), which we'll use for Scout's scheduled work later.
  • Scaffolds a starter agent so there's something to route mail to.

Then run the migrations it printed, and sync the system toolkits:

bash
bin/rails db:migrate
bin/rails protege:toolkits:sync

The sync creates two engine-managed toolkits: All Tools (always mirrors every registered tool) and Default Tools (seeded with the engine's built-in tools). Every agent you create from now on auto-attaches Default Tools — which matters, because tools are the only way an agent can act, including replying to mail. Run the sync on every deploy, and always before creating agents.

Just exploring?

One command provisions a ready-to-chat development setup — the protege.local domain, an agent named Phoenix (generating the ExecutiveAgent class if needed), toolkits synced and attached:

bash
bin/rails protege:setup:dev

Set your API key, bin/dev, and say hello. This tutorial builds The Scoop by hand instead, so you see each piece.

3. Point Protege at a model

Open config/initializers/protege.rb — the generator already set up the OpenRouter provider. Confirm it reads your key and pick a model:

ruby
# config/initializers/protege.rb
Protege.configure do |config|
  config.provider_id = :openrouter
  config.providers   = {
    openrouter: {
      model:    ENV.fetch("OPENROUTER_MODEL", "anthropic/claude-sonnet-4-5"),
      api_key:  ENV.fetch("OPENROUTER_API_KEY", nil),
      base_url: ENV.fetch("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1")
    }
  }
end

(All three keys are in the generated file; base_url is required — the provider reads only this config, never ENV directly.)

One development-only step: extensions are discovered as loaded subclasses, so let Rails load them eagerly in dev, or the tools we write won't register:

ruby
# config/environments/development.rb
config.eager_load = true

The cost is a slower boot and full reloads in development; the payoff is that the registry — and therefore the dashboard's tool checklists — always reflects every class you've written.

4. Define the Customer Service role

Time to define The Scoop's own role (the install generator's starter, app/agents/executive_agent.rb, can be deleted — or kept as a scratch agent). Remember the distinction that trips everyone up: the class is the role, and the record is the agent. Sundae isn't a class — she's a record of the CustomerServiceAgent role.

bash
bin/rails g protege:agent customer_service

That scaffolds the class (the generator applies the Agent suffix, and titleizes the display name — trim it to taste):

ruby
# app/agents/customer_service_agent.rb
class CustomerServiceAgent < Protege::Agent
  self.display_name = "Customer Service"

  message_resolvers do |chain|
    chain.use(Protege::LoadTextResolver, role: :system) { |ctx| ctx.agent.instructions }
    chain.use Protege::ThreadHistoryResolver
  end

  responsibility_resolvers do |chain|
    chain.use(Protege::LoadTextResolver, role: :system) { |ctx| ctx.agent.instructions }
    chain.use(Protege::LoadTextResolver, role: :user)   { |ctx| ctx.responsibility.instructions }
  end
end

5. Register the domain, then create Sundae

Order matters here, and it's deliberate: register the email domain before you create the agent. Protege only routes inbound mail for domains you've registered, and creating the domain is also what mints its DKIM keypair and the DNS records you'll publish in production. Create thescoop.com first, then create Sundae on it.

Boot the app so the dashboard is available:

bash
export OPENROUTER_API_KEY=sk-...
bin/dev

The guided path — the dashboard. Open http://localhost:3000/protege, then:

  1. Domains → New — add thescoop.com. Protege generates its keypair and shows the DNS records to publish later.
  2. Agents → New — create a Customer Service agent named Sundae at support@thescoop.com, and give her an instructions prompt (her editable system prompt).

The quick path — records by hand. Prefer to script it? Create both in the console or a seed, domain first:

ruby
Protege::EmailDomain.create!(domain: "thescoop.com")

CustomerServiceAgent.create!(
  name:          "Sundae",
  email_address: "support@thescoop.com",
  instructions:  "You are Sundae, The Scoop's friendly support agent. Be warm and concise. " \
                 "Help customers with orders and flavor questions."
)

Either way, inbound mail to support@thescoop.com now routes to Sundae.

6. Send her a first message

There are two ways to hand Sundae a message in development, no real mail server required:

  • The dashboard console. Open Inbox and hit Compose — a New message form with a To select. Pick Sundae, say hello, and send. The resulting thread page is where you'll watch runs unfold in the introspection panel.
  • The Action Mailbox conductor. Open http://localhost:3000/rails/conductor/action_mailbox/inbound_emails/new, address the mail to support@thescoop.com, and click Deliver.

Either way: the run starts, send_email fires in the introspection panel, and Sundae's reply lands in the thread. Your agent talks.

Why could she reply?

An agent's plain assistant text is never delivered — mail leaves only through the send_email tool, and tools come only from toolkits attached to the agent (an agent with none can do nothing at all). Sundae can reply because the Default Tools toolkit — synced in step 2, carrying the engine's built-ins — attached itself when you created her. Those built-ins are generic: she can reply, search her own mail, fetch the web, create files. What she can't do is touch anything in The Scoop's app. That's the next chapter.

Next

Your first tool — give Sundae her first real capability: looking up an order.