Skip to main content
Human-in-the-Loop (HITL) allows agents to pause execution and request user input or approval before proceeding. This is crucial for scenarios requiring human oversight, such as approval workflows, form submissions, or interactive decision-making. The key mechanism is the interrupt feature, where the agent sends an interrupt event during streaming, pausing until the client resumes with a payload.

Interrupt Flow Overview

Here’s a high-level sequence of how the client and agent interact during an interrupt:

Agent-Side Implementation (Brief Overview)

On the backend, agents can trigger interrupts:
  • AG-Kit: Use a custom control flow handler to return { action: 'interrupt', reason: '...', payload: { ... } }.
  • LangGraph: Use built-in interrupt nodes in the graph.
For details, see the framework-specific documentation.

Client-Side Handling

Focus on the frontend integration using the @ag-kit/ui-react package for React applications.

Understanding the Interrupt Payload

Before diving into the code, let’s clarify the interrupt payload. The payload is arbitrary, customizable data sent by your agent when it triggers an interrupt. It’s not fixed; you define what it contains based on what user input your agent needs. For example:
  • In an approval workflow, it might be a list of steps for the user to review.
  • In a form submission, it could be a schema defining form fields.
  • It can be any JSON-serializable data, like a question or options.
The agent decides the payload structure on the backend. The frontend then renders appropriate UI based on it and resumes with the user’s response. This flexibility allows HITL to adapt to various scenarios. In the examples below, we’ll show a steps-based payload to demonstrate an approval flow, but remember: customize it for your use case.

Using the useChat Hook

The useChat hook supports HITL via the interrupt option. Here’s a complete simple example:
import { useChat } from "@ag-kit/ui-react";

function SimpleApprovalChat() {
  const { sendMessage, uiMessages, streaming } = useChat({
    url: "/your-agent-endpoint", // Replace with your actual endpoint
    interrupt: {
      renderWithResume({ interrupt, resume }) {
        // Extract message from payload (assume it's { message: string })
        const message = (interrupt.payload as { message: string }).message;
        
        return (
          <div>
            <p>{message}</p>
            <button 
              onClick={() => resume({ approved: true })}
              disabled={streaming} 
            >
              Yes
            </button>
            <button 
              onClick={() => resume({ approved: false })}
              disabled={streaming}
            >
              No
            </button>
          </div>
        );
      },
    },
  });

  return (
    <div>
      {/* Simple message display */}
      {uiMessages.map((msg, i) => (
        <div key={i} style={{ margin: "10px 0" }}>
          <strong>{msg.role}:</strong>
          {msg.parts.map((part, j) => (
            <div key={j}>
              {part.type === "text" && <p>{part.text}</p>}
              {part.type === "interrupt" && part.render?.()}
            </div>
          ))}
        </div>
      ))}
      <button 
        onClick={() => sendMessage("Perform an action that requires approval")}
        disabled={streaming}
      >
        Send Message
      </button>
    </div>
  );
}
The interrupt part appears in uiMessages as a special part with type "interrupt".
  • interrupt: Contains id, reason, and payload from the agent.
  • resume(payload): Sends the user’s response back to resume execution.