Skip to main content
PathDocs

Interaction Seams: Approval, Permission Presets, and Commands

In short: packages/interaction splits "how a person guides a running agent" into several independent seamsctx.approval asks once whether an action may proceed (ApprovalOutcome, a closed four-value set; a missing answerer fails closed), ctx.permissionPresets bundles sandbox mode with approval policy into one user-facing preset selector, and ctx.commands lets plugins register human slash commands that never enter a model round trip; the fourth seam, ctx.userQuestions (the model asking a person), has its own dedicated page, so this page only maps it and cross-links.

Audit baseline 0.1.5-alpha.1 @ 5dda764ed3: package names, ctx keys, event names, methods, types, and config keys are all verified point-by-point against the official source.

This is the core lesson on the interaction plane. After reading you can answer: which stages an approval passes from a tool request to a grant, why the answer vocabulary has only four values, how to write and compose an answerer, how a preset switch "writes through" to the approval policy, and how a slash command is registered, addressed, logged, and where its result goes.

1. How the four seams divide the work (a map)

Seamctx keyPackageWho initiatesAnswer / result shapeHere
Approvalctx.approvalpackages/interaction/user-approvalTool pipeline, sandbox escalation retryApprovalOutcome, a closed four-value set§2–5
Permission presetsctx.permissionPresetspackages/interaction/permission-presetsThe user (/permission)Preset name + two knob events§6
Human commandsctx.commandspackages/interaction/commandsThe user (typing /xxx in a UI)CommandResult, rendered directly§7
Asking a personctx.userQuestionspackages/interaction/user-questionsThe model (ask_user_question)AskUserQuestionAnswerDedicated page

Three relationships matter:

  • Presets write through to approval; approval does not depend on presets. permissionPresets's set() calls setApprovalPolicy() exported by user-approval; conversely, ApprovalService has no idea presets exist — it only folds approval/policy events. Removing the preset package therefore leaves the last knob values in effect.
  • The command registry is not a model surface. Command metadata, input, and direct output never enter a model request; only work a command producer explicitly schedules through an Agent costs tokens.
  • Approval and asking are two different paths. Approval is "I want to act, please authorize" (raised by a tool or the sandbox; the person gives a one-shot grant). Asking is "I need information" (raised by the model; the person gives a structured answer). Both use an Agent-scoped waterfall, but the service, event names, and answer types are entirely different.

For the user-facing configuration, environment variables, and /permission operations see Permissions; for the enforcement boundary itself see Sandbox and Security.

2. ctx.approval: API and decision vocabulary

ApprovalService (service name 'approval') is this seam's service definition (user-approval/src/index.ts:142). Its public surface:

MemberSignatureMeaning
requestrequest(req: ApprovalRequest): Promise<ApprovalOutcome>Ask once; requires an open turn; the audit pair lands in the log
setPolicysetPolicy(agent: Agent, policy: ApprovalPolicy): voidSwitch a live agent's policy and inject a "policy changed" user message for the model
overrideOfoverrideOf(session: Session): ApprovalPolicy | undefinedRead the last approval/policy event in the log; undefined without an override
setApprovalPolicymodule-level setApprovalPolicy(session, policy): voidThe single durable write path (session initialization uses it directly); an invalid value throws before the log changes
APPROVAL_POLICIESreadonly ApprovalPolicy[]['ask', 'never'], used for option advertisement and runtime validation

The effective policy comes from a private fold: overrideOf(session) ?? config.policy ?? 'ask' (user-approval/src/index.ts:235). There is exactly one config field:

- name: '@deepseek-ai/dsh-user-approval'
config:
policy: ask # ask | never, defaults to ask

The decision vocabulary is closed (user-approval/src/types.ts:32):

Return valueMeaningWhat a caller should do
allowed-onceThe only grant value: valid for this requested action only, never a remembered ruleProceed this once
rejectedExplicit refusalDeny
cancelledThe request was withdrawn (signal abort)Deny
unavailableNo answerer, a throwing answerer, or a value outside the closed setFail closed: treat as denial

ApprovalRequest (user-approval/src/index.ts:103) deliberately carries no tool arguments: an answerer attaches the prompt to the already-streamed tool call through callId instead of rendering a second copy that could drift.

FieldRequiredMeaning
agentyesOn whose behalf the question is asked; decides scope routing and which session receives the audit
toolNameyesThe tool the question is about (presentation + audit)
callIdnoThe exact tool-call id, so a UI can attach the prompt to the call it already streamed
reasonnoThe asker's human-readable explanation
signalnoAborting withdraws the question: the request settles cancelled immediately and a late answer is discarded

3. request()'s sequence and fail-closed rules

The order inside request() (user-approval/src/index.ts:207) is fixed:

  1. The open-turn precondition. The session log must sit inside a turn/start not yet closed by turn/end; otherwise it throws immediately and appends nothing. The audit pair must be turn-enclosed because the turn is the durable log's commit/replay boundary — a bare event between turns is indistinguishable from a crash tail on reload.
  2. Mint a fresh ApprovalRequestId(randomUUID()) and append approval/asked.
  3. decide() produces one outcome:
    • signal already aborted → cancelled;
    • effective policy is neverrejected, before any answerer dispatch;
    • otherwise enter ctx.waterfall(scopeTarget(req.agent, req.agent), 'approval/request', req, () => 'unavailable'); a return value outside the closed set → unavailable, a throw → unavailable (a synchronous throw lands in the same containment);
    • with a signal, the answer races the abort: an abort that wins returns cancelled, and a later answerer answer is discarded.
  4. Append approval/decided and return the outcome.

Three fail-closed rules (decide(), user-approval/src/index.ts:258): no answerer claims it → unavailable; an answerer throws → unavailable; a non-vocabulary return → unavailable. Separately, if an audit append fails before the commit point the request rejects — returning an unlogged decision would break the audit pair.

A consumer example: the tools pipeline opportunistically takes the service with ctx.get('approval') and maps an ask decision to allow/deny (packages/core/tools/src/index.ts:1696); sandbox escalation retries ride the same seam (approveEscalation, packages/sandbox/sandbox/src/escalation.ts; see Sandbox and Security for the details).

4. Composing answerers: the approval/request waterfall

The service itself has no provider registry; the entry point is the Cordis waterfall approval/request (user-approval/src/types.ts:85):

declare module '@deepseek-ai/cordis' {
interface Events {
/**
* Ask composed answerers for one decision. Return an outcome to claim the
* request or call `next()` to delegate. Scope-filtered dispatch
* (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
* @mode waterfall
*/
'approval/request'(
this: Scoped<Agent>,
req: ApprovalRequestEvent,
next: () => Promise<ApprovalOutcome>,
): Promise<ApprovalOutcome>
}
}
  • Returning an outcome claims the request; the first returned value ends the waterfall, and return next() means "I won't handle it".
  • Scope filtering: the request is dispatched through scopeTarget(req.agent, req.agent), so only that agent's scoped listeners receive it; the scope layer takes the key from args[0].agent (packages/core/scope/src/scoped-events.generated.ts:24).
  • The terminal default is unavailable, so a deployment should compose exactly one terminal answerer; sibling listener order is not a policy-priority mechanism.
  • Listeners are ordinary ctx.on(...) registrations that unregister with their plugin fiber.

Two real answerers in the shipped composition:

ImplementationLocationBehavior
Browser UIpackages/client/ui-approval/src/client/index.tsctx.remote.$on('approval/request', …): renders the request as a pending-interaction panel; the Host side forwards it to the browser as { event: 'approval/request', mode: 'waterfall' } (packages/api/remotes/src/remote-events.ts:18)
ACP machine decisionpackages/acp/acp/src/index.ts:155Answers only for sessions it owns, and only when the request carries a callId, else next(); it offers the client just allow-once / reject-once and never infers a durable grant from an unknown response

never cannot be bypassed: the policy is enforced on the service's own request() path before any waterfall dispatch, so even a later-registered prepend: true listener cannot make a never session ask.

5. Policy and audit events

user-approval declares four events:

EventModePayloadPurpose
approval/requestwaterfallApprovalRequestEventThe answerer entry point
approval/askedlog-only{ id, toolName, callId?, reason? }First half of the audit pair; id pairs it with the second
approval/decidedlog-only{ id, outcome }Second half; exactly one per ask
approval/policylog-only{ policy, source?: 'delegation' }Session policy override; the last one wins; source: 'delegation' marks an override seeded into a child

The two audit events never enter the model transcript: what the model sees is the consumer's eventual tool result plus the policy sentence in the runtime-context snapshot.

Policy semantics (ApprovalPolicy, user-approval/src/index.ts:63):

  • ask (the default) — delegate to the composed answerers; with none composed the chain falls through to the fail-closed unavailable.
  • never — prompt nobody, and every ask resolves deterministically to rejected; the strict stance for CI and unattended runs. The model is told that approval prompts are disabled and not to request sandbox escalation (not to set sandbox_permissions).

The model-visible surface comes from the systemPrompt contribution approval:policy (user-approval/src/index.ts:155): each policy contributes one sentence stating its complete current meaning, appended after retained history, so switching policy never rewrites the stable system-prompt prefix. setPolicy() additionally injects a sourced user message announcing the change.

Audit invariants (user-approval/src/invariant.ts) validate both loaded and newly appended events: approval/asked / approval/decided must be inside an open turn, pair one-to-one by id, and keep outcome and policy inside their closed sets. Failure messages look like:

approval/asked appended outside any open turn
approval/asked toolName must be non-empty
approval/asked repeated open id "<id>"
approval/decided has no matching approval/asked for id "<id>"
approval/decided carries unknown outcome "<value>"
approval/policy carries unknown policy "<value>"

Error shape: this seam has no error-code enum (unlike user-questions' UserQuestionError with stable codes). Its failure taxonomy is the closed outcome set; only programming errors throw:

TriggerThrown
Calling request() with no open turnError: approval.request() outside an open turn: …
Passing an invalid policy to setApprovalPolicy()TypeError: approval policy must be one of "ask" or "never"

6. ctx.permissionPresets: how a preset writes through to approval

PermissionPresetService (service name 'permissionPresets', permission-presets/src/index.ts:162) bundles two independent enforcement knobs — sandbox mode sandbox/mode and approval policy approval/policy — into named presets a client offers as one selector. It performs no enforcement itself: execution, narration, and replay keep reading each knob's own fold.

A preset is one table entry (PresetSpec, permission-presets/src/index.ts:58):

FieldMeaning
sandboxThe sandbox/mode value this preset writes
approvalThe approval/policy value this preset writes
nameDisplay label a client shows; the table key when omitted
descriptionOne user-facing sentence; omitted when not configured

The default table in the plugin source has only two entries: workspace-write (workspace-write + ask) and danger-full-access (danger-full-access + never). The shipped base bundle overrides it with three at packages/bundle/base/cordis.patch.yml:229, adding read-only (read-only + ask) — that is the table you see in dsh web --dump-config. custom is a reserved name: a table entry named custom throws at plugin load, because it exists only for the derived "matches no preset" state.

The write-through path (set()apply(), permission-presets/src/index.ts:379, :384):

  1. resolve(name) looks up the entry; an unknown name throws;
  2. if the effective preset differs from the target name, append permission/preset (log-only user intent);
  3. compare each knob's effective value and call only the changed one's canonical setter — setSandboxMode (from dsh-sandbox-policy) or setApprovalPolicy (from dsh-user-approval). A net-zero selection writes nothing.

current(session) derives in this order (permission-presets/src/index.ts:308): the last recorded selection while it still matches the knobs (so user intent survives when two presets share a bundle) → the first matching entry in table order → otherwise the derived CUSTOM_PRESET ('custom', display-only, never a switch target).

The read side is the permissions session projection (permission-presets/src/index.ts:237): key permissions, stateVersion: 2, folding permission/preset / sandbox/mode / approval/policy / session/end-seed, with a PermissionSelect wire view (every switchable preset in table order, custom appended exactly while current, plus currentValue). A missing projection key means no permission service is composed and clients hide the control.

Defaults and session pinning: the permission settings namespace's defaultPreset affects future sessions only; session/created and every existing session run pinInitialPermission() — a genuinely fresh session gains the default preset plus both knob facts, while a seeded or partially initialized session keeps its effective knob values and gains only the missing durable facts.

The /permission command is the one write path a web client uses (permission-presets/src/index.ts:256; the service-level write path is set()): a bare invocation reports the current value and the available table, and an argument calls apply(..., policy => ctx.approval.setPolicy(agent, policy)). Note the difference — calling setApprovalPolicy() directly only writes the log, whereas /permission goes through setPolicy(), so it also injects the policy-change notice for the model.

How a preset resolves into approval behavior: the preset's approval knob ultimately lands as an approval/policy event; ApprovalService folds it into the effective policy; never short-circuits before answerer dispatch and ask enters the waterfall. There is no second channel between presets and approval.

Misconfiguration fails at load time (permission-presets/src/index.ts:192):

permission: "custom" is reserved for the derived not-a-preset state and cannot name a table entry
permission: the mounted bash executor does not confine (no sandboxMode) — presets bundle a sandbox mode, so composing this plugin over an unconfined executor is a misconfiguration
permission: composed sandbox and approval defaults match no preset; configure defaultPreset explicitly
permission: unknown preset "<name>" (known: <names>)
permission: permissions session projection is not registered

The service declares static inject = ['shell', 'approval', 'sessions', 'sessionProjections'] (permission-presets/src/index.ts:183): without a confining ctx.shell executor there is no sandboxMode fact to bundle, so composition fails instead of degrading silently.

7. ctx.commands: the plugin command registry contract

CommandRuntime (service name 'commands', commands/src/index.ts:258) is the plugin-owned human command registry consumed by interactive UI adapters. The usage-level tutorial is Custom Commands and User Interaction; this section covers only the registry contract.

Registration contract

export interface CommandDefinition {
readonly name: string // lowercase, ^[a-z][a-z0-9_-]*$
readonly description: string // discovery-UI summary, non-empty
readonly input?: CommandInputDescriptor // { hint: string; attachments?: boolean }
readonly recordInput?: boolean // defaults to true
readonly handler: (invocation: CommandInvocation) => CommandResult | Promise<CommandResult>
}

register(definition) (commands/src/index.ts:280) validates and then freezes a detached copy (normalizeDefinition, :176): the name must match /^[a-z][a-z0-9_-]*$/u, description must be a non-empty string, handler must be a function, input.hint must be a non-empty string, and input.attachments, when present, must be a boolean. It returns the exact disposer that unregisters the definition; a duplicate name in the same layer throws at registration.

Ownership and scope

  • A registration on a plain context is global; a command-producing plugin mounted under an agent context that declares its commands injection registers an agent-scoped command that shadows a same-named global for that agent only.
  • Layers merge through ScopedLayers: list(agent) returns handler-free CommandDescriptors sorted by name, and find(agent, name) returns the effective definition after scoped shadowing.
  • Registration and removal emit commands/change (:87, emit mode). Observer failures are contained one by one: they can neither veto the registry mutation nor starve later observers.

Addressing and arguments

  • CommandId is a brand (commands/src/brand.ts:20); each execution mints cmd-<instanceToken>-<seq>, and the instance-token prefix keeps ids unique across a restart over one resumed log.
  • parseCommand(line) (commands/src/index.ts:122) splits with /^\/([a-z][a-z0-9_-]*)(?=$|[\t\n\r ])/u: everything after the name — separator whitespace included — becomes the verbatim rawInput, and the command owns its own grammar.
  • Attachments: only a command declaring input.attachments: true accepts them; admission happens inside execute() — images go through admitEncodedImages, files are resolved from their receipt by the single registerFileReceiptResolver provider, and the original mixed order is restored. Anything non-conforming (undeclared, no attachment store, unknown receipt, over limit) settles as an error before the handler runs.

Invocation path and lifecycle

An adapter calls the Host-side execute(agent, line, attachments, signal) (commands/src/index.ts:356):

  1. parseCommand or name resolution fails → return undefined and log nothing (it never entered a handler);
  2. mint a commandId and append command/run before the handler (args is omitted when recordInput: false);
  3. admit attachments;
  4. call the handler and validate its return shape with normalizeResult;
  5. append command/done and return { commandId, result }.

Both lifecycle events are standalone, log-only appends: no turn wraps them, and persistence drains them at ordinary checkpoints; a thrown or aborted handler settles as kind: 'error'. A command/run append failure fails the execution loud; a command/done append failure on the handler-failure path is contained so the handler's own error stays the reported failure.

CommandResult has exactly two shapes — { kind: 'success', text?, sourceEventSeq? } and { kind: 'error', text } — rendered directly by the dispatching UI and never entering model history; sourceEventSeq (success only) names an earlier authoritative domain event so a client can combine the command lifecycle with that projection without parsing text.

The browser-half Remote facade addresses by sessionId (packages/client/ui-commands/src/client/service.ts:365), while the Host-side method's first parameter is agent.

Registration and result validation errors are all TypeErrors (commands/src/index.ts:176, :223):

command name "<name>" must match /^[a-z][a-z0-9_-]*$/u
command "<name>" description must be a string
command "<name>" description must not be empty
command "<name>" handler must be a function
command "<name>" input hint must be a string
command "<name>" input hint must not be empty
command "<name>" input attachments flag must be a boolean
command "<name>" handler must return a CommandResult
command "<name>" success text must be a string when supplied
command "<name>" success sourceEventSeq must be a non-negative safe integer when supplied
command "<name>" error text must be a non-empty string
command "<name>" returned unknown result kind "<kind>"

Also: a second registerFileReceiptResolver throws Error: commands: a file receipt resolver is already registered, and an unknown file receipt throws AttachmentError('File upload receipt is unknown for this session.', 'ATTACHMENT_NOT_FOUND'). The command invariant (commands/src/invariant.ts) requires every command/done to pair a prior command/run in the same session log and every sourceEventSeq to point at an earlier non-command event.

Registrants visible in the source: /permission (permission-presets/src/index.ts:256), /plan (packages/plan/plan-mode/src/index.ts:225), /compact (packages/compaction/command-compact/src/index.ts:100), /feedback (packages/feedback/command-feedback/src/index.ts:61), /goal (packages/goal/command-goal/src/index.ts:190), and /export (packages/session-query/session-log-export/src/index.ts:78).

Sources

FileSymbol / line
packages/interaction/user-approval/src/index.tsApprovalService :142; request() :207; setPolicy() :176; overrideOf() :244; setApprovalPolicy() :92; effectivePolicy() fold :235; decide() :258; never short-circuit :266; OUTCOMES :48; APPROVAL_POLICIES :63; approval/policy event declaration :33; approval:policy context contribution :155
packages/interaction/user-approval/src/types.tsApprovalRequestId :17; ApprovalOutcome :32; approval/asked :44; approval/decided :55; ApprovalRequestEvent :63; approval/request waterfall :85
packages/interaction/user-approval/src/invariant.tsvalidateApprovalEvent() :28 (turn enclosure, id pairing, closed vocabulary)
packages/interaction/permission-presets/src/index.tsPresetSpec :58; CUSTOM_PRESET :73; PERMISSION_SETTINGS_NAMESPACE :76; Config :143; PermissionPresetService :162; static inject :183; permissions projection registration :237; /permission command :256; current() :308; selectFor() :333; resolve() :350; optionOf() :365; set() :379; apply() :384; pinInitialPermission() :404
packages/interaction/permission-presets/src/types.tsPresetOption :13; PermissionSelect :27; SessionProjectionMap.permissions declaration
packages/interaction/commands/src/index.tsCOMMAND_NAME :31; CommandInvocation :40; CommandDefinition :60; parseCommand() :122; normalizeDefinition() :176; normalizeResult() :223; CommandRuntime :258; register() :280; registerFileReceiptResolver() :294; list() :310; find() :323; execute() :356; command/run append :368; command/done append :375; mintCommandId() :441; notifyChange() :470
packages/interaction/commands/src/types.tsCommandResult :34; CommandExecution :49; CommandDescriptor :57; commands/change :87; command/run :103; command/done :110
packages/interaction/commands/src/brand.tsCommandId :20
packages/interaction/commands/src/invariant.tsrun/done pairing and sourceEventSeq validation
packages/bundle/base/cordis.patch.ymlapproval config :225; three-entry preset table :229
packages/core/tools/src/index.tsserviceAsk() consuming ctx.approval :1696
packages/sandbox/sandbox/src/escalation.tsEscalationOutcome / approveEscalation: escalation retries share the same seam
packages/acp/acp/src/index.tsmachine answerer :155
packages/client/ui-approval/src/client/index.tsbrowser answerer (ctx.remote.$on('approval/request', …))
packages/api/remotes/src/remote-events.tsapproval/request remote forwarding :18
packages/core/scope/src/scoped-events.generated.tsscope key taken from args[0].agent :24

Verification

SRC=~/.dsh/source/official

# 1) Composition tree: all three services are in the web profile (with the base-overridden preset table)
dsh web --dump-config | grep -nE "dsh-user-approval|dsh-permission-presets|dsh-commands"

# 2) Approval: closed outcome set, waterfall declaration, policy write path
grep -n "allowed-once\|approval/request\|setApprovalPolicy" \
$SRC/packages/interaction/user-approval/src/types.ts \
$SRC/packages/interaction/user-approval/src/index.ts

# 3) Presets: reserved custom name, permission/preset event, the two write-through setters
grep -n "CUSTOM_PRESET\|permission/preset\|setSandboxMode\|setApprovalPolicy" \
$SRC/packages/interaction/permission-presets/src/index.ts

# 4) Commands: name regex, parser, lifecycle events
grep -n "COMMAND_NAME\|parseCommand\|command/run\|command/done" \
$SRC/packages/interaction/commands/src/index.ts \
$SRC/packages/interaction/commands/src/types.ts

# 5) Runtime: audit pairs, command lifecycles, and preset switches all land in the session log (read-only)
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd 2>/dev/null \
| grep -E '"approval/(asked|decided|policy)"|"command/(run|done)"|"permission/preset"' | head

On this machine's 0.1.5-alpha.1, command 1 prints the dsh-user-approval, dsh-permission-presets, and dsh-commands plugin entries, plus the read-only / workspace-write / danger-full-access preset table.

Next steps