Skip to content

Asking the user

Sometimes the model cannot proceed without a decision, a missing detail, a credential or a preference. Guessing is worse than asking, and asking in prose ("which restaurant?") ends the turn — the user types, the agent starts over. ask_user keeps the turn alive: the model emits a small typed spec, the host draws it as a native card in the transcript, and the human's answer comes back as the tool's result. The model then continues with the value in hand.

{"type": "select", "title": "Pick a restaurant", "text": "Where should I book for tonight at 20:00, for two?",
 "options": ["Antica Pesa · Italian", "Oda · Turkish", "Blue Ribbon · Sushi"]}
type the card value the model reads
confirm two buttons, Yes / No true / false
select a radio list + Choose the picked option
multiselect checkboxes, All/None, Done ["a", "b"] (may be empty)
input a text field (optional default) + Send the text
buttons a row of chips (default tinted) the tapped option
form grouped fields — text, password, number, toggle — + Submit; required fields are checked {name: value} — toggles are booleans, numbers are numbers
message an information box + Continue "acknowledged"

Options may be plain strings or {value, label} objects (the label is what the card shows, the value is what the model gets back). Every card has Cancel, and the agent waits at most timeout_s (default 180 s, clamped 5…3600).

The result is JSON the model reads:

{"ok": true, "value": "Oda · Turkish"}
{"ok": false, "cancelled": true}        — the user tapped Cancel
{"ok": false, "timeout": true}          — nobody answered in time
ERROR: ask_user: options required for select, e.g. ["a", "b"]   — a bad spec, written so the model can repair it

It is the approval mechanism

ask_user adds no new control flow. The tool raises an interrupt named ask_user through InterruptScope.interrupt — exactly what a human-in-the-loop approval does. The agent emits .interruptRaised, parks that one call on its gate (the other tools of the same message keep running), and when the host calls agent.resolve(interrupt:response:) the tool body runs a second time with the answer and returns it. A timeout substitutes .null, which the tool turns into {ok:false, timeout:true}.

import Loopl

func run(_ model: any Model) async throws {
    let agent = Agent(model: model, tools: [AskUserTool(), CalculatorTool()])
    for try await event in agent.stream("Book me a table tonight.") {
        switch event {
        case .interruptRaised(let interrupt):
            // Draw the card from the spec. In the app this is `AskUserCard`; here we answer at once.
            if let toolUseId = AskUserTool.toolUseId(fromInterruptId: interrupt.id),
               let request = AskUserRequest(interruptReason: interrupt.reason ?? .null) {
                print("the agent asks:", request.text, request.options.map(\.label))
                let answer = AskUserAnswer.answered(.string(request.options.first?.value ?? ""))
                Task { await agent.resolve(interrupt: AskUserTool.interruptId(toolUseId: toolUseId), response: answer.interruptResponse) }
            }
        case .text(let t): print(t, terminator: "")
        default: break
        }
    }
}

Transcript folds the events for you: when the interrupt is raised, the tool's own row becomes TranscriptItem.Kind.ask(toolUseId:, request, answer: nil); .interruptResolved settles it; the tool result folds into the card (no duplicate row). transcript.pendingAsks lists the cards still waiting. Inside a sub-agent the card appears in the child's card and is answered on the child (SubAgentHub.resolve(child:interrupt:response:)).

The interrupt carries its own wait: Interrupt.timeout (new, optional) overrides the agent's approvalTimeout for that one interrupt — ask_user sets it from timeout_s.

Passwords: the privacy rule

A password field's value reaches the model only inside this tool's result — that is the point of asking for it. Everything else never sees it:

  • the transcript card keeps the answer masked (••••) — AskUserAnswer.masked(for:) runs before the card settles;
  • History on disk and the Share-to-Hugging-Face export store the masked answer (the card is saved as its ask_user tool pair);
  • a history rebuilt from the transcript (edit / retry) carries the masked pair, so an edited thread never re-sends the secret;
  • the field is a secure text field marked so iOS does not offer to save it to the keychain;
  • the tool's description tells the model never to repeat a password in prose.

The model's working context for the current turn does hold the value (the tool result) — it needs it to use it. That context is not persisted.

In the app

ask_user is on by default (Settings › Tools). The card is AskUserCard in LooplUI: waiting for you pill, native controls per type, Cancel always, one quiet line once settled (tap the chevron to re-read the question). Accessibility sizes stack the controls; VoiceOver reads the card as question from the agent: , waiting for you</em>. On iPad with a hardware keyboard, ⌘↩ sends an input or submits a form.</p> <p>Measured with the real MLX stack (see the PR): an ambiguous request produces <strong>one</strong> <code>ask_user</code> and the answer is used in the very next step; a plain question never asks.</p> </article> </div> <script>var tabs=__md_get("__tabs");if(Array.isArray(tabs))e:for(var set of document.querySelectorAll(".tabbed-set")){var labels=set.querySelector(".tabbed-labels");for(var tab of tabs)for(var label of labels.getElementsByTagName("label"))if(label.innerText.trim()===tab){var input=document.getElementById(label.htmlFor);input.checked=!0;continue e}}</script> <script>var target=document.getElementById(location.hash.slice(1));target&&target.name&&(target.checked=target.name.startsWith("__tabbed_"))</script> </div> <button type="button" class="md-top md-icon" data-md-component="top" hidden> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M13 20h-2V8l-5.5 5.5-1.42-1.42L12 4.16l7.92 7.92-1.42 1.42L13 8z"/></svg> Back to top </button> </main> <footer class="md-footer"> <nav class="md-footer__inner md-grid" aria-label="Footer" > <a href="../render-ui/" class="md-footer__link md-footer__link--prev" aria-label="Previous: Render UI"> <div class="md-footer__button md-icon"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11z"/></svg> </div> <div class="md-footer__title"> <span class="md-footer__direction"> Previous </span> <div class="md-ellipsis"> Render UI </div> </div> </a> <a href="../video/" class="md-footer__link md-footer__link--next" aria-label="Next: Video input"> <div class="md-footer__title"> <span class="md-footer__direction"> Next </span> <div class="md-ellipsis"> Video input </div> </div> <div class="md-footer__button md-icon"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M4 11v2h12l-5.5 5.5 1.42 1.42L19.84 12l-7.92-7.92L10.5 5.5 16 11z"/></svg> </div> </a> </nav> <div class="md-footer-meta md-typeset"> <div class="md-footer-meta__inner md-grid"> <div class="md-copyright"> <div class="md-copyright__highlight"> © 2026 Çağatay Çalı · loopl is open source · No data collected </div> </div> <div class="md-social"> <a href="https://github.com/cagataycali/loopl" target="_blank" rel="noopener" title="github.com" class="md-social__link"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path d="M173.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6m-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3m44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9M252.8 8C114.1 8 8 113.3 8 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C436.2 457.8 504 362.9 504 252 504 113.3 391.5 8 252.8 8M105.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1m-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7m32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1m-11.4-14.7c-1.6 1-1.6 3.6 0 5.9s4.3 3.3 5.6 2.3c1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2"/></svg> </a> <a href="https://loopl.dev/app/" target="_blank" rel="noopener" title="loopl.dev" class="md-social__link"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path d="M319.1 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7-55.8.9-115.1 44.5-115.1 133.2 0 26.2 4.8 53.3 14.4 81.2 12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9m-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3"/></svg> </a> <a href="https://tiny.technology" target="_blank" rel="noopener" title="tiny.technology" class="md-social__link"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Free 7.1.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2025 Fonticons, Inc.--><path d="M160.5-26.4c9.3-7.8 23-7.5 31.9.9 12.3 11.6 23.3 24.4 33.9 37.4 13.5 16.5 29.7 38.3 45.3 64.2 5.2-6.8 10-12.8 14.2-17.9 1.1-1.3 2.2-2.7 3.3-4.1C297 44.3 306.8 32 319.9 32c13.4 0 22.8 11.9 30.8 22.1q1.95 2.55 3.9 4.8c10.3 12.4 24 30.3 37.7 52.4 27.2 43.9 55.6 106.4 55.6 176.6 0 123.7-100.3 224-224 224S0 411.7 0 288c0-91.1 41.1-170 80.5-225 19.9-27.7 39.7-49.9 54.6-65.1 8.2-8.4 16.5-16.7 25.5-24.2zM225.7 416c25.3 0 47.7-7 68.8-21 42.1-29.4 53.4-88.2 28.1-134.4-4.5-9-16-9.6-22.5-2l-25.2 29.3c-6.6 7.6-18.5 7.4-24.7-.5-17.3-22.1-49.1-62.4-65.3-83-5.4-6.9-15.2-8-21.5-1.9-18.3 17.8-51.5 56.8-51.5 104.3 0 68.6 50.6 109.2 113.7 109.2z"/></svg> </a> </div> </div> </div> </footer> </div> <div class="md-dialog" data-md-component="dialog"> <div class="md-dialog__inner md-typeset"></div> </div> <script id="__config" type="application/json">{"annotate": null, "base": "../..", "features": ["navigation.instant", "navigation.tracking", "navigation.tabs", "navigation.tabs.sticky", "navigation.path", "navigation.top", "navigation.footer", "search.suggest", "search.highlight", "search.share", "content.code.copy", "content.code.annotate", "content.tabs.link", "content.tooltips", "announce.dismiss", "toc.follow"], "search": "../../assets/javascripts/workers/search.2c215733.min.js", "tags": null, "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}, "version": null}</script> <script src="../../assets/javascripts/bundle.d7400e89.min.js"></script> <script src="../../javascripts/loopl.js"></script> </body> </html>