Human input, approvals, signals, and timers
Waiting primitives suspend a run without completing the current step. Resume the same session through an answer or hook token; do not model ordinary graph dependencies as waits.
Ask a question
import { step } from "@dromio/workflow";
import { z } from "zod";
export const askAudience = step.ask({
id: "requests.ask-audience",
input: {topic: z.string().min(1)},
answer: z.string().trim().min(1),
output: {audience: z.string().min(1)},
question: ({input}) => ({
id: "audience",
type: "text",
title: "Audience",
prompt: `Who should receive ${input.topic}?`,
}),
mapAnswer: ({answer}) => ({audience: answer}),
});
Reaching step.ask() always asks. The run becomes waiting; after
session.answer() and session.resume(), the answer contract is validated and
mapAnswer() completes the step. Put “do we need clarification?” logic in an
earlier step or route.
Question type is one of "text", "choice", "multi", or "confirm".
Choice and multi questions may provide options with label, value, and
optional description or recommended; allowCustom permits another value.
Clarify until a contract is complete
Use workflow.clarifyUntil() when resolving a structured contract may require
several human answers. It builds real resolve, judge, gate, ask, merge, revise,
and complete nodes. These are workflow roles, not new execution kinds.
const clarifyTodo = workflow.clarifyUntil({
id: "todo.requirements",
input: {request: z.string().min(1)},
contract: todoContractSchema,
blockers: blockersSchema,
answer: z.string().trim().min(1),
resolve: resolveTodoRequirements,
judge: judgeTodoCompleteness,
revise: reviseTodoRequirements,
maxRounds: 8,
question: ({blockers}) => ({
id: blockers[0]!.id,
requirementId: blockers[0]!.id,
type: blockers[0]!.id === "stage" ? "choice" : "text",
title: blockers[0]!.message,
prompt: blockers[0]!.id === "stage"
? "How far should this todo app go?"
: `What should we decide for ${blockers[0]!.id}?`,
options: blockers[0]!.id === "stage"
? ["prototype", "mvp", "production"].map((value) => ({label: value, value}))
: undefined,
}),
merge: ({answer, contract, question}) => ({
contract: {...contract, [question.requirementId!]: answer},
blockers: remainingBlockers({...contract, [question.requirementId!]: answer}),
}),
});
Given create a todo app, a run can ask:
Dromio: How far should this todo app go?
Human: MVP
Dromio: Which platform?
Human: Responsive web
Dromio: Who are the users?
Human: Individual users
Dromio: Where should tasks persist?
Human: PostgreSQL
Dromio: How should users authenticate?
Human: Email magic link
Dromio: Where should the app run?
Human: Managed web hosting
Dromio: The contract now has no MVP blockers and passes the completeness gate.
The resulting typed contract is the workflow output:
{
"stage": "mvp",
"platform": "responsive web",
"users": "individual users",
"persistence": "PostgreSQL",
"authentication": "email magic link",
"deployment": "managed web hosting",
"nonGoals": ["native mobile app", "multi-tenant administration"]
}
Prototype policy can stop after the stage and core platform decisions. Production policy adds explicit security, observability, backups, accessibility, scaling, service-objective, and recovery blockers before the same completion boundary can pass.
Only one focused question is pending per round. Every accepted answer is
validated before the run advances and is retained as append-only provenance:
{questionId, requirementId, round, source: "human", value}. The loop only
completes when the judgment passes and no blockers remain. A revision cannot
delete or contradict a contract field that was directly established by an
accepted human answer. maxRounds prevents
an unbounded conversation; supply an explicit exhausted step when escalation
is preferable to failure.
An external harness does not answer these questions by inference. It reads the pending question, presents it to the human, and submits the human's answer.
Request approval
const approvePublish = step.approval({
id: "articles.approve-publish",
input: {articleId: z.string()},
decision: z.enum(["approve", "reject"]),
output: {approvedArticleId: z.string()},
title: "Approve publication",
request: ({input}) => ({articleId: input.articleId}),
reject: ({decision}) => decision === "reject" ? "Publication rejected." : undefined,
mapDecision: ({input}) => ({approvedArticleId: input.articleId}),
});
step.approval() creates a typed approval hook. The host renders and delivers
the decision. reject() may convert a valid negative decision into a failed
run; authorization remains the host's responsibility.
Wait for an external signal
import { defineSignal, step } from "@dromio/workflow";
const paymentConfirmed = defineSignal({
id: "payment.confirmed",
correlation: z.object({orderId: z.string()}),
payload: z.object({transactionId: z.string()}),
title: "Payment confirmed",
});
const waitForPayment = step.waitFor({
id: "orders.wait-for-payment",
input: {orderId: z.string()},
signal: paymentConfirmed,
correlation: ({input}) => ({orderId: input.orderId}),
});
The output contains occurrenceId, occurredAt, and the validated payload.
Correlation is canonicalized and hashed for delivery matching. Signal ingress
must authenticate the sender, store receipts if required, deduplicate
occurrences, and map them to pending waits.
Wait for time
const pauseBeforeReminder = step.sleep({
id: "reminders.pause",
input: {reminderId: z.string()},
output: {reminderId: z.string(), firedAt: z.string()},
timerId: ({input}) => `reminder:${input.reminderId}`,
schedule: () => ({ms: 60_000}),
mapFired: ({fired, input}) => ({
reminderId: input.reminderId,
firedAt: fired.firedAt,
}),
});
schedule() returns either {ms: number} or {until: Date | string}. String
deadlines should be unambiguous ISO-8601 timestamps. mapFired() receives
{input, fired: {firedAt: string}}. The timer is a durable hook;
the host/control plane must persist and deliver it. It does not occupy a worker
while waiting.
Resume correctly
- Human questions:
session.answer({questionId, value}), thensession.resume(). - Signals and approvals: deliver
session.pendingHooks[0].tokenthroughsession.resumeHook({token, value}). - Timers: let the host's timer worker deliver the pending timer hook.
- Process recovery: persist
session.snapshot(), hydrate against the same workflow, then resume.
For a Workflow App exposed through the control-plane MCP provider:
dromio.answer_questionsubmits{runId, questionId, value}through the existingcontrolPlane.answerQuestion()operation and returns the updated run snapshot.dromio.resume_hooksubmits a hook token and value throughcontrolPlane.resumeHook(). Use it forstep.delegate(), approvals, signals, timers, and other hooks—not ordinary question answers.
If answer validation fails, the same question stays pending so the human can
correct it. If delegated output validation fails, the same
handoff_requested hook stays pending. Unknown or already-consumed tokens fail
without replaying completed steps.
Use a workflow edge for ordering, step.router() for a branch,
step.forEach() for polling-like repeated work with an explicit policy, and an
app-owned step for external APIs. Hooks are runtime suspension mechanics, not a
substitute for topology.
An approval hook receives the raw value declared by its decision contract:
await session.resumeHook({token: approvalHook.token, value: "approve"});
A signal hook instead receives {occurrenceId, occurredAt, payload}. See
Execution and durability
for HTTP ingress. Never
send one shape to the other kind of hook.