Human-in-the-Loop
Three mechanisms pause a run for a human:
- AskUserQuestion: the agent asks a question and waits for an answer.
- Tool approval: the agent waits for allow or deny before a gated tool call.
- 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:
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
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:
client.runs.answer(run.id, answers={"Which customer segment should I prioritize?": "enterprise"})Behavior depends on how the agent framed the question:
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()returns409. - If the question already auto-continued,
runs.answer()returns409withauto_continued; send a follow-up viaruns.reply()to redirect. - Auto-continued requests have
auto_resolved == Trueand their answer values intool_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_idruns). 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:
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():
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 policyWith decision="allow", remember=True allows matching requests for this run and saves an always-allow policy for future runs.
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/nofor a tool gate). A bareyes/noto 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:
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:
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:
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 oneHigh-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.
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
{
"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: falseon 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:
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