Skip to main content

Integrate a company

SDK status

The Python SDK ships a reference company, personal_agent_protocol.company: a framework-neutral PAP company over plain ASGI, assembled from Reactor contributions. It is the counterpart the SDK's own end-to-end tests and demo worlds run against, and a starting point for a real integration. The TypeScript SDK has no company side yet.

Start with discoveryโ€‹

Publish https://your-domain.example/.well-known/poppy.json. Advertise only interfaces that are deployed and authorized consistently.

{
"protocol_version": "0.1",
"organization": {
"name": "Example Company",
"domain": "example.com"
},
"auth": {
"issuer": "https://auth.example.com",
"direct": {"scopes": ["poppy:read", "poppy:write"]}
},
"apis": [
{
"type": "openapi",
"url": "https://api.example.com/openapi.json",
"description": "Orders and returns"
}
]
}

The issuer's OAuth metadata must list the discovery domain in poppy_domains. Keep endpoint authorization in application code or a gateway; never ask a model to enforce scopes.

Implement in this orderโ€‹

  1. Publish discovery and OAuth metadata with ordinary signed-out access.
  2. Verify personal-agent client metadata, public keys, assertions, and replay IDs.
  3. Issue short-lived DPoP-bound session tokens.
  4. Add direct sign-in and central scope enforcement.
  5. Expose structured APIs or a company-agent conversation endpoint.
  6. Add Operations when actions require exact, durable approval.

Required operational controlsโ€‹

  • One distributed replay cache for assertion and DPoP identifiers: the default keeps them in the company store, so a store shared by every replica is enough.
  • Rate limits by personal-agent client, user, session, and account as relevant. The reference company limits Mediated Sign-In attempts (max_sign_in_failures failures per account identifier, agent's user, and Session, and wrong codes per account across sign-ins, then 429 rate_limited with Retry-After, the lockout doubling from sign_in_lockout); the account directory must also throttle its own checks, and is told which agent and Session each attempt comes from (SignInContext).
  • Cache-Control: no-store on responses carrying credentials or codes.
  • Immediate sign-out propagation, or very short token lifetimes when that is not possible.
  • Every sign-in ended when the account's password is reset, or the account is locked or recovered: a directory that also answers signed_out_before(account_id) (AccountSignOuts; TypeScript: signedOutBefore) is asked on each signed-in request, and a sign-in from before that moment is refused and revoked whole โ€” its Account Tokens, and the Sessions signed in with them continuing signed out โ€” while a sign-in after it works.
  • Identical authorization semantics across APIs, conversations, browser sessions, company-agent tools, and human-support tools.
  • Generic external errors that do not disclose whether another user's resource exists.

The reference companyโ€‹

from personal_agent_protocol.company import CompanyConfig, build_company, company_app

config = CompanyConfig(
name="Manzanita Outfitters",
domain="manzanita.example",
scopes=("orders:read", "orders:write"),
write_scopes=("orders:write",),
mediated_fields=(("email", "Email", False), ("password", "Password", True)),
)
service = build_company(config, http_client=http, plugins=[(manifest, my_company_plugin)])
app = company_app(service) # any ASGI server, or httpx.ASGITransport in tests

CompanyService.handle() takes a neutral request and returns a neutral response, so any web framework can serve it; company_app() serves it over plain ASGI and rebuilds each request URL on the company's published origin so DPoP htu checks compare against what the personal agent signed. It implements, of draft 0.1 and Operations v1:

  • discovery (/.well-known/poppy.json) and OAuth metadata on one issuer;
  • Sessions with verified client and Session assertions, DPoP-bound tokens, and renewal by session_id;
  • Device Sign-In with authorization_pending and slow_down (await approve_device() stands for the company's own approval page);
  • Mediated Sign-In with a one-time code, an attempt limit, and uniform failures;
  • Account Tokens rotated on every refresh, narrower scopes only, invalid_grant for expired, revoked, or rotated-away tokens, and revocation that signs out every Session signed in with the token;
  • conversations with idempotent messages, cursor events, waited replies, SSE, handoff, Direct Conversations, authorization events, and closing; a conversation that is not yours is conversation_not_found;
  • Operations proposed by the company agent, read, confirmed at most once (latest revision, approval rules, write scopes, extension negotiation), revised (revise()), and cancelled;
  • with web (web=True, web: true in TypeScript), the website (ยง5): the browser-session endpoint joins the agent's own browser to its Session with the company's __Host-poppy_session cookie, and each operation's page confirms or cancels it, the same operation at most once whichever channel confirms it.

Reactor pointsโ€‹

Every part a company replaces is a Reactor contribution. The company's own plugins contribute with a lower order to replace a default.

PointContractDefault
pap.company.storeCompanyStore: versioned JSON records by kind and key (below)MemoryCompanyStore (one process)
pap.company.accountsAccountDirectory: authenticate(credentials, context) and check_code for Mediated Sign-In; context (SignInContext) names the agent and Session, and the directory must throttle its own checks too; optionally AccountSignOuts.signed_out_before(account_id), since when the account's earlier sign-ins are over (a password reset)None: the company contributes it
pap.company.agentCompanyAgent.respond(turn): replies, authorization requests, proposed operationsNone: the company contributes it
pap.company.operationExecutorOperationExecutor.perform(operation, account_id), one per action; operation_id is the idempotency keyNone: an unperformable operation stays in_progress
pap.company.clientPolicyClientPolicy.allows(client_id)AllowEveryAgent
pap.company.clientResolverVerified client metadata and JWKS for a client_idIdentityClientResolver over HTTPS
pap.company.replayCachemark_once(key) for assertion and proof IDsStoreReplayCache: an atomic create in pap.company.store, so a shared store is a shared cache

The PAP core points (pap.clock, pap.randomSource, and the rest) apply on the company side too.

The storeโ€‹

Everything the company remembers โ€” Sessions and their tokens, Account Tokens and the sign-ins they rotate from, pending device, Direct, and Mediated sign-ins, one-use codes, conversations and their events, message IDs, operations โ€” is a JSON record in the pap.company.store contribution, by kind and key. The contract is six async methods:

MethodDoes
get(kind, key)The record (value, version, expires_at), or None when absent or expired
put(kind, key, value, *, expires_at=None)Write it unconditionally: a new version
create(kind, key, value, *, expires_at=None)Write it only if absent (or expired): True when written
replace(kind, key, version, value, *, expires_at=None)Compare-and-set: write it only if its version is still version
take(kind, key)Read and delete at once: exactly one caller gets it
delete(kind, key)Remove it, if present

In TypeScript the methods are the same, with { expiresAt } options and undefined for absent.

The service never changes a record in place: every change is an explicit write, so a database, a key-value service, or any store shared by several replicas sees all of it. Every transition that must not race goes through one of the atomic methods:

  • authorization codes, Direct Sign-In decisions, completed Mediated sign-ins, and approved device sign-ins are taken once (take);
  • message IDs are recorded with create, so a retried message is answered once however many replicas receive it;
  • device approval and polling, Account Token rotation (the presented token is claimed by exactly one refresh; reusing it is invalid_grant), revocation of a whole sign-in, Mediated attempt counting, conversation events and status, and operation confirmation (one compare-and-set from proposed to in_progress, whose winner alone calls the executor) use replace.

A company restarted, replaced, or rolled back over the same store continues every flow: a pending device sign-in can be approved, a rotated-away Account Token stays refused, an operation proposed before is confirmed once after, and an event cursor resumes where it was.

A version that no longer offers a scope โ€” rolled back to one without it, or narrowed โ€” does not honour what was granted for it: a Session holding that scope is refused as invalid_token, so the personal agent starts a new Session within what is offered, and an Account Token refreshed for it is invalid_scope (the agent's client says re_consent_required), while a refresh asking only for scopes still offered succeeds with the same token.

approve_device, deny_device, approve_authorization, deny_authorization, authorization_request, revise, settle, forget_events, disconnect, and operation_record read and write the store, so they are coroutines (promises in TypeScript).

A backend checks itself with the conformance kit, which runs the same checks in both languages: absent and present records, expiry, take once under concurrency, create only if absent, compare-and-set conflicts, values copied rather than shared, and a round trip of every record kind.

from personal_agent_protocol.company.testing import check_store


async def test_my_store_conforms() -> None:
await check_store(lambda: MyDatabaseStore(url))
import { checkStore } from "@datalayer/personal-agent-protocol/server";

test("my store conforms", () => checkStore(() => new MyDatabaseStore(url)));

MemoryCompanyStore judges expiry by the company's own pap.clock (build_company binds it); a backend uses its own time source and passes it to the kit as now.

What stays in the companyโ€‹

  • Credentials sent to the mediated endpoint go to the account directory as SecretStr and are cleared after the check.
  • Account Tokens and device codes are stored as digests.
  • A company agent receives a Turn with the account, scopes, and whether the conversation is direct; it never sees tokens.
  • Errors are generic codes that do not disclose whether another user's conversation or operation exists.

Every page the company serves (the consent page, the operation page) is sent with X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none', and Cache-Control: no-store.

The built-in consent page binds the sign-in request to the browser it is shown to: it sets a __Host-poppy_consent cookie (Secure, HttpOnly, SameSite=Strict; a browser that has one keeps it), stores its digest with the request, and puts a CSRF token derived from the cookie and the request in the form, which posts to /oauth/authorize. The company's web layer, which knows who is signed in, hands that POST to service.consent_decision(request) (consentDecision) and then calls approve_authorization or deny_authorization. It is refused (PermissionError; a rejected promise in TypeScript) when:

  • Sec-Fetch-Site is anything but same-origin, or Origin is another origin (or null);
  • the consent cookie is missing, or is not the one the request was bound to: a form replayed from another browser does not match;
  • the CSRF token is missing or was made for another cookie or request;
  • the request is unknown, expired, or already decided.

A hosted consent page (consent_page) gets no binding: it protects its own form, and consent_decision refuses every POST for its requests.

The operation page (/operations/{operation_id}, both reference companies) does the same with the browser's __Host-poppy_session cookie: its form carries a token derived from that cookie and the operation, and a POST that is cross-site, from another origin, without the token, or with a token from another browser's page is refused with 403. test_guide_security.py and guide-security.test.mjs test each case, and test_mcp_and_web.py and mcp.test.mjs the operation page.

Hosted sign-in pagesโ€‹

By default the reference company serves its own Device Sign-In and consent pages. A company whose sign-in lives elsewhere sets two URL templates:

config = CompanyConfig(
name="Orders helper",
domain="orders.example",
scopes=("orders:read",),
operations=False,
device_page="https://datalayer.ai/pap/device?slug=orders-helper",
consent_page="https://datalayer.ai/pap/consent?slug=orders-helper&request={request_id}",
)
  • device_page (devicePage in TypeScript) becomes the device authorization response's verification_uri; verification_uri_complete adds &code=<user_code>.
  • consent_page (consentPage) must contain {request_id}. GET /oauth/authorize runs every check first โ€” the agent's metadata, its registered redirect_uri, the response type, PKCE, and the scopes โ€” and creates the pending request exactly as before; it then answers 303 to the consent page instead of HTML. A request with an unverified redirect_uri gets the company's own error page and is never redirected anywhere.
  • The consent page reads the request with service.authorization_request(request_id) (authorizationRequest): the agent's client_name, its client_id domain, each requested scope with its custom_scopes words, and expires_at. Reading does not consume the request, and the summary carries no state, PKCE challenge, or redirect. The user's decision then goes to approve_authorization or deny_authorization as before.

Both templates must be HTTPS URLs without a fragment.

Before productionโ€‹

The defaults keep state in one process. A deployment contributes a durable store that passes check_store, a distributed replay cache, its real account directory and agent, an executor per action that is idempotent downstream, and a client policy. It also needs TLS, rate limits, and the operational controls above.

Demo worldsโ€‹

personal_agent_protocol.demo assembles fictional companies from these points, on an in-process network with real keys: Manzanita Outfitters (one user with a company), and Northwind Bank with Ledgerly (two companies, composed). See A user with a company and Two companies. The company-service Pydantic AI example builds a company of its own from a CompanyConfig, a store checked with check_store, a company agent, and one executor, serves it with company_app, and runs a personal agent's journey against it; the company-apis example reads and proposes through Manzanita's OpenAPI API and MCP server.

Use the upstream company implementation guidance alongside the normative specification.