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_modelsends the message throughpipeline.anonymize, so the LLM receivesWhere does <<PERSON:1>> live?.- The model calls
lookup_city(person="<<PERSON:1>>"). UnderToolCallStrategy.FULL,awrap_tool_callrestores the argument to Patrick before running the tool, then de-identifies the tool's string result. aafter_modelrestores 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,
INPUTonly,OUTPUTonly, orPASSTHROUGH, 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.