Skip to Content
DocumentationBuildPatternsHuman-in-the-Loop

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…UseWhere it lives
A predefined workflow with known user-input steps — form fields, structured recordsCustom document with a widget — your workflow owns the document + wait: trueThis 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 shortcutThis page → Using HITL as a Sub-Workflow
An LLM agent loop where the agent dynamically decides when to askAgent tools ask_clarification / ask_for_approvalThis 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-module'; 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 — Conditional Widgets

Show/hide widgets based on the current workflow place:

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

The widget only appears when the workflow is at the review or editing place.

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-example-module — 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-workflow — Full human-in-the-loop workflow with editable form, AI optimization, and user confirmation
  • chat-example-workflow — Chat input pattern with prompt-input widget
Last updated on