Aviary
Guides

Personal agents (Poppy)

Be the Company Agent of Personal Agent Protocol ("Poppy") conversations — personal agents talk to your agent on a user's behalf, signed out or within the account scopes the user granted.

Experimental — tracks a draft spec

The Personal Agent Protocol (Poppy) specification is a Draft 0.1, still in development. This surface implements Draft 0.1 and tracks the spec: it will change as the spec evolves — possibly in breaking ways, and outside semver majors while the spec is a draft. Everything in @dudousxd/nestjs-agent-poppy is experimental.

Personal Agent Protocol ("Poppy", Draft 0.1) is how a user's personal agent works with a company: discovery (poppy.json), Sessions and sign-in, APIs — and conversations (Poppy §7), where the personal agent talks with the Company Agent. AgentPoppyModule makes one of your registered agents that Company Agent.

The rest of the protocol — poppy.json, the OAuth server, Session Tokens and DPoP — is the authorization server's (AuthKit). The two meet in one global slot, never an import: Symbol.for('@adonis-agora/poppy:authenticate'), where the authorization server installs the function that verifies a Session Token. This module reads it per request. (The symbol names are protocol contracts shared with the AdonisJS packages, so one authorization server serves either framework.)

Mounting it

app.module.ts
import { AgentModule } from '@dudousxd/nestjs-agent';
import { AgentPoppyModule } from '@dudousxd/nestjs-agent-poppy';

@Module({
  imports: [
    AgentModule.forRoot({ /* … */ }),
    AgentPoppyModule.forRoot({
      path: 'poppy/conversations',
      baseUrl: 'https://api.acme.com', // the URL DPoP proofs name
      agent: 'support',
    }),
  ],
})
export class AppModule {}

forRootAsync({ imports, inject, useFactory }) takes the same options from a factory. Import it after AgentModule: its AgentService answers the turns.

It is advertised in poppy.json (the authorization server builds that document) as:

"agent": { "protocols": [{ "type": "poppy", "endpoint": "https://api.acme.com/poppy/conversations" }] }

With baseUrl set, the module announces that URL at boot in the global slot Symbol.for('@adonis-agora/poppy:endpoints') ({ conversations: '<baseUrl>/<path>' }, merged with what is already there), and an authorization server in the same process reads it: no URL repeated in its config. baseUrl is required for this: without it nothing is announced, and you give the authorization server the endpoint yourself.

Request
POST {endpoint}Start a conversation with its first message → 201 { conversation_id, status, responder }. With parent_conversation_id, a Direct Conversation.
POST {endpoint}/{id}/messagesAnother message → 202.
GET {endpoint}/{id}/eventsEvents after cursor (wait holds the request), or SSE with Accept: text/event-stream.
POST {endpoint}/{id}/handoffAsk for a person → 202.
POST {endpoint}/{id}/closeClose → 200.

Both POSTs take ?wait=&cursor= and then answer as a read of the events, keeping their 201/202. Anything else under the path is 404 not_found, a wrong method 405 with Allow.

The endpoint is mounted on the Express app itself, in Nest's middleware phase — ahead of the router, so no guard, interceptor or pipe applies (neither your global ones nor the agent's own routes'): every request is authenticated by its Session Token, and before its body is acted on. The path is from the server root — a global prefix does not apply, include it in path if you want one. Nest's body parser still runs first: the module reads what it left (req.rawBody when the app is created with { rawBody: true }, else the parsed JSON written back), and a body the parser refused (malformed JSON, over its limit) is answered only after the token checked out. Nest's own parser limit (100 kB by default, app.useBodyParser('json', { limit })) applies before maxBodyBytes. With no resolver in the slot and no authenticate, the endpoint answers 501 not_implemented — and the module warns at boot. Fastify is not supported yet.

Who is talking

Each request goes to the resolver as { method, url, headers }: url is the full public URL (baseUrl's origin, else the request's protocol and host — X-Forwarded-* when Express trusts the proxy, app.set('trust proxy', …) — upgraded to https when the Poppy issuer Symbol.for('@adonis-agora/poppy:issuer') is), since the DPoP proof's htu names it; headers are the raw ones, authorization and dpop included. No options are passed: a turn's scopes are decided per tool, and the resource a token must carry is the resolver's to derive from the URL.

It answers a principal: the personal agent (clientId), its User (userId, opaque and pairwise), the app account when signed in (accountId, else null), the Session, signedIn, the token's scopes, resource, tokenType and — only when the authorization server maps one — an actor. A refusal (invalid_token, invalid_dpop_proof, use_dpop_nonce, sign_in_required, insufficient_scope) is answered with the resolver's status, its WWW-Authenticate, its extra headers (DPoP-Nonce), its description as error_description, and Cache-Control: no-store.

A turn runs as principal.actor, else your actorFor(principal, defaults), else the default: signed in, the account — { id: accountId }; signed out, poppy:<hash> (stable per personal agent and User, never an account). A conversation used signed in is bound to that account. Whatever the source, the actor gets the role personal_agent and one scope:<id> role per scope of a signed-in token. AgentModule always wraps its roles policy in the personal-agent gate: a personal agent reaches only tools that declare one of its roles, and memory and skills resolve no scope for it.

// Anyone's agent, signed in or not — general questions.
@AiTool({ name: 'store_hours', kind: 'read', roles: ['personal_agent'], /* … */ })
// Reading the account (Poppy spec §4.4).
@AiTool({ name: 'list_orders', kind: 'read', roles: ['scope:poppy:read'], /* … */ })
// Changing it — and your own scopes work the same (`scope:addresses`).
@AiTool({ name: 'cancel_order', kind: 'action', roles: ['scope:poppy:write'], /* … */ })

A delegate tool (@Agent({ handoff })) declares no roles, so personal agents cannot delegate until you re-register it with roles: ['personal_agent'] (or a scope role).

Scopes, sign-in and actions

The protocol has no confirmation step: "sending a message does not authorize the requested account action" (Poppy §7.11), the token's scopes do. So:

  • A tool behind a scope the token lacks is offered but not run. Its refused call ends the turn with an authorization event: sign_in_required for a signed-out token, insufficient_scope otherwise, and scope naming what was missing ("poppy:write"). In a Direct Conversation the Company Agent also asks in a message (Poppy §7.11 SHOULD). The conversation stays open; the personal agent continues it with its new token. A thread is one actor's, so once the User signs in the account's first turn runs in a thread of its own, told what was said before (a transcript of the messages).
  • With the scope, an action that parks for approval is approved by the scope (actions: 'approve-delegated', the default; 'reject' refuses them all). The same goes for independent proposals (actionApprovalMode: 'independent'): the turn decides it, waits for it to execute, and the reply carries it in data.actions — { tool, status, summary, result }, the summary being the confirmation your app would have shown the person. A question set (ask) is skipped. A personal agent's proposal records the roles it was made under (executionContext.personalAgentRoles): the proposal worker resolves the account through backgroundActorResolver and then runs the action under those roles, so it is gated at execution exactly as when it was proposed — never as the whole account.
  • Every action is also told in the reply's text (Poppy §7: a message must make sense without data): status is succeeded, failed (with error) or rejected. When the model wrote nothing, or an action did not go through (the model wrote before the result, so its claim is replaced), the text is each action worded by formatAction(action, texts) — by default texts.actionSucceeded / actionFailed / actionRejected ({title}, {error}) plus the confirmation's detail.

The turn names a refused or parked call by the call the model provider announced on the stream (aiSdkModel does); a provider that announces none cannot be attributed.

Messages and events

A message's text and data reach the model (data as JSON); its context (locale, time_zone, user_available, …) is merged into the conversation's and given to the turn as pageContext: { channel: 'poppy', poppy: { conversationId, direct, sender, signedIn, context } }, with approvals recorded as coming via: 'poppy'. A context-only message updates it without a turn.

The reply is a message event (role: 'company', sender: 'agent'). Components your tools emit become data.components ({ name, props, fallback_text }), and their fallback text is the message's text when the model wrote none. While it is written, an SSE reader gets its text as text-delta events (no SSE id, { message_id, text }), then the complete message event.

Every change of status (working / idle / queued / closed) or responder (agent / human) is a state event. Event ids are cursors; an unknown one is invalid_cursor (400), one pruned by retentionMs is cursor_expired (410). Reads page by 100 with has_more. A stream resumes from Last-Event-ID, sends a keep-alive comment every 15 s, closes after streamMaxMs, and ends once the conversation closes.

Message ids are the personal agent's and unique per User: a retry with the same content answers what the first delivery answered (a retried first message returns the conversation it started); different content is message_id_conflict (409). Unknown fields are ignored everywhere (Poppy §3.3).

Handoff to a person

Without handoff hooks, a handoff request is answered by the Company Agent saying no one is available, and the conversation stays with it. With them:

AgentPoppyModule.forRoot({
  // …
  handoff: {
    // 'queued' (someone will join), 'joined' (now), or { unavailable: 'why' }.
    request: async ({ conversationId }) =>
      (await support.open(conversationId)) ? 'queued' : { unavailable: 'Our team is offline until 9am.' },
    // Personal-agent messages while a person handles it (or is being found): the agent stays out.
    message: ({ conversationId, message }) => support.forward(conversationId, message),
    cancel: ({ conversationId }) => support.cancel(conversationId), // closed meanwhile
  },
});

The Company side is driven through the exported PoppyConversations provider:

@Injectable()
export class SupportDesk {
  constructor(private readonly poppy: PoppyConversations) {}

  async join(id: string) {
    await this.poppy.humanJoined(id);                                 // responder → human
    await this.poppy.postMessage(id, { sender: 'human', text: 'Hi, I am Sam.' });
  }
  // poppy.humanLeft(id)                          — back to the agent
  // poppy.handoffUnavailable(id, 'Nobody could take it.') — queued → idle, with the reason
  // poppy.handoff(id)                            — the Company Agent hands off on its own
  // poppy.requestUser(id, reason) / poppy.close(id) / poppy.get(id)
}

Direct Conversations

poppy.requestUser(id, reason) adds a user_requested event; only the personal agent opens the Direct Conversation, once the User agrees, with parent_conversation_id. It runs in the parent's agent thread, so the Company Agent knows what was said in either. While it is open the parent gets direct_opened, can still be read, and refuses messages and handoffs with direct_conversation_open (409, naming it); closing it adds direct_closed; closing the parent closes it. A Direct Conversation cannot be a parent. direct.opened / direct.closed hooks tell your app.

Durability

Conversations, events and message receipts live in a PoppyStore: store, else the one a store module binds to AGENT_POPPY_STORE, else an in-memory store — one process only, with a warning at boot.

The event log is the turn queue: user messages after the last answered one are what the next turn answers, by one replica at a time (a lease). A replica that finds a conversation working with no one writing it takes it up — and a reply whose writer died is written from the run's own stream, once. A turn whose thread is busy (409 run_active — its Direct Conversation, or a run another replica started) waits for it, within timeoutMs.

With AgentModule.forRoot({ durable: true }) the engine drives the runs it starts, so a single-engine deployment answers a turn — a delegating one included — without waiting on itself. drive exists only for a custom service whose dispatcher merely persists a run: it is called with the run id while the turn is read.

text-delta pieces are relayed by the process writing the reply; a stream served by another replica gets the complete message only (the spec makes deltas optional).

Configuration

FieldDefault
path'poppy/conversations'The endpoint, from the server root.
baseUrlrequest originPublic origin — what the resolver checks DPoP htu against. Required to announce the endpoint for poppy.json.
agentdefault agentThe registered agent that answers.
authenticateglobal slotYour own Session Token resolver.
actorFor{ id: accountId } / poppy:<hash>(principal, defaults) => Actor when the resolver gave no actor.
storeAGENT_POPPY_STORE, else memoryA PoppyStore.
actions'approve-delegated'Or 'reject'.
handoff, direct—Hooks above.
textsEnglishhandoffUnavailable, failed, signIn, actionSucceeded, actionFailed, actionRejected.
formatActionformatPoppyAction(action, texts) => string | null — how an action reads in the reply.
timeoutMs120000A turn running longer is cancelled.
maxBodyBytes1 MiBLargest request body.
maxWaitSeconds30Cap on wait.
streamMaxMs5 minA stream is closed after this; the personal agent reconnects.
retentionMskeepPrune older events.
drivenone(runId) => Promise<void> — execute a run left pending, for a custom service.
serviceAgentServiceYour own turn runtime (PersonalAgentTurnService).

Without Nest, createPoppyHandler({ path, conversations, authenticate }) answers a framework-free PoppyExchange — nodeExchange(req, res, { origin }) builds one over a bare Node server.

On this page