Skip to content

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

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.

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

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

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

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

ruby
class OperationsPersona < Protege::Persona
  tools :check_flavor_stock, :create_order, :send_email
end

Unlike 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).