Skip to Content
GuidesHuman Approval

Human Approval

Some steps should not happen until a person says yes. A workflow node can pause for exactly that — durably, so the process asking does not have to be the process answering, provided the answering process can see the run at all. durable=True covers the graph’s own state (LangGraph’s checkpointer, file-backed by default); pending() and answer() go through a different store — the Runtime’s event log — so a second process only sees a paused run if that store is shared too, which the default (in-process memory) is not. See Where the log lives for the backends that cross a process boundary.

from langgraph.graph import StateGraph from langgraph.types import interrupt from pydantic import BaseModel from agentdeck import Workflow class QuoteState(BaseModel): request: str quote: str = "" approved: bool = False def _quote(state: QuoteState) -> dict: return {"quote": f"EUR 420 for {state.request}"} def _confirm(state: QuoteState) -> dict: decision = interrupt({"question": f"Send quote: {state.quote}?"}) return {"approved": decision == "yes"} def _build_graph() -> StateGraph: graph = StateGraph(QuoteState) graph.add_node("quote", _quote) graph.add_node("confirm", _confirm) graph.set_entry_point("quote") graph.add_edge("quote", "confirm") return graph quote_approval = Workflow(name="QuoteApproval", state=QuoteState, durable=True, graph=_build_graph)

quote runs once and does its work before anything pauses. confirm is the node that interrupts — keep it to the question itself, because it re-runs from its start on resume.

Starting the run returns the question instead of a final state. A second process — the one with the person in front of it — finds it in the approval inbox and answers by run_id:

import asyncio from agentdeck import Deck async def main() -> None: async with Deck.from_project() as deck: paused = await deck.run("QuoteApproval", {"request": "Berlin -> Munich"}, session_id="quote-42") print(paused) # {"type": "interrupt", "payload": {"question": ...}, "thread_id": "quote-42"} inbox = await deck.pending() print([(p.run_id, p.thread_id, p.payload) for p in inbox]) # what a second process, or a later call in this one, would see [mine] = [p for p in inbox if p.thread_id == "quote-42"] final = await deck.answer(mine.run_id, "yes") print(final) # {"request": ..., "quote": ..., "approved": True} asyncio.run(main())

pending() lists every paused run across the whole catalog — a real caller narrows it by p.invocable == "QuoteApproval" when more than one durable workflow is in flight. answer() needs only the run_id pending() named; it looks up which workflow and which thread that run belongs to itself.

The same thing over HTTP

A caller outside the process uses three endpoints instead of three method calls — same inbox, same thread id:

curl -X POST "http://localhost:8000/workflows/QuoteApproval?thread_id=quote-42" -d '{"request": "Berlin -> Munich"}' curl http://localhost:8000/workflows/QuoteApproval/pending curl -X POST http://localhost:8000/workflows/QuoteApproval/quote-42/resume -d '{"value": "yes"}'

resume on a thread that is not paused — wrong id, already answered, never started — answers 404, not a stale state: there is nothing there to apply the value to.

Limits

A workflow’s thread_id is its session: one turn on it at a time, so a second POST to the same thread while the approval is outstanding answers 409, the same as talking over an agent conversation that is still mid-turn. The approval holds the session until someone answers it or until AGENTDECK_RUNTIME_STALE_RUN_AFTER_SECONDS decides nobody is coming back.

Pausing or cancelling a run by run_idRun Control — does not reach a workflow run today: a workflow has no safe point of its own, so interrupt() is the only way one of these pauses, and answering it is the only way it continues.

Next: Workflows covers timers (sleep_until) — the other thing interrupt() is used for — and what as_tool() does with the whole graph.

Last updated on