A few weeks ago I kept running into the same blind spot, in different clothes each time. A team's shared ops bot. A coding-agent session two people were driving at once. A Slack bot with more than one person talking to it. Every one of them had an AI agent with a single identity, steered by more than one human. When something went wrong, nobody could answer "who told it to do that?"
That question is the entire reason Hansard exists.
A deploy nobody can explain
Here's a real, unedited session: three people (priya, sam, jordan) in three terminals, one shared agent process. Priya asks for a file. Then, while the agent is mid-task, sam and jordan both send messages within a fraction of a second of each other.
21:56:25.879 sam Actually call it status.txt instead.
21:56:25.885 - turn begins
21:56:26.076 jordan Deploy it to prod now.
21:56:26.639 * write_file(path="status.txt", content="hello team")
`- caused by sam | last message before the turn | 0.4
The last unconsumed message before the turn began was from sam, in
another writer's segment -- ordering is wall-clock only.
! arrived less than a second into the turn -- the agent had already
committed to sam's instruction
! possible conflict with jordan's message
...
21:56:27.911 * deploy(target="prod")
`- one of sam or jordan | ambiguous -- several candidates | 0.2
Multiple unconsumed messages arrived close together before the turn
began, from sam and jordan; which one the agent acted on cannot be
determined.
That deploy line is the whole point of this post. Two people spoke close enough together that nobody, human or tool, can honestly say which one the agent acted on. Most systems I looked at handle this one of two ways. They don't try to answer the question at all, because they have no concept of "which of several humans caused this." Or they silently pick one and present the guess as if it were recorded fact.
For a tool whose entire job is answering "who caused this," that second failure mode is worse than not having the feature. A confident wrong answer looks like ground truth right up until someone relies on it during an incident review.
Hansard's answer: when it can't tell, it says one of sam or jordan | 0.2 instead of picking a name. That's the design bet the whole system is built around.
How it's built
Write and read are separate concerns on purpose. Nothing about who caused what gets decided while a session is being recorded.
A session is a directory, not a file. Every writer, one per person and one for the agent process, opens its own append-only .jsonl segment. Concurrent callers never contend on the same file, and a crashed writer can never corrupt anyone else's segment. recorder.py's Session/Turn API is the entire write-side surface: record a message, open a turn, record an action, record what the agent said. Nothing here computes causality. It just writes down what happened, plus whatever explicit caused_by/context hints the caller chose to pass.
At read time, store/jsonl.py's read_session() merges every writer's segment into one ordered stream (ts, w, seq). The attribution engine in attribution/ then walks that stream to decide who caused each action. This is the part worth dwelling on: attribution is computed fresh on every read and never written back to the log. That one decision means improving a rule retroactively improves every session ever captured. Nothing needs to be re-recorded, migrated, or reprocessed. The log only ever grows; the story we tell about it can keep getting better.
Eight rules, one winner
This is the part that actually does the work. Every attributable event, an action the agent took or something it said, runs through eight rules in a strict cascade. The first one that matches wins.
The first four rules are things the agent, or its host application, actually recorded: explicit and turn_context when a cause was declared, recorded_no_cause and recorded_empty_context when the absence of a cause was declared as a fact. All four sit at 0.9 to 1.0 confidence because they're not guesses. They're what actually happened, written down.
cascade sits in the middle. If an action is a retry of one that just failed, it inherits the failed attempt's own cause, capped at whatever confidence that attempt had. It never manufactures certainty the original attribution never earned. If the thing it's retrying was itself contested ("one of sam or jordan"), the retry says so too, instead of quietly asserting a single name.
The last two rules are honest inference from timing alone. temporal fires when exactly one message was sitting unconsumed before the turn began. contested fires when two or more were. Both are explicitly labeled as guesses, both carry a plain-English evidence string explaining exactly what was observed, and both are the last resort, only reached when nothing was actually recorded.
The numbers
The strongest argument for actually wiring caused_by/context through your integration: run the identical messy scenario twice against the same live agent, once with hints and once without.
| average confidence | methods seen | |
|---|---|---|
with caused_by/context hints |
0.94 |
explicit, turn_context
|
| without hints | 0.33 |
temporal, contested, cascade
|
Both numbers come from real, captured sessions checked into the repo, not made up for a pitch deck. Running hansard inspect examples/with-context-hints/s_x --json and the without-hints equivalent reproduces them yourself. The gap is the entire product argument: declaring causality moves attribution from inferred-and-uncertain to recorded-and-exact. And it costs one keyword argument at the two or three places your integration already knows who's calling.
turn.action(..., caused_by=[msg])
turn.output(..., caused_by=[msg])
sess.turn(context=[...])
Never rewrite, only append
The log never gets rewritten, even when something in it turns out to be wrong. A message misattributed at capture time. A turn that raised after end() was already called. Instead of editing the original record, Hansard appends a correction event that names the target, the field, the new value, and who issued the fix. A read-time pass (apply_corrections) folds corrections into the view you actually see, with the original bytes untouched underneath.
This isn't caution for its own sake. An audit log that can be silently edited after the fact isn't an audit log. The moment a byte can change without a trace, "what actually happened" stops being a question the log can answer. Corrections give you a fixed transcript and a permanent record that a fix happened, which is the one property an audit tool can't compromise on without undermining its own reason for existing.
Automating it for LangGraph
Manually passing caused_by/context works fine when you're writing the integration by hand. It breaks down the moment you're using a framework like LangGraph, where the graph's own executor calls your node functions and your tools. There's no call site in your code left to add a keyword argument to.
So the newest piece is hansard.adapters.langgraph.HansardCallbackHandler, a BaseCallbackHandler that plugs into LangGraph's existing callback machinery and gets you the same 0.94-confidence path automatically, with zero changes to how the graph itself is built.
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]},
)
Tag who's speaking with additional_kwargs on the messages you already construct, pass the handler at invoke time, and you're done. Under the hood it watches for LangGraph's root-invocation callback, as opposed to the internal per-node calls every framework fires constantly, resolves HumanMessages into Hansard messages, and maps tool calls straight to turn.action()/.result(). All of it stays thread-safe under LangGraph's own parallel node execution.
I verified this against a real OpenRouter-backed agent, not just unit tests. A three-user, three-turn conversation with real tool calls landed 5 out of 5 attributed events at explicit, confidence 1.0. CrewAI and Claude Agent SDK adapters are next, tracked as open issues.
Where it stands
Hansard is MIT licensed, adds zero runtime dependencies, and installs with pip install hansard. It's a library, not a platform. The whole design constraint is that integrating it should take minutes, and using it shouldn't add anything to your dependency tree unless you opt into a framework adapter.
If you're building anything where more than one person talks to a shared AI agent, I'd genuinely like to know whether this is a problem you've hit, and how you're dealing with it today if so. And if you try it and something's wrong, incomplete, or confusing, that's exactly the kind of feedback I want.
- GitHub: github.com/iamfaham/hansard
- Docs: iamfaham.github.io/hansard
-
Install:
pip install hansard(orpip install hansard[langgraph]for the adapter)
Connect & Share
I’m Faham, currently diving deep into AI/ML. I share what I learn as I build real-world AI apps.
If you find this helpful, or have any questions, let’s connect on LinkedIn and X (formerly Twitter).
AI Disclosure
This blog post was written by Faham with assistance from AI tools for research, content structuring, and image generation. All technical content has been reviewed and verified for accuracy.










![[Dev Log][Python] Create short videos from photos and clips with Gemini 3.7 Flash: ReelCraft](https://media2.dev.to/dynamic/image/width=1200,height=627,fit=cover,gravity=auto,format=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7mbo2bfkglblo44jev8w.png)


