DEV Community

Kshitish Handa
Kshitish Handa

Posted on

title: Building a LangGraph Workflow for Hospital Events and Human Approval


Hospitals generate a constant stream of events — a patient is admitted, a bed frees up, a discharge is flagged, staffing dips below a safe ratio. A lot of the reasoning about what to do next (which bed, which unit, which nurse) is a great fit for a multi-agent LLM pipeline. But almost none of the acting should happen without a human in the loop — nobody wants an autonomous agent moving a patient or discharging them without a clinician signing off.

That combination — an agentic pipeline that has to pause mid-flight, wait for a real person, and then pick up exactly where it left off — is a genuinely awkward problem to hand-roll. This post walks through how LangGraph solves it, using the pattern we built for Hospilot's event-response pipeline as the running example.

The shape of the problem

A typical event-response run looks like:

  1. An event comes in ("Bed 4B is now empty").
  2. Several agents reason in parallel: a bed-matching agent looks for the best-fit waiting patient, a staffing agent checks nurse ratios, a logistics agent checks housekeeping/turnover time.
  3. Their outputs get synthesized into a recommendation.
  4. A human has to approve the recommendation before anything actually changes in the real world.
  5. Once approved (or rejected, or timed out), the pipeline finishes: it applies the change, or backs off and re-plans.

Steps 1–3 and 5 are exactly what LangGraph is good at: a StateGraph of nodes with typed, mergeable state. Step 4 is the interesting part, because "pause for an indeterminate amount of time, then resume from exactly this point" is not something most graph/DAG engines are built for out of the box.

Building the graph

Each agent becomes a node; dependencies between agents become edges. Because several agents run independently off the same event, it's natural to group them into levels — everything in a level runs concurrently, and the next level only starts once the whole level has finished:

from langgraph.graph import StateGraph, START, END

g = StateGraph(SessionState)

# Level 0: fan out from the event
g.add_node("bed_agent", bed_agent_node)
g.add_node("staffing_agent", staffing_agent_node)
g.add_edge(START, "bed_agent")
g.add_edge(START, "staffing_agent")

# Level 1: synthesize once the whole level above has completed
g.add_node("synthesize", synthesize_node)
g.add_edge("bed_agent", "synthesize")
g.add_edge("staffing_agent", "synthesize")

# Level 2: nothing proceeds without a human decision
g.add_node("approval_gate", approval_gate_node)
g.add_edge("synthesize", "approval_gate")
g.add_edge("approval_gate", END)

graph = g.compile(checkpointer=checkpointer)
Enter fullscreen mode Exit fullscreen mode

Because both bed_agent and staffing_agent point at synthesize, LangGraph won't run synthesize until both have written their results in the same superstep — you get a "wait for the whole group" barrier for free, no manual counters required. State fields that multiple parallel nodes write to need a merge reducer so they don't clobber each other:

from typing import Annotated, TypedDict

def merge_dict(left: dict, right: dict) -> dict:
    return {**(left or {}), **(right or {})}

class SessionState(TypedDict, total=False):
    event: dict
    results: Annotated[dict, merge_dict]   # each agent writes its own key
    recommendation: dict
Enter fullscreen mode Exit fullscreen mode

The approval gate: interrupt() and its one gotcha

LangGraph's interrupt() is what makes step 4 possible: calling it inside a node pauses the whole graph, persists its state via the checkpointer, and returns control to your API layer. Later, you resume the exact same run by sending a Command(resume=...) against the same thread:

from langgraph.types import interrupt

async def approval_gate_node(state: SessionState) -> dict:
    decision = interrupt({
        "recommendation": state["recommendation"],
        "prompt": "Approve this bed reassignment?",
    })
    return {"recommendation": {**state["recommendation"], "decision": decision}}
Enter fullscreen mode Exit fullscreen mode
from langgraph.types import Command

# ... later, from your approval API endpoint ...
await graph.ainvoke(
    Command(resume="approved"),
    config={"configurable": {"thread_id": session_id}},
)
Enter fullscreen mode Exit fullscreen mode

Here's the gotcha that isn't obvious from the docs: when a node calls interrupt(), the whole node re-runs from the top on resume — not just the line after interrupt(). If your node does real work (a DB write, an external API call, sending a notification) before calling interrupt(), that work runs again the moment a human clicks "approve."

The fix is to make anything before the interrupt idempotent by checking for prior work first, and to stash only the small, serializable bits you need across the resume in an external store (we use Redis) rather than trusting local variables to survive:

async def approval_gate_node(state: SessionState) -> dict:
    pending = await cache.get(f"pending:{state['session_id']}")

    if pending is not None:
        # RESUME PATH: the approval row already exists, don't recreate it
        decision = interrupt({"recommendation": pending})
        await cache.delete(f"pending:{state['session_id']}")
        return {"recommendation": {**pending, "decision": decision}}

    # FIRST-RUN PATH: create the approval record exactly once
    await create_approval_row(state["recommendation"])
    await cache.set(f"pending:{state['session_id']}", state["recommendation"], ttl=3600)
    decision = interrupt({"recommendation": state["recommendation"]})   # raises here on first run
    return {"recommendation": {**state["recommendation"], "decision": decision}}
Enter fullscreen mode Exit fullscreen mode

One more piece: humans don't always respond. A background reaper watches for approvals that have been pending past a timeout window and resumes them itself with Command(resume="timeout"), so a distracted charge nurse can't leave a session stuck forever.

Putting it together

Why this is worth open-sourcing patterns for

The interrupt() + external-store + resume pattern above generalizes to almost any domain where an LLM pipeline needs a human checkpoint — approvals, escalations, "are you sure?" gates, compliance sign-off. It's a small amount of code once you know the shape of it, and a confusing amount of debugging if you don't (ask me how I know about the re-execution gotcha).

If you want to get your hands dirty: take the snippet above, drop it into a fresh LangGraph project, and try extending it — add a second approval gate later in the graph, or swap the Redis pending-store for whatever key/value store you already have. Post what you build (or what breaks) in the comments — I'd genuinely like to see other people's take on the "resume without re-running side effects" problem, since I don't think LangGraph's docs cover it well yet.


Top comments (0)