DromioWorkflow SDK
Workflow SDKAuthoringAPIWorkflow Apps & Harnesses

Design the graph

Decision treeStepsWorkflowsCompositionModels and judgmentHuman boundaries
Design the graph
Decision tree
Steps
Workflows
Composition
Models and judgment
Human boundaries

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}), then session.resume().
  • Signals and approvals: deliver session.pendingHooks[0].token through session.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_question submits {runId, questionId, value} through the existing controlPlane.answerQuestion() operation and returns the updated run snapshot.
  • dromio.resume_hook submits a hook token and value through controlPlane.resumeHook(). Use it for step.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.

On this page

  • Ask a question
  • Execution behavior
  • Question ownership
  • External signals