LangGraph for Onboarding Assistants: Multi-Step Guided Flows
Why Onboarding Is a Graph Problem, Not a Chat Problem
Every product team eventually builds an onboarding flow, and every team eventually discovers that a single chatbot prompt cannot carry it. Onboarding is not a conversation, it is a process. It has required steps, optional branches, validation gates, and a definition of "done." A new user has to connect an account, verify an email, pick a plan, import data, and confirm settings, and each of those steps can fail, get skipped, or need to be revisited.
If you have tried to build this with a single large system prompt and a chat loop, you know the failure mode. The model forgets which step the user is on. It re-asks a question that was already answered three turns ago. It happily lets the user jump to step five before step two is complete, because nothing in the architecture actually tracks state. You end up patching the prompt with more and more instructions, telling the model to "remember" things it has no persistent way to remember, and the whole thing becomes fragile the moment a real user does something unexpected, like closing the tab mid-flow and coming back an hour later.
This is exactly the problem LangGraph was built to solve. LangGraph represents your assistant as an explicit graph of nodes and edges, where each node is a step in the process and the edges define what can happen next. State is not something the model has to infer from chat history, it is a structured object that persists between turns, gets checkpointed, and can be inspected or replayed. Onboarding flows map onto this model almost perfectly, because onboarding already is a graph in the real world, you are just usually implementing it as an if/else pyramid glued to prompt text.
In this article we will build a multi-step onboarding assistant with LangGraph, cover how to model steps as nodes, how to route between them conditionally, how to persist state across sessions, and how to handle the messy edge cases that show up the moment real users touch your flow. This is also one of the core projects we walk through hands-on in the LangGraph Tutorial course at teachyou.ai, so if you want a guided, code-along version of everything below, that course picks up right where this article leaves off.
What Makes Onboarding Different From a Generic Chatbot
Before writing code, it is worth being precise about why onboarding assistants have different requirements than, say, a customer support bot.
- Ordering matters. You cannot let a user set notification preferences before they have an account. Steps have dependencies, and the assistant needs to enforce them, not just suggest them.
- State must survive interruptions. Users abandon onboarding constantly. They close the browser, get pulled into a meeting, or lose their session. When they come back, the assistant needs to resume exactly where they left off, not restart from scratch and not lose the three fields they already filled in.
- Validation gates are non-negotiable. If a user gives an invalid email or an API key that fails a test call, the flow needs to catch that and loop back, not politely continue as if everything is fine.
- Branching is the norm, not the exception. A developer signing up takes a different path than a marketing user. A user who already has an existing account to migrate takes a different path than a brand new signup. Your graph needs conditional edges that route based on actual data, not just the model's guess at intent.
- Progress needs to be observable. Product and support teams want to know where users are getting stuck. A black-box prompt chain gives you nothing to instrument. A graph gives you a node name, which is a metric waiting to happen.
None of these are things a single prompt handles gracefully. All of them are first-class concepts in LangGraph.
Modeling the Onboarding State
The foundation of any LangGraph application is the state schema. This is the object that flows through every node, gets updated by each step, and gets checkpointed so the graph can pause and resume. For an onboarding assistant, the state needs to capture both the conversation and the structured progress data.
from typing import Annotated, Optional
from typing_extensions import TypedDict
from langgraph.graph.message import add_messages
class OnboardingState(TypedDict):
messages: Annotated[list, add_messages]
user_id: str
current_step: str
account_verified: bool
plan_selected: Optional[str]
integration_configured: bool
profile_complete: bool
errors: list[str]Notice that messages uses the add_messages reducer, which appends new messages instead of overwriting the list, while the rest of the fields are simple flags and values that each node can read and update directly. This split is deliberate. The conversation is append-only because you want a full transcript, but the progress flags are point-in-time facts about where the user is in the process. Keeping them separate means your routing logic can make decisions based on current_step and the boolean flags without having to re-parse chat history to figure out what already happened.
This is the single biggest mental shift coming from a plain chatbot. Instead of asking "what did the user say that implies they finished this step," you ask "did the node that handles this step set the flag to true." State becomes a source of truth instead of an inference.
Structuring Steps as Nodes
Each onboarding step becomes its own node function. A node in LangGraph is just a function that takes the current state and returns a partial update to it. Keeping nodes small and single-purpose is what makes the graph maintainable, so resist the urge to cram multiple steps into one giant node.
def verify_account_node(state: OnboardingState) -> dict:
last_message = state["messages"][-1].content
is_valid = check_verification_code(state["user_id"], last_message)
if is_valid:
return {
"account_verified": True,
"current_step": "plan_selection",
"messages": [{"role": "assistant", "content": "Verified. Let's pick a plan."}]
}
return {
"errors": [f"Invalid verification code: {last_message}"],
"messages": [{"role": "assistant", "content": "That code didn't work. Try again?"}]
}
def plan_selection_node(state: OnboardingState) -> dict:
plan = extract_plan_choice(state["messages"])
if plan:
return {
"plan_selected": plan,
"current_step": "integration_setup",
"messages": [{"role": "assistant", "content": f"Great, {plan} it is. Now let's connect your data."}]
}
return {
"messages": [{"role": "assistant", "content": "Which plan works for you: Starter, Pro, or Team?"}]
}Each node has one job: check whether its step's exit condition is satisfied, and either advance the state or ask again. This is a much cleaner mental model than trying to encode "have we verified the account yet" as a fact the LLM has to remember from scrolling back through the transcript. The node checks a boolean. It either is or it isn't.
A practical tip here: keep the LLM-calling logic and the state-transition logic separate even within a node. Call the model to extract structured information (did the user pick a plan, what is it), then use plain Python to decide the state transition. Mixing "ask the model what to do next" with "actually decide what to do next" is how these flows get flaky, because you are trusting the model to both extract facts and make control-flow decisions in the same breath.
Conditional Routing Between Steps
The graph structure itself is defined by edges, and this is where LangGraph earns its keep for onboarding specifically. Instead of a linear sequence, you define conditional edges that inspect the state and route to the appropriate next node.
from langgraph.graph import StateGraph, END
def route_after_verification(state: OnboardingState) -> str:
if state["account_verified"]:
return "plan_selection"
return "verify_account"
def route_after_plan(state: OnboardingState) -> str:
if state["plan_selected"] == "team":
return "invite_teammates"
elif state["plan_selected"]:
return "integration_setup"
return "plan_selection"
builder = StateGraph(OnboardingState)
builder.add_node("verify_account", verify_account_node)
builder.add_node("plan_selection", plan_selection_node)
builder.add_node("invite_teammates", invite_teammates_node)
builder.add_node("integration_setup", integration_setup_node)
builder.add_node("profile_wrapup", profile_wrapup_node)
builder.add_conditional_edges("verify_account", route_after_verification)
builder.add_conditional_edges("plan_selection", route_after_plan)
builder.add_edge("invite_teammates", "integration_setup")
builder.add_edge("integration_setup", "profile_wrapup")
builder.add_edge("profile_wrapup", END)
builder.set_entry_point("verify_account")
graph = builder.compile()This is the piece that a chat-only implementation genuinely cannot replicate well. The team plan branch is a real fork in the graph, not a hint buried in a prompt hoping the model remembers to ask about teammates later. If a product manager asks "what happens if someone picks the Team plan," you can point at route_after_plan and show them exactly what happens, no guessing about emergent LLM behavior required.
It is also worth noting that conditional edges can route back to the same node, which is how you implement retry loops naturally. If verification fails, route_after_verification sends the user right back to verify_account, and because the state persists, the node has access to prior error messages if you want to change the prompt after a second failed attempt, like offering to resend the code.
Persisting State Across Sessions
Onboarding flows are abandoned and resumed constantly, and this is where LangGraph's checkpointing becomes essential rather than optional. A checkpointer saves the graph's state after every node execution, keyed by a thread ID, so a user can close their laptop mid-flow and pick up exactly where they left off a day later.
from langgraph.checkpoint.postgres import PostgresSaver
with PostgresSaver.from_conn_string(DATABASE_URL) as checkpointer:
checkpointer.setup()
graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": f"onboarding-{user_id}"}}
result = graph.invoke(
{"messages": [{"role": "user", "content": user_input}]},
config=config
)The thread ID is the whole trick. Every invocation for a given user passes the same thread ID, and the checkpointer transparently loads the last known state before running the graph and saves the new state after. Your application code does not need any custom "resume where you left off" logic, it just needs to remember which thread ID belongs to which user, which is typically just their user ID with a prefix.
For production onboarding assistants, a durable checkpointer backed by Postgres or another persistent store is not optional. In-memory checkpointers are fine for local development, but the entire value proposition of a resumable onboarding flow evaporates if a server restart wipes everyone's progress. This is also where a graph-based approach pays for itself operationally, because "resume the user's session" is a solved problem at the framework level instead of something your team reinvents with a Redis key and a lot of hope.
Human-in-the-Loop for Validation Gates
Some onboarding steps require a pause that is not really about the LLM at all, it is about waiting for an external event, like a user clicking a confirmation link in their email, or an async API key validation call finishing. LangGraph handles this with interrupts, which pause graph execution at a specific node and wait for external input before continuing.
from langgraph.types import interrupt
def integration_setup_node(state: OnboardingState) -> dict:
api_key = state["messages"][-1].content
validation_result = interrupt({
"type": "validate_api_key",
"key_prefix": api_key[:8],
"message": "Validating your API key with the provider..."
})
if validation_result.get("valid"):
return {
"integration_configured": True,
"current_step": "profile_wrapup"
}
return {
"errors": ["API key validation failed"],
"messages": [{"role": "assistant", "content": "That key didn't validate. Double check it and try again."}]
}The interrupt pauses execution and returns control to your application, which can then run the actual async validation (calling the third-party API, checking a webhook, whatever it needs), and resume the graph by invoking it again with the result. This pattern matters a lot for onboarding because so many onboarding steps genuinely depend on external systems, not on more conversation. Modeling that dependency as a graph interrupt, rather than faking it with a "please wait" message inside a chat loop, keeps your architecture honest about what is actually happening.
Handling Errors and Off-Script Behavior
Real users do not follow the happy path. They ask unrelated questions mid-onboarding, they paste in garbage where a plan name should go, they try to skip ahead. A robust onboarding graph needs a way to handle these without falling apart.
One pattern that works well is adding a lightweight intent-classification node before your main routing logic, which catches off-script inputs and routes them to a dedicated handler rather than letting a step-specific node try to make sense of unrelated text.
def classify_intent_node(state: OnboardingState) -> dict:
intent = classify_message(state["messages"][-1].content, state["current_step"])
return {"messages": [], "current_step": state["current_step"]} if intent == "on_topic" else {
"messages": [{"role": "assistant", "content": handle_off_topic(intent)}]
}
def route_by_intent(state: OnboardingState) -> str:
last = state["messages"][-1]
if getattr(last, "off_topic", False):
return "classify_intent"
return state["current_step"]The other half of resilience is making error accumulation visible rather than silent. Notice the errors field in the state schema from earlier. Every node that hits a validation failure appends to it instead of just responding conversationally and moving on. This gives you two things for free: a real audit trail you can show a support agent when a user says "I've been stuck for twenty minutes," and a signal you can use inside the graph itself, like routing to a "talk to a human" node after three consecutive failures on the same step.
def route_after_failures(state: OnboardingState) -> str:
recent_errors = [e for e in state["errors"] if state["current_step"] in e]
if len(recent_errors) >= 3:
return "escalate_to_human"
return state["current_step"]This kind of failure-aware routing is nearly impossible to bolt onto a plain prompt chain because there is no structured place to count failures. In LangGraph it is one field and one conditional edge.
Testing the Flow Before Real Users See It
Because the onboarding graph is explicit, it is also testable in a way that a freeform chatbot prompt is not. You can write tests that drive the graph through specific paths and assert on the resulting state, without needing to fuzz natural language phrasing to hit a given branch.
def test_team_plan_routes_to_invite_teammates():
config = {"configurable": {"thread_id": "test-thread-1"}}
graph.invoke({"messages": [{"role": "user", "content": "123456"}]}, config)
result = graph.invoke({"messages": [{"role": "user", "content": "team"}]}, config)
assert result["plan_selected"] == "team"
assert result["current_step"] == "invite_teammates" or "teammate" in result["messages"][-1].content.lower()
def test_invalid_verification_code_loops_back():
config = {"configurable": {"thread_id": "test-thread-2"}}
result = graph.invoke({"messages": [{"role": "user", "content": "wrongcode"}]}, config)
assert result["account_verified"] is False
assert len(result["errors"]) == 1Because state transitions are deterministic functions of the state object, not emergent behavior from a prompt, these tests are stable. You are testing your routing logic and your node logic, and only using the LLM for the narrow job of extracting structured information from free text, which you can mock out entirely in most of your test suite. This is a meaningful difference from testing a monolithic chatbot prompt, where the only real testing strategy is "run it a hundred times and eyeball the transcripts."
Visualizing and Debugging the Graph
One underrated benefit of the graph structure is that it can be visualized directly, which turns "why did the user get stuck" from an archaeology project into a quick look at a diagram.
graph.get_graph().draw_mermaid_png(output_file_path="onboarding_flow.png")Combined with LangGraph's built-in tracing (or a tool like LangSmith if you are already using the LangChain ecosystem), you get a per-node execution trace for every onboarding session. When a user reports that onboarding "didn't work," you are not asking them to describe what happened in a chat log, you are looking at exactly which node ran, what the state looked like going in, and what it looked like coming out. For a product team trying to improve conversion through an onboarding funnel, this observability is arguably as valuable as the reliability improvements, because it turns "users are dropping off during onboarding" from a vague complaint into "forty percent of drop-off happens at the integration_setup node, specifically on the API key validation step."
Practical Tips for Production Onboarding Graphs
A few lessons that show up repeatedly once these flows hit real traffic:
- Keep nodes idempotent where possible. If a node calls an external API (like sending a verification email), guard it so re-entering the node due to a retry does not send five emails. Check a flag before firing the side effect.
- Separate "read the state" from "call the LLM." Not every node needs a model call. Steps that are pure data validation (checking an email format, confirming a plan name matches an enum) should skip the LLM entirely and run as plain Python, saving cost and latency.
- Set a hard step limit. Add a recursion or step counter to your graph invocation config so a routing bug cannot spin a user in an infinite loop and burn API credits.
- Version your graph schema. Once you have real users with checkpointed state, changing your
OnboardingStateshape can break resumption for in-flight users. Plan migrations, or at minimum, handle missing fields gracefully with.get()defaults. - Log the current_step on every turn. Even before you build dashboards, a simple log line with the step name gives you a funnel for free.
Wrapping Up
Onboarding assistants expose exactly the weaknesses that plain prompt-and-chat architectures have: no durable state, no enforced ordering, no clean way to branch, and no observability into where users get stuck. LangGraph addresses all four by making state explicit, steps into nodes, transitions into edges, and progress into something you can checkpoint, inspect, and test. The result is an onboarding assistant that behaves less like a chatbot improvising its way through a script and more like a well-engineered state machine that happens to talk.
If you want to go deeper than this article, including building out the full graph above with a real database-backed checkpointer, adding streaming responses, and wiring in human-in-the-loop approval steps end to end, that is exactly what we cover in the LangGraph Tutorial course here on teachyou.ai. We build this onboarding assistant project from an empty repo to a deployed, resumable flow, step by step, so you can adapt the same pattern to whatever guided experience your product needs next.
BootcampA 30-day guided bootcamp: build, harden and ship a production autonomous agent from scratch.
AI AgentsUnderstand how AI agents really work: the loop, the tools, the memory, and why most agent projects fail.