I Added Human-in-the-Loop Control to My LangGraph Lunch Agent
LangGraph defines an interrupt as a way to pause a workflow at a specific point and wait for external input before continuing. When this…
I Added Human-in-the-Loop Control to My LangGraph Lunch Agent
LangGraph defines an interrupt as a way to pause a workflow at a specific point and wait for external input before continuing. When this happens, LangGraph saves the workflow’s state using its checkpointer and waits. It doesn’t continue on its own or time out.
Here, Human-in-the-Loop (HITL) simply means the workflow pauses and waits for a person to decide what to do next, instead of moving forward automatically.
LangChain’s documentation lists a few common situations where you would use this pause:
1) Before an important step, such as an API call, where a person needs to approve or reject the action. 2) When a person needs to review and update the graph’s state. 3) When a person needs to review and edit the LLM’s response before the workflow continues.
The example below uses the 1st approach. The LLM suggests a meal, the workflow pauses, and you can approve it, ask for a different suggestion, or exit.
What the App Does
1) The LLM suggests a random meal.
2) The app pauses and waits for your decision.
3) You choose one of three options: Order It, Try Another, or I Already Ate.
4) The app resumes and responds based on your choice.
Demo

Demo
💥 Save Up to 85% OFF 📚 Premium Courses ⏳ Limited-Time Offer | 🎓 Upgrade Your Skills *👉 **Enroll Now & Start Learning***

The Code
1) The state
class MealState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
current_suggestion: str
seen_dishes: list[str]
choice: str
messages uses the add_messages reducer. This means when a node returns {“messages”: […]}, the new messages are added to the existing list instead of replacing it.
The other fields — current_suggestion, seen_dishes, and choice — do not use a reducer. When a node returns a new value for any of them, it simply replaces the old value.
2) Generating a suggestion
The system prompt used to generate the LLM’s meal suggestion:
SYSTEM_PROMPT_TEMPLATE = """You are a meal suggestion assistant.
Suggest ONE random Indian dish only, suitable as a full lunch meal — not a snack, starter, or dessert.
It should be healthy lunch-dish. Describe it in 10 words or fewer.
Do not suggest any of these dishes again, they were already shown: {seen_dishes}
Return only the meal description, nothing else. No labels, no prefixes, no explanation. Just the meal."""
The {seen_dishes} placeholder is updated each time the node runs. This lets the LLM know which dishes have already been suggested, so it can avoid repeating them.
def suggest_meal_node(state: MealState) -> dict:
llm = ChatOpenAI(model="gpt-4.1-mini")
seen_dishes = state.get("seen_dishes", [])
seen_text = ", ".join(seen_dishes) if seen_dishes else "none yet"
system_msg = SystemMessage(content=SYSTEM_PROMPT_TEMPLATE.format(seen_dishes=seen_text))
suggestion = llm.invoke([system_msg]).content.strip()
return {
"current_suggestion": suggestion,
"seen_dishes": seen_dishes + [suggestion],
}
This node calls the LLM to generate a meal suggestion. It stores the suggestion in state[“current_suggestion”] and adds it to state[“seen_dishes”]. Since seen_dishes is included in the prompt each time, the LLM avoids suggesting the same meal again.
3) Pausing for human input
def review_node(state: MealState) -> dict:
decision = interrupt({"suggestion": state["current_suggestion"]})
choice = decision.get("choice", "").strip()
return {"choice": choice}
interrupt() pauses the workflow at this point and saves its current state using the checkpointer. It then returns {“suggestion”: state[“current_suggestion”]} to the caller. The workflow remains paused until it is explicitly resumed.
4) Resuming with “Command”
result = app.invoke(Command(resume={"choice": "1"}), config=get_config())
When you click a button, the workflow resumes. For ex: {“choice”: “1”} is passed back as the return value of the waiting interrupt() call. Inside review_node (Refer **Point no 3)**, this becomes the user’s selected choice.
5) Routing with a conditional edge
def route_after_review(state: MealState) -> str:
choice = state.get("choice", "")
if choice == "1":
return "finalize"
if choice == "3":
return "decline"
return "suggest_meal"
builder.add_conditional_edges(
"review",
route_after_review,
{"suggest_meal": "suggest_meal", "finalize": "finalize", "decline": "decline"},
)
This is where the workflow decides what to do next. *If state[“choice”] is “2”, it goes back to suggest_meal for another suggestion. Otherwise, it moves to finalize or decline and ends the workflow.*
6) Importance of the Checkpointer
checkpointer = MemorySaver()
return builder.compile(checkpointer=checkpointer)
interrupt() requires a checkpointer because it saves the workflow’s state while the graph is paused. Without a checkpointer, the workflow cannot pause and resume later.
In this example, MemorySaver stores the state only in memory. If the application restarts, the saved state is lost. For production applications, you can use a persistent checkpointer such as SQLite, which stores the workflow state on disk so it survives application restarts.
Note: Depending on your LangGraph version, you may see InMemorySaver instead of MemorySaver. Check which class is available in your installed version before using either name.
7) thread_id — Identifying the Paused Session
thread_id is a unique identifier for a workflow session. It tells LangGraph which saved state to load when resuming a paused workflow. Without the correct thread_id, LangGraph cannot determine which paused session to continue.
def get_config() -> dict:
return {"configurable": {"thread_id": st.session_state.thread_id}}
It can store the state of multiple paused workflows. It must remain the same from the initial run through every resume. In this example, st.session_state keeps the thread_id consistent across user interactions.
8) Configuring It into Streamlit
Streamlit reruns the entire script every time a button is clicked. To preserve the app’s state across these reruns, this example uses st.session_state.
It stores:
1] thread_id — Identifies which paused workflow to resume. 2] graph_result — Stores the latest result returned by app.invoke(). 3] session_done — Indicates whether the workflow has finished. 4] final_message — Stores the final message to display after the workflow ends.
@st.cache_resource
def build_app():
...
This builds the graph and its MemorySaver once per app session, not on every rerun. If you create a fresh checkpointer on every click, the previous paused state is lost — so interrupt() or Command(resume=…) would have nothing to resume into.
interrupts = st.session_state.graph_result.get("__interrupt__")
if interrupts:
suggestion = interrupts[0].value["suggestion"]
interrupt is the key LangGraph adds when the graph pauses. interrupts[0].value is the dictionary passed into interrupt(). If interrupt is missing, the graph has finished, and that is the signal to show the final screen.
Things to keep in mind
1] interrupt() pauses a node and returns a value to the caller. Command(resume=…) replies to it, and that response becomes the return value of the original interrupt() call. 2] Anything that must survive a resume — like the current suggestion — should be stored in graph state, not in local variables. 3] Use one interrupt() per node, and handle looping with a conditional edge so each resume stays simple and predictable. 4] A checkpointer is required for interrupt(). 5] thread_id must remain the same across the initial run and all resumes; otherwise LangGraph treats it as a new session. 6] In Streamlit, st.session_state together with @st.cache_resource keeps both the graph instance and its checkpoint storage alive across reruns.
If you found this useful, try building a small Human-in-the-Loop workflow in LangGraph yourself — it’s one of the fastest ways to really understand interrupts, state, and checkpointing in action.
Thank you for being a part of the community
Before you go:

👉 Be sure to clap and follow the writer ️👏️️
👉 Follow us: **Linkedin| [Medium](https://medium.com/codetodeploy)**
👉 CodeToDeploy Tech Community is live on Discord — **Join now!**
Disclosure: This post includes affiliate and partnership links.
메타데이터
- post_id
- 0e06cd794803
- slug
- i-added-human-in-the-loop-control-to-my-langgraph-lunch-agent-0e06cd794803
- url
- https://medium.com/codetodeploy/i-added-human-in-the-loop-control-to-my-langgraph-lunch-agent-0e06cd794803
- canonical_url
- https://medium.com/codetodeploy/i-added-human-in-the-loop-control-to-my-langgraph-lunch-agent-0e06cd794803
- author_url
- https://medium.com/@nachiket4jan
- status
- ok
- fetched_at
- 2026-07-07 03:40:08