Human in the loop

Pause a tool call for a person to approve, edit, reject or answer, then resume the run.

When a deployment requires approval for a tool, the agent doesn't run it. The run pauses and asks for a decision. Your app shows the proposed call to a person, collects a decision, and resumes the run with it.

Which tools need approval is the operator's setting. You can't switch it on or off per run.

The interrupt

It arrives as an input.requested event carrying LangChain's interrupt payload:

input.requested
  .params.data.interrupt_id        the key to resume with
  .params.data.value
      action_requests[]    [{name, args, description}, …]
      review_configs[]     [{action_name, allowed_decisions}, …]

The two lists are paired by position: one decision per proposed call, in the same order. Swap two decisions and each approves the other's call, without any error.

review_configs[i].allowed_decisions lists the decisions that call accepts. Sending anything else fails the resume, and the interrupt is gone.

The four decisions

Run the call exactly as proposed.

{ "type": "approve" }

Resuming

Send one decision per proposed call, together. There are two ways.

With the SDK: command on a run

result = client.runs.wait(
    thread_id,
    "agent_platform",
    command={
        "resume": {
            "decisions": [
                {"type": "approve"},
                {
                    "type": "reject",
                    "message": "A human reviewer rejected this tool call. Reason: Wrong recipient.\n"
                    "The tool was not executed. Do not retry it unless the user explicitly asks you to.",
                },
            ]
        }
    },
)

runs.stream accepts the same command if you want to stream the rest of the turn.

Over HTTP: the commands endpoint

POST /threads/{thread_id}/commands with the method input.respond. The SDK has no method for it, so send it with your HTTP client and the same headers:

curl -X POST "$AICE_AGENT_URL/threads/$THREAD_ID/commands" \
  -H "Authorization: Bearer $AICE_API_KEY" -H "X-User-Id: $USER_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "id": 1,
    "method": "input.respond",
    "params": {
      "response": { "decisions": [{ "type": "approve" }] },
      "interrupt_id": "'"$INTERRUPT_ID"'"
    }
  }'
  • params.response is the inner value. The server wraps it in resume itself, so sending the {"resume": …} shape here nests it one level too deep.
  • It answers 200 even when it fails. Check the body's error field, not the status code.

When more than one interrupt is pending

Normally one interrupt carries every proposed call, and the untargeted form above is right. When several are pending (parallel subgraphs), resuming untargeted fails:

RuntimeError: When there are multiple pending interrupts, you must specify
the interrupt id when resuming.

Key the resume by the interrupt's ID:

{ "resume": { "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { "decisions": [{ "type": "approve" }] } } }

Check the ID before you send it

The resume is treated as targeted only if every key is exactly 32 lowercase hex characters. An uppercased or truncated ID doesn't raise: the whole object is quietly treated as untargeted, and then answers the wrong interrupt or fails with the error above, which never mentions the bad ID.

Good to know

  • Approval never carries over. Approving a call on one turn doesn't pre-approve the same call later, even in the same thread.
  • These payload shapes are what this deployment accepts. Aegra's and LangChain's own guides show slightly different forms for other setups, so test against a real interrupt before relying on either.

On this page