Skip to content

Migrating from LangGraph / CrewAI / LlamaIndex / plain Python

You don't move everything at once. The recommended path is port one piece, keep it running next to the old code, and grow from there — reactifact is a library, it composes with whatever already works. This page is the how; the port matrix is the what maps to what, and each pattern below links to a runnable example.

If you only take one idea away:

State is primary, execution is derived. Instead of drawing what runs next, you declare what artifacts exist and what each agent consumes/produces; the runtime decides what runs from the state changes.

0. Already on reactifact?

This page is for porting other frameworks. If you are upgrading reactifact itself from 0.1.x, use the dedicated 0.1.x → 0.20.0 migration guide.


1. Concept map

You have (LangGraph / LangChain / CrewAI / LlamaIndex) reactifact
node + add_edge / add_conditional_edges a Step + a Consume(Type) — the edge is "who consumes this type"
a shared TypedDict/dict state, mutated by nodes typed, versioned Artifacts in the Context
reducer (Annotated[list, add]) effects.create/update/link(...) compiled into one atomic Patch
checkpointer / thread state Context (git-like), Session/SessionStore, context.branch()/merge()
interrupt() / Command(resume=...) effects.ask(...) → PendingQuestion, answered with effects.resume(...)
subgraph / nested agent AgentAsTool (isolated nested runtime) or context.branch()
conditional router / supervisor Consume.condition / Consume.by_field, or recipes.Router
RAG chain (retriever → prompt → LLM) Source + fan_out_sources + materialize_doc + produces (quick.rag for the simple case)
tool loop (create_react_agent) LLMAgent/ToolUse (or ToolUseHITL for approvals), or reactifact.tools.native / recipes.run_tool_loop
MemorySaver + summary node Msg artifacts + context.view + recipes.WindowSummarizer/RollingDigestSummarizer
callbacks / LangSmith / Langfuse native traces (reactifact.tracing) + redactor=, export to Langfuse/OTLP/Postgres

2. Port one node (before → after)

A LangGraph node that classifies then answers:

def classify(state):            # node
    return {"route": "billing" if "invoice" in state["text"] else "general"}

def answer(state):              # node
    return {"reply": llm(state["text"], route=state["route"])}

graph.add_edge(START, "classify")
graph.add_edge("classify", "answer")

On reactifact the edges disappear — they become consumes/produces:

from pydantic import BaseModel
from reactifact import StepCall, step


class Question(BaseModel):
    text: str


class Route(BaseModel):
    thread_id: str
    kind: str


class Reply(BaseModel):
    text: str


@step(produces=Route, consumes=Question)
async def classify(call: StepCall) -> None:
    question = call.trigger
    kind = "billing" if "invoice" in question.data.text.lower() else "general"
    call.effects.create(Route(thread_id=question.id, kind=kind), id=f"route:{question.id}")


@step(produces=Reply, consumes=Route)
async def answer(call: StepCall) -> None:
    route = call.trigger  # this step runs because a Route was created
    # ... call the model with run-scoped input ...
    call.effects.create(Reply(text="…"), id=f"reply:{route.data.thread_id}")
from reactifact import Context, Runtime, create_agent

classifier = create_agent("classifier", steps=[classify])
answerer = create_agent("answerer", steps=[answer])

Two things to notice, because they're the migration's real work:

  • Eligibility is a state decision, not a placement. The old graph said "answer runs after classify". Here answer runs because a Route exists — so a guard (return None when the input isn't ready) is the sequencing mechanism, not an edge.
  • Stable ids. f"route:{question.id}" makes re-runs idempotent (§42) — the same event twice never duplicates state. This replaces most hand-written "already done?" bookkeeping.

3. State dict → artifacts

# before: one growing dict
state["facts"].append({"text": "...", "source": url})

# after: typed artifacts + provenance
fact = effects.create(Fact(text="...", source=url))
fact.link("extracted_from", doc)

Nothing is mutated in place: an update is a new version, so context.diff(v1, v2) is a real operation and provenance (supported_by/derived_from/…) is the same graph the runtime uses to decide what to re-run — not a logger bolted on. See reactifact.recipes.changed_fields / downstream_fields for the recompute argument made concrete.

4. Checkpointing, threads, time-travel

  • Persist conversations with SessionStore(FileKVBackend(...)) + ChatAssistant (examples/knowledge).
  • Fork an exploration with context.branch(), run different agents on each fork, and merge() them back — with a real MergeConflict when both forks touch the same artifact (examples/forklab).
  • Reproduce a past state with reactifact.replay / replay_context; pin a run's fingerprint with reactifact.audit.context_hash (examples/fintech_audit).

5. Interrupts → human-in-the-loop

# ask
effects.ask("Approve the refund?", kind="approve", id=f"approve:{qid}")
# resume (from your HTTP handler / CLI)
context.resume(question.id, "yes")

The human is just another reaction (PendingQuestion is an artifact); tools can be gated the same way — see ToolUseHITL (a @tool(destructive=True) that only runs after an approval) and examples/devops.

6. RAG chain → sources

from reactifact.quick import rag

r = rag({"docs": "./docs", "costs": "./costs.csv"})
answer = await r.ask("what's the total gpu cost?")   # answer.text, answer.sources

For full control, compose the same pipeline by hand with fan_out_sources + materialize_doc and produce an Answer linked supported_by the documents (examples/knowledge). Retrieval is a Source capability (filesystem/CSV/embeddings/web), not a hardcoded chain — swap the source, keep the agents.

7. Tool loops

  • Model decides which tool, one step at a time: LLMAgent / HITLLMAgent (examples/devops).
  • OpenAI-native tools/tool_calls: reactifact.tools.native (composable functions), or recipes.run_tool_loop for a bounded, parallel, mandatory-tool-aware loop.
  • Budgets and honest failure come for free: Budget(max_runs=…, max_tool_calls=…), and a produce returns None instead of a confident guess (§59).

8. Don't go all-or-nothing (interop)

  • Call reactifact from a node. Your LangGraph node can build a Context, run a Runtime, and return the result — move one auditable/calculating step at a time.
  • Expose reactifact as MCP. create_mcp_server(tools, context=ctx) publishes your Tools (and read-only context://artifacts/... resources) so any MCP client — Claude, another agent framework — can call the reactifact part.
  • Coexist by concern. Keep your orchestration where it works; use reactifact where provenance, determinism, and auditability matter most (the fintech_audit shape).

9. Ops mapping

Concern reactifact
tracing Tracer(store=TraceStore(...)), LangfuseTracer, OTLPTracer, PostgresStore; scrub with RuntimeResources(redactor=…)
sessions SessionStore over FileKVBackend/SQLiteKVBackend/PostgreSQLKVBackend
budgets / limits Budget(max_runs, max_seconds, max_iterations, max_tool_calls)
error policy fail-loud by default; Runtime(isolate_errors=True, on_agent_error=…) opts into partial progress
per-request data Runtime.arun(request={...}) → call.request (no ContextVar plumbing)
resource lifecycle RuntimeResources.scope(factory) / async with (loop-safe clients)

10. Migration checklist

  • [ ] Every intermediate value is a typed Artifact, not a dict/TypedDict.
  • [ ] Each unit is a Step that declares consumes/produces; no node calls another directly.
  • [ ] Eligibility is a guard (return None), not a scheduling order.
  • [ ] Ids are stable and re-derivable (f"answer:{qid}", effects.create_once(...), effects.create_once_from(...)).
  • [ ] Derived artifacts link(...) to their inputs (provenance).
  • [ ] Model calls go through structured_llm/llm_reply and return None on failure — the caller shows an honest fallback, never a fake answer.
  • [ ] A Budget is set for production runs.
  • [ ] Human steps are effects.ask/resume, not a special case.
  • [ ] Sessions use SessionStore; resources are built per loop (RuntimeResources.scope).
  • [ ] Tracing is wired (Tracer(store=...)), and redactor= is set if data is sensitive.

11. Gotchas

  • There is no add_edge. If you find yourself wanting one, the edge is a Consume(ProducedType) on the downstream agent.
  • effects, not return patch. Write effects.* and return None; the runtime compiles the effect set into one atomic commit (Step returning a Patch is the low-level escape hatch, not the idiom).
  • Artifacts aren't messages. Model input is built from a context.view(...) / consumed artifacts, not a free-floating string list.
  • Determinism is a habit. Use stable ids and keep timestamps/randomness out of what you hash — context_hash then makes runs comparable (examples/fintech_audit).

12. Where to look next