Personal Agent Protocol
1Introduction
Personal Agent Protocol (Poppy for short ) defines how a Personal Agent, acting for one User, works with a Company on that User's behalf. It covers many parts of that interaction, beyond the agent-to-agent conversations that other agent protocols focus on:
- Discovery: a standard way for the Personal Agent to find everything it needs to work with a Company.
- Sessions and authorization: how the agent identifies itself to the Company, how the Company identifies each User (signed in or not), and how Users sign in to their accounts.
- Web browsing: how the Personal Agent browses the Company's website in the same Session.
- APIs: how the Personal Agent uses the Company's APIs, which can be more efficient than web browsing.
- Conversations: how the Personal Agent starts a conversation with the Company Agent, and how either side can bring in a person: the User on the Personal Agent's side, or a person at the Company.
- Extensions: how Companies and Personal Agents add capabilities beyond this specification, described on the Extensions page.
This is draft version 0.1. Any part of it can change before a stable version, including in ways that aren't backward compatible. Areas it doesn't cover yet are listed in Open topics.
The examples use example.com for the Company and agent.example for the Personal Agent. Tokens, keys, and codes in the examples are shortened placeholders, not real values.
The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are used as described in BCP 14 (RFC 2119, RFC 8174) when they appear in capitals, as shown here.
2Terms
| Term | Meaning |
|---|---|
| Parties | |
User | The person a Personal Agent acts for. |
Personal Agent | Software acting for the User. |
Company | An entity that implements the protocol so Personal Agents can work with it. |
| Discovery | |
poppy.json | The JSON document a Company publishes at /.well-known/poppy.json to tell Personal Agents how to start Sessions and sign in, and which APIs and Company Agent it offers (section 3). |
| Sessions and authorization | |
User ID | The ID a Personal Agent gives one User at one Company. It is stable, so the Company recognizes the User across Sessions even when they're signed out. It is opaque and different at each Company, so Companies can't match Users with each other (4.2). |
Session | One User's activity at one Company through one Personal Agent, across web browsing, APIs, and conversations. It starts signed out and can be signed in to one Company account. A Personal Agent can have several Sessions for the same User (section 4). |
Session Token | The short-lived access token for a Session, used at the Company's APIs and conversations. It is signed out, or signed in with the account scopes the User granted, such as poppy:read (4.2). |
Account Token | A long-lived credential from sign-in that lets the Personal Agent get signed-in Session Tokens later without asking the User again. It is used only at the Company's token endpoint (4.8). |
DPoP Proof | A signature the Personal Agent sends with each request to show it holds the key its Session Token is bound to (4.3). |
Direct Sign-In | The User signs in on the Company's own page in their browser and approves access (4.5). |
Device Sign-In | The User signs in on the Company's page on any device, using a link and code the Personal Agent shows them (4.6). |
Mediated Sign-In | The Personal Agent signs in for the User by sending the User's credentials to the Company (4.7). |
| Conversations | |
Company Agent | A Company's own agent, reached through a supported conversation protocol. |
Direct Conversation | A new conversation, linked to an earlier one, in which the User talks with the Company through the Personal Agent's interface (7.10). |
Conversation ID | Identifies one conversation, in the URL of each request about it. A conversation belongs to one Personal Agent and one User or account (7.2). |
3Discovery
A Company MUST publish its poppy.json document at https://{domain}/.well-known/poppy.json over HTTPS, where {domain} is the Company's domain or a subdomain it controls.
The well-known URL MAY redirect to where the document is hosted, so a Company can host it somewhere else, such as with a provider. The document still speaks for the domain the Personal Agent requested (see organization below). Personal Agents MUST follow redirects, and every redirect MUST be to an HTTPS URL. Personal Agents SHOULD cache the document according to its HTTP caching headers.
An example document:
{
"protocol_version": "0.1",
"organization": {
"name": "Example Company",
"domain": "example.com"
},
"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"
}
},
"agent": {
"protocols": [
{
"type": "poppy",
"endpoint": "https://api.example.com/poppy/conversations"
}
]
},
"web": {
"browser_session_endpoint": "https://example.com/poppy/browser-session"
},
"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"
}
]
}3.1 Fields
| Field | Req. | Description |
|---|---|---|
protocol_version | Yes | The protocol version the document follows, as major.minor. This draft is 0.1. A newer minor version doesn't break older Personal Agents, which use the document and ignore fields they don't recognize. A Personal Agent MUST NOT use a document whose major version it doesn't support. |
organization | Yes | Display name and domain. domain MUST match the host of the well-known URL the Personal Agent requested, ignoring a leading www.. After a redirect, the host the document is served from does not count. |
auth | If agent, apis, or web.browser_session_endpoint is present | issuer is the Company's OAuth issuer identifier, an HTTPS URL. It identifies the Company, and the Personal Agent finds the Company's OAuth endpoints from it (3.2). It also contains direct, device, and mediated for the types of sign-in the Company supports (4.4), if any. With none, it offers only signed-out Sessions. Sessions need auth, so a Company without it can only be browsed as an ordinary website (section 5). |
auth.direct | If supported | scopes lists the scopes Direct Sign-In can grant (4.5). |
auth.device | If supported | scopes lists the scopes Device Sign-In can grant (4.6). |
auth.mediated | If supported | endpoint is where the Personal Agent sends credentials, and fields lists them (4.7). scopes lists the scopes Mediated Sign-In can grant. |
auth.custom_scopes | No | A description of each scope the Company defines beyond poppy:read and poppy:write (4.4). |
agent | One of agent, apis, or web | The Company Agent. protocols lists its supported conversation protocols, each with a type and HTTPS endpoint (section 7). An entry MAY include a resource to require tokens issued for it (4.3). |
apis | One of agent, apis, or web | A list of APIs. Each has a type from section 6, a url, and a short description that helps the Personal Agent pick the right one. An entry MAY include a resource to require tokens issued for it (4.3). |
web | One of agent, apis, or web | The Company's website. The optional browser_session_endpoint is where the Personal Agent's browser joins a Session (section 5). Without it, Personal Agents browse the site as ordinary signed-out visitors. |
extensions | No | The extensions the Company supports, keyed by name (below). |
3.2 OAuth server
A Company is identified by its auth.issuer. Several domains MAY name the same issuer, such as a Company with a domain for each country, and they are then one Company: the same User IDs (4.2), Sessions, and Account Tokens apply to all of them.
The issuer MUST publish OAuth Authorization Server Metadata (RFC 8414), and poppy_domains in it MUST list every domain whose poppy.json names the issuer. Before using a Company's poppy.json, the Personal Agent fetches the metadata and MUST check that its issuer exactly matches auth.issuer and that poppy_domains includes organization.domain. Otherwise any domain could claim another Company's issuer and receive its tokens.
{
"issuer": "https://auth.example.com",
"token_endpoint": "https://auth.example.com/oauth/token",
"revocation_endpoint": "https://auth.example.com/oauth/revoke",
"authorization_endpoint": "https://auth.example.com/oauth/authorize",
"device_authorization_endpoint": "https://auth.example.com/oauth/device",
"poppy_domains": ["example.com", "example.co.uk"]
}The Personal Agent takes these endpoints from the metadata:
token_endpointissues Session and Account Tokens (4.2, 4.8). Required.revocation_endpointrevokes Account Tokens (4.9). Required.authorization_endpointstarts Direct Sign-In (4.5). Required ifauth.directis present.device_authorization_endpointstarts Device Sign-In (4.6). Required ifauth.deviceis present.
The metadata MAY have other fields, which Personal Agents ignore. Personal Agents SHOULD cache it according to its HTTP caching headers.
3.3 Extensions
Extensions add optional features on top of this protocol, such as operations. A Company lists the ones it supports in extensions:
"extensions": {
"operations": {
"version": "1",
"endpoint": "https://api.example.com/poppy/operations"
}
}- Extensions defined with this protocol have plain names, such as
operations. Anyone else MUST start an extension's name with a domain they control, such asexample.com/gift-wrap. - Each entry has a
version, the extension's major version. Changes that implementations can safely ignore keep the version, and incompatible changes get a new one. Each extension defines its other fields. - A Personal Agent lists the extensions it supports the same way, in
extensionsin its client metadata document (4.1). - Personal Agents and Companies MUST ignore extensions they don't support and fields they don't recognize, so either side can work with the other under this specification alone.
- A Company MAY refuse a request it handles only with an extension the Personal Agent doesn't list. It returns
extension_required(HTTP 403) with the extension's name inextension.
4Sessions and authorization
Everything a Personal Agent does at a Company happens in a Session. A Session belongs to one User, at one Company, through one Personal Agent, and ties that User's activity together across web browsing, API calls, and conversations, such as a cart or an open conversation. A Personal Agent can run several Sessions for the same User at once, for example for tasks running in parallel. Companies MAY limit what a Personal Agent can do in a Session, signed in or out.
The protocol uses two kinds of token:
| Token | What it is | Used at |
|---|---|---|
Session Token | A short-lived access token for one Session, signed in or out. It is bound to a key the Personal Agent holds (4.3). | The Company's APIs and conversations |
Account Token | A long-lived credential from sign-in. It stands for the User's approval for this Personal Agent to use their account. It is an OAuth refresh token. | Only the Company's token endpoint, to get signed-in Session Tokens (4.8) |
A Session starts signed out: the Company knows which Personal Agent is asking and can recognize the same User over time, but not who the User is. When a task needs the User's account, the User signs in once, and the Personal Agent keeps the Account Token it gets. Later Sessions use the Account Token to start signed in without asking the User again, until the User signs out, disconnects the Personal Agent, or the Account Token expires. Ending a Session doesn't affect the Account Token.
The steps, in the order they usually happen:
| Step | Request | Result |
|---|---|---|
Start a Session (4.2) | token_endpoint with the Personal Agent's assertion | A new Session and a signed-out Session Token |
Renew a Session Token (4.2) | The same, with session_id | A new signed-out Session Token for the same Session |
Sign in (4.4–4.7) | Direct, device, or Mediated Sign-In | An Account Token, and a signed-in Session Token for the current Session |
Use the Account Token (4.8) | token_endpoint with the Account Token | A signed-in Session Token, for a new or existing Session |
Sign out (4.9) | revocation_endpoint with the Account Token | The Account Token is revoked, and Sessions continue signed out |
The endpoints come from the Company's OAuth server metadata (3.2). Everything except Mediated Sign-In is standard OAuth 2.0: Session Tokens are OAuth access tokens and Account Tokens are OAuth refresh tokens, so a Company can use its existing authorization server. The protocol adds the session_id parameter and the rules in this section.
4.1 Agent identity
A Personal Agent's client_id MUST be an HTTPS URL that returns its metadata document. Companies fetch it to learn who the Personal Agent is, to get the keys it signs with, and to check where Direct Sign-In may return the User. This follows the OAuth Client ID Metadata Document draft that MCP also uses.
{
"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",
"extensions": {
"operations": { "version": "1" }
}
}client_idMUST equal the URL the document was fetched from.jwks_uriand every entry inredirect_urisMUST be HTTPS URLs on the same domain asclient_id. The key set contains only public keys.redirect_urislists where Direct Sign-In may send the User back to (4.5).extensionslists the extensions the Personal Agent supports, keyed by name, each with itsversion(3.3). It is optional.- The Personal Agent authenticates at the token endpoint with a client assertion signed by a key from
jwks_uri(private_key_jwt, RFC 7523). The same keys sign its Session assertions (4.2). Its DPoP keys (4.3) are separate and aren't published.
Companies MAY require Personal Agents to register ahead of time. How registration works is outside this specification. A Company that requires registration rejects an unregistered Personal Agent with the OAuth invalid_client error (HTTP 401) when it starts a Session.
Companies MAY keep allowlists or blocklists of Personal Agents by client_id, and MAY revoke a client_id that misuses the protocol. Companies MAY rate-limit Session creation by client_id. A Company that doesn't require registration MAY also limit how many new client_id values it accepts.
4.2 Starting a Session
The Personal Agent MUST give each of its Users a User ID for each Company, where a Company is identified by its auth.issuer (3.2). The ID is stable, so the Company sees the same User across Sessions. It is opaque and different for each Company, so Companies can't match Users with each other. It MUST NOT contain or be derived from personal information, such as an email address, phone number, or name, even with a keyed hash.
To start a Session, the Personal Agent requests a Session Token from the Company's token_endpoint with the JWT bearer grant (RFC 7523). The assertion is a short-lived JWT signed with a key from its jwks_uri, naming itself in iss and the User in sub. Like every token request, it also carries the Personal Agent's client assertion (4.1) and a DPoP Proof (4.3).
POST https://auth.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs…
grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer
&assertion=eyJhbGciOiJFUzI1NiIs…
&client_id=https%3A%2F%2Fagent.example%2Fagent.json
&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
&client_assertion=eyJhbGciOiJFUzI1NiIs…{
"iss": "https://agent.example/agent.json",
"sub": "usr_Q7c1vK",
"aud": "https://auth.example.com/oauth/token",
"iat": 1790900000,
"exp": 1790900060,
"jti": "V6vJGs2ixBAvGdaQgGaTzg"
}issis the Personal Agent'sclient_id(4.1).subis the User ID for this Company, described above.audis the Company'stoken_endpoint, as a single string rather than an array, so the assertion can't be used at another Company.iatandexpare when the assertion was issued and when it expires, in seconds since 1970 (Unix time). The assertion is short-lived: one minute in this example.jtiis a unique ID for the assertion, with at least 128 random bits. The Company rejects one it has already seen, so a copied assertion can't be used again.
The Company verifies both assertions against the Personal Agent's jwks_uri, checks that the assertion's iss is the authenticated client_id, rejects a reused jti, and returns a new Session:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{
"access_token": "eyJhbGciOi…Lm9x",
"token_type": "DPoP",
"expires_in": 3600,
"scope": "",
"session_id": "ses_2Lm0",
"signed_in": false
}access_tokenis the Session Token.token_typeisDPoP, orBearerfor a token requested without a proof (4.3).expires_inis the token's lifetime in seconds. The Company chooses the value, and it MUST be greater than 0. Session Tokens SHOULD last hours, not days.session_ididentifies the Session. It is not a credential.signed_insays whether the Session is signed in to a Company account.scopeis a space-separated string of the account scopes granted (4.4). A signed-out token has none.
Renewing. When a signed-out Session Token expires, the Personal Agent sends the same request with fresh assertions and the session_id:
POST https://auth.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs…
grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer
&assertion=eyJhbGciOiJFUzI1NiIs…
&session_id=ses_2Lm0
&client_id=https%3A%2F%2Fagent.example%2Fagent.json
&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
&client_assertion=eyJhbGciOiJFUzI1NiIs…The Company checks that the Session belongs to the same client_id and User ID as the assertion, and returns a new signed-out Session Token for it. A signed-in Session is renewed with the Account Token instead (4.8). The Company decides how long a Session lasts. If it has ended, the Company returns invalid_session, and the Personal Agent starts a new Session.
Token format. Session Tokens are issued by the Company and are opaque to the Personal Agent. Personal Agents MUST NOT depend on their contents; everything they need is in the response. A Company MAY include its own Session credentials in a token so its existing systems can read them. If it does, it MUST encrypt the token.
Errors use the OAuth error response, with these codes:
| Error | Status | Meaning |
|---|---|---|
invalid_client | 401 | The Company doesn't accept this client_id: it is unregistered, blocked, or revoked, or its metadata or signature doesn't 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 a different Personal Agent or User. Start a new Session. |
account_mismatch | 400 | The Session is signed in to a different account than the one requested. Start a new Session (4.4). |
invalid_dpop_proof, use_dpop_nonce | 400 | The DPoP Proof is missing or invalid, or needs a nonce (4.3). |
rate_limited | 429 | Too many requests for this client_id. Retry after the time in the Retry-After header. |
4.3 Using Session Tokens
API calls and conversations MUST carry the Session Token in the Authorization header with the DPoP scheme, together with a DPoP Proof (RFC 9449):
Authorization: DPoP eyJhbGciOi…Lm9x
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs…Proof of possession. Session Tokens are bound to a key the Personal Agent holds, so a copied token is useless without it. The Personal Agent generates a key pair for each User at each Company, and MAY use a new one for each Session. It keeps the private key to itself and signs a new proof for every request, including token requests. A proof is a JWT with the public key in its header:
{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": { "kty": "EC", "crv": "P-256", "x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs", "y": "9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA" }
}
.
{
"jti": "e1j3V_bKic8-LAEB",
"htm": "POST",
"htu": "https://api.example.com/orders/ord_7Hk2/exchanges",
"iat": 1790900000,
"ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo"
}jtiis a unique ID for the proof.htmandhtuare the request's HTTP method and URL, without the query or fragment.iatis when the proof was made.athis the SHA-256 hash of the Session Token sent with it. Proofs sent to the token endpoint don't have one.nonceis included when the Company requires one (below).
When the Company issues a Session Token, it binds the token to the key in that request's proof. On every request, it MUST check that:
- The proof's signature verifies with the key in its header.
- That key is the one the Session Token is bound to.
htmandhtumatch the request.iatis within the Company's acceptance window, such as one minute.- It hasn't accepted the same
jtiwithin that window. athmatches the Session Token.
A Company MAY require proofs to include a nonce it issued, as RFC 9449 describes, so proofs can't be made in advance. Personal Agents MUST support nonces. Failed checks return invalid_dpop_proof or use_dpop_nonce with the WWW-Authenticate: DPoP header.
Bearer tokens. MCP can't use proofs, because MCP authorization uses Bearer tokens. For MCP, the Personal Agent requests a Session Token without a proof, and the Company issues a Bearer token. To get one for an existing Session, the Personal Agent sends the request that renews the Session's token (4.2, or 4.8 for a signed-in Session) without the DPoP header, and MUST include resource set to the MCP server's url from poppy.json, as MCP authorization requires. Anyone who copies a Bearer token can use it, so each one works at only one MCP server. A Session can have DPoP and Bearer tokens at the same time. Companies MUST accept Bearer Session Tokens only on their MCP APIs (section 6), and MUST require DPoP everywhere else. Web browsing doesn't use Session Tokens (section 5).
POST https://auth.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=pat_8Hk2…Wq1
&session_id=ses_2Lm0
&resource=https%3A%2F%2Fmcp.example.com%2Fmcp
&client_id=https%3A%2F%2Fagent.example%2Fagent.json
&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
&client_assertion=eyJhbGciOiJFUzI1NiIs…Narrower tokens. Any token request MAY include scope, to ask for fewer scopes than the Session has, and resource (RFC 8707), to ask for a token for one service. By default, one DPoP Session Token works at all of a Company's services, except MCP (above). A Company that wants separate tokens for some services gives those entries a resource in its poppy.json (section 3), and accepts a token at such a service only if it was issued for that resource.
- Companies MUST accept the token in the
Authorizationheader without requiring cookies. - Personal Agents MUST send a Session Token on every API and conversation request, and MUST NOT leave it out to appear to the Company as a person on their own. Web browsing follows section 5.
- Whenever the Personal Agent gets a new Session Token for a Session, it MUST use the new token from then on.
- Personal Agents MUST NOT send tokens in a URL, and Companies MUST NOT accept them from one.
Rate limits. A Company that limits how often a Personal Agent can make requests returns HTTP 429 with a Retry-After header when a request goes over the limit, on any of its endpoints, APIs, conversations, or website. The Personal Agent MUST wait at least that long before sending the request again.
4.4 Signing in
A Session does not require sign-in. When a task needs account access and the Personal Agent has no Account Token that covers it (4.8), the User signs in using one of the types the Company supports. The Company describes each type it supports in auth.direct, auth.device, and auth.mediated. A Company that lists none offers only signed-out Sessions.
| Type | Who signs in | How |
|---|---|---|
direct | The User | The User signs in on the Company's own page in their browser and approves access. Standard OAuth 2.0 authorization code flow (4.5). |
device | The User | The Personal Agent shows the User a link and code. The User signs in on the Company's page on any device and approves access. Standard OAuth 2.0 device authorization grant (4.6). |
mediated | The Personal Agent, for the User | The Personal Agent sends the User's credentials to the Company's sign-in endpoint (4.7). |
A Company can't always verify which type actually happened. For example, a Personal Agent could complete a Direct Sign-In page itself. The types work on trust: a Personal Agent MUST use direct or Device Sign-In only when the User signs in themselves. Companies MAY revoke the client_id of a Personal Agent that doesn't (4.1).
Scopes
Scopes say what account access a sign-in grants. The protocol defines two broad scopes:
| Scope | Account access |
|---|---|
poppy:read | View account information, such as orders and bookings. |
poppy:write | Make changes, such as exchanging an item or updating a booking. |
- The scopes are independent: request
poppy:read poppy:writewhen both are needed. Companies decide which operations require each scope and enforce them across web browsing, APIs, and actions performed by the Company Agent. - Companies MAY define their own scopes, such as
addresses, and describe each one inauth.custom_scopes. Names starting withpoppy:are reserved for this protocol, so a Company's existing OAuth scopes can't clash with them. - A Company MAY leave out
poppy:readandpoppy:writeand offer only its own, narrower scopes, if broad access doesn't meet its security needs. The Personal Agent then requests the scopes the task needs from those the Company lists. - Each type lists the scopes it can grant in its
scopes. In the discovery example, Mediated Sign-In can grant onlypoppy:read, while Direct Sign-In can grant all three. The Company MUST NOT grant scopes beyond those requested and allowed for the type used. - Each sign-in request MUST include
scopeas a space-separated string, following OAuth scope syntax. Missing, unknown, or unavailable scopes are rejected withinvalid_scope. - If a task needs more access than the Account Token allows, the User signs in again, through a type that allows the required scopes, and the Personal Agent requests all the scopes it needs. The new Account Token replaces the old one, which the Personal Agent SHOULD revoke (4.9). A pending or failed sign-in leaves existing tokens unchanged.
All three types end the same way: the Company returns an Account Token as refresh_token, and a signed-in Session Token for the current Session. The User may have approved fewer scopes than requested, so the Personal Agent checks scope before continuing.
{
"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_tokenis the Account Token (4.8).refresh_token_expires_inis the Account Token's lifetime in seconds, if the Company sets one. After it, the User signs in again.
How sign-in is remembered
The Company keeps a record for each Account Token: the client_id, the User ID, the account, the granted scopes, and when it expires. It also keeps a record for each Session: the client_id, the User ID, and the account the Session is signed in to, if any.
A Session is signed in to at most one account. Once it has been, it can't switch to another: the Company returns account_mismatch, and the Personal Agent starts a new Session. A User with two accounts at the same Company, such as a personal and a business account, has an Account Token for each and uses separate Sessions for them.
Each Session Token reflects the Session as it was when the token was issued. Several tasks can share one Session, so one may still hold a signed-out token after another task signed in. A request with that token is handled as signed out, and an account action fails with sign_in_required. Before starting a new sign-in, the Personal Agent SHOULD check whether it already holds an Account Token that covers the task (4.8).
4.5 Direct Sign-In
Direct Sign-In is the OAuth 2.0 authorization code flow with PKCE (RFC 6749, RFC 7636). It adds one thing: the Personal Agent sends session_id when it exchanges the code, so the Company knows which Session to sign in. The Company's OAuth server metadata gives the authorization_endpoint (3.2).
1. Send the User to the Company. The Personal Agent creates a random state and a PKCE code verifier, then opens the authorization URL in the User's browser, for example in a new tab, a popup, or a link sent to the User's phone:
https://auth.example.com/oauth/authorize
?response_type=code
&client_id=https%3A%2F%2Fagent.example%2Fagent.json
&redirect_uri=https%3A%2F%2Fagent.example%2Foauth%2Fcallback
&scope=poppy%3Aread%20poppy%3Awrite
&state=Xq81vR
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S2562. The User signs in and approves. The Company's page shows which Personal Agent is asking, using the name and logo from its metadata document. The User signs in however the Company allows, such as a password, a passkey, or a provider like Google or Apple. The page shows the requested scopes and lets the User approve some or all of them, or decline.
3. The Company sends the User back. The Company redirects the browser to redirect_uri with a one-time code, the same state, and its auth.issuer in iss (RFC 9207). redirect_uri MUST exactly match an entry in the Personal Agent's redirect_uris (4.1). If the User declines, the redirect carries error=access_denied and iss instead.
4. The Personal Agent exchanges the code. The Personal Agent checks state, and MUST check that iss exactly matches the auth.issuer of the Company it started sign-in with. This stops a response from another Company's sign-in being swapped in. It then sends the code to the token_endpoint with its client assertion, a DPoP Proof, and the session_id:
POST https://auth.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs…
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fagent.example%2Foauth%2Fcallback
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
&session_id=ses_2Lm0
&client_id=https%3A%2F%2Fagent.example%2Fagent.json
&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
&client_assertion=eyJhbGciOiJFUzI1NiIs…The Company verifies the code, the verifier, and the client assertion, and checks that the Session belongs to the same client_id. It returns an Account Token and a signed-in Session Token for that Session, as shown in 4.4. Errors follow OAuth 2.0, such as invalid_grant for an expired or reused code.
4.6 Device Sign-In
Device Sign-In is the OAuth 2.0 device authorization grant (RFC 8628). The Personal Agent shows the User a link and a short code, and the User signs in on the Company's page on whatever device they like, such as their phone. It suits Personal Agents that can't receive a browser redirect, and Companies that already offer this kind of sign-in. The Company's OAuth server metadata gives the device_authorization_endpoint (3.2).
1. Start the request. The Personal Agent sends the scopes it needs, with its client assertion:
POST https://auth.example.com/oauth/device
Content-Type: application/x-www-form-urlencoded
scope=poppy%3Aread%20poppy%3Awrite
&client_id=https%3A%2F%2Fagent.example%2Fagent.json
&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
&client_assertion=eyJhbGciOiJFUzI1NiIs…{
"device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",
"user_code": "WDJB-MJHT",
"verification_uri": "https://auth.example.com/device",
"verification_uri_complete": "https://auth.example.com/device?user_code=WDJB-MJHT",
"expires_in": 600,
"interval": 5
}2. The User signs in and approves. The Personal Agent shows the User verification_uri and user_code, or verification_uri_complete as a link or QR code. It keeps device_code private. The Company's page shows which Personal Agent is asking and the requested scopes, as in Direct Sign-In, and the User approves some or all of them, or declines. Opening the link MUST NOT approve the request by itself.
3. The Personal Agent waits. Every interval seconds, the Personal Agent asks the token_endpoint whether the User has finished:
POST https://auth.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs…
grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code
&device_code=GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS
&session_id=ses_2Lm0
&client_id=https%3A%2F%2Fagent.example%2Fagent.json
&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
&client_assertion=eyJhbGciOiJFUzI1NiIs…Until the User finishes, the Company returns authorization_pending, or slow_down to ask the Personal Agent to wait longer between requests. Once the User approves, it returns an Account Token and a signed-in Session Token, as shown in 4.4. If the User declines, it returns access_denied, and if the request runs past expires_in, expired_token. The Personal Agent stops asking after either.
4.7 Mediated Sign-In
In Mediated Sign-In, the Personal Agent signs in for the User with credentials the User has given it for this Company. This is not an OAuth flow. The protocol defines the requests and responses so that every Company that offers Mediated Sign-In handles it the same way.
Companies ask for different credentials, such as an email and password, or an account number and PIN. auth.mediated.fields lists the ones the Company needs:
"fields": [
{ "name": "email", "label": "Email", "secret": false },
{ "name": "password", "label": "Password", "secret": true }
]nameis the key the Personal Agent sends.labelis what to ask the User for.secretmarks a value the Personal Agent MUST protect: never shown back to the User, written to logs, or sent anywhere else.
The Personal Agent sends every field in credentials to auth.mediated.endpoint, with its current Session Token:
POST https://auth.example.com/poppy/sign-in
Authorization: DPoP eyJhbGciOi…Lm9x
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs…
Content-Type: application/json
{
"scope": "poppy:read",
"credentials": { "email": "…", "password": "…" }
}If the credentials are correct and nothing else is needed, the Company signs in the Session and returns complete with the same fields as the response in 4.4:
{
"status": "complete",
"access_token": "eyJhbGciOi…Qp4w",
"token_type": "DPoP",
"expires_in": 3600,
"refresh_token": "pat_3Vn7…Lk2",
"scope": "poppy:read",
"session_id": "ses_2Lm0",
"signed_in": true
}One-time codes. If the Company needs a second step, it sends the User a one-time code and returns a sign-in ID instead:
{
"status": "code_required",
"sign_in_id": "sgn_3Hq8",
"code": { "sent_to": "Text to the phone number ending in 71" },
"expires_at": "2026-10-02T17:15:00-07:00"
}The Personal Agent gets the code from the User and submits only the code, not the credentials again, with a Session Token for the same Session:
POST https://auth.example.com/poppy/sign-in/sgn_3Hq8
Authorization: DPoP eyJhbGciOi…Lm9x
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs…
Content-Type: application/json
{ "code": "…" }A correct code returns complete with the tokens. A wrong code returns code_required again, until the Company's attempt limit, and then failed. Only the Session that started a sign-in can finish it.
| Status | Meaning |
|---|---|
complete | Signed in. The response includes the Account Token, the new Session Token, and the granted scope. |
code_required | The Company sent the User a one-time code. Submit it to continue. |
failed | Sign-in didn't succeed, for example a wrong password or too many wrong codes. |
expired | The sign-in took longer than expires_at. Start again. |
Accepting credentials from a Personal Agent puts more responsibility on the Company than Direct Sign-In does. A Company that offers Mediated Sign-In MUST rate-limit attempts, MUST NOT return credentials in any response, and SHOULD require a one-time code when a sign-in looks unusual. Personal Agents MUST NOT send credentials to any endpoint other than the Company's auth.mediated.endpoint. Once it has an Account Token, the Personal Agent doesn't need the credentials to start signed-in Sessions.
4.8 Account Tokens
An Account Token lets the Personal Agent get signed-in Session Tokens later without asking the User to sign in again. It is the OAuth refresh token from sign-in (RFC 6749, section 6).
The Personal Agent sends the Account Token to the token_endpoint with its client assertion and a DPoP Proof. With session_id, it gets a token for that Session, signing it in if it was signed out. Without one, it starts a new Session that is already signed in:
POST https://auth.example.com/oauth/token
Content-Type: application/x-www-form-urlencoded
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs…
grant_type=refresh_token
&refresh_token=pat_8Hk2…Wq1
&session_id=ses_4Tn8
&scope=poppy%3Aread
&client_id=https%3A%2F%2Fagent.example%2Fagent.json
&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
&client_assertion=eyJhbGciOiJFUzI1NiIs…The response has the same fields as in 4.4, without a new refresh_token unless the Company rotates it (below).
- Companies MUST accept an Account Token only at their
token_endpointandrevocation_endpoint, and only from theclient_idit was issued to, authenticated by its client assertion. A copied Account Token is useless without the Personal Agent's private key. - 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 the Company returnsinvalid_sessionoraccount_mismatch. scopeMAY ask for fewer scopes than the Account Token has, for example read-only access for a task that only looks things up. It can't ask for more.- Using an Account Token doesn't extend its lifetime. A Company MAY return a new Account Token in
refresh_token. The Personal Agent then MUST use the new one from then on. - When the Account Token has expired or been revoked, the Company returns
invalid_grant. The Session continues signed out, and the User signs in again when a task needs the account. - Personal Agents MUST store Account Tokens as secrets, and MUST NOT put them in model context, conversation messages, logs, or URLs.
4.9 Signing out
Signing out removes the Personal Agent's access to the User's account. The Personal Agent revokes its Account Token at the Company's revocation_endpoint (RFC 7009), with its client assertion:
POST https://auth.example.com/oauth/revoke
Content-Type: application/x-www-form-urlencoded
token=pat_8Hk2…Wq1
&token_type_hint=refresh_token
&client_id=https%3A%2F%2Fagent.example%2Fagent.json
&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
&client_assertion=eyJhbGciOiJFUzI1NiIs…The Company revokes the Account Token and signs out every Session signed in with it. Usually those Sessions continue signed out, so a cart or an open conversation carries on: the Personal Agent renews them with its assertion (4.2) and gets signed-out Session Tokens. A Company MAY end the Sessions instead, if its policy is to clear everything at sign-out. Renewal then returns invalid_session, and the Personal Agent starts a new Session.
Session Tokens issued before sign-out may still be valid. Companies SHOULD check on each request whether the Session is still signed in, so sign-out takes effect right away. A Company that doesn't SHOULD use much shorter Session Token lifetimes, such as a few minutes.
To end a Session without signing out, the Personal Agent stops using it. The Account Token is unaffected and can start new Sessions.
The User can also disconnect the Personal Agent from the Company's account settings. That revokes the Personal Agent's Account Tokens for that account and signs out its Sessions. The Personal Agent finds out when a request fails with invalid_token or sign_in_required, or the token endpoint returns invalid_grant for the Account Token. It then continues signed out.
5Web browsing
A Personal Agent can browse the Company's website in the same Session it uses for APIs and conversations, so the Company sees one Session for the User, whether or not the User is signed in. The Personal Agent's browser joins the Session by posting a signed browser assertion to the Company, and the Company sets a cookie of its own for that Session.
This is optional. A Company that supports it lists web.browser_session_endpoint in its poppy.json (section 3). When it does, the Personal Agent MUST join its browser to a Session before browsing the Company's website. On a website without the endpoint, the Personal Agent browses as an ordinary signed-out visitor.
1. The Personal Agent signs a browser assertion. It is a JWT signed with a key from the Personal Agent's jwks_uri, like the assertion that starts a Session (4.2), with typ set to poppy-browser+jwt in its header:
{
"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"
}iss,sub,iat, andjtiare as in 4.2.audis the Company'sweb.browser_session_endpoint.session_idis the Session the browser joins, signed in or out.return_tois the page to open afterward. It MUST be an HTTPS URL on the Company'sorganization.domainor one of its subdomains.expis no more than 60 seconds afteriat.
2. The browser posts it. The Personal Agent has its browser send the assertion to the endpoint as a form POST, as a top-level navigation, so the cookie the Company sets lands in that browser. For example, it loads a page with a form that submits itself. The assertion MUST go in the request body, never in the URL.
POST https://example.com/poppy/browser-session
Content-Type: application/x-www-form-urlencoded
assertion=eyJ0eXAiOiJwb3BweS1icm93c2VyK2p3dCIsImFsZyI6IkVTMjU2In0…The Personal Agent MUST post browser assertions only from a browser it controls, never from the User's own browser, which would then be using the Personal Agent's Session.
3. The Company checks it and sets a cookie. The Company MUST check that:
- The signature verifies with a key from the
issclient'sjwks_uri, andtypispoppy-browser+jwt. A Session assertion isn't accepted here, and a browser assertion isn't accepted at the token endpoint. audis its endpoint, the assertion hasn't expired, andexpis no more than 60 seconds afteriat.- It hasn't accepted the same
jtibefore. Like DPoP Proofs (4.3), it only needs to remember ajtiuntil the assertion expires. - The Session is active and belongs to the same
client_idand User. return_tois on its own domain.
If every check passes, the Company sets a cookie for the Session and responds with 303 See Other to return_to. If not, it sets no cookie and returns an error page with HTTP 400.
HTTP/1.1 303 See Other
Location: https://example.com/orders
Set-Cookie: sb_session=Pz81kQ…; Path=/; Secure; HttpOnly; SameSite=LaxThe cookie. The cookie is the Company's own. The Company chooses its name, value, the hosts it covers, and its lifetime, and links it to the Session on its servers. The cookie:
- MUST follow the Session's current state: when the Session signs in or out, or its scopes change, the website reflects it on the next request.
- MUST NOT outlive the Session.
- MUST be
SecureandHttpOnly, and useSameSite=LaxorNone, notStrict. The POST comes from a page the Personal Agent created, so withStrictthe browser wouldn't send the cookie when it follows the redirect.
The website applies the Session's scopes, as the Company's APIs do. While the browser carries the Session's cookie, the Company handles its requests as that Session, signed in or out, whatever other cookies the browser holds from earlier visits.
The Personal Agent MAY post a new browser assertion at any time, for example when the website no longer recognizes the Session. Each one replaces the cookie the Company set before.
6APIs
A Company can list APIs in its poppy.json (section 3). Each API's type MUST be one of these:
| Type | url points to | Specification |
|---|---|---|
openapi | An OpenAPI 3.0 or 3.1 description of the API. | |
mcp | A remote MCP server endpoint, using the Streamable HTTP transport and MCP version 2025-06-18 or later. |
Personal Agents SHOULD skip API types they don't recognize, so later versions can add types. The protocol leaves each API's schema to that API. Every listed API MUST accept Session Tokens as described in 4.3: DPoP for OpenAPI, and Bearer for MCP.
A listed MCP server MUST accept Session Tokens as its access tokens and enforce their scopes. It MUST accept only tokens issued for its url (4.3). MCP clients find where to get a token from the server's protected resource metadata (RFC 9728), and its authorization_servers MUST include auth.issuer. An MCP client that doesn't implement this protocol can then sign in with MCP's own authorization flow, and the Company treats that sign-in as starting a new signed-in Session.
The Personal Agent calls a Company API the way that API's type describes, with the token in the Authorization header:
POST https://api.example.com/orders/ord_7Hk2/exchanges
Authorization: DPoP eyJhbGciOi…Qp4w
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs…
Content-Type: application/json
{ "item_id": "itm_4Qa", "replacement_sku": "stormline-insulated-m" }These errors are returned in the WWW-Authenticate header (RFC 9449, or RFC 6750 for Bearer tokens):
| Error | Status | Meaning |
|---|---|---|
invalid_token | 401 | The token is missing or expired. The Personal Agent gets a new one (4.2, 4.8). |
sign_in_required | 403 | The request needs a signed-in token. The Personal Agent uses its Account Token (4.8), or signs in (4.4) if it has none, and retries. |
insufficient_scope | 403 | The token lacks required account scopes. The response names them in the scope attribute. The Personal Agent uses an eligible sign-in type to request access (4.4). |
Because every token is issued to a known client_id and User, the Company always knows a request comes from a Personal Agent, which one, and for which User.
HTTP/1.1 403 Forbidden
WWW-Authenticate: DPoP error="insufficient_scope", scope="poppy:read poppy:write"7Conversations
A conversation is how the Personal Agent and the Company talk. The Personal Agent talks with the Company on the User's behalf, and brings decisions back to the User when needed. Each message says whether a person or an AI wrote it (7.8).
When the User wants to talk with the Company themselves, or the Company asks for them, the Personal Agent opens a Direct Conversation (7.10): a new conversation, linked to the first, that the User sees and takes part in. The User doesn't have to read the exchange between the two agents that came before it.
7.1 Conversation protocols
A Company lists one or more conversation protocols in agent.protocols. Each entry has a type and an endpoint. The Personal Agent chooses a protocol it supports and uses that entry's endpoint for the conversation. It skips unfamiliar types. The currently defined protocol is:
| Type | Protocol | Reference |
|---|---|---|
poppy | Personal Agent Protocol conversations | The message, event, and streaming format defined in 7.2–7.13 below. |
7.2 Requests
Personal Agent Protocol conversations use JSON over HTTPS. Below, {endpoint} is the endpoint of the selected poppy entry, and {id} is a Conversation ID. Every request carries a Session Token with a DPoP Proof (4.3).
| Request | Purpose |
|---|---|
POST {endpoint} | Start a conversation with its first message (7.3), or a Direct Conversation (7.10). |
POST {endpoint}/{id}/messages | Send another message (7.3). |
GET {endpoint}/{id}/events | Read messages and other events, or stream them (7.5, 7.6). |
POST {endpoint}/{id}/handoff | Ask for a person at the Company (7.9). |
POST {endpoint}/{id}/close | Close the conversation (7.12). |
A conversation belongs to the Personal Agent's client_id and the User ID that started it. Once it uses the User's account, it belongs to that account instead. Any Session that matches can continue it, so a conversation can outlast the Session that started it. The Company MUST reject requests from any other client_id, User, or account.
IDs. Conversation IDs, message IDs, and event IDs are strings of 1 to 256 characters from the URL-safe base64 alphabet (RFC 4648, section 5): letters, digits, -, and _. They SHOULD start with a short prefix for their kind, such as cnv_, msg_, or evt_. The Company chooses Conversation IDs and event IDs, and the Personal Agent chooses message IDs for its own messages. Beyond these rules, the side that chooses an ID decides its format, and the other side MUST NOT depend on it.
7.3 Sending messages
A new conversation starts with its first message:
POST https://api.example.com/poppy/conversations
Authorization: DPoP eyJhbGciOi…Qp4w
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs…
Content-Type: application/json
{
"message": {
"id": "msg_Yq3v8LrT0aWc5NkE",
"sender": "agent",
"text": "The jacket from order #1042 isn't warm enough. The user wants to return it.",
"context": { "locale": "en-US", "time_zone": "America/Los_Angeles", "user_available": true }
}
}The Company returns the new conversation's ID:
HTTP/1.1 201 Created
Content-Type: application/json
{
"conversation_id": "cnv_8f3Kd2",
"status": "working",
"responder": "agent"
}Later messages go to the conversation's messages URL, which returns HTTP 202 with the same fields:
POST https://api.example.com/poppy/conversations/cnv_8f3Kd2/messages
Authorization: DPoP eyJhbGciOi…Qp4w
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs…
Content-Type: application/json
{
"message": { "id": "msg_Hc72PwZn4eKs1QxB", "sender": "agent", "text": "Does the insulated version come in medium?" }
}Message IDs. The Personal Agent chooses each message's id, and MUST make it unique for the User. To retry a message, it sends it again with the same ID. If the Company has already accepted a message with that ID and the same content, it returns the original response and doesn't add the message again. Because the ID is unique for the User rather than for one conversation, the Company can recognize a retried first message and return the conversation it already started. If the content is different, the Company returns message_id_conflict. The Company keeps this check for as long as it keeps the conversation.
Replies in the response. To get the Company's reply in the same request, the Personal Agent adds wait to either POST, and cursor if it has one. The Company accepts the message, then responds as a read of the conversation's events from that cursor would (7.5), holding the request for up to wait seconds. Without a cursor, it reads from the start of the conversation. The status code stays 201 or 202:
POST https://api.example.com/poppy/conversations/cnv_8f3Kd2/messages?cursor=evt_c2&wait=107.4 Message fields
| Field | Req. | Description |
|---|---|---|
id | Yes | The message's ID, chosen by the Personal Agent and unique for the User (7.3). |
sender | Yes | agent if the Personal Agent wrote the message, human if the User wrote it (7.8). |
text | No | The message in natural language. |
data | No | A JSON object for structured details that would need markup, like a list or a table, to fit in a sentence. The protocol doesn't define its keys. |
context | No | Facts about the User's situation, such as locale, time_zone, and user_available (below). |
A message MUST have at least one of text, data, or context.
Use text for the message itself, including simple values like an email address or an order number. Use data for structured details that would otherwise need markup to fit in a sentence. Personal Agents and Company Agents SHOULD NOT put JSON or other markup in text. The Company Agent's replies use data the same way.
For example, choosing seats for a family of six, with their loyalty and security numbers:
{
"id": "msg_Ns8dXo1Gv6JpUa3C",
"sender": "agent",
"text": "The user would like seats together for their family of six on flight SB 482.",
"data": {
"travelers": [
{ "name": "Dana Reyes", "seat": "14A", "frequent_flyer": "SB 4821 9930", "known_traveler": "TT7K2M9QX" },
{ "name": "Sam Reyes", "seat": "14B", "frequent_flyer": "SB 4821 9947", "known_traveler": "TT9W4R1LD" },
{ "name": "Ava Reyes", "seat": "14C", "frequent_flyer": "SB 5530 1182" },
{ "name": "Leo Reyes", "seat": "15A" },
{ "name": "Mia Reyes", "seat": "15B" },
{ "name": "Noah Reyes", "seat": "15C" }
]
}
}The Personal Agent decides how much of the User's situation to share, and sharing more up front saves rounds of back-and-forth.
Context. context holds facts about the User's situation that affect how the Company replies, so the Personal Agent doesn't have to write them into the text. The Company gives it to the Company Agent along with the messages. When a Personal Agent shares one of these facts, it MUST use the field and format defined here:
localeis a language tag such asen-US(RFC 5646), for the language and formatting of replies.time_zoneis an IANA time zone name such asAmerica/Los_Angeles, for reading and showing times.user_availableistrueif the User can answer questions right now.
context MAY include other fields, such as accessibility needs or preferred units. The Company Agent interprets them as it sees fit, and a Company MAY ignore them. Details of the request itself belong in text or data, not context. Personal Agents SHOULD share only what the task needs.
Any message can carry context, and fields it leaves out keep their previous values. A message with only context updates it without saying anything else, for example when the User steps away:
{
"id": "msg_Lw0eFj7Tc2RbVy5H",
"sender": "agent",
"context": { "user_available": false }
}Company messages have id, sender, text, and data, with an ID the Company chooses. Only the Personal Agent sends context. Extensions can add fields, such as operation in the Operations extension.
7.5 Reading events
The Company records each conversation as an ordered list of events: the messages from both sides, and changes to the conversation. The Personal Agent reads the events after the last cursor it saved:
GET https://api.example.com/poppy/conversations/cnv_8f3Kd2/events?cursor=evt_c1&wait=30
Authorization: DPoP eyJhbGciOi…Qp4w
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs…{
"conversation_id": "cnv_8f3Kd2",
"events": [
{
"id": "evt_c2",
"type": "message",
"created_at": "2026-10-08T19:03:00Z",
"message": {
"id": "msg_r2Kq",
"role": "company",
"sender": "agent",
"text": "I can do that. The insulated version would be warmer. Want to exchange instead for $70 more?",
"data": { "replacement_sku": "stormline-insulated-m", "price_difference": 70 }
}
}
],
"cursor": "evt_c2",
"has_more": false,
"status": "idle",
"responder": "agent"
}Every event has an id, a type, and a created_at time (RFC 3339). The type says which other fields it has:
| Type | Fields |
|---|---|
message | message: a message from either side, with role set by the Company to user or company for the side it came from. |
state | status and responder, after either one changes. See 7.7 for their values. |
authorization | error, and scope when needed: the Company needs sign-in or more scopes (7.11). |
user_requested | reason: the Company asks to talk with the User directly (7.10). |
direct_opened, direct_closed | conversation_id: a Direct Conversation linked to this one opened or closed (7.10). |
Personal Agents MUST skip event types they don't recognize, so later versions and extensions can add types.
- Each event's
idis also a cursor. Without acursor, the read starts at the beginning of the conversation. Cursors are opaque, so the Personal Agent MUST NOT compare or sort them. - The Company holds the request for up to
waitseconds until there is an event to return. It MAY return sooner, or capwait. Withoutwait, it returns right away. A read that times out returns an emptyeventslist. - Every successful read MUST include
cursor,has_more,status, andresponder, even with no events.cursoris the last event returned, or the request's cursor if there were none. Whenhas_moreistrue, the Personal Agent reads again right away. - The Personal Agent handles events in order and saves the cursor after them. A read after a reconnect can return events again, so it skips event IDs it has already handled.
- The Company keeps events for as long as it chooses. A cursor for events it no longer has returns
cursor_expired. The Personal Agent then reads from the beginning and SHOULD tell the User that part of the conversation is missing.
7.6 Streaming
A Company MAY stream events with server-sent events (SSE). The Personal Agent asks for a stream by sending Accept: text/event-stream on a read. A Company that doesn't stream returns JSON as usual. In a stream, each event has its event ID as the SSE id and the same event JSON as data:
id: evt_c2
data: {"id":"evt_c2","type":"message","created_at":"2026-10-08T19:03:00Z","message":{…}}Changes to status and responder arrive as state events. The Company MAY close a stream at any time, for example when the Session Token expires. The Personal Agent reconnects with Last-Event-ID set to the last event it handled, in place of cursor.
While the Company Agent writes a message, the Company MAY send pieces of its text as text-delta events, with no SSE id:
event: text-delta
data: {"message_id":"msg_r2Kq","text":"I can do that. "}The Personal Agent can show the pieces as they arrive, then replaces them with the complete message event. It MUST NOT act on text from a text-delta event. After a reconnect, it drops any pieces of a message it didn't receive in full.
7.7 Status and responder
| Status | Meaning |
|---|---|
working | The Company is still working. Read again with the cursor. |
idle | The Company has no work in progress and is waiting for the User's side. |
queued | Waiting for a person at the Company to join (7.9). |
closed | The conversation takes no more messages (7.12). |
responder tells the Personal Agent who is currently handling the Company side of the conversation. It is independent of status:
| Responder | Meaning |
|---|---|
agent | An automated Company Agent is handling the conversation. |
human | A live person at the Company is handling the conversation. |
Every successful response to a POST or a read MUST include status and responder. Every change to either one adds a state event with both, even if there is no new text. human means a person has joined. While the conversation is queued, responder stays agent. In a closed conversation, responder is whoever handled it last.
Each Company message MUST also include sender, with agent or human, to identify who wrote it. This distinguishes earlier automated replies from human replies when both arrive in one response. The role stays company in either case.
{
"conversation_id": "cnv_8f3Kd2",
"events": [
{
"id": "evt_c4",
"type": "state",
"created_at": "2026-10-08T19:05:10Z",
"status": "idle",
"responder": "human"
},
{
"id": "evt_c5",
"type": "message",
"created_at": "2026-10-08T19:05:20Z",
"message": {
"id": "msg_t8Vn",
"role": "company",
"sender": "human",
"text": "I've reviewed the exchange. Can you confirm which size the user wants?"
}
}
],
"cursor": "evt_c5",
"has_more": false,
"status": "idle",
"responder": "human"
}7.8 Human or AI
Both sides say whether a person or an AI is taking part. On the Personal Agent's side, the Personal Agent MUST set each message's sender: agent when the Personal Agent wrote it, and human only when it carries the User's own words. A summary, paraphrase, or translation of what the User said is agent. On the Company's side, responder and each message's sender do the same (7.7).
When the User steps in, the Personal Agent sends their words with sender: "human" in the same conversation:
POST https://api.example.com/poppy/conversations/cnv_8f3Kd2/messages
Authorization: DPoP eyJhbGciOi…Qp4w
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs…
Content-Type: application/json
{
"message": { "id": "msg_Fz4kQa9Ue1YoBs6R", "sender": "human", "text": "Medium, please." }
}Each side uses this to adapt. When both sides are AI, they can skip greetings and small talk, keep text short, and put structured details in data. When a person is on either side, the other side writes for that person: plain sentences, a pace they can follow, and details in text rather than only in data. In a Direct Conversation, the Company replies as it would to the customer on its own channels.
7.9 Handoff to a person
The Personal Agent asks for a person at the Company with an empty JSON object:
POST https://api.example.com/poppy/conversations/cnv_8f3Kd2/handoff
Authorization: DPoP eyJhbGciOi…Qp4w
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs…
Content-Type: application/json
{}The Company returns HTTP 202 with status and responder, and the status becomes queued while it finds someone. When a person joins, responder becomes human. If no one is available, the status goes back to idle with responder still agent, and the Company Agent says why in a message. The Company Agent can also hand off to a person on its own.
The Personal Agent can keep sending messages while it waits, for example to explain the problem for the person who joins or to update user_available. Closing the conversation cancels a pending handoff. A person at the Company is held to the same scopes as the Company Agent (7.11).
7.10 Direct Conversations
A Direct Conversation lets the User talk with the Company themselves. It is a new conversation, linked to the one before it, so the User sees only the Direct Conversation, while the Company keeps the earlier history and can use it. The Personal Agent sends the User's messages as written, with sender: "human" (7.8), and shows the Company's replies to the User.
Starting one. The Personal Agent starts a Direct Conversation like any other (7.3), adding parent_conversation_id with the earlier conversation's ID. The Company returns a new Conversation ID as usual:
POST https://api.example.com/poppy/conversations
Authorization: DPoP eyJhbGciOi…Qp4w
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs…
Content-Type: application/json
{
"parent_conversation_id": "cnv_8f3Kd2",
"message": { "id": "msg_Pe6jWm3Kd0RtXc8V", "sender": "human", "text": "Hi, I have a question about the exchange." }
}The parent conversation MUST be open, belong to the same owner (7.2), and not be a Direct Conversation itself. It can have one open Direct Conversation at a time. Otherwise the Company returns conversation_not_found, conversation_closed, or direct_conversation_open.
When the Company asks. The Company asks to talk with the User by adding a user_requested event to the conversation, with a short reason:
{
"id": "evt_c6",
"type": "user_requested",
"created_at": "2026-10-08T19:06:00Z",
"reason": "A specialist needs to confirm the exchange with the customer."
}The Personal Agent asks the User. If they agree, it starts a Direct Conversation as above. If they decline or aren't available, it says so in the conversation. Only the Personal Agent starts Direct Conversations, so the User decides when they're brought in.
The parent conversation meanwhile. When a Direct Conversation opens, the Company adds a direct_opened event to the parent, with the Direct Conversation's ID in conversation_id. While it is open, the parent can still be read, but messages and handoff requests to the parent return direct_conversation_open, with the Direct Conversation's ID in conversation_id. When the Direct Conversation closes, the Company adds a direct_closed event to the parent, and the parent takes messages again. The Company Agent in the parent knows what was said in the Direct Conversation. Closing the parent also closes its open Direct Conversation.
7.11 Sign-in during a conversation
With a signed-out token, the Company Agent can answer general questions. When the Company Agent needs the User's account, the Company adds an authorization event. It SHOULD also ask in a message, so a User in a Direct Conversation sees the request:
{
"id": "evt_d3",
"type": "authorization",
"created_at": "2026-10-08T19:04:00Z",
"error": "insufficient_scope",
"scope": "poppy:read poppy:write"
}error is sign_in_required or insufficient_scope, with the same meaning as the errors in section 6. For insufficient_scope, scope names the scopes needed. The Personal Agent responds as it would to that error, with its Account Token (4.8) or by signing in (4.4). It then continues the same conversation with the new token. The conversation stays open meanwhile.
A conversation isn't tied to one Session Token or Session. The Personal Agent MAY switch to a new Session Token partway through a conversation, after signing in, renewing, or signing out, or continue it in a new Session, as long as the token matches the conversation's owner (7.2). The conversation keeps its conversation_id throughout.
A signed-in conversation also respects the token's scopes. Before reading account information or making a change, the Company Agent checks for the required scopes, whoever is handling the conversation. Sending a message does not authorize the requested account action.
Once a conversation has used the User's account, a request on it from a token for the same User that isn't signed in to that account, for example after signing out (4.9), gets sign_in_required.
7.12 Closing
The Personal Agent closes a conversation it's done with by sending an empty JSON object to its close URL. The Company adds a state event with status: "closed", cancels any pending handoff, and returns HTTP 200 with status and responder. The Company can also close a conversation, for example after a period of inactivity.
A closed conversation takes no more messages, and its events stay readable for as long as the Company keeps them. Closing a conversation doesn't cancel anything it started, such as an order or a return.
7.13 Errors
Token errors are the ones in section 6. Other errors have a JSON body with an error code, like OAuth error responses:
| Error | Status | Meaning |
|---|---|---|
conversation_not_found | 404 | The Conversation ID is unknown or belongs to a different Personal Agent, User, or account (7.2). All return the same error. |
conversation_closed | 409 | A message or handoff was sent to a closed conversation. |
direct_conversation_open | 409 | The conversation has an open Direct Conversation, named in conversation_id (7.10). |
message_id_conflict | 409 | A message reused an ID that the Company accepted with different content (7.3). |
invalid_cursor | 400 | The cursor is unknown or doesn't belong to this conversation. |
cursor_expired | 410 | The Company no longer has the events at this cursor (7.5). |
8References
This draft uses these standards and versions:
| Subject | References |
|---|---|
Requirement words | |
OAuth | |
Agent identity and assertions | |
Sign-in | |
Token binding and audience | |
APIs |