Set it up, one level at a time
Four levels, and you can stop at any of them. Each one says what to build, roughly how big it is, and which of the checker's rows turn green. Every claim links the section of the spec it comes from.
Level 1: Be findable
Publish one public file that tells assistants where to go, and have your sign-in server confirm that the file is yours.
- Rough size
- Small. Two JSON documents, served over HTTPS. A day or less if you already run an OAuth server.
- Checks it turns green
- 30, all checked today
Publish one public file
A person's AI assistant (the spec calls it a Personal Agent) starts at one public file on your domain, at https://example.com/.well-known/poppy.json. You must serve it over HTTPS. The address may redirect, for example to a provider that hosts the file for you, but every redirect must go to an HTTPS address (spec 3).
The smallest useful file names your company and your website. This one follows the shape of the spec's example, cut down to what level 1 needs (spec 3, spec 3.1):
poppy.json, the whole file
{
"protocol_version": "0.1",
"organization": {
"name": "Example Company",
"domain": "example.com"
},
"web": {}
}
With only web, assistants browse your site as ordinary signed-out visitors (spec 3.1). That is a real first step: assistants can find you, and the checker can read your file.
What each field means
All of these come from the spec's field list (spec 3.1).
protocol_versionis the version of the protocol the file follows. For this draft it is"0.1".organizationholds your displaynameand yourdomain. Thedomainmust match the host the assistant asked for, ignoring a leadingwww.. If the address redirected, the host that finally served the file does not count.agent,apisandwebsay what assistants can use, and you need at least one of them.webis your website.apislists your APIs, which level 2 covers.agentlists ways to talk with your own AI agent, which level 4 covers.authnames your sign-in server. You need it as soon as the file listsagent,apisorweb.browser_session_endpoint. A company withoutauthcan only be browsed like an ordinary website.extensionsis optional. It lists extra features you support, such asoperations(level 4).
Details for implementers: naming extensions
Extensions defined with the protocol have plain names, such as operations. Anyone else must start an extension's name with a domain they control, such as example.com/gift-wrap. Each entry has a version, the extension's major version (spec 3.3).
Name your sign-in server
Levels 2 and 3 need a sign-in server, and you can name it now. It is an OAuth server: OAuth is the standard most sign-in servers already speak. Its name, an HTTPS address, goes in auth.issuer, and it identifies your company (spec 3.1, spec 3.2):
Excerpt from poppy.json
"auth": {
"issuer": "https://auth.example.com"
}
The server must publish a second public file, its OAuth metadata (RFC 8414). In the spec's example it lives at https://auth.example.com/.well-known/oauth-authorization-server (spec 3.2):
OAuth metadata (excerpt)
{
"issuer": "https://auth.example.com",
"token_endpoint": "https://auth.example.com/oauth/token",
"revocation_endpoint": "https://auth.example.com/oauth/revoke",
"poppy_domains": ["example.com"]
}
The two files point at each other. Before an assistant uses your poppy.json, it fetches this metadata and checks two things: its issuer exactly matches auth.issuer, and poppy_domains includes your organization.domain. So poppy_domains must list every domain whose poppy.json names this issuer. Without that check, any domain could claim your sign-in server and receive its tokens (spec 3.2).
The assistant takes its addresses from this metadata (spec 3.2):
token_endpointissues tokens. Required. Level 2 makes it work for assistants.revocation_endpointis where an assistant signs out. Required. Level 3 uses it.authorization_endpointanddevice_authorization_endpointstart the two kinds of sign-in in level 3. You need each one only if you offer that kind.
If you run a domain for each country, several domains may name the same issuer. They then count as one company: the same user IDs, sessions and account tokens apply across all of them (spec 3.2). A session is one person's activity at your company through one assistant; level 2 explains sessions and user IDs, and level 3 account tokens.
Assistants should cache both files according to their HTTP caching headers (spec 3, spec 3.2), so send caching headers that match how often the files change.
Tools that help
A reference implementation is promised. Sierra's announcement gives no date.
Datalayer publishes an open source SDK for Python and TypeScript (on PyPI, on GitHub). It is pre-beta. Its command line tool has a pap company verify command that checks a company's setup from the assistant's side.
Checks this level turns green
These are the checker's rows for this level, by their ids in the check catalog. Building what this level describes, and following the guides it links, is what makes them pass. A row that checks something you do not offer does not apply.
Checked today (30)
T0-01Document existsT0-02Redirects safeT0-03Valid JSON objectT0-04Supported protocol_versionT0-05Organization matches the requested hostT0-06Has an interfaceT0-07auth.issuer when neededT0-08All URLs HTTPST0-SIBPublished on the requested hostT0-09Issuer metadata reachableT0-10Issuer matchT0-11Domain bindingT0-12Required endpointsT0-21Cache headersADV-PKCENo plain PKCET0-13Scopes definedT0-14Mediated sign-in well formedT0-19Extensions named correctlyT0-20Operations advertised fullyT0-15Conversation entriesT0-16API entriesT0-17OpenAPI parsesT0-18MCP resource metadataX-01Same MCP server everywhereX-02Same organizationX-04Domains coveredX-07One issuerX-08One metadata documentX-10Shared token endpoint lists both protocols' grantsX-12Consistent 401s
Level 2: Let agents in, signed out
Assistants prove who they are and get session tokens that expire. Outside MCP, each token works only together with a key the assistant holds.
- Rough size
- Medium. Changes to your token endpoint, and a proof check in front of every API. Days to weeks, depending on whether your OAuth server can bind tokens to keys.
- Checks it turns green
- 24: 4 checked today, the rest later
Everything happens in a session
Everything an assistant does at your company happens in a session: one person's activity, at your company, through one assistant, across your APIs, conversations and website. A session starts signed out. You know which assistant is asking and you can recognise the same person over time, but you do not know who they are (spec 4).
The rest of this level is standard OAuth with one addition, a session_id, so you can usually keep your existing OAuth server (spec 4).
The assistant publishes who it is
Each assistant publishes its own metadata at an HTTPS address, and that address is its client_id. You fetch it to learn the assistant's name and logo, the public keys it signs with, and where sign-in may send people back (spec 4.1). Shortened from the spec's example, for an assistant at agent.example:
Client metadata, published by the assistant (excerpt)
{
"client_id": "https://agent.example/agent.json",
"client_name": "Example Agent",
"logo_uri": "https://agent.example/logo.png",
"jwks_uri": "https://agent.example/jwks.json",
"redirect_uris": ["https://agent.example/oauth/callback"],
"token_endpoint_auth_method": "private_key_jwt"
}
The client_id inside must equal the address you fetched it from. The jwks_uri and every entry in redirect_uris must be HTTPS addresses on the same domain as the client_id (spec 4.1).
Details for implementers: fetching and limiting assistants
- Fetch the
client_idaddress without following redirects, and cache the metadata and keys within limits (guides: verifying personal agents). - You may ask assistants to register first. A company that requires it rejects an unregistered assistant with
invalid_client(HTTP 401) when it starts a session (spec 4.1). - You may keep lists of assistants you allow or block, revoke a
client_idthat misuses the protocol, and limit how fast eachclient_idcan start sessions (spec 4.1).
The assistant starts a session
To start a session, the assistant calls your token_endpoint with the JWT bearer grant (RFC 7523). A JWT is a small piece of JSON with a signature. The request carries two of them, both signed with a key from the assistant's jwks_uri (spec 4.1, spec 4.2):
- a client assertion, which proves the request comes from the assistant (
private_key_jwt); - a session assertion, which names the assistant in
issand the person insub.
Like every token request, it also carries a DPoP proof, explained below (spec 4.2).
Session assertion, the JWT's claims
{
"iss": "https://agent.example/agent.json",
"sub": "usr_Q7c1vK",
"aud": "https://auth.example.com/oauth/token",
"iat": 1790900000,
"exp": 1790900060,
"jti": "V6vJGs2ixBAvGdaQgGaTzg"
}
The sub is a user ID the assistant makes for this person at your company. It stays the same across sessions, it is different at every company, and it is never made from personal details such as an email address (spec 4.2).
Verify both assertions against the assistant's jwks_uri. Check that iss is the client_id that authenticated, that aud is your token_endpoint as a single string, that the assertion has not expired, and that you have not seen its jti before (spec 4.2). A browser assertion, the kind level 4 uses on your website, must not be accepted here (spec 5). Then return a new session:
Response body from your token endpoint
{
"access_token": "eyJhbGciOi…Lm9x",
"token_type": "DPoP",
"expires_in": 3600,
"scope": "",
"session_id": "ses_2Lm0",
"signed_in": false
}
The access_token is the session token. token_type is DPoP for a token bound to a key, or Bearer for a token asked for without a proof (MCP, below). You choose the lifetime in expires_in, in seconds; it must be more than 0 and should be hours, not days. scope lists the account access granted, which is none while signed out, and signed_in says whether the session is signed in to an account. The session_id names the session. It is not a credential: knowing it does not let anyone use the session (spec 4.2).
When a signed-out token runs out, the assistant sends the same request again with fresh assertions and the session_id. Check that the session belongs to the same client_id and user ID, then issue a new token. If the session has ended, answer invalid_session. A signed-in session is renewed with its account token instead, which level 3 covers (spec 4.2).
Details for implementers: token endpoint errors and token contents
Answer with the standard OAuth error response, using these codes (spec 4.2):
invalid_client(401): you do not accept thisclient_id. It is unregistered, blocked or revoked, or its metadata or signature does not check out.invalid_grant(400): the assertion or account token is missing, expired, reused, revoked or malformed.invalid_session(400): the session has ended, or belongs to another assistant or person.account_mismatch(400): the session is signed in to a different account (level 3).invalid_dpop_prooforuse_dpop_nonce(400): the DPoP proof is missing or invalid, or needs a nonce.rate_limited(429): too many requests for thisclient_id. Say when to retry in aRetry-Afterheader.
Session tokens are opaque to the assistant, which must not depend on what is inside them. You may put your own session credentials in a token so your existing systems can read them. If you do, you must encrypt the token (spec 4.2).
Sign assertions and proofs with ES256 or RS256, and reject alg: none and symmetric algorithms such as HS256 (guides: signing and verifying JWTs). Keep error_description and other error text free of account details and other people's data (guides: error messages).
Tokens work only with the assistant's key
A copied token should be useless. So each session token is bound to a key the assistant holds, using DPoP (RFC 9449), which proves the sender holds a private key. This key is not one of the keys the assistant publishes: it stays private, and the assistant can make a new one for each session (spec 4.1, spec 4.3).
The assistant signs a fresh proof, a JWT, for every request, token requests included. Every API call and conversation request then carries the token and the proof (spec 4.3):
Request headers
Authorization: DPoP eyJhbGciOi…Lm9x
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs…
When you issue a token, bind it to the key in that request's proof. Then, on every request, check that (spec 4.3):
- the proof's signature verifies with the key in its header;
- that key is the one the token is bound to;
htmandhtumatch the request's method and URL;iatis recent, for example within the last minute;- you have not accepted the same
jtiin that time; ath, a hash of the token, matches the token sent with it. Proofs sent to the token endpoint have noath.
If a check fails, answer invalid_dpop_proof or use_dpop_nonce, with a WWW-Authenticate: DPoP header. You may also require a nonce, a value you hand out, so proofs cannot be made in advance (spec 4.3).
Accept the token in the Authorization header without asking for cookies, and never accept a token from a URL (spec 4.3).
Details for implementers: if your OAuth server cannot bind tokens
The guides suggest a gateway in front of both your token endpoint and your APIs and conversation endpoint. It checks the proof on each token request, records the key for the token it issues, and checks that key on every later request. Checking a proof's signature alone is not enough (guides: verifying DPoP).
Serve each endpoint at the address you publish. Each redirect hop needs its own proof, so assistants are told not to follow a redirect with the old one (guides: DPoP keys and proofs).
List your APIs
Now you can add apis to poppy.json. Every API you list must accept session tokens as this level describes (spec 6). Each entry has a type, a url, and a short description that helps the assistant pick the right API (spec 3.1):
Excerpt from poppy.json
"apis": [
{
"type": "openapi",
"url": "https://api.example.com/openapi.json",
"description": "Orders, returns, and exchanges"
},
{
"type": "mcp",
"url": "https://mcp.example.com/mcp",
"description": "Product search and sizing"
}
]
There are two types (spec 6):
openapi: theurlpoints to an OpenAPI 3.0 or 3.1 description. OpenAPI is a standard file that describes a web API's requests and responses. These APIs take DPoP tokens.mcp: theurlpoints to a remote MCP server using the Streamable HTTP transport and MCP version 2025-06-18 or later. These take Bearer tokens, as the next section explains.
Plain Bearer tokens, for MCP only
MCP, the Model Context Protocol, is a common way to offer tools to AI models. It cannot send proofs. So for an MCP server in your apis, the assistant asks for a token without a proof and with resource set to that server's url, and you issue a plain Bearer token. Anyone who copies a Bearer token can use it, so each one works at only one MCP server (spec 4.3).
Accept Bearer session tokens only on your MCP servers, and require DPoP everywhere else (spec 4.3). Each MCP server must accept only tokens issued for its own url (spec 6).
MCP clients find out where to get a token from the server's protected resource metadata (RFC 9728), a public file that names the sign-in servers the MCP server trusts. Its authorization_servers must include your auth.issuer (spec 6).
What your APIs answer
When a token is not good enough, say why in the WWW-Authenticate header (spec 6):
invalid_token(401): the token is missing or expired.sign_in_required(403): the request needs a signed-in token, which level 3 provides.insufficient_scope(403): the token lacks the account access the request needs. Name the scopes needed in the header'sscopeattribute.
If you limit request rates, answer HTTP 429 with a Retry-After header (spec 4.3). The guides suggest letting a signed-out session do whatever a signed-out visitor can do on your website, and limiting abuse by client_id rather than turning assistants away (guides: guest access).
Tools that help
Datalayer's SDK, which is pre-beta, includes DPoP creation and verification for Python and TypeScript (on GitHub). It is written mainly for assistants, so check what it covers before you build on it.
Checks this level turns green
These are the checker's rows for this level, by their ids in the check catalog. Building what this level describes, and following the guides it links, is what makes them pass. A row that checks something you do not offer does not apply.
Checked today (4)
T1-01Token endpoint rejects a junk JWT bearer requestT1-02Error bodies leak nothingT1-05MCP server challenges unauthenticated initializeADV-REDIRECTEndpoint answers without a redirect
Coming soon to the checker (7)
T2-M1Call each read-only OpenAPI GET with no token, then with a valid signed-out tokenT2-01Start a Session for U1T2-02Token lifetimeT2-05Renew with session_idT2-14Nonce challengeT2-16Bearer token request for an MCP server (no DPoP, resource = MCP url)T2-18Token accepted without cookies
Checked only when the site owner opts in (13)
T2-M2Send a valid Session Token in ?access_token= instead of the headerT2-03Replay the same assertion jtiT2-04Assertion with aud as an array or another endpointT2-06Renew U1's Session with a U2 assertionT2-07Assertion signed with a key not in the assistant's published key setT2-08alg: none or HS256 assertionT2-09Valid token, reused proof jtiT2-10Proof with wrong htu or htmT2-11Proof with wrong athT2-12Proof signed by a different key than the token's bindingT2-13Stale iat (beyond ~1 min)T2-15Token sent as Authorization: Bearer to a non-MCP endpointT2-17That Bearer token at another MCP server or an OpenAPI endpoint
Level 3: Let people sign in
People sign in on your own pages, choose what each assistant may do with their account, and can disconnect it later.
- Rough size
- Large. Sign-in and consent pages, account tokens, sign out, and an account settings page. Weeks.
- Checks it turns green
- 16, none checked yet
Choose how people sign in
A session does not need sign-in. When a task needs the person's account, the person signs in once, in one of the ways you offer (spec 4.4):
- Direct sign-in (
auth.direct): the person signs in on your own page in their browser and approves access. - Device sign-in (
auth.device): the assistant shows the person a link and a short code, and they sign in on your page on any device they like. - Mediated sign-in (
auth.mediated): the assistant sends you the person's credentials. This one is not OAuth, and it puts more on you (see the end of this level).
Each kind you offer goes under auth in poppy.json, with the scopes it can grant. A scope is a named piece of account access. From the spec's example (spec 3, spec 3.1):
Excerpt from poppy.json
"auth": {
"issuer": "https://auth.example.com",
"direct": {
"scopes": ["poppy:read", "poppy:write", "addresses"]
},
"device": {
"scopes": ["poppy:read", "poppy:write"]
},
"mediated": {
"endpoint": "https://auth.example.com/poppy/sign-in",
"fields": [
{ "name": "email", "label": "Email", "secret": false },
{ "name": "password", "label": "Password", "secret": true }
],
"scopes": ["poppy:read"]
},
"custom_scopes": {
"addresses": "Manage saved shipping addresses"
}
}
The protocol defines two scopes. poppy:read lets the assistant view account information, such as orders. poppy:write lets it make changes, such as exchanging an item. They are independent, so poppy:write does not include poppy:read. You may add your own scopes and describe each one in auth.custom_scopes. Names that start with poppy: are reserved (spec 4.4).
Every sign-in request names its scopes in scope. Reject missing, unknown or unavailable ones with invalid_scope, and never grant more than was asked for and allowed for that kind of sign-in (spec 4.4). You decide which actions need each scope, and you enforce that on your website, your APIs and your own agent alike (spec 4.4, spec 7.11). One shared check that every channel calls is easiest to keep consistent (guides: enforcing scopes).
Direct sign-in
Direct sign-in is the standard OAuth authorization code flow with PKCE (RFC 7636). PKCE ties the one-time code to the assistant that asked for it. Your OAuth metadata must list an authorization_endpoint (spec 3.2, spec 4.5).
- The assistant opens your authorization address in the person's browser.
- Your page shows which assistant is asking, using the name and logo from its metadata. The person signs in however you allow, sees the requested scopes, and approves some or all of them, or declines.
- You send the browser back to
redirect_uriwith a one-timecode, the samestate, and yourauth.issueriniss. Theredirect_urimust exactly match one of the assistant'sredirect_uris. If the person declines, senderror=access_deniedandissinstead. - The assistant exchanges the code at your
token_endpoint, with its client assertion, a DPoP proof and thesession_id. Check the code, the PKCE verifier and the assertion, and that the session belongs to the sameclient_id.
All of these steps are in the spec (spec 4.5). The guides add: accept PKCE with S256 only, and reject plain (guides: consent pages).
Device sign-in
Device sign-in is the OAuth device authorization grant (RFC 8628). Your OAuth metadata must list a device_authorization_endpoint (spec 4.6).
The assistant asks for a code and shows the person your link and the short code, or a link with the code built in. The person opens it on any device, such as their phone, signs in, and approves some or all of the scopes, or declines. Opening the link must not approve anything by itself (spec 4.6).
Details for implementers: answering the assistant while it waits
Every interval seconds, the assistant asks your token_endpoint whether the person has finished, sending the device_code and its session_id. Answer authorization_pending until then, or slow_down to make it wait longer between requests. Once the person approves, return the tokens. If they decline, answer access_denied, and if the request runs past expires_in, expired_token (spec 4.6).
The guides suggest asking the person to confirm that the code on your page matches the one the assistant shows, and keeping expires_in short, such as 10 minutes (guides: device sign-in).
The consent page
The spec asks your page to show which assistant is asking, by the name and logo from its metadata, and the requested scopes (spec 4.5). The guides go further (guides: consent pages):
- Show the domain of the assistant's
client_idnext to its name. The assistant chooses its own name, so the domain is what the person can trust. - Show the account being connected, each scope in plain language, and how long access lasts.
- Protect the page against forged requests, and stop other sites from framing it with
Content-Security-Policy: frame-ancestors.
What sign-in returns
Every kind of sign-in ends the same way. You return an account token as refresh_token, and a signed-in session token for the current session. The person may have approved fewer scopes than asked, so scope says what was granted (spec 4.4):
Response body after sign-in
{
"access_token": "eyJhbGciOi…Qp4w",
"token_type": "DPoP",
"expires_in": 3600,
"refresh_token": "pat_8Hk2…Wq1",
"refresh_token_expires_in": 2592000,
"scope": "poppy:read poppy:write",
"session_id": "ses_2Lm0",
"signed_in": true
}
refresh_token_expires_in is the account token's lifetime in seconds, if you set one. After it, the person signs in again (spec 4.4).
You keep a record for each account token: the client_id, the user ID, the account, the granted scopes and when it expires. A session is signed in to at most one account, and once it has been, it cannot switch: answer account_mismatch, and the assistant starts a new session (spec 4.4).
Account tokens
The account token stands for the person's approval. It is an OAuth refresh token, and it lets the assistant get signed-in session tokens later without asking the person again (spec 4.8). The assistant sends it to your token_endpoint with the refresh_token grant, its client assertion and a DPoP proof. With a session_id, you return a token for that session, signing it in if it was signed out. Without one, you start a new session that is already signed in (spec 4.8).
Request body to your token endpoint (excerpt)
grant_type=refresh_token
&refresh_token=pat_8Hk2…Wq1
&session_id=ses_4Tn8
&scope=poppy%3Aread
Accept an account token only at your token_endpoint and revocation_endpoint, and only from the client_id it was issued to (spec 4.8).
Details for implementers: the rules for account tokens
All from the spec (spec 4.8):
- The session must belong to the same
client_idand user ID as the account token, and be signed out or signed in to the same account. Otherwise answerinvalid_sessionoraccount_mismatch. scopemay ask for fewer scopes than the account token has, for example read only access for a lookup. It can never ask for more.- Using an account token does not extend its lifetime. You may return a new one in
refresh_token, and the assistant must use the new one from then on. - When an account token has expired or been revoked, answer
invalid_grant. The session carries on signed out.
Signing out and disconnecting
To sign out, the assistant revokes its account token at your revocation_endpoint (RFC 7009). Revoke it and sign out every session that used it. Those sessions usually carry on signed out (spec 4.9).
Session tokens issued before sign-out may still work. So you should check on each request whether the session is still signed in, so sign-out takes effect right away. If you do not, you should keep session tokens much shorter, such as a few minutes (spec 4.9).
People can also disconnect an assistant from your account settings. That revokes the assistant's account tokens for that account and signs out its sessions (spec 4.9). The guides suggest listing each connected assistant in account settings with its name, domain, scopes and when it was last used, with a control to disconnect each one (guides: account settings).
If you offer mediated sign-in
In mediated sign-in, the assistant signs in for the person with credentials the person gave it. It is not an OAuth flow (spec 4.7). Assistants are told to prefer direct sign-in, then device sign-in, because then the password stays with you (guides: choosing how to sign in).
If you offer it, you must limit how often anyone can try, and you must never return credentials in any response. You should ask for a one-time code when a sign-in looks unusual (spec 4.7).
Details for implementers: the mediated sign-in requests
auth.mediated has an endpoint, the fields you need, and the scopes it can grant. Each field has a name (the key the assistant sends), a label (what to ask the person for), and secret, which marks a value the assistant must protect (spec 3.1, spec 4.7).
The assistant posts the fields in credentials, with its scope and its current session token (spec 4.7):
Request: assistant to your sign-in endpoint
POST https://auth.example.com/poppy/sign-in
Authorization: DPoP eyJhbGciOi…Lm9x
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs…
Content-Type: application/json
{
"scope": "poppy:read",
"credentials": { "email": "…", "password": "…" }
}
Every answer has a status (spec 4.7):
complete: signed in. The answer has the same fields as the sign-in response above.code_required: you sent the person a one-time code. The answer has asign_in_id, acodeobject whosesent_tosays where the code went, andexpires_at. The assistant then posts only{ "code": "…" }to the endpoint address followed by/and thesign_in_id, such ashttps://auth.example.com/poppy/sign-in/sgn_3Hq8, with a session token for the same session.failed: sign-in did not succeed, for example a wrong password, or too many wrong codes. A wrong code answerscode_requiredagain until your attempt limit.expired: the sign-in took longer thanexpires_at.
Only the session that started a sign-in can finish it (spec 4.7). The guides add: return the same failed answer for an unknown account and a wrong password, never store raw credentials or codes, and tell the person, for example by email, when an assistant signs in (guides: mediated sign-in).
Checks this level turns green
These are the checker's rows for this level, by their ids in the check catalog. Building what this level describes, and following the guides it links, is what makes them pass. A row that checks something you do not offer does not apply.
Coming soon to the checker (1)
T2-M5Load the consent page from a valid S256 request and inspect its headers; never sign in
Checked only when the site owner opts in (2)
T2-M4Start direct sign-in with code_challenge_method=plain; stop at the authorization endpointT1-12Mediated endpoint, only if the company opts in: two bad-credential attempts
Signed-in checks, not run by the checker yet (13)
T3-01Direct sign-inT3-02Redirect URI not in the assistant's redirect_urisT3-03User declinesT3-04Device sign-inT3-05Mediated sign-in (if offered)T3-06Partial scope approvalT3-07Request an unlisted scopeT3-08Scope enforcement across channelsT3-09Account Token from another client_id or at an APIT3-10Account Token with narrower and wider scopeT3-11Sign a Session into account B after account AT3-12Revoke the Account TokenT3-17Account settings
Level 4: Optional surfaces
Let an assistant's browser join its session, let assistants talk with your own agent, and ask for confirmation before you act.
- Rough size
- Varies. Each surface is its own project: days for the browser join, weeks for conversations or operations.
- Checks it turns green
- 30: 3 checked today, the rest later
These three surfaces are independent of each other. Build any of them, in any order, once level 2 works.
Let the assistant's browser join its session
Assistants can browse your website in the same session they use for your APIs, so you see one session for the person, signed in or not. This is optional. If you offer it, list web.browser_session_endpoint in poppy.json, and assistants must join their browser to a session before they browse. Without it, they browse as ordinary signed-out visitors (spec 5).
The assistant's browser sends a signed browser assertion to that endpoint as a form POST, never in the URL. It is a JWT signed with a key from the assistant's jwks_uri, with typ set to poppy-browser+jwt in its header (spec 5):
Browser assertion, the JWT's claims
{
"iss": "https://agent.example/agent.json",
"sub": "usr_Q7c1vK",
"aud": "https://example.com/poppy/browser-session",
"session_id": "ses_2Lm0",
"return_to": "https://example.com/orders",
"iat": 1790900000,
"exp": 1790900060,
"jti": "Hk29vQ0sPZ1mXa7cR4tLwA"
}
Check that the signature verifies and typ is right, that aud is your endpoint, that it has not expired and exp is no more than 60 seconds after iat, that you have not seen its jti, that the session is active and belongs to the same client_id and person, and that return_to is on your own domain. A session assertion must not be accepted here. If all checks pass, set a cookie for the session and answer 303 See Other to return_to. If not, set no cookie and answer 400 (spec 5).
The cookie is your own. It must follow the session as it signs in or out, must not outlive the session, and must be Secure and HttpOnly with SameSite=Lax or None, not Strict (spec 5). The guides suggest limiting it to the hosts assistants need, such as your main site, rather than your whole domain (guides: browser sessions).
Let assistants talk with your own agent
If you run your own AI agent, list it in poppy.json under agent.protocols. The protocol defined so far has the type poppy (spec 3.1, spec 7.1):
Excerpt from poppy.json
"agent": {
"protocols": [
{
"type": "poppy",
"endpoint": "https://api.example.com/poppy/conversations"
}
]
}
Conversations are JSON over HTTPS, and every request carries a session token with a DPoP proof, as in level 2. A conversation belongs to the assistant and person that started it, or to the account once it uses one, and you must refuse anyone else (spec 7.2). The endpoint takes five requests:
POSTto the endpoint starts a conversation with its first message (spec 7.3).POSTto/messagesunder a conversation sends another message. The assistant chooses each messageid. A retry with the sameidand content gets the original answer, and the sameidwith different content getsmessage_id_conflict(spec 7.3).GETto/eventsreads messages and changes in order. The assistant passes acursor, the id of the last event it handled, and can wait up towaitseconds for something new. Every read includescursor,has_more,statusandresponder(spec 7.5). Streaming the events is optional (spec 7.6).POSTto/handoffasks for a person at your company (spec 7.9).POSTto/closecloses the conversation (spec 7.12).
Each message says who wrote it: sender is agent for an AI and human for a person, and responder says who is handling your side right now (spec 7.7, spec 7.8). When your agent needs the person's account, add an authorization event with sign_in_required or insufficient_scope, and keep the conversation open while the assistant signs in (spec 7.11). The person can also talk with you directly, in a new conversation linked to the first (spec 7.10). Errors have their own codes (spec 7.13).
Ask before you act: operations
The operations extension lets you ask the assistant to confirm an action before you perform it, such as an exchange or a changed booking. The person approves one exact version of the terms, the action has one record across channels, and it happens at most once (operations 1).
To offer it, list the extension in poppy.json with your operations endpoint, which accepts DPoP session tokens (operations 2):
Excerpt from poppy.json
"extensions": {
"operations": {
"version": "1",
"endpoint": "https://api.example.com/poppy/operations"
}
}
Return operations only to assistants whose metadata lists the extension. For other assistants, perform the action as usual or refuse with extension_required (operations 2).
The flow has four steps (operations 1):
- Propose. An API, MCP tool or agent message that would perform the action returns an operation with its terms instead. Nothing has happened yet.
- Approve. The assistant shows the person the terms and gets their approval, or applies a standing permission, a rule the person set earlier such as "exchange for a different size if it costs under $20".
- Confirm. The assistant confirms that version of the terms at your operations endpoint, and you perform the action.
- Track. The assistant reads the operation until it has a final result.
You must not start the action before it is confirmed, and you must perform only what the confirmed revision describes. Everything that affects the action, such as the item, price, recipient or date, must be in the summary, and in terms if you send them (operations 3).
Details for implementers: the operation object
Operation, as you propose it
{
"operation_id": "exc_5Rt2",
"revision": 1,
"state": "proposed",
"summary": "Exchange the Stormline Jacket (M) on order ord_7Hk2 for the Stormline Insulated Jacket (M), for $70.00 more, charged to the card used for the order.",
"terms": {
"order_id": "ord_7Hk2",
"item_id": "itm_4Qa",
"replacement_sku": "stormline-insulated-m",
"price_difference": { "amount": "70.00", "currency": "USD" }
},
"expires_at": "2026-10-08T12:20:00-07:00",
"user_approval_required": false,
"url": "https://example.com/orders/ord_7Hk2/exchanges/exc_5Rt2",
"confirmation": null,
"result": null
}
The fields (operations 3):
- Always required:
operation_id,revision(starts at 1 and goes up each time the terms change),state,summary(the terms in plain language, enough for the person to decide from alone),confirmationandresult. expires_atis required while the operation is proposed: the last time this revision can be confirmed.confirmationisnulluntil confirmed, then holds the confirmedrevision,approved_byandconfirmed_at.resultisnulluntil the operation is final, then holds asummaryof what happened and optionaldata.- Optional:
terms(the same terms as JSON you define),url(a page on your website showing the operation), anduser_approval_required. When that istrue, you accept only the person's own approval of this revision, not a standing permission. It defaults tofalse.
The six states: proposed (waiting to be confirmed), in_progress (confirmed, being performed, or you are finding out whether it took effect), and four final ones, succeeded, failed, cancelled and expired. A final operation never changes state again (operations 3).
Once issued, a revision never changes. New terms get a new revision with the next number, and only the latest revision can be confirmed (operations 3.1). An operation belongs to the session it was proposed in. If that session is signed in, it also belongs to the account, so any session of the same assistant and person signed in to that account can use it. One proposed while signed out can be used only in that session. Answer operation_not_found to anyone else (operations 3.2).
Details for implementers: where the operation appears
The operation is the same object in every channel (operations 4):
- An OpenAPI endpoint answers HTTP 202 with the operation in
operation(operations 4.1). - An MCP tool returns it in
operationinside the result'sstructuredContent, with the summary as text (operations 4.2). - A conversation message carries it in
message.operation, next totextanddata. A message never confirms an operation, whatever it says (operations 4.3).
If the same action comes up again in another channel, you should return the existing operation instead of proposing a new one (operations 4).
Details for implementers: confirming, tracking and cancelling
The assistant posts to /confirm under the operation's address at your endpoint, with the revision the person approved and approved_by set to user or standing_permission (operations 5.2):
Request: assistant to your operations endpoint
POST https://api.example.com/poppy/operations/exc_5Rt2/confirm
Authorization: DPoP eyJhbGciOi…Qp4w
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs…
Content-Type: application/json
{ "revision": 1, "approved_by": "user" }
Before you perform the action, check (operations 5.2):
- the session token may use this operation, or answer
operation_not_found; - the token has the scopes the action needs, or answer
insufficient_scopeorsign_in_required; revisionis the latest, or answerterms_changedwith the current operation;approved_byis allowed for this operation, or answeruser_approval_required;- the operation is
proposedand has not expired. In any other state, return the operation as it is and do nothing else, so a repeated confirm is safe.
Then record the confirmation, move the operation to in_progress, and perform the action. If it finishes within a few seconds, answer with the final state; otherwise answer in_progress with a Retry-After header (operations 5.2).
To keep each action to at most once (operations 6):
- Perform each operation at most once, however many times and through whichever channels it is confirmed.
- Do not report
failedwhile you do not know whether the action took effect. Keep itin_progressuntil you find out. - In a final state,
result.summarymust describe every change that was made, including changes from an operation that failed or was cancelled partway.
The assistant can cancel a proposed operation, which then cannot be confirmed, or ask you to stop one in progress (operations 7). Errors are listed in the extension (operations 9).
Checks this level turns green
These are the checker's rows for this level, by their ids in the check catalog. Building what this level describes, and following the guides it links, is what makes them pass. A row that checks something you do not offer does not apply.
Checked today (3)
T1-03Conversation endpoint requires DPoPT1-07Browser endpoint rejects a junk assertionT1-09Operations endpoint requires a token
Coming soon to the checker (2)
T2-19Valid browser assertion, form POSTT2-25Cookie scope
Checked only when the site owner opts in (21)
T2-M3Send a valid browser assertion in the URL instead of the form bodyT2-20Replay the same browser assertionT2-21exp more than 60 s after iatT2-22return_to on a foreign domainT2-23Session assertion (wrong typ) at the browser endpointT2-24Browser assertion at the token endpointT2-26Start with a general questionT2-27Retry the same message id and contentT2-28Same id, different contentT2-29Read events with and without waitT2-30POST …/messages?wait=10T2-31Junk cursorT2-32U2 reads U1's conversationT2-33Stream with Accept: text/event-streamT2-34Ask about the account while signed outT2-35Message with only contextT2-36Close, then send a messageT2-37Handoff (opt-in only; may page staff)T2-38Assistant whose metadata does not list extensions.operations triggers a proposing endpointT2-39U2 reads U1's signed-out operationT2-40Confirm an old revision (if revisions can be triggered)
Signed-in checks, not run by the checker yet (4)
T3-13Signed-out token on an account-using conversationT3-14Direct ConversationT3-15Operation end to end (sandbox action)T3-16standing_permission on a user_approval_required operation