Operator-ask forwarding — answering an agent's questions
A dispatched agent agent (ask/decide/task/converse, each a claude -p subprocess) sometimes needs to ask the operator a clarifying question. The built-in AskUserQuestion tool is the obvious vehicle — and it is a landmine. Operator-ask forwarding replaces it: the agent's question is carried into kitsoki, rendered natively on the live surface (web + TUI), answered by the operator, and the answer is fed back to the agent as the tool result so it continues.
The problem: headless AskUserQuestion auto-resolves empty
Under headless claude -p there is no interactive terminal, so the built-in AskUserQuestion tool resolves immediately with empty answers (anthropics/claude-code#50728) — a silent failure: the agent "asked", got blank input, and proceeds on garbage with no signal to the operator. So AskUserQuestion is hard-denied everywhere — it is in alwaysDeniedTools in internal/host/agents.go on every subprocess. Denying it is correct but leaves the agent unable to ask at all; the forwarding bridge restores that ability through a path kitsoki controls.
Design: synchronous block via a per-call socket + a DI prompter
Agent verbs run in-process, synchronously, mid-turn, blocking the orchestrator turn goroutine inside the host verb while claude -p runs as a direct child. A blocking round-trip from that subprocess back out to the operator is therefore consistent with how agent calls already behave — the turn is already parked. The bridge is one synchronous block layered on top of that park:
sequenceDiagram
participant Agent as claude -p agent
participant Tool as mcp__operator__ask
participant Bridge as kitsoki mcp-operator-ask
participant Socket as KITSOKI_OPERATOR_ASK_SOCK
participant Listener as operatorAskListener
participant Prompter as OperatorPrompter
participant Surface as Web or TUI
Agent->>Tool: ask questions
Tool->>Bridge: stdio MCP call
Bridge->>Socket: send questions and block
Socket->>Listener: per-call unix socket
Listener->>Prompter: Ask(sessionID, questions)
Prompter->>Surface: show pending question
Surface-->>Prompter: answer
Prompter-->>Socket: answers
Socket-->>Bridge: unblock
Bridge-->>Tool: tool_result = answers
Tool-->>Agent: agent continues
Why an MCP tool, not a claude-code hook
A PreToolUse hook can only allow/deny a call — it cannot supply a tool_result. Answering a question means returning a real value to the model mid-run, and the only mechanism that does that is an MCP tool the agent calls. So the "replacement" is literal: deny AskUserQuestion, add the MCP tool mcp__operator__ask whose result is the operator's answer.
Interactivity gating — the tool is attached only when answerable
The decisive question at dispatch is is a live operator surface attached to this session? That capability is carried in context as an OperatorPrompter (DI seam, internal/host), set by the TUI/web run loop and absent for kitsoki turn, flow-fixture tests, cassette replay, and the headless agent-serve path. The seam mirrors WithKitsokiSessionID / WithStreamSink:
type OperatorQuestion struct{ Question, Header string; Options []OperatorOption; MultiSelect bool }
type OperatorPrompter interface {
Ask(ctx context.Context, sessionID string, qs []OperatorQuestion) (answers map[string]any, err error)
}
func WithOperatorPrompter(ctx context.Context, p OperatorPrompter) context.Context
- Prompter present → host dispatch creates a per-call unix socket + listener goroutine, injects
KITSOKI_OPERATOR_ASK_SOCKinto the subprocess env, attaches theoperator-askMCP server, addsmcp__operator__askto--allowedTools, and adds a system-prompt clause telling the agent to use it for clarifying questions.AskUserQuestionstays denied — exactly one path to ask. - Prompter absent (headless) → none of that is attached;
AskUserQuestionstays denied and the agent is instructed to proceed on its own.
This is why automated tests (no prompter) never block and never need an operator: the tool simply isn't there.
Rejected alternative — suspend/resume across turns
Capturing the question, killing the subprocess, transitioning to a "question room", answering next turn, and claude --resume-ing was rejected: MCP tool calls are synchronous, so killing the process leaves an unanswered tool_use at history's end that --resume cannot cleanly satisfy by injecting a later user message. The synchronous block matches existing agent-call-blocks-turn behavior and is far simpler.
The three surfaces
- Host bridge —
internal/host/operator_ask_bridge.golistens on the per-call socket and callsOperatorPrompter.Ask; thekitsoki mcp-operator-asksubcommand (cmd/kitsoki) is the MCP stdio grandchild that dials the socket. Reuses the agent-serve framing (newline-delimited JSON) andresolveKitsokiBin/writeMCPConfigTempfilehelpers. - Web —
internal/runstatus/server/operator_questions.go: a per-session pending-question registry keyed byquestion_id, an SSE feed (/rpc/questions), and the answer RPCrunstatus.session.answer_question {session_id, question_id, answers}that resolves the waiting prompter. The frontend rendersOperatorQuestionModal.vuereusing the typed-view choice widget. Pending questions also project intorunstatus.work.listasoperator_questionrows, so the global inbox badge/panel can reacquire a missed question and reopen the same modal instead of relying only on the original SSE delivery. - TUI —
internal/tui/operator_prompter.go+operator_question.go: anOperatorPrompterthat pushes a bubbletea message into the running program and renders an inlineModeOperatorQuestionchoice widget, resolving on submit. - MCP client —
internal/mcp/studio/operator_prompter.go: the prompter for akitsoki mcpstudio session — the surface is the driving MCP client itself (see below).
MCP client surface
When the operator is an external LLM driving a story through the MCP studio (session.drive), the studio injects a third OperatorPrompter whose surface is the MCP connection — so a driven sub-agent's mcp__operator__ask reaches the driving client, the one story behaviour a plain headless session can't exercise. It is a new implementation of the unchanged interface: the per-call socket, the attachOperatorAsk gate ("prompter present → tool attached"), the wire/answer schema, the bounded wait, and the three operator.question.* trace events are reused verbatim. Only the round-trip transport differs, and the prompter picks one at dispatch:
sequenceDiagram
participant Client as Claude Code
participant Studio as studio turn
participant Agent as agent sub-agent
participant Ask as mcp__operator__ask
participant Prompter as studio OperatorPrompter
Client->>Studio: session.drive
Studio->>Agent: dispatch sub-agent
Agent->>Ask: ask questions
Ask->>Prompter: OperatorPrompter.Ask
alt MCP elicitation supported
Prompter->>Client: elicitation request
Client-->>Prompter: answer
else fallback
Prompter-->>Client: awaiting_operator
Client->>Prompter: session.answer
end
Prompter-->>Ask: answers
Ask-->>Agent: continue
Agent-->>Studio: turn completes
- Primary — MCP elicitation. When the client advertises the elicitation capability,
Asksends a server-initiated elicitation request and blocks for the response, mirroring how the web/TUI prompters block.session.drivestays one call: the elicitation is a nested request mid-turn while the sub-agent is already parked on the socket. - Fallback — suspend/resume via
session.answer. For clients without elicitation,session.drivereturns{awaiting_operator, question_id, questions}and the turn goroutine stays parked (writer lock held, exactly as a TUI/web operator-ask parks it); the client callssession.answer, which delivers the answer and blocks until the turn completes, returning either{outcome, frame}or anotherawaiting_operator. The client loops drive→answer→…→outcome. Works on any MCP client, so coherence never depends on elicitation support.
The prompter is the same DI seam a stub satisfies, so a no-LLM flow test injects a scripted-answer transport and exercises a story's operator-ask branch deterministically (the existing operator-ask test pattern); the live path (real claude -p sub-agent + real client) is gated like the other live operator-ask test below.
Wire / answer schema = AskUserQuestion's, verbatim
Mirroring the built-in shape makes this a drop-in replacement and lets it reuse the existing typed-view choice rendering:
- Request:
{questions:[{question, header, options:[{label, description}], multiSelect}]} - Answer:
{answers: {"<question text>": "<label>" | ["<label>",…]}}
The wait is bounded (default ~5 min, matching the task timeout). On timeout, operator-cancel, or context cancellation the bridge returns a tool error ("operator did not answer; proceed without this input") so the agent continues gracefully rather than hanging, and the socket + registry entry are always torn down on return.
Observability
Three greppable slog events land in the session trace (and the agent-action transcript, so kitsoki-debugging can see them), each carrying question_id, headers, duration_ms, and outcome:
| Event | Meaning |
|---|---|
operator.question.asked | a forwarded question reached the operator surface |
operator.question.answered | the operator answered; the answer is en route to the agent |
operator.question.unanswered | timeout / cancel — the agent got a tool error and proceeded |
If a dispatched agent seems stuck, a modal never appeared, or the agent got blank answers, these three events are the first thing to grep.
Nested sub-agents inherit the bridge
An agent that spawns its own sub-agents via the Task tool can forward operator questions from inside those sub-agents: the --mcp-config (and the allowed mcp__operator__ask tool) is inherited by Task-spawned sub-agents, so a sub-agent's call reaches the per-call socket exactly as the top-level agent's would. Verified 2026-06-12 with a real claude -p run (claude 2.1.173) against a socket harness mirroring attachOperatorAsk: a top-level agent instructed to call the tool only from a spawned sub-agent produced a single socket hit tagged with the sub-agent's question, and the sub-agent received the operator's answer back through the tool result. No special handling is required for the nested case.
Story-level consumer: clarification gates
The same OperatorPrompter seam is also the right boundary for story-authored clarification gates. A story should not hide a question inside a dispatched agent and hope a live-only MCP tool is present; it should expose the ask as a normal host invocation whose input and output are visible in the state machine.
That story-level host path has a different contract from the agent replacement tool:
- It receives questions already produced by the story and forwards them through the attached web, TUI, or MCP Studio prompter.
- With no attached operator (flows, cassettes, headless replay), it returns a stable "not answered" result rather than blocking. The room can then render the same questions and collect typed answers through ordinary intents.
- Flow fixtures and host cassettes stub or replay the same host call, so the live surface is an acceleration path over the same story graph, not a second behavior.
For concrete authoring rules, see docs/stories/authoring.md.
Other consumer: the write-mode gate's action proposals
The bridge is surface-agnostic, so another caller reuses it without new wire: the write-mode gate for write_mode: read_only agent rooms (see hosts.md and state-machine.md). When the dispatched agent attempts a mutating step (an Edit/Write, a Bash command the read-only profile rejects, or an effect ≥ write host call), the gate forwards an action proposal through the same OperatorPrompter.Ask seam — a single question ("The agent wants to edit X / run Y. Allow this write action?") whose options are the grant scopes plus deny:
| Option | Meaning |
|---|---|
turn (surfaced default) | allow edits for the rest of this turn |
action | allow just this one call |
session | allow edits for the rest of the session |
deny | keep the agent read-only |
The interactivity gate is identical to forwarded questions: with no operator attached the gate takes the headless path and denies the mutating step (the agent gets a tool-error and stays read-only), mirroring the no-replacement-tool posture above. An effect: external action (a push, a PR) omits turn/session — it always re-asks per action, so "stop asking me about edits" never silently authorizes an irreversible call. The operator's verdict is recorded as a machine.write_mode_granted trace event (the gate's audit trail); unlike a forwarded question, the write-mode grant is a recorded interpretive decision, not just a passthrough. See internal/host/write_mode_gate.go (operatorAskGrant, writeModeActionProposal).
Open items (deferred pending LLM budget)
- Live end-to-end test: the path is covered by stub/cassette tests (no real LLM); a gated, real-
claude -pend-to-end run is deferred and must only be done on explicit request (it incurs LLM cost).