Personal agents — Poppy
Serve the Personal Agent Protocol ("Poppy", Draft 0.1) — discovery, agent sessions, sign-in, DPoP tokens, MCP and browser sessions.
Experimental — tracks a draft spec
The Personal Agent Protocol ("Poppy") is a
Draft 0.1 still in development. This implementation targets Draft 0.1 and tracks the
spec as it evolves: the config (personalAgents.poppy), the exports (verifyPoppyRequest,
poppyAuth, the global slots…) and the wire behaviour will change, possibly in breaking ways
and outside semver majors, while the spec is a draft. Pin your AuthKit version if you depend
on it.
Poppy lets a personal agent (an AI assistant acting for one user) work with your app: find it, start a session for its user, have the user sign in and approve what the agent may do, call your APIs and your company agent, and browse your website — all in one session. It runs next to PACT: an app can serve both.
How it differs from PACT: instead of an agent JWT plus a delegation token on every call, your app
runs an OAuth authorization server with sessions. The agent identifies itself by its
client_id — an HTTPS URL serving its metadata document — starts a signed-out Session with a
signed assertion, and gets short-lived, DPoP-bound Session Tokens. When the user signs in, the
agent also gets a long-lived Account Token (an OAuth refresh token) that only works at your
token endpoint.
How the user signs in
A Session starts signed out: the agent can only reach what you expose to anyone. To act on the user's account, the user signs in to your app and approves scopes. The spec offers three ways; the agent picks one of those you enable:
| What the user does | Who sees the credentials | Default | |
|---|---|---|---|
| Direct (Poppy §4.5) | The agent opens your login page (OAuth code + PKCE); the user signs in there as usual and approves the scopes. | Only your app. | on |
| Device (Poppy §4.6) | The agent shows a short code and a link; the user opens the link on any device, signs in and approves. For agents with no browser (voice, chat). | Only your app. | on |
| Mediated (Poppy §4.7) | The user never leaves the conversation: the agent asks for what your app declares (an email, say), your app sends a one-time code to the user, and the user tells the code to the agent. | The agent relays what the user types — so ask for an identifier and a one-time code, never a password. | off |
Direct and Device need nothing beyond your normal login: AuthKit's pages do it. Mediated needs a
verify that checks what the agent sends — or the ready-made
poppyEmailCodeSignIn(), which does it with your
accounts and email.
Enabling it
defineConfig({
// ...
mcp: true, // optional: lets Poppy Bearer tokens reach the MCP server of @adonis-agora/agent
personalAgents: {
// PACT stays optional: omit `audience` for a Poppy-only app.
poppy: {
organization: { name: 'Acme', domain: 'acme.com', aliases: ['acme.co.uk'] },
// Your own scopes (`custom_scopes`). Default: `personalAgents.delegation.scopes`.
scopes: { addresses: 'Manage saved shipping addresses' },
signIn: {
direct: true, // OAuth code + PKCE on your own page (default on)
device: { scopes: ['poppy:read', 'poppy:write'] }, // RFC 8628 (default on)
mediated: {
// Off unless you configure it: you verify the credentials.
fields: [
{ name: 'email', label: 'Email', secret: false },
{ name: 'password', label: 'Password', secret: true },
],
scopes: ['poppy:read'],
async verify({ credentials }) {
const account = await accounts.verifyCredentials(credentials.email, credentials.password)
if (!account) return { status: 'failed' }
if (looksUnusual(account)) {
const code = await sendSmsCode(account) // you send it
return { status: 'code_required', accountId: account.id, sentTo: 'Text to …71', code }
}
return { status: 'complete', accountId: account.id }
},
},
},
// Your company agent. Omit it and @adonis-agora/agent registers its endpoint (see below).
agent: { protocols: [{ type: 'poppy', endpoint: 'https://acme.com/poppy/conversations' }] },
apis: [
{ type: 'openapi', url: 'https://api.acme.com/openapi.json', description: 'Orders, returns' },
{ type: 'mcp', url: 'https://acme.com/mcp', description: 'Product search' },
],
},
},
})| Field | Default | Notes |
|---|---|---|
organization | — | name + domain (host only). poppy.json is served only on these hosts (a leading www. is ignored); every domain goes to poppy_domains. |
prefix | /poppy | Route prefix and issuer path (https://acme.com/poppy). Can't sit under the OIDC mountPath or the PACT prefix. |
scopes / broadScopes | PACT scopes / true | Custom scopes (no poppy: prefix) and whether poppy:read/poppy:write are offered. |
signIn.direct / .device | on, all scopes | false disables; { scopes } restricts. |
signIn.mediated | off | fields, scopes (default poppy:read), verify, optional verifyCode, codeTtl (600 s), maxCodeAttempts (5). |
agent, apis, extensions | — | Published as-is in poppy.json. An entry with resource requires tokens issued for it (RFC 8707). |
web | browser session on | false disables; login(ctx, { accountId, … }) replaces the default login (see below). |
clients | open | requireRegistration, registered, blocked (lists or functions). Unregistered agents get invalid_client. The admin console can allow and block on top of these without a deploy. |
allowInsecureClientIds | false | Development/test only, not spec-compliant. Accepts http:// client_ids (and their jwks_uri/redirect_uris) and fetches them without the SSRF guard, so a local agent (the poppy-agent CLI of @adonis-agora/agent, with --insecure-dev, at http://localhost:7777/agent.json) can start Sessions. Boot fails with NODE_ENV=production. |
sessionTokenTtl / accountTokenTtl / sessionTtl | 1 h / 30 d / 30 d | Session Token lifetime, Account Token lifetime (refresh_token_expires_in), idle Session lifetime. |
dpop | ES256, 60 s | requireNonce, maxAge, algorithms. |
replay | 'database' | Where seen jtis live: 'database', 'memory', { redis: 'main' }, or your own { claim }. |
rateLimit | 120 sessions/min per agent, 10 mediated attempts/h per user | In-memory per process; false or your own limiter. |
toActor | — | Maps a verified principal to your actor (returned as principal.actor). |
Four lib-owned tables back it — auth_poppy_sessions, auth_poppy_tokens,
auth_poppy_requests, auth_poppy_jtis — created by ensureAuthkitSchema(). Account Tokens are
grants in auth_agent_grants (ids pgrant_…), so they show up — and are revoked — in the same
Personal agents list of the account console as PACT delegations.
Theming the consent screens
The Direct and Device approval screens (and PACT's delegation screen) render through your
config.render as the views agents/consent and agents/done — the same mechanism as the
login pages. With inertiaRenderer, add them to views and write
inertia/pages/authkit/agents/consent.tsx / done.tsx:
render: inertiaRenderer({ prefix: 'authkit', views: ['login', 'agents/consent', 'agents/done'] })| View | Props |
|---|---|
agents/consent | csrfToken, action (POST/GET target), account: { email } | null, error ('agents.consent.impersonating' | 'agents.consent.invalid_code' | null), request: null | { agentName, agentOrigin, logoUri?, scopes: { id, description }[], hidden?: { name, value }[] (Direct: post them back), userCode? (Device/PACT: post it back as user_code) } |
agents/done | status: 'approved' | 'denied' | 'invalid' | 'expired', agentName, scopes |
Submit a plain HTML form (decision=allow|deny, one scope per checked box, _csrf): the
controller answers with a redirect to the agent or the agents/done view. Without request, the
view asks for the device code (a GET form with user_code). The screens deny framing and refuse an
impersonated session whatever the view looks like.
Ready-made Mediated Sign-In: email + code
You don't have to write verify to offer Mediated Sign-In. poppyEmailCodeSignIn() asks the agent
for the account's email, emails a 6-digit code to it and lets the agent submit the code. It uses
AuthKit's own accounts (accountStore.findByEmail) and email (the mail.onPoppySignInCode hook, or
the default mailer with your branding and i18n; without @adonisjs/mail the code is logged, for
dev):
import { poppyEmailCodeSignIn } from '@adonis-agora/authkit-server'
personalAgents: {
poppy: {
// …
signIn: {
mediated: poppyEmailCodeSignIn({
scopes: ['orders:read'], // default: ['poppy:read']
eligible: (account) => account.kind === 'customer', // default: every active account
}),
},
},
}| Option | Default | |
|---|---|---|
scopes | ['poppy:read'] | Scopes this sign-in can grant. |
codeTtl | 600 | Code lifetime, in seconds. |
maxCodeAttempts | 5 | Wrong codes before failed. |
eligible | every active account | (account) => boolean — who may sign in this way. |
maxPerHour | 5 | Codes per email per hour, per instance, so an agent can't flood someone's inbox. |
label / sentTo | 'Email address' / `Email to a***@example.com` | Text the agent shows the person. |
It never enumerates accounts: an unknown email, a disabled account, one eligible refuses, or an
email over maxPerHour all get the same code_required answer without a code, and the email is
sent without being awaited so the response time doesn't tell either. The email names the agent
(from its metadata) and tells the person to ignore it if they didn't ask.
Mediated Sign-In without account enumeration
If you write your own Mediated Sign-In and it only asks for an identifier (email, phone) and then sends a code, answering
failed for an unknown identifier tells any agent who has an account. Answer an unknown (or
ineligible) identifier exactly like a real one instead:
async verify({ credentials }) {
const account = await accounts.findByEmail(credentials.email)
const sentTo = `Email to ${mask(credentials.email)}`
if (!account) return { status: 'code_required', accountId: null, sentTo } // no code is sent
const code = randomCode()
await mail.sendLater(/* … code … */) // queue it: the same work, and time, on both paths
return { status: 'code_required', accountId: account.id, sentTo, code }
}With accountId: null AuthKit answers the same code_required shape, and every code submitted for
that sign-in is treated as a wrong code — same attempt counting, same failed at the end. The
Session's account is only compared at the code step, so account_mismatch cannot leak it either.
Rate limits (rateLimit.mediatedPerHour, the OTP throttle) still apply.
Routes
| Route | Spec | Who calls it |
|---|---|---|
GET /.well-known/poppy.json | §3 | Agent — discovery (only on your domains) |
GET /.well-known/oauth-authorization-server/poppy (+ /poppy/.well-known/…) | §3.2 | Agent — RFC 8414 metadata with poppy_domains, device_authorization_endpoint, revocation_endpoint |
POST /poppy/oauth/token | §4.2–4.8 | Agent — jwt-bearer sessions, refresh_token, authorization_code, device_code |
POST /poppy/oauth/revoke | §4.9 | Agent — RFC 7009 sign-out |
POST /poppy/oauth/device | §4.6 | Agent — device authorization |
GET/POST /poppy/oauth/authorize | §4.5 | User — Direct Sign-In consent (behind the account session) |
GET/POST /poppy/device | §4.6 | User — device code approval (behind the account session) |
POST /poppy/sign-in, /poppy/sign-in/:id | §4.7 | Agent — Mediated Sign-In (Session Token + DPoP) |
POST /poppy/browser-session | §5 | Agent's browser — joins the website session |
authkitCsrfExceptions exempts the agent endpoints automatically; the two consent screens keep
CSRF, deny framing, refuse impersonation and never approve on open.
Protecting your APIs
import { poppyAuth, poppyOf } from '@adonis-agora/authkit-server'
router
.post('/orders/:id/exchanges', async (ctx) => {
const p = poppyOf(ctx)! // { userId, accountId, clientId, scopes, sessionId, signedIn, … }
// …act on p.accountId
})
.use(poppyAuth({ scopes: ['poppy:write'] }))poppyAuth() / verifyPoppyRequest(ctx | { method, url, headers }, options) check the
Authorization: DPoP token and its DPoP proof (signature, htm, htu, iat window, ath,
key binding, replay, nonce), the session, and whether the session is still signed in (a
sign-out takes effect on the next request). Failures answer with the spec's WWW-Authenticate:
invalid_token (401), sign_in_required (403), insufficient_scope (403, with scope),
invalid_dpop_proof / use_dpop_nonce (401, with DPoP-Nonce). Tokens are never read from the
URL. Bearer tokens are accepted only at the MCP server they were issued for.
verifyDpopProof() is exported on its own for other resource servers.
MCP
A Poppy agent asks for a Bearer token without a DPoP proof and with resource set to an MCP URL —
one of your apis of type mcp, or one registered in the @adonis-agora/oauth:resources slot
(what @adonis-agora/agent's MCP provider does). For a signed-in session the token is minted as
an oidc-provider access token (aud = the MCP URL, scopes = the granted Poppy scopes), so the
existing authKitAuth() MCP integration accepts it with no extra code, and a sign-out revokes it.
Signed-out Bearer tokens live only in the Poppy store — verify them with verifyPoppyRequest or the
global slot below.
The company agent (@adonis-agora/agent)
Conversations (Poppy §7) are implemented by @adonis-agora/agent. Neither package imports the other;
they meet through two global slots:
// authkit → agent: authenticate a request (installed at boot when `personalAgents.poppy` is set)
const authenticate = globalThis[Symbol.for('@adonis-agora/poppy:authenticate')]
const result = await authenticate(
{ method: 'POST', url: 'https://acme.com/poppy/conversations', headers },
{ scopes: ['poppy:read'], signedIn: true }, // optional
)
// { ok: true, principal: { userId, accountId, clientId, scopes, sessionId, signedIn,
// resource, tokenType, actor? } }
// | { ok: false, status: 401 | 403, error, scope?, wwwAuthenticate, description, headers }
// agent → authkit: where the conversations live (used when `agent.protocols` is not configured)
globalThis[Symbol.for('@adonis-agora/poppy:endpoints')] = {
conversations: 'https://acme.com/poppy/conversations',
}userId is the Poppy User ID (opaque, per agent user); accountId is the account in your app
when signed in. actor is whatever your toActor returns. Symbol.for('@adonis-agora/poppy:issuer')
holds the Poppy issuer (for the MCP protected-resource metadata).
Web browsing
POST /poppy/browser-session takes a form-posted assertion (typ: poppy-browser+jwt, aud =
the endpoint, exp ≤ 60 s after iat, session_id, return_to on your domain or a subdomain,
signed by the agent's key, single-use). On success it regenerates the app session, marks it as the
agent's, logs the user in with AuthKit's account session (+ adonisAuth.guard) when the session is
signed in, and answers 303 to return_to; otherwise 400 with no cookie. Your session cookie
must be Secure, HttpOnly and SameSite=Lax (Adonis' defaults in production). Use
web.login if your app logs users in some other way.
Add poppyWebSession() to your router middleware so the browser follows the session's current
state (signed in/out, ended) on every request; poppyWebSessionOf(ctx) gives you its scopes. An
agent's browser is refused by the account console, the admin console and the consent screens.
Signing out and disconnecting
The agent revokes its Account Token at /poppy/oauth/revoke; sessions signed in with it continue
signed out. The user can disconnect the agent from Account → Apps → Personal agents (or
DELETE /account/api/agents/:id), and "sign out everywhere", password resets, account deletion
and admin actions revoke every Account Token too. Admins can revoke any connection, or block an
agent outright, from the admin console.
Spec notes
Where Draft 0.1 is ambiguous, AuthKit takes the most literal reading:
jwks_uriandredirect_urismust be on the same host asclient_id.- The session assertion's
jtimust have at least 22 characters (128 bits in base64url). session_idis required when exchanging a code or a device code (it carries the User ID).- A token issued for a
resourceis accepted only there; a token without one is refused where aresourceis required. - Revoking another agent's Account Token is a silent no-op (RFC 7009).