Skip to main content

Documentation Index

Fetch the complete documentation index at: https://langchain-5e9cc07a-preview-cbuipl-1779916257-33d1bcf.mintlify.app/llms.txt

Use this file to discover all available pages before exploring further.

Not every agent action should run unsupervised. When an agent is about to send an email, delete a record, execute a financial transaction, or perform any irreversible operation, you need a human to review and approve the action first. The Human-in-the-Loop (HITL) pattern lets your agent pause execution, present the pending action to the user, and resume only after explicit approval. Because HITL is built on LangGraph interrupts and checkpoints, the pause is durable. A user can refresh the page, a reviewer can answer from a different component, and the agent still resumes from the exact point where execution stopped instead of replaying the whole run.

How interrupts work

LangGraph agents support interrupts, explicit pause points where the agent yields control back to the client. When the agent hits an interrupt:
  1. The agent stops executing and emits an interrupt payload
  2. The useStream hook surfaces the interrupt via stream.interrupt
  3. Your UI renders a review card with approve/reject/edit options
  4. The user makes a decision
  5. Your code calls stream.submit() with a resume command
  6. The agent picks up where it left off
The frontend SDK keeps the interrupt alongside the rest of the thread state, so your UI can render it wherever it makes sense: inline in the transcript, in a review queue, in an admin dashboard, or in a modal that blocks the next user action until the decision is made.

Setting up useStream

Connect useStream to your human-in-the-loop agent. When the graph hits an interrupt, the hook exposes the pending payload on stream.interrupt. Render an approval card while that value is set, then resume the run with stream.submit(null, { command: { resume: response } }) after the user approves, rejects, or edits the action.
The code examples use useStream<typeof myAgent> for type-safe stream state. See Type inference for Python or JavaScript backends.
import { useStream } from "@langchain/react";

const AGENT_URL = "http://localhost:2024";

export function Chat() {
  const stream = useStream<typeof myAgent>({
    apiUrl: AGENT_URL,
    assistantId: "human_in_the_loop",
  });

  const interrupt = stream.interrupt;

  return (
    <div>
      {stream.messages.map((msg) => (
        <Message key={msg.id} message={msg} />
      ))}
      {interrupt && (
        <ApprovalCard
          interrupt={interrupt}
          onRespond={(response) =>
            stream.submit(null, { command: { resume: response } })
          }
        />
      )}
    </div>
  );
}

The interrupt payload

When the agent pauses, stream.interrupt contains a HITLRequest with the following structure:
interface HITLRequest {
  actionRequests: ActionRequest[];
  reviewConfigs: ReviewConfig[];
}

interface ActionRequest {
  name: string;
  args: Record<string, unknown>;
  description?: string;
}

interface ReviewConfig {
  allowedDecisions: ("approve" | "reject" | "edit" | "respond")[];
}
PropertyDescription
actionRequestsArray of pending actions the agent wants to perform
actionRequests[].nameThe action name (e.g. "send_email", "delete_record")
actionRequests[].argsStructured arguments for the action
actionRequests[].descriptionOptional human-readable description of what the action does
reviewConfigsPer-action configuration controlling which decisions are allowed
reviewConfigs[].allowedDecisionsWhich buttons to show: "approve", "reject", "edit", "respond"

Decision types

The HITL pattern supports four decision types:

Approve

The user confirms the action should proceed as-is:
const response: HITLResponse = {
  decisions: [{ type: "approve" }],
};

stream.submit(null, { command: { resume: response } });

Reject

The user denies the action with an optional reason:
const response: HITLResponse = {
  decisions: [
    {
      type: "reject",
      message: "The email tone is too aggressive. Please revise.",
    },
  ],
};

stream.submit(null, { command: { resume: response } });
When an action is rejected, the agent receives the rejection reason and can decide how to proceed. It may rephrase, ask clarifying questions, or abandon the action entirely.

Edit

The user modifies the action’s arguments before approving:
const response: HITLResponse = {
  decisions: [
    {
      type: "edit",
      editedAction: {
        name: actionRequest.name,
        args: {
          ...actionRequest.args,
          subject: "Updated subject line",
          body: "Revised email body with softer language.",
        },
      },
    },
  ],
};

stream.submit(null, { command: { resume: response } });

Respond

The user provides a direct reply for “ask user” style tools. The message becomes the tool result and the tool itself is not executed:
const response: HITLResponse = {
  decisions: [{ type: "respond", message: "Blue." }],
};

stream.submit(null, { command: { resume: response } });
Use respond when the tool is intentionally a placeholder for human input — for example, an ask_user tool that prompts the agent to collect information from the user.

Building the ApprovalCard

Here is the decision wiring used by the approval cards. The UI can split each action into its own card, but the resume payload is a single HITLResponse with one decision per pending action:
async function approveAll() {
  const resume: HITLResponse = {
    decisions: actionRequests.map(() => ({ type: "approve" })),
  };
  await stream.submit(null, { command: { resume } });
}

async function rejectOne(index: number, message: string) {
  const resume: HITLResponse = {
    decisions: actionRequests.map((_, i) =>
      i === index
        ? { type: "reject", message }
        : { type: "reject", message: "Rejected along with other actions" },
    ),
  };
  await stream.submit(null, { command: { resume } });
}

async function editOne(index: number, editedArgs: Record<string, unknown>) {
  const originalAction = actionRequests[index];
  const resume: HITLResponse = {
    decisions: actionRequests.map((_, i) =>
      i === index
        ? {
            type: "edit",
            editedAction: { name: originalAction.name, args: editedArgs },
          }
        : { type: "approve" },
    ),
  };
  await stream.submit(null, { command: { resume } });
}

The resume flow

After the user makes a decision, the full cycle looks like this:
  1. Call stream.submit(null, { command: { resume: hitlResponse } })
  2. The useStream hook sends the resume command to the LangGraph backend
  3. The agent receives the HITLResponse and continues execution. Each entry in decisions may be one of:
    • { type: "approve" }: The agent continues executing the action
    • { type: "reject", message }: The agent receives the rejection message and decides its next step
    • { type: "edit", editedAction }: The agent runs the tool with edited arguments
    • { type: "respond", message }: The human’s message is returned directly as the tool result without executing the tool
  4. The interrupt property resets to null as the agent resumes streaming
You can chain multiple HITL checkpoints in a single agent run. For example, an agent might ask for approval to search, then ask again before sending an email with the results. Each interrupt is handled independently.

Handling multiple pending actions

An interrupt can contain multiple actionRequests when the agent wants to perform several actions at once. Render a card for each and collect all decisions before resuming:
function MultiActionReview({
  interrupt,
  onRespond,
}: {
  interrupt: { value: HITLRequest };
  onRespond: (response: HITLResponse) => void;
}) {
  const [decisions, setDecisions] = useState<Record<number, HITLResponse["decisions"][number]>>({});
  const request = interrupt.value;

  const allDecided =
    Object.keys(decisions).length === request.actionRequests.length;

  return (
    <div className="space-y-4">
      {request.actionRequests.map((action, i) => (
        <SingleActionCard
          key={i}
          action={action}
          config={request.reviewConfigs[i]}
          onDecide={(response) =>
            setDecisions((prev) => ({ ...prev, [i]: response }))
          }
        />
      ))}
      {allDecided && (
        <button
          className="rounded bg-green-600 px-4 py-2 text-white"
          onClick={() =>
            onRespond({
              decisions: request.actionRequests.map((_, i) => decisions[i]),
            })
          }
        >
          Submit All Decisions
        </button>
      )}
    </div>
  );
}

Best practices

Keep these guidelines in mind when implementing HITL workflows:
  • Show clear context. Always display what the agent wants to do and why. Include the action description and the full arguments.
  • Make approve the easiest path. If the action looks correct, approving should be a single click. Reserve multi-step flows for reject/edit.
  • Validate edited args. When users edit action arguments, validate the JSON structure before sending. Show inline errors for malformed input.
  • Persist the interrupt state. If the user refreshes the page, the interrupt should still be visible. useStream handles this via the thread’s checkpoint.
  • Log all decisions. For audit trails, log every approve/reject/edit decision with timestamps and the user who made the decision.
  • Set timeouts thoughtfully. Long-running agents should not block indefinitely on human review. Consider showing how long the agent has been waiting.