Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
40d8426
feat: add PAP Device Sign-In clients
echarles Oct 10, 2026
852f931
dvice sign in
echarles Oct 11, 2026
f2db08f
readme
echarles Oct 11, 2026
9ccfdb1
feat: add PAP Account Token refresh, rotation, narrowing and revocati…
echarles Oct 11, 2026
5ba357f
feat: add PAP Mediated Sign-In with protected input (§4.7)
echarles Oct 11, 2026
498e610
feat: add PAP conversation streaming, handoff, Direct Conversations a…
echarles Oct 11, 2026
5312ef3
feat: add the PAP Operations v1 client
echarles Oct 11, 2026
e639f84
feat: add a Reactor-assembled PAP company and a user-with-a-company d…
echarles Oct 11, 2026
ab71cea
feat: demonstrate two companies working together over PAP
echarles Oct 11, 2026
0828b33
feat(ts): add Account Token refresh, rotation, narrowing and revocation
echarles Oct 11, 2026
134c857
feat(ts): add Mediated Sign-In with protected input
echarles Oct 11, 2026
68d73a0
feat(ts): add conversation streaming, handoff, Direct Conversations a…
echarles Oct 11, 2026
658df9c
feat(ts): add the Operations v1 client
echarles Oct 11, 2026
1716d77
examples: add Pydantic AI examples for Account Tokens, Mediated Sign-…
echarles Oct 11, 2026
3c38e83
docs: add Account Tokens, Mediated Sign-In, conversations and Operati…
echarles Oct 11, 2026
9b75fc4
docs: add example flows for Account Tokens, Mediated Sign-In, convers…
echarles Oct 11, 2026
d921ef3
docs: regenerate the Python and TypeScript API references
echarles Oct 11, 2026
dc4cf02
fix(demo): a demo Reactor stops cleanly; Manzanita reports the confir…
echarles Oct 11, 2026
56b9e74
feat: company-side Direct Sign-In and an OpenAPI API whose writes pro…
echarles Oct 11, 2026
108b4ff
feat(demo): serve a demo world over real TCP and TLS
echarles Oct 11, 2026
cd0723a
fix(ts): read null optional members as absent, as Python does; fixtur…
echarles Oct 11, 2026
d48fd08
feat(ts): framework-neutral company handlers and a Node adapter (./se…
echarles Oct 11, 2026
7bb6460
test(ts): Manzanita in TypeScript and the company specs, one shared j…
echarles Oct 11, 2026
c9278f8
test: the language pairings over real TCP and TLS, from one scenario
echarles Oct 11, 2026
bf6c25f
fix(company): refusals the conformance suite requires
echarles Oct 11, 2026
16b178a
fix(demo): no Nagle wait on the TLS demo server
echarles Oct 11, 2026
862376e
feat: extension names and negotiation, known API and event types, saf…
echarles Oct 11, 2026
fa35759
feat: a conformance runner and shared fixtures; Python agent <-> Pyth…
echarles Oct 11, 2026
6a8180b
feat: §7.4 context fields in their defined format; Retry-After report…
echarles Oct 11, 2026
b38a690
feat: the normative requirement ledger for draft 0.1 and Operations v1
echarles Oct 11, 2026
c267a55
feat(company): an MCP server with resource-bound Bearer tokens; brows…
echarles Oct 11, 2026
04639ce
feat: resource-bound MCP Bearer acquisition, an MCP client, and one o…
echarles Oct 11, 2026
219beb5
test(conformance): MCP and website cases; the ledger records MCP Bear…
echarles Oct 11, 2026
cb0c9d0
fix(company): sign-out reaches every Session of a sign-in; assertion …
echarles Oct 11, 2026
7140120
feat(cli): sessions, auth, api, conversations, operations, requiremen…
echarles Oct 11, 2026
172e57f
release: prepare 0.4.0
echarles Oct 11, 2026
6afe469
ci: bundle the TypeScript demo company for node:test; regenerate the …
echarles Oct 11, 2026
c2c334e
fix(typing): mypy passes on the package
echarles Oct 11, 2026
6ebfd6c
test: CLI tests on any Typer and Python; pre-commit clean at its pinn…
echarles Oct 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,28 @@
-->

# Changelog

## 0.4.0 (2026-10-11)

Both sides of PAP draft 0.1 and Operations v1, in Python and TypeScript.

- **Personal agent:** Account Tokens (refresh, serialized rotation, narrowing,
revocation), Mediated Sign-In with protected input, conversation streaming
(SSE, `text-delta`, `Last-Event-ID`), handoff, Direct Conversations and
closing, the Operations v1 client (exact approval, standing permissions,
recovery), resource-bound MCP Bearer, OpenAPI and MCP clients, operations
from every channel.
- **Company:** a framework-neutral reference company assembled from Reactor
points (`pap.company.*`), served over ASGI (Python) and node:http(s)
(TypeScript): discovery, Sessions, Direct, Device and Mediated Sign-In,
Account Tokens, conversations, an OpenAPI API and an MCP endpoint, the
browser session, Operations performed at most once.
Comment on lines +19 to +23
- **Demonstrations:** a user with a company (Manzanita), two companies working
together (Ledgerly at Northwind Bank), served in process or over real TCP
and TLS (`server` extra).
- **Conformance:** the requirement ledger of every MUST and SHOULD
(`protocol/requirements.yaml`), 113 fixture cases, a runner with JSON and
JUnit output (91 of 91 against the reference company over TLS), and the four
Python/TypeScript pairings.
- **CLI:** `pap sessions`, `auth`, `api`, `conversations`, `operations`,
`requirements`, `conformance`, with protected input never as an option.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@

# 🤖 🌸 Personal Agent Protocol

![Personal Agent Protocol demo](https://images.datalayer.io/products/personal-agent-protocol/pap-demo.gif)

Python and TypeScript SDKs for the
[Personal Agent Protocol](https://personalagentprotocol.org), currently
targeting [draft 0.1](https://personalagentprotocol.org/docs/spec).

![Personal Agent Protocol demo](https://images.datalayer.io/products/personal-agent-protocol/pap-demo.gif)

PAP lets a personal agent discover and talk to a company's agent on behalf of
a person. The company remains responsible for its identity, authorization,
data, and actions; the personal agent does not need the person's company
Expand Down
73 changes: 73 additions & 0 deletions docs/docs/examples/account-tokens.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
---
title: Account Tokens
description: Keep a user signed in with a rotating Account Token that never leaves the host.
---

# Account Tokens

A sign-in returns an Account Token (the OAuth `refresh_token`). The personal
agent uses it only at the company's token and revocation endpoints, to sign a
Session in again after its short-lived Session Token expires. The paired
examples run the real `AccountTokenClient` against Manzanita Outfitters, the
reference company in `personal_agent_protocol.demo`, with real keys over HTTP
in one process.

```mermaid
sequenceDiagram
autonumber
participant Tool as Agent tool
participant SDK as AccountTokenClient
participant Lock as pap.accountTokenLock
participant Vault as pap.tokenStore
participant Company as Company token endpoint
Tool->>SDK: refresh(reference, session_id)
SDK->>Lock: Hold the reference
SDK->>Vault: Read Account Token
SDK->>Company: refresh_token grant + session_id + assertion + DPoP
Company-->>SDK: Signed-in Session Token + rotated Account Token
SDK->>Vault: Replace the old token before releasing the lock
SDK-->>Tool: signed_in, rotated, granted scopes
Tool->>SDK: refresh(reference, scopes=orders:read)
SDK->>Company: Narrower scope
Company-->>SDK: Session with orders:read
Tool->>SDK: refresh(reference, scopes=orders:admin)
Company-->>SDK: invalid_scope
SDK-->>Tool: re_consent_required (sign the user in again)
Note over Company: Ninety-one days later
SDK->>Company: refresh_token grant
Company-->>SDK: invalid_grant
SDK->>Vault: Delete the stored copy
SDK-->>Tool: signed_out
Tool->>SDK: revoke(reference)
SDK->>Company: RFC 7009 revocation
Company-->>SDK: 200, every Session signed in with it is signed out
SDK->>Vault: Delete once the company answered
```

The walkthrough:

1. **Refresh** signs the same Session in again (`session_id` unchanged) and the
company rotates the token. The new one replaces the old in the token store
before the call returns.
2. **Two refreshes at once** both succeed: the Reactor `pap.accountTokenLock`
serializes them, so the second presents the token the first rotated in, not
the one the company already revoked.
3. **Narrower** scopes are granted; **wider** scopes come back as
`re_consent_required`, a decision for the user, never a retry.
4. An **expired** token draws `invalid_grant`; the SDK deletes it and the
Session continues signed out.
5. **Revocation** signs out every Session signed in with the token. The next
question to the company draws an `authorization` event again.

The model sees statuses (`signed_in`, `re_consent_required`, `signed_out`),
whether a rotation happened, and scope names. The Account Token, Session
Tokens, client assertions, and DPoP proofs stay host-only, behind opaque
references in Reactor stores.

TypeScript has the same client: `new AccountTokenClient({ reactor, fetch })`,
with `refresh(reference, { context, sessionId, scopes })` and `revoke()`, and
the lock contributed at `AccountTokenLocks`.

- Run the [Pydantic AI example](https://github.com/datalayer/personal-agent-protocol/tree/main/examples/pydantic-ai/account-tokens).
- Run the [Agent Runtimes example](https://github.com/datalayer/agent-runtimes/tree/main/examples/personal-agent-protocol/account-tokens).
- Read the [Account Tokens guide](/guides/account-tokens/).
63 changes: 63 additions & 0 deletions docs/docs/examples/conversation-control.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
title: Conversation control
description: Sign in mid-conversation, open a Direct Conversation, hand off to a person, and close.
---

# Conversation control

A conversation is more than messages. The company can ask the user to sign in
part-way through, the user can talk to the company directly through the
agent's interface, either side can bring in a person, and the agent can close
it. The paired examples drive one Manzanita conversation through all four,
against a Manzanita with a support desk staffed.

```mermaid
sequenceDiagram
autonumber
actor Ada
participant Atlas as Atlas (personal agent)
participant Company as Manzanita
Atlas->>Company: Where is my jacket order? (signed out)
Company-->>Atlas: authorization: sign_in_required (orders:read orders:write)
Atlas->>Ada: Sign in on Manzanita's page
Ada->>Company: Approves the device code
Atlas->>Company: Same conversation, signed-in token
Company-->>Atlas: Your order ord_7Hk2 … delivered
Ada->>Atlas: I'd like to ask about sizing myself
Atlas->>Company: start_direct(parent) with sender human
Company-->>Atlas: Direct Conversation cnv_d
Atlas->>Company: Message or handoff on the parent
Company-->>Atlas: 409 direct_conversation_open (conversation_id cnv_d)
Atlas->>Company: close(cnv_d)
Company-->>Atlas: Parent: direct_closed
Atlas->>Company: handoff(parent)
Company-->>Atlas: state queued, then idle with responder human
Atlas->>Company: close(parent)
Company-->>Atlas: closed, later messages are conversation_closed
```

- **Sign-in mid-conversation (§7.11).** An `authorization` event says what is
missing (`sign_in_required` or `insufficient_scope`) and which scopes. The
host signs in with any mode the company offers and keeps sending to the
**same conversation ID** with the new token.
- **Direct Conversations (§7.10).** Only the personal agent starts one, for
the user to talk with the company in the agent's interface. While it is
open, the parent refuses messages and handoff with
`direct_conversation_open`; the SDK puts the open conversation's ID in
`error.details["conversation_id"]`.
- **Handoff (§7.9).** `handoff()` asks for a person; the conversation is
`queued` until one takes it, then `responder` is `human`. When no one is
available the company says so and the agent continues.
- **Closing (§7.12).** `close()` ends the conversation; operations it
proposed or started are not cancelled.

The model sees event types, statuses, the company's answers, and error codes.
Tokens, the device code, and proofs stay host-only; the user code is shown to
the user by the host, not by the model.

TypeScript: `startDirect()`, `handoff()`, and `close()` on
`ConversationClient`, with the same error codes and details.

- Run the [Pydantic AI example](https://github.com/datalayer/personal-agent-protocol/tree/main/examples/pydantic-ai/conversation-control).
- Run the [Agent Runtimes example](https://github.com/datalayer/agent-runtimes/tree/main/examples/personal-agent-protocol/conversation-control).
- Read the [conversations guide](/guides/conversations/).
4 changes: 2 additions & 2 deletions docs/docs/examples/conversation-events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ missing.

Session Tokens, DPoP proofs, pairwise identity, and the saved cursor remain in
the trusted host. A model or UI receives only the event fields it needs. A
future SSE slice will add reconnect and `text-delta` handling; applications
must not treat a `text-delta` as an actionable final message.
company that streams can be read over SSE instead, with reconnects and
display-only `text-delta` frames: see [conversation streaming](./conversation-streaming).

Run the [Pydantic AI example](https://github.com/datalayer/personal-agent-protocol/tree/main/examples/pydantic-ai/conversation-events)
or the [Agent Runtimes example](https://github.com/datalayer/agent-runtimes/tree/main/examples/personal-agent-protocol/conversation-events).
Expand Down
65 changes: 65 additions & 0 deletions docs/docs/examples/conversation-streaming.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
---
title: Conversation streaming
description: Stream a company's reply over SSE, reconnect from Last-Event-ID, and never act on a delta.
---

# Conversation streaming

A company that streams answers `GET …/events` with `Accept: text/event-stream`.
Events arrive as SSE frames with their IDs, and `text-delta` frames carry a
message while it is still being written. The paired examples stream a
Manzanita conversation through a connection that breaks in the middle of the
company's first reply.

```mermaid
sequenceDiagram
autonumber
participant Tool as Agent tool
participant SDK as ConversationClient.stream()
participant Net as Network
participant Company
SDK->>Company: GET /events (Accept: text/event-stream) + DPoP
Company-->>SDK: id evt_1 (user message)
Company-->>SDK: text-delta msg_r (half of the reply)
Net--xSDK: Connection drops before message msg_r
SDK-->>Tool: TextDelta (actionable = false)
SDK-->>Tool: StreamReconnected(last_event_id=evt_1, discarded=[msg_r])
SDK->>Company: GET /events, Last-Event-ID: evt_1
Company-->>SDK: text-delta msg_r, id evt_2 (complete message)
SDK-->>Tool: Message event evt_2 (the one to show and act on)
Company-->>SDK: Stream ends, conversation still open
SDK-->>Tool: StreamReconnected(last_event_id=evt_2)
Tool->>Company: close()
SDK->>Company: GET /events, Last-Event-ID: evt_2
Company-->>SDK: id evt_3 state closed
SDK-->>Tool: State closed, the iterator ends
```

What the stream guarantees:

- **Deltas are display only.** `TextDelta.actionable` is always `False`; the
complete `message` event replaces the pieces. A host routes deltas to a
typing indicator, never to anything that acts.
- **Reconnects resume.** When the company ends the stream, the SDK reopens it
with `Last-Event-ID` set to the last event handled, and yields
`StreamReconnected` so the UI knows.
- **Partial messages are discarded.** Pieces whose complete message never
arrived are listed in `discarded_message_ids`, so the display drops them.
- **No event twice.** Replayed events are suppressed by ID, within a bounded
set that fails closed.
- **Bounded retries.** After `max_reconnects` reconnects in a row without a
new event, the stream fails with `stream_unavailable`. A company that does
not stream answers JSON; the SDK raises `sse_unsupported` and the host falls
back to the long-polling `events()` reader.

The model sees the company's complete messages, how many deltas and
reconnects there were, and that nothing was delivered twice. The Session
Token, DPoP proofs, and pairwise identity stay host-only.

TypeScript: `for await (const item of client.stream(endpoint, id, options))`,
where `item.kind` is `"text-delta"` or `"reconnected"` for the two
stream-only items.

- Run the [Pydantic AI example](https://github.com/datalayer/personal-agent-protocol/tree/main/examples/pydantic-ai/conversation-streaming).
- Run the [Agent Runtimes example](https://github.com/datalayer/agent-runtimes/tree/main/examples/personal-agent-protocol/conversation-streaming).
- Read the [conversations guide](/guides/conversations/).
44 changes: 44 additions & 0 deletions docs/docs/examples/device-sign-in.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
title: Device Sign-In
description: Hand authentication to the user, pace polling, and protect every credential.
---

# Device Sign-In

The paired examples use the real SDK state machine against a deterministic
company transport. They demonstrate a terminal-style personal agent that
cannot receive a browser callback.

```mermaid
sequenceDiagram
autonumber
actor User
participant Tool as Read-only agent tool
participant SDK as DeviceSignInClient
participant Company
participant Vault as Reactor stores
Tool->>SDK: Begin for existing signed-out Session
SDK->>Company: Request device authorization
Company-->>SDK: Link, user code, interval, private device code
SDK->>Vault: Save private device code
SDK-->>Tool: Link, user code, opaque reference
Tool-->>User: Authenticate on company page
SDK->>Company: Poll at interval with DPoP
Company-->>SDK: authorization_pending
SDK->>Company: Poll at interval
Company-->>SDK: slow_down
Note over SDK: Increase interval by five seconds
SDK->>Company: Poll at new interval
Company-->>SDK: Signed-in Session + Account Token + partial scopes
SDK->>Vault: Store Account Token
SDK-->>Tool: Safe lifecycle evidence + opaque token reference
```

The model sees that the link and code were presented, early polling was
suppressed, pending and slow-down responses were respected, the original
Session was upgraded, and one requested scope was omitted. It never sees the
device code, assertions, proofs, or tokens.

- Run the [Pydantic AI example](https://github.com/datalayer/personal-agent-protocol/tree/main/examples/pydantic-ai/device-sign-in).
- Run the [Agent Runtimes example](https://github.com/datalayer/agent-runtimes/tree/main/examples/personal-agent-protocol/device-sign-in).
- Read the production [Device Sign-In guide](/guides/device-sign-in/).
12 changes: 11 additions & 1 deletion docs/docs/examples/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,19 @@ capability owns sensitive work, and what may safely cross into model context.
| [Session lifecycle](./session-lifecycle) | Start, retry, and renew a signed-out Session | An authorization server |
| [Direct Sign-In lifecycle](./direct-sign-in-lifecycle) | Upgrade the same Session after user consent | A browser callback |
| [Browser Session](./browser-session) | Join a controlled browser to the same Session | A signed browser assertion |
| [Account Tokens](./account-tokens) | Refresh, rotate, narrow, expire, and revoke without exposing the token | A signed-in Session |
| [Mediated Sign-In](./mediated-sign-in) | Relay protected input to one endpoint, then a one-time code | Declared mediated fields |
| [Conversation streaming](./conversation-streaming) | Stream over SSE, reconnect from `Last-Event-ID`, never act on deltas | An open conversation |
| [Conversation control](./conversation-control) | Sign in mid-conversation, go direct, hand off, and close | An `authorization` event |
| [Operations](./operations) | Confirm the exact revision, recover lost answers, use standing permissions | A proposed operation |
| [A user with a company](./user-with-a-company) | The whole journey with Manzanita, both sides from the SDK | A user's request |
| [Two companies](./two-companies) | Ledgerly acts for Ada at Northwind, by composition | Two PAP relationships |

The examples use deterministic or local transports where needed, so they can
teach the boundary without real credentials. Production hosts replace those
teach the boundary without real credentials. The newer flows run against the
reference company in `personal_agent_protocol.demo`: real keys and real HTTP
over ASGI in one process, with no network. The two journeys at the end of the
table combine several features; each other page showcases one. Production hosts replace those
fixtures through Reactor contributions for egress, state, browsers, signing,
DPoP, key custody, and token storage.

Expand Down
60 changes: 60 additions & 0 deletions docs/docs/examples/mediated-sign-in.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
title: Mediated Sign-In
description: Relay the user's credentials to one company endpoint, once, without a model seeing them.
---

# Mediated Sign-In

Some companies cannot send their users to a browser. They declare the fields
they need (`auth.mediated.fields`) and an endpoint on their own domain, and
the personal agent relays what the user types. It is the one sign-in mode
where a password passes through the agent's host, so the SDK narrows what the
host can do with it. The paired examples run the real `MediatedSignInClient`
against Manzanita Outfitters, which asks for an email, a password, and then a
one-time code.

```mermaid
sequenceDiagram
autonumber
actor User
participant Tool as Agent tool
participant SDK as MediatedSignInClient
participant Input as pap.protectedInput (host surface)
participant Company as Company mediated endpoint
Tool->>SDK: sign_in(declared fields, signed-out Session)
SDK->>SDK: Endpoint on the company's domain? Otherwise refuse before asking
SDK->>Input: Ask for Email and Password for manzanita.example
User->>Input: Types them (never into the chat)
SDK->>Company: POST credentials once + DPoP, no redirect followed
SDK->>SDK: Drop the values
Company-->>SDK: code_required, sign_in_id, sent_to a•••@example.com
SDK-->>Tool: code_required + opaque reference + masked sent_to
Tool->>SDK: submit_code(reference, same Session)
SDK->>Input: Ask for the code
User->>Input: Types the code
SDK->>Company: POST code only to …/sign_in_id
Company-->>SDK: complete + signed-in Session + Account Token
SDK-->>Tool: complete, granted scopes, Account Token reference
```

The walkthrough also shows the refusals:

- An endpoint off the company's domain is `unsafe_mediated_endpoint`, raised
before the user is asked anything.
- The protected input must answer exactly the declared fields, none empty.
- Only the Session that started a sign-in can finish it
(`session_mismatch`); the pending state is kept for the right one.
- A wrong code is `code_required` again with the same reference; the company
ends the sign-in with `failed` after its attempt limit.

The model sees field labels, which fields are secret, the company's domain,
the masked destination of the code, statuses, and granted scopes. The
credentials and the code exist only in the host's protected input and in the
one request body; the Session Token and Account Token stay in Reactor stores.

TypeScript: `new MediatedSignInClient({ reactor, fetch })` with `signIn()` and
`submitCode()`, the trusted surface contributed at `ProtectedInputs`.

- Run the [Pydantic AI example](https://github.com/datalayer/personal-agent-protocol/tree/main/examples/pydantic-ai/mediated-sign-in).
- Run the [Agent Runtimes example](https://github.com/datalayer/agent-runtimes/tree/main/examples/personal-agent-protocol/mediated-sign-in).
- Read the [Mediated Sign-In guide](/guides/mediated-sign-in/).
Loading
Loading