Skip to content

Configuration

Protege is configured once at boot, in an initializer, and treated as read-only thereafter — the Orchestrator snapshots a frozen copy of the config for each run. rails g protege:install writes a fully-documented config/initializers/protege.rb; this page explains every option in it.

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

provider_id

Default: nil. The symbolic id of the provider Protege runs inference through. It must match a registered provider's protege_id:openrouter for the bundled adapter, or the id you gave a custom one. Until it's set, no inference can run, so this is effectively required.

ruby
config.provider_id = :openrouter

providers

Default: { openrouter: {} }. A plain hash keyed by provider id. Each provider reads its own slice for the model, credentials, and any sampling knobs — the engine treats the values as opaque, so a custom provider can put whatever keys it needs under its own id and read them back with Protege.configuration.provider_options(:its_id).

ruby
config.providers = {
  openrouter: {
    model:    ENV.fetch("OPENROUTER_MODEL", "anthropic/claude-sonnet-4-5"),
    api_key:  ENV.fetch("OPENROUTER_API_KEY", nil),
    base_url: "https://openrouter.ai/api/v1"
    # temperature: 0.7,
    # max_output_tokens: 4096
  },
  scoop_llm: { api_key: ENV["SCOOP_LLM_KEY"], region: "us" }   # a custom provider's slice
}

Assignment replaces the whole hash, so include every provider you configure — not just the active one. The bundled OpenRouter provider requires model, api_key, and base_url in its slice (they raise on first use if missing); temperature and max_output_tokens are optional.

max_tool_turns

Default: 100. How many tool-calling rounds the harness allows before it stops and returns whatever the model last produced. It's the guard against a runaway tool chain — an agent that keeps calling tools without ever answering. Raise it for genuinely multi-step work; lower it to cap cost and latency on simple agents.

ruby
config.max_tool_turns = 100

recursion_limit

Default: 50. The X-Protege-Recursion hop count at which inbound mail is silently dropped — the bound on agent-to-agent reply loops. Every agent-sent email carries the header (1 on fresh mail, the inbound message's count plus one on a reply); human mail clients never echo it back, so a person replying anywhere in a conversation resets the chain, and only an unbroken Protege-to-Protege exchange accumulates hops. Raise it if your agents legitimately hand work back and forth more than a few times; lower it to clamp down sooner. See Security.

ruby
config.recursion_limit = 50

console_address

Default: "console@protege.local". The address that represents the dashboard user in conversations started from the console. It's how the engine recognizes an in-app message: replies addressed to it stay entirely in-app and skip SMTP delivery, so you can exercise an agent from the dashboard (and via Gateway.message) without sending real mail.

ruby
config.console_address = "console@protege.local"

Default: "🥚 Protege". The brand shown in the dashboard's nav header — the place to white-label the console for your own product. The Scoop might set it to its own name so the dashboard reads as an in-house tool.

ruby
config.nav_title = "The Scoop · Agents"

tracing

Default: { enabled: false }. Turns tracing on or off. When enabled, the harness snapshots every inference turn (the exact request, the response, and the model/settings) into a Protege::Trace row for later review and fine-tuning. It's off by default because nothing should be captured until you opt in — and because multimodal turns store attachment bytes inline, so rows can be large.

ruby
config.tracing = { enabled: true }

inbound_access

Default: permit-all. The global ceiling on which senders may reach any agent — the committed, org-wide layer of the access guardrail. Assign a policy built through Gateway.build_access_policy. Per-agent access rules can only narrow this further, never widen past it, so this is where you set the outer boundary once.

ruby
config.inbound_access = Protege::Gateway.build_access_policy(allow: ["*@thescoop.com"])
# or a block-list:
config.inbound_access = Protege::Gateway.build_access_policy(deny: ["*@spam.example"])

Patterns are an exact address or a single * wildcard, matched against the sender's tag-stripped, lowercased address. Supplying an allow list flips the policy into allow-list mode (default-deny). That derived fallback can be overridden explicitly with default_decision: :allow or :deny:

ruby
config.inbound_access = Protege::Gateway.build_access_policy(default_decision: :deny)  # lock inbound down entirely

attachment_policy

Default: enabled, 10 MB per attachment, 10 attachments per message, 25 MB total. The limits enforced at the mail edges — inbound mail that breaches any limit is bounced, and the send_email tool refuses to attach a breaching set. Assign a policy built through Gateway.build_attachment_policy. The factory takes four kwargs — enabled: true, max_bytes: 10 * 1024 * 1024 (per attachment), max_count: 10 (per message), and max_total_bytes: 25 * 1024 * 1024 (combined) — pass only the ones you're changing.

ruby
config.attachment_policy = Protege::Gateway.build_attachment_policy(
  max_bytes: 5 * 1024 * 1024,
  max_count: 3
)
# or turn attachments off entirely:
config.attachment_policy = Protege::Gateway.build_attachment_policy(enabled: false)

http_user_agent

Default: a generic, unbranded Chrome string. The User-Agent the built-in web_search and web_fetch tools send on outbound HTTP. The default is deliberately unbranded because some origins reject or fingerprint unusual agents. Override it to present your host's own browser agent.

ruby
config.http_user_agent = ENV.fetch("HTTP_USER_AGENT", Protege::Configuration::DEFAULT_HTTP_USER_AGENT)

logger

Default: Rails.logger. The logger the engine writes to. Override it to route Protege's output — tool failures, swallowed hook errors, provisioning warnings — to a dedicated target.

ruby
config.logger = Logger.new(Rails.root.join("log/protege.log"))

failure_alerts

Default: {} (off). Emails a platform admin when an inference run fails and can't be recovered — for example when the provider rejects every request because the account is out of credits. Without it, such failures are silent: the web app keeps serving, the job records the error and retries, and nothing tells you the agent has stopped replying. Assign a hash with the recipients and sender to turn it on.

ruby
config.failure_alerts = {
  to:   ["ops@thescoop.com"],
  from: "alerts@agent.thescoop.com"
}
  • to — an array of recipient addresses. Alerting is enabled only when this is present, so leaving failure_alerts unset (or to empty) keeps it off.
  • from — the sender. There is no default sender: for the alert to actually arrive, from must be an address at a registered email domain so your outbound MTA DKIM-signs it and it passes that domain's DMARC policy. If you set to without a from, Protege logs a warning and sends nothing rather than mailing from an address that would bounce.

The alert carries the error class, the error message, and the run's correlation id (for tracing) — not agent or message specifics, so it serves any failure. A failing run is retried a few times and each attempt fires the underlying event, so alerts are deduplicated per run (you get one email, not one per retry) and a global cap bounds how many can go out in a window, so a systemic outage can't flood the recipients.

Extension scaffold paths

Defaults: app/tools, app/resolvers, app/hooks, app/agents, app/providers. Where the rails g protege:{tool,resolver,hook,agent,provider} generators write their files. Override them if you group extensions elsewhere — but the target must stay under an autoloaded path, or Rails (and Protege's subclass discovery) won't load them.

ruby
config.tools_path     = "app/tools"
config.resolvers_path = "app/resolvers"
config.hooks_path     = "app/hooks"
config.agents_path  = "app/agents"
config.providers_path = "app/providers"

Secrets

Nothing secret is hard-coded; credentials come from the environment. The variables the generated defaults read:

VariableUsed for
OPENROUTER_API_KEYThe bundled OpenRouter provider's key
OPENROUTER_MODELThe model OpenRouter targets (e.g. anthropic/claude-sonnet-4-5)
OPENROUTER_BASE_URLOverride the OpenRouter endpoint (rarely needed)
RAILS_INBOUND_EMAIL_PASSWORDAction Mailbox ingress auth (and the self-hosted MTA push) — see Mail

A note on development

Extensions are discovered as loaded subclasses, so in development you must let Rails load them eagerly — add config.eager_load = true to config/environments/development.rb, or your tools, hooks, and providers won't register until first referenced.