Skip to content

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.