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:
- 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.
- 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>
)}
</>
);
}
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
});
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)