Elements

Question flow

A few short questions asked one at a time, answered together and kept as a receipt.

1 of 3

Who should receive the project update?

fig. 01

Installation

npx shadcn@latest add "@assistant-ui/elements-question-flow"
First time? Set up a runtime

Runtime components read their state from an assistant-ui runtime. Add one to an existing project:

npx assistant-ui@latest init

Then wrap your app in a runtime provider:

import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";

export default function App() {
  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({ api: "/api/chat" }),
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {/* your components */}
    </AssistantRuntimeProvider>
  );
}

The installation guide covers new projects, templates, and API routes.

A question flow gives the user one small decision at a time, then sends the complete set of answers back together. It is useful when one answer narrows the next decision without turning the conversation into a form.

Getting started

A question flow is a human tool: the model supplies its steps as arguments, the run pauses when the arguments are complete, and the final step returns { answers } as the tool result.

Render the human tool

app/toolkit.tsx
"use client";

import { defineToolkit, useAuiState } from "@assistant-ui/react";
import { z } from "zod";
import { QuestionFlow } from "@/components/assistant-ui/elements/question-flow";

export const toolkit = defineToolkit({
  ask_project_questions: {
    type: "human",
    description: "Ask a short sequence of questions before planning the project update.",
    parameters: z.object({
      steps: z.array(
        z.object({
          id: z.string(),
          question: z.string(),
          description: z.string().optional(),
          options: z.array(
            z.object({
              id: z.string(),
              label: z.string(),
              description: z.string().optional(),
            }),
          ),
          selectionMode: z.enum(["single", "multiple"]).optional(),
          minSelections: z.number().optional(),
          maxSelections: z.number().optional(),
        }),
      ),
    }),
    render: function AskProjectQuestions({ args, status, result, addResult }) {
      const canAnswer = useAuiState(
        (s) => s.thread.capabilities.answerToolCall,
      );
      const waiting = status.type === "requires-action" && canAnswer;

      return (
        <QuestionFlow
          steps={args.steps ?? []}
          choice={result?.answers}
          onComplete={
            waiting ? (answers) => addResult({ answers }) : undefined
          }
        />
      );
    },
  },
});

status.type is "requires-action" only once the arguments are complete and the run is waiting on the user. answerToolCall is false in a readonly thread, such as a sub-agent transcript. Outside that window, onComplete is absent and the flow displays every supplied step without controls.

Register the toolkit

app/MyRuntimeProvider.tsx
import { AssistantRuntimeProvider, AuiConfig, Tools } from "@assistant-ui/react";
import { toolkit } from "./toolkit";

const config = AuiConfig({ tools: Tools({ toolkit }) });

export function MyRuntimeProvider({ children }: { children: React.ReactNode }) {
  return (
    <AssistantRuntimeProvider runtime={runtime} config={config}>
      {children}
    </AssistantRuntimeProvider>
  );
}

The model receives the result only after the last answer lands. See Tool UI for the human tool lifecycle.

Anatomy

<div data-slot="question-flow" data-state="open | receipt">
  <div>
    <span />
    <button type="button" />
  </div>
  <div role="progressbar" />
  <p />
  <div data-slot="option-list" />
</div>

An open flow has one paper card, a counter, a progress bar, and the current step's OptionList. A single selection advances immediately. A multiple selection uses Next until the final step, which uses Submit by default. Back preserves prior answers and returns focus to the next step's first enabled option. Without onComplete, every step is a display-only OptionList with no header controls.

After onComplete resolves, the card is a receipt. choice, when set, takes precedence over the internally confirmed answers. A receipt has one row for each answered step, with the question above its selected labels, and no controls.

Examples

Start with existing answers

defaultValue starts a flow with answers that are already known. Going Back keeps any answers made during this flow too:

<QuestionFlow
  steps={STEPS}
  defaultValue={{ audience: ["team"], topics: ["milestones"] }}
  onComplete={saveAnswers}
/>

Change the final action

Use completeLabel when the final action has a more specific name. It only replaces the final multiple-selection button. A final single selection still submits on pick:

<QuestionFlow
  steps={STEPS}
  completeLabel="Create update"
  onComplete={saveAnswers}
/>

API reference

Human-tool render props

PropTypeDescription
args.stepsQuestionFlowStep[]Questions the model supplied, which can arrive incrementally.
statusToolCallMessagePartStatusstatus.type === "requires-action" while the run waits for an answer.
result{ answers: Record<string, string[]> } | undefinedThe completed answers, supplied to choice for the receipt.
addResult(result) => voidCompletes the human tool with { answers }.
s.thread.capabilities.answerToolCallbooleanGates onComplete so readonly threads never offer controls.