Approval modes
The modes every agent product ends up with — plan (read-only), ask (confirm every change), auto-edit (changes go through, commands ask), auto (never ask) — as one call on your config and one element in the composer. It decides; it never executes anything and stores nothing.
npm install @aparte/plugin-approval @aparte/coreimport { aparteGlobalConfig } from '@aparte/core';import { setupApproval } from '@aparte/plugin-approval';
const approval = setupApproval({ classify: { read: ['read_file', 'search', /^list_/], write: ['write_file', 'edit_file'], exec: ['run_command'], }, mode: 'ask', // the default}, aparteGlobalConfig); // the config last, like every setup*: it defaults to the global<aparte-composer-toolbar> <aparte-approval-mode></aparte-approval-mode></aparte-composer-toolbar>@aparte/core is the only peer dependency. The classification is on your tool
names — they are wire format the library cannot know: nothing in core can tell that
run_command executes and search_docs reads.
The table
Section titled “The table”read | write | exec | unlisted | |
|---|---|---|---|---|
| plan | runs | refused, with a reason | refused, with a reason | its own needsApproval |
| ask | runs | asks | asks | its own needsApproval |
| auto-edit | runs | runs | asks | its own needsApproval |
| auto | runs | runs | runs | runs |
“Asks” is the same composer panel a tool marked needsApproval gets — the person answers
in one place, whatever asked. “Refused, with a reason” is what makes plan usable: the
model reads a sentence that names the tool and what it does, and says what it would do
instead — nobody is told “the user rejected this”, because nobody did.
Plan mode: `write_file` changes files or state, and only read-only tools run in this mode.Describe what you would do instead; the user can switch the mode to let you do it.A tool in no list keeps its own needsApproval under three of the modes; auto lets it
through as well, because auto is auto.
The policy rules on every tool call the loop dispatches to a handler — including
@aparte/plugin-artifacts’ create_artifact, which is a registered
tool like any other: classify it as a write and plan mode asks before the model produces
a document. (It used to be a built-in the loop executed itself, outside any policy; that
built-in is gone.)
The switch
Section titled “The switch”<aparte-approval-mode> is a select over the four modes, bound to the setup of the config
it sits in — drop it in <aparte-composer-toolbar> beside the model selector. A switch
applies to the next tool call, mid-run included. With no setupApproval() on its config
it renders disabled and says so once in the console: an affordance that cannot act is not
offered.
approval.setMode('plan'); // from your own UIapproval.subscribe((mode, previous) => { … }); // from the element, or from setModeapproval.getMode();The element also dispatches aparte-approval-mode-change ({ mode, previousMode }, typed
as AparteApprovalModeChangeEventDetail in core’s event map), bubbling and composed, so a
host can persist the choice from any ancestor.
Labels are English by default; a localised host sets element.labels = { plan: 'Planifier', … }.
Nothing is remembered across a reload — read getMode() and write it wherever you keep
preferences, the way the model selector’s preference is yours to persist.
Under the hood — the seam in core
Section titled “Under the hood — the seam in core”The plugin is one function from (mode, class, call) to a ruling, installed with
config.setApprovalPolicy(). An AparteApprovalPolicy decides per call, from the
arguments, where a tool’s needsApproval is a declaration about the tool. Its ruling is an
AparteApprovalRuling — allow, ask, or deny with a reason — and undefined means
“no opinion”, in which case the tool’s own flag decides; config.ruleOnToolCall(call) is the
one place the two are combined. The client’s default approval channel consults it twice: once
to decide whether the call pauses at all (an allowed call never flashes awaiting approval),
once to answer. A host that injected its own approvalResolver is untouched — it already
owns the whole decision.
// Your own policy, no plugin:aparteGlobalConfig.setApprovalPolicy((call, tool) => call.name === 'run_command' && String(call.input.cmd).startsWith('rm ') ? { verdict: 'deny', reason: 'Deleting is off in this workspace.' } : undefined); // everything else: the tool's own needsApprovalThe <aparte-approval-mode> element
Section titled “The <aparte-approval-mode> element”Example
Beside the model selector, in the composer’s toolbar
<aparte-composer><aparte-composer-input></aparte-composer-input><aparte-composer-toolbar><aparte-approval-mode></aparte-approval-mode></aparte-composer-toolbar></aparte-composer>Properties
| Property | Type | Description |
|---|---|---|
labels | Partial<Record<ApprovalMode, string>> | The label of each mode, for a localised host. Missing entries keep the English default. |
mode (readonly) | ApprovalMode | null | The current mode, or null with no setup. |
Methods
| Method | Description |
|---|---|
aparteConfigChanged(next: AparteConfig): void | See AparteConfigAware: a boundary moved, so the controller may be another one. |
Events
| Event | Type | Description |
|---|---|---|
aparte-approval-mode-change | “ | After a switch: { mode, previousMode }. Bubbles and crosses shadow roots. |