Skip to main content

Chronicle

Record what your agent did. Replay a request without the LLM.
The edge SDK of the AgentPlane suite: record at each decision point, save to the control plane, replay a recorded request to test a fix.

CI PyPI arXiv Python License: MIT Typed Ruff

To appear at REALM (Research on Agent Language Models), a workshop at EMNLP 2026


What it is · Quickstart · How it fits · Scope · Configuration · Versioning · Development · Roadmap · Support

1.0 is a clean break from 0.5. Chronicle now records onto the shared agentplane-primitives schema and saves to the control plane over HTTP. Local stores, fixtures, the CLI and the visualizer are gone. Read the migration note before upgrading.

What it is

Chronicle runs inside your agent's own process and does three things:

  1. Record what the agent did at each decision point (a boundary: an LLM call or a tool call).
  2. Save it to the control plane, the shared HTTP service that stores, queries and visualizes traces.
  3. Replay one recorded request so a fix can be tested without live LLM calls.

It stores nothing locally, draws nothing and does not export to OpenTelemetry. Those belong to the control plane. That is what makes Chronicle small enough to live in every agent.

One lifecycle @boundary, wrap(client), wrap_llm and the instrumenters all go through one interceptor.
Only two kinds A boundary is an llm call or a tool call. A router is one or the other.
Cost fields that never move output.provider, output.model and exclusive, additive output.usage buckets are filled the same way for every SDK and agent framework.
Raw is always stored The input and output exactly as they crossed the boundary, so replay needs no reverse mapping.
Transparent The caller always gets the real return value or the real exception. Only a hook's AbortCall can stop a call.
Cross-service traces The W3C traceparent header carries the trace; A2A, plain HTTP and any httpx based protocol are covered.
Safe by default traceparent goes only to hosts you list; redaction runs before anything is sent.

Quickstart

pip install agent-chronicle

Point Chronicle at a control plane (environment variables work too, see Configuration):

import chronicle

chronicle.configure(
    control_plane_url="http://control-plane:8000",
    api_key="...",
    service_name="support-agent",
    inject_hosts=["billing-agent.internal"],  # peers that receive the trace header
)

1. Wrap your model client. Every model call is recorded; streaming (stream=True) is not supported and passes straight through.

from openai import OpenAI

client = chronicle.wrap(OpenAI())
client.chat.completions.create(model="gpt-4o", messages=[...])  # recorded

wrap detects OpenAI, Azure OpenAI, Anthropic and LiteLLM from the client type. An unknown client records provider = "unknown" and usage = null.

2. Mark your own boundaries. Put @boundary on decision nodes, not on the whole agent runner:

@chronicle.boundary("search", kind="tool")
def search(query: str) -> dict: ...


@chronicle.boundary("planner", kind="llm", provider="openai")
async def planner(model: str, messages: list[dict]) -> dict: ...

Arguments are recorded by name (see how arguments are named).

3. Open a span per request. The ASGI middleware does it for you; for anything else use span:

app.add_middleware(chronicle.ChronicleMiddleware, service="support-agent")  # FastAPI, Starlette

with chronicle.span(headers) as session:  # a worker, a CLI job: join the caller's trace
    handle(message)

4. Follow calls to other agents. Turn on the httpx instrumenter once. Calls to the hosts in inject_hosts are recorded as tool envelopes and carry the traceparent header, so the other agent's span is parented on that call:

chronicle.instrument_httpx()

5. Replay a recorded request. A span is the unit of replay. Fetch it by id, stub everything, or run one boundary live to test a fix (a cut-point):

from chronicle import ReplayPlan

with chronicle.replay_trace(trace_id, span_id, ReplayPlan().run("delete_file", 1)) as run:
    run_agent(message)  # the same code, boundaries intercepted
    assert run.captured_result("delete_file", 1)["blocked"] is True

Boundaries not named in the plan return their recorded output.raw and make no call. Nothing is written during replay.

When you need more

You want to Use
Stop a call, or govern spend A hook that raises AbortCall
Keep secrets out of what is sent configure(default_redactors=True) and your own redactors
Declare a retry with chronicle.attempt(2, of=first_envelope_id):
Instrument a LangGraph chronicle.instrument(graph)
Call a peer from your own client chronicle.inject(headers) inside a boundary
Know what may change between releases Versioning

Integrating with your framework

Chronicle hooks in at two places: the model call (wrap the client) and your tools (mark them with @boundary). The saved model call has the same canonical fields whichever framework made it. Runnable versions of all of these are in examples/frameworks.py.

You use Wrap the model call with Tools
OpenAI SDK chronicle.wrap(openai.OpenAI()) @chronicle.boundary
Anthropic SDK chronicle.wrap(anthropic.Anthropic()) @chronicle.boundary
LangChain chronicle.wrap(chat.client) (the SDK resource under ChatOpenAI) @chronicle.boundary
LangGraph chronicle.wrap(openai.OpenAI()) in the model node chronicle.instrument(graph)
Your own HTTP calls chronicle.wrap_llm("llm.chat", call_provider, provider="openai") @chronicle.boundary
import chronicle

# OpenAI SDK, and the same for anthropic.Anthropic()
client = chronicle.wrap(openai.OpenAI())
reply = client.chat.completions.create(model="gpt-4o-mini", messages=messages, tools=tools)

# LangChain: wrap the SDK client LangChain holds, then use the chat model as usual
chat = ChatOpenAI(model="gpt-4o-mini", use_responses_api=False)
chronicle.wrap(chat.client)
model = chat.bind_tools(tools)

# LangGraph: wrap the model client, then instrument the graph (every node and the router)
graph = StateGraph(State)
graph.add_node("agent", agent_node)
graph.add_node("tools", tools_node)
app = chronicle.instrument(graph).compile()

# No SDK: wrap the function that calls the provider
chat = chronicle.wrap_llm("llm.chat", call_provider, provider="openai")


# Any framework: a tool is a decision point
@chronicle.boundary("get_weather", kind="tool")
def get_weather(city: str) -> dict: ...

Then open one span per request (chronicle.record(...), or the ASGI middleware) so the calls are saved as one trace. The examples/ run against a live model and a real control plane: record_and_replay.py (record an incident, prove the fix by replay), multi_agent.py (one trace across two services), frameworks.py and incidents.py.

How it fits

flowchart LR
    P["primitives<br/>Trace · Span · Envelope"]
    C["Chronicle<br/>edge SDK: record, save, replay"]
    CP["Control plane<br/>storage, query, visualization"]
    T["TokenOps<br/>governance"]

    C -->|imports| P
    CP -->|imports| P
    T -->|imports| P
    C -->|"traces over HTTP"| CP
    T -->|"hooks, cost data"| C

Three tiers describe a request: a Trace is one request across services, a Span is one service's handling of it (one agent run in one process, and the unit of replay), and an Envelope is one boundary crossing inside the span. A downstream span's parent is the caller's outbound-call envelope; that is the only link between services.

Chronicle never defines its own copy of these. It imports them from agentplane-primitives.

Scope

In scope Out of scope (here)
Record, save and replay a request Storage, query, visualization and OpenTelemetry export (control plane)
@boundary, wrap, wrap_llm, LangGraph, ASGI and httpx instrumenters Streaming capture and replay
OpenAI-style (also Azure, LiteLLM) and Anthropic adapters Gemini, Bedrock, the OpenAI Responses API
Hooks, redaction, retries, intent capture (emit_open) Events, fan-in, unfinished state, TestCase and a pytest runner
Replay by name plus call count Replay matching by input; rebuilding Python types on replay

Limitations you should know before relying on replay:

  • JSON only. Boundary inputs and outputs must be JSON-serializable (JSON values, pydantic models, dataclasses). Anything else is stored as its repr and cannot be restored.
  • Matching is by boundary name plus call count, not by input. A fix that calls a tool with different arguments or in a different order still gets the n-th recording of that tool.
  • Parallel calls can start in a different order, so the call number can shift.
  • A crash mid-call loses that envelope (emit_open off) or leaves it open (emit_open on).
  • Replay needs a reachable control plane and a trace still within its retention.

Configuration

Precedence: code (chronicle.configure(...)), then CHRONICLE_* environment variables, then the config file named by CHRONICLE_CONFIG (JSON, or TOML on Python 3.11+).

Group Setting Default Meaning
Core enabled true Master switch for recording. Does not disable hooks.
control_plane_url, api_key none Where and how to save. With no URL nothing is delivered.
service_name agent Names the service on its spans.
Capture capture_input, capture_output true Toggles.
max_payload_bytes none Cuts any one string longer than this.
Redaction redactors, default_redactors none, false Opt in; see Redaction.
Propagation extract, inject true Join an inbound header; send the header.
inject_hosts none Allow-list of hosts that get traceparent. Default deny.
Hooks hooks_fail_call_on_error false Whether a hook failure fails the call. AbortCall always propagates.
Delivery batch_size, flush_interval, max_queue 50, 1.0, 10000 Buffering.
on_overflow drop drop or block when the queue is full.
timeout, fail_open 5.0, true Network behaviour. fail_open=false is strict: a failed delivery fails the request at span end.
Trace registration trace_registration sync sync registers at the trace root; lazy leaves it to first ingest.
registration_fail_open true When the control plane is unreachable at the root.
Intent capture emit_open false Also send an open write for each envelope.

Hooks

A hook is a callback with four events: enter (before the call; may patch keyword arguments or abort), leave (after, always, if enter succeeded), crossing (the call returned and was captured) and record (the envelope was written). Hooks run in registration order and are registered on a session or as defaults:

class Governor(chronicle.Hook):
    def enter(self, call: chronicle.CallInfo):
        if over_budget(call.trace_id):
            raise chronicle.AbortCall("budget exhausted")  # always propagates


chronicle.default_hooks.register(Governor())

A call stopped by AbortCall is recorded as a failure with output.error.type = "AbortCall". What hooks may see and do (they see the arguments before redaction) is future scope for a security review.

Redaction

Redaction acts on the JSON form before anything is sent, then the canonical fields are derived from the redacted raw. default_redactors() masks API keys, tokens, JWTs and private keys; PII needs your own redactors. Pattern-based redaction is a baseline, not a guarantee. Metadata is not scrubbed.

How arguments are named

input.raw is an object by argument name: declared parameters keep their names (positional-only included; self and cls are skipped), **kwargs is flattened into top-level keys, *args keeps its declared name and holds a list. If the signature cannot be read, positional arguments are stored under args and keyword arguments are top-level keys.

Versioning

Chronicle follows SemVer from 1.0: the Python API, the configuration and the documented wire behaviour change only in a new major version. Every change is classified compatible or breaking in the changelog with the migration each consumer needs. The policy is in docs/versioning.md. Pin agent-chronicle>=1.0,<2.

The entity schema is versioned by agentplane-primitives, which Chronicle pins at the exact minor (==0.2.*).

Development

Uses uv and Python 3.10 to 3.13. On Windows run python -m pytest.

uv sync                                   # environment with the dev group
uv run python -m pytest -m "not e2e"      # fast unit and provider conformance tests
uv run python -m pytest -m e2e tests/e2e  # multi-agent systems over A2A and HTTP (localhost only)
uv run ruff check . && uv run ruff format --check .
uv run python -m mypy                     # strict type check

The end-to-end suite starts local servers on ephemeral ports (a fake LLM provider speaking the OpenAI and Anthropic wire formats, a fake control plane, and agents built with five frameworks) and needs no network beyond localhost. tests/live calls the real provider APIs only when CHRONICLE_LIVE=1 and the provider keys are set; it never runs in CI.

Roadmap

See ROADMAP.md. Next: streaming capture and replay, a local control-plane scaffold, more adapters (Responses API, Gemini, Bedrock) and a pytest runner.

Reference

Project structure
src/chronicle/               # installable package (typed; version in _version.py)
├── framework/               # in-process machinery, no network
│   ├── interceptor/         #   the one crossing lifecycle
│   ├── kinds/               #   one strategy per Kind member
│   ├── adapters/            #   one-way provider shape -> canonical shape
│   ├── hooks/ redaction/    #   hooks and AbortCall; redaction on the JSON form
│   ├── context/             #   recording session, envelope stack, attempt()
│   └── serialization/       #   JSON form of any value
├── repositories/            # the only layer that does I/O: control-plane client, delivery queue
├── capabilities/            # record and replay workflows
├── interfaces/              # boundary, wrap, middleware, httpx and LangGraph instrumenters
├── config/                  # settings: code, environment, file
└── judge/                   # LLM-as-judge (out of scope for the rewrite)
docs/rfcs/                   # the technical design
docs/adr/                    # architecture decision records
tests/                       # unit, providers (conformance), e2e (multi-agent), live (opt in)
examples/                    # runnable demos: live model, real control plane (examples/README.md)
More documentation

Support

Need Where
Bug Open an issue
API change API or schema change proposal
Question or idea Discussions
Security issue SECURITY.md

Cite

If you use Chronicle in research, please cite the paper (see CITATION.cff).


Metadata

Release files for agent-chronicle 1.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for agent-chronicle 1.1.0
File Size Uploaded
agent_chronicle-1.1.0.tar.gz 185.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for agent-chronicle 1.1.0
File Interpreter ABI Platform
agent_chronicle-1.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 280.8 kB

Release files / agent_chronicle-1.1.0.tar.gz

Download URL agent_chronicle-1.1.0.tar.gz
Size 185.6 kB
Tags Source
SHA-256 checksum
How to use checksums
5bd54709700b6e2c89aa3f460f911d823c9d5bc534a4dfc8d951e7de08822faa
BLAKE2b-256 checksum
How to use checksums
4f6a8e0c083202100e03c1e9223434347653adcb01d4d78afd73ab059259d2f1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.

Transparency log

Release files / agent_chronicle-1.1.0-py3-none-any.whl

Download URL agent_chronicle-1.1.0-py3-none-any.whl
Size 95.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b323a7cdc613a33503dc3e34093a719c72e11c670bd91564233dce4451859894
BLAKE2b-256 checksum
How to use checksums
d7c4e7254f33271bf2c1f02bb373cf2765bb4a194fb5761fc00bc24e99a676c6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 15, 2026.

Transparency log

Release history Release notifications | RSS feed

1.1.1

2 release files

This release

1.1.0 This release

2 release files

1.0.0

2 release files

0.5.0

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

0.1.3

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page