Extensions
You build an agent by writing four kinds of plain-Ruby extension. Each is a subclass of a Protege base class living in your host app's app/ directory, and each has a different job. Together they are the whole surface you write against — the engine wires them in and calls them at the right moments.
| Surface | Base class | What it does | How it's discovered |
|---|---|---|---|
| Tools | Protege::Tool | Actions the model can take in your app | Automatically (every subclass) |
| Providers | Protege::Provider | Model backend adapters | Selected by config.provider_id |
| Resolvers | Protege::Resolver | Assemble the context sent to the model | Added explicitly to an agent's chain |
| Hooks | Protege::Hook | React to lifecycle events | Automatically (every subclass) |
Where they live
The generators put each surface in its own conventional directory. This is The Scoop's app/ after a few chapters of the tutorial:
app/
├── agents/ # agent classes (roles) — Sundae is a CustomerServiceAgent record, Scout an OperationsAgent
│ ├── customer_service_agent.rb
│ └── operations_agent.rb
├── tools/ # actions — auto-discovered
│ ├── lookup_order_tool.rb
│ ├── check_flavor_stock_tool.rb
│ ├── process_refund_tool.rb
│ └── create_order_tool.rb
├── resolvers/ # context assembly — wired onto an agent
│ └── customer_context_resolver.rb
├── hooks/ # event reactions — auto-discovered
│ └── refund_audit_hook.rb
└── providers/ # model adapters — selected by config
└── scoop_llm_provider.rb # (optional; OpenRouter ships built-in)The directories are conventions, not requirements — each is configurable (config.tools_path and friends) — but everything must stay under an autoloaded path so Rails (and Protege) can find it.
Two discovery models
There is one distinction worth internalizing, because it explains why some extensions "just work" and others need a line of wiring:
- Auto-discovered — Tools and Hooks. Every subclass is found by the engine automatically. Write a
Protege::Toolsubclass and it's registered and listed in the manifest — an agent offers it to the model once a toolkit containing it is attached. Write aProtege::Hooksubclass and its event handlers are live at boot — nothing more to do. - Explicitly wired — Resolvers and Providers. A resolver only runs if you add it to an agent's resolver chain — order matters, and different agents want different context. A provider is chosen by id in configuration, because an app runs one at a time.
However it activates, every extension lands in the manifest with the same metadata: an id (derived from the class name), a display_name, a short human-facing summary shown in dashboard pickers, and a description — for tools, the verbose LLM-facing text sent to the model. Protege::Manifest.registered(:tool) enumerates a type's entries and Protege::Manifest.entry(type:, id:) fetches one — the supported way to ask what extensions exist.
Scaffolding
Every surface has a generator that writes a correctly-shaped starter file into the right directory:
bin/rails g protege:tool lookup_order
bin/rails g protege:resolver customer_context
bin/rails g protege:hook refund_audit
bin/rails g protege:provider scoop_llm
bin/rails g protege:agent customer_serviceThe generators are idempotent about the type suffix — protege:tool send_email, protege:tool SendEmail, and protege:tool SendEmailTool all produce the same SendEmailTool class. See Generators & CLI for the full set.
Where to start
Read Tools first — they're the agent's hands and the surface you'll write most of. Then Resolvers for giving the agent the right context, then Hooks for observing and reacting to runs. Providers you'll rarely touch — the bundled OpenRouter adapter covers most models.