Your agent calls a tool. A dialog pops up: "Allow write_file with path /etc/hosts?" You press Tab to reach Deny — and focus disappears into the page behind the dialog. Meanwhile the agent, still streaming, keeps narrating what it will do, and your screen reader is now announcing tokens about an action you haven't approved yet.
This is the frontend side of a question people are asking a lot lately: as agents get more tools, what happens when the boundaries fail? On the backend that's a sandboxing problem. On the frontend, the boundary is the approval dialog — and most implementations of it break keyboard users exactly when the stakes are highest.
Let's build one that doesn't.
The failure modes first
From testing agent UIs with keyboard-only and screen-reader setups, approval dialogs fail in four repeatable ways:
| Failure | What the user experiences |
|---|---|
| Focus escapes the dialog | Tab lands on page controls behind the modal |
| Stream keeps announcing |
aria-live region narrates the agent's plan while approval is pending — the user can't tell done from proposed |
| No cancel path | Escape does nothing; only pointer users can dismiss |
| Focus not restored on close | After approving/denying, focus drops to <body> and the transcript position is lost |
The fix is a small typed state machine plus deliberate focus choreography — not more ARIA sprinkled on a div.
The state machine
type ApprovalState =
| { kind: 'idle' }
| { kind: 'pending'; tool: string; args: string; returnFocus: HTMLElement }
| { kind: 'decided'; decision: 'allow' | 'deny' }
| { kind: 'cancelled' };
Key rule: while pending, the transcript's live region is paused. The agent's streaming narration is paused or buffered until the decision resolves, so announcements never race the dialog.
Runnable single-file demo
Save as approval.html, open in a browser, press the "Simulate tool call" button, then navigate with Tab / Shift+Tab / Escape only:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Accessible tool approval</title>
<style>
[hidden] { display: none; }
dialog::backdrop { background: rgb(0 0 0 / .5); }
dialog { max-width: 32rem; }
code { background: #eee; padding: .1em .3em; }
#log div { padding: .25rem 0; border-bottom: 1px solid #ddd; }
</style>
</head>
<body>
<h1>Agent transcript</h1>
<!-- aria-live is paused while approval is pending -->
<div id="log" aria-live="polite" aria-atomic="false"></div>
<button id="simulate">Simulate tool call</button>
<dialog id="approval" aria-labelledby="approval-title" aria-describedby="approval-desc">
<h2 id="approval-title">Approve tool call?</h2>
<p id="approval-desc">The agent wants to run <code id="tool-name"></code>
with arguments <code id="tool-args"></code>.</p>
<button id="deny">Deny</button>
<button id="allow">Allow</button>
</dialog>
<script>
const dlg = document.getElementById('approval');
const log = document.getElementById('log');
let state = { kind: 'idle' };
function announce(text) {
const line = document.createElement('div');
line.textContent = text;
log.append(line);
}
function requestApproval(tool, args) {
state = { kind: 'pending', tool, args,
returnFocus: document.activeElement };
log.setAttribute('aria-live', 'off'); // pause stream narration
document.getElementById('tool-name').textContent = tool;
document.getElementById('tool-args').textContent = args;
dlg.showModal(); // native focus trap
document.getElementById('deny').focus(); // default to safe action
}
function resolve(decision) {
dlg.close();
log.setAttribute('aria-live', 'polite'); // resume narration
announce(decision === 'allow'
? `Approved: ${state.tool}. Executing…`
: `Denied: ${state.tool}. Agent informed.`);
state = { kind: 'decided', decision };
state.returnFocus?.focus(); // restore transcript position
}
document.getElementById('allow').addEventListener('click', () => resolve('allow'));
document.getElementById('deny').addEventListener('click', () => resolve('deny'));
dlg.addEventListener('cancel', (e) => { // Escape key
e.preventDefault(); // treat Escape as explicit Deny
resolve('deny');
});
document.getElementById('simulate').addEventListener('click', () =>
requestApproval('write_file', '{"path":"./notes.md"}'));
</script>
</body>
</html>
Three decisions worth noting:
-
<dialog>withshowModal(), not a custom overlay — you get the focus trap and top-layer behavior for free, and it works with every screen reader that matters. - Initial focus lands on Deny, not Allow. Approving a destructive tool call should never be the default Tab-stop accident.
-
Escape = Deny, explicitly. Swallowing
canceland routing it through the same code path as the Deny button means keyboard users get a real cancel, and "dismissed" is never ambiguous to the agent.
Test it against a real model, not a mock
A mock fires requestApproval at a predictable moment. A real streaming model fires it mid-token-burst, twice in a row, or right after an error — and that's exactly where the aria-live pause and focus-restore logic fall over if you got them wrong.
My setup for this: point the demo's event source at a real model endpoint and script three scenarios — (1) tool call mid-stream, (2) two consecutive tool calls, (3) tool call immediately after a stream error. For (2), verify returnFocus is captured fresh each time; for (3), verify the dialog still traps focus when the transcript is in an error state.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. I used MonkeyCode's free model access for this kind of loop because approval-UX regression runs burn a lot of tokens, and its free server option means the test rig runs somewhere other than my laptop. That's the whole pitch that applies here — no claims beyond that, and any real model endpoint you already have works identically for this workflow.
If you want to try the same setup, the demo above is endpoint-agnostic: swap the simulate button for a fetch to whatever model you have access to and call requestApproval from the tool-call event.
QA matrix
| Environment | Check |
|---|---|
| NVDA + Chrome (Windows) | Dialog title + description announced on open; Escape announces denial |
| VoiceOver + Safari (macOS) | Focus stays trapped; stream silence during pending
|
| JAWS + Edge |
returnFocus restoration lands on the same transcript line |
| Keyboard only, any browser | Full flow completes without pointer; no focus loss after two consecutive calls |
Limitations and who shouldn't use this
- This covers the frontend boundary only. An approval dialog is not a security control — if the tool itself isn't sandboxed server-side, a pretty Deny button saves no one.
- Native
<dialog>still has quirks (notably aroundautofocusand some older Android WebViews); test your actual target browsers. - If your agent makes dozens of low-risk tool calls per turn, a modal-per-call is the wrong pattern entirely — you want a batched review list, which is a different article.
- Skip the real-model testing step only if your tool calls are fired deterministically by your own backend; if a model's streaming output triggers the dialog, mocks will miss the timing bugs.
What's the worst approval-dialog behavior you've hit in an agent UI? If you reproduce one of the focus bugs above, tell me your browser, OS, screen reader versions, and the exact transition that lost focus — those reports are how this pattern gets better.
Top comments (0)