Build a PPX-native app in 30 minutes¶
This tutorial walks through making an app PPX-aware from zero. By the end you'll have:
- Requested a scoped grant from a PPX provider,
- Bounced the user through the provider's consent page,
- Minted a grant-scoped token,
- Read the user's preferences under that scope,
- Re-minted the token on return visits without re-prompting,
- Handed the same grant to an AI agent via LangChain or Google ADK so the agent can reason about the user's preferences.
Total code: ~80 lines of TypeScript (or Python) + ~30 lines of agent glue.
Prerequisites: none. Every step below runs against the public demonstration provider at
https://ppx.dev/ppx— no signup, no Docker, no local stack. It is seeded with one subject and serves demonstration data only.Point
baseUrlat your own provider when you have one; nothing in the code changes.
0. Register your app with the provider¶
A PPX provider needs to know what client_id your app uses so grant
requests can be tied to a real grantee. You'll need:
- A
client_id(string) - A
grantee_idDID the provider associates with you - An allowed redirect URL for the consent bounce-back
On the public demo provider this is already done for you: fragrance-demo
and travel-demo are registered, and the examples below use
fragrance-demo. Passing a client_id the provider does not know returns
404 unknown_client — grants are never issued to unregistered grantees.
The examples below use fragrance-demo
That way they run against a freshly seeded provider with no extra
setup. Swap in your own client_id once you have registered one.
Passing a client_id the provider does not know returns
404 unknown client_id: ….
1. Install an SDK¶
The TypeScript client is published, and steps 2–6 use it:
Alpha, tracking a draft spec
Current version 0.1.0-alpha.1. PPX is still a draft
specification, so expect breaking changes between alphas.
The Python packages are not on PyPI yet
pip install ppx-client / ppx-langchain still 404. This affects
step 7 only — steps 2–6 are TypeScript and run today. Until the
Python packages ship, make the same calls directly against the HTTP
binding; every SDK method is a thin wrapper over one documented HTTP
request.
The examples are TypeScript; the Python shape is essentially identical.
2. Request a scoped grant¶
import { PpxClient, consentRedirectUrl } from "@blazing-customs/ppx-client";
const ppx = new PpxClient({ baseUrl: "https://ppx.dev/ppx" });
const { grant_request_id, consent_url } = await ppx.requestGrant({
client_id: "fragrance-demo",
subject_id: "did:example:user-demo-0001", // whose profile you're asking about
purposes: ["recommendation", "explanation"],
allowed_domains: ["fragrance"],
allowed_namespaces: ["fragrance"],
allowed_operations: ["read"],
cross_domain_transfer: "deny",
writeback_policy: "review_required",
requested_duration_days: 30,
});
The provider hasn't given you anything yet — just a request ID the user has to approve.
3. Send the user to the provider's consent page¶
const returnTo = `${window.location.origin}/callback`;
// Don't hardcode the consent host: the consent UI may live on a different
// origin from the API. `POST /v1/consent/request` already told you where
// to send the user.
window.location.href = `${consent_url}?return_to=${encodeURIComponent(returnTo)}`;
The provider takes over: it authenticates the user (Keycloak on the
reference provider), shows them exactly what you asked for, lets them
approve, narrow, or reject. When they're done, the browser comes back
to returnTo with ?grant_request_id=…&decision=approve.
4. Mint a grant-scoped token¶
// In your /callback route
const search = new URLSearchParams(window.location.search);
const id = search.get("grant_request_id")!;
const decision = search.get("decision");
if (decision !== "approve") throw new Error("user declined");
const token = await ppx.mintToken(id);
// → { access_token, token_type: "Bearer", expires_in: 3600, grant_id }
Save token.grant_id to localStorage — you'll reuse it on return
visits to skip the consent flow.
5. Read the user's scoped preferences¶
const profile = await ppx.effectiveProfile(
{
context: { climate: "hot_humid", occasion: "date_night" },
requested_namespaces: ["fragrance"],
},
token.access_token,
);
for (const claim of profile.claims) {
console.log(`${claim.namespace}.${claim.key}`, claim.value, claim.confidence);
}
The provider applies any relevant context modifiers (e.g. climate-bias tweaks) before returning. Claims you didn't ask for — or that fall outside the grant — are silently omitted.
6. Return visits skip the consent flow¶
On the next page load, check localStorage and re-mint directly:
import { PpxGrantRevokedError } from "@blazing-customs/ppx-client";
const cached = localStorage.getItem("fragrance-demo.grant");
if (cached) {
try {
const token = await ppx.refresh("fragrance-demo", cached);
// you're back in business — fetch the profile as above
} catch (err) {
if (err instanceof PpxGrantRevokedError) {
// user revoked at the provider; clear and prompt again
localStorage.removeItem("fragrance-demo.grant");
// → fall back to step 2
} else throw err;
}
}
This mirrors OAuth 2.1 refresh semantics: the grant is the long-lived authorization artifact, the access token is short-lived and derived.
7. Let an AI agent use the grant¶
Two integrations covered — pick whichever matches your stack.
LangChain¶
from ppx_client import PpxClient
from ppx_langchain import ppx_preference_tool
from langchain_openai import ChatOpenAI
ppx = PpxClient("https://ppx.dev/ppx")
tool = ppx_preference_tool(ppx, grant_token=my_grant_token)
llm = ChatOpenAI(model="gpt-4o-mini").bind_tools([tool])
response = llm.invoke(
"Suggest a fragrance for tonight. Use lookup_user_preference to check the user's profile."
)
The LLM calls lookup_user_preference("fragrance"); the call is routed
through the same grant your browser code uses. Every read is scoped.
Google ADK¶
from google.adk.agents import LlmAgent
from google.adk.tools import FunctionTool
from ppx_client import PpxClient
from ppx_google_adk import build_preference_tool, fetch_preference_context
ppx = PpxClient("https://ppx.dev/ppx")
preferences = fetch_preference_context(
ppx, grant_token=my_grant_token,
requested_namespaces=["core", "fragrance"],
context={"climate": "hot_humid"},
)
lookup = build_preference_tool(ppx, grant_token=my_grant_token)
agent = LlmAgent(
name="scent_advisor",
model="gemini-2.0-flash",
instruction=(
"Recommend fragrances. Use the user's preferences below and the "
"lookup_user_preference tool if you need more detail.\n\n" + preferences
),
tools=[FunctionTool(lookup)],
)
You can mix both patterns — seed the agent with baseline context at session start, then let it refine on demand via the tool.
8. Where the boundaries are¶
- Your app never sees claims outside the grant. Keys you didn't list are silently redacted, not rejected.
- The user can revoke at any time. The next
refreshreturns410 Gone, and any read with an already-issued token starts returning403— revocation is immediate, not eventually-consistent. - The user can approve less than you asked for. Always read what you
actually got from the
grantblock in the response rather than assuming you were granted what you requested. - Cross-domain transfer (using data derived in one domain — say fragrance —
in another, say travel) requires explicit
allow_with_reviewin the grant. Anything else is denied at the grant engine. - Writeback (
propose_update) is deny-by-default. Underreview_required, proposed claim updates wait for the user's approval before they take effect.
9. Verify you're spec-compliant¶
Every conforming client should pass the same round-trip tests a conforming provider does. Use the conformance suite:
git clone https://github.com/Blazing-Customs/ppx-spec
pip install -e ppx-spec/conformance
ppx-conformance --provider https://ppx.dev/ppx --level L1
(The conformance suite is not on PyPI — install it from the spec repo.)
Green means you're safe to link against any conforming provider, not just the reference one. Run it in CI.
10. What's next¶
- AG-UI consent streaming — show live explanation text during the
consent flow. See
consent-and-trust.md. - MCP binding — expose the same PPX operations as MCP tools so any
MCP-compatible agent can use them. See
bindings/mcp.md. - A2A binding — call PPX from another agent. See
bindings/a2a.md. - Extension domains — add a new namespace beyond
core/fragrance/travel. Seeextensions/overview.md.
Total time to a working integration: ~30 minutes if you copy-paste, maybe 60 if you write it yourself. Either way, you now have a user-controlled, grant-scoped, auditable preference pipeline backing your app — zero custom profile storage required.