Developers
Cliffside Retail is a fictional company that implements the Personal Agent Protocol (Poppy), draft 0.2. Point your personal agent here to try the protocol against a real server. The inspector shows every request your agent makes.
Start here
Fetch the discovery document, then the OAuth metadata for its issuer:
curl https://www.cliffside-retail.com/.well-known/poppy.json
curl https://www.cliffside-retail.com/.well-known/oauth-authorization-serverCliffside Retail is its own OAuth server, so auth.issuer is https://www.cliffside-retail.com. The metadata gives the authorization_endpoint, token_endpoint, and revocation_endpoint. Before sending anything, check that its issuer matches and that poppy_domains lists the company's domain. The mediated sign-in endpoint, https://www.cliffside-retail.com/agent-auth/sign-in, is Cliffside Retail's choice of path: another company can put it anywhere, and discovery says where.
Try it without a personal agent
Atlas is a small demo personal agent. It creates a key pair, registers the public key here, and then talks Poppy to Cliffside Retail, showing every request and response.
The quickest way is Atlas in your browser. It keeps its keys in the browser and calls Cliffside Retail's endpoints directly. It shows the user's chat with Atlas, Atlas's conversation with Cliffside Retail's agent, its API calls, and the pages it browses, and every request, side by side. A web page can't send a Cookie header or read Set-Cookie, so its browser sends each request through a demo-only relay, /demo/browser.
Atlas also runs from a terminal, from this demo's repository. It prints every request as a curl command you can run again: each DPoP proof, assertion, and token in it comes from a pnpm atlas command that makes a new one. It needs Node 22.18 or later.
pnpm install
pnpm atlas init # create a key pair and register Atlas as a test agent
pnpm atlas session # discover, then start a signed-out session
pnpm atlas whoami # ask Cliffside Retail who it thinks you are
pnpm atlas stock stormline-insulated # the inventory API, which works signed out
pnpm atlas rewards # the rewards API, which needs sign-in
pnpm atlas renew # get a new Session Token for the same session
pnpm atlas sign-in direct # sign in on Cliffside Retail's own page
pnpm atlas sign-in --phone 4155550123 # or send the phone number yourself
pnpm atlas use-account --new # a new session, signed in with the Account Token
pnpm atlas sign-out # revoke the Account Token
pnpm atlas browse /clearance # Atlas's own browser joins the session, then reads the page
pnpm atlas browse /sizes # the user's saved sizes, once the session is signed in
pnpm atlas browse /private # Cliffside Retail won't show this one to agents: 403
pnpm atlas say "Hello"
pnpm atlas say slow --stream # a reply that streams in as it's written
pnpm atlas say "ship to my work address" # Cliffside Retail asks the user to confirm
pnpm atlas say --human "yes" # the user's own answer, sent with sender: human
pnpm atlas say "I was charged twice" # Cliffside Retail asks to talk with the user directly
pnpm atlas direct open "hi" # the user agrees: a Direct Conversation, in their own words
pnpm atlas direct say "<what happened>" # sent with sender: human
pnpm atlas direct close # back to the parent, which gets direct_closed
pnpm atlas poll # read new events
pnpm atlas close # close the conversationAtlas's inspector link opens the inspector with only Atlas's requests, so you can watch each one arrive and see what Cliffside Retail made of it.
Each request also shows the reasoning on both sides, one short line per step: what Atlas checked and signed before sending it, how Cliffside Retail checked it, and what Atlas made of the response. Routine checks, like verifying a token and its DPoP proof, show as one line you can expand to see each check. A ✗ marks the check that failed. The inspector shows Cliffside Retail's reasoning for your own agent's requests too.
Anyone who knows a test agent's client_id can see its requests in the inspector. Any other agent's client_id is a public URL, so its requests show only at a private inspector link. Your agent gets one by sending a client assertion, as it does at the token endpoint. The link works for 30 days. This is demo-only, not part of the protocol.
curl -X POST https://www.cliffside-retail.com/demo/inspector-link \
--data-urlencode client_id=<your client_id> \
--data-urlencode client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
--data-urlencode client_assertion=<a new client assertion>
{ "inspector_url": "https://www.cliffside-retail.com/inspect?link=…", "expires_in": 2592000 }Test agents
Your agent normally hosts its own client metadata document and JWKS. If you aren't ready to host them yet, register a public key here and Cliffside Retail hosts both for you. Send only the public key: Cliffside Retail rejects private key material. This is a demo-only endpoint, not part of the protocol, like everything under /demo/. The inspectors hide demo-only requests unless you ask for them.
curl -X POST https://www.cliffside-retail.com/demo/test-agents \
-H 'Content-Type: application/json' \
-d '{"name": "My agent", "jwk": {"kty": "EC", "crv": "P-256", "x": "…", "y": "…", "alg": "ES256", "kid": "key-1"}}'The response is your client metadata document. Its client_id is the URL you use as iss in assertions. Test agents expire after 30 days.
Starting a session
Every request after discovery carries a DPoP proof (RFC 9449): a short JWT that names the request's method and URL, signed with a key your agent keeps for this. Cliffside Retail binds each Session Token to that key, so a stolen token is useless without it. Sign a new proof for every request: each one works once, for up to 60 seconds. pnpm atlas proof POST https://www.cliffside-retail.com/oauth/token prints one.
To start a session, sign an assertion with the key in your JWKS: iss is your client_id, sub is your user's ID, aud is Cliffside Retail's issuer, https://www.cliffside-retail.com, as a single string, plus iat, exp (at most 5 minutes later), and a unique jti. Cliffside Retail accepts ES256, ES384, RS256, PS256, and EdDSA, for assertions and proofs. pnpm atlas assertion prints one.
Like every token request, it also carries a client assertion that authenticates your agent (private_key_jwt): the same kind of JWT, with sub set to your client_id. Its aud is the issuer too. Cliffside Retail rejects any other aud, even the token endpoint. pnpm atlas client-assertion prints one.
curl -X POST https://www.cliffside-retail.com/oauth/token \
-H "DPoP: $(pnpm -s atlas proof POST https://www.cliffside-retail.com/oauth/token)" \
--data-urlencode grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer \
--data-urlencode "assertion=$(pnpm -s atlas assertion)" \
--data-urlencode "client_id=$(pnpm -s atlas client-id)" \
--data-urlencode client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
--data-urlencode "client_assertion=$(pnpm -s atlas client-assertion)"The response has a signed-out access_token with token_type: "DPoP", and a session_id. To renew a signed-out session, send the same request with session_id added. Sessions last 7 days, and Session Tokens last 1 hour. When a request gets 401 invalid_token, Atlas renews the session on its own and tries again.
Send the token as Authorization: DPoP, with a new proof whose ath is the token's SHA-256 hash. https://www.cliffside-retail.com/api/whoami is an example company API that echoes back what Cliffside Retail knows about the session:
curl https://www.cliffside-retail.com/api/whoami \
-H "Authorization: DPoP $(pnpm -s atlas token)" \
-H "DPoP: $(pnpm -s atlas proof GET https://www.cliffside-retail.com/api/whoami --with-token)"Signing in
Cliffside Retail supports two kinds of sign-in. With direct sign-in, the user signs in on Cliffside Retail's own page and your agent never sees their username or password. It can grant poppy:read, poppy:write, and addresses. With mediated sign-in, your agent sends the user's phone number, and Cliffside Retail asks for a one-time code. It can grant the same three scopes. The spec says to prefer direct sign-in.
Direct sign-in
Direct sign-in is the OAuth authorization code flow with PKCE. Start a session first: the sign-in signs it in. Then send the user's browser to the authorization_endpoint, https://www.cliffside-retail.com/oauth/authorize, with response_type=code, your client_id, a redirect_uri from your metadata's redirect_uris, the scope, a random state, and a code_challenge with code_challenge_method=S256.
The user signs in with a username and password, then approves some or all of the scopes, or declines. In this demo, any username and password work. Each username gets its own demo account, which lasts 3 days, and anyone who knows the username can sign in to it. The password isn't checked or stored.
Cliffside Retail sends the browser back to your redirect_uri with a code, your state, and iss, or with error=access_denied if the user declined. Check that state is the one you sent and that iss is https://www.cliffside-retail.com before using the code. The code works once, within 60 seconds. Exchange it at the token endpoint, with a DPoP proof:
curl -X POST https://www.cliffside-retail.com/oauth/token \
-H "DPoP: $(pnpm -s atlas proof POST https://www.cliffside-retail.com/oauth/token)" \
--data-urlencode grant_type=authorization_code \
--data-urlencode code=… \
--data-urlencode redirect_uri=… \
--data-urlencode code_verifier=… \
--data-urlencode session_id=… \
--data-urlencode "client_id=$(pnpm -s atlas client-id)" \
--data-urlencode client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer \
--data-urlencode "client_assertion=$(pnpm -s atlas client-assertion)"The response is the same as a completed mediated sign-in's, below. Its scope is what the user approved, which can be less than you asked for. Using a code a second time revokes the Account Token it was exchanged for, in case someone copied it.
A test agent's redirect_uri is a page Cliffside Retail hosts at https://www.cliffside-retail.com/test-agents/<id>/callback. A real personal agent serves its own callback page and finishes on its own. Atlas finishes in one of two ways:
- In the browser, Atlas opens the sign-in page in a popup. The callback page passes the result back to the Atlas window with
postMessageand closes, so the chat stays on screen. If pop-ups are blocked, Atlas sends the whole page to sign in, and finishes when the callback page sends it back. - From the terminal, the callback page shows the command that finishes:
pnpm atlas sign-in finish '<url>'.
Mediated sign-in
Send the user's phone number to the mediated sign-in endpoint, with your Session Token. Nothing is actually texted. The code is always 123456. Any phone number with 10 to 15 digits works and gets its own demo account, which lasts 3 days. Use a made-up number, like 415 555 0123. Anyone who knows the number can sign in to it.
curl -X POST https://www.cliffside-retail.com/agent-auth/sign-in \
-H "Authorization: DPoP $(pnpm -s atlas token)" \
-H "DPoP: $(pnpm -s atlas proof POST https://www.cliffside-retail.com/agent-auth/sign-in --with-token)" \
-H 'Content-Type: application/json' \
-d '{"scope": "poppy:read", "credentials": {"phone": "4155550123"}}'The response has status: "code_required" and a sign_in_id. Send the code to https://www.cliffside-retail.com/agent-auth/sign-in/<sign_in_id> the same way, with {"code": "123456"}. A wrong code returns code_required again, and the fifth wrong code returns failed. A sign-in expires after 10 minutes, and only the session that started it can finish it. An unknown sign_in_id, or one another session started, gets 404 sign_in_not_found.
A correct code returns status: "complete" with a signed-in Session Token for the same session, and an Account Token in refresh_token. Use the new Session Token from then on: older ones are still treated as signed out. A session can only ever be signed in to one account. Signing it in to another gets account_mismatch.
The Account Token remembers the sign-in for 30 days. To use it, send it to the token endpoint with grant_type=refresh_token and a client assertion. You get a signed-in token for session_id, or for a new session if you leave session_id out. The user doesn't sign in again. Keep the Account Token secret: send it only to the token and revocation endpoints.
To sign out, revoke the Account Token at the revocation_endpoint with token and a client assertion. Every session that used it is signed out at once and carries on signed out, so an open conversation continues. Renew to get a token that says so.
Calling the company's APIs
poppy.json lists Cliffside Retail's APIs in apis. It has one, of type openapi, described at https://www.cliffside-retail.com/openapi.json. Find each operation there by its operationId, and call it with your Session Token and a DPoP proof, like any other request. Read it when you discover the company, so your agent knows what it can ask for before it needs to.
| operationId | Path | Needs |
|---|---|---|
getInventory | GET /api/products/{sku}/inventory | Any Session Token, signed in or out |
getRewards | GET /api/rewards | A signed-in token with poppy:read |
whoami | GET /api/whoami | Any Session Token, signed in or out |
curl https://www.cliffside-retail.com/api/products/stormline-insulated/inventory \
-H "Authorization: DPoP $(pnpm -s atlas token)" \
-H "DPoP: $(pnpm -s atlas proof GET https://www.cliffside-retail.com/api/products/stormline-insulated/inventory --with-token)"Stock levels are fixed demo numbers, with a few sizes sold out. The SKUs are stormline-shell, stormline-insulated, ridgeline-fleece, and trailhead-pack-28.
Send https://www.cliffside-retail.com/api/rewards a signed-out token and it returns 403, with the reason in the WWW-Authenticate header, as section 6 describes:
HTTP/1.1 403 Forbidden
WWW-Authenticate: DPoP error="sign_in_required", error_description="…"Use your Account Token, or sign the user in, and try again. A sign-in without poppy:read gets insufficient_scope instead, with the scope it needs in scope.
Browsing the website
poppy.json lists web.browser_session_endpoint, https://www.cliffside-retail.com/poppy/browser-session. Before your agent's browser loads a page here, it joins your session: sign a browser assertion, and have the browser post it as a form. Use a browser your agent controls, never the user's own.
Header: { "alg": "ES256", "kid": "…", "typ": "poppy-browser+jwt" }
Payload: { "iss": "<client_id>", "sub": "<user ID>",
"aud": "https://www.cliffside-retail.com/poppy/browser-session",
"session_id": "ses_…", "return_to": "https://www.cliffside-retail.com/clearance",
"iat": …, "exp": "60 seconds after iat at most", "jti": "…" }
curl -i -X POST https://www.cliffside-retail.com/poppy/browser-session \
--data-urlencode "assertion=$(pnpm -s atlas browser-assertion https://www.cliffside-retail.com/clearance)"If it checks out, Cliffside Retail answers 303 See Other to return_to and sets ar_session, a cookie for that session. The cookie lasts until the session ends. Each page looks the session up again, so signing in or out shows on the next page load. A new assertion replaces the cookie. If anything is wrong, Cliffside Retail sets no cookie and returns an error page with HTTP 400 that says why.
- /clearance has deals at about 20% off. Two of them aren't on the home page or in the inventory API. The page publishes them as schema.org JSON-LD, which is how Atlas reads it. It's the same for everyone, signed in or out.
- /sizes shows the sizes saved to the account, once the session is signed in. Signed out, it asks you to sign in. Sign in, load it again, and the sizes appear. They're made up, the same for each demo account, and published as a schema.org
ItemListofPropertyValueitems. A person who signs in on the website at /account with the same username sees the same sizes. - /private shows made-up embarrassing secrets to a person signed in on the website. A browser that sends
ar_sessiongets 403, even when the session is signed in. The spec doesn't require that. It's a choice a company can make, and the session cookie is what lets it tell an agent from a person.
Talking to the company agent
Start a conversation by sending its first message to https://www.cliffside-retail.com/poppy/conversations, with your Session Token and a DPoP proof. Your agent chooses each message's id, unique for the user.
curl -X POST 'https://www.cliffside-retail.com/poppy/conversations?wait=10' \
-H "Authorization: DPoP $(pnpm -s atlas token)" \
-H "DPoP: $(pnpm -s atlas proof POST https://www.cliffside-retail.com/poppy/conversations --with-token)" \
-H 'Content-Type: application/json' \
-d '{"message": {"id": "msg_first_8a6c7f20", "sender": "agent", "text": "Hello", "context": {"locale": "en-US"}}}'The response has a conversation_id. Send later messages to https://www.cliffside-retail.com/poppy/conversations/<conversation_id>/messages, and read the conversation's events from …/<conversation_id>/events. With wait, a POST also returns the events since its cursor, including the reply. Close the conversation with an empty JSON object to …/<conversation_id>/close.
A conversation belongs to your client_id and the user, not to the session, so a new session can continue it. Once it has used the user's account, continuing it needs a token signed in to that account.
The company agent is scripted and deliberately minimal: this demo is about the protocol, not customer service. It understands eight things:
| Say | What it shows |
|---|---|
Hello | Who Cliffside Retail thinks you are: client_id, user ID, sign-in state, and context |
returns | The return policy, which needs no sign-in, with the details in the message's data |
order | An authorization event while signed out (7.11), the order once signed in |
ship to my work address | Asks the user to confirm the change, with the details in data.confirmation_required. Needs the addresses scope. Only a reply with sender: "human" can confirm (7.8). A yes changes the address, and anything else cancels it. Ask again to switch back. |
charged twice | Asks to talk with the user directly: a user_requested event (7.10). Needs sign-in. Open a Direct Conversation with this one as its parent, and a scripted specialist hears the user out and opens a dispute. A message saying the user would rather not talk declines it. |
my zipper broke | A warranty claim. Needs sign-in. A person has to review it, so the agent asks you to request a handoff (7.9), with the claim in data.warranty. |
slow | A reply after 6 seconds: status "working", waiting, and text-delta streaming |
bye | Closes the conversation (7.12) |
Changes the user confirms
Changing the shipping address needs the addresses scope, which both kinds of sign-in can grant. A session can't switch accounts, so a user signed in by phone adds the scope by signing in by phone again, asking for every scope they need. With it, Cliffside Retail doesn't make the change yet. It asks for the user's own confirmation in a message, with the change in data.confirmation_required. Ask the user, and send their answer as they wrote it, with sender: "human". A reply with sender: "agent" is turned down, and the change stays pending. Cliffside Retail can't check sender, so it relies on your agent labeling it honestly, as section 7.8 requires.
Direct Conversations
Say "charged twice" while signed in. Cliffside Retail wants to hear what happened from the user, so it adds a user_requested event with a reason. Ask the user. If they agree, start a conversation with parent_conversation_id set to this one, and the user's first message, sent as written with sender: "human". The parent gets a direct_opened event, and until the Direct Conversation closes, messages and handoffs to the parent get direct_conversation_open, with its ID in conversation_id.
A specialist answers in the Direct Conversation, with responder and sender set to human. Cliffside Retail has no people, so the specialist is a script, and its messages start with "(Imaginary specialist)". It asks what happened and opens a dispute once the user explains. Words sent with sender: "agent" don't count. Close the Direct Conversation when the user is done. The parent gets direct_closed, then a message from the company agent with the outcome in data.dispute. Closing the parent also closes its Direct Conversation.
Handoff to a person
Send a POST with {} to the conversation's handoff URL. The status is queued, with responder still agent, for 4 seconds. Then a state event says responder: "human": Sam, a support rep, has joined. Cliffside Retail has no people, so Sam is a script, and their messages start with "(Imaginary support rep)" and carry sender: "human". A second handoff while queued, or while Sam is there, changes nothing. Closing the conversation before Sam joins cancels the handoff.
Sam handles warranty claims. Say "my zipper broke" while signed in, and the company agent says a person has to review it. Ask for a handoff, and Sam picks up the claim and offers a replacement or a refund, with the claim in data.warranty. Say which one the user wants. Either is a change to the account, and Sam is held to the same scopes as the company agent, so it needs poppy:write, which both kinds of sign-in can grant. Without it, Sam adds an authorization event. Once Sam is done, a state event hands the conversation back to the company agent. Sam hands back right away when asked about anything else. If the personal agent says it's checking with the user, with "let me check" or "one moment", Sam replies "No rush, take your time." and waits.
Choices this demo makes
Draft 0.2 leaves these open. Cliffside Retail answers them this way for now:
- Cliffside Retail never asks for a DPoP nonce. Other companies may, so a personal agent should still handle
use_dpop_nonce. - Cliffside Retail has no MCP server yet, so it never issues Bearer tokens. A token request without a DPoP proof gets
invalid_request, and one withresourcegetsinvalid_target. - Account Tokens don't rotate: the same one works until it expires or is revoked.
- A message ID is remembered for 7 days, as long as the conversation. Resending a message with the same ID and content returns the conversation without adding it again.
- A
POSTwithwaitholds the request until the company agent stops working, for up towaitseconds. AGETwithwaitholds it until there's an event.waitis capped at 30 seconds. A longer one isn't rejected. - A conversation keeps its last 200 events. An older cursor gets
cursor_expired. Reads return up to 50 events at a time, withhas_morewhen there are more. - Streams close after about 50 seconds, or after the conversation closes. Reconnect with
Last-Event-ID. The "slow" reply streams its text astext-deltaevents for 3 seconds before it arrives. - A person is always available. Sam joins every handoff after 4 seconds, and messages sent while queued wait for them. When Sam is done, they hand back to the company agent with a
stateevent. - The spec has no field for declining a
user_requestedevent, so Cliffside Retail reads it from a message, like "My user would rather not talk with a specialist right now." - A Direct Conversation can't be a parent itself. Naming one as
parent_conversation_idgetsconversation_not_found. - When a Direct Conversation closes, the company agent reports the outcome in the parent, after
direct_closed. - The protocol endpoints accept calls from any origin (CORS), so a personal agent running in a browser on its own domain can call them. They never use cookies. The browser session endpoint is the exception. A browser navigates to it and it sets a cookie, so it doesn't accept cross-origin calls.
What works today
| Area | Spec section | Status |
|---|---|---|
| Discovery and OAuth metadata | 3 | Available |
| Sessions, renewal, and DPoP | 4.1 to 4.3 | Available |
| Conversations as an event log, with streaming | 7.1 to 7.8, 7.11, 7.12 | Available |
| Mediated sign-in, Account Tokens, and sign-out | 4.4, 4.7, 4.8, 4.9 | Available |
| Direct sign-in | 4.5 | Available |
| Company APIs (OpenAPI) | 6 | Available |
| Changes the user confirms (shipping address) | 7.8, 7.11 | Available |
| Direct Conversations (disputing a charge) | 7.10 | Available |
| Handoff to a person (a warranty claim) | 7.9 | Available |
| Web browsing (clearance, sizes, and private pages) | 5 | Available |
| MCP | 6 | Coming soon |
Features marked "Coming soon" aren't in the discovery documents yet. They appear there when they ship.