Untether's Claude Code control channel already supports Approve/Deny buttons for tool permissions (ExitPlanMode, Bash, etc.). AskUserQuestion uses the same can_use_tool control request protocol, but needs the user to answer questions (pick from options or type free text) rather than just approve/deny.
AskUserQuestion can present 1-4 questions per invocation, each with 2-4 options plus implicit "Other" for free text. The response mechanism is updatedInput.answers -- a dict mapping question text to selected option label.
- AskUserQuestion arrives as
control_request/can_use_tool/tool_name: "AskUserQuestion" - Input:
{questions: [{question: str, header: str, options: [{label, description}], multiSelect: bool}]} - Response:
{behavior: "allow", updatedInput: {questions: [...], answers: {"Which DB?": "PostgreSQL"}}} - The
updatedInput.answersdict is what Claude sees as the tool result
In scope (Phase 1): Single-select questions, sequential multi-question, "Other" free text Deferred: multiSelect (toggle buttons with Done confirmation)
Add alongside existing registries (_ACTIVE_RUNNERS, _SESSION_STDIN, etc.):
@dataclass
class PendingQuestionState:
request_id: str
questions: list[dict[str, Any]] # from tool_input["questions"]
current_index: int = 0 # which question is active
answers: dict[str, str] = field(default_factory=dict) # question -> answer
_PENDING_QUESTIONS: dict[str, PendingQuestionState] = {} # request_id -> state
_PENDING_TEXT_ANSWERS: dict[int, str] = {} # chat_id -> request_id (for "Other")Add build_question_keyboard() helper:
- Builds
inline_keyboardbuttons for a single question's options - callback_data format:
claude_control:ans:Q:IDX:REQUEST_ID(max 59 bytes, fits 64-byte limit) - "Other..." button:
claude_control:other:Q:REQUEST_ID(59 bytes) - Layout: one button per row for clarity
File: claude.py, in the StreamControlRequest match arm (~line 370)
Branch on tool_name == "AskUserQuestion" before the generic Approve/Deny path:
- Parse
questionsfrom tool input - Create
PendingQuestionState, store in_PENDING_QUESTIONS - Call
build_question_keyboard()for question[0] - Set action title to the question text (e.g.
"Question: Which database should we use?") - Return
ActionEvent(kind="warning")with question-specificinline_keyboardin detail
The existing TelegramPresenter.render_progress() in bridge.py already finds the first non-completed action with inline_keyboard and merges it with Cancel -- no changes needed there.
File: claude.py
Parallel to existing send_claude_control_response():
- Takes
request_idandanswers: dict[str, str] - Copies original tool input from
_REQUEST_TO_INPUT, setsanswerskey - Overwrites
_REQUEST_TO_INPUT[request_id]with updated input - Calls existing
write_control_response(request_id, approved=True)(which already reads_REQUEST_TO_INPUTforupdatedInput) - Cleans up all registries (
_REQUEST_TO_SESSION,_REQUEST_TO_INPUT,_PENDING_QUESTIONS,_HANDLED_REQUESTS)
File: claude_control.py
Currently parses approve:REQUEST_ID / deny:REQUEST_ID. Add two new sub-commands:
ans:Q:IDX:REQUEST_ID -- option selected:
- Look up
_PENDING_QUESTIONS[request_id] - Resolve option label from
questions[Q].options[IDX].label - Record
answers[question_text] = label - If more questions: advance
current_index, edit progress message with next question's keyboard (directbot.edit_message_text-- safe because Claude is blocked) - If all answered: call
send_claude_question_answer(request_id, answers)
other:Q:REQUEST_ID -- free text requested:
- Set
_PENDING_TEXT_ANSWERS[chat_id] = request_id - Return CommandResult prompting user to type their answer
Problem: CommandContext doesn't expose the bot client, but we need it to edit_message_text when advancing to Q2.
Solution: Store a module-level _BOT_CLIENT reference in claude.py:
- Set it from
run_main_loopinloop.pyduring startup (one line:claude._BOT_CLIENT = cfg.bot) - Import in
claude_control.pywhen needed for multi-question edits - Graceful fallback: if
_BOT_CLIENTis None, send all questions' answers at once (less interactive but functional)
File: loop.py, at the top of route_message()
Early check before normal message processing:
pending_req_id = _PENDING_TEXT_ANSWERS.pop(msg.chat_id, None)
if pending_req_id:
pending = _PENDING_QUESTIONS.get(pending_req_id)
if pending:
q = pending.questions[pending.current_index]
pending.answers[q["question"]] = msg.text.strip()
pending.current_index += 1
if pending.current_index < len(pending.questions):
# edit progress message with next question keyboard
else:
await send_claude_question_answer(pending_req_id, pending.answers)
return- Add
_PENDING_QUESTIONSand_PENDING_TEXT_ANSWERSto existing cleanup inprocess_error_events,stream_end_events, and expired-request loop - Add to autouse test fixture
_clear_registries
File: test_claude_control.py (extend existing)
| Test | What it verifies |
|---|---|
test_ask_user_question_produces_question_keyboard |
AskUserQuestion control request -> ActionEvent with option buttons (not Approve/Deny) |
test_ask_user_question_pending_state_created |
_PENDING_QUESTIONS populated with correct structure |
test_ask_user_question_single_answer_sends_response |
Answering single-question flow sends control response with updatedInput.answers |
test_ask_user_question_multi_question_sequential |
2-question flow: Q1 answer recorded, Q2 would render, final response has both |
test_ask_user_question_other_sets_pending_text |
"Other" button populates _PENDING_TEXT_ANSWERS |
test_ask_user_question_text_interception |
Text message intercepted and used as answer |
test_ask_user_question_callback_data_fits_64_bytes |
Verify callback_data for worst case (Q=3, IDX=3, UUID) fits 64 bytes |
test_ask_user_question_expired_cleanup |
Expired pending questions cleaned up |
| File | Changes |
|---|---|
src/untether/runners/claude.py |
PendingQuestionState, _PENDING_QUESTIONS, _PENDING_TEXT_ANSWERS, _BOT_CLIENT, build_question_keyboard(), send_claude_question_answer(), AskUserQuestion branch in translate_claude_event, cleanup |
src/untether/telegram/commands/claude_control.py |
Handle ans: and other: callback sub-commands, multi-question edit logic |
src/untether/telegram/loop.py |
Set _BOT_CLIENT on startup, text interception early-return in route_message |
tests/test_claude_control.py |
~8 new tests for question flow |
cd /home/nathan/untether-fork && uv run pytest tests/test_claude_control.py -vcd /home/nathan/untether-fork && uv run pytest --no-cov -x -qsystemctl --user restart untether- Via Telegram: send a task that triggers AskUserQuestion (e.g. ask Claude to clarify something), verify question + option buttons appear, tap an option, verify Claude continues with that answer
- Test "Other": tap Other, type a response, verify it's used
- Test multi-question: trigger a task that asks 2+ questions, verify sequential flow