Integrate a company
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โ
- Publish discovery and OAuth metadata with ordinary signed-out access.
- Verify personal-agent client metadata, public keys, assertions, and replay IDs.
- Issue short-lived DPoP-bound session tokens.
- Add direct sign-in and central scope enforcement.
- Expose structured APIs or a company-agent conversation endpoint.
- 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_failuresfailures per account identifier, agent's user, and Session, and wrong codes per account across sign-ins, then429 rate_limitedwithRetry-After, the lockout doubling fromsign_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-storeon 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_pendingandslow_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_grantfor 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,
authorizationevents, and closing; a conversation that is not yours isconversation_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: truein TypeScript), the website (ยง5): the browser-session endpoint joins the agent's own browser to its Session with the company's__Host-poppy_sessioncookie, 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.
| Point | Contract | Default |
|---|---|---|
pap.company.store | CompanyStore: versioned JSON records by kind and key (below) | MemoryCompanyStore (one process) |
pap.company.accounts | AccountDirectory: 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.agent | CompanyAgent.respond(turn): replies, authorization requests, proposed operations | None: the company contributes it |
pap.company.operationExecutor | OperationExecutor.perform(operation, account_id), one per action; operation_id is the idempotency key | None: an unperformable operation stays in_progress |
pap.company.clientPolicy | ClientPolicy.allows(client_id) | AllowEveryAgent |
pap.company.clientResolver | Verified client metadata and JWKS for a client_id | IdentityClientResolver over HTTPS |
pap.company.replayCache | mark_once(key) for assertion and proof IDs | StoreReplayCache: 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:
| Method | Does |
|---|---|
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 fromproposedtoin_progress, whose winner alone calls the executor) usereplace.
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
SecretStrand are cleared after the check. - Account Tokens and device codes are stored as digests.
- A company agent receives a
Turnwith 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.
Consent pages and the websiteโ
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-Siteis anything butsame-origin, orOriginis another origin (ornull);- 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(devicePagein TypeScript) becomes the device authorization response'sverification_uri;verification_uri_completeadds&code=<user_code>.consent_page(consentPage) must contain{request_id}.GET /oauth/authorizeruns every check first โ the agent's metadata, its registeredredirect_uri, the response type, PKCE, and the scopes โ and creates the pending request exactly as before; it then answers303to the consent page instead of HTML. A request with an unverifiedredirect_urigets 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'sclient_name, itsclient_iddomain, each requested scope with itscustom_scopeswords, andexpires_at. Reading does not consume the request, and the summary carries nostate, PKCE challenge, or redirect. The user's decision then goes toapprove_authorizationordeny_authorizationas 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.