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 ONE agent: its own system prompt and, optionally, a narrower tool allow-list. Everything else — the agent's model, its access rules, its handoffs, its history — stays the agent's. A variant that needs any of those to differ is its own agent, not a persona. Personas retain a wire contract compatible with Aviary (POST /agent/chat { persona }, forwardedProps.persona, persona on messages and threads). Agora's local React client supports these fields.

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',
        description: 'Find and track products',
        systemPrompt: 'Help the customer find and track products. Be concise.',
        allowedTools: ['getOrder', 'searchProducts'],
      },
      {
        id: 'support',
        label: 'Support agent',
        systemPrompt: (ctx) => `${ctx.basePrompt}\nHelp resolve issues. Refunds need approval.`,
        allowedTools: ['getOrder', 'issueRefund'],
        aliases: ['support-agent'], // the agent this persona replaced — see below
      },
    ],
  },
})

What a persona controls:

  • The prompt. A flat systemPrompt stands in for the agent's base prompt. A PromptBuilder — (ctx) => string | Promise<string> — is handed the base as ctx.basePrompt, so it can wrap it instead. No systemPrompt keeps the base prompt, which can itself read ctx.persona.
  • The tools. With allowedTools, only those names are offered — after the agent's own allow-list, enabled, the roles policy and canUse. It narrows, never widens. And it is enforced, not only offered: a call the model names anyway is refused by ToolRegistry.invoke (ToolForbiddenError), and a handoff (ask_<agent> / start_<agent>) that is not on the list is refused before the delegate starts.

Tools see the persona as ctx.persona, and every message the turn writes — the user's, the assistant's, the intake's — records its id (StoredMessage.persona).

Which persona a send runs under

Most specific first:

  1. the send's own persona (POST /agent/chat { persona }, AG-UI forwardedProps.persona, ChatParams.personaId);
  2. else the thread's pinned persona, when the agent of this send declares it;
  3. else the agent's defaultPersona (or, without one, a persona whose id is 'default');
  4. else none — the base prompt and the agent's tools, exactly as without personas.

A persona a send names is pinned on the thread, so the next send that names none keeps it; one the send fell back to is not pinned. PATCH /agent/threads/:id { persona } pins one directly (null clears it). An id the agent does not declare is refused with 400 and code: 'persona_not_found' — on a send before anything is created, and on the patch.

Pickers

GET /agent/agents lists each agent's personas ({ id, label, description? }) and its defaultPersona — what useAgents().personasOf(agent) and defaultPersonaOf(agent) read in the React package. A thread's pin is ThreadSummary.persona (null when none). GET /agent/threads/personas/catalog still answers the default agent's list.

Durable: a parked run keeps the persona it started with

The service resolves the persona to an id before the run starts, so only the id rides the run's input (and a queued message). The loop then looks the definition up once and freezes it — id, label, allow-list and the resolved prompt — in a persona:resolve checkpoint, ahead of every other. A run that parks on an approval and resumes days later, on another deployment, runs every later step on the prompt and the offer it started with, even if the persona was edited or removed in between. If the persona is gone when the run first resolves it, the turn runs under none.

Only a run that names a persona spends that checkpoint; a run without one records exactly the sequence it always did. A run started by a release before 0.60 carries the persona in its input instead, and replays as it was recorded, with no persona:resolve.

Because the prompt is resolved inside that checkpoint, a PromptBuilder is called once per run, not once per resume — still, keep it to stable inputs (actor, persona, page context) rather than wall-clock time or randomness.

A queued message keeps its persona

A send that waits in the thread's queue stores the persona it resolved (agent_queued_message.persona), and the turn it starts runs under that one. A message queued before this column existed resolves one as it starts — the thread's pin, then the agent's default — and is never refused.

Folding agents into personas: aliases

aliases lets an app merge separate agents into personas of one without migrating data. A name in a persona's aliases resolves, everywhere an agent name is read, to the agent that owns the persona under that persona: a send's agent, a thread's defaultAgent (stored or patched), a queued message's agent, and a run that is in flight under the old name — served with the persona applied as config. A name that is still a registered agent is never an alias.

Stores

agent_thread.persona holds the pin (an empty string is "none pinned"; the column predates pins, so an older thread holds whatever it was created with, and the routes report a pin no agent declares as null). agent_message.persona holds each message's persona. agent_queued_message.persona is new: createAgentTables — and so the published migration and autoCreateTables — adds it to an existing table on SQLite, Postgres and MySQL. A store of your own implements the optional personaForThread(threadId) and UpdateThreadInput.persona, and round-trips persona on queued messages.

The prompt context available to a PromptBuilder:

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

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 and the turn runs as the thread's default agent — PATCH /agent/threads/:id { defaultAgent: 'billing' } sets it, null clears it (a name no agent is registered under is a 400) — else as the configured 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 takes defaultRoles — unrestricted unless you set it (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.

Background delegation — the chat stays free

An awaited delegation holds the turn open until the delegate answers. For work the user does not need to sit and wait for — a long research pass, a report — declare the edge detached:

config/agent.ts
agents: [
  { name: 'researcher', systemPrompt: 'You dig into incidents.', tools: ['searchLogs', 'purgeCache'] },
  {
    name: 'orchestrator',
    delegatesTo: [{ agent: 'researcher', detached: true, roles: ['ADMIN', 'ANALYST'] }],
  },
]

The synthesized tool is then start_researcher (not ask_researcher), described to the model as returning a receipt rather than an answer. What happens when it is called:

  1. The delegate starts as a run of its own, and the call returns at once with a receipt — { detached: true, status: 'started', agent, runId, note }. The note tells the model, in prose, that the answer is not part of this turn, so it says "I've started that" instead of inventing a result.
  2. The turn ends. The chat is free — the user can keep talking while the delegate works.
  3. The delegate streams into its own run (GET /agent/chat/:runId/stream, owner-only), not into the finished turn. Its action tools and questions park under its own run id, so a decision reaches it through the ordinary POST /agent/tool-call/approve and the approvals inbox.
  4. When it finishes, its answer is posted into the same thread as a message of its own, stamped runId (the delegate's) and agentName ('researcher'), and the delegating tool call is updated to status: 'delivered' with the text.
  5. If it fails, or someone stops it (POST /agent/chat/:runId/cancel with the receipt's runId), the thread gets a message saying so and the tool call settles failed / cancelled — a card never says "started" for ever. A thread deleted meanwhile receives nothing.
  6. Stopping the turn that started it does not stop it. The person stopped the turn they were watching; the work it handed off goes on, and its card follows it to delivered / failed / cancelled. Stop it by its own runId.

Detached is declared per edge, never chosen by the model: a model that could detach could detach the very thing the user is waiting on. A delegate can itself delegate, detached or not; depth and cycle guards apply to detached hops exactly as to awaited ones. A runner with no way to detach (a custom one without startAgent) awaits instead, so the user still gets an answer.

On the client, useAgentChat({ background: true }) tracks these runs from the thread itself — see React → Background runs.

Durable runs and upgrades

Under the durable runner the delegate is started as a run of its own — not a ctx.startChild child, whose parent's cancel would cascade to it — from one journaled detach:<toolCallId> step behind ctx.patched('agent:detached-unlinked'), under the id <runId>.detached.<toolCallId> and the parent's namespace, and only for a call whose persist:toolcall checkpoint recorded detached. A run that journaled a spawn:<childRunId> there on 0.58.0 replays it; its Stop still cascades to that child (the engine owns the edge), and the runner settles the child's card cancelled. A run already in flight when you upgrade, or when you flip an edge to detached, replays the delegations it already made exactly as they were made (awaited), and detaches only the ones it makes afterwards. Flip an edge only once every process runs a release that has the option: an older process cannot replay a run that detached.

On this page

Partner

Your product as the official integration for Agent, right here.

Become a partner →