OGuardAI
GuidesCase Studies

German Customer Support

How a support team uses ChatGPT to draft formal German replies without sending customer data to OpenAI

How a support team uses ChatGPT to draft formal German replies without sending customer data to OpenAI.


Note: This scenario relies on detecting the customer name. Person detection requires the Python NER sidecar (GUARDAI_DETECTOR_URL, with detector.mode: both or advanced); in builtin-only mode names are not detected (email, phone, order number, and customer number are still detected via regex). The shipped german-support policy already lists person under detection.required_for, so a request fails closed rather than passing the name through when the sidecar did not run. A name the sidecar scores below its confidence threshold is still not returned, so this is a data-minimization control, not an absolute guarantee. See Detector Capabilities.

The Situation

A consumer electronics company runs a support team in Munich. Agents handle 400 tickets per day in German. Management wants to use ChatGPT to draft replies: faster turnaround, consistent tone, fewer errors.

The pilot is killed in legal review. The problem: every customer email contains a name, email address, phone number, and often an order number. Sending these to OpenAI violates the company's data processing agreements and GDPR posture. The legal team's position is clear: no customer PII may leave company infrastructure to reach a third-party model provider.

The engineering team tries naive masking: replace names with [NAME] and emails with [EMAIL]. ChatGPT generates responses like "Sehr geehrter [NAME]", grammatically wrong for female customers (should be "Sehr geehrte") and missing the formal title "Frau" entirely. The output is unusable without manual correction, which defeats the purpose.


The Solution

OGuardAI is deployed as a Docker container alongside the support tool backend. It sits between the agent's application and the OpenAI API. No changes to the support tool UI. The backend calls /v1/transform before the model and /v1/rehydrate after it, which is the form the walkthrough below shows because it makes both boundaries visible. A team that would rather not touch the call sites can instead point the OpenAI base_url at the OpenAI-compatible proxy, which performs the same two steps in-line.


Pipeline Overview

Step-by-Step: A Real Ticket

A customer named Erika Mustermann writes in about a delayed refund.

Step 1: Customer Message Arrives

The support agent's tool receives the customer email:

Sehr geehrtes Support-Team,

mein Name ist Erika Mustermann und meine E-Mail ist erika.mustermann@example.com.
Ich warte seit 4 Tagen auf die Rueckzahlung fuer Bestellung B-2026-4471.
Bitte informieren Sie mich ueber den Status.

Mit freundlichen Gruessen,
Erika Mustermann

Step 2: OGuardAI Transforms the Message

The agent clicks "Draft Reply." The backend sends the message to OGuardAI.

curl -X POST http://localhost:3000/v1/transform \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $GUARDAI_API_KEY" \
  -d '{
    "input": "Sehr geehrtes Support-Team,\n\nmein Name ist Erika Mustermann und meine E-Mail ist erika.mustermann@example.com.\nIch warte seit 4 Tagen auf die Rueckzahlung fuer Bestellung B-2026-4471.\nBitte informieren Sie mich ueber den Status.\n\nMit freundlichen Gruessen,\nErika Mustermann",
    "policy": "german-support"
  }'

OGuardAI detects three entities and replaces them with semantic tokens:

Original ValueEntity TypeTokenMetadata
Erika Mustermannperson{{person:p_001:ad4f97591c16}}gender: female, formality: formal, language: de
erika.mustermann@example.comemail{{email:e_001:1bcaef1a4aff}}belongs_to: p_001
B-2026-4471order{{order:o_001:57e07d900ca4}}--

The transform response contains the safe text that will go to OpenAI:

Sehr geehrtes Support-Team,

mein Name ist `{{person:p_001:ad4f97591c16}}` und meine E-Mail ist `{{email:e_001:1bcaef1a4aff}}`.
Ich warte seit 4 Tagen auf die Rueckzahlung fuer Bestellung `{{order:o_001:57e07d900ca4}}`.
Bitte informieren Sie mich ueber den Status.

Mit freundlichen Gruessen,
`{{person:p_001:ad4f97591c16}}`

The response also includes an encrypted session blob and entity context metadata.

Step 3: ChatGPT Generates a Reply

The backend forwards the tokenized message to OpenAI with the entity context. ChatGPT sees the tokens, reads from that context that {{person:p_001:ad4f97591c16}} is female and that the language is formal German, and generates:

Sehr geehrte `{{person:p_001:ad4f97591c16}}`,

vielen Dank fuer Ihre Nachricht. Es tut uns leid, dass Sie bereits seit
4 Tagen auf Ihre Rueckzahlung warten.

Wir haben Ihre Bestellung `{{order:o_001:57e07d900ca4}}` ueberprueft und die Rueckzahlung
wurde heute angewiesen. Der Betrag sollte innerhalb von 3-5 Werktagen
auf Ihrem Konto eingehen.

Sollten Sie weitere Fragen haben, erreichen Sie uns jederzeit unter
dieser E-Mail-Adresse.

Mit freundlichen Gruessen,
Ihr Support-Team

OpenAI never saw "Erika Mustermann," "erika.mustermann@example.com," or "B-2026-4471."

Step 4: OGuardAI Rehydrates the Response

The backend sends ChatGPT's response back with the session blob, and OGuardAI restores the original values:

curl -X POST http://localhost:3000/v1/rehydrate \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $GUARDAI_API_KEY" \
  -d '{
    "output": "Sehr geehrte `{{person:p_001:ad4f97591c16}}`,\n\nvielen Dank ...",
    "session_state": "<encrypted-blob-from-step-2>",
    "output_channel": "customer_email"
  }'

The german-support policy with formatted restore mode prepends the formal title. The final output:

Sehr geehrte Frau Erika Mustermann,

vielen Dank fuer Ihre Nachricht. Es tut uns leid, dass Sie bereits seit
4 Tagen auf Ihre Rueckzahlung warten.

Wir haben Ihre Bestellung B-2026-4471 ueberprueft und die Rueckzahlung
wurde heute angewiesen. Der Betrag sollte innerhalb von 3-5 Werktagen
auf Ihrem Konto eingehen.

Sollten Sie weitere Fragen haben, erreichen Sie uns jederzeit unter
dieser E-Mail-Adresse.

Mit freundlichen Gruessen,
Ihr Support-Team

The agent sees the finished reply in their tool and sends it to the customer.


What OGuardAI Made Possible

Correct German formal address. ChatGPT wrote "Sehr geehrte" (feminine) instead of "Sehr geehrter" (masculine) because the entity context indicated female gender. The "Frau" title was added by OGuardAI's formatted restore mode, not hallucinated by the model.

No detected PII sent to OpenAI. The name, email, and order number never left company infrastructure. OpenAI processed the tokenized text plus the safe metadata the policy exposes. The guarantee is scoped to what the detectors flag: a value no detector matches follows the raw path.

No workflow changes for agents. The support tool UI is identical. Agents click "Draft Reply" as before. The protection is invisible.

Full audit trail. Every transform and rehydrate operation emits a structured audit event recording entity types, counts, policy applied, and processing duration, never raw values. The compliance team can demonstrate to auditors exactly what data flowed where.

Regulatory confidence. The legal team approved the deployment because the trust boundary keeps detected personal data inside company infrastructure: raw values stay inside the trusted zone, and the provider receives tokens and safe metadata. The DPIA was straightforward: OGuardAI is self-hosted, adds no sub-processor of its own, stores no PII persistently, and expires sessions automatically.


Runnable Examples

Two runnable examples in the repository perform this exact German-support flow against a live server, using nothing but curl and jq. Each one tokenizes an inbound customer email, drafts a reply over tokens only, then rehydrates that single draft differently per destination. Both detect the customer name, so both need the Python NER sidecar (see the note at the top of this page); the built-in regex detectors still cover email, phone, IBAN, order, customer id, and address without it.

curl-demo: one script, no SDK

Source: examples/curl-demo/. Musterfirma GmbH (fictional) runs an AI-assisted support desk. A customer, Frau Dr. Erika Mustermann, sends a billing complaint full of PII, and an agent wants an LLM to draft the German reply without the raw data ever reaching the model, the logs, or the CRM.

Prerequisites (needs-NER). OGuardAI running on localhost:3000 with the Python NER sidecar so the customer name is tokenized, plus curl and jq. The bundled Docker Compose stack starts both the server and the sidecar:

GUARDAI_SESSION_SECRET=$(openssl rand -base64 32) \
  docker compose -f ../../deploy/docker/docker-compose.yml up -d

Builtin regex detection alone still masks the structured PII (email, phone, IBAN, order, customer id, address); the name is tokenized only when the sidecar is running. OPENAI_API_KEY is optional: when set, Step 3 drafts the reply with a real model, otherwise the demo runs fully offline with a deterministic draft. The base URL defaults to http://localhost:3000 and can be overridden with GUARDAI_URL.

Run.

export GUARDAI_API_KEY=...   # only if the server runs with GUARDAI_AUTH_MODE=api_key
chmod +x demo.sh
./demo.sh

The inbound email

examples/curl-demo/inbound-email.txt, the fixture the demo tokenizes (all PII synthetic):

Sehr geehrtes Kundenservice-Team,

meine Jahresabrechnung zum Stromvertrag ORD-2026-88123 (Kundennummer CUST-4471822)
weist eine Nachzahlung von 480,00 EUR aus, obwohl mein monatlicher Abschlag stets
puenktlich eingezogen wurde. Das ist fuer mich nicht nachvollziehbar, ich bitte um
Pruefung und Korrektur.

Die Abschlaege wurden von meinem Konto DE97 9900 0000 0123 4567 89 abgebucht. Sollte
sich eine Erstattung ergeben, ueberweisen Sie diese bitte auf dasselbe Konto.

Bitte melden Sie sich telefonisch unter +49 151 23456789 oder schriftlich per E-Mail
an erika.mustermann@example.de.

Meine Anschrift fuer den Schriftverkehr lautet Lindenallee 14, 20259 Hamburg.

Mit freundlichen Gruessen
Frau Dr. Erika Mustermann

The demo script

examples/curl-demo/demo.sh, embedded in full: liveness and capabilities, a detect audit view, transform with a hard no-leak assertion, a token-only draft, per-channel rehydration, the restore-mode ceiling, and a fail-closed check.

#!/usr/bin/env bash
# OGuardAI curl demo -- AI-assisted German support desk for a fictional utility.
#
# Scenario: "Musterfirma GmbH" runs an AI-assisted customer-support desk. A customer
# emails a billing complaint (examples/curl-demo/inbound-email.txt) full of PII: her name,
# IBAN, email, phone, contract number and customer number. An agent drafts the German reply
# with an LLM. OGuardAI sits between the inbox and the model and guarantees:
#   1. The raw name / IBAN / email / phone / contract / customer number NEVER reach the LLM.
#   2. The model still gets enough SAFE context (gender, formality, language) to write a
#      correct, personalised German reply.
#   3. The drafted reply is restored differently per destination: the customer sees a warm,
#      fully personalised mail; the audit log sees a fully redacted copy; the CRM tool sees a
#      masked copy. One draft, many policy-controlled views.
#   4. A tampered session fails closed with a clean error and leaks nothing.
#
# Everything below is nothing but curl + jq. No SDK, no dependencies.
#
# Prerequisites:
#   - OGuardAI running on localhost:3000 WITH the Python NER sidecar (person detection).
#     Full stack: docker compose -f ../../deploy/docker/docker-compose.yml up -d
#   - curl and jq installed.
#   - Optional: OPENAI_API_KEY set to draft the reply with a real model instead of the
#     built-in deterministic draft. The key is read from the environment only, never hardcoded.
#
# Usage:
#   export GUARDAI_API_KEY=...   # only if the server runs with GUARDAI_AUTH_MODE=api_key
#   chmod +x demo.sh && ./demo.sh

set -euo pipefail

# Accept the same env names the rest of the repo uses, in order of preference.
GUARDAI_URL="${GUARDAI_URL:-${GUARDAI_BASE_URL:-${GUARDAI_BASE:-http://localhost:3000}}}"
GUARDAI_API_KEY="${GUARDAI_API_KEY:-}"
POLICY="german-support"
LANG_HINT="de"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FIXTURE="$SCRIPT_DIR/inbound-email.txt"

AUTH=()
if [ -n "$GUARDAI_API_KEY" ]; then
  AUTH=(-H "X-API-Key: $GUARDAI_API_KEY")
fi

for bin in curl jq; do
  command -v "$bin" >/dev/null 2>&1 || { echo "Missing dependency: $bin" >&2; exit 1; }
done
[ -f "$FIXTURE" ] || { echo "Missing fixture: $FIXTURE" >&2; exit 1; }

hr() { printf '\n=== %s ===\n' "$1"; }

# POST a JSON body to an OGuardAI endpoint. Usage: guardai <path> <json-body>
guardai() {
  curl -sS -X POST "$GUARDAI_URL$1" \
    -H "Content-Type: application/json" \
    "${AUTH[@]}" \
    -d "$2"
}

# ---------------------------------------------------------------------------
# Step 0: Liveness + capabilities
# ---------------------------------------------------------------------------
hr "Step 0: Liveness + capabilities"
curl -sf "$GUARDAI_URL/livez" >/dev/null && echo "server:   up ($GUARDAI_URL)" || { echo "server:   DOWN" >&2; exit 1; }
CAPS=$(curl -sS "${AUTH[@]}" "$GUARDAI_URL/v1/capabilities")
echo "policy:   $POLICY"
echo "ner:      $(echo "$CAPS" | jq -r '.ner_active') (person detection needs the NER sidecar)"
echo "modes:    $(echo "$CAPS" | jq -rc '.restore_modes')"

# ---------------------------------------------------------------------------
# Step 1: Detect -- what PII is in the inbound email? (audit view, no changes)
# ---------------------------------------------------------------------------
hr "Step 1: Detect (what sensitive data is in the inbound email)"
EMAIL_BODY=$(cat "$FIXTURE")
DETECT=$(guardai /v1/detect "$(jq -n --arg i "$EMAIL_BODY" --arg l "$LANG_HINT" '{input:$i, language:$l, include_values:false}')")
echo "entities found (type x count, highest confidence):"
echo "$DETECT" | jq -r '
  def lbl(t): if (t|type)=="object" then "custom:\(t.custom)" else t end;
  .entities
  | group_by(lbl(.type))
  | map({type: lbl(.[0].type), count: length, confidence: (max_by(.confidence).confidence)})
  | sort_by(-.count)[]
  | "  \(.type)  x\(.count)  (conf \((.confidence*100|round)/100))"'

# ---------------------------------------------------------------------------
# Step 2: Transform -- replace every entity with a semantic token
# ---------------------------------------------------------------------------
hr "Step 2: Transform (PII -> semantic tokens; this is all the LLM will see)"
TRANSFORM=$(guardai /v1/transform "$(jq -n --arg i "$EMAIL_BODY" --arg p "$POLICY" --arg l "$LANG_HINT" \
  '{input:$i, input_type:"text", policy:$p, language:$l}')")

SAFE_TEXT=$(echo "$TRANSFORM" | jq -r '.safe_text')
SESSION_STATE=$(echo "$TRANSFORM" | jq -r '.session_state')
TRACE_ID=$(echo "$TRANSFORM" | jq -r '.trace_id')

echo "safe_text (goes to the model):"
echo "$SAFE_TEXT" | sed 's/^/  /'

# The exposed metadata is WHY a model can pick the correct German salutation without ever
# seeing the name: it learns gender + formality + language, not the person.
echo
echo "safe metadata exposed for the customer's name:"
echo "$TRANSFORM" | jq -r '.entities[] | select(.type=="person") | "  person \(.token[9:16])...  gender=\(.metadata.gender)  formality=\(.metadata.formality)  lang=\(.metadata.language)"'

# Company-grade guarantee: assert no raw PII survived into the safe text.
echo
LEAK=0
for secret in "Erika" "Mustermann" "0123 4567" "erika.mustermann" "23456789" "CUST-4471822"; do
  if printf '%s' "$SAFE_TEXT" | grep -qF "$secret"; then
    echo "  LEAK: raw value '$secret' is still present in safe_text" >&2
    LEAK=1
  fi
done
[ "$LEAK" -eq 0 ] && echo "guarantee: no raw name / IBAN / email / phone / customer number in safe_text  OK"
[ "$LEAK" -eq 0 ] || exit 1

# Pull the exact tokens (with checksums) so the drafted reply references the real ones.
tok_at() { echo "$TRANSFORM" | jq -r --arg t "$1" --argjson n "$2" '[.entities[] | select(.type==$t) | .token][$n] // empty'; }
P_CUST=$(tok_at person 0)   # the only person in the letter = the customer
T_ORDER=$(tok_at order 0)
T_IBAN=$(tok_at iban 0)
T_EMAIL=$(tok_at email 0)
T_PHONE=$(tok_at phone 0)
# The amount is a custom type, so its `type` is an object ({"custom":"money"}) and tok_at
# cannot select it. The draft has to echo this token too: writing the amount out raw is a
# raw session value, and the output guard rejects the whole rehydrate for it.
T_MONEY=$(echo "$TRANSFORM" | jq -r '[.entities[] | select(.token | startswith("{{custom:money:")) | .token][0] // empty')

# ---------------------------------------------------------------------------
# Step 3: The LLM drafts the reply -- using ONLY tokens, never real data
# ---------------------------------------------------------------------------
hr "Step 3: LLM drafts the German reply (token-only)"

# Deterministic stand-in for the model output so the demo runs with no provider. A real
# deployment sends $SAFE_TEXT to the provider and gets a draft like this back; the model is
# instructed to preserve tokens verbatim. Note the salutation the model chose ("Sehr geehrte",
# feminine) comes from the exposed gender metadata, not from seeing the name.
LLM_DRAFT="Sehr geehrte ${P_CUST},

vielen Dank fuer Ihre Nachricht zur Jahresabrechnung fuer Vertrag ${T_ORDER}. Wir haben Ihr
Konto geprueft und veranlassen eine Erstattung in Hoehe von ${T_MONEY} auf Ihr Konto ${T_IBAN}.
Bei Rueckfragen erreichen Sie uns telefonisch unter ${T_PHONE}; eine schriftliche Bestaetigung
senden wir zusaetzlich an ${T_EMAIL}.

Mit freundlichen Gruessen
Kundenservice Musterfirma GmbH"

# Optional: draft with a real model when OPENAI_API_KEY is present. Key is read from the
# environment only. If the call or parsing fails, we fall back to the deterministic draft so
# the OGuardAI round-trip below always runs.
if [ -n "${OPENAI_API_KEY:-}" ]; then
  echo "(OPENAI_API_KEY detected -- drafting with a real model; tokens must be preserved verbatim)"
  SYS="You are a German customer-support agent. Reply formally in German to the tokenised message. Keep every {{type:id:checksum}} token exactly as-is. Do not invent data."
  REQ=$(jq -n --arg s "$SYS" --arg u "$SAFE_TEXT" \
    '{model:"gpt-4o-mini", messages:[{role:"system",content:$s},{role:"user",content:$u}]}')
  RESP=$(curl -sS https://api.openai.com/v1/chat/completions \
    -H "Authorization: Bearer $OPENAI_API_KEY" -H "Content-Type: application/json" \
    -d "$REQ" 2>/dev/null || true)
  MODEL_DRAFT=$(echo "$RESP" | jq -r '.choices[0].message.content // empty' 2>/dev/null || true)
  [ -n "$MODEL_DRAFT" ] && LLM_DRAFT="$MODEL_DRAFT"
else
  echo "(no OPENAI_API_KEY -- using the built-in deterministic draft; export the key to use a real model)"
fi

echo "draft (still tokenised, safe to log/store):"
echo "$LLM_DRAFT" | sed 's/^/  /'

# ---------------------------------------------------------------------------
# Step 4: One draft, three destinations -- the OUTPUT-CHANNEL axis
# The policy restores the SAME draft differently depending on where it is going.
# ---------------------------------------------------------------------------
# rehydrate the shared draft on a given output channel
rehydrate() {
  local response
  response=$(guardai /v1/rehydrate "$(jq -n --arg o "$LLM_DRAFT" --arg s "$SESSION_STATE" --arg c "$1" --arg t "$TRACE_ID" \
    '{output:$o, session_state:$s, output_channel:$c, trace_id:$t}')")
  # A denied restore returns {error, code} and no restored_text. Fail loudly rather than
  # printing "null" and reading like a successful round trip.
  echo "$response" | jq -e -r '.restored_text' 2>/dev/null || {
    echo "  rehydrate failed on channel $1: $(echo "$response" | jq -r '.code // "unknown"') $(echo "$response" | jq -r '.error // .')" >&2
    exit 1
  }
}

hr "Step 4a: Channel customer_email -- the reply the customer receives"
# Base restore modes: person=formatted (Frau/Herr + name), order/email/phone=full so she sees
# her own details, IBAN=masked (financial data stays masked even to her).
CUSTOMER_REPLY=$(rehydrate customer_email)
echo "$CUSTOMER_REPLY" | sed 's/^/  /'
# A clean exit is not proof the round trip worked. Assert the reply the customer receives
# is actually restored: the surname must be present on this untightened channel, and no
# unresolved {{token}} or revoked [DELETED] marker may reach a customer. The gender
# honorific prefix a detector may add ("[PERSON]Frau ...") is tolerated; only the surname
# is required here.
case "$CUSTOMER_REPLY" in
  *Mustermann*) : ;;
  *) echo "  FAIL: customer reply is missing the restored name 'Mustermann'" >&2; exit 1 ;;
esac
case "$CUSTOMER_REPLY" in
  *'{{'*) echo "  FAIL: customer reply still carries an unresolved token" >&2; exit 1 ;;
  *'[DELETED]'*) echo "  FAIL: customer reply carries [DELETED] (a revoked value reached a customer channel)" >&2; exit 1 ;;
esac

hr "Step 4b: Channel log_safe -- the copy written to the audit log"
# Every entity collapses to [REDACTED]: logs and observability never hold PII.
rehydrate log_safe | sed 's/^/  /'

hr "Step 4c: Channel tool_payload -- the copy handed to a downstream CRM/ticket tool"
# Names and order masked, IBAN masked: the tool gets structure without the raw identity.
rehydrate tool_payload | sed 's/^/  /'

# ---------------------------------------------------------------------------
# Step 5: One line, four ceilings -- the RESTORE-MODE axis
# A request may raise or lower the restore ceiling per call; the policy floor still wins.
# ---------------------------------------------------------------------------
hr "Step 5: Restore-mode ceiling (full / partial / masked / formatted)"
PROBE="Kunde ${P_CUST}, Vertrag ${T_ORDER}, Konto ${T_IBAN}"
restore_at() {
  guardai /v1/rehydrate "$(jq -n --arg o "$PROBE" --arg s "$SESSION_STATE" --arg m "$1" --arg t "$TRACE_ID" \
    '{output:$o, session_state:$s, restore_mode:$m, trace_id:$t}')" | jq -r '.restored_text'
}
for mode in full partial masked formatted; do
  printf '  %-10s %s\n' "$mode" "$(restore_at "$mode")"
done
# WHY: the IBAN stays masked even at restore_mode=full. It is a protection-level-1 entity with a
# masked floor in the policy, and a request can only tighten below that floor, never loosen it.

# ---------------------------------------------------------------------------
# Step 6: Fail-closed -- a tampered session must error, never guess
# ---------------------------------------------------------------------------
hr "Step 6: Fail-closed (tampered session_state)"
TAMPERED="${SESSION_STATE:0:${#SESSION_STATE}-6}AAAAAA"
STATUS=$(curl -sS -o /tmp/guardai_fail.json -w '%{http_code}' -X POST "$GUARDAI_URL/v1/rehydrate" \
  -H "Content-Type: application/json" "${AUTH[@]}" \
  -d "$(jq -n --arg o "Sehr geehrte ${P_CUST}." --arg s "$TAMPERED" '{output:$o, session_state:$s, output_channel:"customer_email"}')")
echo "http_status: $STATUS (expected 4xx)"
echo "error_code:  $(jq -r '.code // .error_code // "?"' /tmp/guardai_fail.json)"
echo "message:     $(jq -r '.error // .message // "?"' /tmp/guardai_fail.json | cut -c1-70)"
rm -f /tmp/guardai_fail.json

echo
echo "Done. One inbound email, one LLM draft, three channel views, four restore ceilings, zero PII to the model or the logs."

Expected output

The script prints a labelled block per step. The load-bearing lines are the restorations. Step 2 proves the model never sees raw data:

guarantee: no raw name / IBAN / email / phone / customer number in safe_text  OK

Step 4, the same draft restored three ways:

customer_email:  Sehr geehrte Frau Erika Mustermann, ... Vertrag ORD-2026-88123 ...
                 ... auf Ihr Konto D*** **** **** **** **** *9 ... erika.mustermann@example.de
log_safe:        Sehr geehrte [REDACTED], ... Vertrag [REDACTED] ... auf Ihr Konto [REDACTED] ...
tool_payload:    Sehr geehrte E**** *********n, ... Vertrag O************3 ...

Step 5, the restore-mode ceiling. The IBAN stays masked in every mode because it is a protection-level-1 entity with a masked floor that a request can tighten below but never loosen:

full       Kunde Frau Erika Mustermann, Vertrag ORD-2026-88123, Konto D*** **** **** **** **** *9
partial    Kunde E. Mustermann, Vertrag **************, Konto D*** **** **** **** **** *9
masked     Kunde E**** *********n, Vertrag O************3, Konto D*** **** **** **** **** *9
formatted  Kunde Frau Erika Mustermann, Vertrag O************3, Konto D*** **** **** **** **** *9

Step 6, fail-closed on a tampered session_state:

http_status: 400 (expected 4xx)
error_code:  GUARDAI_INVALID_INPUT

docker-basic: full stack via Docker Compose

Source: examples/docker-basic/. Musterfirma GmbH (fictional) runs a support desk. A customer, Frau Dr. Erika Mustermann, emails about a double charge; her message carries her name, customer number, order, IBAN, phone, email, and postal address, and OGuardAI sits in front of the model so none of it reaches the LLM. This example ships the whole runtime, the Rust server plus the Python NER sidecar wired by Docker Compose, so person, company, and location are detected next to the built-in structured detectors (needs-NER, provided by the compose stack itself).

Prerequisites. Docker and Docker Compose, plus curl and jq for run.sh.

Run.

cd examples/docker-basic
cp .env.example .env          # optional: adjust the dev key/secret
docker compose up -d          # server + detector; wait for both to be healthy

docker compose ps
# SERVICE    STATUS
# server     running (healthy)
# detector   running (healthy)

./run.sh

run.sh targets http://localhost:3000 with the key from .env by default. Point it at any running server with GUARDAI_URL and GUARDAI_API_KEY. To draft with a real model instead of the offline stub, export a provider key first (OPENAI_MODEL defaults to gpt-4o-mini):

export OPENAI_API_KEY=sk-...
./run.sh

docker-compose.yml

examples/docker-basic/docker-compose.yml, the Rust server plus the NER detector with healthchecks and dev-key auth. The detector publishes no host port: it handles raw PII and is reachable only on the internal compose network at http://detector:9090.

# Full-stack OGuardAI for the docker-basic example: Rust server + Python NER
# detector. This is the smallest deployment that runs the German customer
# support scenario end to end (see run.sh and README.md).
#
#   GUARDAI_API_KEY       API key the server accepts and run.sh sends (dev default below)
#   GUARDAI_SESSION_SECRET 32-byte secret for sealed-session encryption
#
# Copy .env.example to .env (or export the vars) before `docker compose up -d`.
#
# Architecture: the published images are linux/amd64, and only linux/amd64 is exercised in CI. On an
# arm host, Docker pulls the amd64 image and runs it under emulation. Uncomment the platform lines
# below to make that explicit.

services:
  server:
    # NOTE: version-pinned; prefer an @sha256 digest once these images are published to a registry.
    image: ghcr.io/oronts/oguardai/server:0.1.0
    # platform: linux/amd64
    build:
      context: ../..
      dockerfile: deploy/docker/Dockerfile.server
    ports:
      - "3000:3000"
    volumes:
      - ./oguardai.yaml:/app/oguardai.yaml:ro
      - ../../policies:/app/policies:ro
    environment:
      # Dev defaults keep `docker compose up` a one-liner. Change both before
      # exposing this anywhere real.
      GUARDAI_SESSION_SECRET: ${GUARDAI_SESSION_SECRET:-dev-only-change-in-production-32bytes!}
      GUARDAI_API_KEY_1: ${GUARDAI_API_KEY:-dev-local-key-change-me}
      # Entitlements for that key. They are separate from scopes and deny by default, so without
      # them the key authenticates and is then refused with 403 on every transform and rehydrate.
      # These cover what run.sh exercises: the german-support policy this example defaults to, the
      # `default` policy for a request that names it instead, an external LLM, and the three
      # channels the script rehydrates to plus user_output, which is where a rehydrate that names
      # no channel lands.
      GUARDAI_API_KEY_1_ALLOWED_POLICIES: german-support,default
      GUARDAI_API_KEY_1_ALLOWED_DESTINATIONS: external_llm
      GUARDAI_API_KEY_1_ALLOWED_OUTPUT_CHANNELS: user_output,customer_email,internal_summary,log_safe
      GUARDAI_DETECTOR_URL: http://detector:9090
      # Mirrors the server's built-in filter, which RUST_LOG replaces outright. `guardai::audit=info`
      # is what puts the audit events in `docker compose logs`: they are INFO on that target.
      RUST_LOG: ${GUARDAI_LOG_LEVEL:-warn,oguardai_server=info,guardai_server=info,guardai::audit=info,tower_http=info}
    depends_on:
      detector:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "curl", "-sf", "http://localhost:3000/readyz"]
      interval: 15s
      timeout: 5s
      start_period: 10s
      retries: 3
    restart: unless-stopped

  detector:
    # NOTE: version-pinned; prefer an @sha256 digest once these images are published to a registry.
    image: ghcr.io/oronts/oguardai/detector-py:0.1.0
    # platform: linux/amd64
    build:
      context: ../..
      dockerfile: deploy/docker/Dockerfile.detector-py
    # No published host port: the detector handles raw PII and is reachable only
    # over the internal compose network (server -> http://detector:9090).
    expose:
      - "9090"
    environment:
      PYTHONUNBUFFERED: "1"
      # Fail startup if GLiNER cannot load rather than silently serving weaker spaCy.
      NER_BACKEND_STRICT: "true"
      # Internal-network only, so it runs without its own API key.
      GUARDAI_DETECTOR_ALLOW_INSECURE: "true"
    healthcheck:
      test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:9090/readyz')"]
      interval: 15s
      timeout: 5s
      start_period: 20s
      retries: 3
    restart: unless-stopped

oguardai.yaml

examples/docker-basic/oguardai.yaml, the server config: detector.mode: both, default policy german-support, api_key auth, and sealed stateless sessions.

# OGuardAI configuration for the docker-basic example.
#
# This boots the full runtime the German-support scenario needs: the Rust
# server plus the Python NER sidecar, so person / company / location are
# detected alongside the built-in structured detectors (email, phone, IBAN,
# order, customer_id, address, ...).
#
# Operational secrets (session secret, API keys) are injected from the
# environment by docker-compose.yml, not written here. See .env.example.
# Full schema: schemas/config/oguardai-config.json

server:
  host: "0.0.0.0"
  port: 3000

# api_key mode is the production-shaped default: every request must present a
# valid X-API-Key. Keys are provided via GUARDAI_API_KEY_1 (see compose).
auth:
  mode: api_key

# Sealed sessions are stateless and encrypted: the token->value map travels
# with the caller as an opaque blob, so the server holds no PII between calls.
session:
  backend: "sealed"
  ttl_seconds: 3600
  secret: "${GUARDAI_SESSION_SECRET}"

# both = built-in regex detectors AND the NER sidecar. The sidecar is reachable
# only on the internal compose network at http://detector:9090.
detector:
  mode: both
  advanced_url: "http://detector:9090"
  # The detector is a trusted sidecar on the private compose network, not TLS. This opts its
  # non-loopback http url out of the https-or-loopback rule; use https or a loopback host otherwise.
  allow_insecure_http: true
  # Pin the opt-out to the known sidecar host so an env url override cannot redirect raw text elsewhere.
  endpoint_allowlist: ["detector"]

# Default to the German formal-support policy: gender/formality-aware restore,
# IBAN pinned to masked, contact details full only on the customer's own reply.
policy:
  default: "german-support"
  directory: "/app/policies"

.env.example

examples/docker-basic/.env.example, the dev key, the sealed-session secret, and the optional provider key:

# Copy to .env, then `docker compose up -d` reads it automatically.
# run.sh reads GUARDAI_API_KEY and (optionally) OPENAI_API_KEY from the same env.

# API key the server accepts and run.sh sends as X-API-Key.
# Change this to a strong random value before exposing the server anywhere.
GUARDAI_API_KEY=dev-local-key-change-me

# 32-byte secret for sealed-session encryption. Rotate for production.
GUARDAI_SESSION_SECRET=dev-only-change-in-production-32bytes!

# Optional. If set, run.sh drafts the reply with the real OpenAI Chat API using
# the tokenized safe text. If unset, run.sh uses a deterministic offline draft.
# The provider only ever sees {{type:id:cap}} tokens, never raw PII.
# OPENAI_API_KEY=sk-...
# OPENAI_MODEL=gpt-4o-mini

The inbound email (docker-basic)

examples/docker-basic/fixtures/inbound-email.txt (all PII synthetic):

Von: erika.mustermann@example.de
An: support@example.de
Betreff: Doppelte Abbuchung Bestellung ORD-2026-9982

Sehr geehrtes Team von Musterfirma,

ich bin Frau Dr. Erika Mustermann, Kundennummer CID-77410. Fuer meine Bestellung ORD-2026-9982 wurde der Betrag von 149,90 EUR zweimal von meinem Konto DE97990000000123456789 abgebucht.

Bitte erstatten Sie den doppelt gebuchten Betrag. Sie erreichen mich unter +49 170 9988776 oder per E-Mail an diese Adresse. Meine Anschrift lautet Lindenstrasse 12, 80331 Muenchen.

Mit freundlichen Gruessen
Dr. Erika Mustermann

The run script

examples/docker-basic/run.sh, embedded in full: readiness, detect, transform, draft, rehydrate per channel, and the fail-closed checks.

#!/usr/bin/env bash
#
# OGuardAI docker-basic: end-to-end German customer-support scenario.
#
# Musterfirma GmbH (fictional utility) runs an AI assistant that drafts
# replies to customer emails. GDPR forbids sending raw customer PII to the LLM.
# OGuardAI sits in front: it tokenizes PII on the way in, the LLM sees only
# {{type:id:cap}} tokens plus safe metadata (gender, formality, language), and the
# draft is rehydrated per destination on the way out.
#
# Flow: liveness -> detect -> transform -> [draft: OpenAI or offline] ->
#       rehydrate per channel -> fail-closed checks.
#
# Prerequisites: a running server (docker compose up -d, or any OGuardAI at
# GUARDAI_URL), plus curl and jq. No SDK.
#
# Usage:
#   chmod +x run.sh && ./run.sh
#
# Env:
#   GUARDAI_URL       server base URL (default http://localhost:3000)
#   GUARDAI_API_KEY   X-API-Key to send (default dev-local-key-change-me)
#   OPENAI_API_KEY    if set, draft the reply with the real OpenAI Chat API
#   OPENAI_MODEL      OpenAI model (default gpt-4o-mini)

set -euo pipefail

GUARDAI_URL="${GUARDAI_URL:-${GUARDAI_BASE_URL:-${GUARDAI_BASE:-http://localhost:3000}}}"
GUARDAI_API_KEY="${GUARDAI_API_KEY:-dev-local-key-change-me}"
OPENAI_MODEL="${OPENAI_MODEL:-gpt-4o-mini}"
POLICY="german-support"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FIXTURE="$SCRIPT_DIR/fixtures/inbound-email.txt"

for bin in curl jq; do
  command -v "$bin" >/dev/null 2>&1 || { echo "error: '$bin' is required" >&2; exit 1; }
done
[ -f "$FIXTURE" ] || { echo "error: fixture not found: $FIXTURE" >&2; exit 1; }

TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT

hr(){ printf '%s\n' "------------------------------------------------------------"; }
# POST helper: $1 path, $2 body file. Sends the API key as X-API-Key.
post(){ curl -s -X POST "$GUARDAI_URL$1" -H "Content-Type: application/json" -H "X-API-Key: $GUARDAI_API_KEY" -d @"$2"; }

echo "OGuardAI docker-basic  |  $GUARDAI_URL  |  policy=$POLICY"
hr

# ---------------------------------------------------------------------------
# Step 0: readiness. readyz returns 200 only when policies and the detector are up.
# ---------------------------------------------------------------------------
echo "[0] Readiness"
code="$(curl -s -o /dev/null -w '%{http_code}' "$GUARDAI_URL/readyz" || true)"
if [ "$code" != "200" ]; then
  echo "    server not ready (readyz=$code). Start it with: docker compose up -d" >&2
  exit 1
fi
echo "    readyz=200"
echo

# ---------------------------------------------------------------------------
# Step 1: detect. Preview which entities OGuardAI finds, without transforming.
# ---------------------------------------------------------------------------
echo "[1] Detect  (inbound email: $FIXTURE)"
jq -n --rawfile i "$FIXTURE" --arg p "$POLICY" '{input:$i, policy:$p, language:"de", include_values:false}' > "$TMP/detect_req.json"
post /v1/detect "$TMP/detect_req.json" > "$TMP/detect.json"
echo "    entities found:"
jq -r '[.entities[] | (.type|tostring)] | group_by(.) | map("      \(.[0]) x\(length)") | .[]' "$TMP/detect.json"
echo

# ---------------------------------------------------------------------------
# Step 2: transform. PII -> tokens. safe_text + entity metadata is all the LLM sees.
# ---------------------------------------------------------------------------
echo "[2] Transform  (raw PII -> semantic tokens)"
jq -n --rawfile i "$FIXTURE" --arg p "$POLICY" '{input:$i, input_type:"text", policy:$p, language:"de"}' > "$TMP/tf_req.json"
post /v1/transform "$TMP/tf_req.json" > "$TMP/tf.json"
if [ "$(jq -r 'has("session_state")' "$TMP/tf.json")" != "true" ]; then
  echo "    transform failed:"; jq . "$TMP/tf.json"; exit 1
fi
SESSION_STATE="$(jq -r '.session_state' "$TMP/tf.json")"
echo "    safe_text (this is the trust boundary; nothing below sees raw PII):"
jq -r '.safe_text' "$TMP/tf.json" | sed 's/^/      /'
echo "    person metadata the LLM may use for tone:"
jq -r '.entities[] | select(.type=="person") | "      gender=\(.metadata.gender) formality=\(.metadata.formality) language=\(.metadata.language)"' "$TMP/tf.json" | head -1
echo

# Pull the concrete tokens this transform minted (hashes are random per request).
tok(){ jq -r --arg t "$1" '[.entities[] | select((.type|type)=="string" and .type==$t) | .token][0] // empty' "$TMP/tf.json"; }
P="$(tok person)"; O="$(tok order)"; IB="$(tok iban)"; EM="$(tok email)"; CID="$(tok customer_id)"; CO="$(tok company)"

# ---------------------------------------------------------------------------
# Step 3: draft the reply. Real OpenAI if a key is present, else a deterministic
#         offline draft. Either way the model only ever sees tokenized text.
# ---------------------------------------------------------------------------
echo "[3] Draft reply"
SAFE_TEXT="$(jq -r '.safe_text' "$TMP/tf.json")"
if [ -n "${OPENAI_API_KEY:-}" ]; then
  echo "    drafting with OpenAI ($OPENAI_MODEL) over tokenized text"
  SYS="You are a German customer-support assistant for a utility company. The user message contains {{type:id:cap}} placeholder tokens standing in for protected data. Write a short, formal German reply in Sie-Form that confirms the double charge was refunded. Reuse every placeholder token EXACTLY as written and never invent names, numbers, IBANs, or addresses."
  jq -n --arg m "$OPENAI_MODEL" --arg sys "$SYS" --arg usr "$SAFE_TEXT" \
    '{model:$m, temperature:0, messages:[{role:"system",content:$sys},{role:"user",content:$usr}]}' > "$TMP/openai_req.json"
  DRAFT="$(curl -s https://api.openai.com/v1/chat/completions \
      -H "Content-Type: application/json" -H "Authorization: Bearer $OPENAI_API_KEY" \
      -d @"$TMP/openai_req.json" | jq -r '.choices[0].message.content // empty')"
  [ -n "$DRAFT" ] || { echo "    OpenAI returned no content; check OPENAI_API_KEY" >&2; exit 1; }
else
  echo "    OPENAI_API_KEY not set -> deterministic offline draft (tokens reused verbatim)"
  DRAFT="Sehr geehrte ${P},

vielen Dank fuer Ihre Nachricht zur Bestellung ${O}. Wir haben die doppelte Abbuchung geprueft und den zu viel gebuchten Betrag auf Ihr Konto ${IB} erstattet. Eine Bestaetigung senden wir an ${EM}. Unter Ihrer Kundennummer ${CID} koennen Sie den Vorgang jederzeit einsehen.

Mit freundlichen Gruessen
Ihr Team von ${CO}"
fi
printf '%s' "$DRAFT" > "$TMP/draft.txt"
echo "    draft (still tokenized, safe to store or forward):"
sed 's/^/      /' "$TMP/draft.txt"
echo

# ---------------------------------------------------------------------------
# Step 4: rehydrate per destination. One session, one draft, three audiences.
#         The policy tightens per channel; a request restore_mode can only
#         tighten further, never widen past the policy's protection floor.
# ---------------------------------------------------------------------------
rehydrate(){ # $1 channel  [$2 restore_mode]
  local ch="$1" rm="${2:-}"
  if [ -n "$rm" ]; then
    jq -n --rawfile o "$TMP/draft.txt" --arg s "$SESSION_STATE" --arg ch "$ch" --arg rm "$rm" \
      '{output:$o, session_state:$s, output_channel:$ch, restore_mode:$rm}'
  else
    jq -n --rawfile o "$TMP/draft.txt" --arg s "$SESSION_STATE" --arg ch "$ch" \
      '{output:$o, session_state:$s, output_channel:$ch}'
  fi > "$TMP/reh_req.json"
  post /v1/rehydrate "$TMP/reh_req.json" | jq -r '.restored_text'
}

echo "[4] Rehydrate per channel"
echo "    (a) customer_email  -> the reply we send: 'Frau' salutation, full contact, IBAN stays masked"
rehydrate customer_email | sed 's/^/        /'
echo
echo "    (b) internal_summary -> the agent's CRM note: name visible, contact formatted"
rehydrate internal_summary | sed 's/^/        /'
echo
echo "    (c) log_safe -> the audit line: every value redacted, safe for logs/SIEM"
rehydrate log_safe | sed 's/^/        /'
echo
echo "    (d) customer_email, restore_mode=full -> proves the floor holds: even 'full'"
echo "        cannot widen the IBAN past the policy's masked rule"
rehydrate customer_email full | sed 's/^/        /'
echo

# ---------------------------------------------------------------------------
# Step 5: fail-closed. Bad auth, tampered session, and unknown policy must all
#         fail loudly, never leak, never 200 on garbage.
# ---------------------------------------------------------------------------
echo "[5] Fail-closed checks (each should be a clean error, not a leak)"
c="$(curl -s -o /dev/null -w '%{http_code}' -X POST "$GUARDAI_URL/v1/transform" \
     -H "Content-Type: application/json" -H "X-API-Key: wrong-key" -d @"$TMP/tf_req.json" || true)"
echo "    wrong API key         -> HTTP $c (expect 401)"

jq -n '{output:"Hallo {{person:p_001:deadbeef}}", session_state:"not-a-valid-sealed-blob", output_channel:"customer_email"}' > "$TMP/bad_reh.json"
c="$(post /v1/rehydrate "$TMP/bad_reh.json" | jq -r '.code // "OK"')"
echo "    tampered session_state -> $c (expect GUARDAI_INVALID_INPUT)"

jq -n '{input:"Hallo", policy:"does-not-exist"}' > "$TMP/bad_pol.json"
c="$(post /v1/transform "$TMP/bad_pol.json" | jq -r '.code // "OK"')"
echo "    unknown policy         -> $c (expect GUARDAI_INVALID_INPUT)"
echo

hr
echo "Done. Raw PII stayed inside OGuardAI; the model and the logs only ever saw tokens."

Expected output (docker-basic)

Token hashes are random per request, so the {{type:id:hash}} values differ on each run; the restored text is stable. Abridged:

[1] Detect  (inbound email: fixtures/inbound-email.txt)
      address x1, company x1, customer_id x1, email x2, iban x1,
      order x2, person x2, phone x1, money x1

[2] Transform  (raw PII -> semantic tokens)
      Von: {{email:e_001:7b1068662ab5}}
      ...
      ich bin Frau Dr. {{person:p_001:ad4f97591c16}}, Kundennummer {{customer_id:cid_001:6f2b8e4a1d90}}. ...
      Konto {{iban:ib_001:3a9d17c60b45}} ...
      person metadata: gender=female formality=formal language=de

[4] Rehydrate per channel
    (a) customer_email
        Sehr geehrte Frau Erika Mustermann,
        ... Bestellung ORD-2026-9982 ... Konto D********************9 ...
        Bestaetigung an erika.mustermann@example.de ...
    (b) internal_summary
        Sehr geehrte Frau Erika Mustermann, ... an e*************************e ...
    (c) log_safe
        Sehr geehrte [REDACTED], ... Bestellung [REDACTED] ... Konto [REDACTED] ...
    (d) customer_email, restore_mode=full
        ... Konto D********************9 ...   # IBAN still masked; the floor holds

[5] Fail-closed checks
    wrong API key          -> HTTP 401
    tampered session_state -> GUARDAI_INVALID_INPUT
    unknown policy         -> GUARDAI_INVALID_INPUT

Read the three restorations side by side: the customer gets a gender correct salutation (Frau), their own order number and email in full, but the IBAN masked even to them; the CRM note keeps the name and formats the contact details down; the audit log redacts everything, so nothing sensitive lands in the logs.