koh / poppy
Framework-agnostic server core for the Personal Agent Protocol (Poppy): poppy.json discovery, issuer binding, agent identity, DPoP, Sessions, Direct and Device Sign-In, Account Tokens.
Requires
- php: ^8.3
- ext-json: *
- ext-mbstring: *
- ext-openssl: *
- ext-sodium: *
- psr/clock: ^1.0
- psr/http-client: ^1.0.3
- psr/http-factory: ^1.1.0
- psr/http-message: ^1.1 || ^2.0
Requires (Dev)
- doctrine/dbal: ^3.10 || ^4.0
- infection/infection: ^0.35.8
- nyholm/psr7: ^1.8.2
- phpstan/phpstan: ^2.3.3
- phpunit/phpunit: ^11.5 || ^12.0 || ^13.0
- symfony/rate-limiter: ^6.4 || ^7.4 || ^8.0
Suggests
- doctrine/dbal: DbalStorage, DbalReplayGuard and DbalFeedbackSink (DBAL 3.10 or 4).
- symfony/rate-limiter: SymfonyRateLimiter.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-28 16:43:10 UTC
README
koh/poppy
A framework-agnostic PHP server for the Personal Agent Protocol (Poppy), draft 0.2: the protocol through which a person's own AI agent works with a company on their behalf. The agent discovers what the company offers, starts a Session, browses its website and calls its APIs in that Session, and signs in to the person's account only when a task needs it — with the person approving on the company's own page.
This package is the company side. It has no framework dependency and no global state: it speaks PSR-7, PSR-17, PSR-18 and PSR-20, and leaves storage, pages and routing to the integration. Integrations exist for Sylius and TYPO3.
What it covers
| Section of the specification | Here |
|---|---|
| 3 Discovery | PoppyDocument (/.well-known/poppy.json) and AuthorizationServerMetadata (RFC 8414, with poppy_domains for the issuer binding) |
| 4.1 Agent identity | ClientMetadataResolver, ClientAuthenticator — client ID metadata documents, private_key_jwt, aud pinned to the issuer |
| 4.2 Sessions | TokenEndpoint, JWT bearer grant, session_id, renewal |
| 4.3 DPoP | DpopVerifier (RFC 9449), Bearer tokens only for MCP servers without DPoP |
| 4.5 Direct Sign-In | DirectSignIn — authorization code with PKCE S256 and RFC 9207 iss |
| 4.6 Device Sign-In | DeviceAuthorizationEndpoint, DeviceVerification (RFC 8628) |
| 4.8–4.9 Account Tokens, sign-out | refresh_token grant, RevocationEndpoint, AccountDisconnector |
| 5 Web browsing | BrowserSessions — browser assertions; the integration sets its own cookie |
| 6 APIs | ResourceGuard, SessionContext, Challenge (WWW-Authenticate) |
| 8 Feedback | FeedbackEndpoint |
Not covered: Mediated Sign-In (4.7), in which the agent sends the person's password to the company, and conversations with a Company Agent (section 7). A company that offers neither is fully conformant; the discovery document only declares what is wired up.
The checks that matter
- Issuer binding, both directions.
poppy.jsonnames the issuer, and the issuer's metadata lists the domain inpoppy_domains. The document builder refuses a domain the issuer does not list. The metadata also carries the same binding asprotected_resources, the form draft 0.3 uses. - Assertions addressed to the issuer. Client and Session assertions must
carry the issuer as a single-string
aud— never the token endpoint, never an array — live at most five minutes, and carry ajtithat is accepted once. Browser assertions have their owntypand audience, so neither kind passes for the other. - Every token request proves a key. Session Tokens are bound to the DPoP
key of the request that obtained them; proofs are checked for method, URL,
age,
ath, key binding and — at the token endpoint — replay. ES256 is always accepted. Nonces are optional (TimeSlotNonceSource). - Bearer only where it cannot be avoided. A Bearer Session Token exists only for an MCP server that does not verify DPoP, and is accepted at that server's URL and nowhere else.
- Sign-out takes effect on the next request. Whether a request is signed in is read from the Session each time, not from the token, so a revoked Account Token turns every token issued under it into a signed-out one at once.
- A Session keeps its account. Once signed in, a Session can be signed in
again only to the same account; anything else is
account_mismatchand no tokens. - Nothing is fetched that should not be. Client metadata and key sets are fetched over HTTPS on port 443 only, never from loopback, private, link-local or other non-public addresses, never following redirects, and only after every check that needs no network has passed — a blocked or malformed request makes the server send nothing.
- No token is stored. Tokens are random strings; storage sees only their SHA-256 hash.
Usage
use Koh\Poppy\Discovery\Api; use Koh\Poppy\Discovery\PoppyDocument; use Koh\Poppy\PoppyServer; use Koh\Poppy\Server\Endpoints; use Koh\Poppy\Server\ServerConfiguration; $configuration = new ServerConfiguration( issuer: 'https://shop.example/poppy', domains: ['shop.example'], endpoints: Endpoints::below('https://shop.example/poppy'), directScopes: ['poppy:read', 'poppy:write'], deviceScopes: ['poppy:read'], apis: [Api::openApi('https://shop.example/openapi.json', 'Orders and returns')], ); $server = new PoppyServer( $configuration, $storage, // PoppyStorage — your persistence $replayGuard, // ReplayGuard — atomic set-if-absent, e.g. Redis SET NX or a unique key $clientPolicy, // ClientPolicy — e.g. new ListClientPolicy(block: [...]) $jsonFetcher, // JsonFetcher over a PSR-18 client that does not follow redirects $clock, // PSR-20 $responseFactory, $streamFactory, ); // GET /.well-known/poppy.json $document = new PoppyDocument('Example Shop', 'shop.example', $configuration, $configuration->apis); // GET /.well-known/oauth-authorization-server/poppy $metadata = $server->metadata(); // POST /poppy/token, /poppy/revoke, /poppy/device_authorization $response = $server->tokenEndpoint()->handle($request); // An API endpoint $session = $server->resourceGuard()->authenticate($request, 'https://shop.example/api/orders'); $accountId = $session->requireScopes('poppy:read');
The integration provides the pages a person sees — sign-in and consent for
Direct Sign-In, code entry for Device Sign-In, the account page where Personal
Agents are disconnected — and calls DirectSignIn, DeviceVerification and
AccountDisconnector from them.
Storage on Doctrine DBAL
Storage\Dbal\DbalStorage, DbalReplayGuard and DbalFeedbackSink implement
the storage ports on DBAL 3.10 or 4 — the DBAL both Sylius and TYPO3 run on.
PoppySchema::addTo($schema, $prefix) describes the tables, for a Doctrine
migration or as the source of a TYPO3 ext_tables.sql. The single-use
operations are atomic in the database itself: the replay guard on a primary
key, codes and device approvals on the affected-row count of the statement
that takes them. SymfonyRateLimiter adapts symfony/rate-limiter.
What the integration must get right
- Replay guard: atomic, shared.
claim()must be one set-if-absent operation across every worker. Two operations ("exists?", then "store") let two parallel requests both accept the same assertion. - Storage: two single-use operations.
consumeAuthorizationCode()andconsumeApprovedDeviceAuthorization()must hand a record out at most once under concurrency. - HTTP client: no redirects, address pinning.
PublicAddressGuardchecks the resolved addresses before the request; the HTTP client resolves again. Use a client that refuses private addresses at connect time (Symfony'sNoPrivateNetworkHttpClient, or cURL withCURLOPT_RESOLVE) to close that gap. - Public URLs. Pass the public URL of an endpoint to the verifiers rather than the URL the request arrived at, which behind a TLS-terminating proxy is the internal one.
- Rate limits. Implement
RateLimiter, and rate-limit the device code entry page per client address: user codes are short by design.
Requirements
PHP 8.3 or later with openssl and sodium, a PSR-18 client and PSR-17
factories.
Independence
This is an independent implementation, written from the Personal Agent Protocol specification. It is not affiliated with the authors of the specification.
Licence
MIT. See LICENSE.