Installation
npx shadcn@latest add @pixeldosa/autonomy-controlRequires `cn` at `@/lib/utils`. List real actions in the user's words, and put anything irreversible or external at `risk: "high"`. Enforce the same rule server-side: this control shows the policy, it doesn't enforce it.
Usage
"use client";
import { AutonomyControl } from "@/components/ui/autonomy-control";
<AutonomyControl
level={settings.level}
onLevelChange={(level) => save({ level })}
alwaysAsk={settings.alwaysAsk}
onAlwaysAskChange={(alwaysAsk) => save({ alwaysAsk })}
actions={[
{ id: "label", label: "Label and archive email", risk: "low" },
{ id: "calendar", label: "Accept meeting invites", risk: "medium" },
{ id: "send", label: "Send email for you", risk: "high" },
]}
/>
Why it exists
People ask agents to be less autonomous, and to have an off switch. Most products offer a slider from "cautious" to "autonomous", which tells nobody what will happen to their inbox. This control explains every level through the agent's actual actions.
Features
- Concrete outcomes. Every action reads "Asks first" or "Does it, tells you" for the chosen level.
- A floor you can raise, never lower. Pin any action to Always ask. High-risk actions ask at every level. That isn't a setting, on purpose.
- Announced changes. "Does 2 of 5 without asking" updates politely when the level changes, because every row changed with it.
- Native radios, so arrow keys and form semantics work without extra code.
The policy
| Risk | Ask me first | Ask when it matters | Act, then tell me |
|---|---|---|---|
low | Asks first | Does it, tells you | Does it, tells you |
medium | Asks first | Asks first | Does it, tells you |
high | Asks first | Asks first | Asks first |
Examples
Deciding whether to show an approval, with the same rule the control displays:
function needsApproval(action, { level, alwaysAsk }) {
if (action.risk === "high" || alwaysAsk.includes(action.id)) return true;
if (level === "ask") return true;
return level === "balanced" && action.risk === "medium";
}
{needsApproval(action, settings) ? <AIApprovalGate {...gate} /> : execute(action)}
Props
| Prop | Type | Description |
|---|---|---|
level | "ask" | "balanced" | "act" | Required. |
onLevelChange | (level) => void | Required. |
actions | { id, label, risk }[] | Required. Real actions, in the user's words. |
alwaysAsk | string[] | Action ids pinned to asking. |
onAlwaysAskChange | (ids) => void | Shows the Always ask checkboxes. |
label | string | The fieldset legend. |
headingLevel | 2 | 3 | 4 | 5 | 6 | Heading level, so the component fits your page's outline. Defaults to 3. |
Accessibility
- Levels are a
<fieldset>of native radios with a legend. - Each Always ask checkbox is named after its action ("Always ask before: Draft replies").
- The live count is a polite status region.
When not to use
This isn't the approval itself. That's AI Approval Gate, and this control decides when
to show one. It also doesn't enforce anything: apply the same policy on the server.
why it's built this way
Engineering Notes
Research on agent products keeps finding people asking for lower automation and an off switch, while the 'match autonomy to risk' principle has no UI anywhere. Most autonomy settings fail because they are abstract. A slider labelled cautious-to-autonomous doesn't tell anyone what will happen to their inbox. So each level is explained through the agent's actual actions, with the outcome for each in words. The policy is a pure function of level and risk: 'ask' asks for everything, 'balanced' acts only on low risk, 'act' acts on low and medium. High risk asks at every level, and that is deliberately not configurable, because a setting that can turn off the approval for an irreversible send is a trap with a friendly label. User overrides only move in the safe direction: 'Always ask' pins an action to asking, and nothing un-pins the floor. Levels are native radios in a fieldset, so arrow keys, labels and form semantics come for free. The 'Does N of M without asking' count sits in a polite live region, because changing the level silently changes every row below it.
how it moves, and when it doesn't
Motion Notes
Still. This is a settings surface and the user's turn, so nothing animates on its own. Selection and hover change background and border colour at `--pd-duration-instant`. Row outcomes swap text instantly when the level changes, not with a transition, so the new policy can be read the moment it applies. The focus ring follows the radio via `has-[:focus-visible]`. Under prefers-reduced-motion the colour transitions become instant.