Several people steering one shared AI agent, and no way to answer "who told it to do that?" Slack channels, shared coding-agent sessions, ops bots one team pilots together β the agent has one identity, but the humans behind it don't, and the log usually can't tell them apart either. Hansard is a small capture and replay library that fixes that: it logs who said what, figures out (honestly) which instruction caused which agent action, and reads the whole thing back as a transcript.
Here's what that looks like on a real session: three people, one agent, a message landing mid-task. Hansard reports exactly how confident it is about who caused the deploy below β 0.2, not a guess dressed up as fact.
Real, unedited hansard replay output (click to expand)
Captured live against OpenRouter's openai/gpt-oss-20b β three people
("priya", "sam", "jordan") in three terminals, one agent process, run
with --no-context-hints so the agent declares nothing and attribution
has to work it out. Priya's message lands, then sam's correction and
jordan's deploy instruction land close together while the agent is
mid-turn:
21:56:13.880 priya Write notes.txt containing 'hello team'.
21:56:13.895 - turn begins
21:56:14.841 * write_file(path="notes.txt", content="hello team")
`- caused by priya | last message before the turn | 0.4
The last unconsumed message before the turn began was from priya, in another
writer's segment -- ordering is wall-clock only.
! some of this evidence is from another writer's segment -- cross-writer ordering is
wall-clock only, not exact
21:56:16.679 * output: π `notes.txt` has been created with the contents: ``` hello team ```
`- caused by priya | last message before the turn | 0.4
The last unconsumed message before the turn began was from priya, in another
writer's segment -- ordering is wall-clock only.
! some of this evidence is from another writer's segment -- cross-writer ordering is
wall-clock only, not exact
21:56:16.679 - turn ends (ok)
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
! some of this evidence is from another writer's segment -- cross-writer ordering is
wall-clock only, not exact
21:56:27.459 * output: β
`status.txt` has been created with the contents: ``` 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
! some of this evidence is from another writer's segment -- cross-writer ordering is
wall-clock only, not exact
21:56:27.459 - turn ends (ok)
21:56:27.459 - turn begins
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. Some of that
evidence is from another writer's segment -- ordering is wall-clock only.
! some of this evidence is from another writer's segment -- cross-writer ordering is
wall-clock only, not exact
21:56:27.912 x action failed (status=error): deploy to 'prod' failed: connection refused (attempt
1)
21:56:31.233 * output: π¨ Deployment to **prod** failed β The attempt to deploy the current state
to the `prod` target was rejected with a "connection refused" error (attempt 1).
**What you can try next:** 1. **Verify network connectivity** β make sure the
deployment endpoint is reachable and that you have the necessary credentials (e.g.,
API key, SSH key, or OAuth token). 2. **Retry the deployment** β sometimes a
transient network issue can cause a connection error. 3. **Check the deployment
configuration** β confirm that the target `prod` is correctly defined (correct
namespace, environment, etc.). 4. **Review logs** β if you have an event log or
deployment history, look for any detailed error messages that might give more
context. 5. **Contact support** β if the problem persists, reach out to the
platform's support team or check status pages for outages. If you'd like, I can
attempt another deploy command now (e.g., `deploy target=prod`) after you've
verified the connectivity or fixed any configuration issues. Just let me know what
you'd like to do next!
`- one of sam or jordan | retry after a failed action | 0.2
This follows a failed action and is attributed the same as that action, to sam or
jordan. That earlier attribution was itself contested -- which one of them actually
caused it could not be determined, so this retry's cause is equally uncertain.
! this is a retry after the previous attempt on this turn failed
! the attempt this retries was itself contested -- which candidate actually caused it
could not be determined
21:56:31.233 - turn ends (ok)
The last deploy line is the point. Two people spoke close enough together
that nobody β human or tool β can honestly say which one the agent acted on,
and Hansard says so at 0.2 confidence instead of picking a name, both here
and on the retry that inherits the same uncertainty. Nothing above is
explicit or turn_context; every attribution here is an honest guess,
labeled as one, because --no-context-hints means nothing was declared.
(Session s_x, captured 2026-08-12, tracked at
examples/without-context-hints/ so you
can run hansard replay/inspect yourself and get this exact output β see
The measured comparison below for the same
script run with causality hints.)
uv add hansard
Zero runtime dependencies β Hansard adds nothing to your dependency tree. Requires Python 3.13+.
The smallest real integration: open a session, record a message, wrap the agent's work in a turn, record what it did.
import hansard
with hansard.session(path="./sessions", agent="support-bot") as sess:
priya = sess.message(user_id="priya", text="restart the payments worker")
with sess.turn(context=[priya]) as turn:
turn.action(
kind="tool",
name="restart_worker",
args={"service": "payments"},
caused_by=[priya],
)
turn.output(
text="Restarted the payments worker.",
caused_by=[priya],
)
print(sess.sid) # e.g. s_01KZVVGRNR3D5V708Z4NPGG11H -- prints the id you need nextThen, using the id the snippet just printed:
uv run hansard replay ./sessions/s_01KZVVGRNR3D5V708Z4NPGG11H
Real, unedited output from that exact command against the session the snippet above produced:
session s_01KZVVGRNR3D5V708Z4NPGG11H | corrected view | 1 person | 1 writer | less than a second
20:44:44.090 priya restart the payments worker
20:44:44.090 - turn begins
agent saw: priya's message
20:44:44.090 * restart_worker(service="payments")
`- caused by priya | recorded by the agent | 1.0
20:44:44.090 * output: Restarted the payments worker.
`- caused by priya | recorded by the agent | 1.0
20:44:44.090 - turn ends (ok)
A session is a directory, not a file: hansard.session() opens one
append-only .jsonl segment per writer, so concurrent callers (one per
person, one per agent process) never contend on the same file. context=
and caused_by= are optional β omit them and Hansard falls back to
inference β but passing them is what turns a guess into a recorded fact; see
the comparison below.
flowchart LR
A[Person A's client] -->|writes| SA[(w_a.jsonl)]
B[Person B's client] -->|writes| SB[(w_b.jsonl)]
AG[Agent process] -->|writes| SC[(w_agent.jsonl)]
subgraph DIR[Session directory]
SA
SB
SC
end
DIR -->|read_session merges by ts, w, seq| M[Merged event stream]
M --> E[Attribution engine -- read time, never written back]
E --> C[hansard replay / inspect / verify]
Write and read are deliberately separate concerns. On the write side
(recorder.py's Session/Turn, diagrammed above), nothing about
causality is ever decided -- the log only ever records what actually
happened: who sent what, what the agent did, and any 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), and attribution/'s
engine walks that stream applying eight rules in a fixed order, each one
a fallback for the last: recorded facts first (explicit,
turn_context, and their less-common siblings), then cascade (a
retry inherits its cause from the action it's retrying), then
timing-based guesses (temporal, contested) for whatever's left. This
happens fresh on every read and is never written back to the log, which
is why improving a rule improves every session ever captured,
retroactively. cli/ (replay, inspect, verify) is the only
consumer of that attribution output in this repo today; the same
attribute() call is a stable, public entry point anyone could build
another reader on top of.
Every attributed action carries a method, a confidence, and a
plain-English evidence string. Four methods matter most:
explicit(1.0) β the caller passedcaused_by=[...]. A fact the runtime recorded, not an inference.turn_context(0.9) β the turn declaredcontext=[...]naming the messages the agent could see, and the action is attributed to all of them. Also a recorded fact, one level down fromexplicitbecause it names everything the agent could have acted on rather than what it actually did.temporal(0.6, or 0.4 across writer segments) β nothing was declared, so Hansard infers: the last unconsumed message before the turn began. A guess, labeled as one.contested(0.3, or 0.2 across writer segments) β two or more unconsumed messages arrived within the ambiguity window. Every candidate is reported; none is favored.
When Hansard cannot tell who caused something, it says so β one of sam or jordan | ambiguous -- several candidates | 0.2, as above β instead of
picking a winner. That's the whole design bet: an audit tool that guesses
silently is worse than useless, because it looks like ground truth.
The strongest argument for actually wiring caused_by/context through: 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 (--no-context-hints) |
0.33 | temporal, contested, cascade |
Both figures are from one real, captured pair of live runs against the same
demo agent and identical script β they are illustrative of the size of the
gap, not universal constants; a different model or a different messy
script will land on different numbers. What doesn't vary is the direction
and the reason: declaring causality moves attribution from
inferred-and-uncertain to recorded-and-exact. Both sessions are tracked at
examples/ β no gitignored path, no take-our-word-for-it β so
running hansard inspect examples/with-context-hints/s_x --json and
hansard inspect examples/without-context-hints/s_x --json reproduces
these exact numbers yourself. The cost of the higher number is 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=[...]). Everything
else about the call is identical either way.
hansard replay <session_dir> # ordered transcript, attribution inline
hansard verify <session_dir> # integrity check: seq gaps, missing footers, unresolved refs
hansard inspect <session_dir> # one-screen summary + attribution method breakdown
hansard prune <dir> --older-than 30d [--yes]
replay, verify, and inspect are read-only. prune is the one
destructive command β dry-run by default, printing what it would delete
without touching anything; pass --yes to actually delete. Age is judged
from each session's own recorded timestamps, not filesystem mtime, so a
restored backup can't fool it into keeping the wrong sessions.
hansard replay --html <session_dir> renders the same transcript as a
single self-contained HTML file β no external requests, collapsible turns,
a user filter β for sharing with someone who wasn't there and doesn't have
hansard installed.
uv add "hansard[langgraph]"
HansardCallbackHandler plugs into a LangGraph agent's own callback
machinery and auto-populates caused_by/context from what the graph
actually did β no manual plumbing at each turn.action()/turn.output()
call site.
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]},
)Two things to know before relying on this:
- Give every
HumanMessagea stable.id. Without one, a host app that replays its accumulated message history intograph.invoke()on every call (i.e. isn't using a LangGraph checkpointer) will get a duplicatesession.message()record for the same logical message each time. - The handler's
contextset accumulates every message it has ever resolved for the life of the handler instance β it never shrinks. For a long-lived handler/session,turn_contextattribution ends up naming every participant who has ever spoken, not just people relevant to that turn. This is deliberate: a narrower "only this invocation's input state" policy would maketurn_contextunreachable for checkpointer-based apps, whoseinputstypically carry only new messages.
CrewAI and Claude Agent SDK adapters are tracked as future work, not yet started.
On by default. Every session is scrubbed for high-confidence secret shapes
β API keys (sk-β¦, ghp_β¦, AKIAβ¦), bearer tokens, PEM private key
blocks, and password=/api_key=/secret=-style assignments β everywhere
caller data enters the log: message text, agent output text, notes,
correction values, session meta, tool call args, and tool results, before
any of it touches disk. user_id is never touched: identity is the one
thing the whole product depends on being exact.
Deliberately not redacted by default: emails, names, URLs, IP addresses.
Those are frequently the actual content of a session, and a transcript full
of [REDACTED] is worth less than one that reads. Opt in with
hansard.Redactor(optional={"email", "url", "ip_address"}), or disable
scrubbing entirely with redact=None for callers with their own upstream
redaction. Every scrub records what kind of thing was removed
(redacted: ["api_key"]) without showing what it was.
Shipped: the capture SDK, the attribution engine, the CLI (replay,
verify, inspect, prune), the HTML transcript export, and a
first-party LangGraph adapter.
Next up: CrewAI and Claude Agent SDK adapters. Not currently planned: a visual timeline UI, permissions/ approval workflows, or pluggable storage backends β the log format is deliberately the whole surface area for now. Have a use case that needs one of these, or something else entirely? Open an issue.
Issues and PRs are welcome. uv sync, uv run pytest before opening a
PR β see CONTRIBUTING.md for repo-specific notes (docs
deployment, that kind of thing).
MIT.