Hansard
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.
Below is real, unedited, unabridged output from hansard replay against a
session 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.)
Install
uv add hansard
Zero runtime dependencies — Hansard adds nothing to your dependency tree. Requires Python 3.13+.
Integrate in about fifteen minutes
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 next
Then, 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.
The confidence model
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 measured comparison
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.
CLI
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.
Redaction
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.
Status
v0. The log format is the product at this stage — get it right before building on top of it. Explicitly out of scope for now: a visual timeline UI, permissions/approval workflows, proxy/middleware distribution, pluggable storage backends, an LLM-judge attribution pass, and adapters for specific agent frameworks. Text replay via the CLI is the whole interface.
MIT licensed.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file hansard-0.1.0.tar.gz.
File metadata
- Download URL: hansard-0.1.0.tar.gz
- Upload date:
- Size: 322.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
53fc05463246e1fb94f15de3e6b7f91b3bc3987c0984c09a299ddf6d93c4386d
|
|
| MD5 |
80305eb7906b9366932ce8f16aeaa1b2
|
|
| BLAKE2b-256 |
5eef4b27db1c3a1316e8caaf52d47b1eda586ad34f486146f401d7eff54ca4a2
|
Provenance
The following attestation bundles were made for hansard-0.1.0.tar.gz:
Publisher:
release.yml on iamfaham/hansard
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
hansard-0.1.0.tar.gz -
Subject digest:
53fc05463246e1fb94f15de3e6b7f91b3bc3987c0984c09a299ddf6d93c4386d - Sigstore transparency entry: 2442045838
- Sigstore integration time:
-
Permalink:
iamfaham/hansard@b68ee3bc1050ff5396a68ae84968fc4ddfaf254d -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/iamfaham
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@b68ee3bc1050ff5396a68ae84968fc4ddfaf254d -
Trigger Event:
release
-
Statement type:
File details
Details for the file hansard-0.1.0-py3-none-any.whl.
File metadata
- Download URL: hansard-0.1.0-py3-none-any.whl
- Upload date:
- Size: 125.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
973c03b34d827630f72ef04c2fafb6beb644704c92e441ab54c02c5dd5b52957
|
|
| MD5 |
e9b8cfbbaf41da19f3b3715cd164b042
|
|
| BLAKE2b-256 |
4359c6090e016d76dc46dfaa5b9c46964866a1d443760133a4d9fb8646618064
|
Provenance
The following attestation bundles were made for hansard-0.1.0-py3-none-any.whl:
Publisher:
release.yml on iamfaham/hansard
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
hansard-0.1.0-py3-none-any.whl -
Subject digest:
973c03b34d827630f72ef04c2fafb6beb644704c92e441ab54c02c5dd5b52957 - Sigstore transparency entry: 2442046038
- Sigstore integration time:
-
Permalink:
iamfaham/hansard@b68ee3bc1050ff5396a68ae84968fc4ddfaf254d -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/iamfaham
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@b68ee3bc1050ff5396a68ae84968fc4ddfaf254d -
Trigger Event:
release
-
Statement type: