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:

  1. Discovery: a standard way for the Personal Agent to find everything it needs to work with a Company.
  2. 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.
  3. Web browsing: how the Personal Agent browses the Company's website in the same Session.
  4. APIs: how the Personal Agent uses the Company's APIs, which can be more efficient than web browsing.
  5. 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.
  6. 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

TermMeaning
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:

poppy.json, published by the Company
{
  "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

FieldReq.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.

GET https://auth.example.com/.well-known/oauth-authorization-server
{
  "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_endpoint issues Session and Account Tokens (4.2, 4.8). Required.
  • revocation_endpoint revokes Account Tokens (4.9). Required.
  • authorization_endpoint starts Direct Sign-In (4.5). Required if auth.direct is present.
  • device_authorization_endpoint starts Device Sign-In (4.6). Required if auth.device is 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:

Excerpt from poppy.json
"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 as example.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 extensions in 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 in extension.

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:

TokenWhat it isUsed 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:

StepRequestResult
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 metadata document, published by the Personal Agent
{
  "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_id MUST equal the URL the document was fetched from.
  • jwks_uri and every entry in redirect_uris MUST be HTTPS URLs on the same domain as client_id. The key set contains only public keys.
  • redirect_uris lists where Direct Sign-In may send the User back to (4.5).
  • extensions lists the extensions the Personal Agent supports, keyed by name, each with its version (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).

Request: Personal Agent → Company
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…
Assertion claims (JWT payload)
{
  "iss": "https://agent.example/agent.json",
  "sub": "usr_Q7c1vK",
  "aud": "https://auth.example.com/oauth/token",
  "iat": 1790900000,
  "exp": 1790900060,
  "jti": "V6vJGs2ixBAvGdaQgGaTzg"
}
  • iss is the Personal Agent's client_id (4.1).
  • sub is the User ID for this Company, described above.
  • aud is the Company's token_endpoint, as a single string rather than an array, so the assertion can't be used at another Company.
  • iat and exp are 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.
  • jti is 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:

Response: Company → Personal Agent
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_token is the Session Token. token_type is DPoP, or Bearer for a token requested without a proof (4.3).
  • expires_in is 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_id identifies the Session. It is not a credential.
  • signed_in says whether the Session is signed in to a Company account.
  • scope is 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:

Request: Personal Agent → Company
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:

ErrorStatusMeaning
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):

Request headers: Personal Agent → Company
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:

DPoP Proof (JWT header and payload)
{
  "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"
}
  • jti is a unique ID for the proof.
  • htm and htu are the request's HTTP method and URL, without the query or fragment.
  • iat is when the proof was made.
  • ath is the SHA-256 hash of the Session Token sent with it. Proofs sent to the token endpoint don't have one.
  • nonce is 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.
  • htm and htu match the request.
  • iat is within the Company's acceptance window, such as one minute.
  • It hasn't accepted the same jti within that window.
  • ath matches 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).

Request: Personal Agent → Company
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 Authorization header 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.

TypeWho signs inHow
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:

ScopeAccount 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:write when 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 in auth.custom_scopes. Names starting with poppy: are reserved for this protocol, so a Company's existing OAuth scopes can't clash with them.
  • A Company MAY leave out poppy:read and poppy:write and 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 only poppy: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 scope as a space-separated string, following OAuth scope syntax. Missing, unknown, or unavailable scopes are rejected with invalid_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.

Response: Company → Personal Agent
{
  "access_token": "eyJhbGciOi…Qp4w",
  "token_type": "DPoP",
  "expires_in": 3600,
  "refresh_token": "pat_8Hk2…Wq1",
  "refresh_token_expires_in": 2592000,
  "scope": "poppy:read poppy:write",
  "session_id": "ses_2Lm0",
  "signed_in": true
}
  • refresh_token is the Account Token (4.8).
  • refresh_token_expires_in is 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:

Authorization URL, opened in the User's browser
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=S256

2. 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:

Request: Personal Agent → Company
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:

Request: Personal Agent → Company
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…
Response: Company → Personal Agent
{
  "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:

Request: Personal Agent → Company
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:

Excerpt from poppy.json
"fields": [
  { "name": "email", "label": "Email", "secret": false },
  { "name": "password", "label": "Password", "secret": true }
]
  • name is the key the Personal Agent sends.
  • label is what to ask the User for.
  • secret marks 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:

Request: Personal Agent → Company
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:

Response: Company → Personal Agent
{
  "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:

Response: Company → Personal Agent
{
  "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:

Request: Personal Agent → Company
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.

StatusMeaning
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:

Request: Personal Agent → Company
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_endpoint and revocation_endpoint, and only from the client_id it 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_id and User ID as the Account Token, and be signed out or signed in to the same account. Otherwise the Company returns invalid_session or account_mismatch.
  • scope MAY 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:

Request: Personal Agent → Company
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:

Browser assertion claims (JWT payload)
{
  "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, and jti are as in 4.2.
  • aud is the Company's web.browser_session_endpoint.
  • session_id is the Session the browser joins, signed in or out.
  • return_to is the page to open afterward. It MUST be an HTTPS URL on the Company's organization.domain or one of its subdomains.
  • exp is no more than 60 seconds after iat.

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.

Request: Personal Agent's browser → Company
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 iss client's jwks_uri, and typ is poppy-browser+jwt. A Session assertion isn't accepted here, and a browser assertion isn't accepted at the token endpoint.
  • aud is its endpoint, the assertion hasn't expired, and exp is no more than 60 seconds after iat.
  • It hasn't accepted the same jti before. Like DPoP Proofs (4.3), it only needs to remember a jti until the assertion expires.
  • The Session is active and belongs to the same client_id and User.
  • return_to is 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.

Response: Company → Personal Agent's browser
HTTP/1.1 303 See Other
Location: https://example.com/orders
Set-Cookie: sb_session=Pz81kQ…; Path=/; Secure; HttpOnly; SameSite=Lax

The 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 Secure and HttpOnly, and use SameSite=Lax or None, not Strict. The POST comes from a page the Personal Agent created, so with Strict the 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:

Typeurl points toSpecification
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:

Request: Personal Agent → Company
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):

ErrorStatusMeaning
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.

Response: Company → Personal Agent
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:

TypeProtocolReference
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).

RequestPurpose
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:

Request: Personal Agent → Company
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:

Response: Company → Personal Agent
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:

Request: Personal Agent → Company
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:

Request: Personal Agent → Company
POST https://api.example.com/poppy/conversations/cnv_8f3Kd2/messages?cursor=evt_c2&wait=10

7.4 Message fields

FieldReq.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:

Message: Personal Agent → Company
{
  "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:

  • locale is a language tag such as en-US (RFC 5646), for the language and formatting of replies.
  • time_zone is an IANA time zone name such as America/Los_Angeles, for reading and showing times.
  • user_available is true if 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:

Message: Personal Agent → Company
{
  "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:

Request: Personal Agent → Company
GET https://api.example.com/poppy/conversations/cnv_8f3Kd2/events?cursor=evt_c1&wait=30
Authorization: DPoP eyJhbGciOi…Qp4w
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIs…
Response: Company → Personal Agent
{
  "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:

TypeFields
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 id is also a cursor. Without a cursor, 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 wait seconds until there is an event to return. It MAY return sooner, or cap wait. Without wait, it returns right away. A read that times out returns an empty events list.
  • Every successful read MUST include cursor, has_more, status, and responder, even with no events. cursor is the last event returned, or the request's cursor if there were none. When has_more is true, 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:

Stream: Company → Personal Agent
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:

Stream: Company → Personal Agent
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

StatusMeaning
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:

ResponderMeaning
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.

Response: Company → Personal Agent
{
  "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:

Request: Personal Agent → Company
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:

Request: Personal Agent → Company
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:

Request: Personal Agent → Company
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:

Event: Company → Personal Agent
{
  "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:

Event: Company → Personal Agent
{
  "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:

ErrorStatusMeaning
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:

SubjectReferences
Requirement words
OAuth
Agent identity and assertions
Sign-in
Token binding and audience
APIs