Human-in-the-Loop — ask() and Pending Asks
The ask() pattern lets an agent pause mid-execution, send a question to a human over a channel, and resume when the human replies. This is useful for confirmation steps, clarification requests, or approval gates.
Contents
- How it works
- ask()
- PendingAsk shape
- getPendingAsk()
- handlePendingAsk()
- evaluateAnswer()
- resolvePendingAsk()
- Constraints
- Full example
How it works
User message arrives
│
▼
invokeAgent()
│
├─ check for pending ask ─────────────────────────────┐
│ (no pending ask) │
│ (pending ask exists)
▼ │
do some work... ▼
│ handlePendingAsk()
▼ │
this.ask('What is your order number?') ├─ evaluateAnswer()
│ │ │
▼ │ (sufficient)
registry.addPendingAsk(...) │ │
this.sendTo(channelName, question) │ ▼
│ │ continue with answer
▼ │
returns { metadata: { waitingForHuman: true } } │ (insufficient + retries left)
│ │
│ ▼
Human replies (new message in same conversation) │ ask again with clarification
│ │
▼ (retries exhausted)
invokeAgent() │
│ ▼
├─ getPendingAsk() ─────────────► skip step with message
│ (pending ask found)
▼
handlePendingAsk(pending, reply, onSufficient)
ask()
ask() is a protected method on BaseAgent. It:
- Creates a
PendingAskrecord in the registry. - Sends the question to the triggering channel via
this.sendTo(). - Returns immediately with
{ metadata: { waitingForHuman: true, askId } }.
The agent is not suspended in a literal async sense — execution continues and the current invocation returns. When the human replies, it arrives as a new message to invokeAgent().
protected async ask(
question: string,
options?: {
context?: Record<string, unknown>; // developer state to persist alongside the ask
maxRetries?: number; // max re-ask attempts (default: 2)
expiresIn?: number; // ms until ask expires (default: never)
},
): Promise<AgentResult>
// Inside invokeAgent():
const result = await this.ask('What is your order number?', {
context: { intent: 'refund', productId: '123' },
maxRetries: 2,
expiresIn: 10 * 60 * 1000, // 10 minutes
});
// result.metadata.waitingForHuman === true
return result;
PendingAsk shape
interface PendingAsk {
id: string; // UUID
conversationId: string; // ties ask to the thread
agentName: string; // agent that created the ask
question: string; // the question sent to the human
context: Record<string, unknown>; // developer-stored state
status: 'pending' | 'answered' | 'expired';
answer?: string; // human's answer (when answered)
retries: number; // current retry count
maxRetries: number;
askedAt: Date;
expiresAt?: Date;
channelName: string; // channel for sending follow-up questions
}
getPendingAsk()
Check whether a conversation has an outstanding ask. Call this at the start of invokeAgent() to detect incoming replies.
protected getPendingAsk(conversationId?: string): PendingAsk | null
async invokeAgent(input: AgentInput): Promise<AgentResult> {
// Check for pending ask first
const pending = this.getPendingAsk(input.conversationId);
if (pending && input.message) {
return this.handlePendingAsk(
pending,
input.message,
(answer) => this.processWithAnswer(answer, pending.context),
);
}
// Normal flow
return this.run(input.message ?? '');
}
handlePendingAsk()
handlePendingAsk() handles the complete retry/resolution lifecycle for a pending ask.
protected async handlePendingAsk(
pending: PendingAsk,
reply: string,
onSufficient: (answer: string) => Promise<AgentResult> | AgentResult,
onInsufficient?: () => Promise<AgentResult> | AgentResult,
): Promise<AgentResult>
What it does:
- Calls
evaluateAnswer(pending.question, reply)to check if the reply is sufficient. - Sufficient — calls
resolvePendingAsk(pending.id, reply)and then callsonSufficient(reply). - Insufficient, retries left — increments retry count, calls
ask()again with a clarification prompt. - Insufficient, retries exhausted — resolves with
'__insufficient__', sends "skipping" message, callsonInsufficient()if provided; otherwise returns{ output: 'Step skipped due to insufficient input.' }.
return this.handlePendingAsk(
pending,
input.message!,
async (answer) => {
// Happy path — process the confirmed order number
const order = await this.lookupOrder(answer);
return { output: `Order ${answer} found: ${order.status}` };
},
async () => {
// Give up gracefully
return { output: 'Unable to process without an order number. Please start over.' };
},
);
evaluateAnswer()
Validates whether a reply sufficiently addresses a question. Used internally by handlePendingAsk().
protected async evaluateAnswer(
question: string,
answer: string,
options?: {
simpleValidation?: (answer: string) => boolean;
},
): Promise<boolean>
- If
simpleValidationis provided, uses it directly (no LLM call). - Otherwise, uses
this.run()to ask the LLM:"Is this answer sufficient? Reply ONLY 'yes' or 'no'.".
For most cases, simpleValidation is preferable to avoid the overhead of an extra LLM call:
await this.evaluateAnswer('What is your order number?', reply, {
simpleValidation: (a) => /^\d{5,10}$/.test(a.trim()),
});
resolvePendingAsk()
Mark a pending ask as answered with the human's reply.
protected async resolvePendingAsk(id: string, answer: string): Promise<void>
Call this when you decide to accept the answer (even if not using handlePendingAsk):
await this.resolvePendingAsk(pending.id, reply);
Constraints
Cannot use ask() from trigger channels
ScheduledChannel and EmailChannel have isTriggerChannel = true. Calling ask() inside a scheduled trigger throws:
AgentError: this.ask() called from a trigger channel (ScheduledChannel).
Trigger channels have no human recipient.
Requires AgentRegistry
ask() uses this._registry to store the pending ask and this._triggeringChannel to route the question. Both are set by AgentRegistry.start(). Calling ask() on a standalone agent (not in a registry) throws:
AgentError: Agent not registered - cannot use ask()
Conversation ID required
ask() requires a conversationId so it can route the human's reply back to the correct pending ask. Messages without a conversationId are rejected before reaching invokeAgent().
Full example
type SupportIntent = 'refund' | 'general';
class SupportAgent extends BaseAgent<SupportIntent> {
name = 'support-agent';
description = 'Customer support with confirmation flow';
mode = 'chat';
systemPrompt = 'You are a helpful customer support agent.';
async invokeAgent(input: AgentInput<SupportIntent>): Promise<AgentResult> {
// 1. Handle replies to pending asks
const pending = this.getPendingAsk(input.conversationId);
if (pending && input.message) {
return this.handlePendingAsk(
pending,
input.message,
async (orderNumber) => {
const refundResult = await this.processRefund(orderNumber, pending.context);
return { output: `Refund for order ${orderNumber} has been processed: ${refundResult}` };
},
async () => ({
output: 'Unable to process the refund without a valid order number.',
}),
);
}
// 2. Route by intent
if (input.intent === 'refund') {
// Ask for confirmation before proceeding
return this.ask('Please provide your order number to process the refund.', {
context: { intent: 'refund', userId: input.participant?.id },
maxRetries: 3,
expiresIn: 15 * 60 * 1000, // 15 minutes
});
}
// 3. General queries
return this.run(input.message ?? '');
}
private async processRefund(orderNumber: string, context: Record<string, unknown>): Promise<string> {
// ... refund logic
return 'approved';
}
}