picoflow.io Docs

HomeInsuranceQuoteFlow tutorial

7. Exact quote tables and response fallback

Quote options and comparisons are rendered by code. Tool requests use `direct(...)` to skip another model call, while the initial logic-to-step crossing has an explicit deterministic fallback.

Once money exists, presentation must preserve it exactly. QuotePresenter turns the code-owned QuoteResult into Markdown; PresentQuoteStep decides when to return that Markdown directly and when to navigate to another stage.

The goal

  • Render option and comparison tables from typed quote data.
  • Use direct(...) from a tool handler to answer without a second model call.
  • Validate requested option IDs against the current quote.
  • Cover the initial presentation path, which has no user tool call yet.

One deterministic renderer

QuotePresenter.options(result) formats the initial table. It includes the quote ID, annual and monthly amounts, all option fields, the validity date, rules version, and the non-binding disclosure. comparison(options) formats a smaller table from the already calculated options. Both use the same Intl.NumberFormat currency formatter, so no prompt is responsible for punctuation, rounding, or a price calculation.

public static options(result: QuoteResult): string {
  return [
    `### Preliminary home insurance quote ${result.quoteId}`,
    "",
    "| Option | Annual premium | Monthly estimate | Dwelling | Extension | Deductible | Liability | Endorsements |",
    "| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
    ...result.options.map((option) => this.row(option)),
    "",
    `Valid through ${result.validThrough} under demo rules ${result.rulesVersion}. This is a non-binding estimate, not proof of insurance or an offer to bind coverage.`,
  ].join("\\n");
}

Direct responses for user-initiated actions

When the model correctly selects a presentation tool, the tool handler returns the exact text with direct(...):

@Tool
protected async compare_home_quote_options(args: {
  optionIds: string[];
}): Promise<ToolResponseType> {
  const result = this.quoteResult();
  const requested = [...new Set(args.optionIds.map((id) => id.trim().toUpperCase()))];
  const options = requested.flatMap((id) => {
    const option = result.options.find((candidate) => candidate.id === id);
    return option ? [option] : [];
  });
  if (options.length !== requested.length) {
    return stay(`Choose only current option IDs: ${result.options.map((option) => option.id).join(", ")}.`);
  }
  return direct(QuotePresenter.comparison(options));
}

direct(...) is deliberately tool-only: it returns a user response while keeping the current step active, with no second LLM call that could alter dollar figures. The same pattern powers show_home_quote_options.

The initial crossing is different

The first table is requested by RateQuoteStep, a LogicStep, not by a model tool call. PresentQuoteStep.onCrossing() gives that entry a synthetic message asking it to show the exact options. Its onResponse() also handles the explicit state flag as a deterministic fallback:

public override async onResponse(llmResult: string | object) {
  if (this.getState<boolean>("needsPresentation")) {
    this.removeState("needsPresentation");
    return QuotePresenter.options(this.quoteResult());
  }
  return super.onResponse(llmResult);
}

The fallback returns a string, not direct(...), because it is outside a tool handler. It ensures that the first quote table remains code-authored even if the provider replies with prose instead of emitting show_home_quote_options.

The current quote is the only quote

onEnter() compares presentedQuoteId with the latest calculated ID and clears selectedOption when they differ. The selection tool then validates its input against the current result.options. That paired check prevents a stale option ID from becoming a contact request after a deductible re-rate.

const option = result.options.find(
  (candidate) => candidate.id === args.optionId.trim().toUpperCase(),
);
if (!option) {
  return stay(`Choose one current option ID: ${result.options.map((candidate) => candidate.id).join(", ")}.`);
}
this.saveState({ selectedOption: durableJson(option) });
return go(ContactStep);

Next

Return to the track overview or revisit the live replay to connect this response boundary to the complete interaction.