diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py
index ac9a36066d..dc7d9f9dd3 100644
--- a/backend/open_webui/tools/builtin.py
+++ b/backend/open_webui/tools/builtin.py
@@ -498,6 +498,114 @@ async def edit_image(
return JSONCodec.dumps({'error': str(e)})
+# =============================================================================
+# USER INPUT TOOLS
+# =============================================================================
+
+
+async def ask_user(
+ questions: list[dict],
+ allow_other: bool = True,
+ __event_call__: callable = None,
+) -> str:
+ """
+ Ask the user clarifying questions before continuing.
+ Use this when the next step depends on user intent, preference, or a tradeoff that cannot be inferred safely.
+
+ :param questions: 1-3 question objects, each with id, header, question, and 2-3 options. Each option needs label and description.
+ :param allow_other: Whether users may enter a free-form answer instead of choosing one of the options
+ :return: JSON with status and answers keyed by question id
+ """
+ try:
+ if not isinstance(questions, list) or not 1 <= len(questions) <= 3:
+ raise ValueError('ask_user requires 1-3 questions.')
+
+ normalized_questions = []
+ seen_ids = set()
+ for index, question in enumerate(questions):
+ if not isinstance(question, dict):
+ raise ValueError('Each question must be an object.')
+
+ question_id = str(question.get('id') or '').strip()[:64]
+ if not question_id:
+ raise ValueError('Each question requires a non-empty id.')
+ if question_id in seen_ids:
+ raise ValueError(f'Duplicate question id: {question_id}')
+ seen_ids.add(question_id)
+
+ options = question.get('options')
+ if not isinstance(options, list) or not 2 <= len(options) <= 3:
+ raise ValueError('Each question requires 2-3 options.')
+
+ normalized_options = []
+ for option in options:
+ if not isinstance(option, dict):
+ raise ValueError('Each option must be an object.')
+
+ label = str(option.get('label') or '').strip()[:80]
+ description = str(option.get('description') or '').strip()[:240]
+ if not label or not description:
+ raise ValueError('Each option requires a label and description.')
+
+ normalized_options.append(
+ {
+ 'label': label,
+ 'description': description,
+ }
+ )
+
+ question_text = str(question.get('question') or '').strip()[:500]
+ if not question_text:
+ raise ValueError('Each question requires question text.')
+
+ normalized_questions.append(
+ {
+ 'id': question_id,
+ 'header': str(question.get('header') or '').strip()[:48] or f'Question {index + 1}',
+ 'question': question_text,
+ 'options': normalized_options,
+ 'allow_other': bool(question.get('allow_other', allow_other)),
+ }
+ )
+
+ if __event_call__ is None:
+ return JSONCodec.dumps(
+ {
+ 'status': 'error',
+ 'error': 'User input requires an active browser session with WebSocket connection.',
+ },
+ ensure_ascii=False,
+ )
+
+ output = await __event_call__(
+ {
+ 'type': 'request:user_input',
+ 'data': {
+ 'questions': normalized_questions,
+ 'allow_other': allow_other,
+ },
+ }
+ )
+
+ if not isinstance(output, dict):
+ return JSONCodec.dumps({'status': 'error', 'error': 'Invalid user input response.'}, ensure_ascii=False)
+ if output.get('error'):
+ return JSONCodec.dumps({'status': 'error', 'error': output.get('error')}, ensure_ascii=False)
+ if output.get('status') == 'cancelled':
+ return JSONCodec.dumps({'status': 'cancelled', 'answers': {}}, ensure_ascii=False)
+
+ return JSONCodec.dumps(
+ {
+ 'status': 'answered',
+ 'answers': output.get('answers', {}),
+ },
+ ensure_ascii=False,
+ )
+ except Exception as e:
+ log.exception(f'ask_user error: {e}')
+ return JSONCodec.dumps({'status': 'error', 'error': str(e)}, ensure_ascii=False)
+
+
# =============================================================================
# CODE INTERPRETER TOOLS
# =============================================================================
diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py
index 5a3852b89d..bb682d4cdb 100644
--- a/backend/open_webui/utils/tools.py
+++ b/backend/open_webui/utils/tools.py
@@ -47,6 +47,7 @@ from open_webui.models.tools import Tools
from open_webui.models.users import UserModel
from open_webui.tools.builtin import (
add_memory,
+ ask_user,
calculate_timestamp,
create_automation,
create_calendar_event,
@@ -583,6 +584,9 @@ async def get_builtin_tools(
if is_builtin_tool_enabled('time'):
builtin_functions.extend([get_current_timestamp, calculate_timestamp])
+ if is_builtin_tool_enabled('user_input'):
+ builtin_functions.append(ask_user)
+
metadata = extra_params.get('__metadata__') or {}
chat_files = metadata.get('files') or extra_params.get('__files__') or []
has_chat_files = any(
diff --git a/src/lib/components/chat/AskUserCard.svelte b/src/lib/components/chat/AskUserCard.svelte
new file mode 100644
index 0000000000..4c69789c04
--- /dev/null
+++ b/src/lib/components/chat/AskUserCard.svelte
@@ -0,0 +1,296 @@
+
+
+{#if show}
+