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.
Experimental — tracks a draft spec
This implements the client side of the Personal Agent Protocol (Poppy) Draft 0.1. The spec is still in development; this surface will change, possibly in breaking ways and outside semver majors, as it evolves.
Poppy lets personal agents talk to your app. This is the other direction: your app's agent becomes
a personal agent for its users at other Poppy companies — it discovers them, connects each user's
account there, reads through their MCP tools and their own agent, and acts — always behind your app's
confirmation. It is built on the framework-agnostic Poppy client that ships in the same package
(@dudousxd/nestjs-agent-poppy/client).
Mounting it
import { AgentModule } from '@dudousxd/nestjs-agent';
import { AgentPoppyConnectionsModule } from '@dudousxd/nestjs-agent-poppy';
@Module({
imports: [
AgentModule.forRoot({ /* … */ }),
AgentPoppyConnectionsModule.forRoot({
baseUrl: process.env.APP_URL!, // HTTPS: the agent identity lives under it
identity: {
name: 'Acme Assistant', // what companies show on their consent screen
keys: process.env.POPPY_AGENT_KEYS!, // JSON array of private EC P-256 JWKs
},
secret: process.env.POPPY_CONNECTIONS_SECRET!, // ≥ 32 chars: seals connections at rest
// Default: NO company. The app decides where its agent may go.
companies: {
'shop.example': { label: 'Shop', readScopes: ['poppy:read', 'orders:read'] },
},
}),
],
})
export class AppModule {}forRootAsync({ path?, imports, inject, useFactory }) takes the same options from a factory
(ConfigService); path stays static, since the routes mount when the module is imported.
Generate a key with generatePoppyAgentKey() (from @dudousxd/nestjs-agent-poppy) and keep the JSON
in an env var. Several keys rotate: the first signs, all are published.
| Field | Default | |
|---|---|---|
baseUrl | — | Your public origin, with your global route prefix if you set one. client_id is {baseUrl}/{path}/agent.json. |
path | 'poppy-agent' | Identity (agent.json, jwks.json, logo.svg), connect pages, oauth/callback. |
identity | — | name, logoUri (default: a served {path}/logo.svg with the name's initial), keys. |
secret | required | At least 32 characters. Seals connections at rest (AES-256-GCM) and keys the connect links. Keep it stable: a new one makes stored connections unreadable. |
companies | none | A list, a map to { label, scopes, readScopes }, or a predicate. |
owner | actor.tenantRef ?? actor.id | Whose connection a turn uses. |
store | AGENT_POPPY_CONNECTION_STORE | Your own PoppyConnectionStore. Unset: the store module's binding (Drizzle, MikroORM, Redis), else process memory — with a boot warning, connections are lost on restart. |
insecureDev | false | Development only, not spec-compliant: http companies and identity. Refused when NODE_ENV=production. |
connectTtlSeconds | 900 | How long a connect link — and its card — works. |
replyTimeoutMs | 90000 | How long ask/request wait for the company's agent. |
tools | { prefix: 'poppy_' } | prefix, roles, ability, connect ('action' card that resumes, or 'link'), writes (false = build your own confirmed writes), register (false = you call registerPoppyConnectionTools on your own registry), twoStep (see below). |
texts | English | Pages and confirmation cards. |
render | built-in HTML | (view, texts) => string — give the connect pages your look. |
api | — | { actor(req) } mounts GET/DELETE {path}/api/connections[/:domain] for your settings UI. null → 401. |
The routes are plain controllers under path. They must be public — companies fetch the
identity, and the person opens the connect link from a chat — so if your app has a global guard,
let {path}/* through it. They need no session or CSRF token: the connect id in the URL is the
capability.
The tools are registered in AGENT_TOOL_REGISTRY, so your RolesPolicy, personas and agent tool
allow-lists apply (tools: ['poppy_companies', 'poppy_connect', …]). Personal agents never reach
them (they carry no app roles).
Connections
One connection per owner (person/account) and company: the company's issuer, the granted scopes,
and the client state — the per-company User ID, the DPoP key, the Account Token (a refresh token) and
Session Tokens — sealed with secret. Sessions, DPoP proofs and nonces, renewals, narrowing and
Account Token rotation are handled by the client; calls of one owner are serialized so a rotated token
is never lost. Inject PoppyConnections for list(owner), connection(owner, domain) and
disconnect(owner, domain); a disconnect revokes the Account Token at the company (Poppy §4.9), and a
revocation on their side is noticed (invalid_grant) and marks the connection revoked.
Connecting — a link, then the conversation resumes
poppy_connect is an action: its confirmation card carries a link to a page on your app
(/{path}/connect/:id) — a link works in the web chat and in WhatsApp alike. There the person picks:
- Sign in at the company (Direct, Poppy §4.5): PKCE,
stateand the RFC 9207isscheck, back to your callback; - a form on your page (Mediated, Poppy §4.7) when the company offers it: the fields the company declares, posted from the person's browser to your server and straight to the company — never stored, logged or shown to the model; a one-time code on the next page;
- a code (Device, Poppy §4.6) when Direct is not offered.
Completing it decides the parked connect call (a proposal decision under
actionApprovalMode: 'independent', an approval otherwise — recorded as decided via: 'poppy-connect'):
the turn resumes with the account connected and does what was asked. The link is a 128-bit capability,
keyed to that call with your secret, expiring (connectTtlSeconds, 15 min) and single-purpose. A call
whose account is already connected with the scopes asked completes at once, without a card.
A loop of your own
An app that runs the loop with its own per-turn registry and its own write confirmation (no
AgentService parking) sets tools: { register: false } and calls
registerPoppyConnectionTools(registry, connections, { connect: 'link', writes: false }) on its
registry: connect then returns the link as a read tool, and the app builds its confirmed writes on
callCompanyTool(connections, owner, input, false) and talkToCompany(connections, owner, input, true).
Tools
| Tool | Kind | |
|---|---|---|
poppy_companies | read | Allowed companies and the person's connection at each. |
poppy_connect | action | The connect card (see above); scopes for a step-up. |
poppy_tools | read | The company's MCP tools, with readOnly. |
poppy_read | read | Runs a tool the company marks readOnlyHint; anything else is refused. |
poppy_write | action | Any other company tool — your confirmation card shows the tool and its arguments (and the company's own preview, for a two-step tool). |
poppy_ask | read | A question to the company's agent with a token narrowed to readScopes (default poppy:read): a question cannot make the company act. |
poppy_request | action | A request for the company's agent to act — the person confirms the message. |
poppy_disconnect | action | Revokes the connection. |
The model may name a company rather than its domain ("Shop", shop.com): it resolves to the one
allowed company whose label or organization name matches. Anything else is refused with a pointer
back to poppy_companies, and nothing is fetched.
Two-step company tools. Some companies confirm writes themselves: called without confirm, the
tool only answers a preview and a confirmToken. poppy_write detects a tool whose input schema has
both fields: the card shows the company's preview (its summary/resumo/message), and confirming
the card makes the call and commits it with the fresh token — one confirmation, by the person, in
your app. tools.twoStep renames the fields ({ confirm, token, summary }) or turns it off (false).
Building that card calls the company's tool before the person confirms (without confirm). It is
safe only with a company whose preview call really changes nothing; for one you do not trust to honour
that, set tools.twoStep: false — the card then shows the tool and its arguments, and the tool is
called once, after the confirmation.
A company answer of insufficient_scope, sign_in_required or an authorization event comes back to
the model as needs_permission / not_connected with the next step — poppy_connect with the scopes —
never as a dead end. Opening the company's website for the person (Poppy §5 browser sessions) is not offered:
the spec requires the assertion to be posted from a browser the agent controls, not the person's.
Independent approvals and text channels
With actionApprovalMode: 'independent', completing the sign-in decides the connect proposal; once
it ran and its outcome is in the thread (ActionProposalWorkerService.onAdmitted), the module starts
the next turn on the thread in the person's voice (texts.continueAfterConnect), so the model goes on
with what was asked. The turn is delivered where the conversation is: the web chat follows it, and a
text channel (WhatsApp, Telegram) gets it through the AGENT_CHANNEL_FOLLOWER that
AgentChannelsModule binds — the channel address is the proposal's recorded pageContext.channel.
The connect card's confirmation carries link: a text channel sends it as one message with the link
in full and no buttons nor reply commands — the link is the action. A screen can render it as the
card's main button.
A proposal still waiting tells the model plainly that nothing happened yet
(PENDING_ACTION_PROPOSAL_NOTE), so it says it is awaiting confirmation rather than done.
The connect card lives as long as its link (connectTtlSeconds, default 15 minutes) — not the
approval policy's ttlMs (ToolSpec.approvalTtlMs). A sign-in finished after the card expired
anyway still stores the connection and continues the conversation.
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.
Asking the user
Collect the scope before the work — an authored intake the turn passes through, or a model-callable `ask` tool — both parking the run on the same durable signal an approval uses.