DSH plugin approval gate: DeepSeek Harness ctx.approval
A DSH plugin's "approval gate" and "user questions" are two different paths: the gate intercepts a call before tool execution with tools/pre-execute and may return ask, while user questions ride the provider-agnostic ctx.userQuestions seam (source). This article covers the gate's ask decision and ctx.approval answer, the ctx.userQuestions ask-waterfall contract and approve semantics, and the error and caller constraints; the gate details of deny and next() pass-through are in plugin hooks, while this article covers the part that needs a human answer.
Returning ask from a DSH plugin: tools/pre-execute and ctx.approval
A hook plugin returns a typed decision from the tools/pre-execute gate to allow or refuse a call; sandbox, permission, and plan-mode plugins can all use this extension point (source). The minimal form checks policy, then allows or refuses:
export function apply(ctx: Context) {
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (!(await isAllowed(exec))) {
return { kind: 'deny', reason: 'Denied by policy.' }
}
return next()
})
}
When a human answer is needed, the path is ask, not deny: in the official architecture map, the "permission system / AskUserQuestion" row is implemented by returning ask from tools/pre-execute and answering through ctx.approval; "ordinary user questions" instead register a separate model-facing ask tool (source). Three boundaries to remember:
- This waterfall is a re-orderable policy layer — when an invariant needs a monotonic final refusal, use
ctx.tools.guard()instead of winning on order inpre-execute. - plan-mode's enforcement stays on a separate sandbox/approval axis — plan review and execution enforcement are kept apart.
- A native hook is just an ordinary Cordis plugin — it runs on the interception point and needs no external protocol.
The DSH plugin user-interaction seam: ctx.userQuestions
ctx.userQuestions is the user-interaction seam of dsh-user-questions, the provider-agnostic vocabulary any DeepSeek Harness plugin uses when it needs a human answer before the agent can continue (source). The shape of one question flow:
AskUserQuestionOption—labelis the user-facing option text and also the model-facing selected value;descriptionis optional UI help text.AskUserQuestionItem— the caller supplies a stableid, which is echoed back with the answer so batched questions remain routable; optionaldetailcarries supporting text (providers render it with the question but keep it out of option labels); plusquestion,header,options,multiSelect, andintent.AskUserQuestionRequest—questionsis an array so a UI can present related prompts in one flow while preserving a stable id per answer. When present,agentmust be the exact live caller, admitted only while the live registry identifies that instance as a runtime root.- Answer — providers return one
AskUserQuestionAnswerItemper question id:selectedholds selected option labels, andcustomcarries a free-form "Other" answer when the user typed one. For a single-select question,customoverrides the selected choice andselectedis empty; for a multi-select question,custommay supplementselected; an item with emptyselectedand nocustompreserves a skipped question in an otherwise completed batch.
ctx.userQuestions.ask(request) does validation plus a scoped answerer waterfall: it asks composed answerers for structured input and awaits the human answer; user-questions/request is a waterfall — return an answer to claim the request or call next() to delegate, and scope-filtered dispatch means agent-scoped listeners receive only that agent's requests.
AskUserQuestionIntent is what separates presentation from protocol: it is tagged on kind (such as plan-review) so intents can be added; a UI that does not recognise a tag renders the generic option list. approve names the affirmative option's label rather than relying on option order, so no UI can infer the verdict from the arrangement. An intent changes presentation only, never the protocol: a UI honouring it answers with the same option labels a generic UI would send, so the caller reads the same answer fields either way. ask() rejects the two assertions no type can carry: an approve naming none of its own question's options, and an intent on a question with no detail.
DSH plugin user-question errors and caller constraints
UserQuestionError extends HarnessError, so ctx.tools.execute() preserves { name, code } for model-facing tool failures such as EMPTY_QUESTIONS, NO_PROVIDER, ASK_ABORTED, or UI-side cancellation (source). ask() itself throws three stable errors:
ASK_ABORTED— the supplied signal is already or becomes aborted.CALLER_NOT_LIVE— a supplied agent is not the registry's exact live instance.DELEGATED_CALLER— that live agent is owned by another agent.
Runtime ownership, not durable session lineage, decides the boundary: an owned child has no human answerer and would block forever, while a lineage-bearing session resumed as a new runtime root may ask normally. This constraint is the reason behind "agent must be the exact live caller" above.
Three self-checks when done: when a human answer is needed, did you go through ask / ctx.approval instead of a hard deny; is the asking agent the exact live runtime root; and is intent used only to change presentation, with approve naming an option of this very question. To find community implementations of approval and question plugins, search the DSH Plugin Hub; for extension-point ownership see the feature-to-mechanism map.
FAQ
A DSH plugin listens on the tools/pre-execute gate and returns a typed decision: return next() to allow, or return { kind: 'deny', reason } to refuse. To ask the user for confirmation, return an ask decision from pre-execute and answer through ctx.approval; sandbox, permission, and plan-mode plugins can all share this extension point. The waterfall is a re-orderable policy layer, so when you need a monotonic final refusal use ctx.tools.guard() instead.
In a DSH plugin, ctx.userQuestions is the user-interaction seam provided by dsh-user-questions for when a tool or permission plugin needs a human answer before the agent can continue, using provider-agnostic vocabulary. It does validation plus a scoped answerer waterfall: a caller uses ctx.userQuestions.ask(request) to ask and await an answer, and agent-scoped listeners compose the available UI surfaces, including listeners that forward to a connected client.
A DSH plugin's AskUserQuestionIntent is tagged on kind (such as plan-review), and its approve field names the affirmative option's label instead of relying on option order, so no UI can infer the verdict from the arrangement. An intent changes presentation only, never the protocol: a UI honouring it answers with the same option labels a generic UI would send, so the caller reads the same answer fields either way.
In a DSH plugin, ctx.userQuestions.ask() throws UserQuestionError code ASK_ABORTED when the supplied signal is already or becomes aborted, CALLER_NOT_LIVE when a supplied agent is not the registry's exact live instance, and DELEGATED_CALLER when that live agent is owned by another agent. Also, UserQuestionError extends HarnessError, so ctx.tools.execute() preserves { name, code } for model-facing tool failures such as EMPTY_QUESTIONS, NO_PROVIDER, ASK_ABORTED, or UI-side cancellation.
In a DSH plugin, when a caller supplies an agent, human interaction is valid only for the exact live runtime root: runtime ownership, not durable session lineage, decides the boundary. An owned child has no human answerer and would block forever, while a lineage-bearing session resumed as a new runtime root may ask normally. The interaction seam admits an agent only while the live registry identifies that instance as a runtime root.
Related Terms
- tools/pre-execute
- tools/pre-execute is the DeepSeek Harness waterfall extension point that intercepts a call before tool execution: listeners return a typed decision (next() to allow, deny to refuse), and the permission system plus AskUserQuestion return an ask decision here answered through ctx.approval; sandbox, permission, and plan-mode plugins share this extension point.— https://deepseek-harness.github.io/deepseek-harness/en/reference/cookbook/extension-cookbook
- AskUserQuestionIntent
- AskUserQuestionIntent is a caller-declared known decision kind, tagged on kind (such as plan-review); approve names the affirmative option's label rather than relying on option order, and an intent changes presentation only, never the protocol; ask() rejects an approve naming none of its own question's options and an intent on a question with no detail.— https://deepseek-harness.github.io/deepseek-harness/en/reference/subsystems/user-questions
- ctx.userQuestions
- ctx.userQuestions, the UserQuestionService, provides validation plus the scoped answerer waterfall: ask(request) asks scoped answerers a structured question and awaits the human answer, and scope-filtered dispatch means agent-scoped listeners receive only that agent's requests.— https://deepseek-harness.github.io/deepseek-harness/en/reference/subsystems/user-questions
- UserQuestionError
- UserQuestionError extends HarnessError and is the stable error taxonomy for user-interaction failures, with codes such as EMPTY_QUESTIONS, NO_PROVIDER, and ASK_ABORTED; ask() also throws CALLER_NOT_LIVE (a supplied agent is not the registry's exact live instance) and DELEGATED_CALLER (that live agent is owned by another agent).— https://deepseek-harness.github.io/deepseek-harness/en/reference/subsystems/user-questions