Framework adapters¶
Wiring caused_by/context through by hand (see Quickstart)
gives the best attribution, but it means touching every call site in your
integration. An adapter closes that gap for a specific agent framework by
reading the framework's own execution events and populating those hints
itself.
LangGraph¶
$ uv add "hansard[langgraph]"
hansard.adapters.langgraph.HansardCallbackHandler is a
langchain_core callback handler: attach it to a graph invocation's
config, and it records every HumanMessage, every tool call, and the
final AIMessage into a Hansard session on its own, deriving caused_by
and context from what the graph's own callbacks report rather than
requiring you to pass them.
import hansard
from hansard.adapters.langgraph import HansardCallbackHandler
from langchain_core.messages import HumanMessage
with hansard.session(path="./sessions", agent="support-bot") as sess:
handler = HansardCallbackHandler(sess)
graph.invoke(
{
"messages": [
HumanMessage(
content="restart the payments worker",
additional_kwargs={"hansard_user_id": "priya"},
)
]
},
config={"callbacks": [handler]},
)
A HansardCallbackHandler instance is meant to be created once and reused
across a whole multi-turn conversation (not rebuilt per call) — it keeps a
map of message ids it has already resolved, so a later invocation's turn
can still cite an earlier invocation's message.
hansard_user_id in a HumanMessage's additional_kwargs is how the
handler learns who sent it; a message without that key is still recorded,
just under user_id="unknown".
Two things to know before relying on this¶
Give every HumanMessage a stable .id. The handler keys its
already-resolved-messages map by HumanMessage.id. A host app that
replays its accumulated message history into graph.invoke() on every
call (i.e. isn't using a LangGraph checkpointer, which instead passes only
the new messages per turn) will get a fresh, duplicate session.message()
record for the same logical message on every invocation if that message
has no id to deduplicate on.
turn_context grows to include everyone who has ever spoken. The
handler's context set — the messages a turn declares it "could see," used
for turn_context attribution (0.9 confidence) — accumulates every
message the handler has ever resolved, for the life of the handler
instance. It never shrinks. For a long-lived handler/session this means
every turn with no new message of its own gets a turn_context verdict
naming every participant who has ever spoken, not just the ones relevant
to that specific turn.
This is a deliberate tradeoff, not a bug: a narrower policy — only
messages present in this invocation's own input state — would make
turn_context attribution unreachable for checkpointer-based apps, where
the root inputs LangGraph hands the callback typically carry only the
turn's new messages, not the full history. If your application needs a
narrower context set, wire context= through by hand at your own
sess.turn() call sites instead of relying on the adapter.
One more thing worth knowing¶
Tool exceptions are recorded verbatim. on_tool_error's str(error) is
written to the action result's error field as-is — unlike output,
which passes through Hansard's normal redaction path — so a tool that
raises an exception containing a secret will log that secret unredacted.
If your tools might raise on sensitive input, scrub before raising.
What's next¶
CrewAI and Claude Agent SDK adapters are tracked as future work; neither has been started.