Protect LangChain chains with OGuardAI so PII is tokenized before the model and restored in the output
The OGuardAI SDKs ship an optional LangChain integration that wraps any LangChain Runnable so PII
in the input is tokenized before the chain runs and restored in the chain output afterward. The
wrapped runnable and the model only ever see the tokenized safe_text; the sealed session never
enters the chain, its callbacks, or its memory. Each integration is a thin seam over the SDK
guarded_call / guardedCall primitive.
Both give the same core guarantee for the wrapped runnable: the transform tokenizes the input and the
rehydrate restores the output, so the model sees only tokenized text on the way in and out. They
differ in how they interact with LangChain callbacks and tracers. The guarantee is the input and
output boundary of the wrapped runnable; in an agentic (ReAct or tool-calling) chain, a tool that
returns fresh raw PII mid-run is not tokenized by the wrapper, so guard those tool boundaries yourself.
guarded_invoke / guardedInvoke runs the transform and rehydrate outside LangChain. No LangChain
callback or tracer ever observes the raw input or the restored output. Prefer this when a
PII-exporting tracer (for example LangSmith) is attached to your chain.
protect / protectRunnable returns a composable Runnable you can drop into a larger chain. Its
own input and output boundary (raw in, restored out) is visible to callbacks and tracers attached
at or above that node, so use it when the outer chain is not exported to a PII-sensitive
destination.
from guardai_sdk import OGuardAIClientfrom guardai_sdk.integrations.langchain import protect, guarded_invokeclient = OGuardAIClient(base_url="http://localhost:3000")# Boundary-safe: nothing raw or restored reaches a LangChain callback or tracer.answer = guarded_invoke(chain, client, "Email Erika Mustermann at erika.mustermann@firma.example")# Composable Runnable (see the tracer note above).guarded = protect(chain, client)answer = guarded.invoke("Email Erika Mustermann at erika.mustermann@firma.example")
import { OGuardAIClient } from "@oguardai/sdk";import { protectRunnable, guardedInvoke } from "@oguardai/sdk/integrations/langchain";const client = new OGuardAIClient({ baseUrl: "http://localhost:3000" });// Boundary-safe: nothing raw or restored reaches a LangChain callback or tracer.const answer = await guardedInvoke(chain, client, "Email Erika Mustermann at erika.mustermann@firma.example");// Composable Runnable (see the tracer note above).const guarded = protectRunnable(chain, client);const composed = await guarded.invoke("Email Erika Mustermann at erika.mustermann@firma.example");
In both cases the wrapped chain sees only Email {{person:p_001:ad4f97591c16}} at {{email:e_001:1bcaef1a4aff}} on the way in, and the returned
answer has the real values restored. If the chain is agentic and a tool returns fresh raw PII mid-run,
that value is not tokenized by the wrapper; guard those tool results yourself.
String input is safe by construction: only the tokenized text reaches the chain. For a structured
input (a dict of prompt variables, or a message object), pass an extractor to pull the single text
to protect and a builder to rebuild the chain input. Structured input without an extractor is
rejected (fail loud) so a raw value can never be sent by accident.
The builder receives the raw original input, so it is your responsibility to pass back only the
fields your chain needs and never a raw PII field. When several fields need protecting, use the
structured transform API directly instead of this single-text integration.
Three complete, self-contained LangChain examples live in the repository. Each one runs end
to end against a local runtime, and each falls back to a deterministic offline drafter when
no OPENAI_API_KEY is set, so the OGuardAI round trip is exercised with or without a
provider key. All names, emails, IBANs, phone numbers, and IDs in the fixtures are fictional.
examples/langchain-python: a tenant-support copilot built as a reusable guard() wrapper
around any LangChain runnable.
examples/langchain-protected: a billing-support copilot that wraps a real LCEL chain and
restores one draft per destination.
examples/langchain-ts: a German support copilot built as a LangChain ReAct agent that
reasons on tokens only.
A reply copilot for Musterfirma GmbH, a fictional Munich residential property manager,
drafts formal German replies to tenant emails. One draft is restored into three audiences
from a single sealed session: the tenant sees their own name, order, and email in full, a
colleague sees the name with masked contacts, and the audit log sees a fully de-identified
line. A passport number is hard-redacted at transform and never reaches the model. The policy
is german-support.
Needs NER. Person, company, and location detection and the German honorific restore
need the NER detector, so start the full stack, which brings up the runtime plus the NER
detector, from the repository root:
# The bundled stack requires these four secrets, and its client key ships least-privilege (channel# user_output only), so widen it to the channels this example restores on.export GUARDAI_SESSION_SECRET=$(openssl rand -base64 32)export GUARDAI_API_KEY_1=$(openssl rand -hex 32)export GUARDAI_DETECTOR_API_KEY=$(openssl rand -hex 32)export GUARDAI_REDIS_PASSWORD=$(openssl rand -hex 32)export GUARDAI_API_KEY_1_ALLOWED_OUTPUT_CHANNELS=user_output,customer_email,internal_summary,log_safedocker compose -f deploy/docker/docker-compose.yml up -d
german-support declares person detection as required, so a runtime without the NER detector
rejects the transform (fail closed, HTTP 503) rather than let an un-tokenized name reach the model.
The built-in regex still covers structured PII (email, phone, IBAN, IDs, address, date of birth,
passport), but this example needs the full stack with NER.
Install the dependencies and run it from examples/langchain-python:
pip install -r requirements.txt
export GUARDAI_BASE_URL="http://localhost:3000" # or GUARDAI_URL / GUARDAI_BASEexport GUARDAI_API_KEY="$GUARDAI_API_KEY_1" # the bundled stack runs api_key authexport OPENAI_API_KEY="sk-..." # optional: swaps in ChatOpenAIpython guard.py
Without OPENAI_API_KEY the example runs fully offline with a deterministic drafter, and no
provider is ever contacted.
requests>=2.31langchain-core>=0.3# Only needed when OPENAI_API_KEY is set (the ChatOpenAI path). The example runs# fully offline without it, using a deterministic drafter.langchain-openai>=0.1
examples/langchain-python/inbox/01-brandl-kaution.txt is one inbound tenant email:
Von: Erika Mustermann <erika.mustermann@post.example>An: service@example.deBetreff: Kautionsrueckzahlung nach Auszug - Vorgang ORD-2026-4471Sehr geehrtes Team der Musterfirma GmbH,ich bin Frau Dr. Erika Mustermann und war bis zum 30.06.2026 Mieterin derWohnung in der Musterstrasse 42, 80337 Muenchen. Meine Mieternummer lautetCUST-4892, der zugehoerige Vorgang ist ORD-2026-4471.Die Wohnung wurde protokolliert uebergeben, dennoch warte ich seit vier Wochenauf die Rueckzahlung meiner Kaution. Bitte ueberweisen Sie den Betrag auf meinKonto DE97 9900 0000 0123 4567 89.Zusaetzlich wurde mir eine Nebenkostennachzahlung berechnet, die ich nichtnachvollziehen kann. Fuer Rueckfragen erreichen Sie mich unter 089 12345678.Zur Identitaetspruefung fuege ich meine Reisepassnummer C01234567 bei; geborenbin ich am 14.03.1985.Mit freundlichen GruessenDr. Erika Mustermann
The three OGuardAI steps run inside RunnableLambda, from examples/langchain-python/guard.py:
def transform_step(payload: dict) -> dict: """Detect -> Tokenize. Sanitize one raw email; only typed tokens and safe metadata leave the boundary. Returns the dict the inner chain and rehydrate step consume.""" res = post("/v1/transform", {"input": payload["raw"], "language": LANG, "policy": POLICY}) safe = res["safe_text"] ctx = res.get("entity_context", []) # The safe metadata the policy permits out. Formatted as instruction lines the # copilot reads: it tells the model the gender/formality WITHOUT the raw name, so # the German agreement is correct rather than the naive "Sehr geehrte Herr ...". hints = "\n".join( f"- {e['token']} type={e['type']} gender={e.get('gender', 'unknown')} " f"formality={e.get('formality', 'formal')} language={e.get('language', 'de')}" for e in ctx ) or "- (no entities detected)" return { "msg_id": payload.get("msg_id", "?"), "subject": payload.get("subject", ""), "safe_text": safe, "session_state": res["session_state"], "entity_context": ctx, "tokens": token_map(safe), # "[REDACTED:...]" markers mean a hard identifier (passport/SSN) was dropped at # transform and never tokenized, so it can never reach the model or a reply. "redacted": "[REDACTED:" in safe, # The copilot reads the tokenized document only. The raw subject is not prepended: # the Betreff line is already inside safe (tokenized), so no raw header crosses. "safe_input": safe, "hints": hints, }def rehydrate_one(draft: str, session_state: str, *, mode: str | None = None, channel: str | None = None) -> str: body: dict = {"output": draft, "session_state": session_state} if mode: body["restore_mode"] = mode if channel: body["output_channel"] = channel return post("/v1/rehydrate", body)["restored_text"]def rehydrate_step(d: dict) -> dict: """Rehydrate. One token-only draft, restored into three audiences from the SAME sealed session. The channel decides what each audience is allowed to see.""" draft, state = d["draft"], d["session_state"] return { **d, # The tenant's own reply: their name (with honorific), order, and email in full; # the IBAN stays masked because the policy caps it below `full`. "customer_reply": rehydrate_one(draft, state, mode="full", channel="customer_email"), # Colleague handoff: identity visible, contact details masked. "agent_note": rehydrate_one(draft, state, channel="internal_summary"), # Audit trail: fully de-identified line, safe to persist in logs. "audit_line": rehydrate_one(draft, state, channel="log_safe"), }
The reusable guard() composes these around any runnable with RunnablePassthrough.assign,
which carries the sealed session_state around the inner chain so the rehydrate step can
still resolve the tokens the copilot echoed. From examples/langchain-python/guard.py:
def build_model() -> Runnable: """ChatOpenAI when a key is present, else the offline drafter. Both are LangChain runnables, so the guarded chain is byte-for-byte the same either way.""" if os.environ.get("OPENAI_API_KEY"): from langchain_openai import ChatOpenAI # imported lazily: offline runs need no provider SDK return ChatOpenAI(model=os.environ.get("OPENAI_MODEL", "gpt-4o-mini"), temperature=0.2) return RunnableLambda(offline_drafter)def build_copilot() -> Runnable: return PROMPT | build_model() | StrOutputParser()def guard(inner: Runnable) -> Runnable: """Wrap ANY LangChain runnable so raw PII never reaches it. transform -> run the inner chain on tokens (keeping the sealed session via assign) -> rehydrate.""" return ( RunnableLambda(transform_step) | RunnablePassthrough.assign(draft=inner) | RunnableLambda(rehydrate_step) )
The full file also walks the inbox, prints the restore-mode spectrum (full, formatted,
masked, partial, abstract, none) on one draft, and proves the guard raises rather
than leak on a corrupted session.
Offline (no OPENAI_API_KEY), the first email restores like this:
1. TENANT INBOX -> guarded LangChain chain (one draft, three audiences) [01-brandl-kaution] (13 tokens, 1+ hard-redacted) safe (tokenized document): Von: {{person:p_001:...}} <{{email:e_001:...}}> ... {{order:o_001:...}} ... -> customer reply : Sehr geehrte Frau Erika Mustermann, ... auf D*** **** **** **** **** *0. ... an erika.mustermann@post.example. -> agent note : Sehr geehrte Frau Erika Mustermann, ... auf D*** **** **** **** **** *0. ... an k*****************e. -> audit line : Sehr geehrte [REDACTED], ... auf [REDACTED]. ... an [REDACTED]. note: a passport number was hard-redacted at transform; the model never saw it.
The customer sees the name with the correct honorific, the order, and the email in full; the
IBAN is masked because the policy caps it there. The agent note shows the name but masks the
email. The audit line is fully de-identified. The second email (Herr Max Mustermann, male)
flips the honorific and adjective to Sehr geehrter Herr.
A reply copilot for Musterfirma GmbH, a fictional Hamburg green-electricity provider,
drafts formal German replies to billing complaints. A real LCEL chain
(ChatPromptTemplate | ChatOpenAI | StrOutputParser) drafts on tokens only, and the one
draft is restored three ways through the german-support policy: the customer channel with
full contact details, the internal note with contacts partially masked, and the audit line
fully redacted.
Needs NER. Start the runtime with the NER detector so person tokens are detected.
german-support declares person detection as required, so a runtime without the NER detector rejects
the transform (fail closed, HTTP 503) rather than let an un-tokenized name reach the model.
# The bundled stack requires these four secrets, and its client key ships least-privilege, so widen# it to the channels this example restores on.export GUARDAI_SESSION_SECRET=$(openssl rand -base64 32)export GUARDAI_API_KEY_1=$(openssl rand -hex 32)export GUARDAI_DETECTOR_API_KEY=$(openssl rand -hex 32)export GUARDAI_REDIS_PASSWORD=$(openssl rand -hex 32)export GUARDAI_API_KEY_1_ALLOWED_OUTPUT_CHANNELS=user_output,customer_email,internal_summary,log_safedocker compose -f ../../deploy/docker/docker-compose.yml up -d
Install the client and run it from examples/langchain-protected. requests is enough for
the offline path; the LangChain packages are only needed for the live LLM draft.
pip install -r requirements.txt
export GUARDAI_URL="http://localhost:3000" # optional, this is the defaultexport GUARDAI_API_KEY="$GUARDAI_API_KEY_1" # the bundled stack runs api_key auth# Optional: draft with a real model. Omit to run fully offline.export OPENAI_API_KEY="sk-..."python main.py
With the key set, the same tokens are drafted by gpt-4o-mini (override with OPENAI_MODEL).
# Required: the OGuardAI transform/rehydrate calls (offline path needs only this).requests>=2.28.0# Optional: only for the live LLM draft, used when OPENAI_API_KEY is set.langchain-core>=0.2.0langchain-openai>=0.1.0
Von: Erika Mustermann <erika.mustermann@example.de>An: kundenservice@example.deBetreff: Doppelte Abbuchung Abschlag JuniSehr geehrtes Team,meine Abschlagszahlung fuer den Oekostromtarif wurde im Juni zweimalabgebucht. Die Bestellung ORD-2026-88231 betrifft meinen Vertrag.Bitte erstatten Sie 84,50 EUR auf mein Konto DE97 9900 0000 0123 4567 89.Erreichbar bin ich unter +49 40 5566 7788.Mit freundlichen GruessenErika Mustermann
Two calls wrap any LangChain chain, fail-closed on any transport error or non-2xx status.
The complete examples/langchain-protected/guard.py:
"""OGuardAI client for the LangChain support-drafting pipeline.Two calls wrap any LangChain chain so the LLM never sees raw customer data: transform() before the chain -- raw PII -> semantic tokens + session_state rehydrate() after the chain -- tokens -> real values (per output channel)The helpers are deliberately fail-closed: any transport error, non-2xx status, ormalformed body raises GuardError. Callers must let that abort the request instead offalling back to the raw text, otherwise unprotected PII would reach the model."""from __future__ import annotationsimport osimport requests# GUARDAI_URL is the canonical name; GUARDAI_BASE_URL is accepted for parity with the# other examples and the docker-compose env.BASE = ( os.environ.get("GUARDAI_URL") or os.environ.get("GUARDAI_BASE_URL") or "http://localhost:3000").rstrip("/")API_KEY = os.environ.get("GUARDAI_API_KEY", "")TIMEOUT_S = 30class GuardError(RuntimeError): """Raised when the runtime cannot protect or restore text. Treat this as fatal for the request. Never degrade to sending the raw input to the LLM: that is exactly the leak OGuardAI exists to prevent. """def _headers() -> dict[str, str]: headers = {"Content-Type": "application/json"} if API_KEY: headers["X-API-Key"] = API_KEY return headersdef _post(path: str, body: dict) -> dict: try: resp = requests.post(f"{BASE}{path}", json=body, headers=_headers(), timeout=TIMEOUT_S) except requests.RequestException as exc: # server down, DNS, timeout, TLS, ... raise GuardError(f"OGuardAI unreachable at {BASE}{path}: {exc}") from exc if resp.status_code >= 400: # Never surface the raw upstream body: it can echo request PII. Prefer OGuardAI's # typed error envelope (code + message), else a generic status-only message. detail = resp.json() if resp.headers.get("content-type", "").startswith("application/json") else {} code = detail.get("code", f"HTTP_{resp.status_code}") error = detail.get("error") message = error if isinstance(error, str) else f"HTTP {resp.status_code}" raise GuardError(f"OGuardAI {path} failed: {code} - {message}") try: return resp.json() except ValueError as exc: raise GuardError(f"OGuardAI {path} returned a non-JSON body") from excdef transform(text: str, policy: str = "german-support", language: str | None = None) -> dict: """Replace PII in `text` with semantic tokens under the named policy. Returns the full response: safe_text (send this to the LLM), session_state (the sealed blob you pass back to rehydrate), and entity_context (per-token gender / formality / language hints the model needs to write correct output). """ payload: dict = {"input": text, "input_type": "text", "policy": policy} if language: payload["language"] = language return _post("/v1/transform", payload)def rehydrate(output: str, session_state: str, output_channel: str) -> dict: """Restore tokens in the LLM `output` for one destination. The output_channel selects the restore rules: the same protected draft is restored fully for the customer, partially for the internal note, and redacted for the log. """ payload = { "output": output, "session_state": session_state, "output_channel": output_channel, } return _post("/v1/rehydrate", payload)def entity_hints(entity_context: list[dict]) -> str: """Render entity_context into a compact prompt block. The model must keep tokens verbatim, so it needs to know what each token stands for and, for people, the gender and formality that drive the German salutation. """ lines: list[str] = [] for ctx in entity_context: parts = [f"{ctx['token']} = {ctx['type']}"] if ctx.get("type") == "person": if ctx.get("gender") and ctx["gender"] != "unknown": parts.append(f"gender={ctx['gender']}") if ctx.get("formality"): parts.append(f"formality={ctx['formality']}") lines.append(", ".join(parts)) return "\n".join(lines)
The LCEL chain, the per-destination loop, and the fail-closed demo, from
examples/langchain-protected/main.py (the offline drafter and the boundary checks are
helpers in the same file):
# One protected draft, three destinations. Order matters only for readability.CHANNELS = [ ("Customer reply (sent to the customer)", "customer_email"), ("Internal CRM note (agent-visible)", "internal_summary"), ("Audit log line (analytics / retention)", "log_safe"),]SYSTEM_PROMPT = ( "You are a customer-service agent at Musterfirma GmbH, a German green-electricity " "provider. Draft a short, formal reply in German to the customer email below.\n" "Rules:\n" "1. The email is tokenized. Keep every {{type:id:hash}} token exactly as written. Never " "invent names, numbers, emails, or IBANs; only reuse the tokens you are given.\n" "2. Pick the salutation from the person's gender hint: female -> 'Sehr geehrte " "<person-token>', male -> 'Sehr geehrter <person-token>'.\n" "3. Acknowledge the issue, confirm the next step for the order token, and close with " "'Mit freundlichen Gruessen' then 'Ihr Musterfirma-Kundenservice'.\n" "Entity hints:\n{hints}")def build_chain(): """Build the LCEL chain, or None when no OpenAI key is configured. Kept out of import scope so the offline path needs neither the key nor the LangChain OpenAI extra installed. """ if not os.environ.get("OPENAI_API_KEY"): return None from langchain_openai import ChatOpenAI from langchain_core.prompts import ChatPromptTemplate from langchain_core.output_parsers import StrOutputParser prompt = ChatPromptTemplate.from_messages( [("system", SYSTEM_PROMPT), ("user", "{email}")] ) model = ChatOpenAI(model=os.environ.get("OPENAI_MODEL", "gpt-4o-mini"), temperature=0.2) return prompt | model | StrOutputParser()def draft_reply(chain, transform_result: dict) -> str: """Turn safe_text into a token-only draft via the LLM, or the offline stand-in.""" if chain is None: return deterministic_draft(transform_result) # Only safe_text ever reaches the model; entity hints carry gender/formality, not PII. return chain.invoke( { "email": transform_result["safe_text"], "hints": guard.entity_hints(transform_result["entity_context"]), } ).strip()def process_email(path: str, chain, ner_available: bool, name_notes: list[str]) -> None: with open(path, encoding="utf-8") as handle: raw_email = handle.read() # Fail-closed: if the runtime cannot protect the email, abort the run rather than fall back # to the raw text. The model is reached only after a successful transform and a clean check. try: protected = guard.transform(raw_email, policy=POLICY, language=LANGUAGE) except guard.GuardError as exc: name = os.path.basename(path) raise SystemExit(f"FAIL-CLOSED: {exc}; the model was never called for {name}") print(" Safe text sent to the LLM (tokenized):") for line in protected["safe_text"].strip().splitlines(): print(f" {line}") check_crossing( protected["safe_text"], "the safe text sent to the LLM", ner_available=ner_available, name_notes=name_notes, ) draft = draft_reply(chain, protected) check_crossing( draft, "the token-only draft", ner_available=ner_available, name_notes=name_notes ) print("\n Token-only draft returned by the chain:") for line in draft.strip().splitlines(): print(f" {line}") print("\n Restored per destination:") for label, channel in CHANNELS: restored = guard.rehydrate(draft, protected["session_state"], channel) print(f"\n [{label}]") for line in restored["restored_text"].strip().splitlines(): print(f" {line}")def demonstrate_fail_closed() -> None: """Prove the guard refuses when the runtime is unreachable. Points the client at a dead port and confirms transform raises GuardError instead of returning, so a caller can never accidentally forward the raw email to the model. """ saved = guard.BASE guard.BASE = "http://127.0.0.1:1" # nothing listens here try: guard.transform("Ich bin Erika Mustermann, erika.mustermann@example.de.") except guard.GuardError: print(" OK: transform failed closed; raw text was NOT forwarded to the LLM.") else: raise SystemExit("fail-closed check failed: transform did not fail closed when the runtime was unreachable") finally: guard.BASE = saved
Per email you see the safe text sent to the model, the token-only draft, then the three
restorations. For the first inbound email:
[Customer reply (sent to the customer)] Sehr geehrte Frau Erika Mustermann, ... Die Erstattung veranlassen wir auf Ihr Konto D*** **** **** **** **** *9. Eine Bestaetigung senden wir an erika.mustermann@example.de. [Internal CRM note (agent-visible)] Sehr geehrte Frau Erika Mustermann, ... Eine Bestaetigung senden wir an k*******************************e. [Audit log line (analytics / retention)] Sehr geehrte [REDACTED], ... Eine Bestaetigung senden wir an [REDACTED].
The run ends with a fail-closed check that points the client at a dead port and confirms the
guard refuses rather than forwarding raw text to the model.
A support copilot for Musterfirma GmbH, a fictional Munich furniture maker,
runs a LangChain ReAct agent that never sees customer PII. The inbound email is transformed
to tokens once, before the agent runs, so the agent input, its tool calls, and the LLM prompt
only ever contain {{type:id:hash}} tokens. Rehydration happens after the agent finishes. One
tokenized draft is restored into four channels: customer_email, internal_summary,
tool_payload, and log_safe.
Needs NER. An OGuardAI server on localhost:3000 with the NER detector enabled is
required (the person name and gender detection needs it), plus the german-support policy
(it ships with the server). The full Docker stack provides both. The bundled stack requires these
four secrets, and its client key ships least-privilege, so widen it to the channels this example uses:
examples/langchain-ts/fixtures/lindenhof-support-email.de.md. The runner reads only the
block between the <<<EINGANG>>> and <<<ENDE>>> markers:
# Musterfirma GmbH, Support-Ticket (fiktiv)Musterfirma GmbH ist ein frei erfundener Muenchner Hersteller vonMassivholzmoebeln mit Direktvertrieb. Der Kundenservice-Copilot "Linnea" entwirftformelle deutsche Antworten mit einem externen LLM. Die deutsche Datenschutzlageverbietet es, personenbezogene Kundendaten an diesen Anbieter zu senden. OGuardAIist die Laufzeit, die dem LLM nur semantische Tokens zeigt und die echten Werte aufdem Rueckweg pro Ausgabekanal wiederherstellt.Die eingehende E-Mail unten enthaelt ausschliesslich frei erfundene personenbezogeneDaten (Name, Kundennummer, Bestellnummer, Lieferadresse, IBAN, Geburtsdatum, E-Mail,Telefon). Es sind keine echten Personendaten enthalten. Der Runner liest genau denBlock zwischen den Markern und schickt ihn durch den OGuardAI-Schutzzyklus.<<<EINGANG>>>Sehr geehrtes Serviceteam, mein Name ist Herr Max Mustermann. Meine Kundennummerlautet KD-590233 und meine Bestellung ORD-2026-73118, ein Eichenholz-Esstisch, ist miteinem tiefen Kratzer in der Tischplatte bei mir eingetroffen. Geliefert wurde an dieMusterstrasse 27, 81547 Muenchen. Ich möchte den Tisch zurücksenden und denKaufbetrag erstattet bekommen. Bitte überweisen Sie die Erstattung auf mein KontoIBAN DE97 9900 0000 0123 4567 89. Zur Verifizierung meiner Identität nenne ich meinGeburtsdatum 03.09.1985. Sie erreichen mich per E-Mail untermax.mustermann@beispiel.de oder telefonisch unter +49 160 5551234 zu denüblichen Geschäftszeiten.<<<ENDE>>>
ProtectionContext transforms the raw message once and holds the token-to-value map inside
the trusted zone, so an internal tool can resolve a token without the model ever seeing the
value. From examples/langchain-ts/src/protection.ts:
export class ProtectionContext { private constructor( private readonly client: OGuardAIClient, private readonly sessionState: string, readonly safeText: string, readonly entities: EntityInfo[], readonly hints: EntityHint[], readonly summary: Record<string, number>, /** token -> real value, resolvable only inside the trusted zone. */ private readonly tokenValues: Map<string, string>, ) {} /** * Protect one raw inbound message. This is the only place the raw text is read. * The `destination: external_llm` context records that the tokens are bound for a * third-party model, which is what the policy's protection levels are written for. */ static async protect(client: OGuardAIClient, rawText: string): Promise<ProtectionContext> { const res = await client.transform({ input: rawText, policy: COMPANY.policy, language: COMPANY.language, context: { destination: "external_llm", provider: "openai", caller_purpose: "support" }, }); if (!res.session_state) { // No sealed state means no way to rehydrate or revoke later. Fail closed. throw new ValidationError( "transform returned no session_state; refusing to run the agent (fail closed)", ); } const hints: EntityHint[] = (res.entity_context ?? []).map((c: EntityContext) => ({ token: c.token, type: c.type, gender: c.gender, formality: c.formality, language: c.language, })); // Build token -> real value from the entity spans over the raw input. The raw // input is inside the boundary, so this map never crosses it. Spans are UTF-8 byte // offsets, so they must be sliced as bytes: a String.slice would bind a token to a // shifted window of the text as soon as the input contains an umlaut. const tokenValues = new Map<string, string>(); for (const e of res.entities) { if (e.span) { tokenValues.set(e.token, sliceByteSpan(rawText, e.span.start, e.span.end).trim()); } } return new ProtectionContext( client, res.session_state, res.safe_text, res.entities, hints, summarize(res.entities, res.entity_summary), tokenValues, ); } /** First token of a given entity type (e.g. the person or order token). */ tokenOf(type: string): string | undefined { return this.hints.find((h) => h.type === type)?.token; } /** * Trusted-zone resolution of a token to its real value, for internal lookups only. * Returns undefined for anything the context did not tokenize, so an invented or * foreign token can never resolve to a real value. */ resolveToken(token: string): string | undefined { return this.tokenValues.get(token.trim()); } /** Restore one tokenized draft for a specific output channel. */ rehydrate(draft: string, channel: OutputChannel): Promise<RehydrateResponse> { return this.client.rehydrate({ output: draft, session_state: this.sessionState, output_channel: channel, }); }}
Both tools operate purely in the token domain. lookup_order_status resolves an order token
to its real value inside the trusted zone, queries the internal system, and returns a PII-free
status. The complete examples/langchain-ts/src/tools.ts:
import { DynamicTool } from "@langchain/core/tools";import type { ProtectionContext } from "./protection.js";import { ORDER_DB } from "./scenario.js";export function makeAgentTools(ctx: ProtectionContext): DynamicTool[] { const lookupOrderStatus = new DynamicTool({ name: "lookup_order_status", description: "Schlage den internen Status einer Bestellung nach. Eingabe: der Bestell-Token " + "der Form {{order:...}} aus der Nachricht. Gibt Status, Artikel und Erstattungsbetrag " + "zurueck (ohne personenbezogene Daten). Rufe dies auf, bevor du eine Erstattung zusagst.", func: async (orderToken: string): Promise<string> => { const realOrder = ctx.resolveToken(orderToken); if (!realOrder) { // An unknown/invented token never resolves to a real order. Say so plainly. return "Unbekannter Bestell-Token: kein Datensatz gefunden."; } const record = ORDER_DB[realOrder]; if (!record) { return "Zu dieser Bestellung liegt kein interner Datensatz vor."; } return [ `Status: ${record.status}`, `Artikel: ${record.item}`, `Erstattungsbetrag: ${record.refundEur} EUR`, ].join("; "); }, }); const checkReturnPolicy = new DynamicTool({ name: "check_return_policy", description: "Gibt die Rueckgabe- und Erstattungsrichtlinie fuer beschaedigte Ware zurueck. " + "Eingabe: eine kurze Beschreibung des Grundes (ohne personenbezogene Daten).", func: async (): Promise<string> => "Beschaedigte Ware: kostenlose Ruecksendung per beigelegtem Etikett, volle Erstattung " + "nach Wareneingang, Bearbeitung innerhalb von 5 Werktagen.", }); return [lookupOrderStatus, checkReturnPolicy];}
The agent runs entirely in the token domain. Its input is the tokenized ticket, its tools take
and return tokens or non-PII text, and its final answer is a tokenized German draft. The
complete examples/langchain-ts/src/agent.ts:
import { AgentExecutor, createReactAgent } from "langchain/agents";import { PromptTemplate } from "@langchain/core/prompts";import { buildChatModel, tokenContract } from "./llm.js";import { makeAgentTools } from "./tools.js";import { COMPANY } from "./scenario.js";import type { ProtectionContext } from "./protection.js";const REACT_TEMPLATE = `{preamble}Du hast Zugriff auf folgende Werkzeuge:{tools}Nutze exakt dieses Format:Question: die zu bearbeitende AufgabeThought: ueberlege, was zu tun istAction: die Aktion, genau eine von [{tool_names}]Action Input: die Eingabe fuer die AktionObservation: das Ergebnis der Aktion... (Thought/Action/Action Input/Observation koennen sich wiederholen)Thought: Ich kenne nun die finale AntwortFinal Answer: der fertige, tokenisierte Antwortentwurf an den KundenBeginne!Question: {input}Thought:{agent_scratchpad}`;/** Run the ReAct agent on the tokenized ticket and return the tokenized draft. */export async function runReactAgent(ctx: ProtectionContext): Promise<string> { const tools = makeAgentTools(ctx); const llm = buildChatModel(); const preamble = [ `Du bist ${COMPANY.assistant}, Kundenservice-Copilot von ${COMPANY.name}.`, "Antworte auf Deutsch in der formellen Sie-Form, knapp und hilfsbereit.", "", tokenContract(ctx.hints), ].join("\n"); const prompt = PromptTemplate.fromTemplate(REACT_TEMPLATE); const agent = await createReactAgent({ llm, tools, prompt }); const executor = new AgentExecutor({ agent, tools, // The scratchpad it prints holds tokens only, so verbose tracing stays PII-free. verbose: process.env.AGENT_VERBOSE === "1", handleParsingErrors: true, maxIterations: 6, }); const input = [ "Bearbeite dieses Support-Ticket und entwirf eine formelle deutsche Antwort.", "Pruefe zuerst den Bestellstatus, bevor du eine Erstattung zusagst.", "Ticket (bereits tokenisiert):", "", ctx.safeText, ].join("\n"); const result = (await executor.invoke({ input, preamble })) as { output: string }; return result.output;}
Offline mode, abridged. Token ids and the trailing hash change per run; the structure does not.
Policy: german-support Sprache: de LLM: offlineWAS DAS LLM SIEHT (safe_text, nur Tokens) Sehr geehrtes Serviceteam, mein Name ist Herr {{person:p_001:...}}. Meine Kundennummer lautet {{customer_id:cid_001:...}} und meine Bestellung {{order:o_001:...}} ...Metadaten fuer das Modell (kein Rohwert, nur Hinweise): {{person:p_001:...}} -> person, male, formal, de ...ENTWURF DES AGENTEN (noch tokenisiert) Guten Tag {{person:p_001:...}}, vielen Dank fuer Ihre Nachricht zu Bestellung {{order:o_001:...}} ...WIEDERHERGESTELLT PRO KANAL (dieselbe Antwort, verschiedene Sichtbarkeit)[customer_email] -> Guten Tag Herr Max Mustermann, ... Konto D*** **** **** **** **** *0 ...[internal_summary] -> ... senden wir an t*****************************e.[tool_payload] -> Guten Tag T***** ***********r, ... Bestellung O************8 ...[log_safe] -> Guten Tag [REDACTED], ... Bestellung [REDACTED] ...FAIL-CLOSED1) Manipuliertes session_state -> abgelehnt: ValidationError (GUARDAI_INVALID_INPUT)2) Erfundener Token -> aufgeloest=1 offen=[{{person:p_999:deadbeef0000}}]