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_id — Run 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.