picoflow.io Docs

SupportFlow tutorial

5. Approval holds and session restoration

A review-required refund is a temporary offer, not a completed transaction. ApprovalStep presents the computed quote, commits only explicit consent, and the flow invalidates a hold that has aged out.

Some return requests are eligible but need the customer to approve an exact amount. SupportFlow treats that as a stateful hold: AdjudicateStep writes a fixed PendingRefund to ApprovalStep; the approval step presents it without editing it; one explicit tool call either commits or abandons it.

The goal

  • Carry a policy-computed quote into a conversational approval step without asking the model to calculate it again.
  • Separate an ambiguous answer from a committed confirmation.
  • Atomically update the case and return-specific state when a refund commits.
  • Use onRestoreSessionDoc() to expire a hold before an old customer can accept it.

The approval prompt contains the exact immutable offer

The destination state is established by the review branch in AdjudicateStep. ApprovalStep.getPrompt() refuses to run without it:

getPrompt(): string {
  const pending = this.getState<PendingRefund>("pending");
  if (!pending) throw new Error("ApprovalStep requires a pending refund.");

  return `${supportRole}\n\n${approvalInstructions
    .replace("", JSON.stringify({
      orderId: pending.request.orderId,
      lineIds: pending.request.lineIds,
      reason: pending.request.reason,
      reasons: pending.reasons,
    }))
    .replace("", GenReceipt.quoteTable(pending.quote))}`;
}

GenReceipt.quoteTable() builds the markdown table from the RefundQuote: each selected line, items subtotal, any restocking fee, any shipping refund, and the net refund to the recorded payment method. It does not receive a model argument. The generated prompt tells the model to present every value exactly as supplied and to ask for an unambiguous yes or no.

The only two actions

defineTool(): ToolType[] {
  return [
    { name: "confirm_refund",
      description: "Commit the exact pending refund after clear customer confirmation.",
      schema: z.object({ confirmed: z.boolean() }) },
    { name: "decline_refund",
      description: "Abandon the pending refund and return to the returns specialist.",
      schema: z.object({ declined: z.boolean() }) },
  ];
}

The booleans do not make model output authoritative. The confirmation handler guards the only irreversible branch:

@Tool
protected async confirm_refund(args: { confirmed: boolean }) {
  if (!args.confirmed) {
    return stay("Only an explicit confirmation commits this refund.");
  }
  const pending = requirePending(this);
  const refund: RefundRecord = {
    rma: generateRma(),
    orderId: pending.request.orderId,
    lineIds: pending.request.lineIds,
    netRefund: pending.quote.netRefund,
    refundTarget: pending.quote.refundTarget,
    authority: "customer_confirmed",
  };
  // persist refund and returned lines, remove the pending hold, then go to triage
}

“Maybe,” a request to change items, and “hold off” are all prompt-directed to the decline path or another clarification; confirmed: false keeps the cursor on approval and supplies corrective tool feedback. A handler never turns a non-confirmation into a refund merely because it reached the tool.

A confirmation updates every affected owner

On success, the handler appends the refund to triage state and the selected lines to returns state before clearing its own temporary state:

const triage = this.flow.getStepState<RefundRecord[]>(TriageStep, "refunds") ?? [];
const returned = this.flow.getStepState<string[]>(ReturnsStep, "returnedLineIds") ?? [];

this.flow.saveStepState(TriageStep, { refunds: [...triage, refund] });
this.flow.saveStepState(ReturnsStep, {
  returnedLineIds: [...returned, ...pending.request.lineIds],
});
this.removeState("pending");
this.saveState({ decidedAt: new Date().toISOString() });
return go(TriageStep);

The case hub owns the refund because it will use it in later turns and in the closing recap. The returns step owns returnedLineIds because it must refuse a second request for the same item. pending is removed so the approval cannot be confirmed twice. decidedAt is an audit marker for this approval step.

Declining performs the symmetric cleanup—remove pending, save decidedAt, and return to ReturnsStep with an empty denial list. The customer can choose a different item without being told that their valid declined offer was a policy denial.

Expiring an old hold at restore time

An approval request is sensitive to time. The flow overrides session restore:

protected async onRestoreSessionDoc(session: SessionType): Promise<SessionType | null> {
  const restored = await super.onRestoreSessionDoc(session);
  if (!restored) return null;

  const idleMs = this.sessionIdleMs(restored);
  if (idleMs >= DEFAULT_IDLE_MS) return null;
  if (restored.flow.currentStep !== ApprovalStep.id ||
      idleMs < DEFAULT_APPROVAL_HOLD_MS) {
    return restored;
  }

  const approval = restored.flow.steps.find((step) => step.name === ApprovalStep.id);
  if (approval) {
    const { pending: _pending, ...released } = approval.state as Record<string, unknown>;
    approval.state = released;
  }
  restored.flow.currentStep = TriageStep.id;
  return restored;
}

The flow-owned idle timeout is 30 minutes. If it has elapsed, returning null tells the engine to discard the session and begin a new case. The approval-specific timeout defaults to 10 minutes. Between ten and thirty minutes, the flow preserves the case but removes the quoted offer and resets the cursor to triage. The customer can begin another request, but cannot accept a stale amount.

Both values are code constants in SupportFlow, alongside the restore policy that uses them. The hook reads the persisted document before the next model turn, which is the correct point to enforce a time-dependent business rule.

Next: 6. Billing disputes and escalation.