Agora
Authoring

Personas & agents

Shape one assistant with personas (prompt + tool allow-list), or run several named agents that hand work to one another through delegatesTo multi-agent delegation.

The default setup is a single agent. Two features let you go further: personas reshape one agent per request, and named agents with delegatesTo turn a flat assistant into a small team that hands work around.

Personas

A persona is a named variant of an agent: its own system prompt, and optionally a tool allow-list. The caller selects one per request (the persona field on POST /agent/chat), and it's pinned onto the thread.

config/agent.ts
export default defineConfig({
  model: () => aiSdkModel(openai('gpt-4o-mini')),
  store: 'lucid',
  stores: { lucid: stores.lucid() },
  actorResolver: new AuthActorResolver(),
  defaultAgent: {
    systemPrompt: 'You are the assistant for our store.',
    defaultPersona: 'shopper',
    personas: [
      {
        id: 'shopper',
        label: 'Shopping assistant',
        systemPrompt: 'Help the customer find and track products. Be concise.',
        allowedTools: ['getOrder', 'searchProducts'],
      },
      {
        id: 'support',
        label: 'Support agent',
        systemPrompt: 'Help resolve issues. You may issue refunds with approval.',
        allowedTools: ['getOrder', 'issueRefund'],
      },
    ],
  },
})

Two things a persona controls:

  • The prompt. A persona's systemPrompt is resolved with the agent's base prompt available as basePrompt, so a persona can wrap or extend the base rather than replace it. It can be a flat string or a PromptBuilder(ctx) => string | Promise<string> — composed per request from the actor, persona, and page context.
  • The tool allow-list. If allowedTools is set, only those tool names are offered — after role filtering. So the effective tool set is: registered tools ∩ the actor's role ∩ the persona's allow-list ∩ the agent's allow-list.

Catalog

GET /agent/threads/personas/catalog returns the { id, label } list so a UI can render a persona picker. Omit personas entirely for a plain assistant.

The prompt context available to a PromptBuilder:

interface PromptContext {
  actor: Actor
  persona?: Persona
  pageContext?: PageContext
  basePrompt: string // the agent's own resolved base prompt
}

Because the loop resolves the prompt once per turn from stable inputs, a PromptBuilder stays replay-safe — don't reach for wall-clock time or randomness inside it.

Named agents

Register additional agents alongside the default one with agents. Each is an AgentDefinition with its own prompt, tool allow-list, personas, and step budget:

config/agent.ts
export default defineConfig({
  model: () => aiSdkModel(openai('gpt-4o-mini')),
  store: 'lucid',
  stores: { lucid: stores.lucid() },
  actorResolver: new AuthActorResolver(),

  defaultAgent: {
    name: 'orchestrator',
    systemPrompt: 'You coordinate specialists. Delegate work, then summarize.',
    delegatesTo: ['researcher', 'billing'],
  },
  agents: [
    {
      name: 'researcher',
      systemPrompt: 'You research questions using read tools.',
      tools: ['searchDocs', 'getOrder'],
    },
    {
      name: 'billing',
      systemPrompt: 'You handle billing. Refunds require approval.',
      tools: ['getInvoice', 'issueRefund'],
    },
  ],
})

A request targets an agent by name via the agent field on POST /agent/chat (omit it for the default). Model, store, sink, and governance are shared across all agents; each definition overrides only its own prompt, tools, personas, defaultPersona, modelId, maxSteps, maxDelegationDepth, maxAgentAppearances, and actorResolver.

Per-agent identity

actorResolver on a definition overrides the module-global one for that agent's turns — for an agent whose caller arrives differently from the rest of the app, e.g. a machine-facing agent that reads its principal from the request body while your human-facing agents read ctx.auth:

config/agent.ts
export default defineConfig({
  actorResolver: new AuthActorResolver(),       // the default for every agent
  agents: [
    { name: 'webhook', actorResolver: new BodyActorResolver() },  // only this one
  ],
})

Precedence is simply "the agent's own, else the global", so an agent that declares none behaves exactly as it did before per-agent resolvers existed.

The override applies to `POST /agent/chat` only

That route reads the body first precisely so it knows which agent — and therefore which resolver — a turn belongs to. Every other route has no agent in scope: GET /agent/chat/:runId/stream, cancel, approve/reject, the threads routes, quota, attachments, approvals/mine, and all of /agent/governance/* resolve the caller with the global resolver, as do the MCP endpoint (which has its own identity path entirely) and any turn started through AgentService from a job.

So a per-agent resolver must produce an actor the global resolver can also produce for the same caller — otherwise that caller starts a run it then cannot stream, cancel, or approve, because ownership compares against a different actor.id.

Multi-agent delegation

delegatesTo declares which other agents an agent may hand work to. For each edge, the provider synthesizes an agent-kind delegate tool named ask_<target> (non-alphanumeric characters become underscores). The orchestrator calls it like any tool — ask_researcher({ task: '...' }) — and the loop runs the target agent and feeds its answer back. You never write the delegate handler; the loop handles delegation itself.

How a delegate tool is named and described

delegatesTo: ['researcher'] produces ask_researcher (non-alphanumeric characters become underscores). Its description is generated — "Delegate a task to the 'researcher' agent and get its answer." — and when the target has a flat-string base prompt, that prompt is appended, so the orchestrator's model knows what the specialist is for without you restating it.

Its input is { task: string }, validated before the delegation runs exactly like any other tool's input. A model that calls ask_researcher({ topic: '...' }) gets a validation error back, not a delegation with an undefined task.

Authorizing a delegation

A delegate tool is a tool, and it goes through the same authorization gate as every other one. It is not implicitly allowed because you declared the edge.

A bare-string edge declares no roles and no ability, which means:

  • under the default DefaultToolAuthorizer, it is ADMIN-only (the fallback for any tool with no declared roles);
  • under the authz Bouncer adapter, it is always denied — that adapter has no role fallback, and a tool with no ability is unreachable by construction.

If your orchestrator's callers are not ADMINs, or you use the authz adapter at all, say who may delegate by giving the edge the object form:

config/agent.ts
export default defineConfig({
  defaultAgent: {
    name: 'orchestrator',
    systemPrompt: 'You coordinate specialists. Delegate work, then summarize.',
    delegatesTo: [
      // Role-based (the default authorizer):
      { agent: 'researcher', roles: ['ANALYST', 'ADMIN'] },
      // Ability-based (the authz adapter):
      { agent: 'billing', ability: 'agent.delegate.billing' },
    ],
  },
  agents: [/* ... */],
})

roles and ability mean exactly what they mean on a hand-written tool, so both can be present and the active policy picks the one it understands. The bare string stays supported and stays fail-closed; it is the right form when only admins should orchestrate.

A denied delegation is recorded, not retried

When the gate denies, the tool call is persisted failed — never auto_executed, not even transiently — no agent.delegated event is published, and the error is fed back to the model as a tool result. The model usually apologizes and answers on its own, which is why a mis-authorized delegation looks like "the orchestrator ignored the specialist" rather than a visible error. Check the tool-call feed if delegation seems to be silently not happening.

A chain that goes in circles

delegatesTo is a graph, and the model cannot see it. In a mutual handoff — alpha hands to beta, beta hands back to alpha — every agent makes one reasonable call and the recursion belongs to the wiring, so the loop has to stop it.

Both runners hand each child the chain of agent names that reached it, and the loop compares a delegation's target against that chain plus its own agent. A repeat is the cycle, and the refusal names it:

delegation cycle: alpha → beta → alpha — alpha 2 times on one chain

maxAgentAppearances is how many times one agent may appear on a single chain, default 1. It counts appearances, so 2 admits exactly one deliberate return to an earlier agent — what a supervisor that genuinely hands work back needs.

maxDelegationDepth (default 5) stays as the backstop for a chain that is long without repeating, and is reported only when nothing is circular:

delegation depth limit of 5 reached

Both are declared per agent, beside maxSteps. Which one a deployment ever meets is not obvious: at one appearance per agent a chain cannot be longer than the number of registered agents, so an app with fewer agents than the ceiling never reaches the depth guard — the cycle guard always fires first. Depth starts mattering with a bigger fleet than the ceiling, or once maxAgentAppearances is raised, which is what lets a chain revisit an agent and grow past the fleet's size.

Why not just count

A count can only say a chain is LONG. It cannot say it is going in circles, and conflating the two gets both cases wrong: a mutual handoff burns the whole ceiling in agent turns before reporting a depth the reader then has to interpret, and a legitimate chain of six DISTINCT agents is refused for resembling a cycle it is not. The chain of names costs exactly what the counter cost to thread.

Under the inline runner, delegation runs as a nested in-process loop on a transient sub-thread; under the durable runner, it maps to a tracked durable child workflow (ctx.child). Either way a sub-agent's action tools and question sets park on a human, under the sub-agent's own run id, and the frame forwarded into the stream a human is watching carries that id so the wait can be answered. A sub-agent whose approvals nobody will answer therefore hangs — the same trade a top-level agent makes. See Answering a sub-agent, the agent loop and The durable runner.

On this page