Personal Agent Protocol
Agent Runtimes depends on Personal Agent Protocol (PAP, or Poppy) in Python
and TypeScript: personal-agent-protocol[ui] and
@datalayer/personal-agent-protocol (0.27.0, implementing
draft 0.3 and the
Operations extension
version 1; see the SDKs'
protocol coverage).
The protocol itself is specified by the Personal Agent Protocol organization on GitHub: its public personal-agent-protocol/personal-agent-protocol repository is the source of personalagentprotocol.org, which publishes each version of the specification, extensions and guides, the changelog, and takes feedback as issues on that repository.
The integration divides responsibility explicitly:
- the PAP SDK validates discovery, issuer/domain binding, client metadata, same-domain JWKS, protocol models, and cryptographic flows;
- Agent Runtimes decides what is safe to place in a tool result, model context, trace, or UI state; and
- Datalayer host services own DNS-pinned egress, key custody, one-use authorization state, tokens, consent, and audit.
Python
Without a host, PapPersonalAgentCapability exposes two credential-free
methods:
from agent_runtimes.pap import PapPersonalAgentCapability
async with PapPersonalAgentCapability() as pap:
company = await pap.discover_company("example.com")
identity = await pap.inspect_agent("https://agent.example/agent.json")
PapCompanySummary contains the verified organization, domain, protocol
version, issuer, sign-in choices (Direct and Device as the issuer's
grant_types_supported offers them, Mediated as poppy.json describes it),
the company's own scopes with what each allows and their hints
(auth.scopes, auth.scope_hints), the issuer's protected_resources,
interface kinds, and extension versions.
PapAgentIdentitySummary contains the verified client identity, redirect
count, signing algorithms, and extension versions. Neither contains endpoint
operational endpoint URLs, response bodies, JWK coordinates, assertions,
proofs, or tokens. The verified issuer and client ID remain visible because
they are the public identities being inspected.
A local runtime's personal agent
Given the host's PAP Reactor, the same capability is a local runtime's
personal agent: it uses the SDK's SessionClient, ConversationClient,
DeviceSignInClient and OperationsClient, and answers summaries only.
from personal_agent_protocol import build_pap_reactor
reactor, _ = build_pap_reactor(http_client=http, plugins=[host_plugin])
pap = PapPersonalAgentCapability(
reactor=reactor, http_client=http,
client_id="https://atlas.example/agent.json", tenant_id="local",
)
answer = await pap.ask("manzanita.example", "Where is my order?", user_id="ada")
# answer.authorization == {"error": "insufficient_scope", "scope": "orders:read orders:write"}
# answer.replies[0].author == "agent": each reply says who wrote it (its agent or one of its people)
started = await pap.sign_in("manzanita.example", user_id="ada")
# started.open and started.code are for the person, on the company's own page
status = await pap.sign_in_status("manzanita.example", user_id="ada")
answer = await pap.ask("manzanita.example", "Exchange it for a warmer one", user_id="ada")
proposed = answer.operations[0]
done = await pap.confirm("manzanita.example", proposed.operation_ref, proposed.revision, user_id="ada")
The host contributes to the SDK's points what a personal agent holds: its
signing key (pap.signer), a DPoP key per Session, the Session Key, whose
thumbprint names the Session (pap.dpopProofProvider), tokens
(pap.tokenStore), one-use authorization state
(pap.authorizationStateStore), pairwise identities
(pap.pairwiseUserIdStore) and the person's approval surface
(pap.approvalSurface). The capability fails closed without them: no
reactor, every session call raises PapCapabilityError("no_host"); no
approval surface, confirm sends nothing (no_approval_surface).
| Method | Answers | What never crosses |
|---|---|---|
ask(domain, text, user_id=) | PapAnswer: the company's words, each with its author (agent or human) and name, what it asks for (insufficient_scope and the scopes: signed out, to sign in for; signed in, to ask for again), the operations it proposed | the Session Token, DPoP proofs, the Session Key, the pairwise id |
sign_in(domain, user_id=, scopes=) | PapSignInSummary: the company's link and the code the person types there; offered when the issuer's grant_types_supported lists Device Sign-In, only scopes in its scopes_supported | the private device code |
sign_in_status(domain, user_id=) | pending, signed_in with granted and missing scopes | the Account Token |
confirm(domain, operation_ref, revision, user_id=) | PapConfirmSummary: confirmed with the result, not_approved, terms_changed (asked again) | the approval itself is the person's, on the host's surface |
session_of(domain, user_id=) | a one-way reference to the Session and whether someone is signed in | the Session Key's thumbprint |
Only Device Sign-In is offered locally: Direct Sign-In needs a page the
company returns the browser to, and Mediated Sign-In a trusted page to type
on. In Datalayer the same journey is the gateway's pap toolset (pap_ask,
pap_sign_in, pap_sign_in_status, pap_confirm), whose answers these
mirror; there IAM holds every credential. The local-personal-agent
example runs it against the SDK's Manzanita.
TypeScript
The TypeScript package provides the same boundary and vocabulary:
import { PapPersonalAgentCapability } from '@datalayer/agent-runtimes';
const pap = new PapPersonalAgentCapability();
const company = await pap.discoverCompany('example.com');
const identity = await pap.inspectAgent('https://agent.example/agent.json');
Both methods accept injected PAP clients/functions for server policy and testing. Production backends should inject a Reactor host whose metadata transport enforces connected-address policy and tenant-scoped caching.
Authorization boundary
Discovery and public identity are safe agent tools. Direct Sign-In, Session start/renewal, DPoP generation, Account Token handling, and callbacks are not. Those flows run as deterministic capabilities outside the model loop and give applications only safe status or opaque references.
The repository's Personal Agent Protocol examples provide one small application per feature and make that boundary executable. The PAP repository separately provides plain Pydantic AI examples with no Agent Runtimes dependency.
The dpop-proof pair is the reference for proof-of-possession: both examples
create and verify a real ES256 proof in trusted host code, demonstrate method,
URL, token, nonce, and replay binding, and return only non-secret policy
evidence to the model. The browser gallery adds a Reactor-contributed view that
uses non-exportable Web Crypto key material and retains neither generated
proof in React state. Protocol details remain canonical in the PAP
JOSE and DPoP guide.
The session-lifecycle pair drives the real SessionClient through signed-out
start, one DPoP nonce challenge, and renewal. Its deterministic company
transport makes the wire behavior observable without external credentials,
while the Loop tool returns only lifecycle evidence. Session Keys, pairwise
subjects, assertions, proofs, nonces, and tokens remain host-owned. See the
canonical Sessions guide.
The sign-in card as a display
A tool that starts a sign-in at a company answers with a display beside
its result: a PapSignIn (personal_agent_protocol.ui, type
application/vnd.datalayer.pap.sign-in+json) in the MCP result's
_meta["reactor.ui/display"]. Datalayer's gateway answers pap_sign_in and
pap_sign_in_status this way while the person has something to do.
The page to open and the code to enter are for the person only: a device's verification URL may carry the code. Agent Runtimes keeps them out of the model and hands them to the page:
agent_runtimes.mcp.displays.DisplayMCPToolsetis the MCP toolset the runtime builds everywhere. pydantic-ai's MCP mapping drops_meta; this toolset catches the display and returns aToolReturnwhose value is the display'smodel_view()(its type, its text, and itsboth-audience fields:mode,expires_at,sent_to) and whose metadata is the whole wire display. A display type not registered in the process gives the model its text only.agent_runtimes.transports.agui_displays.DisplayAGUIAdapterstreams the AG-UI tool result with the display beside the model view, under_meta. The model's message history keeps the model view alone.agent_runtimes.transports.vercel_displays.DisplayVercelAIAdapterdoes the same on the Vercel AI stream (tool-output-available'soutputcarries the_meta).- A reloaded history draws the card again: the stored tool return keeps the
model view as its content and the wire display beside it, and the history
route serves the page the model view with the display under
_meta, as the live stream did. - In the page, the chat calls
resolveToolResult(@datalayer/reactor/ui): a result that embeds a display is drawn from it; otherwise a tool binding claims the tool by name.ReactorDisplay(@datalayer/reactor/ui/react) picks the renderer for the display's type. Thepap-sign-inplugin (in the default preset) contributes both the renderer, Sign in at a company, and the binding forpap_sign_inwith or without a server prefix.
from personal_agent_protocol.ui import PapSignIn
display = PapSignIn(mode="device", open=verification_uri, code=user_code)
# An MCP tool result carries it for the page:
meta = display.tool_meta() # {"reactor.ui/display": {...}}
# What the model is told:
display.model_view() # type, text, mode, expires_at, sent_to
import { resolveToolResult } from '@datalayer/reactor/ui';
import { ReactorDisplay } from '@datalayer/reactor/ui/react';
const display = resolveToolResult(platform, { toolName, result, toolCallId });
return display ? <ReactorDisplay display={display} context={context} /> : row;
The sign-in-display example runs a real device sign-in at the SDK's demo
company through DisplayMCPToolset and DisplayAGUIAdapter and shows that
the model read neither the link nor the code; the PAP Sign-In Display
gallery example draws the card from those results.
Working with a company: pap.connections
An application works with a company as a personal agent by naming it in its Appspec:
pap:
profile: draft-01 # the Personal Agent Protocol profile it is written for
connections:
- id: orders
company: manzanita.example # the domain discovery takes; nothing else
access: read # read | write
as: user # user (each person's own account) | owner
scopes: [orders:read] # the fewest scopes the application needs
interfaces: [agent, mcp] # agent | openapi | mcp
sign_in: [device, direct] # accepted modes, preferred first
extensions: [operations]
In Python, app.company_connection("orders", "manzanita.example", scopes=["orders:read"], interfaces=["agent", "mcp"], sign_in=["device", "direct"]) writes the same block, checked by agentspecs at the call.
No endpoint, issuer or credential is ever part of the spec. In the runtime
(agent_runtimes.apps.pap_connections) each company is one server of
Datalayer's MCP gateway, named from its domain (pap_manzanita_example,
reached with ?only=pap&pap-company=manzanita.example) and prefixing its
tools, so two companies' tools never collide. The token sent is the person's
for as: user, the owner's for as: owner; the runtime holds no key or token
of the company's.
What each gateway tool does is classed in agentspecs
(protocol-tools/pap.yaml, read by the gateway and the runtime alike), so
the application's Rules decide them as any other tool. A read connection is
given only the tools that read (pap_company, pap_ask, pap_sign_in,
pap_sign_in_status, pap_read, pap_propose, pap_mcp_tools); pap_confirm,
pap_mcp_call and pap_disconnect need access: write.
Scopes narrow the sign-in. When the gateway starts a sign-in for an
application, it asks, of the scopes the company offers for that mode, only
those the connection's scopes names, and never a :write scope on a read
connection. A connection that names no scope asks for none, and consent never
widens either. A person signing in for themselves, outside an application, is
asked whatever the company offers. This narrowing is the gateway's (Datalayer
services); the runtime passes the connection, and the company-connection
example shows the declared scopes and the tools a read level gives.
A personal agent, step by step
Signing in, three ways. pap_sign_in prefers Direct (the company's
page, then back), then Device (the company's page and a code), then
Mediated: the person types what the company asks on Datalayer's own page
(/pap/sign-in?ticket=…), and the gateway hands it to the company once
through the SDK's pap.protectedInput — never the model, never a tool's
arguments. The result carries the SDK's PapSignIn display for the page
(the sign-in card as a display); the model
reads where, how and until when.
Confirming, and why the gateway asks the person itself. pap_confirm is
classed write (or buy when a term moves money), so the application's
Rules decide it at the tool call like any other tool. A company may require
the person's own approval of an operation: then the Rule does not count —
an approval in advance, Do it if I asked and Do it alike — and the
gateway asks the person on Waiting for you for that exact revision,
confirming once it is approved. Only the person's decision on that revision
lets it through, so a model or a Maker's rule can never confirm what the
company reserved for them. While the person decides, the result carries the
SDK's PapConfirm display; once confirmed, its PapOutcome. In a chat
host (Slack, Discord, WhatsApp) the person can decide it where they talk
(1.5.90): the host's pap-confirm card shows the company, its summary, every
term, the operation and its exact revision, and fails closed, back to
Datalayer, when any of them is missing (see
the hosts entry).
A change asked through the company's API. With interfaces: [openapi],
pap_propose sends a POST to one of the API's paths (/orders/ord_7Hk2/gift-wrap)
in the person's name; what the company answers 202 is an operation it
proposes, kept to confirm with pap_confirm, never done by the call. It is
classed send, as pap_ask is, and an approval in advance the person gave
covers the confirmation when the company does not reserve it to them.
The company's own MCP server. With interfaces: [mcp], pap_mcp_tools
lists the company's MCP tools and pap_mcp_call calls one with a Bearer
token bound to that server alone (its resource), never reusable elsewhere;
what matters comes back as an operation to confirm, never done by the call.
Signed requests. Everything the toolset sends a company carries a Web Bot
Auth signature made by IAM with the application's own key, its public keys at
<IAM>/api/iam/v1/agents/<application>/web-bot-auth/jwks.json
(Identity).
Being a company: pap.company
An application can itself be a company personal agents work with:
pap:
profile: draft-01
company:
enabled: true # publish it as a company at Ship
signed_out: true # personal agents may ask before sign-in
interfaces: [agent, openapi, mcp]
scopes: # its own, never poppy:; each says what it allows
- name: orders:read
description: Read your orders
changes: none # hints, when the Maker says them: none | reversible | irreversible
personal_data: true
- name: orders:write
description: Exchange an order you approved
excluded_tasks: # what it does not do through the protocol, with another way
- task: Cancel an order
alternative:
instructions: Cancel it from your account on the website.
extensions: [operations]
personal_agents: registered # any | registered
registered_agents:
- https://atlas.example/agent.json # each one's client_id URL
operations:
- action: orders.exchange
tool: exchange_order
summary: Exchange order {order_id} for size {size}
user_approval_required: true
In Python, app.as_company(...) writes the role and @app.company_operation( "orders.exchange", summary=...) over an @app.tool declares an operation
done by that tool (Python applications).
agentspecs validates the block (0.0.77 and later): a company admitting nobody
signed out offers at least one scope, registered_agents are HTTPS
client_id URLs named only with personal_agents: registered, operations
require the operations extension, and each action and each tool is declared
once. web is not an interface a company application offers yet. Since
agentspecs 0.0.126 (PAP draft 0.3) each scope is an object: its name (never
under the reserved poppy: prefix), its description, published as the
company's auth.scopes, and, when given, its hints changes and
personal_data, published as auth.scope_hints (left out, Datalayer derives
them from the Rules classes of the tools behind the scope); excluded_tasks
are published at the company's excluded_tasks document, each with its
alternative (instructions, references, or both). A list of scope
names is refused.
Datalayer's company service publishes the application and asks it over A2A,
marking the message metadata.pap. In such a turn the runtime
(agent_runtimes.apps.pap_company):
- proposes a declared operation instead of making it. The model's call
of
exchange_orderdoes not run: its arguments become the terms, the Maker'ssummaryis filled with them, the model is told the operation was proposed and not done, and the answer carries apap_operationA2A data artifact. The company service shows the person the exact terms. - performs it only in a turn whose one-use ticket the company service
redeems for the revision the person confirmed: the declared tool, with
exactly those terms, once. Other terms, another tool and a retry are
refused, and the turn reports
pap_performed. Anything else that does more than read stays refused, and the rules' Leave it to me stays the person's.
What is proposed and what is performed is decided by the Appspec and the
company service's record, never by the model or by the message. The
company-role example walks this journey: two proposals, the SDK's
approve() showing that an approval of revision 1 does not confirm revision 2,
then the confirmed call allowed once. The operations example shows the
personal agent's side of the same exchange over HTTP.
A company, step by step
A company in a local runtime
PapCompanyCapability is the open-source form of that company service, for a
local runtime: the SDK's reference company (build_company) with
pap.company.agent being the application over its A2A route.
from agent_runtimes.pap import A2AApplication, PapCompanyCapability
from agent_runtimes.protocols import protocols_platform
company = PapCompanyCapability(
name="Order Desk", domain="orders.example",
company=app_spec.pap.company, # agentspecs AppPapCompany
application=A2AApplication("https://runtime.local/api/v1/a2a/order-desk/", http_client=http),
accounts=host_accounts, # pap.company.accounts: whose account a person signs in to
http_client=http,
)
protocols_platform().register_plugin(*company.runtime_plugin()) # perform turns redeem its tickets
asgi_app = company.asgi() # served at https://orders.example
await company.approve_device(user_code, account_id="acct_ada") # the host's own sign-in page
Its contributions: the application answers each turn (read scopes before the
person's account is read, write scopes before an operation is proposed,
nothing proposed for nobody signed in); each declared operation's executor
issues a one-use ticket and asks the application to perform; the runtime
redeems it at pap.company.operationTickets — a point of the
agent-runtimes.protocols.pap plugin whose default is Datalayer's company
service — and makes that one call, then says pap_performed; with
personal_agents: registered only the listed agents are admitted. A local
company offers the agent interface and Device Sign-In; an Appspec asking
for mcp or openapi is refused when the capability is built. The
local-company example runs it, reached by the local-personal-agent's
capability.
Examples
Each example in
examples/personal-agent-protocol
is a small Application that runs against the released PAP SDK, most of them
in the SDK's self-contained demo worlds (real keys, proofs and HTTP over ASGI
in one process, no network, no model). python scripts/validate_pap_examples.py
builds every one, rejects duplicate IDs and tools that take a host-only value,
checks that a company's operations name its own tools, and runs every
argument-free read tool once.
Where each runs: live reaches the company or agent you name over HTTPS; demo worlds runs the SDK's demonstration companies (Manzanita, Ledgerly, Northwind) in one process; in process answers from a company written in the example itself (recorded answers over a local transport). None needs an account, and none reaches a Datalayer service.
| Example | Shows | Runs on |
|---|---|---|
company-discovery, agent-identity | PapPersonalAgentCapability: verified public summaries | live |
local-personal-agent | PapPersonalAgentCapability with the host's reactor: Session, Device Sign-In, conversation, an operation the person approves | demo worlds |
local-company | PapCompanyCapability: the application as a company over A2A, an operation performed once through its ticket | in process |
session-assertion, session-lifecycle, browser-session | Sessions: assertions, nonce retry, renewal, browser join | in process |
direct-sign-in-boundary, direct-sign-in-lifecycle | Direct Sign-In: state, PKCE, upgrade, partial consent | in process |
device-sign-in | Device Sign-In: link, code, paced polling | in process |
mediated-sign-in | Mediated Sign-In through pap.protectedInput | demo worlds |
dpop-proof, dpop-transport | DPoP proofs and the transport that retries with nonces | in process |
account-tokens | Refresh, rotation, narrowing, revocation | demo worlds |
conversation-messages, conversation-events | Conversations: idempotent messages, long poll | in process |
conversation-streaming, conversation-control | SSE, handoff, Direct Conversation and close | demo worlds |
operations | Operations v1 from the personal agent: exact approval | demo worlds |
user-with-a-company, two-companies | Whole journeys between parties | demo worlds |
sign-in-display | The sign-in card as a display; nothing for the model | demo worlds |
company-connection | pap.connections with app.company_connection: gateway server, level, scopes | in process (no company reached) |
company-role | pap.company with app.as_company and @app.company_operation: propose, confirm, perform once | demo worlds (its identities) |
In the product, the same connections run through Datalayer's MCP gateway
(the pap toolset) and the same company role through Datalayer's company
service; the Studio examples Order Desk and Exchange Desk are those two,
built on the Canvas rather than in Python.
The browser gallery has a view per feature under Personal Agent Protocol, including PAP Sign-In Display.
Guided interaction example
PAP Guided Journey is the user-facing companion to the focused diagnostic
examples. It synchronizes three perspectives across five navigable steps:
- the user's conversation and company-owned sign-in page in a phone frame;
- the minimum Session, conversation, authorization, and result state the fictional company is allowed to see; and
- an expandable protocol inspector whose assertions, proofs, state, codes, identifiers, and tokens are explicitly host-only or redacted.
The view is contributed through the same Reactor plugin mould as the other PAP examples. On load, it validates the fictional discovery document, creates a real Session-assertion shape, and creates a real S256 Direct Sign-In request with the TypeScript PAP SDK. It stores none of the generated security material in React state. This keeps the visual narrative aligned with executable SDK behavior rather than presenting an unrelated mockup.