DSH plugin approval gate: DeepSeek Harness ctx.approval

Plugin DevelopmentPublished 2026-10-07Author: DeepSeek Plugin Market
DSH pluginDeepSeek Harnessapproval gatectx.approvalctx.userQuestions
How does a DSH plugin gate permissions and ask users? This guide covers the tools/pre-execute ask decision, ctx.approval, and the ctx.userQuestions waterfall.

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:

ts
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:

  1. 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 in pre-execute.
  2. plan-mode's enforcement stays on a separate sandbox/approval axis — plan review and execution enforcement are kept apart.
  3. 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 — label is the user-facing option text and also the model-facing selected value; description is optional UI help text.
  • AskUserQuestionItem — the caller supplies a stable id, which is echoed back with the answer so batched questions remain routable; optional detail carries supporting text (providers render it with the question but keep it out of option labels); plus question, header, options, multiSelect, and intent.
  • AskUserQuestionRequest — questions is an array so a UI can present related prompts in one flow while preserving a stable id per answer. When present, agent must be the exact live caller, admitted only while the live registry identifies that instance as a runtime root.
  • Answer — providers return one AskUserQuestionAnswerItem per question id: selected holds selected option labels, and custom carries a free-form "Other" answer when the user typed one. For a single-select question, custom overrides the selected choice and selected is empty; for a multi-select question, custom may supplement selected; an item with empty selected and no custom preserves 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:

  1. ASK_ABORTED — the supplied signal is already or becomes aborted.
  2. CALLER_NOT_LIVE — a supplied agent is not the registry's exact live instance.
  3. 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

How does a DSH plugin intercept a call before execution and ask the user to confirm?

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.

What is ctx.userQuestions for in a DSH plugin?

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.

Why does a DSH plugin name the affirmative option with approve instead of relying on order?

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.

Which errors can ask() throw in a DSH plugin?

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.

What constraints apply to the agent supplied when a DSH plugin asks a question?

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

Sources