motus serve can pause itself, send a payload to the parent server, wait for a reply, and then continue from exactly where it stopped. The session API exposes this as a state called interrupted, and clients drive the conversation back to running by posting to a new endpoint called /resume.
Pick your approach
Block dangerous tools
Ask the user a question
Build your own flow
interrupt() primitive for any custom payload shape.Try it in 30 seconds
The fastest way to see HITL in action is the bundled example agent and the reference CLI client.src/motus/serve/cli.py for the full implementation if you want to use it as a template for your own UI.
How it works
Every message you send to a session spawns a fresh worker subprocess. The worker runs your agent, and your agent can callinterrupt() from anywhere inside its execution. When that happens:
The worker pauses
await interrupt(payload). The worker sends the payload to the parent server over a pipe and the agent’s coroutine blocks on a future, waiting for a reply.The session enters the interrupted state
running to interrupted. Any client doing a long poll on GET /sessions/{id} wakes up immediately and sees the new state.The client shows the payload to the user and collects a reply
The client posts a resume
POST /sessions/{id}/resume with the interrupt_id and a value ships the user’s reply back through the server, into the worker, and into the agent’s awaiting future. The agent picks up exactly where it left off.AgentServer runs in both environments, so the REST API, session lifecycle, and wire protocol are the same.
Three ways to pause an agent
1. Tool approval gates
The simplest case: you have a tool that should never run without explicit user approval. Addrequires_approval=True to the @tool decorator and Motus does the rest.
delete_file, Motus pauses the worker and emits an interrupt with this shape:
approved is true, the tool runs as normal. If it is false or missing, Motus raises a ToolRejected exception that surfaces to the model as a tool error. The agent sees {"error": "User rejected delete_file"} and can react: try a different approach, ask the user what to do instead, or give up gracefully.
2. Structured questions with ask_user_question
Sometimes the agent does not need approval. It needs information. Maybe it does not know which file to edit, or which date range to pull data for, or whether the user wants the long answer or the short one. The ask_user_question builtin tool lets the model ask structured questions with predefined options.
ask_user_question to clarify. The interrupt payload looks like this:
answers dict becomes the return value of the ask_user_question tool that the model sees, so the agent can continue reasoning with the user’s choice in hand.
Schema reference
Theask_user_question tool validates inputs against this Pydantic schema:
| Field | Type | Constraint | Notes |
|---|---|---|---|
questions | list | required, 1 to 4 items | Top level array. |
questions[].question | string | required | The full question text, ending with ?. |
questions[].header | string | required, recommended max 12 chars (length not enforced) | Short chip label for the UI. |
questions[].multiSelect | bool | optional, default false | Set to true for non-mutually-exclusive choices. |
questions[].options | list | required, 2 to 4 items | The list of choices. |
questions[].options[].label | string | required | Display text, 1 to 5 words. |
questions[].options[].description | string | required | Explanation of this option. |
questions[].options[].markdown | string | null | optional | Preview shown in a monospace box when this option is focused. |
3. Custom interrupts with the interrupt() primitive
If neither pattern fits, drop into the primitive directly. interrupt() accepts any dict and returns whatever value the client posts back. This is what you reach for when you want to ask for free form text input, present a custom UI, or build your own elicitation pattern.
type field is a convention, not enforced. Pick any string your client knows how to handle. The framework’s built-in interrupts use tool_approval and user_input. If you want the reference CLI client (motus serve chat) to render your custom interrupts, reuse one of those strings. Otherwise, the CLI prints [warn] unknown interrupt type on every poll and never resumes the interrupt, so the session stays wedged until you build your own client.
Session state machine
The session status is the source of truth for what your client should do next.| Status | What it means | What you can do |
|---|---|---|
idle | Waiting for input. Initial state after creation. | Send a message with POST /messages. |
running | The worker is executing the agent. | Long poll GET /sessions/{id}?wait=true. |
interrupted | The agent paused and is waiting for one or more resumes. | Read interrupts, present to user, post POST /resume for each. |
error | The worker failed (exception, timeout, cancellation, or crash). The error field has the message. | Read the error, optionally send a new message to retry. |
The REST flow end to end
Here is the complete sequence for a turn that triggers an approval gate.Show the user, collect a decision, post the resume
200 OK):interrupted until you have resumed all of them.idle or error.
A minimal client loop
Here is the polling pattern you want to follow in any custom client:Common errors
RuntimeError: interrupt() called outside motus serve worker subprocess
RuntimeError: interrupt() called outside motus serve worker subprocess
interrupt() from a unit test, REPL, or some other context that is not a serve worker. The primitive only works inside a process spawned by AgentServer. To test, use a real serve process in your fixture (see tests/integration/serve/test_hitl.py).ValueError: Interrupt message too large
ValueError: Interrupt message too large
409 Conflict on POST /messages
409 Conflict on POST /messages
running or interrupted. You cannot send a new user message until the current turn finishes. Either wait for the long poll to return idle, post a resume, or DELETE the session.404 Not Found on POST /resume
404 Not Found on POST /resume
interrupt_id you sent does not exist in the session’s pending interrupts. This usually means one of three things: the interrupt was already resumed (resumes are not idempotent, the second call gets a 404), the session was deleted or timed out, or there is a typo in the id. Check your client logic for double-resume and use the exact id from the most recent poll response.409 Conflict on POST /resume
409 Conflict on POST /resume
interrupted state. You probably raced a resume against a fast-completing turn. Re-poll with wait=true and only post a resume when the status comes back interrupted.Session never returns to idle
Session never returns to idle
interrupts array and posts a resume for each one. The CLI client (motus serve chat) only handles tool_approval and user_input. Custom interrupt types make it print [warn] unknown interrupt type on every poll without ever resuming, so the session stays wedged. If you use custom types, build your own client.Things to know
A few details that will save you debugging time later.Sessions live in memory
Sessions live in memory
motus serve process holds all sessions in a dict that does not survive restarts. In Motus Cloud, each deployment runs as a single process, so HITL just works. If you ever scale a serve deployment horizontally yourself, you need sticky session routing so resume requests land on the same process that holds the interrupted session.Interrupted sessions bypass TTL sweeps
Interrupted sessions bypass TTL sweeps
--ttl flag auto-sweeps idle and errored sessions, but not running or interrupted ones. A session that pauses on an interrupt and then gets abandoned will stay in memory until you explicitly delete it or restart the server. Build a client side timeout if you expect users to walk away mid approval.Cancellation kills pending interrupts
Cancellation kills pending interrupts
DELETE the session while it is interrupted, the worker is killed. Any pending await interrupt(...) inside the agent raises EOFError("Worker pipe closed"), and the session transitions to error. The error message visible over HTTP will be a traceback, not a clean string.Rejected approvals are not silent
Rejected approvals are not silent
{"error": "User rejected <tool_name>"} as the tool result, not a clean skip. This is intentional: it lets the model know what happened and decide what to do next. The agent might apologize, suggest alternatives, or ask a follow up question.Multiple concurrent interrupts are supported
Multiple concurrent interrupts are supported
asyncio.gather). The session’s pending_interrupts is a dict and each must be resumed individually. The status only flips back to running once every pending interrupt has been resolved. The order does not matter.Webhooks fire on turn completion, not on interrupts
Webhooks fire on turn completion, not on interrupts
POST /messages request, it fires when the session reaches idle or error, not on each interrupt. If your orchestration depends on knowing the exact moment an interrupt arrives, use long polling instead.The type field is a convention, not validated
The type field is a convention, not validated
type field on an interrupt payload. The framework, the CLI, and the example client use tool_approval and user_input. If you make up your own type strings, your client needs to know how to handle them, and the reference CLI client will warn and skip them.Where to go next
Serving
Sessions API
Tools
@tool decorator and guardrails work under the hood.
