DEV Community

babycat
babycat

Posted on

Build an Accessible Tool-Call Consent Gate for Streaming Agents

An agent I'm testing decided, mid-stream, that it wanted to delete a temp directory. The confirmation dialog appeared while my focus was in the message input. I pressed Enter to send my half-typed message — and confirmed the deletion instead. The dialog had stolen focus, my keystroke landed on its default button, and the agent happily proceeded.

This is the interaction failure behind a lot of the current "agent boundaries" discussion: the boundary isn't just a policy problem, it's a UI timing problem. A tool call can arrive at any point in a stream — while the user is typing, while a screen reader is mid-announcement, while focus is three components away. If your consent UI is a generic modal, it will eventually confirm something the user never saw.

Here's a consent gate that's typed, keyboard-operable, and testable against a real streaming endpoint.

The state table first

Before any code, the states. Consent is not open | closed:

State Trigger Focus Announcement Allowed exits
streaming tokens arriving stays in input/log polite token append consentRequested
consentRequested tool-call event moves to dialog, on the deny button assertive: "Agent requests: delete ./tmp" approved, denied, dismissed
approved user activates approve returns to input "Approved. Running tool." streaming (tool result streams in)
denied user activates deny returns to input "Denied. Agent notified." streaming
dismissed Esc returns to input "Request dismissed — treated as denied." denied
streamEnded stream closes mid-consent returns to input "Stream ended before your response. Nothing was run." terminal

Two decisions worth calling out:

  1. Default focus lands on Deny, not Approve. A destructive action should require deliberate navigation. The classic pattern of focusing the primary button is an anti-pattern when the primary button deletes things.
  2. Esc denies, it doesn't just close. A dismissed request that silently times out and runs anyway is the worst of both worlds. Denial must be the safe default at every exit.

A runnable consent gate

Single-file React component. It renders a fake streaming log plus the consent dialog; wire the onToolCall event to whatever transport you use.

import { useEffect, useRef, useState } from "react";

type ConsentState =
  | { kind: "streaming" }
  | { kind: "consentRequested"; tool: string; args: string }
  | { kind: "approved" } | { kind: "denied" }
  | { kind: "dismissed" } | { kind: "streamEnded" };

export function ConsentGate() {
  const [state, setState] = useState<ConsentState>({ kind: "streaming" });
  const [announcement, setAnnouncement] = useState("");
  const dialogRef = useRef<HTMLDivElement>(null);
  const denyRef = useRef<HTMLButtonElement>(null);
  const inputRef = useRef<HTMLTextAreaElement>(null);

  // Simulate a tool-call event arriving mid-stream (replace with your transport)
  useEffect(() => {
    const t = setTimeout(
      () => setState({ kind: "consentRequested", tool: "fs.delete", args: "./tmp" }),
      3000
    );
    return () => clearTimeout(t);
  }, []);

  useEffect(() => {
    if (state.kind === "consentRequested") {
      setAnnouncement(`Agent requests permission: ${state.tool} with ${state.args}`);
      denyRef.current?.focus(); // safe default
    } else if (state.kind !== "streaming") {
      const msg = {
        approved: "Approved. Running tool.",
        denied: "Denied. Agent notified.",
        dismissed: "Request dismissed — treated as denied.",
        streamEnded: "Stream ended before your response. Nothing was run.",
      }[state.kind];
      setAnnouncement(msg);
      inputRef.current?.focus(); // restore focus, always
    }
  }, [state]);

  const resolve = (kind: "approved" | "denied" | "dismissed") => {
    // TODO: send the decision back over the stream before updating UI
    setState({ kind });
  };

  return (
    <>
      <div aria-live="assertive" role="status" className="sr-only">
        {announcement}
      </div>

      <textarea ref={inputRef} aria-label="Message" placeholder="Type a message…" />

      {state.kind === "consentRequested" && (
        <div
          ref={dialogRef}
          role="alertdialog"
          aria-modal="true"
          aria-labelledby="consent-title"
          aria-describedby="consent-desc"
          onKeyDown={(e) => {
            if (e.key === "Escape") resolve("dismissed");
            if (e.key === "Tab") {
              // minimal focus trap across the two buttons
              const buttons = dialogRef.current!.querySelectorAll("button");
              const first = buttons[0], last = buttons[buttons.length - 1];
              if (e.shiftKey && document.activeElement === first) {
                e.preventDefault(); (last as HTMLElement).focus();
              } else if (!e.shiftKey && document.activeElement === last) {
                e.preventDefault(); (first as HTMLElement).focus();
              }
            }
          }}
        >
          <h2 id="consent-title">Agent requests permission</h2>
          <p id="consent-desc">
            Run <code>{state.tool}</code> with <code>{state.args}</code>?
          </p>
          <button ref={denyRef} onClick={() => resolve("denied")}>Deny</button>
          <button onClick={() => resolve("approved")}>Approve</button>
        </div>
      )}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notes on the semantics: role="alertdialog" because this is an urgent interruption, not a routine dialog. aria-live="assertive" on a separate, persistent live region — don't put the announcement inside the dialog, or it won't fire for screen readers that were already mid-sentence elsewhere.

Test it against a real endpoint, not a setTimeout

The setTimeout above is a placeholder, and it lies to you in specific ways: it fires at a deterministic moment, never mid-keystroke, never while your screen reader is announcing the previous token batch. Real streams emit tool calls at hostile moments.

To get a realistic signal I run a small local agent server and point the UI at it, then hammer it with a scripted scenario: type continuously in the message input for 10 seconds and record where focus was when the tool-call event arrived. For the model side of that setup I've been using MonkeyCode, which currently offers free model access and a free server option — enough to stand up a streaming endpoint that emits tool-call events with real network jitter, without burning a paid quota on accessibility regression runs. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Any OpenAI-compatible streaming endpoint works the same way; the point is that the timing is real.

The regression script that matters:

// playwright: consent-timing.spec.ts (pseudocode-adjacent, adapt to your app)
test("tool call while typing does not confirm on Enter", async ({ page }) => {
  await page.goto("/chat");
  const input = page.getByLabel("Message");
  await input.focus();
  await input.pressSequentially("please refactor the", { delay: 80 });
  // wait for the real endpoint to emit a tool call mid-typing
  const dialog = page.getByRole("alertdialog");
  await dialog.waitFor();
  await page.keyboard.press("Enter"); // user was mid-message
  // Deny has default focus, so Enter must NOT approve
  await expect(dialog).toBeHidden();
  await expect(page.getByRole("status")).toHaveText(/Denied/);
  await expect(input).toBeFocused(); // focus restored
});
Enter fullscreen mode Exit fullscreen mode

If your default button were Approve, this test would be red. That's the bug from my opening paragraph, caught in CI.

QA matrix

Environment What to verify
Chrome + NVDA (Windows) assertive announcement fires before focus moves; Esc denies
Safari + VoiceOver (macOS) focus trap holds; announcement not swallowed mid-token
Firefox + keyboard only full flow with zero pointer input; focus returns to input
Slow 3G throttle consent arrives late; streamEnded path announces correctly

Limitations and who shouldn't use this

  • This gates the UI, not the agent. A determined or buggy agent runtime can still act without asking — enforce permission server-side too. The consent gate is the human legible layer, not the security boundary.
  • If tool calls arrive faster than a human can read them, no dialog design saves you; you need batching or a pre-approved allowlist instead.
  • Free tiers for hosted models and servers change or disappear without notice; don't bake the free endpoint into CI as a hard dependency. Keep the mock path as a fallback and treat the real-endpoint run as a scheduled job.
  • High-frequency, low-risk tool calls (read-only file reads, for example) probably shouldn't interrupt at all — auto-approve those with a visible audit log, and reserve the gate for irreversible actions.

The broader lesson: an agent boundary that fires a modal is not a boundary a keyboard user can trust. Type the states, default to denial, restore focus every time, and test against timing that a real network gives you — a setTimeout will always be polite in ways production never is.

If you reproduce the focus-theft bug on your own setup, I'd love the details: browser, OS, assistive tech versions, and which transition in the state table misbehaved.

Top comments (0)