# Personal Agent Protocol

## 1. Introduction

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](/docs/extensions).

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](/docs/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](https://www.rfc-editor.org/rfc/rfc2119), [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174)) when they appear in capitals, as shown here.

## 2. Terms

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

## 3. Discovery

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

| 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](https://www.rfc-editor.org/rfc/rfc8414)), 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](/docs/extensions/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`.

## 4. Sessions 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 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](https://www.rfc-editor.org/rfc/rfc7523)). 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](https://www.rfc-editor.org/rfc/rfc7523)). 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:

| 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](https://www.rfc-editor.org/rfc/rfc9449)):

*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](https://www.rfc-editor.org/rfc/rfc8707)), 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.

| 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: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](https://www.rfc-editor.org/rfc/rfc6749#section-3.3). 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](https://www.rfc-editor.org/rfc/rfc6749), [RFC 7636](https://www.rfc-editor.org/rfc/rfc7636)). 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](https://www.rfc-editor.org/rfc/rfc9207)). `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](https://www.rfc-editor.org/rfc/rfc8628)). 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.

| 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](https://www.rfc-editor.org/rfc/rfc6749#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](https://www.rfc-editor.org/rfc/rfc7009)), 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.

## 5. Web 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.

## 6. APIs

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. | [OpenAPI Specification 3.1](https://spec.openapis.org/oas/v3.1.1.html) |
| `mcp` | A remote MCP server endpoint, using the Streamable HTTP transport and MCP version 2025-06-18 or later. | [Model Context Protocol 2025-06-18](https://modelcontextprotocol.io/specification/2025-06-18) |

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](https://www.rfc-editor.org/rfc/rfc9728)), 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](https://www.rfc-editor.org/rfc/rfc9449), or [RFC 6750](https://www.rfc-editor.org/rfc/rfc6750) 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.

*Response: Company → Personal Agent*

```
HTTP/1.1 403 Forbidden
WWW-Authenticate: DPoP error="insufficient_scope", scope="poppy:read poppy:write"
```

## 7. Conversations

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](https://www.rfc-editor.org/rfc/rfc4648#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

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

*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](https://www.rfc-editor.org/rfc/rfc5646)), 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](/docs/extensions/operations#binding-conversations).

### 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](https://www.rfc-editor.org/rfc/rfc3339)). 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 `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](https://html.spec.whatwg.org/multipage/server-sent-events.html)). 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

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

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

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

## 8. References

This draft uses these standards and versions:

| Subject | References |
| --- | --- |
| Requirement words | [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119), [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174) |
| OAuth | [RFC 6749](https://www.rfc-editor.org/rfc/rfc6749), [RFC 6750](https://www.rfc-editor.org/rfc/rfc6750), [RFC 7009](https://www.rfc-editor.org/rfc/rfc7009), [RFC 8414](https://www.rfc-editor.org/rfc/rfc8414) |
| Agent identity and assertions | [OAuth Client ID Metadata Document](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/), [RFC 7519](https://www.rfc-editor.org/rfc/rfc7519), [RFC 7523](https://www.rfc-editor.org/rfc/rfc7523) |
| Sign-in | [RFC 7636](https://www.rfc-editor.org/rfc/rfc7636), [RFC 8628](https://www.rfc-editor.org/rfc/rfc8628), [RFC 9207](https://www.rfc-editor.org/rfc/rfc9207) |
| Token binding and audience | [RFC 9449](https://www.rfc-editor.org/rfc/rfc9449), [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707) |
| APIs | [OpenAPI Specification 3.1](https://spec.openapis.org/oas/v3.1.1.html), [Model Context Protocol 2025-06-18](https://modelcontextprotocol.io/specification/2025-06-18), [RFC 9728](https://www.rfc-editor.org/rfc/rfc9728) |

---

Licensed under the [Apache License 2.0](https://personalagentprotocol.org/license).
