{"slug": "stop-drawing-the-graph-reactive-agents-over-typed-versioned-artifacts", "title": "Stop drawing the graph: reactive agents over typed, versioned artifacts", "summary": "A developer has released reactifact, an open-source Python agent runtime that replaces hand-drawn orchestration graphs with reactive agents that declare typed, versioned artifacts they consume and produce. The runtime watches which artifacts exist in a shared Context and fires any agent whose declared inputs are satisfied, so agents that never reference each other compose automatically; it also speaks MCP natively as both client and server. Artifacts are pydantic models carrying an id, version, and history, with built-in provenance links such as \"supported_by\" that can be queried rather than reconstructed from logs.", "body_md": "*I built this — [`reactifact`](https://github.com/bzdvdn/reactifact), a Python agent runtime that also speaks MCP natively, both as a client and a server. Here's the argument for why it exists.*\n\nA knowledge-agent question like *\"why did infra costs jump in Q2?\"* usually needs Confluence **and** GitLab **and** a CSV calculation **and**, sometimes, a human to confirm a number before it ships. The *next* question needs a different subset of those. Multiply that by a real product surface and you're not writing an agent anymore — you're maintaining a graph of `add_edge`/` add_conditional_edges` calls that has to be re-drawn every time the shape of a question changes.\n\nThat's not a LangGraph problem specifically — it's what happens whenever the orchestration *is* the code. You're modeling every path a question could take, by hand, up front.\n\n[`reactifact`](https://github.com/bzdvdn/reactifact) flips which thing you write down. You don't draw a path from A to B. You declare, per agent, **what it consumes and what it produces** — typed artifacts, not string blobs in a shared dict. The runtime watches what actually exists and runs whichever agent's `consumes` just got satisfied. Two agents that have never heard of each other compose correctly as long as one produces what the other needs.\n\nHere's the whole thing, runs offline, no API key:\n\n``` python\nfrom pydantic import BaseModel\nfrom reactifact import Budget, Consume, Context, Runtime, RuntimeResources, create_agent, produce\n\nclass Question(BaseModel):\n    text: str\n\nclass Evidence(BaseModel):\n    text: str\n\nclass Answer(BaseModel):\n    text: str\n\nDOCS = {\n    \"refund\": \"Refunds are available within 14 days of purchase.\",\n    \"pricing\": \"The Pro plan is $49/month, billed annually.\",\n}\n\n@produce(Evidence)\nasync def find_evidence(context, inputs, event, effects):\n    question = next((a for a in inputs if isinstance(a.data, Question)), None)\n    if question is None:\n        return None\n    hit = next((v for k, v in DOCS.items() if k in question.data.text.lower()), None)\n    if hit is not None:\n        effects.create(Evidence(text=hit))\n\n@produce(Answer)\nasync def answer_from_evidence(context, inputs, event, effects):\n    evidence = next((a for a in inputs if isinstance(a.data, Evidence)), None)\n    if evidence is None:\n        return None\n    effects.create(Answer(text=evidence.data.text)).link(\"supported_by\", evidence)\n\nsearch_agent = create_agent(\"search\", consumes=[Consume(Question)], produces=[find_evidence])\nanswer_agent = create_agent(\"answer\", consumes=[Consume(Evidence)], produces=[answer_from_evidence])\n\nctx = Context(resources=RuntimeResources())\nruntime = Runtime(ctx, agents=[search_agent, answer_agent], budget=Budget(max_runs=10))\n\nctx.create(Question(text=\"what's your refund policy?\"))\nruntime.run()  # search_agent and answer_agent both react — nobody wired them together\n\nanswer = ctx.latest(Answer)\nevidence = ctx.related(answer.id, \"supported_by\")[0]\nprint(answer.data.text)                     # \"Refunds are available within 14 days of purchase.\"\nprint(\"supported_by:\", evidence.data.text)  # provenance you can trace, not just a string in a log\n```\n\nNo edge between `search_agent` and `answer_agent` exists anywhere in this file. `answer_agent` fires the instant an `Evidence` artifact lands in `Context` — because it declared `consumes=[Consume(Evidence)]`, not because anyone told it \"run after search.\" Add a third agent that also produces `Evidence` from a different source next month, and `answer_agent` still fires, unmodified.\n\n**State is typed and versioned, not a dict.** Every artifact is a pydantic model with an id, a version, and history. `context.diff(v1, v2)` is a real operation — not something you reconstruct from logs after the fact.\n\n**Provenance is built in, not bolted on.** That `.link(\"supported_by\", evidence)` call above isn't a debugging add-on — it's a real edge the runtime stores. `Answer —supported_by→ Evidence —extracted_from→ Doc` is queryable. \"Why did the agent say that?\" has an actual answer instead of a grep through message history.\n\n**Calculations are calculated.** A recipe pushes arithmetic into a deterministic code path, not the model's next-token guess. In the demo below, \"$3,580\" comes from `sum()` over a CSV column, and the answer says so — not \"approximately.\"\n\nShort version, next to the two frameworks people usually compare this to:\n\n|  | LangGraph | CrewAI | reactifact | \n|---|---|---|---|\n| Primary abstraction | explicit state graph (nodes + edges) | role-based crew of agents | typed artifacts + reactive agents | \n| Control flow | you draw it | mostly fixed (sequential/hierarchical) | derived from state changes | \n| State | a shared, loosely-typed dict/ `TypedDict` | task outputs passed along | versioned, typed, immutable-per-version artifacts | \n| \"Why did it say that?\" | manual logging/checkpoint inspection | not tracked by default | provenance graph ( `supported_by` /`derived_from` /…) built in | \n| Numbers/calculations | the LLM computes unless you write a tool | same | recipes push calculation into deterministic code | \n| Rollback / branching | checkpointer + manual replay logic | not built in | `context.branch()` , three-way`merge()` , deterministic replay | \n| MCP | via `langchain-mcp-adapters` (client) | via `MCPServerAdapter` (client) | client **and** server, built in | \n| Maturity / ecosystem | high, widely used in production | high, large community | pre-1.0, one maintainer | \n\nFull version, written as a comparison and not a pitch — including where reactifact is the wrong call — in [docs/en/comparison.md](https://github.com/bzdvdn/reactifact/blob/master/docs/en/comparison.md).\n\nHere's the CLI from the `knowledge` example answering a question that touches docs *and* a spreadsheet:\n\nAnd the shape of what's actually happening — two independent agent groups, neither aware of the other, both required before the answer fires:\n\n`reactifact.mcp` (an optional extra — the core has no dependency on it) goes\n\nboth ways:\n\n`mcp_stdio_tools`/` mcp_http_tools` connect to any MCP server and\nhand back its tools as ordinary `Tool` s — usable by `ToolUse`/` LLMAgent`\nexactly like a local `@tool` function, no separate code path.`create_mcp_server(tools, context=ctx)` exposes reactifact's\nown `**kwargs` — and, with `context=`, a running `Context`'s artifacts\nas two read-only resources. Claude Desktop, Claude Code, or another agent\ncan call straight into a live reactifact app.\n\n``` python\nfrom reactifact import Consume, create_agent\nfrom reactifact.mcp import mcp_stdio_tools\nfrom reactifact.tool_use import ToolUse\n\nasync with mcp_stdio_tools(\"npx\", [\"-y\", \"@modelcontextprotocol/server-filesystem\", \"/tmp\"]) as tools:\n    fs_agent = create_agent(\"fs\", consumes=[Consume(Question)], produces=[\n        ToolUse(\"Answer questions about files in /tmp.\", tools),\n    ])\n    # fs_agent is a normal agent from here — it doesn't know or care that\n    # its tools came from another process over a pipe\n```\n\n`effects.ask(...)` → `PendingQuestion`, and resumes on the next message instead of restarting. No special \"interrupt\" plumbing.`Context.branch()` + three-way `merge()`\nI built this alone, it's pre-1.0, there's no funding and no managed platform\n\nbehind it. I'd rather say that here than have you find out after adopting it —\n\nalong with the more specific cases where it's the wrong call:\n\n`reactifact`'s `Source` abstraction is intentionally small — filesystem, CSV, embeddings, web — you write the rest (MCP narrows this specifically for tool-calling, not for retrieval).\n\n```\npip install reactifact\n```\n\nIf you've hit the \"the next question needs a different graph\" wall, I'd genuinely like to know whether this model holds up outside my own use case — issues and PRs both welcome.", "url": "https://wpnews.pro/news/stop-drawing-the-graph-reactive-agents-over-typed-versioned-artifacts", "canonical_source": "https://dev.to/bzdvdn/stop-drawing-the-graph-reactive-agents-over-typed-versioned-artifacts-43cn", "published_at": "2026-09-11 10:51:54+00:00", "updated_at": "2026-09-11 11:09:31.293453+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "ai-infrastructure"], "entities": ["reactifact", "Python", "MCP", "LangGraph", "pydantic", "Confluence", "GitLab"], "alternates": {"html": "https://wpnews.pro/news/stop-drawing-the-graph-reactive-agents-over-typed-versioned-artifacts", "markdown": "https://wpnews.pro/news/stop-drawing-the-graph-reactive-agents-over-typed-versioned-artifacts.md", "text": "https://wpnews.pro/news/stop-drawing-the-graph-reactive-agents-over-typed-versioned-artifacts.txt", "jsonld": "https://wpnews.pro/news/stop-drawing-the-graph-reactive-agents-over-typed-versioned-artifacts.jsonld"}}