Skip to content
Color theme

Human-in-the-Loop

Pause workflows for user input, review, or confirmation. Loopstack offers three distinct patterns — pick the one that matches who decides what to ask.

Choosing a HITL Pattern

You are building… Use Where it lives
A predefined workflow with known user-input steps — form fields, structured records Custom document with a widget — your workflow owns the document + wait: true This page → Wait Transition Pattern
A predefined workflow that just needs a quick generic ask (free text, yes/no, pick one) AskUserWorkflow / ConfirmUserWorkflow as a sub-workflow shortcut This page → Using HITL as a Sub-Workflow
An LLM agent loop where the agent dynamically decides when to ask Agent tools ask_clarification / ask_for_approval This page → Agent-Driven HITL

The custom-document pattern is the default for predefined workflows: the form is the structured data, the wait-transition payload schema is the document schema, and types line up end-to-end. Sub-workflow shortcuts are for when designing a form would be overkill. Agent tools are for LLM-driven flows where the next question isn’t known in advance.

Wait Transition Pattern

A transition with wait: true pauses the workflow until externally triggered by user interaction:

import type { TransitionInput } from '@loopstack/common';
@Transition({
from: 'waiting_for_user',
to: 'ready',
wait: true,
schema: z.object({ message: z.string() }),
})
async userMessage(state: Record<string, unknown>, input: TransitionInput<{ message: string }>) {
await this.documentStore.save(LlmMessageDocument, {
role: 'user',
text: input.data.message,
});
}

Document Action Buttons

Documents can include buttons that trigger wait: true transitions:

# Document YAML
type: document
ui:
widgets:
- widget: form
options:
properties:
text:
title: Text
widget: textarea
actions:
- type: button
transition: userResponse # Must match the method name
label: 'Submit'

When the user clicks Submit, the workflow’s userResponse method fires with the document’s current content as the payload.

Chat Input Widget

For conversational UIs, use the prompt-input widget:

ui:
widgets:
- widget: prompt-input
enabledWhen:
- waiting_for_user
options:
transition: userMessage
@Transition({
from: 'waiting_for_user',
to: 'ready',
wait: true,
schema: z.string(),
})
async userMessage(state: Record<string, unknown>, input: TransitionInput<string>) {
await this.documentStore.save(LlmMessageDocument, {
role: 'user',
text: input.data,
});
}

Confirmation Pattern

Show AI-generated content for user review before proceeding:

import { z } from 'zod';
import { toJSONSchema } from 'zod';
import { BaseWorkflow, Transition, Workflow } from '@loopstack/common';
import type { RunContext } from '@loopstack/common';
import { LlmGenerateObjectTool } from '@loopstack/llm-provider';
interface MeetingNotesState {
meetingNotes?: z.infer<typeof MeetingNotesDocumentSchema>;
}
const MeetingNotesArgsSchema = z.object({ inputText: z.string().default('...') });
type MeetingNotesArgs = z.infer<typeof MeetingNotesArgsSchema>;
@Workflow({
widget: './meeting-notes.ui.yaml',
schema: MeetingNotesArgsSchema,
})
export class MeetingNotesWorkflow extends BaseWorkflow<MeetingNotesArgs> {
constructor(private readonly llmGenerateObject: LlmGenerateObjectTool) {
super();
}
@Transition({ to: 'waiting_for_response' })
async createForm(state: MeetingNotesState, ctx: RunContext<MeetingNotesArgs>) {
await this.documentStore.save(MeetingNotesDocument, { text: ctx.args.inputText }, { key: 'input' });
}
// Wait for user to edit and submit
@Transition({ from: 'waiting_for_response', to: 'response_received', wait: true, schema: MeetingNotesDocumentSchema })
async userResponse(state: MeetingNotesState, input: TransitionInput<z.infer<typeof MeetingNotesDocumentSchema>>) {
const result = await this.documentStore.save(MeetingNotesDocument, input.data, { key: 'input' });
this.assignState({ meetingNotes: result.content as z.infer<typeof MeetingNotesDocumentSchema> });
}
// AI generates structured output
@Transition({ from: 'response_received', to: 'notes_optimized' })
async optimizeNotes(state: MeetingNotesState) {
const result = await this.llmGenerateObject.call(
{
outputSchema: toJSONSchema(OptimizedMeetingNotesDocumentSchema) as Record<string, unknown>,
prompt: `Structure these notes: ${state.meetingNotes?.text}`,
},
{ config: { provider: 'claude', model: 'claude-sonnet-4-6' } },
);
const objectResult = result.data as LlmGenerateObjectResult;
await this.documentStore.save(
OptimizedNotesDocument,
objectResult.data as z.infer<typeof OptimizedMeetingNotesDocumentSchema>,
{ key: 'final', validate: 'skip' },
);
}
// Wait for user to confirm
@Transition({ from: 'notes_optimized', to: 'end', wait: true, schema: OptimizedMeetingNotesDocumentSchema })
async confirm(state: MeetingNotesState, input: TransitionInput<z.infer<typeof OptimizedMeetingNotesDocumentSchema>>) {
await this.documentStore.save(OptimizedNotesDocument, input.data, { key: 'final' });
}
}

enabledWhen and showWhen — Conditional Widgets

Two widget keys gate a widget on the current workflow place. enabledWhen keeps the widget on screen but disables it outside the listed places:

ui:
widgets:
- widget: form
enabledWhen:
- review
- editing
options:
properties:
summary:
title: Summary
widget: textarea
actions:
- type: button
transition: confirm
label: 'Confirm'

The form is visible at every place, but it can only be edited and submitted while the workflow is at the review or editing place.

showWhen hides the widget entirely outside the listed places — use it for a widget that should only exist at one step:

ui:
widgets:
- widget: prompt-input
enabledWhen:
- waiting_for_user
options:
transition: userMessage
- widget: button
showWhen:
- awaiting_tools
options:
transition: cancelPendingTools
label: Cancel pending tools

The prompt input is always on screen and disabled while the agent works; the cancel button is only rendered while the workflow is at awaiting_tools. When a widget sets both keys, showWhen decides whether it is rendered and enabledWhen whether it is enabled. See Workflow YAML Schema — showWhen.

When a park has more than one enabled widget — a confirmation document from a sub-workflow and the parent’s chat input, say — Studio, the CLI and parkView() all pick the same one as the prompt: in run-tree order, and within each workflow its document prompts before its workflow-level widgets. Prompt Selection Rules describes the full order.

Using HITL as a Sub-Workflow

The wait: true pattern above is for workflows that own their own UI. For generic prompts you don’t want to design a form for, run AskUserWorkflow or ConfirmUserWorkflow from @loopstack/hitl as a sub-workflow and receive the answer through a callback.

The callback delivers the standard TransitionInput<TData> envelope — the schema on the transition describes only data, and input.data is fully typed. See Sub-Workflows → Typing the Callback Envelope for the full reference.

AskUserWorkflow — free text

import { z } from 'zod';
import { BaseWorkflow, MessageDocument, Transition, type TransitionInput, Workflow } from '@loopstack/common';
import { AskUserWorkflow } from '@loopstack/hitl';
const AnswerSchema = z.object({ answer: z.string() });
@Workflow({ title: 'Ask Then Continue' })
export class AskThenContinueWorkflow extends BaseWorkflow {
constructor(private readonly askUser: AskUserWorkflow) {
super();
}
@Transition({ to: 'waiting' })
async ask(state: Record<string, unknown>) {
await this.askUser.run({ question: 'What is your name?' }, { callback: { transition: 'onAnswer' } });
}
@Transition({ from: 'waiting', to: 'end', wait: true, schema: AnswerSchema })
onAnswer(state: Record<string, unknown>, input: TransitionInput<{ answer: string }>) {
this.setResult({ name: input.data.answer });
}
}

AskUserWorkflow — pick from options

Pass mode: 'options' with a list of choices. allowCustomAnswer: true adds a free-text field alongside the choices for “other”.

await this.askUser.run(
{
question: 'Which environment should we deploy to?',
mode: 'options',
options: ['staging', 'production'],
allowCustomAnswer: true,
},
{ callback: { transition: 'choiceReceived' } },
);

The envelope shape is the same as the free-text case (input.data: { answer: string }).

AskUserWorkflow — yes / no

Pass mode: 'confirm'. The answer comes back as the literal string 'yes' or 'no' in input.data.answer — compare directly.

await this.askUser.run(
{ question: 'Send the email now?', mode: 'confirm' },
{ callback: { transition: 'decisionReceived' } },
);

ConfirmUserWorkflow — markdown review

For showing a pre-rendered markdown blob (a release plan, a summary, a code diff) and receiving an explicit approve/deny, use ConfirmUserWorkflow. The callback input.data carries both the user’s decision and the original markdown:

import { ConfirmUserWorkflow } from '@loopstack/hitl';
const ConfirmSchema = z.object({ confirmed: z.boolean(), markdown: z.string() });
@Transition({ to: 'awaiting' })
async showSummary(state: Record<string, unknown>) {
await this.confirmUser.run(
{ markdown: '## Ready to deploy v1.2.3?\n\n- 3 commits since last release\n- Smoke tests passing' },
{ callback: { transition: 'decisionReceived' } },
);
}
@Transition({ from: 'awaiting', to: 'end', wait: true, schema: ConfirmSchema })
decisionReceived(
state: Record<string, unknown>,
input: TransitionInput<z.infer<typeof ConfirmSchema>>,
) {
this.setResult({ confirmed: input.data.confirmed });
}

Agent-Driven HITL

When the asking party is an LLM agent rather than your workflow, use the ask_clarification and ask_for_approval tools from @loopstack/hitl. The agent decides at runtime to call the tool; the agent loop pauses, the user answers, and the answer flows back as the tool result — no extra wait-transition wiring at your level.

ask_clarification — agent asks the user a question

Register the tool in your module and add it to the agent’s tool list. A system prompt that tells the agent to use the tool when info is missing is enough:

import { z } from 'zod';
import { AgentWorkflow } from '@loopstack/agent';
import { BaseWorkflow, MessageDocument, Transition, type TransitionInput, Workflow } from '@loopstack/common';
const AgentResponseSchema = z.object({ response: z.string() });
const SYSTEM_PROMPT = `You are a trip-planning assistant.
- Before recommending a destination, you MUST know BOTH the user's budget AND climate preference.
- If either is missing, your response MUST be exactly ONE tool call to "ask_clarification".`;
@Workflow({ title: 'Trip Planner' })
export class TripPlannerWorkflow extends BaseWorkflow {
constructor(private readonly agent: AgentWorkflow) {
super();
}
@Transition({ to: 'running' })
async start(state: Record<string, unknown>) {
await this.agent.run(
{
system: SYSTEM_PROMPT,
tools: ['ask_clarification'],
userMessage: 'Where should I go on holiday next month?',
},
{ callback: { transition: 'onComplete' } },
);
}
@Transition({ from: 'running', to: 'end', wait: true, schema: AgentResponseSchema })
async onComplete(state: Record<string, unknown>, input: TransitionInput<{ response: string }>) {
await this.documentStore.save(MessageDocument, { role: 'assistant', text: input.data.response });
this.setResult({ response: input.data.response });
}
}

ask_clarification supports the same mode arg as AskUserWorkflow ('text' / 'options' / 'confirm') — the LLM can pick the right mode per call.

ask_for_approval — agent asks the user to approve content

Same shape, with tools: ['ask_for_approval']. The agent drafts content and passes it as the concept argument; the user sees the markdown and confirms or denies. The agent loop only resumes after the decision.

const SYSTEM_PROMPT = `You are a release-notes drafting assistant.
- Your response MUST be exactly ONE tool call to "ask_for_approval".
- The "concept" argument IS the markdown draft.`;
await this.agent.run(
{
system: SYSTEM_PROMPT,
tools: ['ask_for_approval'],
userMessage: 'Draft release notes for v1.2.3.',
},
{ callback: { transition: 'onComplete' } },
);

See @loopstack/hitl for the full tool args reference.

Registry References

  • HITL examples — Side-by-side examples of every HITL pattern: custom document with widget, all AskUserWorkflow modes, ConfirmUserWorkflow, and both agent tools
  • @loopstack/hitl — The underlying HITL module: AskUserWorkflow, ConfirmUserWorkflow, ask_clarification, ask_for_approval
  • Meeting Notes example — Full human-in-the-loop workflow with editable form, AI optimization, and user confirmation
  • Prompt Input Chat example — Chat input pattern with prompt-input widget