Typed-form human-in-the-loop#
Collect a structured payload from a human before an action runs, using LangGraph’s interrupt() plus a Pydantic schema. Backed by tests/integration/test_form_hitl.py.
1. Define the form schema#
Any Pydantic BaseModel. The interrupt payload will carry the model’s JSON schema so any UI / chat client can render a real form.
from datetime import date
from pydantic import BaseModel
class DeliveryConfirmation(BaseModel):
delivery_date: date
notes: str
signature_waiver: bool = False
DeliveryConfirmation.model_json_schema()["properties"].keys()
dict_keys(['delivery_date', 'notes', 'signature_waiver'])
2. Build a 2-action plan gated by the form#
require_human_approval=DeliveryConfirmation (a class, not True) tells the executor to emit a typed-form interrupt before running the action.
from typing import Any
from langgoap.actions import ActionSpec
from langgoap.goals import GoalSpec
from langgoap.graph.builder import GoapGraph
from langgraph.checkpoint.memory import MemorySaver
actions = [
ActionSpec(
name="prepare",
preconditions={},
effects={"prepared": True},
execute=lambda ws: {"prepared": True},
),
ActionSpec(
name="confirm_delivery",
preconditions={"prepared": True},
effects={"dispatched": True},
execute=lambda ws: {"dispatched": True},
require_human_approval=DeliveryConfirmation,
human_input_key="confirmation",
),
]
graph = GoapGraph(actions).compile(checkpointer=MemorySaver())
config = {"configurable": {"thread_id": "primer"}}
[a.name for a in actions]
/Users/brian.sam-bodden/Code/langgoap/.venv/lib/python3.12/site-packages/langgraph/checkpoint/serde/encrypted.py:5: LangChainPendingDeprecationWarning: The default value of `allowed_objects` will change in a future version. Pass an explicit value (e.g., allowed_objects='messages' or allowed_objects='core') to suppress this warning.
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
['prepare', 'confirm_delivery']
3. First invoke hits the interrupt#
The graph pauses before confirm_delivery. The interrupt payload carries the schema and the action name.
result = graph.invoke(
{"goal": GoalSpec(conditions={"dispatched": True}),
"world_state": {}},
config=config,
)
interrupts = result["__interrupt__"]
payload = interrupts[0].value
(payload["type"], payload["action"],
sorted(payload["form_schema"]["properties"].keys()))
('goap_action_form',
'confirm_delivery',
['delivery_date', 'notes', 'signature_waiver'])
4. Resume with a valid payload#
Command(resume=...) passes the form data back. The executor validates it via Model.model_validate, merges the parsed instance into world_state["confirmation"], and runs the action.
from langgraph.types import Command
resumed = graph.invoke(
Command(resume={
"delivery_date": "2026-05-15",
"notes": "Leave at side door",
"signature_waiver": True,
}),
config=config,
)
(resumed["status"],
resumed["world_state"]["dispatched"],
resumed["world_state"]["confirmation"])
('goal_achieved',
True,
{'delivery_date': '2026-05-15',
'notes': 'Leave at side door',
'signature_waiver': True})
5. Invalid payloads are treated as denial#
A resume payload that fails Pydantic validation aborts the action with a structured failure — no exception leaks out. The failure surfaces in execution_history as a success=False entry with a form-flagged error string.
config2 = {"configurable": {"thread_id": "primer-bad"}}
graph.invoke(
{"goal": GoalSpec(conditions={"dispatched": True}),
"world_state": {}},
config=config2,
)
bad = graph.invoke(
Command(resume={"delivery_date": "not-a-date"}),
config=config2,
)
denial = [h for h in bad["execution_history"]
if h.action_name == "confirm_delivery"][-1]
(denial.success, "form" in (denial.error or "").lower(),
"dispatched" in bad["world_state"])
Action 'confirm_delivery' failed: human_form_invalid: form payload failed validation: [{'type': 'date_from_datetime_parsing', 'loc': ('delivery_date',), 'msg': 'Input should be a valid date or datetime, invalid character in year', 'input': 'not-a-date', 'ctx': {'error': 'invalid character in year'}, 'url': 'https://errors.pydantic.dev/2.13/v/date_from_datetime_parsing'}, {'type': 'missing', 'loc': ('notes',), 'msg': 'Field required', 'input': {'delivery_date': 'not-a-date'}, 'url': 'https://errors.pydantic.dev/2.13/v/missing'}]
(False, True, False)
Next steps#
For a plain approve/deny gate use
require_human_approval=Trueinstead of a model class.See the end-to-end story in
examples/tutorials/scheduled_delivery_confirmer.ipynb.