DEV Community

babycat
babycat

Posted on

AI Agent Tool Calls Need an Accessible Approval Gate, Not Just a Confirmation Dialog

When an AI agent can trigger a real action—sending an email, deleting a document, calling an HTTP endpoint—the confirmation dialog stops being a convenience and becomes the only boundary between the model's suggestion and the user's consent. If that boundary is not operable by keyboard, announced to a screen reader, and recoverable after a failure, you have not really asked for consent; you have merely placed a styled button in front of an opaque transition. The pattern I want to show here is a small accessible approval gate that treats every tool call as an asynchronous state transition, and you can test it against a free model endpoint without standing up a paid backend.

Start with the failure that usually ships. A custom modal appears when the agent wants to use a tool, the user presses Enter on "Approve," and the modal closes immediately. For a sighted mouse user the next few seconds might feel fine because the button they are watching changes into a progress indicator. For a keyboard or screen reader user, however, focus often jumps back to the document body, the live region was never populated, and the only feedback is silence while the model streams a tool call that may fail later. If the request fails after the dialog is gone, the user has no obvious way to retry or inspect what was sent. The confirmation was rendered as a one-shot visual gate, not as a state machine with an announced destination.

A better mental model is a lockbox with a receipt. When the agent proposes a tool call, the gate opens a reviewing state and stores the proposed action in an accessible list. Approving is not the end of the interaction; it is the transition into an executing state, which can end in success, failure, or cancellation. Each transition must move focus to the element that represents the new state and use an aria-live region to say what just happened. If you do not announce the destination, a screen reader user hears a click and then nothing, which is the accessibility equivalent of handing someone a key and walking away.

The state table below is the first artifact worth copying into your design notes, because it forces you to decide where focus goes and what is announced before you write any JavaScript.

State Focus target Announcement
idle trigger button
reviewing dialog heading "Reviewing: send_email with 2 recipients"
approving progress region "Approving tool call"
executing status log "Executing send_email"
success status log "Email sent"
failure retry button "Failed to run tool. Retry button available"
cancelled trigger button "Tool call cancelled"

The code below strips that table down to a minimal browser implementation. It is deliberately unopinionated about your agent framework; it only cares that a proposed tool call arrives as a named action and an arguments object. The gate owns its own state, keeps focus where the next decision has to be made, and exposes the same transition methods whether a model streams a tool call proposal or a test harness feeds one in manually.

class ToolApprovalGate {
  constructor({ trigger, dialog, liveRegion, onExecute, log }) {
    this.state = "idle";
    this.trigger = trigger;
    this.dialog = dialog;
    this.liveRegion = liveRegion;
    this.onExecute = onExecute;
    this.log = log;
    this.lastProposal = null;
  }

  async open(proposal) {
    this.lastProposal = proposal;
    this.state = "reviewing";
    this.dialog.hidden = false;
    this.dialog.querySelector("h2").textContent =
      `Reviewing: ${proposal.name}`;
    this.dialog.querySelector("[data-args]").textContent =
      JSON.stringify(proposal.args);
    this.dialog.querySelector("[data-approve]").focus();
    this.announce("Reviewing: " + proposal.name);
  }

  approve() {
    if (this.state !== "reviewing") return;
    this.state = "approving";
    this.announce("Approving tool call");
    this.dialog.hidden = true;
    this.log.focus();
    this.run();
  }

  async run() {
    this.state = "executing";
    this.announce("Executing " + this.lastProposal.name);
    try {
      const result = await this.onExecute(this.lastProposal);
      this.state = "success";
      this.announce(result.label || this.lastProposal.name + " completed");
    } catch (error) {
      this.state = "failure";
      this.announce("Failed to run tool. Retry button available");
      this.log.nextElementSibling.focus(); // retry button
    }
  }

  cancel() {
    if (this.state !== "reviewing") return;
    this.state = "cancelled";
    this.dialog.hidden = true;
    this.trigger.focus();
    this.announce("Tool call cancelled");
  }

  announce(message) {
    this.liveRegion.textContent = "";
    requestAnimationFrame(() => { this.liveRegion.textContent = message; });
  }
}
Enter fullscreen mode Exit fullscreen mode

The important detail is not the class name or the exact selector; it is that approve() can fail after the dialog has closed. That is why the failure path moves focus to a visible retry button instead of reopening the original dialog, and why every state change writes to a single live region rather than relying on the browser's default focus ring. If you wire onExecute to a real model call, you can trigger that failure deterministically by aborting the fetch after approval, which is exactly the kind of regression a free endpoint makes cheap to run repeatedly.

To build a minimal reproduction, have a free model endpoint propose a tool call from a one-line prompt such as "Show me a tool call for sending a short confirmation email." Feed the parsed proposal into the gate, then host the static page on a free server so you can test from a real phone and screen reader rather than only from localhost. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's operator advertises a free model access path with a 30-million-token allowance and a free server option, which is enough for the repeated aborted-request and retry loops this test requires; the same harness works with any endpoint that returns a tool-call proposal. Keep the harness separate from the agent itself, because you want to test the consent boundary in isolation.

Your QA matrix should cover at least four transitions: the user approves with Enter and the call succeeds, the user approves and the call fails, the user presses Escape during review, and the user cancels after the live region has already announced one state. Record the browser, operating system, and assistive technology versions along with the exact transition that failed, because screen reader timing differs enough that a passing Chrome and NVDA run does not guarantee the same behavior in Safari and VoiceOver. The most common bugs you will find are focus landing on a hidden element after the dialog closes, an announcement being swallowed because the live region was updated before it became visible, and the retry button being present in the DOM but unreachable by keyboard.

This pattern is not a security boundary. It makes approval legible and recoverable, but it does not validate the tool call, restrict the agent's permissions, or remove the need for server-side authorization. You should not lean on this gate for destructive actions where the model could misstate its own intent, and you should not present it as a substitute for an audit log. It is most useful for the middle layer of agent interactions where the risk is confusion rather than catastrophe, and where the confirmation dialog would otherwise be the only thing standing between a streamed suggestion and a real effect.

If you want to test that middle layer without first negotiating a paid model key or a cloud function, a free endpoint and a free static server are enough to expose the worst focus and announcement bugs in an afternoon. The gate itself is deliberately small; the value comes from treating approval as a state machine that can fail after the user says yes, and from testing that path with the same seriousness you would give to a backend error.

Top comments (0)