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
rails new the_scoop --database=sqlite3 --javascript=importmap
cd the_scoop# Gemfile
gem "protege"bundle install2. Run the install generator
One command wires Protege into the app:
bin/rails g protege:installHere'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 loudTODOto 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:
bin/rails db:migrate3. 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:
# 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)
}
}
endOne 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:
# config/environments/development.rb
config.eager_load = true4. 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.
bin/rails g protege:persona customer_serviceThat scaffolds the class (the generator applies the Persona suffix):
# 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
end5. 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:
export OPENROUTER_API_KEY=sk-...
bin/devThe guided path — the dashboard. Open http://localhost:3000/protege, then:
- Domains → New — add
thescoop.com. Protege generates its keypair and shows the DNS records to publish later. - Personas → New — create a Customer Service persona named
Sundaeatsupport@thescoop.com, and give her aninstructionsprompt (her editable system prompt).
The quick path — records by hand. Prefer to script it? Create both in the console or a seed, domain first:
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 tosupport@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.

