Skip to main content

A minimal, local-first execution journal for AI agents: goals, justifications, artifacts, and approvals in a replayable graph.

Project description

execution-journal

tests python license status

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.

The HTML journal viewer

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:

Searching a run in the viewer

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

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_tool adds the parameter there rather than to a JSON schema.
  • create() forwards tools to the provider only when max_turns is set, so the wrapper defaults it to 1. Without it aisuite drops tools silently.
  • aisuite 0.1.14 pins docstring-parser <0.16, which uses ast.NameConstant — removed in Python 3.12. So the aisuite extra runs on Python 3.10–3.11 only, and it imports pydantic without 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

execution_journal-0.1.0.tar.gz (50.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

execution_journal-0.1.0-py3-none-any.whl (21.5 kB view details)

Uploaded Python 3

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

Hashes for execution_journal-0.1.0.tar.gz
Algorithm Hash digest
SHA256 e7a0425d372b4dc0b4582180329206c6f95edd2493f85f355d3be40a739729b7
MD5 13861812ef7bfa3358578be55ffa61f2
BLAKE2b-256 9c0938ce8af37ec790546de0e272bdb9d5f1891df0e0dfac903d5d943ced3e59

See more details on using hashes here.

File details

Details for the file execution_journal-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for execution_journal-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 efb4fed546d297d959c1c7f419dc9509679388f92f6024cfad1b9eb4bc1e8cf2
MD5 e6d484ff0ae797af81981123c174f7b3
BLAKE2b-256 2734b4a27c9dc3623b5ecb69abd8ba36f7d20f103d18d00f6020b3a356ae5e0f

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page