Poppy agents
Direct credentials, live Auth0 proxy admission, and dedicated web agent forwarders.
Poppy agents call the same /v1 procedures as every other caller, with Authorization: DPoP and a proof, and discover
them through GET /poppy/openapi.json, an OpenAPI 3.1.1 view of the contract filtered to the procedures Poppy callers may
use.
The Auth0 proxy issues credentials and answers introspection. Direct resource traffic goes to the API or MCP host. The
Auth0 proxy’s poppy.json advertises ${SHOP_API_RESOURCE}/poppy/conversations and the OpenAPI description at
${SHOP_API_RESOURCE}/poppy/openapi.json. The API runs Fern and the hub callbacks (/internal/conversations/{id}/turn),
preserves cursors and checks live session and lifecycle authority every thirty seconds on SSE.
For the relationship between a Session, an account grant and its credentials, read the Poppy overview. Independent invited agents obtain their delegated browser session through a separate approval and sign-in flow.
Signed-in REST
CTE uses the shared Auth0 proxy client. The Action allows exactly the company, API and canonical MCP audiences.
The API token has act.poppy_sid, act.poppy_gid and act.poppy_jkt. Its sub is the account.
Signed-out REST
Issuer containment: at+jwt verifies only against Auth0 keys; poppy-session+jwt verifies only against Auth0 proxy
keys, even when their issuer strings match. Proxy Auth0 tokens must claim signed_in: false and carry no account
scopes. They reach only catalog and cart operations. An Auth0 proxy key cannot mint account authority.
MCP
Signed-in subjects are independently verified for the MCP audience and must match the OBO account and Auth0 proxy actor
chain. Signed-out subjects require the dedicated mcp_agent_forwarder bearer. There is no shop-domain MCP alias. The MCP
transport verifies the Session before exchanging or passing on credentials.
Web agent forwarder
The webshop always uses dedicated agent-forwarder credentials for agent browser calls. The web agent forwarder never uses a customer or storefront token. Invited-agent contexts are bound to the forwarded-agent bearer, last at most 60 seconds, and retain the original immutable delegation snapshot. Every request checks the live connection, identity, admission row, lifecycle and logout cutoff. Read-only agents cannot check out or use human-only routes.
Signed-in channels share account:<sub>; signed-out channels share poppy:<sid>. Writes introspect live. Reads
may use a hashed cache entry for at most five seconds. Proxy Auth0 timeouts or invalid responses fail closed with 503.
Errors are the API’s single application/problem+json body with an error member (spec 7.13), and DPoP challenges end with algs="ES256". Proofs bind the configured public API URL, never an
internal service-binding URL. Nonces accept the current and previous five-minute windows. Replay protection is shared
by every isolate: an accepted jti is a row in the D1 table dpop_proofs, inserted once and deleted by the hourly
clean_dpop_proofs job after it expires. A Session may hold 10,000 live proofs; past that, only that Session is refused.
The webshop /api/v1 proxy does not forward DPoP: direct Poppy REST clients must use the API origin.
Effective authority
A request acts with the Poppy permissions the token carries that the Session still grants. The token’s scopes
are the Auth0 API scopes it was issued with, which map back to poppy:read, poppy:write and checkout; the Session’s
come from Auth0 proxy introspection. A poppy:read token on a read/write Session cannot cancel an order: REST answers 403
insufficient_scope naming poppy:write, the MCP cancelOrder tool answers the same, and the Company Agent raises an
authorization event. The same intersection applies to the MCP server, where the Session is read before OBO.
A token is read as it was issued. A signed-out token for a Session that has since signed in, and a signed-in token whose
Session has since signed out, both act signed out: catalog and cart work on the Session cart, and an account action
answers 403 sign_in_required. A Session that ended is invalid_token.
Every token failure is challenged in one scheme chosen before the token is verified: DPoP for Session Tokens, the
DPoP scheme and the Poppy paths, Bearer otherwise. A missing token is invalid_token. A Bearer Session Token is not a
credential outside MCP. A request over its Session or client budget gets 429 with a numeric Retry-After, on conversations
too; the domain message limit adds RateLimit and RateLimit-Policy.
Conversation streams
GET /poppy/conversations/{id}/events is one URL for a JSON page or, with Accept: text/event-stream, a stream of oRPC’s
native event iterator. Ownership, cursor and authority errors are thrown before the first byte, so they are ordinary
problems with status codes, never inside a 200 stream. Each event is written as id: evt_... and data: {...} with the
default event type; Last-Event-ID replaces cursor on reconnect, and a comment keeps the connection open every 15
seconds. The stream ends by returning (oRPC writes a data-less event: close that an SSE parser does not dispatch) when
the client leaves, the conversation closes, the stream is ten minutes old or the reader lost access. A reader that lost
access gets no event: error (the spec’s event set has none): its reconnect receives the normal 401 or 403 challenge.
text-delta events are not sent. Request bodies on conversation writes are refused above 64 KiB.
Conversation authority
Every authenticated conversation request (start with a parent, send, JSON read, SSE connect, handoff and close) makes its token the one the Company Agent acts with, so an agent that switches to a narrower token narrows what Fern may do next. An open stream stays bound to the token that opened it. Its thirty-second liveness check introspects that token’s Session, it never changes Fern’s authority, and a newer, narrower token elsewhere does not close it.
Message retries
The first response to a message ID is stored with the ID (response_status and response_body on
conversation_message_ids, written once) and a retry with the same content returns it unchanged. That holds after Fern
has replied, after the conversation closed, after a restart, from another Session of the same User and client, and for
a wait read, which returns the events, cursor and has_more as first sent, even when events were pruned since and the cursor would now be refused with 410. Replay records live exactly as long as their conversation. A POST read response carries at most
256 KiB of events; the rest follows with has_more: true. A retry is checked for ownership and sign-in again, so a
signed-out token never gets a replay, and it does not count against the rate limits. A retry whose first attempt stored
nothing builds the response, stores it, and every later attempt agrees. A handoff request answers 202 queued and adds the
queued and idle state events with Fern’s explanation, since nobody from the team can join.
Local verification
The webshop suite e2e/config/poppy.ts runs the poppy-company, poppy-direct and conversations-api specs. Its launcher starts the
storefront and the Shop API in one process with the API’s local issuer, then the Auth0 proxy and the MCP server, with
SHOP_E2E_POPPY_DIRECT=1, one ephemeral introspection secret and the direct resources advertised. Local Worker and
agent metadata URLs allow loopback HTTP explicitly; deployed resources keep their HTTPS requirements. The local issuer
verifies Auth0 proxy subject tokens through the shipped Action and keeps the original personal-agent actor below the
exchanging MCP and Auth0 proxy clients. The company scenario uses separate API and conversation credentials because each
resource has its own audience.
Run from the repository root:
E2E_SUITE=poppy pnpm --filter @retail-demo/webshop exec e2e run
E2E_SUITE=shop pnpm --filter @retail-demo/webshop exec e2e run
pnpm --filter @retail-demo/proxy-auth0 exec e2e run
The root e2e.config.ts selects the suite while keeping paths relative to the webshop. The Poppy suite is also the last step of the webshop’s pnpm --filter @retail-demo/webshop e2e chain. With
SHOP_E2E_POPPY_CONTRACT=1, poppy-direct.e2e.ts also checks that the Auth0 proxy answers 404 for the retired
/api/poppy/v1/* and /mcp paths.