Personas
A persona is how Protege models an agent identity. There are two halves to it, and keeping them straight is the whole idea:
- A persona class (a subclass of
Protege::Persona) is the role — a kind of agent. It's code.CustomerServicePersonais a role; so isOperationsPersona. - A persona 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
CustomerServicePersona; Scout is a record ofOperationsPersona.
You write the class once; you create records from it. The class defines how that kind of agent thinks — its resolver chains and tool scope. 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 agent
The class is the role, not the person. Write class CustomerServicePersona — not class Sundae. "Sundae" is the record's name.
Defining the class
A persona class carries the Persona suffix and lives in app/personas/. It's an STI model, so the type column stores the class name and the right subclass loads for each row.
# app/personas/customer_service_persona.rb
class CustomerServicePersona < Protege::Persona
self.display_name = "Customer Service"
message_resolvers do |chain|
chain.use(Protege::LoadTextResolver, role: :system) { |ctx| ctx.persona.instructions }
chain.use CustomerContextResolver
chain.use Protege::ThreadHistoryResolver
end
endScaffold one with rails g protege:persona customer_service — the generator applies the Persona suffix, so customer_service becomes CustomerServicePersona in customer_service_persona.rb.
Creating the record
The record is the actual agent. Create it in the dashboard (Personas → New) or in a seed:
CustomerServicePersona.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 personas — one agent per name, one persona per 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 "CustomerServicePersona".
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 OperationsPersona < Protege::Persona
self.display_name = "Operations"
responsibility_resolvers do |chain|
chain.use(Protege::LoadTextResolver, role: :system) { |ctx| ctx.persona.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 persona 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 tools grant below is the opposite — it does inherit.)
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 — the scope grant
By default a persona may use every registered tool. The tools macro narrows that to a declared set — the code-side ceiling on what that role can do. OperationsPersona, an internal role, gets a tighter grant than customer-facing CustomerServicePersona:
class OperationsPersona < Protege::Persona
tools :check_flavor_stock, :create_order, :send_email
endUnlike the resolver chains, the grant inherits down the STI tree (it's a class_attribute), so a base role can set a family-wide ceiling that subclasses tighten. Omitting tools entirely means "every registered tool."
Runtime scoping: disabled_tool_ids
The tools grant is the ceiling; an operator can narrow it further at runtime through the dashboard, which writes to the record's disabled_tool_ids. An agent's effective tools are the grant minus the disabled ids — a disabled tool can never be re-enabled past the grant. The narrowing is enforced at dispatch, not just by hiding the tool from the catalog, so a tool switched off mid-conversation can't still be called from an earlier turn. A stale id (naming a tool since deleted from the code) is simply skipped.
Routing and subaddressing
Inbound mail routes to a persona 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 a persona 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. Persona-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 a persona class declares.
- Security — per-persona access rules.
- A second persona & scoping — adding the Operations role (Scout), step by step.

