picoflow.io Docs

HotelFlow tutorial

7. Answering without an LLM

A comparison table is data, not prose. CompareStep renders one in TypeScript and returns it with direct(), which ends the turn without a second model call and leaves the cursor where it was.

Once the model has decided which hotels and which feature, there is nothing left for it to do. The numbers come from state, the layout is fixed, and asking a language model to format a table is a good way to get a table with a wrong number in it. direct() is the escape hatch: the handler returns finished content, the runner returns it verbatim, and the turn ends.

The goal

  • Return deterministic, pre-rendered content from a tool handler with direct().
  • Understand why the cursor stays on the current step.
  • Assemble a comparison row from three different steps’ state.
  • Know when a direct() response is the right answer and when it is not.

What direct() is

The whole implementation, from the framework:

/**
 * Return a direct AI message while keeping the currently executing Step active.
 * Valid only inside a PicoFlow tool handler.
 */
export function direct(content: string | object): ToolResponseBuilder {
  const step = getToolResponseStepInstance();
  return go(step.getName()).withMessage(new DirectMessage(step, content));
}

Three facts follow from those two lines.

It is go(self). The target is the currently executing step, resolved from an async-local scope. flow.gotoByName short-circuits when the target is already the current step, so no onExit, no onEnter, and no cursor movement. For CompareStep that specifically means eraseMemory() does not fire between comparisons.

It only works inside a tool handler. The step is read from an AsyncLocalStorage scope that the tool runner establishes. Calling direct() from onResponse() or getPrompt() throws.

The content is a marked message. DirectMessage is an AI message carrying additional_kwargs.direct = true, and it stringifies objects:

export class DirectMessage extends AiMessageEx {
  constructor(step: Step, content: string | object) {
    if (typeof content === 'object') {
      super(step, JSON.stringify(content), { direct: true });
    } else {
      super(step, content, { direct: true });
    }
  }
}

The runner looks for that flag after processing tool responses:

//find the Ai Message that is direct.
for (const msg of toolResponseMessages) {
  if (msg.type === 'ai') {
    const direct = msg.additional_kwargs['direct'];
    if (direct) {
      history.push(msg);
      //return, do not make another LLM call
      return MessageUtil.contentToText(msg.content);
    }
  }
}

The message is appended to the step’s history — so the next turn’s model call sees the table it produced — and returned to the caller. No second inference.

Assembling the row

From pico-demo/src/myflow/hotel-flow/compare-step.ts, the handler that produces the table:

@Tool
protected async generate_comparison(
  args: Record<string, any>,
): Promise<ToolResponseType> {
  //perform a hotel search
  let chosenHotels;
  try {
    chosenHotels = JSON.parse(args?.hotels);
  } catch (_e) {
    chosenHotels = args?.hotels;
  }

  this.saveState({ compare_hotel: chosenHotels });

  const feature = args?.feature;

  //find the full hotel doc from DB
  const fetchHotels = (await PricingEngine.fetchHotels(
    chosenHotels,
  )) as object[];

  //merge the price into the chosenHotels JSON
  const hotelAvailable = this.flow.getStepState(
    PresentStep,
    "hotelFound",
  ) as object[];
  ...
}

The tool schema declares hotels: z.array(z.string()), but the handler still tries JSON.parse first and falls back to the raw argument. That is defensive coding against a model that sometimes sends a JSON-encoded array in a field typed as an array — worth knowing about, and worth writing as an explicit normalisation step rather than a bare try/catch in your own code.

A comparison row is then built per hotel, and where the values come from depends on the feature:

if (feature === "amenities") {
  merge(myFeatures, GenChart.flattenObject(doc["amenities"]));
} else if (feature === "roomType") {
  merge(myFeatures, GenChart.transRoomType(doc["roomType"]));
} else if (feature === "distance") {
  merge(myFeatures, { cityCenter: `${doc["cityCenter"]} mi` });
  merge(myFeatures, { airport: `${doc["airport"]} mi` });
} else if (feature === "price") {
  const tree = this.flow.getStepState<{ cDateArray: string[] }>(
    ExploreStep,
    "json",
  );
  const dates = tree["cDateArray"];
  const prices = aHotel["prices"];
  const jObject = GenChart.createJsonObject(dates, prices);
  merge(myFeatures, jObject);
  merge(myFeatures, {
    total: GenChart.formatCurrency(aHotel["total"]),
  });
}

The price branch is the interesting one. It reads three steps in one expression:

Source Value Where it came from
PricingEngine.fetchHotels full catalogue record data/hotels.json
PresentStep.state.hotelFound daily prices, total the original search, four steps ago
ExploreStep.state.json.cDateArray the individual stay dates the model’s accumulated scaffold

cDateArray is the reason Task 2 of explore.md insists — “Important! You must figure out individual days” — on enumerating the dates rather than only capturing a range. Those strings become the table’s column labels, zipped against the price array:

public static createJsonObject(
  dates: string[],
  values: number[],
): { [key: string]: string } {
  const result: { [key: string]: string } = {};

  for (let i = 0; i < dates.length; i++) {
    result[dates[i]] = this.formatCurrency(values[i]);
  }

  return result;
}

Currency formatting is Intl.NumberFormat with en-US and USD, in code — not a prompt instruction to “format as US currency”, which is exactly the sort of thing a model gets right nine times in ten.

Normalising sparse data

hotels.json omits amenities a hotel does not have, so two hotels compared on amenities can have different key sets. GenChart.transAmenities unions the keys and fills the gaps:

public static transAmenities(hotels: object[]) {
  // Step 1: Gather all unique keys from all hotel objects
  const allKeys = new Set<string>();

  hotels.forEach((hotel) => {
    Object.keys(hotel).forEach((key) => allKeys.add(key));
  });

  // Step 2: Transform each hotel object to ensure all keys are present
  return hotels.map((hotel) => {
    const transformedHotel = { ...hotel };

    allKeys.forEach((key) => {
      if (transformedHotel[key] === undefined) {
        transformedHotel[key] = '❌';
      } else {
        transformedHotel[key] =
          transformedHotel[key] === true
            ? '✅'
            : transformedHotel[key] === false
              ? '❌'
              : transformedHotel[key];
      }
    });

    return transformedHotel;
  });
}

Every row now has the same keys, which is a precondition for getChart — it takes the key list from the first object only.

Rendering the table

GenChart.getChart emits GitHub-flavoured Markdown with padded cells:

let table =
  '| ' +
  'Features'.padEnd(columnWidths[0]) +
  ' | ' +
  jsonObjects
    .map((_, index) => `Hotel ${index + 1}`.padEnd(columnWidths[index + 1]))
    .join(' | ') +
  ' |\n';
table +=
  '| ' +
  columnWidths.map((width) => '-'.repeat(width)).join(' | ') +
  ' |\n';

Columns are headed Hotel 1, Hotel 2, and so on, and the first column is Features. Rows are one per key.

Padding is approximate

Value cells are padded with value.padEnd(columnWidths[1]) for every column, not columnWidths[index + 1]. Markdown renderers do not care about cell alignment, so the table displays correctly — but the raw text is not aligned the way the code appears to intend.

The return

this.saveState({ chosen_hotels: finalHotels });

//produce a comparison chart
const table = GenChart.getChart(finalHotels);

// direct(...) returns this table without another model call and keeps CompareStep active.
return direct(`${table}\nAnother comparison or ready to book?`);

Two things happen on the way out. The enriched rows are saved as chosen_hotels, which is the key getPrompt() reads — so the next turn’s prompt renders a non-empty ChosenHotels and compare.md’s State 2 rules let the user say “compare on amenities” without naming hotels again. And the table plus a follow-up question are returned directly.

The follow-up question is part of the string because there is no model call to generate one. Anything a direct() response should say has to be in the content you build.

Why it is written this way

The alternative is to hand the enriched JSON back to the model as tool feedback and let it write the table. That costs a second inference per comparison, adds latency, and puts arithmetic in front of a component that does not do arithmetic. Prices are already computed. Amenity flags are already booleans. There is no judgement left to apply.

The general rule: use direct() when the answer is fully determined by data you already hold. Keep the model in the loop when the answer needs interpretation, tone, or a decision. CompareStep uses the model for exactly the parts that need it — resolving “compare 2, 5 and 8 on price” into hotel names and one feature — and then gets out of the way.

direct() is also the same mechanism InvoiceFlow uses to return raw JSON with an application/json content type, which is covered in InvoiceFlow lesson 5.

Common mistakes

  • Calling direct() outside a tool handler. The async-local scope is not established, and it throws.
  • Expecting onEnter() to run afterwards. The target is the current step, so goto short-circuits and no lifecycle hook fires.
  • Forgetting the follow-up prompt. No model call means no conversational glue unless you write it into the content.
  • Rendering rows with mismatched keys. getChart reads the key list from the first object only; normalise first, as transAmenities does.
  • Using direct() where judgement is needed. It bypasses the model entirely; if the answer depends on how the user asked, keep the model.

Next

8. Present and book closes the loop with the presentation prompt, a generated confirmation number, and the terminal step.