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
rails new the_scoop --database=sqlite3 --javascript=importmap
cd the_scoop# Gemfile
gem "protege", "0.1.0.alpha.8"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
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:migrateA few small touches the later chapters lean on — associations, an Order#refunded? predicate, and the Order.recent / Flavor.available scopes:
# 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) }
endAnd the data the tutorial's examples reference (order SCP-1042, a few flavors):
# 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
endRun bin/rails db:seed, and The Scoop is open for business.
2. 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 agent so there's something to route mail to.
Then run the migrations it printed, and sync the system toolkits:
bin/rails db:migrate
bin/rails protege:toolkits:syncThe 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:
bin/rails protege:setup:devSet 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:
# 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:
# config/environments/development.rb
config.eager_load = trueThe 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.
bin/rails g protege:agent customer_serviceThat scaffolds the class (the generator applies the Agent suffix, and titleizes the display name — trim it to taste):
# 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
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. - Agents → New — create a Customer Service agent 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")
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 tosupport@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.