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 and can receive mail. (She won't reply yet — that's the next chapter.)

If you haven't met the cast, skim What is Protege? first. Assume The Scoop's Rails app already has its own domain models — Customer, Order, Flavor, Refund — the way any real app would.

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"
bash
bundle install

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 persona so there's something to route mail to.

Then run the migrations it printed:

bash
bin/rails db:migrate

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)
    }
  }
end

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

4. Define the Customer Service role

Time to replace the starter persona with The Scoop's own. 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 CustomerServicePersona role.

bash
bin/rails g protege:persona customer_service

That scaffolds the class (the generator applies the Persona suffix):

ruby
# app/personas/customer_service_persona.rb
class CustomerServicePersona < Protege::Persona
  self.display_name = "Customer Service"

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

  responsibility_resolvers do |chain|
    chain.use(Protege::LoadTextResolver, role: :system) { |ctx| ctx.persona.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. Personas → New — create a Customer Service persona 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")

CustomerServicePersona.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. From Sundae's page in /protege, start a thread. This is also where you'll watch the run 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, watch the logs: Sundae receives the message, an inference run starts… and nothing comes back. That's expected.

Sundae has no hands yet

An agent's plain assistant text is never delivered — mail goes out only through the send_email tool, and Sundae has no tools at all yet. Giving her the ability to reply, and to look things up, is the next chapter.

Next

Your first tool — give Sundae the ability to reply and to look up an order.