Skip to main content

createUserInputNode

workflow.createUserInputNode() Creates a human input node. When the node executes, it will first check whether the current run has a submission answer corresponding to requestId; If not, it will publish the user_input_requested event to runtime, save run as waiting_for_input, and display the waiting form in CLI Web or API.

While a Conversation is waiting for input, shared runtime UI hides the bottom message composer and keeps the waiting form available until the user submits or cancels it.

The form field reuses the ParamDefinition of the current Workflow entrypoint(Conversation is executor), and does not create another schema for the user input node.

Wait forms also support date, time, datetime, and date-range. The submitted values remain YYYY-MM-DD, HH:mm:ss, local YYYY-MM-DDTHH:mm:ss, and closed interval YYYY-MM-DD/YYYY-MM-DD strings, respectively, so workflow can read directly from payload.values[param.name] without receiving a Date object or a time zone-converted value. The date field must use type: "string", can only be placed in main or auxiliary, cannot declare enumeration options or multiple: true; Required blank values or formatting errors will remain in the form to display errors and prevent submission until the user corrects them.

Signature​

workflow.createUserInputNode<Input, Output extends RunPayload>(
options: UserInputNodeOptions<Input, Output>,
): BaseNode<Input, Output>

options​

FieldTypeDescription
namestringNode name, default "user-input". will be used for report, requestId, and Diagram.
titlestringWait for the form title. Use name when not passed.
descriptionstringWait for the form description.
paramsParamDefinition[]Form field, field definition is consistent with params of the selected Workflow entry;Conversation uses executor.params.
defaultValuesRecord<string, unknown>Form default, match by param name.
resolveRequest(input, context) => UserInputRequestDefinition | Promise<UserInputRequestDefinition>Dynamically generate title, description, params, and defaultValues based on the current node input.
createRequestId(input, context) => stringThe custom request ID. The default contains workflow, runId, and node name.
format(payload, input, context) => OutputConvert the default submission payload to the business payload.
historyLimitnumberThe number of nodes retained in the execution history. The default value is 50.
metadataNodeMetadataInputThe node displays meta information.

Default payload​

interface UserInputPayload extends RunPayload {
requestId: string;
values: Record<string, unknown>;
submittedAt: string;
}

Default return on successful recovery:

{
errCode: 0,
errMessage: "",
requestId,
values,
submittedAt
}

If format is passed in, the return value must still inherit RunPayload, using errCode: 0, errMessage: "" on success.

Runtime behavior​

PhaseBehavior
First executionWhen no value is submitted, user_input_requested is published, pendingUserInput is reported, and the run status changes to waiting_for_input.
Submit answerThe local runtime UI submits { requestId, values }.
Resume Executionruntime injects resolvedUserInputs with the same runId, the node publishes user_input_resolved and returns the payload.
Non-interactive CLICLI does not read stdin; steamharness json --run-id ... --resolved-user-inputs-json ... recovery is required.

waiting_for_input is a persistent, non-final state. Resume with the locally saved pendingUserInput, the same runId, and the original entrypointId; do not switch to a new default entrypoint. Conversation retains a single entry and rejects entrypoint parameters. Server does not save or resume runs.

When the problem field is dynamically generated by an external agent, remote approval system, or current node input, you can keep static params as the default definition for structural analysis and use resolveRequest to generate this waiting form. resolver can be executed asynchronously, but must be deterministic and have no side effects on the same input; the field names and option values returned must be stable when the same requestId is restored.

const questionNode = workflow.createUserInputNode<AgentQuestion>({
name: "agent-question",
params: [],
resolveRequest(question) {
return {
title: question.header,
description: question.prompt,
params: [{
name: "answer",
type: "string",
control: question.multiple ? "checkboxes" : "radio",
multiple: question.multiple,
required: true,
options: question.options,
}],
};
},
createRequestId(question) {
return `agent-question:${question.id}`;
},
});

Example​

interface ApprovalPayload extends RunPayload {
approved: boolean;
note: string;
reviewDate: string;
}

const approvalNode = workflow.createUserInputNode<Plan, ApprovalPayload>({
name: "approval",
title: "Review deployment",
description: "Confirm whether the workflow should continue.",
params: [
{ name: "approved", flag: "--approved", type: "boolean" },
{ name: "note", flag: "--note", type: "string", control: "textarea" },
{ name: "reviewDate", flag: "--review-date", type: "string", control: "date", required: true },
],
defaultValues: { approved: false, reviewDate: "2026-07-26" },
format(payload) {
return {
errCode: 0,
errMessage: "",
approved: payload.values.approved === true,
note: typeof payload.values.note === "string" ? payload.values.note : "",
reviewDate: typeof payload.values.reviewDate === "string" ? payload.values.reviewDate : "",
};
},
});

export const deployProject = workflow.defineProject<Input, OutputPayload>({
name: "deploy",
async run(input, context) {
const plan = await workflow.runNode(planNode, input, context);
const approval = await workflow.runNode(approvalNode, plan, context);
if (!approval.approved) {
return workflow.createOutputPayload({
items: [{ title: "Stopped", content: approval.note, contentType: "text" }],
});
}
return workflow.runNode(deployNode, plan, context);
},
});