How to Make a LangGraph Agent GDPR-Safe
Use customer data in AI agents without losing control
This is a practical walkthrough for putting a minimal LangGraph agent behind the Talon LLM gateway.
The target use case is simple: a customer-support agent receives a billing question that contains personal data. We want the agent to answer, but we do not want the LangGraph app to call OpenAI directly with raw customer data and no audit trail.
The final architecture:
LangGraph → Talon Gateway → OpenAIThe main application change is this:
llm = ChatOpenAI(
model="gpt-4o-mini",
base_url="http://localhost:18080/v1/proxy/openai/v1",
api_key="talon-gw-langgraph-demo",
)The LangGraph app uses a Talon caller key. Talon stores the real OpenAI key, applies policy, forwards the request, scans the response, and writes signed evidence.
The companion notebook is here: Colab notebook
What we are building
We will run a minimal LangGraph workflow:
START → support_agent → ENDNo tools yet. No human approval. No memory.
That is intentional. The first thing to govern is the LLM boundary. Tool governance comes later.
The test input contains an email and an IBAN:
My email is anna.kowalska@example.com and my IBAN is DE89370400440532013000.
I was charged twice for order ORD-18422. Can you help?What we want Talon to prove:
- Gateway receives the LangGraph request.
- Caller is identified as `langgraph-support-agent`.
- Model is restricted to `gpt-4o-mini`.
- Email address is detected.
- IBAN is detected.
- Input is redacted before the upstream provider call.
- Response is scanned.
- Evidence is written.
- Evidence signature verifies successfully.
Dependencies
Python dependencies first:
python -m pip install -q --upgrade pip
python -m pip install -q langgraph langchain-openai langchain-core openai requests pyyamlTalon also needs to be installed. One practical issue: do not use Ubuntu’s default golang-go package in Colab. It can be too old for current Talon builds.
The notebook tries three install paths:
Use an existing
talonbinary if available.Download a GitHub Linux AMD64 release binary.
Install modern Go from
go.devand run:
GOBIN=/usr/local/bin \
GONOSUMDB=github.com/dativo-io/talon \
go install github.com/dativo-io/talon/cmd/talon@latestThat avoids the common failure where Colab installs Go 1.18 and the Talon module requires a newer Go version.
Generate runtime keys
For this demo, I generate ephemeral Talon keys inside the notebook:
import os
import secrets
from pathlib import Path
project_dir = Path("/content/talon-langgraph-demo")
project_dir.mkdir(parents=True, exist_ok=True)
os.environ.setdefault("TALON_DATA_DIR", str(project_dir / ".talon"))
os.environ.setdefault("TALON_SECRETS_KEY", secrets.token_hex(32))
os.environ.setdefault("TALON_SIGNING_KEY", secrets.token_hex(32))
os.environ.setdefault("TALON_ADMIN_KEY", secrets.token_urlsafe(32))
os.environ.setdefault("TALON_PORT", "18080")For production, these should come from your secret manager. For a notebook, ephemeral keys are fine.
The OpenAI key is read from Colab Secrets if available:
from google.colab import userdata
os.environ["OPENAI_API_KEY"] = userdata.get("OPENAI_API_KEY")or entered manually with getpass.
Create agent.talon.yaml
This file describes the agent policy.
For this first example, keep it concise:
agent:
name: langgraph-support-agent
version: "1.0.0"
description: GDPR-safe LangGraph support agent demo
capabilities:
allowed_tools: []
policies:
data_classification:
input_scan: true
output_scan: true
redact_pii: true
block_on_pii: false
cost_limits:
per_request: 0.10
daily: 10.00
monthly: 200.00
audit:
log_level: detailed
retention_days: 30
log_prompts: false
log_responses: false
compliance:
frameworks:
- gdpr
- eu-ai-act
data_residency: eu
risk_level: lowCreate talon.config.yaml
This file configures the gateway.
gateway:
mode: enforce
providers:
openai:
enabled: true
base_url: "https://api.openai.com"
secret_name: "openai-api-key"
allowed_models:
- "gpt-4o-mini"
default_policy:
default_pii_action: "redact"
response_pii_action: "warn"
max_daily_cost: 10.00
max_monthly_cost: 200.00
allowed_models:
- "gpt-4o-mini"
callers:
- name: "langgraph-support-agent"
tenant_key: "talon-gw-langgraph-demo"
tenant_id: "demo"
allowed_providers:
- "openai"
policy_overrides:
pii_action: "redact"
response_pii_action: "warn"
allowed_models:
- "gpt-4o-mini"
max_daily_cost: 10.00
max_monthly_cost: 200.00The route we use later is:
/v1/proxy/openai/v1/chat/completionsTalon extracts openai from that path, looks it up under gateway.providers.openai, and checks that it is enabled.
Store the OpenAI key in Talon’s vault
The LangGraph app should not use the real OpenAI key.
Store the upstream provider key in Talon:
talon secrets set openai-api-key "$OPENAI_API_KEY"Then the application uses the Talon caller key:
talon-gw-langgraph-demoThat key maps to:
tenant_id: demo
name: langgraph-support-agentThis gives you caller-level evidence and budget attribution.
Start Talon Gateway
In Colab, I use port 18080 to avoid collisions with common notebook services.
talon serve \
--gateway \
--gateway-config /content/talon-langgraph-demo/talon.config.yaml \
--host 127.0.0.1 \
--port 18080 \
--log-level infoThe OpenAI-compatible base URL becomes:
http://localhost:18080/v1/proxy/openai/v1The full chat completions route is:
http://localhost:18080/v1/proxy/openai/v1/chat/completionsSmoke-test the gateway before LangGraph
Before involving LangGraph, test the gateway directly:
import requests
base_url = "http://localhost:18080/v1/proxy/openai/v1"
url = f"{base_url}/chat/completions"
headers = {
"Authorization": "Bearer talon-gw-langgraph-demo",
"Content-Type": "application/json",
}
payload = {
"model": "gpt-4o-mini",
"messages": [
{
"role": "user",
"content": (
"My email is anna.kowalska@example.com and my IBAN is "
"DE89370400440532013000. I was charged twice for order ORD-18422. "
"Can you help?"
),
}
],
"max_tokens": 120,
}
r = requests.post(url, headers=headers, json=payload, timeout=60)
print(r.status_code)
print(r.text[:1500])Expected result: 200.
Common failures:
- `404`: wrong route. Check `/v1/proxy/openai/v1/chat/completions`.
- `unknown or disabled provider`: missing `enabled: true` under `gateway.providers.openai`.
- `401` or `403`: wrong caller key, or missing admin key for admin endpoints.
- Upstream auth error: OpenAI key was not stored in Talon’s vault, or `secret_name` does not match.
- Schema validation error: invalid `agent.talon.yaml`; commonly `audit.log_level`.
Build the LangGraph agent
The LangGraph agent is just one node:
from typing import Annotated, TypedDict
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_openai import ChatOpenAI
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
class SupportState(TypedDict):
messages: Annotated[list, add_messages]
llm = ChatOpenAI(
model="gpt-4o-mini",
base_url="http://localhost:18080/v1/proxy/openai/v1",
api_key="talon-gw-langgraph-demo",
temperature=0,
)
def support_agent(state: SupportState):
system = SystemMessage(
content=(
"You are a customer support assistant for a SaaS company. "
"Help with billing questions. "
"Do not repeat raw personal data such as email addresses or IBANs. "
"Do not claim you performed a refund. "
"Say that a support teammate can verify the order and duplicate charge."
)
)
response = llm.invoke([system] + state["messages"])
return {"messages": [response]}
workflow = StateGraph(SupportState)
workflow.add_node("support_agent", support_agent)
workflow.add_edge(START, "support_agent")
workflow.add_edge("support_agent", END)
graph = workflow.compile()The Talon-specific part is only:
base_url="http://localhost:18080/v1/proxy/openai/v1"
api_key="talon-gw-langgraph-demo"Everything else is standard LangGraph/LangChain code.
Run the PII-bearing request
result = graph.invoke({
"messages": [
HumanMessage(
content=(
"My email is anna.kowalska@example.com and my IBAN is "
"DE89370400440532013000. I was charged twice for order "
"ORD-18422. Can you help?"
)
)
]
})
print(result["messages"][-1].content)At this point, the important thing is not whether the answer is amazing. This is a governance test, not a support automation benchmark.
The important question is: what did Talon record?
Inspect evidence
List records:
talon audit list --limit 10You should see records with IDs like:
gw_cd438803-4d0Then inspect one:
talon audit show gw_cd438803-4d0A useful record should show:
Evidence: gw_cd438803-4d0
Tenant / Agent: demo / langgraph-support-agent
Invocation: gateway
HMAC Signature: ✓ VALID
Policy Decision
Allowed: true
Action: allow
Classification
Input Tier: 2
Output Tier: 2
PII Detected: email, iban, person, ...
PII Redacted: true
Execution
Model: gpt-4o-mini
Cost: €< 0.0001
Duration: 1267ms
Tokens: in=106 out=62
Tools Called: (none)This is the useful part of the demo.
The LLM call is no longer invisible. It has a tenant, an agent identity, a model, a PII classification, a redaction result, cost, latency, token counts, and a signature.
Verify the evidence signature
Run:
talon audit verify gw_cd438803-4d0Expected:
✓ Evidence gw_cd438803-4d0: signature VALIDThis proves the evidence record has not been modified since Talon created it.
For a technical buyer, this is materially different from application logs. Logs are useful for debugging. Signed evidence is useful for governance and later review.
Understand the output PII warning
In the audit explanation, you may see something like:
POLICY_DENIED_PII_OUTPUTIn this demo, that does not necessarily mean the request was blocked.
Check the final policy fields:
Allowed: true
Action: allowThe reason is this config:
response_pii_action: "warn"So Talon records output PII findings but still allows the response.
For the first demo, this is useful. It proves response scanning happened without making the notebook fail.
For production, pick the behavior explicitly:
response_pii_action: "warn" # record only
response_pii_action: "redact" # mask before returning
response_pii_action: "block" # deny responseOne product note: the compact audit-list label can look confusing here. It would be clearer if the list view showed something like:
ALLOWED_WITH_OUTPUT_PII_WARNINGwhen the final action is allow but an output PII finding was recorded.
Test model restriction
The config only allows:
allowed_models:
- "gpt-4o-mini"Test a different model:
bad_payload = {
"model": "gpt-4o",
"messages": [{"role": "user", "content": "Say hello."}],
"max_tokens": 20,
}
r = requests.post(url, headers=headers, json=bad_payload, timeout=60)
print(r.status_code)
print(r.text[:2000])Expected: a denial or policy error.
This is a basic but important control. You do not want every application instance choosing arbitrary models. Model selection affects cost, vendor review, latency, and sometimes data-residency posture.
What this gives you
This pattern gives a small team a practical governance boundary with a small app change.
What this adds:
- PII scan and redaction for raw customer data in prompts.
- Provider key isolation: the app calls Talon, not OpenAI directly.
- Model allowlist to prevent unapproved model usage.
- Caller identity mapped to tenant and agent.
- Signed evidence records for every governed call.
- Cost and token recording.
- Output scanning for response leakage risk.
Summary
LangGraph makes it easy to add more autonomy: tools, loops, memory, retries, human approval, and long-running workflows.
Those are exactly the places where governance becomes harder.
Starting with the model boundary is the lowest-friction control:
change base_url + api_keyYou can keep the LangGraph workflow mostly unchanged and still get:
policy + redaction + model restriction + evidenceThat is the right first step before giving the agent tools that can read or write business data.
The next post will build on this and govern tool calls: safe tools, forbidden tools, dry runs, and blocking destructive actions before the agent can execute them.

