Skip to content

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.

Terminal window
npm install @aparte/plugin-approval @aparte/core
import { 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.

readwriteexecunlisted
planrunsrefused, with a reasonrefused, with a reasonits own needsApproval
askrunsasksasksits own needsApproval
auto-editrunsrunsasksits own needsApproval
autorunsrunsrunsruns

“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-artifactscreate_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.)

<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 UI
approval.subscribe((mode, previous) => { … }); // from the element, or from setMode
approval.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.

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 AparteApprovalRulingallow, 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 needsApproval

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

PropertyTypeDescription
labelsPartial&lt;Record&lt;ApprovalMode, string>>The label of each mode, for a localised host. Missing entries keep the English default.
mode (readonly)ApprovalMode | nullThe current mode, or null with no setup.

Methods

MethodDescription
aparteConfigChanged(next: AparteConfig): voidSee AparteConfigAware: a boundary moved, so the controller may be another one.

Events

EventTypeDescription
aparte-approval-mode-changeAfter a switch: { mode, previousMode }. Bubbles and crosses shadow roots.