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_version is the version of the protocol the file follows. For this draft it is "0.1".
  • organization holds your display name and your domain. The domain must match the host the assistant asked for, ignoring a leading www.. If the address redirected, the host that finally served the file does not count.
  • agent, apis and web say what assistants can use, and you need at least one of them. web is your website. apis lists your APIs, which level 2 covers. agent lists ways to talk with your own AI agent, which level 4 covers.
  • auth names your sign-in server. You need it as soon as the file lists agent, apis or web.browser_session_endpoint. A company without auth can only be browsed like an ordinary website.
  • extensions is optional. It lists extra features you support, such as operations (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_endpoint issues tokens. Required. Level 2 makes it work for assistants.
  • revocation_endpoint is where an assistant signs out. Required. Level 3 uses it.
  • authorization_endpoint and device_authorization_endpoint start 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-01 Document exists
  • T0-02 Redirects safe
  • T0-03 Valid JSON object
  • T0-04 Supported protocol_version
  • T0-05 Organization matches the requested host
  • T0-06 Has an interface
  • T0-07 auth.issuer when needed
  • T0-08 All URLs HTTPS
  • T0-SIB Published on the requested host
  • T0-09 Issuer metadata reachable
  • T0-10 Issuer match
  • T0-11 Domain binding
  • T0-12 Required endpoints
  • T0-21 Cache headers
  • ADV-PKCE No plain PKCE
  • T0-13 Scopes defined
  • T0-14 Mediated sign-in well formed
  • T0-19 Extensions named correctly
  • T0-20 Operations advertised fully
  • T0-15 Conversation entries
  • T0-16 API entries
  • T0-17 OpenAPI parses
  • T0-18 MCP resource metadata
  • X-01 Same MCP server everywhere
  • X-02 Same organization
  • X-04 Domains covered
  • X-07 One issuer
  • X-08 One metadata document
  • X-10 Shared token endpoint lists both protocols' grants
  • X-12 Consistent 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.

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_id address 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_id that misuses the protocol, and limit how fast each client_id can 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 iss and the person in sub.

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 this client_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_proof or use_dpop_nonce (400): the DPoP proof is missing or invalid, or needs a nonce.
  • rate_limited (429): too many requests for this client_id. Say when to retry in a Retry-After header.

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;
  • htm and htu match the request's method and URL;
  • iat is recent, for example within the last minute;
  • you have not accepted the same jti in that time;
  • ath, a hash of the token, matches the token sent with it. Proofs sent to the token endpoint have no ath.

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: the url points 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: the url points 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's scope attribute.

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-01 Token endpoint rejects a junk JWT bearer request
  • T1-02 Error bodies leak nothing
  • T1-05 MCP server challenges unauthenticated initialize
  • ADV-REDIRECT Endpoint answers without a redirect
Coming soon to the checker (7)
  • T2-M1 Call each read-only OpenAPI GET with no token, then with a valid signed-out token
  • T2-01 Start a Session for U1
  • T2-02 Token lifetime
  • T2-05 Renew with session_id
  • T2-14 Nonce challenge
  • T2-16 Bearer token request for an MCP server (no DPoP, resource = MCP url)
  • T2-18 Token accepted without cookies
Checked only when the site owner opts in (13)
  • T2-M2 Send a valid Session Token in ?access_token= instead of the header
  • T2-03 Replay the same assertion jti
  • T2-04 Assertion with aud as an array or another endpoint
  • T2-06 Renew U1's Session with a U2 assertion
  • T2-07 Assertion signed with a key not in the assistant's published key set
  • T2-08 alg: none or HS256 assertion
  • T2-09 Valid token, reused proof jti
  • T2-10 Proof with wrong htu or htm
  • T2-11 Proof with wrong ath
  • T2-12 Proof signed by a different key than the token's binding
  • T2-13 Stale iat (beyond ~1 min)
  • T2-15 Token sent as Authorization: Bearer to a non-MCP endpoint
  • T2-17 That 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).

  1. The assistant opens your authorization address in the person's browser.
  2. 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.
  3. You send the browser back to redirect_uri with a one-time code, the same state, and your auth.issuer in iss. The redirect_uri must exactly match one of the assistant's redirect_uris. If the person declines, send error=access_denied and iss instead.
  4. The assistant exchanges the code at your token_endpoint, with its client assertion, a DPoP proof and the session_id. Check the code, the PKCE verifier and the assertion, and that the session belongs to the same client_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 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_id next 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_id and user ID as the account token, and be signed out or signed in to the same account. Otherwise answer invalid_session or account_mismatch.
  • scope may 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 a sign_in_id, a code object whose sent_to says where the code went, and expires_at. The assistant then posts only { "code": "…" } to the endpoint address followed by / and the sign_in_id, such as https://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 answers code_required again until your attempt limit.
  • expired: the sign-in took longer than expires_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-M5 Load 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-M4 Start direct sign-in with code_challenge_method=plain; stop at the authorization endpoint
  • T1-12 Mediated endpoint, only if the company opts in: two bad-credential attempts
Signed-in checks, not run by the checker yet (13)
  • T3-01 Direct sign-in
  • T3-02 Redirect URI not in the assistant's redirect_uris
  • T3-03 User declines
  • T3-04 Device sign-in
  • T3-05 Mediated sign-in (if offered)
  • T3-06 Partial scope approval
  • T3-07 Request an unlisted scope
  • T3-08 Scope enforcement across channels
  • T3-09 Account Token from another client_id or at an API
  • T3-10 Account Token with narrower and wider scope
  • T3-11 Sign a Session into account B after account A
  • T3-12 Revoke the Account Token
  • T3-17 Account 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.

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:

  • POST to the endpoint starts a conversation with its first message (spec 7.3).
  • POST to /messages under a conversation sends another message. The assistant chooses each message id. A retry with the same id and content gets the original answer, and the same id with different content gets message_id_conflict (spec 7.3).
  • GET to /events reads messages and changes in order. The assistant passes a cursor, the id of the last event it handled, and can wait up to wait seconds for something new. Every read includes cursor, has_more, status and responder (spec 7.5). Streaming the events is optional (spec 7.6).
  • POST to /handoff asks for a person at your company (spec 7.9).
  • POST to /close closes 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):

  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.
  2. 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".
  3. Confirm. The assistant confirms that version of the terms at your operations endpoint, and you perform the action.
  4. 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), confirmation and result.
  • expires_at is required while the operation is proposed: the last time this revision can be confirmed.
  • confirmation is null until confirmed, then holds the confirmed revision, approved_by and confirmed_at. result is null until the operation is final, then holds a summary of what happened and optional data.
  • Optional: terms (the same terms as JSON you define), url (a page on your website showing the operation), and user_approval_required. When that is true, you accept only the person's own approval of this revision, not a standing permission. It defaults to false.

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 operation inside the result's structuredContent, with the summary as text (operations 4.2).
  • A conversation message carries it in message.operation, next to text and data. 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_scope or sign_in_required;
  • revision is the latest, or answer terms_changed with the current operation;
  • approved_by is allowed for this operation, or answer user_approval_required;
  • the operation is proposed and 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 failed while you do not know whether the action took effect. Keep it in_progress until you find out.
  • In a final state, result.summary must 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-03 Conversation endpoint requires DPoP
  • T1-07 Browser endpoint rejects a junk assertion
  • T1-09 Operations endpoint requires a token
Coming soon to the checker (2)
  • T2-19 Valid browser assertion, form POST
  • T2-25 Cookie scope
Checked only when the site owner opts in (21)
  • T2-M3 Send a valid browser assertion in the URL instead of the form body
  • T2-20 Replay the same browser assertion
  • T2-21 exp more than 60 s after iat
  • T2-22 return_to on a foreign domain
  • T2-23 Session assertion (wrong typ) at the browser endpoint
  • T2-24 Browser assertion at the token endpoint
  • T2-26 Start with a general question
  • T2-27 Retry the same message id and content
  • T2-28 Same id, different content
  • T2-29 Read events with and without wait
  • T2-30 POST …/messages?wait=10
  • T2-31 Junk cursor
  • T2-32 U2 reads U1's conversation
  • T2-33 Stream with Accept: text/event-stream
  • T2-34 Ask about the account while signed out
  • T2-35 Message with only context
  • T2-36 Close, then send a message
  • T2-37 Handoff (opt-in only; may page staff)
  • T2-38 Assistant whose metadata does not list extensions.operations triggers a proposing endpoint
  • T2-39 U2 reads U1's signed-out operation
  • T2-40 Confirm an old revision (if revisions can be triggered)
Signed-in checks, not run by the checker yet (4)
  • T3-13 Signed-out token on an account-using conversation
  • T3-14 Direct Conversation
  • T3-15 Operation end to end (sandbox action)
  • T3-16 standing_permission on a user_approval_required operation