Skip to main content

Pause a workflow for human input

Sometimes a run should not decide alone: a rule is missing, a value is uncertain, a consequential step needs a go-ahead. A workflow stops on a question with ctx.user.ask, waits for the answer, and goes on with it. This guide covers that call, how the question is answered, and what asks for a human without any code of yours.

This is the author's side. For the user's view in the desktop application, see Approve or reject an action.

Ask a question from a step​

Inside a step, await ctx.user.ask(...) stops the run until the user answers:

from typing import TypedDict

from apollia import WorkflowCtx, workflow
from apollia.errors import QuestionDeclined


RULES = {"ACME": "suppliers"}


class Routing(TypedDict):
vendor: str
amount: float


@workflow(
"route-invoice",
"Decide where an invoice is filed, asking when no rule covers the vendor.",
inputs=Routing,
steps=["lookup", "decide"],
action="read",
)
async def route_invoice(ctx: WorkflowCtx, inputs: Routing) -> dict[str, str]:
async with ctx.step("lookup"):
folder = RULES.get(inputs["vendor"])

async with ctx.step("decide"):
if folder is None:
try:
folder = await ctx.user.ask(
f"No rule for {inputs['vendor']} ({inputs['amount']:.2f}). "
"Where should it be filed?",
options=["to-review", "suppliers", "rejected"],
hint="Another folder",
)
except QuestionDeclined as declined:
ctx.logger.info("no folder chosen: %s", declined.reason)
folder = "to-review"

return {"folder": folder}

The signature is ask(question, *, options=None, hint=None) and it returns the answer as a string:

ArgumentMeaning
questionWhat is asked, as the user reads it.
optionsThe proposed choices, 20 at most.
hintA placeholder for the free answer.

The user may always give a free answer instead of a choice. Check the answer before you branch on it: it is one of the options or any text.

What the wait does​

The run waits inside the step that asked. The question is listed with the decisions waiting for the user, with the run, the workflow and the step it comes from. The wait lasts until the user answers or the run is stopped:

  • the wait spends the wall clock of the budget of the run;
  • stopping the run ends the wait;
  • a run that is waiting when the runtime stops is failed at the next start, as every interrupted run is.

Write a clear question. Its quality drives the quality of the decision.

  • Weak: "Continue?"
  • Better: "No rule for Acme Corp (1240.00). Where should it be filed?"

Keep secrets and unnecessary personal data out of a question: it is stored and shown.

Answer the question​

From the command line, list what is waiting, then answer one item by its id:

apollia-os approvals pending
apollia-os approvals decide <id> answer --value "suppliers"
apollia-os approvals decide <id> refuse --reason "file it by hand this quarter"

answer hands --value to the run, which goes on: ask returns it. refuse declines the question: ask raises QuestionDeclined, which carries the question and the reason.

When the user declines​

QuestionDeclined is yours to catch. Caught, the workflow goes on without the answer, as the example does with a default folder. Left uncaught, the step that asked fails, and the run with it.

Test both branches​

Under MockContext, ctx.user is a mock: answers maps a question to its answer, and declined maps a question to a reason and makes ask raise. See Test your tools and workflows.

What asks without code​

Two pauses need no code in a package.

An agent in a conversation asks the user through the native ask_user tool. That is the counterpart of ctx.user.ask for an agent: the model decides to ask, and the answer comes back as the result of the tool.

A sensitive tool call asks for an approval. Every tool declares an action family, and every family except read is sensitive: before such a call runs, the user is asked, unless they already authorised it. The same approvals decide command answers it, with once for this call, always for this call and every later one of the same agent in the same family, or refuse. An organisation policy or a deny rule refuses the call outright, with no question. This is why the family a tool declares has to say what the tool really does: see Write a tool.

An MCP server carries no approval setting of its own. A tool it publishes is decided like any other tool, on its action family: the one the curated selection gives it, else the one its own annotations declare, else none, and a tool nobody classified is treated as sensitive. When an agent calls a tool whose family is sensitive, the run asks first, with three answers: once, always for this agent, or refuse. "Always" is a standing consent of that agent for that family, kept until it is withdrawn; an organisation policy, when there is one, comes before all of it.

What a tool cannot do​

ctx.user is bound in a workflow run only. A tool does not pause: when it lacks something, it returns what it found or raises a DomainError, and the agent that called it decides whether to ask the user.

NeedHumanInput, ctx.is_resumed and ctx.input_response belong to a task that pauses and is run again from the top when it resumes. Neither a tool nor a workflow is resumed that way: raised there, NeedHumanInput fails the call or the run like any other exception.