DEV Community

babycat
babycat

Posted on

Build an Accessible Tool-Approval Dialog With Focus Recovery and a Real Model Behind It

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' };
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Three decisions worth noting:

  1. <dialog> with showModal(), not a custom overlay — you get the focus trap and top-layer behavior for free, and it works with every screen reader that matters.
  2. Initial focus lands on Deny, not Allow. Approving a destructive tool call should never be the default Tab-stop accident.
  3. Escape = Deny, explicitly. Swallowing cancel and 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 around autofocus and 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)