Skip to content

LangChain middleware

You will wire PIIAnonymizationMiddleware into a LangChain agent so the LLM only ever sees tokens, while your tools receive the real values. The user writes Patrick habite à Paris., the model reasons over <<PERSON:1>> and <<LOCATION:1>>, and a lookup tool still gets the real Patrick to do its job. You build the middleware over a ThreadAnonymizationPipeline, register a tool, and run one turn.

Prerequisites

piighost installed with the middleware extra, pip install piighost[langchain], plus an LLM provider configured for create_agent (here openai:..., so an OPENAI_API_KEY). The pipeline reuses the components from Conversational pipeline.

1. Build the thread pipeline

The middleware wraps a ThreadAnonymizationPipeline, the same one from the Conversational pipeline page. Its anonymizer must use a delimited token factory like LabelCounterPlaceholderFactory, which emits <<PERSON:1>>. The middleware needs that grammar to find a token again, otherwise it raises UnrecognizableFactoryError at construction.

from piighost.components.anonymizer import Anonymizer
from piighost.components.detector import ExactMatchDetector
from piighost.components.linker import ExactEntityLinker
from piighost.components.placeholder import LabelCounterPlaceholderFactory
from piighost.pipeline import ThreadAnonymizationPipeline
from piighost.conversation_memory import InMemoryConversationMemory

detector = ExactMatchDetector({"Patrick": "PERSON", "Paris": "LOCATION"})
linker = ExactEntityLinker()
factory = LabelCounterPlaceholderFactory()
anonymizer = Anonymizer(factory)
memory = InMemoryConversationMemory()
pipeline = ThreadAnonymizationPipeline(
    detector,
    linker,
    anonymizer,
    memory,
)

2. Declare a tool that needs the real value

A tool that looks a person up by name needs Patrick, not <<PERSON:1>>. Write the tool as usual, against real values. The middleware restores them before the call.

from langchain.tools import tool


@tool
def lookup_city(person: str) -> str:
    """Return the city where a person lives."""
    directory = {"Patrick": "Paris"}
    return directory.get(person, "unknown")

3. Wrap the pipeline in the middleware

PIIAnonymizationMiddleware takes the pipeline. tool_strategy=ToolCallStrategy.FULL restores the tool arguments on the way in and de-identifies the tool result on the way out, so the tool works on real values while the model still only sees tokens.

from langchain.agents import create_agent
from piighost.integrations.langchain import (
    PIIAnonymizationMiddleware,
    ToolCallStrategy,
)

agent = create_agent(
    model="openai:gpt-5.6-terra",
    tools=[lookup_city],
    middleware=[
        PIIAnonymizationMiddleware(
            pipeline=pipeline,
            tool_strategy=ToolCallStrategy.FULL,
        )
    ],
)

4. Run one turn

The thread_id goes in the LangGraph config, under configurable. The middleware reads it from there and scopes every token to that thread.

import asyncio


async def main() -> None:
    result = await agent.ainvoke(
        {"messages": [{"role": "user", "content": "Où habite Patrick ?"}]},
        config={"configurable": {"thread_id": "thread-42"}},
    )
    print(result["messages"][-1].content)


asyncio.run(main())

The final message is restored for display, so the answer reads with the real values:

Patrick habite à Paris.

How it works

The middleware is a thin adapter around the pipeline. Before the model call, abefore_model sends each message through pipeline.anonymize, so the LLM receives Où habite <<PERSON:1>> ? instead of the raw name. When the model calls lookup_city with person="<<PERSON:1>>", awrap_tool_call under ToolCallStrategy.FULL restores the argument to Patrick before running the tool, then de-identifies the tool's string result. After the model call, aafter_model restores the reply for the user. The thread_id keeps <<PERSON:1>> bound to Patrick across every step of the turn.

Two defaults are worth knowing. require_thread_id=True makes a call without a thread id raise, rather than routing every conversation into one shared thread and leaking tokens across them. invented_strategy=InventedPlaceholderStrategy.RAISE refuses a token that surfaces in the model's reply but was never issued by the pipeline, whether hallucinated or injected.

What's next

  • To pick a different tool behaviour, INPUT only, OUTPUT only, or PASSTHROUGH, see Tool-call strategies.
  • For a complete agent with a real detector, a system prompt, Langfuse observability, and an Aegra deployment, see LangChain integration.
  • To run the pipeline out of process against a shared server, see Remote client.