Skip to content

LangChain integration

You want a working LangGraph agent where the LLM only ever sees tokens, a tool still receives the real values it needs, and detection runs on a real NER model instead of a fixed value list. The assembly below runs end to end. It brings together a GLiNER2 detector, a ThreadAnonymizationPipeline, PIIAnonymizationMiddleware, a system prompt that teaches the model to treat tokens as data, and a tool that looks a person up by name.

For the minimal version with a stub detector, start with the LangChain middleware tutorial. What follows is the same shape with a real model and a system prompt.

1. Build the pipeline over a GLiNER2 detector

Gliner2Detector wraps a GLiNER2 model. Pass the model id as a string and it loads on construction. Pass labels to tell it which entity types to query. The default anonymizer uses LabelCounterPlaceholderFactory, which emits the delimited <<PERSON:1>> the middleware can find again.

from piighost.components.detector.ner import Gliner2Detector
from piighost.pipeline import ThreadAnonymizationPipeline

detector = Gliner2Detector(
    model="fastino/gliner2-multi-v1",
    labels=["PERSON", "LOCATION"],
    threshold=0.5,
)
pipeline = ThreadAnonymizationPipeline(detector)

2. Declare a tool that needs the real value

A tool that looks a person up by name needs Patrick, not <<PERSON:1>>. Write it against real values. Under ToolCallStrategy.FULL, the middleware restores the argument before the call, then de-identifies the result.

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. Tell the model that tokens are data

The model reasons over <<PERSON:1>> instead of a name. A short system prompt keeps it from commenting on the token or refusing to pass it to a tool.

SYSTEM_PROMPT = """\
You are a helpful assistant. Some inputs contain placeholders like <<PERSON:1>> \
that stand in for real values withheld for privacy.

Treat each placeholder as if it were the real value. Never comment on its \
format, never say it is a token, and pass it to tools unchanged as an argument. \
If the user asks about the content of a placeholder, say the data is withheld \
and you cannot reveal it.
"""

4. Wrap the pipeline and create the agent

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. The tool thus 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",
    system_prompt=SYSTEM_PROMPT,
    tools=[lookup_city],
    middleware=[
        PIIAnonymizationMiddleware(
            pipeline=pipeline,
            tool_strategy=ToolCallStrategy.FULL,
        )
    ],
)

5. Run one turn

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

import asyncio


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


asyncio.run(main())

The reply is restored for display, so it reads with the real values. Its wording depends on the model, for example:

Patrick lives in Paris.

Who sees what

GLiNER2 flags Patrick as PERSON in the incoming message. From there each boundary of the turn replaces in one direction only, either a value with its token or a token with its value:

  • abefore_model sends the message through pipeline.anonymize, so the LLM receives Where does <<PERSON:1>> live?.
  • The model calls lookup_city(person="<<PERSON:1>>"). Under ToolCallStrategy.FULL, awrap_tool_call restores the argument to Patrick before running the tool, then de-identifies the tool's string result.
  • aafter_model restores the reply for the user.

The thread_id keeps <<PERSON:1>> bound to Patrick across every step.

See also

  • To pick a different tool behaviour, INPUT only, OUTPUT only, or PASSTHROUGH, see Tool-call strategies.
  • To swap GLiNER2 for spaCy, a regex pack, or your own detector, see Extending piighost.
  • To run the pipeline out of process against a shared server, see Remote client.