Skip to main content
PathDocs

The user-questions Seam and Its Identity Boundary

One-liner: ctx.userQuestions is the seam for "pause a tool call and ask the human a question". Exactly one active UI provider exists per context (a second registration throws DUPLICATE_PROVIDER; with none registered ask() throws NO_PROVIDER). ask() runs validation before reaching the provider; when an agent is supplied it admits only the exact live instance in the AgentRegistry — identity is decided by runtime root ownership, not durable session lineage. To plug in, a plugin/UI developer just implements { ask(request) } and calls registerProvider.

This is a core foundational lesson in the "interaction and advancement" mechanisms. After reading you will know: how the model pauses to wait for a human answer, which error codes correspond to what, why a child owned by another live agent must never ask the human, and how to write your own UI provider.

1. Where the seam sits

@deepseek-ai/dsh-user-questions is the Service Definition package for this seam. It renders no UI itself; it owns:

  • ctx.userQuestions — the UserQuestionService
  • a set of wire-safe types (AskUserQuestionRequest / AskUserQuestionAnswer, …)
  • a stable error taxonomy (UserQuestionError subclass)

The consumer is the model-facing tool @deepseek-ai/dsh-tool-ask-user (ask_user_question); the UI-side implementation is supplied by the host runtime and registered as the single active provider. The loop stays unchanged: a tool call awaits a promise, and the human's answer feeds back into the agent loop as an ordinary tool result.

2. Public API and types

// packages/interaction/user-questions/src/index.ts (excerpt)
export interface UserQuestionProvider {
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
}

// request = a batch of questions + optional (exact live) agent + abort signal
export interface AskUserQuestionRequest {
questions: AskUserQuestionItem[]
agent?: Agent
signal?: AbortSignal
}
APIBehavior
registerProvider(provider)Register the UI-side provider; returns a disposer that unregisters it. At most one active provider per context.
ask(request)Ask the active provider and wait; when an agent is supplied, authenticate it first.

Key types (user-questions/src/types.ts):

  • AskUserQuestionItem: { id, question, detail?, header?, options?, multiSelect?, intent? }. detail is supporting text rendered with the question but kept out of option labels.
  • AskUserQuestionOption: { label, description? } (recommended options go first, with "(Recommended)" appended).
  • AskUserQuestionIntent: { kind: 'plan-review', approve }, a declared presentation intent for UIs that recognise the tag.
  • AskUserQuestionAnswer: { answers: [{ id, selected, custom? }] }. Single-select: custom overrides the selected choice and selected is empty; multi-select: custom may supplement the labels in selected.

Single / multi select and skipped items

From the source README: for a single-select question, custom overrides the selected choice and selected is empty; for a multi-select question, custom may supplement the labels in selected. A UI may preserve a skipped item as { id, selected: [] }, keeping the existing answer shape while retaining the other answers in the batch.

Presentation intent

intent asserts that the question is a known kind of decision, so a UI that recognises the tag may present it accordingly, otherwise it renders the generic option list — presentation only, the protocol never changes, and callers read identical answer fields either way. approve names the label that approves rather than relying on option order. dsh-plan-mode sets plan-review on the exit_plan_mode question.

3. The four groups of ask() validation

ask() validates before touching the provider, throwing UserQuestionError:

OrderTriggerCode
1signal is already abortedASK_ABORTED
2questions.length === 0EMPTY_QUESTIONS
3an agent is supplied but it is not the registry's exact live instanceCALLER_NOT_LIVE
4it is the live instance but owned by another live agent (not a root)DELEGATED_CALLER
5a question's intent assertion breaks (approve names none of its options, or a plan-review has no detail)BAD_INTENT
6no provider is registeredNO_PROVIDER

The head of ask() in the source (user-questions/src/index.ts):

async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
if (request.signal?.aborted) {
throw new UserQuestionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED')
}
if (request.questions.length === 0) {
throw new UserQuestionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS')
}
const agent = request.agent
if (agent !== undefined) {
const agents = this.ctx.get('agents')
if (agents === undefined || agents.get(agent.id) !== agent) {
throw new UserQuestionError(
'human interaction requires the exact live calling agent when an agent is supplied',
'CALLER_NOT_LIVE')
}
if (!agents.roots().includes(agent)) {
throw new UserQuestionError(
'human interaction is unavailable while the calling agent is owned by another live agent; …',
'DELEGATED_CALLER')
}
}

}

4. The identity boundary: CALLER_NOT_LIVE / DELEGATED_CALLER

This is the heart of the lesson. "Who may ask the human" is decided by runtime root ownership, not durable session lineage.

  • CALLER_NOT_LIVE: agents.get(agent.id) !== agent — you are not the exact live instance registered under that id. E.g. a replaced or disposed Agent handle. Identity is stricter than agents.get(id); it also requires === the same instance.
  • DELEGATED_CALLER: you are the live instance in the registry, but !agents.roots().includes(agent) — you are owned by another live agent. An owned child has no human answerer and would block forever, so it is mechanically rejected.

Why runtime root ownership rather than lineage? The ask() JSDoc says it plainly:

/**
* When a caller supplies an agent, human interaction is valid only for the
* exact live runtime root. Runtime ownership, not durable session lineage,
* decides this 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.
*/
  • A session with historical delegation depth may ask after it is resumed as a new runtime root (deep lineage ≠ barred).
  • A live child, even with delegationDepth 0, is rejected as long as it is still owned by another agent.

When a child is rejected, what next? The error message points the way: include the unresolved question or decision in the child's final result, so the parent relays it to the human. This is the "child hands unresolved questions back to parent" convention.

5. BAD_INTENT: two assertions the types can't carry

intent asserts two facts no type can encode:

  1. the approve label must be one of this question's own options — otherwise a UI would put a choice the asker never offered in front of the user.
  2. a plan-review must carry detail (that is "the plan under review") — otherwise a UI would approve something invisible.
// packages/interaction/user-questions/src/index.ts (excerpt)
for (const question of request.questions) {
const intent = question.intent
if (intent === undefined) continue
if (!(question.options ?? []).some(option => option.label === intent.approve)) {
throw new UserQuestionError(
`question ${question.id} declares intent ${intent.kind} whose approve label …`,
'BAD_INTENT')
}
if (question.detail === undefined) {
throw new UserQuestionError(
`question ${question.id} declares intent ${intent.kind} without the detail it reviews`,
'BAD_INTENT')
}
}

The source comment stresses catching the mistake at the asker, where the bug lives, rather than in every UI.

6. registerProvider: the single active provider

// packages/interaction/user-questions/src/index.ts (excerpt)
registerProvider(provider: UserQuestionProvider): () => void {
const dispose = this.ctx.effect(function* (this: UserQuestionService) {
if (this.provider !== undefined) {
throw new UserQuestionError('a user-questions provider is already registered', 'DUPLICATE_PROVIDER')
}
this.provider = provider
yield () => { this.provider = undefined }
}.bind(this), 'userInteraction.registerProvider()')
return () => void dispose()
}
  • At most one active provider per context: a second registration throws DUPLICATE_PROVIDER.
  • It registers through a Cordis ctx.effect: the returned disposer / plugin teardown clears this.provider back to undefined automatically.
  • With none registered, ask() throws NO_PROVIDER rather than degrading — fail closed, not swallowed.

The README's Known Limitations also note there is one provider per context — no routing or fan-out to multiple UIs; and the interaction vocabulary is currently only the question-form shape (selectable options + optional custom text) — file pickers, diff-preview confirmations, and richer shapes have no seam vocabulary yet.

7. How to implement a UI provider

A plugin/UI developer plugs into this seam in three steps:

import { ctx } from '@deepseek-ai/cordis' // has ctx.userQuestions
import type {
UserQuestionProvider,
AskUserQuestionRequest,
AskUserQuestionAnswer,
} from '@deepseek-ai/dsh-user-questions'

// 1) implement { ask(request) }: render the questions, collect answers, resolve
const uiProvider: UserQuestionProvider = {
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
const answers = await myRenderPipeline(request) // your UI/CLI/email… any presentation
return { answers }
},
}

// 2) register as the single active provider (inside apply); get the disposer
const dispose = ctx.userQuestions.registerProvider(uiProvider)

// 3) unregister on teardown
export function apply(host: typeof ctx): void {
const unregister = host.userQuestions.registerProvider(uiProvider)
host.on('dispose', unregister)
}

Things to watch:

  • Register once: a second registration is DUPLICATE_PROVIDER, so confirm no other UI is already registered, or agree who owns registration.
  • Your ask() may await any endpoint — the loop does not care where you render or how you collect answers, as long as you resolve to an AskUserQuestionAnswer.
  • A custom visual / terminal / Web UI, email, or a "headless" auto-responder is just a different implementation of one provider.

8. The model-facing side: tool-ask-user

@deepseek-ai/dsh-tool-ask-user exposes ask_user_question to the model. It implements no provider itself; it only depends on the userQuestions service, and all validation / identity checks live in ctx.userQuestions.ask(). In execute, exec.agent is passed as agent (only when one exists) and exec.signal is the cancellation channel:

// packages/interaction/tool-ask-user/src/index.ts (excerpt)
async execute(args, exec) {
const result = await ctx.userQuestions.ask({
questions: args.questions.map(question => ({ id, question,})),
...exec.agent !== undefined ? { agent: exec.agent } : {},
signal: exec.signal,
})
return { answers: result.answers.map(a => ({ id: a.id, selected: [...a.selected],})) }
}

On success the model gets a compact JSON answer; on failure one of the following (README / index.ts source):

Error: ask_user_question was aborted before the user answered
Error: ask_user_question requires at least one question
Error: human interaction requires the exact live calling agent when an agent is supplied
Error: human interaction is unavailable while the calling agent is owned by another live
agent; include the unresolved question or decision in the child agent's final result
Error: no user-questions provider is registered
Error: <message>

9. Verify

# Composition tree: tool-ask-user / user-questions both loaded
dsh web --dump-config | grep -iE "ask-user|user-questions" | head

# Session log: ask_user_question calls and results
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep -E '"tool/call"|ask_user_question' | head

# From an "owned child" agent, call ask_user_question and observe DELEGATED_CALLER
zstdcat ~/.dsh/sessions/*/*/session.jsonl.zstd | grep -E 'DELEGATED_CALLER|CALLER_NOT_LIVE' | head

To actually see a question in a UI, start the Web UI and have the model call ask_user_question in a session (e.g. "I need your confirmation before continuing"), and watch the host-supplied provider render the question and feed the answer back.

Next steps