Codelab: Sanctions check (Simple)
Estimated time: 15 min
This codelab shows how to create agents and perform Private Set Intersection (PSI) between them.
This integration is using a simple and clean PrivacyAgent method.
Info
If you need a more advanced integration with greater flexibility, take a look at the lower-level integration method in the A2A + AP3 middleware.
Here is the use case we implement here:
- Consumer (consumer) has a customer list to check for sanctioned individuals. It runs a
PrivacyAgentand initiates PSI - Provider (provider) has a sanction list and provides privacy preserving lookups as a service. It runs own instance of
PrivacyAgentand answers to PSI requests
What is PrivacyAgent?
PrivacyAgent is a ready-to-run A2A server with AP3 out of the box.
It handles most of the work under the hood for you:
- Serves an AgentCard with AP3 extension and fields
- Signs and verifies AP3 directives
- Runs a privacy-preserving function (
PSIOperationin this example)
What you’ll build
As the result, you’ll have up and running:
- Consumer agent server at
http://localhost:10002 - Provider agent server at
http://localhost:10003 - Two SQLite Databases:
- Customer list
- Sanction list
A completed PSI (Private Set Intersection) operation returns match found / no match as the outcome.
Prerequisites
- Python 3.11–3.13
uv
Step 1: Create the project layout
From the repo root:
mkdir -p my-privacy-agents/consumer my-privacy-agents/provider
You’ll end up with:
my-privacy-agents/
consumer/
provider/
1.1 Root pyproject.toml (uv workspace)
Add a workspace manifest at the repo root (next to my-privacy-agents/):
[project]
name = "my-privacy-agents"
version = "0.1.0"
requires-python = ">=3.11,<3.14"
[tool.uv.workspace]
members = [
"my-privacy-agents/consumer",
"my-privacy-agents/provider",
]
Step 2: Add pyproject.toml for both agents
Create my-privacy-agents/consumer/pyproject.toml:
[project]
name = "ap3-privacy-consumer"
version = "0.1.0"
description = "AP3 PrivacyAgent PSI consumer"
requires-python = ">=3.11,<3.14"
dependencies = [
"a2a-sdk[http-server]>=1.0.3",
"ap3-functions>=1.2.2",
"ap3[a2a]>=1.2.2",
"httpx>=0.28.1",
"pydantic>=2.11.4",
"starlette>=0.46.2",
"uvicorn>=0.34.2",
]
[tool.hatch.build.targets.wheel]
packages = ["."]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Create my-privacy-agents/provider/pyproject.toml
[project]
name = "ap3-privacy-provider"
version = "0.1.0"
description = "AP3 PrivacyAgent PSI provider"
requires-python = ">=3.11,<3.14"
dependencies = [
"a2a-sdk[http-server]>=1.0.3",
"ap3-functions>=1.2.2",
"ap3[a2a]>=1.2.2",
"httpx>=0.28.1",
"pydantic>=2.11.4",
"starlette>=0.46.2",
"uvicorn>=0.34.2",
]
[tool.hatch.build.targets.wheel]
packages = ["."]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
Step 3: Add SQLite DB helpers
3.1 Provider DB (provider/db.py)
Create my-privacy-agents/provider/db.py:
from __future__ import annotations
import sqlite3
from pathlib import Path
from typing import Iterable
def ensure_schema(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS sanction_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
row TEXT NOT NULL UNIQUE
)
"""
)
conn.commit()
def seed_if_empty(conn: sqlite3.Connection, rows: Iterable[str]) -> None:
(count,) = conn.execute("SELECT COUNT(*) FROM sanction_entries").fetchone() or (0,)
if count:
return
conn.executemany("INSERT OR IGNORE INTO sanction_entries(row) VALUES (?)", [(r,) for r in rows])
conn.commit()
def fetch_sanction_list(db_path: Path) -> list[str]:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
try:
ensure_schema(conn)
seed_if_empty(
conn,
[
"Joe Quimby,S4928374,213 Church St",
"C. Montgomery Burns,S9283746,1000 Mammon Lane",
"Bob Johnson,C3456789,789 Pine Street",
],
)
cur = conn.execute("SELECT row FROM sanction_entries ORDER BY id ASC")
return [r for (r,) in cur.fetchall()]
finally:
conn.close()
3.2 consumer DB (consumer/db.py)
Create my-privacy-agents/consumer/db.py:
from __future__ import annotations
import sqlite3
from pathlib import Path
from typing import Iterable
def ensure_schema(conn: sqlite3.Connection) -> None:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS customer_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
row TEXT NOT NULL UNIQUE
)
"""
)
conn.commit()
def seed_if_empty(conn: sqlite3.Connection, rows: Iterable[str]) -> None:
(count,) = conn.execute("SELECT COUNT(*) FROM customer_entries").fetchone() or (0,)
if count:
return
conn.executemany("INSERT OR IGNORE INTO customer_entries(row) VALUES (?)", [(r,) for r in rows])
conn.commit()
def fetch_customer_list(db_path: Path) -> list[str]:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
try:
ensure_schema(conn)
seed_if_empty(conn, ["Joe Quimby,S4928374,213 Church St"])
cur = conn.execute("SELECT row FROM customer_entries ORDER BY id ASC")
return [r for (r,) in cur.fetchall()]
finally:
conn.close()
def fetch_default_customer(db_path: Path) -> str:
rows = fetch_customer_list(db_path)
if not rows:
raise RuntimeError("No customers in database")
return rows[0]
Step 4: Create the Provider PrivacyAgent
We’ll go step by step to create the agent. See 5.6 if you need the complete resulting __main__.py .
4.1 Load provider data from SQLite
Add the imports at the top of my-privacy-agents/provider/__main__.py:
from pathlib import Path
from db import fetch_sanction_list
…and seed/load the DB at the start of main():
def main() -> None:
fetch_sanction_list(Path(__file__).parent / "data" / "provider.db")
...
On the first run the helper will:
- create
my-privacy-agents/provider/data/provider.db - create a
sanction_entriestable - seeds three default row for the demo
4.2 Generate an AP3 identity keypair
AP3 directives are signed. The provider needs a keypair so it can:
- publish its public key in the AgentCard
- verify signatures from peers
Info
You should persist these keys in actual deployments. For simplicity, this codelab generates a new keypair each run.
private_key, public_key = generate_keypair()
4.3 Define a Commitment
The provider advertises a commitment describing its data:
- data structure:
BLACKLIST - format: structured
- entry/field counts (metadata only)
commitment = CommitmentMetadata(
agent_id="company_b_sanctions",
commitment_id="sanctions_v1",
data_structure=DataStructure.BLACKLIST,
data_format=DataFormat.STRUCTURED,
entry_count=len(sanction_list),
field_count=3,
estimated_size_mb=0.001,
last_updated=datetime.now(timezone.utc).isoformat(),
data_freshness=DataFreshness.DAILY,
industry=Industry.FINANCE,
)
4.4 Construct the PrivacyAgent
Key fields:
card_url: where peers fetch your AgentCard (must match the reachable URL)role="ap3_receiver": this agent answers PSI requestsoperation=PSIOperation(): the AP3 PSI implementationreceiver_config_provider: provides receiver-only configuration (the sanction list)
agent = PrivacyAgent(
name="PSI provider",
description="Holds a sanction list; performs PSI on request",
card_url=f"http://localhost:{port}",
host="localhost",
port=port,
role="ap3_receiver",
operation=PSIOperation(),
commitment=commitment,
private_key=private_key,
public_key=public_key,
receiver_config_provider=lambda: {"sanction_list": sanction_list},
# Dev-only: consumer advertises a loopback card URL. Remove in production —
# the SSRF guard will otherwise refuse the consumer's localhost initiator URL.
allow_private_initiator_urls=True,
)
4.5 Serve and wait for PSI requests
async with agent.serving(): starts the HTTP server and serves the AgentCard + JSON-RPC.
await agent.wait() keeps the process alive.
4.6 Final file:
Here is the resulting my-privacy-agents/provider/__main__.py:
from __future__ import annotations
import argparse
import asyncio
from datetime import datetime, timezone
from pathlib import Path
from ap3.a2a import PrivacyAgent
from ap3.signing.primitives import generate_keypair
from ap3.types import CommitmentMetadata, DataFormat, DataFreshness, DataStructure, Industry
from ap3_functions import PSIOperation
from db import fetch_sanction_list
async def main(port: int) -> None:
sanction_list = fetch_sanction_list(Path(__file__).parent / "data" / "provider.db")
private_key, public_key = generate_keypair()
commitment = CommitmentMetadata(
agent_id="company_b_sanctions",
commitment_id="sanctions_v1",
data_structure=DataStructure.BLACKLIST,
data_format=DataFormat.STRUCTURED,
entry_count=len(sanction_list),
field_count=3,
estimated_size_mb=0.001,
last_updated=datetime.now(timezone.utc).isoformat(),
data_freshness=DataFreshness.DAILY,
industry=Industry.FINANCE,
)
agent = PrivacyAgent(
name="PSI provider",
description="Holds a sanction list; performs PSI on request",
card_url=f"http://localhost:{port}",
host="localhost",
port=port,
role="ap3_receiver",
operation=PSIOperation(),
commitment=commitment,
private_key=private_key,
public_key=public_key,
receiver_config_provider=lambda: {"sanction_list": sanction_list},
# Dev-only: consumer advertises a loopback card URL. Remove in production.
allow_private_initiator_urls=True,
)
async with agent.serving():
print(f"[provider] serving on http://localhost:{port}")
print(f"[provider] sanction list entries: {len(sanction_list)}")
await agent.wait()
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=10003)
args = parser.parse_args()
asyncio.run(main(args.port))
Step 5: Create the consumer PrivacyAgent (Consumer)
In AP3 PSI, the consumer:
- holds the private query (here: the “customer row”)
- publishes its AgentCard + public key so the provider can verify signatures
- starts a PSI run via
run_intent(...)
See 5.6 if you need the complete resulting __main__.py .
5.1 Load consumer data from SQLite
Add the imports at the top of my-privacy-agents/consumer/__main__.py:
from pathlib import Path
from db import fetch_customer_list
…and seed/load the DB at the start of main():
def main() -> None:
fetch_customer_list(Path(__file__).parent / "data" / "consumer.db")
...
On the first run the helper will:
- create
my-privacy-agents/consumer/data/consumer.db - create a
customer_entriestable - seeds a default row for the demo
We’ll use the first row as the input to PSI.
5.2 Generate an AP3 identity keypair
Same reasoning as the provider: the consumer needs keys to sign directives and to publish its AP3 public key in its AgentCard.
private_key, public_key = generate_keypair()
5.3 Define a Commitment
The consumer advertises a CUSTOMER_LIST commitment describing its dataset shape
and counts. This is metadata only: it does not reveal actual customer rows.
commitment = CommitmentMetadata(
agent_id="company_a_customers",
commitment_id="customers_v1",
data_structure=DataStructure.CUSTOMER_LIST,
data_format=DataFormat.STRUCTURED,
entry_count=len(customer_list),
field_count=3,
estimated_size_mb=0.001,
last_updated=datetime.now(timezone.utc).isoformat(),
data_freshness=DataFreshness.REAL_TIME,
industry=Industry.FINANCE,
)
5.4 Construct the consumer PrivacyAgent
Key fields:
role="ap3_initiator"operation=PSIOperation()card_url/host/portso peers can fetch the card during the run
agent = PrivacyAgent(
name="PSI consumer",
description="Checks customers against partner sanction lists",
card_url=f"http://localhost:{port}",
host="localhost",
port=port,
role="ap3_initiator",
operation=PSIOperation(),
commitment=commitment,
private_key=private_key,
public_key=public_key,
)
5.5 Run PSI with run_intent(...)
Once the consumer is serving, it can:
- fetch the provider’s AgentCard (to verify AP3 public key)
- run the full PSI round-trip
- return a
PrivacyResultDirectivethat includes the result metadata (match / no match) - exits after completion
result = await agent.run_intent(
peer_url=provider_url,
inputs={"customer_data": customer},
)
5.6 Final file: __main__.py
Here is the resulting my-privacy-agents/consumer/__main__.py:
from __future__ import annotations
import argparse
import asyncio
from datetime import datetime, timezone
from pathlib import Path
from ap3.a2a import PrivacyAgent
from ap3.signing.primitives import generate_keypair
from ap3.types import CommitmentMetadata, DataFormat, DataFreshness, DataStructure, Industry
from ap3_functions import PSIOperation
from db import fetch_customer_list, fetch_default_customer
async def main(port: int, provider_url: str) -> None:
db_path = Path(__file__).parent / "data" / "consumer.db"
customer_list = fetch_customer_list(db_path)
private_key, public_key = generate_keypair()
commitment = CommitmentMetadata(
agent_id="company_a_customers",
commitment_id="customers_v1",
data_structure=DataStructure.CUSTOMER_LIST,
data_format=DataFormat.STRUCTURED,
entry_count=len(customer_list),
field_count=3,
estimated_size_mb=0.001,
last_updated=datetime.now(timezone.utc).isoformat(),
data_freshness=DataFreshness.REAL_TIME,
industry=Industry.FINANCE,
)
agent = PrivacyAgent(
name="PSI consumer",
description="Checks customers against partner sanction lists",
card_url=f"http://localhost:{port}",
host="localhost",
port=port,
role="ap3_initiator",
operation=PSIOperation(),
commitment=commitment,
private_key=private_key,
public_key=public_key,
)
async with agent.serving():
customer = fetch_default_customer(db_path)
print(f"[consumer] serving on http://localhost:{port}")
print(f"[consumer] checking '{customer}' against {provider_url}")
result = await agent.run_intent(
peer_url=provider_url,
inputs={"customer_data": customer},
)
print()
print("=" * 72)
print("PSI result:", result.result_data.metadata["description"])
print("=" * 72)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=10002)
parser.add_argument("--provider", default="http://localhost:10003")
args = parser.parse_args()
asyncio.run(main(args.port, args.provider))
Step 6: Run it end-to-end
From the repo root (one level above my-privacy-agents/), in two separate terminals:
# Terminal 1 (provider)
uv lock
uv sync --package ap3-privacy-provider
uv run --package ap3-privacy-provider python my-privacy-agents/provider/__main__.py
# Terminal 2 (consumer)
uv lock
uv sync --package ap3-privacy-consumer
uv run --package ap3-privacy-consumer python my-privacy-agents/consumer/__main__.py
Expected outcome:
PSI result: {"is_match":true}
If you see this in the client output, congratulations! The private computation works.
Step 7: Try the non-match
From repo root:
# Replace Consumer customer with a non-matching row
sqlite3 my-privacy-agents/consumer/data/consumer.db "DELETE FROM customer_entries;"
sqlite3 my-privacy-agents/consumer/data/consumer.db \
"INSERT INTO customer_entries(row) VALUES ('Alice Nobody,X0000000,1 Nowhere St');"
Rerun the consumer command.
You should see:
PSI result: {"is_match":false}
Correctly indicating that Alice Nobody... is not in Provider’s sanction list.
Recap
Congratulations! You have just built a minimal AP3 PSI system using PrivacyAgent.
It includes:
- provider that hosts an AP3-capable A2A server and loads its sanction list from SQLite
- consumer that hosts an AP3-capable A2A server, loads customer data from SQLite, and runs the private function
If you want to integrate AP3 into an existing A2A agent without replacing your server, see the A2A + AP3 middleware codelab.
Happy computing!