Pauses the plan and captures a single free-text input from the user. Use when the agent needs a value (email, hostname, IP, account ID, etc.) that cannot be auto-detected and isn't covered by a button-picker (wait_for_user_ack, max 4 options) or a multi-select card (present_preview). Returns { value: string } — empty string on cancel/timeout. Routed through G4's runUserInputGate; runs OUTSIDE TOOL_TIMEOUT_MS.
/** * mcp/skills/requestUserInput.ts — request_user_input synthetic user-wait gate * * Third user-wait gate alongside `wait_for_user_ack` (button picker) and * `present_preview` (multi-select card). This one captures **free-text input** * from the user mid-plan and returns it as `{ value: string }`. * * Why this exists * --------------- * SKILL.md authors frequently write prose like "ask the user via chat for * their printer IP" or "ask if not known" — these are functionally broken * in the current ReAct loop because the conversationIdRef clears on run * end (see useAgent.ts:156). When the LLM emits a text response asking a * question, the run terminates and the next user message starts a fresh * conversation with no memory of the prior turn. * * This tool replaces the chat-narrate-then-extract anti-pattern with a * proper synthetic gate: it pauses the plan, surfaces a text-input card * in the renderer, waits for the user's typed input, and resumes the * plan with the captured value in scratchpad. * * Routing * ------- * G4's executeStep() detects `meta.isUserWaitGate === true` and routes * this tool through `runUserInputGate()` rather than the normal * tool-execution pipeline. The dispatcher in execution.ts switches on * tool name: * - present_preview → runPresentPreviewGate (multi-select card) * - wait_for_user_ack → runUserAckGate (button picker) * - request_user_input → runUserInputGate (text input) * * Skill prose contract * -------------------- * After this step runs, scratchpad contains { value: "<user-typed text>" } * (or an empty string on timeout). Skill prose branches on whether * `value.length > 0` before using it. Validator-rejected timeouts return * empty string, NOT the rejected value. * * Returns * ------- * { value: string } * * SKILL.md authors reference this in `inputsFrom: [{ step: N, field: "value" }]` * for downstream steps that consume the captured text. The string is the * literal text the user typed, or an empty string on cancel / timeout / * validator rejection / aborted run. Skill prose typically branches on * `value.length > 0` before passing it to a downstream tool. * * Sensitive input * --------------- * When `sensitive: true`, the renderer shows a password-style masked * input (dots), AND the captured value is treated as a sensitive * parameter — redacted in audit logs, scratchpad replay, and downstream * tool-input logs. The skill must still declare the value as sensitive * at the planner level via `sensitiveParams` if it flows into a * downstream tool call that should also redact it. * * Smoke test * npx tsx -r dotenv/config mcp/skills/requestUserInput.ts */import { z } from "zod";// -- Meta ---------------------------------------------------------------------export const meta = { name: "request_user_input", description: "Pauses the plan and captures a single free-text input from the user. " + "Use when the agent needs a value (email, hostname, IP, account ID, etc.) " + "that cannot be auto-detected and isn't covered by a button-picker " + "(wait_for_user_ack, max 4 options) or a multi-select card (present_preview). " + "Returns { value: string } — empty string on cancel/timeout. Routed " + "through G4's runUserInputGate; runs OUTSIDE TOOL_TIMEOUT_MS.", riskLevel: "low", destructive: false, requiresConsent: false, supportsDryRun: false, affectedScope: ["user"], auditRequired: false, /** * Marks this tool as a synthetic user-wait gate. G4 detects this and * routes via runUserInputGate() instead of the normal pipeline, * bypassing TOOL_TIMEOUT_MS and racing against USER_ACK_TIMEOUT_MS * (15 min default) for the user's typed input. */ isUserWaitGate: true, schema: { prompt: z .string() .min(1) .describe( "Question shown to the user above the text-input field " + "(e.g. 'What's your Okta email address?').", ), placeholder: z .string() .optional() .describe( "Greyed-out hint text inside an EMPTY input. Disappears when " + "the user starts typing. NOT submitted as a value if the user " + "leaves the field blank. Use to suggest the expected format " + "without pre-filling text — e.g. 'alice@example.com'.", ), initialValue: z .string() .optional() .describe( "Text PRE-FILLED into the input. User can accept (click Continue) " + "or edit. Submitted as the captured value if the user clicks " + "Continue without editing. Use ONLY when the agent has a real " + "best-guess (e.g. partial auto-detect result). Do NOT use as a " + "placeholder substitute.", ), validator: z .string() .optional() .describe( "Optional regex (string source, no leading/trailing slashes) " + "the captured value must match before Continue enables. " + "Example: '^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$' for email format. " + "Validation happens client-side (button disabled until match) " + "AND server-side at gate resolution (defense in depth). " + "Server-side rejection returns empty string to the scratchpad.", ), sensitive: z .boolean() .optional() .describe( "When true, the renderer shows a password-style masked input " + "(dots), AND the captured value is redacted in audit logs, " + "scratchpad replay, and consent-card previews. Use for tokens, " + "MFA codes, recovery keys. Do NOT use for usernames or emails " + "(masking those just frustrates the user when they want to " + "verify they typed correctly).", ), },} as const;// -- Stub run -----------------------------------------------------------------/** * Synthetic tool — never actually invoked through the normal pipeline. * G4's executeStep() detects `meta.isUserWaitGate === true` and routes * the step through `runUserInputGate()` in execution.ts instead. This * stub exists only to satisfy the mcpTools registry's "every tool has * a run() function" invariant. * * If this throws in production, something is wrong with the gate routing * dispatcher — investigate `if (meta.isUserWaitGate)` in execution.ts. */export async function run(): Promise<never> { throw new Error( "[request_user_input] This is a synthetic user-wait gate tool. " + "G4 is expected to route steps whose tool.meta.isUserWaitGate is true " + "through runUserInputGate() in execution.ts, NOT through the normal " + "tool pipeline. If you see this error, the gate routing is broken.", );}// -- CLI smoke test -----------------------------------------------------------if (false) { console.log(JSON.stringify({ meta: { ...meta, schema: "..." } }, null, 2));}