I reproduced a three-tool agent in a local page, then watched the confirmation chip appear beside the stream. The composer still held focus, so Enter sent another user message instead of allowing anything. Have you ever approved a destructive tool without meaning to, just because the prompt never entered the tab order? That is the failure this retrospective walks from symptom to a blocking dialog.
The chip looked confident. It sat under the last token, colored like a success toast, and it started an eight-second auto-allow. I never tabbed into it, NVDA never named the verbs, and run_command still fired. This write-up keeps the broken page, the state table, and the fix in one place so you can rerun the same transition.
The symptom I actually hit
I was keyboard-only in the composer, which is where almost every chat agent leaves you. A streamed plan appeared: read_file, then run_command, then write_file. A compact chip said “Allow 2 tools,” but it was a div with a click handler and a CSS animation. I pressed Enter because I was still editing the follow-up sentence. The agent treated that key as consent and executed the shell step.
What failed, in order:
- Focus never left
#composer. - The chip was not in the tab order and had no accessible name.
- An auto-allow timer treated silence as a yes.
- A new user message did not retire the open proposal.
- The transcript live region re-read partial JSON instead of the decision.
Does that sound like a visual polish bug? It is a consent bug wearing a badge.
Put the state table on the wall first
I stopped restyling the chip and wrote the legal states down. If a transition cannot be named, the UI will invent an implicit yes.
| State | Composer | Prompt UI | Announcement | Keyboard action |
|---|---|---|---|---|
idle |
enabled | none | none | type and send |
planning |
disabled, status text on the field | none | “Planning tools.” once | Cancel planning |
awaiting_confirm |
inert | modal dialog, focus on Deny | “Two tools proposed. Deny or allow.” | Deny, Allow, review list |
executing |
disabled | closed | one verb per step | Cancel remaining |
failed |
enabled | closed | which step failed | compose a new turn |
cancelled |
enabled | closed | “Tool run cancelled.” | compose a new turn |
Notice what is missing on purpose. There is no implicit_allow. There is no timeout_yes. There is no enter_in_composer_means_ok. If you need those states, you are not confirming tools. You are hoping nobody notices.
How I debugged it without guessing
I did not start with more ARIA. I logged focus, then I logged the machine, then I recorded what a screen reader would hear. Would you ship a payment sheet that never received focus? Then do not ship tool consent that way.
Reusable checks:
- Log
document.activeElement.idon every state change. - Tab from the composer with the mouse unplugged and count stops.
- Speak the dialog title and the first button without looking at the screen.
- Send a second message while a proposal is open and assert the proposal dies.
- Kill the auto-allow timer and prove the tools do not run.
// Label: local debug helper for a single-page reproduction.
const focusLog = [];
function traceFocus(reason) {
const id = document.activeElement && document.activeElement.id;
focusLog.push({ reason, id, at: Date.now() });
console.table(focusLog.slice(-8));
}
The first trace was embarrassing. Every planning token called traceFocus("chunk"), and the id stayed composer. The chip never appeared in the log, which meant the keyboard path could not reach Deny or Allow. That is the whole incident in one column.
Root cause: a toast tried to do a dialog’s job
The chip failed for structural reasons, not because the copy was short. A toast is a transient status. A tool prompt is a permission gate. Mixing them trains the interface to steal consent from whoever is still typing.
The broken control did four illegal things at once:
- It rendered after the stream, so it competed with incoming tokens.
- It used
div+onclick, so it skipped the tab order. - It armed
setTimeout(..., 8000)and called that “safe default.” - It left the composer enabled, so Enter could not mean Deny.
I also had aria-live="polite" on the whole transcript. Partial tool JSON like {"cmd":"rm got announced, while “Allow?” stayed silent. The live region was busy reciting garbage, and the actual decision never won the queue. Have you checked who wins when a token and a permission question arrive together?
The broken reproduction
This is the smallest page that recreates the failure. Paste it, tab into the textarea, and press Enter after the chip appears. The shell step runs even if you never touched Allow.
<!-- Label: intentionally broken demo. Do not copy this pattern. -->
<textarea id="composer" aria-label="Message">Inspect package.json</textarea>
<button type="button" id="send">Send</button>
<div id="stream" aria-live="polite"></div>
<div id="chip" hidden>
Allow 2 tools
<span onclick="allowTools()">Allow</span>
</div>
<script>
let allowTimer;
const proposal = [
{ id: "t1", name: "read_file", args: { path: "package.json" } },
{ id: "t2", name: "run_command", args: { cmd: "rm -rf build" } }
];
send.onclick = () => {
composer.disabled = false; // still typeable: the bug
stream.textContent = JSON.stringify(proposal);
chip.hidden = false;
allowTimer = setTimeout(allowTools, 8000);
};
function allowTools() {
clearTimeout(allowTimer);
stream.textContent += "\nrunning " + proposal[1].name;
}
</script>
Expected broken states:
- Visual: chip visible, composer focused, timer ticking.
- Keyboard: Tab never lands on Allow; Enter submits the composer.
- Screen reader: JSON string spoken; “Allow 2 tools” unnamed.
- Recovery: none, because the proposal is not a state you can deny.
The fix: showModal(), Deny first, proposal retirement
I replaced the chip with a native modal dialog and a typed machine. Native HTMLDialogElement.showModal() gives a focus trap, Escape, and an inert backdrop without a custom overlay folklore. Deny is the first control because accidental Enter must mean no.
// Label: proposed state machine for the confirmation path.
/** @typedef {"idle"|"planning"|"awaiting_confirm"|"executing"|"failed"|"cancelled"} AgentUi */
const ui = {
state: /** @type {AgentUi} */ ("idle"),
proposal: /** @type {null | {id: string, name: string, summary: string}[]} */ (null)
};
function setState(next) {
ui.state = next;
composer.disabled = next !== "idle" && next !== "failed" && next !== "cancelled";
if (next === "awaiting_confirm") {
dialog.showModal();
denyBtn.focus();
status.textContent = `${ui.proposal.length} tools proposed. Deny or allow.`;
} else if (dialog.open) {
dialog.close();
}
traceFocus(next);
}
function retireProposal(reason) {
ui.proposal = null;
clearTimeout(allowTimer); // there is no allowTimer anymore
status.textContent = reason;
setState("cancelled");
}
form.addEventListener("submit", (event) => {
event.preventDefault();
if (ui.state === "awaiting_confirm") {
retireProposal("Proposal retired because you sent a new message.");
return;
}
// start planning…
});
denyBtn.addEventListener("click", () => retireProposal("Tools denied."));
allowBtn.addEventListener("click", () => setState("executing"));
dialog.addEventListener("cancel", (event) => {
event.preventDefault();
retireProposal("Tools denied.");
});
Dialog markup that actually participates in the accessibility tree:
<div id="status" role="status"></div>
<dialog id="dialog" aria-labelledby="prompt-title">
<h2 id="prompt-title">The agent wants to run tools</h2>
<ol id="tool-list"></ol>
<button type="button" id="denyBtn">Deny</button>
<button type="button" id="allowBtn">Allow listed tools</button>
</dialog>
Fill the list with real verbs, not a count chip. read_file on package.json is a decision. Allow 2 tools is a shrug. Pointer users can still click Allow. Keyboard users get Deny first. Screen reader users get one status sentence, not a JSON firehose.
When planning starts, disable the composer and put the busy text on the field itself. That keeps the name and the state in one place.
<textarea id="composer" aria-label="Message" aria-disabled="true"></textarea>
<p id="composer-state">Planning tools. Cancel is available.</p>
Where a free remote model actually helps this bug
Local mocks hide the worst timing. Real streams stall, split a tool name across chunks, and then dump three proposals at once. I needed that jitter to prove the dialog still opens once, with focus on Deny, even when the last argument arrives late.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode’s free model access and free server option are enough to stream a multi-step tool plan into this page without standing up a paid inference account. Point the reproduction at that remote stream, cancel it, and send a second user turn while the dialog is open. If the proposal does not retire, the machine is still lying.
A sibling gate belongs in front of the first remote call. Tool consent is not the same as “this prompt leaves the browser,” but both must block. A dismissible banner under the composer will fail the same keyboard path you just watched.
// Label: first-run remote-inference consent, same blocking pattern.
if (ui.state === "idle" && !consent.remoteInference) {
privacyDialog.showModal();
privacyDeny.focus();
return;
}
Accessibility notes the dialog does not magically solve
Native modal behavior is necessary and not sufficient. You still owe a named list, a single status announcement, and pointer-independent actions. ARIA on a chip will not create a tab stop that was never there.
Checklist I keep beside the demo:
- The dialog title names the decision, not the product.
- Deny is first in DOM order and receives initial focus.
- Escape and Deny take the same
cancelledpath. - The tool list is an
ol, and each item includes the verb plus the target. -
role="status"announces the state change once, not every token. - The composer is inert while the dialog is open; it does not submit.
- A new submit retires the proposal instead of stacking a second chip.
- No timer calls
allowTools.
Diagram of the only legal confirmation loop:
idle -> planning -> awaiting_confirm
| |
Deny/Esc Allow
| |
cancelled executing -> idle or failed
^
|
new user message retires proposal
Environment-specific QA matrix
Please reproduce with versions written down. The transition that failed for me was planning to awaiting_confirm while focus stayed in the composer, then Enter on a still-enabled textarea.
| Environment | What to drive | Pass |
|---|---|---|
| Firefox, Windows, NVDA | planning → dialog opens, NVDA speaks the title and Deny | Deny is first utterance after “Planning tools.” |
| Chrome, Windows, NVDA | Escape from dialog | state is cancelled, composer focused |
| Safari, macOS, VoiceOver | VO+Space on Deny | tools never run |
| Chrome, macOS, keyboard only | Tab cycle inside dialog | no composer, no stream links |
| Any browser, slow remote stream | arguments arrive after the dialog opened | list updates without moving focus |
| Any browser | Send a second message during the prompt | proposal retired, no execution |
If you file a miss, send the browser, OS, assistive technology, and that exact transition. A screenshot of the chip is not evidence, because the chip was the bug.
Limitations, and who should not use this
This pattern is for interactive product UX in the browser, where a human is present and tools can mutate files or run commands. It does not authorize tools on the server. A pretty dialog with a lying backend is still an auto-run.
Do not use this approach if:
- the agent must run unattended in CI with a pre-signed policy;
- you only render read-only explanations and never call tools;
- you are replacing OS permission prompts in a native shell;
- you planned to keep auto-allow and “just announce it louder.”
I also am not claiming conformance. This is a reproduction and a focus-safe state machine, not a WCAG certificate. If your tools include purchase, delete, or deploy, add server-side authorization and a review surface that survives a refresh.
The recovery path stays boring on purpose. After Deny or failure, focus returns to the composer, the proposal is gone, and nothing retries itself. A second Stop button or a resurrected chip would hide the same consent hole we just closed.
So here is the debugging habit I am keeping. When an agent assumes yes, freeze the UI on a real dialog, log activeElement, and retire the proposal if the human keeps talking. If you want a remote stream that is slow enough to expose the race, MonkeyCode’s free model access and free server option can feed this dialog while you watch focus, not while you admire the chip.
Top comments (0)