A minimal, local-first execution journal for AI agents: goals, justifications, artifacts, and approvals in a replayable graph.
Project description
execution-journal
Logs record what happened. A journal records why: goals, justifications, artifacts, and approvals, linked into a graph you can search and replay.
A minimal, local-first execution journal for AI agents. One SQLite file per project, zero dependencies in the core, and a decorator or context manager as the entire integration cost.
Status
Experimental, pre-1.0. The event schema and the Journal API are the parts I expect to keep;
everything else may move. Treat minor versions as breaking until 1.0.
What works today: the schema, SQLite storage, the three exports, replay, and Tier 2 justifications against Anthropic, OpenAI, and aisuite. 91 tests, CI on Python 3.10 and 3.12, zero runtime dependencies, ~0.1 ms of overhead per step.
What is deliberately not built yet — each has an issue:
a CLI, an index page across runs, LangGraph and OpenAI Agents SDK adapters, Tier 3 framework
extraction, an OpenTelemetry exporter. There is no dashboard and no server — you get a
.db file and a self-contained HTML page you generate on demand. If you need a team-visible
view over a fleet of agents, this is not that tool.
Bug reports and adapters are the most useful contributions right now — see CONTRIBUTING.md.
Quickstart
from execution_journal import Journal
journal = Journal(goal="Answer the user's question about the approval gate")
with journal.tool_call("repo_search",
justification="The question names a specific codebase; grounding beats recall.",
query="approval gate") as step:
step.record_output(n_hits=3, top_file="agent/approvals.py")
journal.approval("email_findings", granted=False, by="user", reason="Answer locally.")
journal.finish("Answered from code evidence; no email sent.")
That is the whole adoption story: construct a Journal, wrap the steps you care about, call finish. Everything lands in .execution_journal.db, which you can search, export, replay, or attach to a bug report as-is.
# PyPI release pending; install from source for now
pip install git+https://github.com/Harsh-Dhingra/execution-journal
python examples/demo_agent.py # no API keys, no network — writes a journal and all three exports
The viewer
export_html writes a single self-contained file that opens from file:// — execution tree on the left, entries on the right, justification set off from everything else. Light and dark follow the system theme.
Search filters the entries and the tree together — here, narrowing a run to the failed doc fetch and the retry that recovered it, one justification written by the model and one by the developer:
Both are the demo run that ships with the repo. Regenerate with
python examples/demo_agent.py && open examples/demo_output/demo.html.
A journal is not a log
Three families of fields distinguish a journal entry from a log line:
| Fields | Answers | |
|---|---|---|
| Intent | goal, justification |
What was this step for, and why this step now? |
| Evidence | inputs, outputs, artifacts (content-hashed) |
What went in, what came out, which file came from which step? |
| Authority | approval (by, granted) |
Who allowed the side effect — and who refused one? |
Plus parent_id on every event, which makes the run a graph rather than a flat timeline.
| Application logs | Cloud tracing (LangSmith, Langfuse, OTel GenAI) | execution-journal | |
|---|---|---|---|
| Chronology, latency, tool sequence | ✅ | ✅ | ✅ |
| Intent + justification as fields | ✕ | ✕ | ✅ |
| Approval / authority semantics | ✕ | ✕ | ✅ |
| Content-hashed artifact provenance | ✕ | partial | ✅ |
| Local-first, no account or server | ✅ | ✕ | ✅ |
| Deliverable you can attach to a ticket | ✕ | link | the .db file |
A recorded entry:
{
"run_id": "a3f9c2e11b04",
"event_id": "7d1e0aa93c55",
"parent_id": "f21bb8d0447a",
"kind": "tool_call",
"name": "github_search",
"goal": "Find repository context before answering",
"justification": "Question names a specific codebase; grounding beats recall.",
"status": "completed",
"ts_start_ms": 1753715000123,
"ts_end_ms": 1753715000963,
"duration_ms": 840,
"inputs": {"query": "approval gate", "repo": "andrewyng/openworker"},
"outputs": {"n_hits": 3, "top_file": "coworker/approvals.py"},
"artifacts": [],
"approval": null,
"error": null,
"meta": {}
}
Failures are entries too. An exception inside a step is recorded with its type and message, marked failed, and then re-raised — the journal never swallows it.
Where the "why" comes from
Every justification records its own provenance in meta.justification_source.
Tier 1 — developer-declared. You write it at the call site. Reliable for deterministic paths — retries, fallbacks, verification steps — where the reason is a design decision. Static: the same string every run.
journal.tool_call("fetch_docs_retry", justification="One retry against the cached mirror before falling back.")
Tier 2 — model-declared at decision time. A required justification parameter is injected into every tool schema before it reaches the model, so the same generation that picks an action also states why:
{"name": "github_search",
"arguments": {"query": "approval gate",
"justification": "The question references OpenWorker's codebase; I need the approval implementation before answering."}}
The integration strips the field before the real tool runs — your tool function never sees it — and writes it to the entry with meta.justification_source = "model". Cost is a few tokens per call. If a model omits it despite required, the entry records justification: null and meta.justification_missing: true; nothing is invented.
Tier 3 — framework-extracted. Harvesting ReAct "Thought:" lines and planner scratchpads from framework internals. On the roadmap, not in v0.1.
The faithfulness caveat
A model-declared justification is the agent's stated reason at decision time — not verified ground truth. Models can produce plausible post-hoc rationalizations, and the reasoning-faithfulness literature is clear that a stated reason need not be the operative one. This project's claim is deliberately narrow: it records asserted intent, with provenance and timing, which is the record an auditor asks for and a debugging signal that localizes where the agent's world-model went wrong. Verifying that a justification is true is out of scope.
Architecture
your agent → instrumentation → journal event model → one SQLite file
@step_fn / step() intent · evidence ↓ ↓ ↓
tool_call() authority · parent_id viewer replay exports
Instrumentation knows about frameworks; the event model does not. Storage knows nothing about semantics — it persists indexed columns plus the full event JSON, so the schema can gain optional fields without a migration. Viewer, replayer, and exporters are peers that only read.
Exports
from execution_journal import export_json, export_markdown, export_html
export_json(db_path, run_id, "journal.json") # machine-readable
export_markdown(db_path, run_id, "journal.md") # readable diff-able record
export_html(db_path, run_id, "journal.html") # self-contained viewer, no external requests
Replay
Recorded inputs turn a flaky one-off failure into a deterministic replay target. v0.1 replays the decisions in order and hands each recorded event to a handler you register — no state snapshotting.
from execution_journal import Replayer
replayer = Replayer(".execution_journal.db")
replayer.register("repo_search", lambda event: search(**event.inputs))
for name, result in replayer.replay_from(run_id, event_id):
print(name, result)
Tier 2 in your own tool loop
The integrations/ modules are provider adapters — copy the two files you need next to your agent
(justification.py plus one adapter). They import no SDK, so they work with the Anthropic SDK, the
OpenAI SDK, raw HTTP, or anything speaking either wire shape.
from execution_journal import Journal
from provider_journal import journaled_tools, tool_results
journal = Journal(goal="Answer the user's question about the approval gate")
response = client.messages.create(
model="claude-opus-5",
max_tokens=4096,
tools=journaled_tools(TOOLS), # required `justification` injected into each schema
messages=messages,
)
results = tool_results(journal, response, {"github_search": github_search})
messages += [{"role": "assistant", "content": response.content},
{"role": "user", "content": results}]
Every tool call becomes a journal entry carrying the model's own justification, and results is
already shaped for the provider you called — tool_result blocks for Anthropic, role: "tool"
messages for OpenAI. Your tool functions never see the injected argument.
The field lives in a different place in each schema dialect, and all three are handled:
| Dialect | Where the schema lives |
|---|---|
| Anthropic Messages | input_schema |
| OpenAI chat completions | function.parameters |
| aisuite | generated from the function signature — see below |
Prefer to dispatch yourself? tool_calls(response) returns one dict per call — name, args with
the justification already stripped, justification (None if the model omitted it, never
fabricated), id, and provider — and you open the journal entry however you like.
aisuite integration
Optional: install the [aisuite] extra. JournaledClient journals every completion (hashing
message content rather than storing it) and injects the Tier 2 parameter into your tools.
import aisuite
from execution_journal import Journal
from aisuite_journal import JournaledClient
journal = Journal(goal="Answer the user's question")
client = JournaledClient(journal, aisuite.Client())
response = client.chat.completions.create(
model="anthropic:claude-opus-5",
messages=[{"role": "user", "content": question}],
tools=[github_search], # plain Python callables, as aisuite expects
)
# each tool call is journaled with the model's own justification
Verified end-to-end against aisuite 0.1.14 with a stub provider. Three upstream constraints are worth knowing, and they are why the provider adapter above exists:
- aisuite builds each tool schema from the function signature and docstring, so
justified_tooladds the parameter there rather than to a JSON schema. create()forwardstoolsto the provider only whenmax_turnsis set, so the wrapper defaults it to 1. Without it aisuite drops tools silently.- aisuite 0.1.14 pins
docstring-parser <0.16, which usesast.NameConstant— removed in Python 3.12. So the aisuite extra runs on Python 3.10–3.11 only, and it importspydanticwithout declaring it, which is why our extra pulls it in. Neither limitation touches the core library or the provider adapter, both of which are stdlib-only on 3.10+.
Roadmap
LangGraph and OpenAI Agents SDK adapters · OpenTelemetry span export mapping journal fields to GenAI semantic conventions · run-vs-run diffing · multi-agent runs · signed/tamper-evident journals · richer replay with state snapshots
Where this sits
Between cloud tracing SaaS (chronological spans, no intent or approval semantics, and your run history lives on someone else's server) and heavyweight governance machinery (cryptographic receipts, formal policy engines). This is the lightweight embeddable middle: a minimal schema, in a local file, at a decorator's adoption cost.
The June 2026 survey From Agent Traces to Trust: Evidence Tracing and Execution Provenance in LLM Agents (arXiv:2606.04990) maps out this gap; execution-journal is a concrete minimal implementation of what it calls for.
Citation
@misc{execution-journal,
title = {execution-journal: a minimal local-first execution journal for AI agents},
author = {Dhingra, Harsh},
year = {2026},
note = {arXiv ID pending}
}
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
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 execution_journal-0.1.0.tar.gz.
File metadata
- Download URL: execution_journal-0.1.0.tar.gz
- Upload date:
- Size: 50.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e7a0425d372b4dc0b4582180329206c6f95edd2493f85f355d3be40a739729b7
|
|
| MD5 |
13861812ef7bfa3358578be55ffa61f2
|
|
| BLAKE2b-256 |
9c0938ce8af37ec790546de0e272bdb9d5f1891df0e0dfac903d5d943ced3e59
|
File details
Details for the file execution_journal-0.1.0-py3-none-any.whl.
File metadata
- Download URL: execution_journal-0.1.0-py3-none-any.whl
- Upload date:
- Size: 21.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/7.0.0 CPython/3.14.4
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
efb4fed546d297d959c1c7f419dc9509679388f92f6024cfad1b9eb4bc1e8cf2
|
|
| MD5 |
e6d484ff0ae797af81981123c174f7b3
|
|
| BLAKE2b-256 |
2734b4a27c9dc3623b5ecb69abd8ba36f7d20f103d18d00f6020b3a356ae5e0f
|