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

<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.

Its options are ApprovalSetupOptions and what it hands back is an ApprovalController; getApprovalController(config) finds that controller again from anywhere — it is how <aparte-approval-mode> resolves its own, and it returns undefined when the policy has since been removed from the config. The element’s class is exported as AparteApprovalMode for a host that needs the type of a ref.

It ships no framework binding yet. In Angular, add CUSTOM_ELEMENTS_SCHEMA to the component that hosts the tag — core’s own elements come with directives, this one does not. Svelte and Vue take it as it comes: svelte/elements types any hyphenated tag, and a Vue app that already marks aparte- tags as custom elements covers this one too. React with TypeScript is the exception — @aparte/react types core’s tags and JSX.IntrinsicElements has no catch-all, so <aparte-approval-mode> in a .tsx is a compile error, not an untyped tag. Declare it once, in a .d.ts of your own:

import type { DetailedHTMLProps, HTMLAttributes } from 'react';
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'aparte-approval-mode': DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
}
}
}

The element declares no attributes, so plain HTML props are the whole contract — labels is a property, set through a ref. On React 18’s types the same interface goes under declare global instead. Or keep the tag out of JSX entirely: create the element in an effect and append it to a ref’d container. The rule for any plugin’s element, ours or yours, is Your own element, or a plugin’s.

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 whole loop runs in this repository’s example sites: get_weather is registered with needsApproval: true, search_docs / read_file / write_file / run_command are classified read / write / exec, and <aparte-approval-mode> sits in the composer’s toolbar — apps/examples/_shared/site-setup.ts.

Without the element, and without setupApproval

Section titled “Without the element, and without setupApproval”

setupApproval owns a mode for you. If the mode already lives somewhere else — a settings page, a URL parameter, a Redux slice — take the policy alone. It reads the current mode on every call, so a switch flipped mid-run applies to the next tool call.

import { aparteGlobalConfig } from '@aparte/core';
import {
APPROVAL_MODES,
classifyTool,
createApprovalPolicy,
rulingFor,
type ApprovalClassification,
type ApprovalMode,
} from '@aparte/plugin-approval';
const classify: ApprovalClassification = {
read: ['read_file', 'search', /^list_/],
write: ['write_file', 'edit_file'],
exec: ['run_command'],
};
// Wherever your app already keeps it.
let mode: ApprovalMode = 'ask';
aparteGlobalConfig.setApprovalPolicy(createApprovalPolicy(() => mode, classify));
// The table, readable without installing anything — for a settings screen that has to
// explain what each mode will do:
APPROVAL_MODES; // ['plan', 'ask', 'auto-edit', 'auto']
classifyTool('run_command', classify); // 'exec'
rulingFor('plan', 'exec', { id: 'c1', name: 'run_command', input: {} });
// → { verdict: 'deny', reason: 'Plan mode: `run_command` runs a command, …' }

createApprovalPolicy(getMode, classification) returns a plain AparteApprovalPolicy — (call, tool) => AparteApprovalRuling | undefined. The second argument is the registered declaration for that call, or undefined when the model named a tool nothing registered. classifyTool and rulingFor are the two halves it is built from, both pure, both usable on their own. ApprovalMode, ToolClass and ToolMatcher (a name, exactly, or a RegExp over names) are exported for the store that holds them.

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 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-change“After a switch: { mode, previousMode }. Bubbles and crosses shadow roots.