Using LangGraph interrupt for multi-step wizards with branching — right tool or wrong abstraction?

I’m building an AI assistant in Next.js + LangGraph JS. My core flow is well-suited for LangGraph: decompose a research question → parallel ReAct sub-agents → synthesize. No complaints there.

But I want to add guided wizards alongside this — for example, a scraper configuration flow:

  1. Agent analyzes a URL and proposes extraction rules

  2. User approves or gives feedback

  3. Agent adjusts, then moves to link collection setup

  4. …and so on

The wizard has:

  • Branching based on page type (list page vs. detail page → different next steps)

  • Multiple entry points (user can start from data collection or from link setup)

  • Mid-flow exits — user switches to a different intent entirely

  • Resume later — user comes back to the incomplete wizard

My instinct is that this is a finite state machine problem, not a LangGraph problem. Each wizard step is a state, user input is an event, and pause/resume is just serializing a snapshot to JSON in a DB.

The question: Is LangGraph’s interrupt() / human-in-the-loop the right primitive here, or does it fight against you when you need:

  • deterministic branching (not LLM-driven transitions)

  • serializable pause/resume across sessions (not just within a single graph run)

  • multiple wizard entry points in the same graph

  • clean exit when user abandons mid-flow

Has anyone used LangGraph to manage wizard-style flows like this? Did it work well, or did you end up wrapping it with explicit state management (XState, a custom FSM, etc.)? Curious whether the LangGraph checkpoint + interrupt system is expressive enough for this, or whether it’s genuinely the wrong layer of abstraction for deterministic multi-step UX flows.

Hello @lofti198, welcome to langchain community.

LangGraph is the right primitive for your wizard flows

1. Deterministic branching (not LLM-driven)

LangGraph does not require LLM-driven transitions. Any node , even a pure utility function , can return a Command that specifies the next node deterministically:

const analyzePageType = async (state) => {
  const type = detectPageType(state.url); // zero LLM, pure logic
  return new Command({
    update: { pageType: type },
    goto: type === "list" ? "configureListStep" : "configureDetailStep",
  });
};

goto accepts a single node name, an array of names, or Send objects for parallel fan-out. The branching is yours to control, not the model’s. Docs: Command API Reference


2. Serializable pause/resume across sessions

This is the exact problem interrupt() + the checkpointer was built for. The mechanics:

  • interrupt() inside a node suspends execution and serializes full graph state to your checkpointer backend (Postgres in production).
  • The thread_id in your config is the persistent key — store it in your DB against the user record.
  • When the user returns, invoke the same graph with the same thread_id and a Command({ resume: userInput }). Execution picks up from the exact interrupted line, with all prior task() results cached and not re-run.

Docs: Persistence & Checkpointing


3. Multiple wizard entry points

Use the Functional API (entrypoint + task), not StateGraph. Each wizard becomes its own entrypoint , a regular async function with no graph topology to declare. Branching is plain if/else, loops are plain for loops, and each interrupt() is just a await-able pause:

import { entrypoint, task } from "@langchain/langgraph/func";

const scraperWizard = entrypoint(
  { checkpointer, name: "scraperWizard" },
  async (input: { url: string }) => {
    const rules = await analyzeUrl(input.url).then(r => r.result); // task() — cached on resume
    const approval = interrupt({ rules, prompt: "Approve or give feedback" });

    if (approval.type === "feedback") {
      const revised = await adjustRules({ rules, feedback: approval.text }).then(r => r.result);
      interrupt({ rules: revised, prompt: "Confirm revised rules" });
    }

    const linkConfig = interrupt({ prompt: "Configure link collection" });
    return { rules, linkConfig };
  }
);

const linkSetupWizard = entrypoint(  // separate entry point
  { checkpointer, name: "linkSetupWizard" },
  async (input) => { /* ... */ }
);

Both wizards share one Postgres checkpointer instance , no cross-contamination of state, unified persistence. Docs: Functional API Concepts


4. Clean mid-flow exit when user abandons or switches intent

An abandoned thread is just a frozen checkpoint, no process is running, no cost incurred. The pattern is simple:

User action What you do
Abandons wizard Stop invoking. Thread stays frozen in Postgres.
Comes back later Load saved thread_id, call graph.invoke(new Command({ resume: ... }))
Switches to different intent entirely Start a new thread_id for the new flow. Old thread stays untouched.
Permanently discards a wizard Delete the checkpoint row from Postgres.

Docs: Threads & State Management


Recommended architecture for your app

Next.js frontend
    │
    │  thread_id per wizard session (stored in your DB per user)
    ↓
LangGraph Functional API (JS)
    ├── scraperWizard      entrypoint  ─┐
    ├── linkSetupWizard    entrypoint   ├── shared Postgres checkpointer
    └── researchGraph      StateGraph  ─┘  (your existing ReAct flow)

Your ReAct decompose→parallelize→synthesize flow stays as a StateGraph (topology matters there). The wizard flows use entrypoint (control flow is sequential and conditional). Both run on the same LangGraph runtime and the same Postgres checkpointer. No XState, no custom FSM layer needed.


The one case to genuinely reach for XState

If a wizard has zero AI steps, purely routing user input through deterministic transitions with no task() calls; then LangGraph’s serialization and checkpoint overhead adds complexity with no payoff. XState’s visual state chart tooling is also better for documenting complex pure-UX flows to non-engineers. But if there’s a single LLM call in the loop, LangGraph’s task() result caching on resume alone makes it worth it.