Human-in-the-Loop

Three mechanisms pause a run for a human:

  1. AskUserQuestion: the agent asks a question and waits for an answer.
  2. Tool approval: the agent waits for allow or deny before a gated tool call.
  3. Plan mode: the agent proposes a plan and waits for approval before executing.

Runs inherit their permission mode from the explicit request override or the saved agent/task default. Turn on human_in_the_loop when you want checkpoints before the agent continues.

Quick start

runs.wait() handles all three inline, no manual polling loop:

Python
from m8tes import M8tes

client = M8tes()

run = client.runs.create(agent_id=1, message="draft and send a weekly customer update",
                         human_in_the_loop=True, permission_mode="approval", stream=False)

run = client.runs.wait(
    run.id,
    on_question=lambda req: {"Which segment?": "enterprise"},  # answer questions
    on_approval=lambda req: "allow",                           # approve tool calls
)
print(run.output)

Both callbacks receive the full PermissionRequest: inspect req.tool_name and req.tool_input (for questions, req.tool_input["questions"] is the full list) before returning your decision.

permission_mode="approval" only pauses for tools that are not on the safe allowlist. Safe reads (and the Agent/Task spawn tools) do not raise awaiting_approval, so on_approval will not fire for those. If the agent stalls without a pending permission, create_and_wait / reply_and_wait raise TimeoutError and best-effort cancel the run (cancel_on_timeout=True); bare wait / poll observe only unless you opt in.

Permission modes

ModeWhat happens
autonomousAgent runs without approval pauses
approvalAgent handles routine, vetted work itself and pauses for high-stakes tool calls (shown as "Auto" in the platform UI)
planAgent proposes a plan first and waits for approval

If a run resolves to approval or plan and you omit human_in_the_loop, the API enables it automatically. Explicitly setting human_in_the_loop=False with those modes returns a validation error.

AskUserQuestion

Answers always go through runs.answer(). The answer key is the question text:

Python
client.runs.answer(run.id, answers={"Which customer segment should I prioritize?": "enterprise"})

Behavior depends on how the agent framed the question:

QuestionBehavior
Has a recommended option (label ending in (Recommended))Pauses to awaiting_approval; unanswered after ~10 minutes, the run auto-continues with the recommendation and notifies the owner
Recommended + non-blocking (metadata.source == "non-blocking")Never pauses; proceeds with the recommendation immediately and notifies the owner
No recommended optionPauses indefinitely (a question the agent genuinely can't default)

Tool approval gates and plan approvals never auto-continue.

Advanced: auto-continue markers, 409s, and finding pending questions
  • If the run is awaiting_approval, your answer resumes the run.
  • If the run is terminal (completed, failed, cancelled), runs.answer() returns 409.
  • If the question already auto-continued, runs.answer() returns 409 with auto_continued; send a follow-up via runs.reply() to redirect.
  • Auto-continued requests have auto_resolved == True and their answer values in tool_input["answers"] end with an [auto-selected ...] marker, so you can always tell a platform default from a human answer.
  • Non-blocking asks never enter awaiting_approval; deferrable ones pause up to ~10 minutes, then resume on their own. A run can complete with questions nobody answered.
  • A blocking pause unanswered for ~10 minutes on a first-party run triggers a "waiting on you" email to the owner (skipped for multi-tenant user_id runs). A present user who answers in-app never triggers it.

Find pending questions with runs.permissions(). Entries have tool_name == "AskUserQuestion" and tool_input["questions"] with the full question list:

Python
for req in client.runs.permissions(run.id):
    if req.tool_name == "AskUserQuestion" and req.status == "pending":
        for q in req.tool_input["questions"]:
            # q["question"] is the answer key; q["options"] is the list of choices
            print(q["question"], [o["label"] for o in q["options"]])
        client.runs.answer(run.id, answers={"Which customer segment should I prioritize?": "enterprise"})

Tool approval

Fetch pending requests with runs.permissions(), then decide with runs.approve():

Python
for req in client.runs.permissions(run.id):
    if req.status == "pending":
        client.runs.approve(run.id, request_id=req.request_id,
                            decision="allow",  # or "deny"
                            remember=True)     # also stores a cross-run always-allow policy

With decision="allow", remember=True allows matching requests for this run and saves an always-allow policy for future runs.

ResponseWhat to show
status="allowed", remembered=true“Always allowed”
status="allowed", remembered=falseAllowed for now; policy was not saved
status="denied"Denied
status="pending"Waiting for a decision
remembered=nullOlder record; saved-policy outcome unknown

GET /runs/{id}/permissions and permission_resolved events carry the saved outcome. A later event can confirm persistence; update the UI when it arrives.

Strict human-decision tools such as decide_value_observation always ask again and omit the remember control. To save a policy before running, see pre-approve trusted tools.

Approve from your inbox or Slack

A paused run (a tool gate or an AskUserQuestion) can be answered without opening the app:

  • Email: reply to the pause email with the option number or option name (or yes/no for a tool gate). A bare yes/no to a question is dropped.
  • Slack: when the run originated in Slack, click Approve/Deny for a tool gate or the per-option button for a single-question AskUserQuestion in the thread. The clicker must be the installer or an allowlisted Slack id.

Email is the universal fallback when a Slack prompt can't deliver. These are the human-friendly equivalents of runs.approve() (tool gates) and runs.answer() (AskUserQuestion).

Switch permission mode mid-run

Change the permission mode on a running or paused run without restarting it:

Python
client.runs.update_permission_mode(run_id=42, permission_mode="autonomous")
client.runs.update_permission_mode(run_id=42, permission_mode="approval")

Switching to autonomous auto-approves pending tool approval requests and resumes a paused tool approval run immediately. AskUserQuestion and plan approvals still wait for client.runs.answer().

Pre-approve trusted tools

Create per-user permission policies so trusted tools skip approval pauses across runs. Pre-approve read-only tools (search, fetch, list) and keep write/send/delete actions gated:

Python
client.permissions.create(user_id="cust_123", tool="slack")
client.permissions.create(user_id="cust_123", tool="linear")

Omit user_id to pre-approve a tool at the account level: the scope runs match when they carry no user_id. This is also where "always allow" decisions made during a run are stored, so it is the list to check when you want to see or revoke standing approvals:

Python
client.permissions.create(tool="slack")           # applies to runs with no user_id
for policy in client.permissions.list():          # standing account-level approvals
    client.permissions.delete(policy.id)          # revoke one

High-stakes actions (money, access, destroy) ask a human by default in approval/plan mode. Autonomous mode and an explicit Always-allow (approval card tick, or POST /permissions) supply standing grants; hard blocks, explicit denials, and strict human-decision tools still take precedence. Treat pre-approving one as deliberate. Every policy records source (run_approval or api). Routine task setup, including attaching an already-connected app and pausing or resuming a schedule, auto-approves without a stored policy.

Onboarding pattern

Set policies at user creation time so the agent isn't interrupted constantly. Low-risk candidates: slack, linear, googlesheets, gmail, notion.

Python
def onboard_user(user_id: str):
    client.users.create(user_id=user_id)
    for tool in ["slack", "linear", "googlesheets"]:
        client.permissions.create(user_id=user_id, tool=tool)

Plan mode

The agent proposes a plan and waits for approval before executing. It has its own page: Plan Mode.

Production pattern: webhooks

Prefer webhooks over polling: subscribe to run.awaiting_input, show the pause, send the decision with answer or approve. AskUserQuestion pauses include questions in the payload; tool approval pauses use runs.permissions(). See Webhook Events.

Sample run.awaiting_input payload
JSON
{
  "type": "run.awaiting_input",
  "data": {
    "id": 42,
    "status": "awaiting_approval",
    "questions": [
      {
        "question": "Which customer segment should I prioritize?",
        "header": "Segment",
        "multiSelect": false,
        "options": [
          { "label": "enterprise", "description": "Large orgs (500+ seats)" },
          { "label": "mid-market", "description": "Growing teams" }
        ]
      }
    ]
  }
}
Replies on human-in-the-loop runs

runs.reply() inherits the run's persisted settings: the permission mode keeps applying on follow-ups, and AskUserQuestion stays enabled when the run was created with human_in_the_loop: true (or a mode that defaults it on). Two consequences:

  • Unattended reply loops: a reply on a HITL run can pause on a question. If your server-side code replies without a human watching, pass human_in_the_loop: false on the reply to pin the always-non-interactive behavior. Runs created before this setting was persisted stay non-interactive.
  • Handling gates on follow-ups: use reply_and_wait() to answer approvals and questions inline:
Python
run = client.runs.reply_and_wait(
    run.id,
    message="also break it down by region",
    on_approval=lambda req: "allow",
)
print(run.output)

Next: Runs · Users · Tools · Webhook Events

Was this page helpful?