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
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}/messages | Another message → 202. |
GET {endpoint}/{id}/events | Events after cursor (wait holds the request), or SSE with Accept: text/event-stream. |
POST {endpoint}/{id}/handoff | Ask for a person → 202. |
POST {endpoint}/{id}/close | Close → 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
authorizationevent:sign_in_requiredfor a signed-out token,insufficient_scopeotherwise, andscopenaming 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
actionthat 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 indata.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 throughbackgroundActorResolverand 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):statusissucceeded,failed(witherror) orrejected. 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 byformatAction(action, texts)— by defaulttexts.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
| Field | Default | |
|---|---|---|
path | 'poppy/conversations' | The endpoint, from the server root. |
baseUrl | request origin | Public origin — what the resolver checks DPoP htu against. Required to announce the endpoint for poppy.json. |
agent | default agent | The registered agent that answers. |
authenticate | global slot | Your own Session Token resolver. |
actorFor | { id: accountId } / poppy:<hash> | (principal, defaults) => Actor when the resolver gave no actor. |
store | AGENT_POPPY_STORE, else memory | A PoppyStore. |
actions | 'approve-delegated' | Or 'reject'. |
handoff, direct | — | Hooks above. |
texts | English | handoffUnavailable, failed, signIn, actionSucceeded, actionFailed, actionRejected. |
formatAction | formatPoppyAction | (action, texts) => string | null — how an action reads in the reply. |
timeoutMs | 120000 | A turn running longer is cancelled. |
maxBodyBytes | 1 MiB | Largest request body. |
maxWaitSeconds | 30 | Cap on wait. |
streamMaxMs | 5 min | A stream is closed after this; the personal agent reconnects. |
retentionMs | keep | Prune older events. |
drive | none | (runId) => Promise<void> — execute a run left pending, for a custom service. |
service | AgentService | Your 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.
Text channels
Put the agent on WhatsApp (Evolution API, Whatsmiau or the Cloud API) and Telegram — one webhook route per channel that verifies, deduplicates, answers durably and in order, converts markdown, splits long replies, attaches media, sends files, asks questions as text, turns proposals into Confirm/Cancel buttons and relays their outcomes — with hooks for the app's own flows.
Poppy connections (your agent at other companies)
Let your agent act on OTHER Personal Agent Protocol ("Poppy") companies for its users — agent identity, per-user connections sealed at rest, connect links that resume the conversation, company tools and agent behind your confirmation.