PAP is an open protocol for AI agents that act for a person (such as Claude, or your own assistant) to talk to a business on that person’s behalf: discover what the company offers, start a session, sign the user in with the right scope, chat with the company’s own support agent, call its APIs, and get explicit user approval before anything with real consequences happens.
The protocol never lets the company mistake an agent for a person. Every request carries a session token that says which agent is calling, and the agent is forbidden from dropping it to look like a human browser.
Owns the intent (“get me to Lisbon next Friday under $600”). Signs in to the company themselves, and is the only one who can approve real actions, either one at a time or as a standing permission.
client_id is a URLIdentified by an HTTPS URL serving its metadata and public keys. Gives each user a stable, pairwise, non-PII user id per company. Holds tokens as secrets, never in model context.
Publishes /.well-known/poppy.json. Runs an OAuth issuer, optionally a support agent (with human handoff), APIs (OpenAPI or MCP), and a website the agent can browse inside the same session.
An agent knows nothing about a company until it fetches https://{domain}/.well-known/poppy.json. That document points to five surfaces. Only organization plus at least one of agent, apis or web is required, so a company can start small.
protocol_version “0.1” · organization {name, domain} · must match host (ignoring one leading www.) · cached per HTTP headers
RFC 8414 metadata with poppy_domains. Sign-in modes: direct, device, mediated. Optional custom_scopes.
Endpoint where the personal agent chats with the company’s support agent, with optional handoff to a human.
Typed tools. OpenAPI takes DPoP tokens, MCP (2025-06-18, streamable HTTP) takes Bearer tokens bound to its URL.
Agent joins its own headless browser to the session, so the website sees the same signed-in state.
operations is the propose → approve → confirm ledger. Others must be domain-prefixed.
{
"protocol_version": "0.1",
"organization": { "name": "Poppy Travel", "domain": "poppy.travel" },
"auth": {
"issuer": "https://poppy.travel",
"direct": { "scopes": ["poppy:read", "poppy:write", "travel:loyalty"] },
"device": { "scopes": ["poppy:read", "poppy:write"] },
"custom_scopes": {
"travel:loyalty": "See and redeem your Poppy Miles balance"
}
},
"agent": { "protocols": [
{ "type": "poppy", "endpoint": "https://poppy.travel/poppy/conversations" }
]},
"apis": [
{ "type": "mcp", "url": "https://poppy.travel/mcp",
"description": "Search flights, hotels, fares and seat maps" },
{ "type": "openapi", "url": "https://poppy.travel/openapi.json",
"description": "Trips, bookings, changes and refunds" }
],
"web": { "browser_session_endpoint": "https://poppy.travel/poppy/browser-session" },
"extensions": {
"operations": { "version": "1", "endpoint": "https://poppy.travel/poppy/operations" }
}
}
Paths under /poppy/*, /oauth/* and /mcp are this example's choice. The spec fixes only /.well-known/poppy.json; the Company publishes every other URL in poppy.json and its OAuth metadata.
issuer == auth.issuer and poppy_domains contains the domain. Metadata fetches carry no cookies or tokens.private_key_jwt; sub is the pairwise user id. Response: session_id, DPoP-bound access token, signed_in:false, lifetime of hours.authorization event or a WWW-Authenticate challenge naming the scope. User signs in via direct (PKCE S256) or device code. Agent receives a new session token plus an account token (refresh token).wait or SSE). A human can take over; responder flips to human.revision, plain-language summary, exact terms (fare, fare class, refundability), expires_at. Nothing is charged yet.409 terms_changed. On success, result.summary says what changed.
sequenceDiagram
autonumber
actor U as User
participant A as Personal agent
participant I as Company issuer (OAuth)
participant M as Company MCP / OpenAPI
participant O as Operations ledger
A->>I: GET /.well-known/poppy.json + oauth-authorization-server
A->>I: POST /oauth/token (jwt-bearer, private_key_jwt, DPoP)
I-->>A: session_id, token (signed_in=false)
A->>M: search_flights LIS, Fri (Bearer, resource=/mcp)
M-->>A: 14 options
U->>A: "Book the 08:40 TAP, aisle seat"
A->>M: POST /trips/book (DPoP)
M-->>A: 401 WWW-Authenticate scope="poppy:write"
A->>U: Sign in to Poppy Travel?
U->>I: Direct sign-in (PKCE S256, consent page)
I-->>A: new session token + account token
A->>M: POST /trips/book (DPoP)
M->>O: create operation op_7Q rev 1
M-->>A: operation proposed (summary, terms, expires_at)
A->>U: "Poppy Travel wants to: Book TP1351 LIS, EUR 412, non-refundable"
U-->>A: Approve
A->>O: POST /operations/op_7Q/confirm {revision:1}
O-->>A: succeeded, result.summary "Booked. PNR K9XT2B"
| Credential | Who mints | Where it’s accepted | Key rules |
|---|---|---|---|
| Client assertion | Agent (its JWKS key) | Token endpoint | private_key_jwt (RFC 7523), short-lived; guides: ES256 preferred, none and HS256 rejected |
| Session token (DPoP) | Company issuer | OpenAPI, conversation, operations, browser-session | Hours not days; bound to agent key; proof checks htm, htu, iat, jti, ath, optional nonce |
| Session token (Bearer) | Company issuer | MCP server only | Requested without DPoP, with resource = the MCP url (RFC 8707); works at that MCP server only |
| Account token | Company issuer (refresh token) | Token + revocation endpoints only | Stored as a secret, never in prompts/logs/URLs; refresh can narrow scope, never widen |
| Browser assertion | Agent | Browser session endpoint (POST body) | ≤60s; sets a Secure, HttpOnly cookie that tracks sign-in state and dies with the session |
| Mode | How | Use when | Spec notes |
|---|---|---|---|
direct | Authorization code + PKCE S256, user approves on company consent page | Agent has a UI that can open a browser | Exact redirect_uri match; agent checks iss (RFC 9207); consent page has CSRF token and frame-ancestors none |
device | RFC 8628 device code; user enters a code on their phone | Voice assistants, chat apps, headless agents | Opening the link does not approve; polling too fast gives slow_down (+5s) |
mediated | Agent relays fields (email, password, OTP) to auth.mediated.endpoint | Legacy logins only | Secret fields never echoed or logged; rate-limited; one-time code on unusual sign-ins. Agents prefer it last. |
Scopes: poppy:read, poppy:write, plus your own (e.g. travel:loyalty). The company never grants more than requested. A session can sign out and keep going anonymously, but can never switch to a different account (account_mismatch).
Your company’s support agent becomes an endpoint. Messages carry text, data (structured JSON) and/or context (locale, time_zone, user_available). Each message says whether a person or an agent wrote it: sender: human only for the user’s own words.
flowchart LR
A[Personal agent] -- "POST /conversations" --> C((cnv_…))
A -- "POST …/messages {id, sender, text|data|context}" --> C
A -- "GET …/events?cursor&wait or SSE" --> C
A -- "POST …/handoff" --> H[Human agent]
A -- "POST …/close" --> C
C --> E1[message]
C --> E2["state: working | idle | queued | closed"]
C --> E3["authorization: needs scope X"]
C --> E4["user_requested: need the user present"]
C --> E5["direct_opened / direct_closed"]
H -. "responder = human" .-> C
409 message_id_conflict.cursor_expired means history was lost and the agent must tell the user.text-delta SSE events are display-only; the agent must not act on them.Anything with consequences (book, change, cancel, refund, redeem miles) becomes an operation. The company spells out exactly what will happen, the user approves it, and the company does it at most once.
stateDiagram-v2 [*] --> proposed: company proposes rev 1 proposed --> proposed: price moved → new revision (old one frozen) proposed --> in_progress: confirm latest revision proposed --> cancelled: agent cancels proposed --> expired: expires_at passes in_progress --> succeeded: result.summary in_progress --> failed: only if known not to have happened succeeded --> [*] failed --> [*] cancelled --> [*] expired --> [*]
{
"operation_id": "op_7Q2c",
"revision": 2,
"state": "proposed",
"summary": "Book TAP TP1351 SFO→LIS, Fri 17 Oct 08:40, seat 14C, Basic fare, non-refundable. Charge EUR 437 to Visa ••4242.",
"terms": { "total": {"amount": "437.00", "currency": "EUR"}, "fare_class": "basic",
"refundable": false, "change_fee": {"amount": "75.00", "currency": "EUR"} },
"expires_at": "2026-10-10T18:15:00Z",
"user_approval_required": true,
"confirmation": null,
"result": null
}
Anonymous flight, hotel and package search over MCP. Rate-limit per client_id, not per IP. Agents get real inventory instead of scraping.
“What’s on my trip?” Itineraries, PNRs, boarding passes, loyalty balance, visa reminders.
Book, hold, change date, upgrade seat, add bags, cancel, refund. Each one is a proposal with terms the user approves.
Fares move every few minutes. A price change issues a new revision; the agent re-asks the user only when it matters.
Auto-rebook on disruption, auto-check-in, auto-accept a free upgrade, price-drop rebook under a ceiling.
travel:loyalty (redeem miles), travel:documents (passport details for APIS), travel:payments.
Your own AI concierge answers policy questions; complex disruptions hand off to a human, with responder: human visible to the agent.
“Need the traveller to confirm passport expiry” or a 3-D Secure challenge only the person can complete.
Side thread with a specific hotel’s front desk or a group-booking desk, while the main trip thread stays open.
Hand the agent the seat-map or ancillaries page that has no API yet, already signed in.
time_zone and locale for local departure times and currency; user_available:false tells the concierge to batch questions.
The protocol is mostly plumbing: OAuth, JWT/DPoP, session store, event log, operation ledger. Every company needs the same plumbing; what differs is the domain logic. So the framework owns the plumbing and the developer writes only tools, operations and a support agent.
flowchart TB
subgraph App["Your Next.js app (App Router)"]
CFG["poppy.config.ts
definePoppy({...})"]
RT["app/[...poppy]/route.ts
one catch-all handler"]
UI["app/poppy/consent · /device · /admin"]
end
subgraph Core["@poppy/core (framework-agnostic)"]
DISC[discovery + poppy.json builder]
OAUTH[issuer: jwt-bearer · PKCE · device · revoke]
DPOP[DPoP + client metadata verifier]
CONV[conversation engine: events, cursors, SSE, handoff]
OPS[operations ledger: revisions, at-most-once]
MCP[MCP server + OpenAPI generator]
end
subgraph Adapters
ST[(store: Postgres / Drizzle · Redis · memory)]
AU[user auth: Auth.js · Clerk · Supabase]
LLM[support agent: Claude via AI SDK]
end
CFG --> RT --> Core
UI --> Core
Core --> ST
OAUTH --> AU
CONV --> LLM
// poppy.config.ts
import { definePoppy, tool, operation, z } from "@poppy/next";
import { drizzleStore } from "@poppy/store-drizzle";
import { authJs } from "@poppy/auth-authjs";
import { claudeConcierge } from "@poppy/agent-claude";
export default definePoppy({
organization: { name: "Poppy Travel", domain: "poppy.travel" },
store: drizzleStore(db),
auth: authJs({ signIn: ["direct", "device"], customScopes: {
"travel:loyalty": "See and redeem your Poppy Miles balance" } }),
tools: { // exposed over MCP + OpenAPI
searchFlights: tool({ scope: null, input: z.object({ from: z.string(), to: z.string(), date: z.string() }),
run: ({ input }) => inventory.flights(input) }),
myTrips: tool({ scope: "poppy:read", run: ({ account }) => trips.list(account.id) }),
},
operations: { // propose → approve → confirm
bookFlight: operation({
scope: "poppy:write",
input: z.object({ offerId: z.string(), seat: z.string().optional() }),
propose: async ({ input }) => {
const offer = await inventory.price(input.offerId);
return { summary: describe(offer), terms: termsOf(offer), expiresIn: "15m" };
},
reprice: async ({ input }) => termsOf(await inventory.price(input.offerId)), // new revision if changed
execute: async ({ input, account, idempotencyKey }) => {
const pnr = await gds.book(input, account, { idempotencyKey });
return { summary: `Booked. PNR ${pnr.locator}`, data: pnr };
},
}),
},
concierge: claudeConcierge({ model: "claude-sonnet-5-5", knowledge: "./policies",
handoff: { when: "disruption|complaint", inbox: "/poppy/admin/inbox" } }),
});
// app/[...poppy]/route.ts — serves .well-known, /oauth/*, /mcp, /poppy/*
export { GET, POST } from "@poppy/next/handler";
| Piece | Why it matters |
|---|---|
create-poppy-app starter | Templates: travel, retail, SaaS. Seeded data, dev TLS, a fake personal agent to click through. |
| Agent simulator (dev UI) | Plays the personal agent: discovery, DPoP, sign-in, approvals. Like Stripe’s test mode for PAP. |
| Admin console | Live sessions per client_id, operation ledger, human handoff inbox, client allow/blocklist, revoke. |
| Conformance runner | Runs requirement-tagged checks, each citing the official spec section it proves, against your deployment. |
<PoppyBadge/> + <AgentBanner/> | Shows humans when an agent session joined the website; links the browser session to the agent. |
Client SDK @poppy/agent | The other side: lets anyone build a personal agent (or a Claude tool) that talks to any PAP company. |
terms_changed live. Standing-permission rebook on a simulated delay.responder: human.approved_by: standing_permission, and the user only gets a notification.Hosted poppy issuer + operations ledger as a service. A company adds a DNS record and a webhook per operation; you run the OAuth and DPoP.
Shopify, Booking engines (Amadeus, Sabre, Duffel), Cal.com. Wrap their APIs as tools and operations so they become agent-ready overnight.
Paste a domain, get a score: poppy.json valid, issuer checks, DPoP, scopes, conformance. Lead-gen for the framework.
Standing permissions plus a flight-status feed: rebooking, hotel vouchers and lounge passes as operations the agent approves within user-set rules.
One user agent talks PAP to airline, hotel and car rental; a trip planner composes operations across companies and shows one approval sheet.
Each traveller’s own agent approves their own leg and payment. The company sees N sessions bound to one group booking.
Which client_ids convert, abandonment at sign-in step-up, terms_changed rate. A new funnel nobody measures yet.
A shared vocabulary for terms (money, dates, refundability) so agents can evaluate permissions consistently. Could become a PAP extension (poppy.travel/terms).
Payments are out of scope for 0.1. Prototype a domain-prefixed extension using tokenised cards or Stripe’s agent payments so you are ready when it lands.
@poppy/core behind a version switch on protocol_version; unknown fields must already be ignored.execute.jose and test with a real agent peer.failed when you know the action did not happen, so the ledger must be transactional with the GDS call.