Agents
An agent is Protege's addressable actor — the identity behind an email address. There are two halves to it, and keeping them straight is the whole idea:
- An agent class (a subclass of
Protege::Agent) is the role — a kind of agent. It's code.CustomerServiceAgentis a role; so isOperationsAgent. - An agent record (a database row of that class) is the individual agent — a specific name, email address, and prompt. It's data. Sundae is a record of
CustomerServiceAgent; Scout is a record ofOperationsAgent.
You write the class once; you create records from it. The class defines how that kind of agent thinks — its resolver chains. The record holds what an operator can change without a deploy — the display name shown per agent, the email_address inbound mail routes to, and the instructions (the editable system prompt).
Don't name the class after the individual
The class is the role, not the person. Write class CustomerServiceAgent — not class Sundae. "Sundae" is the record's name.
Defining the class
An agent class carries the Agent suffix and lives in app/agents/. It's an STI model, so the type column stores the class name and the right subclass loads for each row.
# 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 CustomerContextResolver
chain.use Protege::ThreadHistoryResolver
end
endScaffold one with rails g protege:agent customer_service — the generator applies the Agent suffix, so customer_service becomes CustomerServiceAgent in customer_service_agent.rb.
Creating the record
The record is the actual agent. Create it in the dashboard (Agents → New) or in a seed:
CustomerServiceAgent.create!(
name: "Sundae",
email_address: "support@thescoop.com",
instructions: "You are Sundae, The Scoop's friendly support agent. Look up orders, answer " \
"flavor questions, and issue refunds when a customer is clearly owed one."
)name and email_address are both unique across all agents — no two agents share a name or an address. That per-address uniqueness is what makes each agent a singleton on its inbox. You can have several records of one class (two support agents on different addresses), but most roles have exactly one agent.
display_name
Set on the class, display_name is the role label shown in the dashboard, logs, and tooling. It falls back to the demodulized class name when unset. Set it to something readable — "Customer Service" rather than "CustomerServiceAgent".
self.display_name = "Customer Service"message_resolvers and responsibility_resolvers
These declare the two resolver chains that assemble the context sent to the model. Each takes a block yielding the chain:
message_resolvers— the reactive chain, run when the agent answers an inbound email. It usually includes thread history, since there's a conversation to continue.responsibility_resolvers— the proactive chain, run for a scheduled responsibility. There's no inbound thread, so it's typically the system prompt plus the duty's own instructions.
class OperationsAgent < Protege::Agent
self.display_name = "Operations"
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
endThere's also a shorthand — message_resolver(klass, ...) / responsibility_resolver(klass, ...) — to append a single resolver without the block.
Chains are per-class and don't inherit
The resolver chains are declared on each concrete agent class and are not inherited by a subclass of that class. If you build a family of roles on a shared base, each concrete class declares its own chains.
The methods are message_resolvers / responsibility_resolvers
Some older examples show a bare resolvers do |chain|. The current methods are the two above — use them.
Tools: attached toolkits
An agent has no tools of its own — an agent with no attached toolkit can't even reply, since sending mail is itself a tool. Tools come entirely from the toolkits attached to it in the dashboard: named, reusable bundles whose attachments each carry their own sender gate (allow/deny address rules; no rules admits everyone) and an Allow on scheduled runs flag for proactive work — so the same toolkit can be given to two agents on different terms, and one agent can offer different tools to different correspondents, even mid-thread. Gating is enforced at dispatch, not just by hiding tools from the catalog.
In practice a new agent starts functional: the engine's Default Tools system toolkit (seeded with every engine built-in tool, membership yours to curate) is attached automatically on create, and the All Tools system toolkit always mirrors the full registry. Both are maintained by the protege:toolkits:sync task — run it on every deploy, and before creating your first agents.
The full model — gate precedence, scheduled runs, enforcement, system toolkits, and worked scenarios — is covered in Toolkits.
Routing and subaddressing
Inbound mail routes to an agent record by matching the recipient against email_address, honouring RFC 5233 subaddressing: support+order42@thescoop.com still routes to Sundae at support@thescoop.com. The plus-tag is stripped for routing but preserved on the stored message, so you can use it for threading hints or campaign tags. Only active (non-archived) records route; archiving an agent takes that agent out of service while keeping all its history.
Memory
An agent's memory is its conversation history. The ThreadHistoryResolver replays a thread's prior messages — and the agent's prior tool use — on each turn, so within a thread the agent remembers what was said and what it did. Threading is keyed off the email headers, so this works across a back-and-forth without any setup.
For long threads, a common app-level pattern is a running summary: a resolver that keeps a rolling recap of everything older than the recent window, so the agent retains the gist without replaying hundreds of messages. This is a pattern you build in your app, not an engine built-in.
Planned: cross-thread working memory
Today an agent's memory is per-thread. Agent-level working memory that persists across threads — so Sundae remembers a customer between separate conversations — is planned but not yet built. Until then, load durable facts explicitly with a resolver (e.g. the customer's record via CustomerContextResolver).
Related
- Resolvers — the chains an agent class declares.
- Security — per-agent access rules.
- A second agent & scoping — adding the Operations role (Scout), step by step.