{"slug":"human-in-the-loop","category":"agents","job":"oversee","added":"2026-09-15T11:23Z","title":"Human in the Loop Question","description":"A compact dialog that pauses an agent mid-task to collect a structured choice — radio options, a free-text fallback, skip/continue controls, and multi-step pagination.","url":"https://ai-patterns.app/patterns/agents/human-in-the-loop","markdown":"https://ai-patterns.app/patterns/agents/human-in-the-loop.md","json":"https://ai-patterns.app/api/patterns/agents/human-in-the-loop","seenIn":[{"team":"Claude","source":"https://code.claude.com/docs/en/agent-sdk/user-input"}],"spec":"# Human in the Loop Question\n\n## Summary\nA compact dialog that pauses an agent mid-task to collect a structured choice from the user — a set of radio options, an optional free-text fallback, and skip/continue controls. When more than one question is needed, a step counter opens a dropdown list so the user can see what's coming and jump between questions. Once all questions are answered the dialog resolves into a compact confirmation row and the agent proceeds.\n\n## When to use\n- The agent has reached a decision point where the output depends on a preference only the user can supply (a strategy choice, a scope decision, a threshold).\n- The agent needs input before taking an action that is hard to reverse or would require significant rework if the wrong assumption is made.\n- You want to front-load a short sequence of configuration questions at the start of a long autonomous run, so the agent can work uninterrupted afterward.\n\n## When not to use\n- For binary yes/no confirmation of a specific pending action — use Tool Approval, which is purpose-built for that pattern and carries the literal call details the user needs to see.\n- When the agent can make a safe, recoverable assumption and ask forgiveness rather than permission — unnecessary interruptions erode trust in the agent.\n- For long or complex surveys; cap the sequence at three to five questions. If more input is required, break the task into distinct phases with a planning step first.\n- As a substitute for proper onboarding; don't use this pattern to collect user preferences that should have been gathered during setup.\n\n## Anatomy\n- **Question header**: the question text and an optional close/dismiss button.\n- **Option list**: radio-style buttons; each shows a filled circle when selected. Label text shifts from muted to full contrast on selection.\n- **Free-text fallback** (optional): an inline text input that acts as a write-in option; selecting it deselects the preset options.\n- **Step indicator**: a `n/total` counter with a chevron that opens a step-list dropdown; answered steps show a filled checkmark, the current step is highlighted, future steps are dimmed.\n- **Action row**: Skip (ghost) and Continue (primary blue); Continue is disabled until a choice is made.\n- **Resolved state**: after the final question, the dialog transitions to a single confirmation row (\"Got it — continuing the task.\").\n\n## Behavior\n- Clicking Continue advances to the next question with a short fade-slide transition, or submits on the final step.\n- Clicking Skip advances without recording an answer; the agent uses a default for that question.\n- The step-list dropdown lets the user jump back to any question and change their answer before submitting.\n- The free-text input captures focus and activates as the selected option; its radio indicator fills when the field has content.\n- The dialog entry animates in (fade + slide up) so it feels like an insertion in the flow rather than a blocking overlay.\n- After submit, the dialog transitions to the resolved state and is replaced after a short delay by the agent's next output.\n\n## Guardrails\n- The Continue button must remain disabled until the user has made a selection — either a preset option or a free-text entry with at least one character. Never auto-advance on selection without an explicit tap.\n- Skip must never silently drop a question that the agent requires to proceed. If a question is mandatory, remove Skip and show a hint explaining why an answer is needed.\n- Cap the sequence at five questions. Beyond that, the interruption feels like a form rather than a clarification and should be replaced by a dedicated settings or planning step.\n- Do not show this pattern for decisions the agent can safely reverse or re-ask later. Reserve it for choices that meaningfully fork the agent's path or whose cost to undo is high.\n- Never pre-select an option on the user's behalf. A pre-selected radio implies a default; if a default is acceptable, document it in the agent's behavior and skip the question entirely.\n- The free-text fallback must not be the only option. It signals \"none of the above\" — if every question needs an open answer, a chat prompt is more appropriate than this pattern.\n\n## Content guidelines\n- Questions should be concrete decision points, not open-ended prompts (\"Which model should handle reasoning tasks?\" not \"What do you prefer?\").\n- Option labels should be short noun phrases or brief imperatives (five words or fewer); avoid starting with a verb that duplicates the question's verb.\n- The free-text placeholder should be the literal string \"Something else...\" — this signals write-in without over-explaining.\n- Skip implies the agent has a sensible default; don't show it if skipping the question would leave the agent in an undefined state.\n- Keep the entire sequence to three questions or fewer when possible; if the sequence must be longer, show a progress indicator.\n\n## Accessibility\n- Each option must be a real `<button>` element — not a styled `<div>` — so it's reachable and activatable by keyboard.\n- The step-list dropdown uses `aria-haspopup` and `aria-expanded` on its trigger, and closes on Escape or an outside click.\n- Continue's `disabled` state must be communicated beyond color alone — the button text remains legible and the label unchanged.\n- The free-text input must have a visible label or an `aria-label`; the placeholder alone is not sufficient for accessibility.\n- Avoid auto-advancing on radio selection without an explicit Continue click — screen readers and keyboard users need a stable target.\n\n## Related patterns\n- **Tool Approval** — Use it instead when the agent only needs a yes or no on one pending tool call rather than an open choice.\n- **Create Agent** — Use it instead when the questions need text fields, toggles and pickers rather than single-choice options.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { motion } from \"motion/react\";\nimport { ChevronLeft, ChevronRight, X } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { ToolCallChip } from \"@/registry/loaders/tool-call-chip/component\";\n\nexport interface HitlOption {\n  id: string;\n  label: string;\n}\n\nexport interface HitlQuestion {\n  id: string;\n  question: string;\n  options: HitlOption[];\n  freeTextPlaceholder?: string;\n}\n\nexport interface HumanInTheLoopProps {\n  questions: HitlQuestion[];\n  onSubmit?: (answers: Record<string, string>) => void;\n  onSkip?: () => void;\n  onClose?: () => void;\n  /** Called ~500 ms after the resolved chip shows \"success\". */\n  onDone?: () => void;\n  className?: string;\n}\n\nexport function HumanInTheLoop({\n  questions,\n  onSubmit,\n  onSkip,\n  onClose,\n  onDone,\n  className,\n}: HumanInTheLoopProps) {\n  const [step, setStep] = React.useState(0);\n  const [answers, setAnswers] = React.useState<Record<string, string>>({});\n  const [done, setDone] = React.useState(false);\n  const [chipStatus, setChipStatus] = React.useState<\"running\" | \"success\">(\"running\");\n\n  React.useEffect(() => {\n    if (!done) return;\n    const t1 = setTimeout(() => setChipStatus(\"success\"), 1200);\n    const t2 = setTimeout(() => onDone?.(), 1700);\n    return () => { clearTimeout(t1); clearTimeout(t2); };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [done]);\n\n  const current = questions[step];\n  const total = questions.length;\n  const selected = answers[current.id];\n  const isFreeText = selected?.startsWith(\"free:\");\n  const freeTextValue = isFreeText ? selected.slice(5) : \"\";\n\n  function selectOption(optionId: string) {\n    setAnswers((prev) => ({ ...prev, [current.id]: optionId }));\n  }\n\n  function setFreeText(value: string) {\n    setAnswers((prev) => ({ ...prev, [current.id]: `free:${value}` }));\n  }\n\n  function handleContinue() {\n    if (step < total - 1) {\n      setStep((s) => s + 1);\n    } else {\n      setDone(true);\n      onSubmit?.(answers);\n    }\n  }\n\n  function handleSkip() {\n    if (step < total - 1) {\n      setStep((s) => s + 1);\n    } else {\n      onSkip?.();\n    }\n  }\n\n  if (done) {\n    return (\n      <motion.div\n        initial={{ opacity: 0, y: 4 }}\n        animate={{ opacity: 1, y: 0 }}\n        transition={{ duration: 0.18 }}\n        className={cn(\"w-full max-w-xs\", className)}\n      >\n        <ToolCallChip\n          kind=\"code\"\n          verb=\"Resuming\"\n          target=\"agent task\"\n          status={chipStatus}\n          result=\"Agent task resumed\"\n        />\n      </motion.div>\n    );\n  }\n\n  return (\n    <motion.div\n      key={step}\n      initial={{ opacity: 0, y: 6 }}\n      animate={{ opacity: 1, y: 0 }}\n      transition={{ duration: 0.18 }}\n      className={cn(\"w-full max-w-xs overflow-visible rounded-2xl border bg-card\", className)}\n    >\n      {/* Header */}\n      <div className=\"flex items-start gap-2 px-4 pt-4 pb-3\">\n        <p className=\"flex-1 text-xs font-semibold leading-snug\">{current.question}</p>\n        {onClose && (\n          <button\n            type=\"button\"\n            onClick={onClose}\n            aria-label=\"Dismiss question\"\n            className=\"mt-0.5 shrink-0 rounded-md p-0.5 text-muted-foreground transition-colors hover:text-foreground\"\n          >\n            <X className=\"size-3.5\" />\n          </button>\n        )}\n      </div>\n\n      {/* Options */}\n      <div className=\"space-y-0 px-4 pb-3\">\n        {current.options.map((option) => {\n          const isSelected = selected === option.id;\n          return (\n            <button\n              key={option.id}\n              type=\"button\"\n              onClick={() => selectOption(option.id)}\n              className=\"flex w-full items-center gap-2.5 rounded-lg px-1 py-1.5 text-left transition-colors hover:bg-accent\"\n            >\n              <span\n                className={cn(\n                  \"flex size-4 shrink-0 items-center justify-center rounded-full border-[1.5px] transition-colors\",\n                  isSelected\n                    ? \"border-foreground bg-foreground\"\n                    : \"border-muted-foreground/40\"\n                )}\n              >\n                {isSelected && <span className=\"size-1.5 rounded-full bg-white\" />}\n              </span>\n              <span\n                className={cn(\n                  \"text-xs transition-colors\",\n                  isSelected ? \"font-medium text-foreground\" : \"text-muted-foreground\"\n                )}\n              >\n                {option.label}\n              </span>\n            </button>\n          );\n        })}\n\n        {/* Free-text fallback */}\n        {current.freeTextPlaceholder !== undefined && (\n          <div className=\"flex items-center gap-2.5 px-1 pt-1.5\">\n            <span\n              className={cn(\n                \"flex size-4 shrink-0 items-center justify-center rounded-full border-[1.5px] transition-colors\",\n                isFreeText && freeTextValue\n                  ? \"border-blue-500 bg-blue-500\"\n                  : \"border-muted-foreground/40\"\n              )}\n            >\n              {isFreeText && freeTextValue && (\n                <span className=\"size-1.5 rounded-full bg-white\" />\n              )}\n            </span>\n            <input\n              type=\"text\"\n              value={freeTextValue}\n              placeholder={current.freeTextPlaceholder}\n              onChange={(e) => setFreeText(e.target.value)}\n              onFocus={() => {\n                if (!isFreeText) setFreeText(\"\");\n              }}\n              className=\"flex-1 bg-transparent text-xs text-muted-foreground placeholder:text-muted-foreground/50 outline-none focus:text-foreground\"\n            />\n          </div>\n        )}\n      </div>\n\n      {/* Footer */}\n      <div className=\"flex items-center gap-2 border-t px-4 py-3\">\n        {/* Prev / step count / next */}\n        <div className=\"flex items-center gap-0.5\">\n          <button\n            type=\"button\"\n            onClick={() => setStep((s) => s - 1)}\n            disabled={step === 0}\n            aria-label=\"Previous question\"\n            className=\"rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-30\"\n          >\n            <ChevronLeft className=\"size-3.5\" />\n          </button>\n          <span className=\"tabular-nums text-xs text-muted-foreground\">\n            {step + 1}/{total}\n          </span>\n          <button\n            type=\"button\"\n            onClick={() => setStep((s) => s + 1)}\n            disabled={step === total - 1}\n            aria-label=\"Next question\"\n            className=\"rounded p-0.5 text-muted-foreground transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-30\"\n          >\n            <ChevronRight className=\"size-3.5\" />\n          </button>\n        </div>\n\n        <div className=\"ml-auto flex items-center gap-2\">\n          <button\n            type=\"button\"\n            onClick={handleSkip}\n            className=\"rounded-full px-3.5 py-1.5 text-[11px] font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n          >\n            Skip\n          </button>\n          <button\n            type=\"button\"\n            onClick={handleContinue}\n            disabled={!selected || (isFreeText && !freeTextValue)}\n            className=\"rounded-full bg-foreground px-4 py-1.5 text-[11px] font-medium text-background transition-colors hover:bg-foreground/90 disabled:cursor-not-allowed disabled:opacity-40\"\n          >\n            Continue\n          </button>\n        </div>\n      </div>\n    </motion.div>\n  );\n}\n\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils","@/registry/loaders/tool-call-chip/component"]}