OGuardAI
Integrations

LangChain

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.

Install

The integration is an optional extra, so LangChain is only pulled in when you use it.

Python

pip install "oguardai-sdk[langchain]"

TypeScript

pnpm add @oguardai/sdk @langchain/core

Two entry points

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.

Python

from guardai_sdk import OGuardAIClient
from guardai_sdk.integrations.langchain import protect, guarded_invoke

client = 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")

TypeScript

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.

Structured input

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.

Python

guarded = protect(
    chain,
    client,
    input_extractor=lambda payload: payload["question"],
    safe_input_builder=lambda payload, safe: {"question": safe},
    output_key="answer",
)

TypeScript

const guarded = protectRunnable(chain, client, {
  inputExtractor: (payload) => payload.question,
  safeInputBuilder: (_payload, safe) => ({ question: safe }),
  outputKey: "answer",
});

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.

How it works

  1. The integration calls /v1/transform on the input text, replacing PII with semantic tokens.
  2. The wrapped runnable runs on the tokenized safe_text and returns its output.
  3. The integration calls /v1/rehydrate on that output using the sealed session, restoring the real values per the output channel.

If the transform fails, the wrapped runnable is never invoked (fail closed).

Runnable examples

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.

Python: tenant-support copilot with a reusable guard

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_safe
docker 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_BASE
export GUARDAI_API_KEY="$GUARDAI_API_KEY_1"        # the bundled stack runs api_key auth
export OPENAI_API_KEY="sk-..."                     # optional: swaps in ChatOpenAI

python guard.py

Without OPENAI_API_KEY the example runs fully offline with a deterministic drafter, and no provider is ever contacted.

Dependencies

examples/langchain-python/requirements.txt:

requests>=2.31
langchain-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

Fixture

examples/langchain-python/inbox/01-brandl-kaution.txt is one inbound tenant email:

Von: Erika Mustermann <erika.mustermann@post.example>
An: service@example.de
Betreff: Kautionsrueckzahlung nach Auszug - Vorgang ORD-2026-4471

Sehr geehrtes Team der Musterfirma GmbH,

ich bin Frau Dr. Erika Mustermann und war bis zum 30.06.2026 Mieterin der
Wohnung in der Musterstrasse 42, 80337 Muenchen. Meine Mieternummer lautet
CUST-4892, der zugehoerige Vorgang ist ORD-2026-4471.

Die Wohnung wurde protokolliert uebergeben, dennoch warte ich seit vier Wochen
auf die Rueckzahlung meiner Kaution. Bitte ueberweisen Sie den Betrag auf mein
Konto DE97 9900 0000 0123 4567 89.

Zusaetzlich wurde mir eine Nebenkostennachzahlung berechnet, die ich nicht
nachvollziehen kann. Fuer Rueckfragen erreichen Sie mich unter 089 12345678.
Zur Identitaetspruefung fuege ich meine Reisepassnummer C01234567 bei; geboren
bin ich am 14.03.1985.

Mit freundlichen Gruessen
Dr. Erika Mustermann

The guard steps

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.

Expected output

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.

Python: billing-support copilot wrapping an LCEL chain

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_safe
docker 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 default
export 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).

Dependencies

examples/langchain-protected/requirements.txt:

# 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.0
langchain-openai>=0.1.0

Fixture

examples/langchain-protected/inbox/01-seidel-doppelte-abbuchung.txt:

Von: Erika Mustermann <erika.mustermann@example.de>
An: kundenservice@example.de
Betreff: Doppelte Abbuchung Abschlag Juni

Sehr geehrtes Team,

meine Abschlagszahlung fuer den Oekostromtarif wurde im Juni zweimal
abgebucht. 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 Gruessen
Erika Mustermann

The OGuardAI client

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, or
malformed body raises GuardError. Callers must let that abort the request instead of
falling back to the raw text, otherwise unprotected PII would reach the model.
"""

from __future__ import annotations

import os

import 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 = 30


class 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 headers


def _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 exc


def 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 chain and the per-channel restore

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

Expected output

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.

TypeScript: a ReAct agent on semantic tokens

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:

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,tool_payload,log_safe
docker compose -f ../../deploy/docker/docker-compose.yml up -d

Node.js 20 or newer is required. Install and run from examples/langchain-ts:

cd examples/langchain-ts
pnpm install
export GUARDAI_URL="http://localhost:3000"
export GUARDAI_API_KEY="$GUARDAI_API_KEY_1"   # the bundled stack runs api_key auth

# Offline drafter (no LLM key needed): exercises transform + 4x rehydrate + fail-closed
pnpm dev

# Live ReAct agent: add a provider key first
export OPENAI_API_KEY="sk-..."
pnpm dev

pnpm build && pnpm start runs the compiled output.

Environment variables

VariableDefaultDescription
GUARDAI_URLhttp://localhost:3000OGuardAI server URL (GUARDAI_BASE_URL is accepted too).
GUARDAI_API_KEY(none)Sent as X-API-Key when OGuardAI auth is enabled.
OPENAI_API_KEY(none)When set, the live ReAct agent drafts the reply; otherwise offline mode.
OPENAI_MODELgpt-4o-miniModel for the OpenAI path.
SUPPORT_POLICYgerman-supportOGuardAI policy name.
AGENT_VERBOSE(unset)Set to 1 to print the agent's token-only reasoning trace.

Package manifest

examples/langchain-ts/package.json (the dev, build, and start scripts above map to these):

{
  "name": "guardai-langchain-ts-example",
  "version": "1.0.0",
  "description": "German customer-support copilot: a LangChain ReAct agent that reasons on OGuardAI semantic tokens, with per-channel rehydration",
  "type": "module",
  "scripts": {
    "build": "tsc",
    "typecheck": "tsc --noEmit",
    "test": "tsx --test src/byte-span.test.ts",
    "start": "node dist/index.js",
    "dev": "tsx src/index.ts"
  },
  "dependencies": {
    "@oguardai/sdk": "workspace:*",
    "@langchain/core": "^0.3.0",
    "@langchain/openai": "^0.3.0",
    "langchain": "^0.3.0",
    "zod": "^3.22.0"
  },
  "devDependencies": {
    "@types/node": "^20",
    "tsx": "^4.0",
    "typescript": "^5.5"
  }
}

Files

FileRole
fixtures/lindenhof-support-email.de.mdThe inbound German ticket (fictional multi-entity PII).
src/scenario.tsCompany config, channel map, fixture loader, mock internal order table.
src/protection.tsThe trust boundary: transform once, resolve tokens in the trusted zone, rehydrate per channel.
src/tools.tsLangChain tools that operate on tokens only.
src/llm.tsLLM mode selection, the token contract, the deterministic offline drafter.
src/agent.tsThe ReAct agent (OpenAI path), with an inline prompt (no network hub pull).
src/index.tsThe end-to-end trace and the fail-closed demo.

Fixture

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 von
Massivholzmoebeln mit Direktvertrieb. Der Kundenservice-Copilot "Linnea" entwirft
formelle deutsche Antworten mit einem externen LLM. Die deutsche Datenschutzlage
verbietet es, personenbezogene Kundendaten an diesen Anbieter zu senden. OGuardAI
ist die Laufzeit, die dem LLM nur semantische Tokens zeigt und die echten Werte auf
dem Rueckweg pro Ausgabekanal wiederherstellt.

Die eingehende E-Mail unten enthaelt ausschliesslich frei erfundene personenbezogene
Daten (Name, Kundennummer, Bestellnummer, Lieferadresse, IBAN, Geburtsdatum, E-Mail,
Telefon). Es sind keine echten Personendaten enthalten. Der Runner liest genau den
Block zwischen den Markern und schickt ihn durch den OGuardAI-Schutzzyklus.

<<<EINGANG>>>
Sehr geehrtes Serviceteam, mein Name ist Herr Max Mustermann. Meine Kundennummer
lautet KD-590233 und meine Bestellung ORD-2026-73118, ein Eichenholz-Esstisch, ist mit
einem tiefen Kratzer in der Tischplatte bei mir eingetroffen. Geliefert wurde an die
Musterstrasse 27, 81547 Muenchen. Ich möchte den Tisch zurücksenden und den
Kaufbetrag erstattet bekommen. Bitte überweisen Sie die Erstattung auf mein Konto
IBAN DE97 9900 0000 0123 4567 89. Zur Verifizierung meiner Identität nenne ich mein
Geburtsdatum 03.09.1985. Sie erreichen mich per E-Mail unter
max.mustermann@beispiel.de oder telefonisch unter +49 160 5551234 zu den
üblichen Geschäftszeiten.
<<<ENDE>>>

The trust boundary

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,
    });
  }
}

Token-domain tools

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 ReAct agent

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 Aufgabe
Thought: ueberlege, was zu tun ist
Action: die Aktion, genau eine von [{tool_names}]
Action Input: die Eingabe fuer die Aktion
Observation: das Ergebnis der Aktion
... (Thought/Action/Action Input/Observation koennen sich wiederholen)
Thought: Ich kenne nun die finale Antwort
Final Answer: der fertige, tokenisierte Antwortentwurf an den Kunden

Beginne!

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;
}

Expected output

Offline mode, abridged. Token ids and the trailing hash change per run; the structure does not.

Policy:    german-support   Sprache: de   LLM: offline

WAS 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-CLOSED
1) Manipuliertes session_state -> abgelehnt: ValidationError (GUARDAI_INVALID_INPUT)
2) Erfundener Token -> aufgeloest=1 offen=[{{person:p_999:deadbeef0000}}]