Skip to main content

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

MethodAnswersWhat 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 proposedthe 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_supportedthe private device code
sign_in_status(domain, user_id=)pending, signed_in with granted and missing scopesthe 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 inthe 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.DisplayMCPToolset is the MCP toolset the runtime builds everywhere. pydantic-ai's MCP mapping drops _meta; this toolset catches the display and returns a ToolReturn whose value is the display's model_view() (its type, its text, and its both-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.DisplayAGUIAdapter streams 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.DisplayVercelAIAdapter does the same on the Vercel AI stream (tool-output-available's output carries 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. The pap-sign-in plugin (in the default preset) contributes both the renderer, Sign in at a company, and the binding for pap_sign_in with 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):

  1. proposes a declared operation instead of making it. The model's call of exchange_order does not run: its arguments become the terms, the Maker's summary is filled with them, the model is told the operation was proposed and not done, and the answer carries a pap_operation A2A data artifact. The company service shows the person the exact terms.
  2. 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.

ExampleShowsRuns on
company-discovery, agent-identityPapPersonalAgentCapability: verified public summarieslive
local-personal-agentPapPersonalAgentCapability with the host's reactor: Session, Device Sign-In, conversation, an operation the person approvesdemo worlds
local-companyPapCompanyCapability: the application as a company over A2A, an operation performed once through its ticketin process
session-assertion, session-lifecycle, browser-sessionSessions: assertions, nonce retry, renewal, browser joinin process
direct-sign-in-boundary, direct-sign-in-lifecycleDirect Sign-In: state, PKCE, upgrade, partial consentin process
device-sign-inDevice Sign-In: link, code, paced pollingin process
mediated-sign-inMediated Sign-In through pap.protectedInputdemo worlds
dpop-proof, dpop-transportDPoP proofs and the transport that retries with noncesin process
account-tokensRefresh, rotation, narrowing, revocationdemo worlds
conversation-messages, conversation-eventsConversations: idempotent messages, long pollin process
conversation-streaming, conversation-controlSSE, handoff, Direct Conversation and closedemo worlds
operationsOperations v1 from the personal agent: exact approvaldemo worlds
user-with-a-company, two-companiesWhole journeys between partiesdemo worlds
sign-in-displayThe sign-in card as a display; nothing for the modeldemo worlds
company-connectionpap.connections with app.company_connection: gateway server, level, scopesin process (no company reached)
company-rolepap.company with app.as_company and @app.company_operation: propose, confirm, perform oncedemo 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.