Skip to content

Human in the loop

An agent can stop and ask before it acts. reflex-agentkit uses the AG-UI standard interrupts for both kinds of pause: approvals for a tool call, and questions (generic interrupts) for anything else. While paused, run_status is awaiting_input and agent_chat opens the approval dialog.

Approvals with PydanticAI

Mark the tool requires_approval=True. Nothing else is needed: the adapter makes the agent pause cleanly.

@agent.tool_plain(requires_approval=True)
def book_hotel(city: str, nights: int) -> str:
    """Book a hotel room."""
    return f"Booked {nights} nights in {city}."

The dialog lets the user approve, edit the arguments as JSON, or reject with a reason. Edited arguments are what the tool receives, and the tool's result tells the model that the user changed them, so it doesn't "correct" the edit by calling the tool again (turn this off with PydanticAIAgent(agent, notify_edited_args=False)). A rejection becomes the tool's result, so the model can react to it.

Approvals with LangGraph

Call request_tool_approval inside a node or tool. It pauses the graph and returns the decision when the run resumes:

from reflex_agentkit.adapters.langgraph import request_tool_approval


def act(state):
    call = state["messages"][-1].tool_calls[0]
    decision = request_tool_approval(str(call["id"]), call["name"], call["args"])
    if decision.approved:
        result = book_hotel(**decision.args)  # the edited arguments, if any
    else:
        result = f"Rejected: {decision.reason or 'no reason given'}"
    content = decision.tool_result(result)  # tells the model about edits
    return {"messages": [ToolMessage(content=content, tool_call_id=call["id"])]}

decision.tool_result(result) appends a note when the user edited the arguments, so the model knows the change was deliberate. Without it, models often report the edited result as a mistake or call the tool again with their original arguments.

The graph needs a checkpointer (for example InMemorySaver), because LangGraph resumes from its checkpoint.

Questions

In LangGraph, interrupt(value) asks the user. A dict value with reason and message keys sets PendingInterrupt.reason and .message; the whole value is in .payload.

from langgraph.types import interrupt


def ask(state):
    nights = interrupt({"reason": "question", "message": "How many nights?"})
    return {"nights": int(nights)}

The default form is a JSON editor, validated against the interrupt's response_schema when there is one. Register a renderer per reason to replace it. The renderer must call respond_to_interrupt itself:

def question_form(pending, state):
    return rx.form(
        rx.text(pending.message),
        rx.input(name="answer", aria_label="Your answer"),
        rx.button("Send answer", type="submit"),
        on_submit=lambda form: state.respond_to_interrupt(form.to(dict)["answer"]),
    )


agent_chat(ChatState, interrupt_renderers={"question": question_form})

Several at once, and cancelling

One pause can hold several interrupts. The dialog shows them one at a time, approvals first, and the resume run starts when all of them have an answer. Each LangGraph node receives its own answer.

cancel_run while paused rejects the open approvals and cancels the questions. The next run tells the agent: a LangGraph approval returns ApprovalDecision(approved=False, reason="cancelled"), and a cancelled interrupt() returns {"__agui_cancelled__": True, ...}.

The resume payload

Any AG-UI agent that follows this contract works with the dialog:

Interrupt ResumeEntry.status ResumeEntry.payload
Approved resolved {"approved": true}
Approved with edits resolved {"approved": true, "editedArgs": {...}}
Rejected resolved {"approved": false, "reason": "..."} (reason optional)
Question answered resolved the answer, as given
Cancelled cancelled omitted