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.
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
systemPromptis resolved with the agent's base prompt available asbasePrompt, so a persona can wrap or extend the base rather than replace it. It can be a flat string or aPromptBuilder—(ctx) => string | Promise<string>— composed per request from the actor, persona, and page context. - The tool allow-list. If
allowedToolsis 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:
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:
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
abilityis 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:
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 chainmaxAgentAppearances 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 reachedBoth 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.
Tools
Give the agent things to do — @AiTool classes and defineTool functions, discovered from app/agent_tools, with read/action kinds, Standard Schema inputs, and role/ability gating.
Attachments & multimodal
Let users attach images and PDFs to a message so a vision-capable model sees them natively — the MessageAttachment shape, the attachment-staging SPI, and the optional POST /agent/attachments upload route.