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=True instead of a model class.

  • See the end-to-end story in examples/tutorials/scheduled_delivery_confirmer.ipynb.