TL;DR — Most agent frameworks ask you to draw a graph. A knowledge question
doesn't have a fixed path, and you can't ship an answer you can't audit. We built
reactifact: think Celery, but tasks wake on typed artifacts, not messages.
It's pip install reactifact, 3 core deps, and a run you can reproduce.
We kept drawing graphs we couldn't finish
We were building a devops knowledge assistant. Ask it a real question —
"why did our infra costs jump in Q2?" — and answering it needs a Confluence
page, two GitLab MRs, a CSV of cloud costs, some arithmetic, a verification step,
and maybe a follow-up question. The next question needs a different subset of
all that.
Every framework we tried asked us to draw that path: nodes, edges,
conditional routing, a shared state dict. But there is no universal graph. The
path depends on the question and on the data. Encoding every combination as edges
turned into a wiring exercise — and the framework still couldn't tell us why it
produced the answer it produced.
So we stopped describing execution and described state instead.
The idea: state is primary, execution is derived
Instead of "node A then node B", you declare:
-
what artifacts exist — typed objects like
Question,Evidence,Claim,Calculation,Answer; -
what each agent consumes and produces —
Consume(Evidence),produces=Answer.
The runtime looks at what just changed and derives what runs next. Nothing calls
anything. There is no graph to draw — the schedule is a function of state.
If you know Celery, you already know the model:
| Celery | reactifact |
|---|---|
| a task | @produce(Model) |
delay() / apply_async()
|
you don't call it — creating the input artifact is the trigger |
| routing key / queue | Consume(Type) |
| chain / group / chord | several consumes / produces; the runtime derives the order |
retries, acks_late
|
guards + Budget, an honest None instead of a wrong result |
| result backend | the Context — typed, versioned artifacts |
| worker | Runtime |
Single process today (no broker, no worker pool) — it's a Celery-shaped model,
not its distributed runtime.
The second thing we couldn't get: an answer you can audit
The other half of the problem is trust. An LLM writes confident prose and
unreliable arithmetic, and most frameworks leave "who computed this number, from
which source?" to whatever logs you remembered to add.
In reactifact, the answer is an artifact like everything else:
-
Calculations are computed, not generated. A
Producedoing arithmetic over a CSV computes it in plain Python and hands the model the result to explain — never the raw numbers to guess from. -
Every derived artifact links to its inputs —
Answer —supported_by→ Variance —calculated_from→ Spend, Table. That's a queryable graph, not a log line. -
A run is reproducible. Artifacts are versioned, and a content hash
(
context_hash) is identical across deterministic runs, so you can re-run and verify:reactifact replay <store> --session <id> --verify <hash>. -
The audit is a report.
reactifact.auditrenders the answer with a sha256 per artifact, its producing author, and the provenance edges.
Proof, offline, in one command
examples/fintech_audit answers a finance question — "what's the Q2 cloud spend
variance, and does policy require approval?" — from a transactions CSV, a budget
CSV, and a policy document. No API key, nothing leaves the machine:
$ python -m examples.fintech_audit.main # from a repo checkout, no API key
variance vs budget: +12.5% ($45,000 actual vs $40,000 budget, 10% policy threshold → over)
answer: Q2 cloud spend was $45,000 against a $40,000 budget (+12.5%) —
exceeds the 10% policy threshold. CFO approval is required.
audit: Answer —supported_by→ {Variance, Spend, Table, Policy}
answer sha256 5461290d… · context sha256 24449f6f…
Run it twice: the hash is the same. Change a fact, and only what actually
consumed it recomputes — because Artifact.version tracks real reads, not a
graph edge someone drew by hand.
The number is computed, and its provenance recorded, in plain Python (trimmed
from examples/fintech_audit/produce.py):
@produce(Variance, reacts_to=Spend) # wakes when a Spend artifact exists
async def compute_variance(call: ProduceCall) -> None:
spend = call.trigger # the artifact that triggered this run
budget = cloud_budget(call.context) # read from budget.csv
pct = (spend.data.total - budget) / budget # deterministic — never the model
variance = call.effects.create_once_from(
spend, # stable id → idempotent re-runs
Variance(actual=spend.data.total, budget=budget, pct=pct,
threshold=0.10, within_policy=abs(pct) <= 0.10),
)
variance.link("calculated_from", spend) # provenance edge, queryable
What you get beyond the model
A task queue gives you execution. Layering state, versions, and provenance on top
of the type system gives you a few things for free:
- Determinism where it matters — compute in code, let the model reason.
-
Branching and merge — fork an exploration, run different agents on each
fork, three-way
merge()with an explicit conflict instead of silent last-write-wins. -
Human-in-the-loop as state —
effects.ask(...)is just another effect; a human is another reaction, not a special case. - Replay — reproduce a past run from its recorded model calls and commit chain, offline.
-
MCP both ways — call any MCP server's tools, or expose your own
Contextas one.
And it's small: three core dependencies (pydantic, httpx, python-dotenv),
fully typed, no framework-of-the-week dependency tree.
Honest limits
reactifact is pre-1.0 (0.10.0), maintained by one person, and
single-process today — no broker, no worker pool, no managed platform. If you
need a mature ecosystem and hosted execution right now, use LangGraph; it's a
better fit. reactifact is for teams building open-ended, auditable knowledge
agents — internal assistants over docs, ops/incident tooling, finance/compliance
workflows — where "why did it say that?" has to have an answer.
Try it
pip install reactifact
Repo: https://github.com/bzdvdn/reactifact · Docs and offline demos:
https://bzdvdn.github.io/reactifact/
If you build agents over docs, CSVs, or repos, I want to hear what breaks for
you. That's more useful to me than a star.

Top comments (4)
Author here — some background that didn't fit in the post.
We didn't set out to build a runtime. We were building a devops knowledge
assistant and kept hitting two walls:
So we inverted it: declare typed artifacts and what each agent consumes and
produces, and let the runtime derive the schedule from state changes. The Celery
mapping in the post is the honest mental model — a Celery-shaped model, not a
Celery-shaped runtime (it's single-process today).
Two things I'd genuinely like your take on:
If you run
examples/fintech_audit, tell me if the offline demo works on yourmachine — that's the part I'm most worried about breaking. Happy to answer
anything about the internals: versioning, merge conflicts, replay, MCP.
Thanks — you've put your finger on the exact seam where the model is strong in one half and deliberately incomplete in the other.
The split already exists at the state layer. A produce never mutates anything; it emits effects (create/update/link/ask) and the runtime compiles them into one atomic Patch. Commits are deterministic and revisioned, so replay is state reconstruction, not re-execution: replay walks the commit chain and rebuilds the Context at a version without running any agent. The transfer unit you describe is literally that snapshot — artifacts, versions, relations, provenance, pending triggers — not a transcript.
The half that's not modeled yet is the environment effect: an email, a webhook, a deploy. Today those are ordinary I/O inside a produce, and replay doesn't re-fire them because it doesn't run produces — but a live re-run (crash resume is at-least-once) could, which is exactly the gap.
The direction I'd take before distributing execution is to make the distinction first-class:
pulls (resolve a Reference, fetch a page) are already idempotent via stable ids (SourceRef.stable_id, create_once/upsert);
pushes (notify, pay, act) become a recorded intent artifact with a stable id derived from content/context, with dispatch gated on that id (create_once(id=…) folds in the "already done" guard). The action becomes a function of state, and replay reads the record instead of re-sending.
That's a transactional outbox in artifact clothing, and it's what makes your merge case tractable: a content-derived key means two branches that independently reach "notify X" converge to a single intent after merge; two different actions surface instead of being silently duplicated. Three-way merge already refuses conflicting writes; an explicit merge policy for outbound actions is the piece still missing.
Caveat, as on the page: single-process, at-least-once, no cross-process exactly-once. But your framing — the unit you transfer is state, not a transcript — is the property I most want to protect. An Action/intent artifact with a stable key may be the right primitive; how would you draw that boundary?
Quick follow-up, since this is now shipped: reactifact 0.14.0 is out, and your question is exactly what the new outbox is built around.
You can now make the two explicit instead of blurred:
a replayable computation is a normal effect (create/update/link) — committed state, reconstructed on replay;
an external side effect is recorded as an intent, not performed: self.effects.act(kind=..., key=..., payload=...) writes a PendingAction under a stable id (action:{key}), atomically with the rest of the patch, and Runtime(dispatcher=...) performs it after the commit, once per id, recording dispatched/failed as state.
Which gives exactly what you asked for:
Replay reconstructs, never re-sends — replay walks the commit chain and rebuilds the Context without running the runtime, so a recorded notification is read back as state, not re-fired.
Retries don't duplicate — a re-run re-derives the same stable id, so act returns None and no second intent exists.
Two merged branches converge — the same intent on both forks shares the id and merges to one; the dispatch bookkeeping is excluded from the three-way signature, and the dispatched side wins whichever branch is the merge target, so a merge never resurrects an already-sent action. Divergent payloads under one id are an explicit MergeConflict, not a silent pick.
It stays single-process and at-least-once — the framework can't make the I/O exactly-once, so the intent carries idempotency_key for the external system to dedupe, and it deliberately ships no background relay or backoff (that policy stays with the app). There's an offline, deterministic demo, examples/outbox, that walks seven cases: commit→dispatch, re-derivation, same-generation duplicate, merged branches, replay, failure→retry, and an app-owned retry wrapper. Release notes: github.com/bzdvdn/reactifact/relea...
Also in the release, for the ops side you'll hit next: correlated structured logging (configure_logging()), and a hard per-turn deadline with graceful shutdown (request_stop()/ashutdown(), plus an in-flight registry).