Skip to content

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. CustomerServiceAgent is a role; so is OperationsAgent.
  • 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 of OperationsAgent.

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.

ruby
# 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
end

Scaffold 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:

ruby
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".

ruby
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.
ruby
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
end

There'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).