picoflow.io Docs

BasicFlow tutorial

16. @Tools batching

WeatherStep uses a deterministic local fixture to show how a group handler combines matching model tool calls into one operation and one routing decision.

WeatherStep is the entry step in BasicFlow and the track’s @Tools example. It is deliberately not an MCP integration: the hotel-pricing tutorial now owns that realistic service-boundary example. Here, a tiny local fixture keeps the focus on PicoFlow’s group-tool dispatch.

The goal

  • Accumulate partial results and advance only when the required set is complete.
  • Batch matching calls with @Tools([...]).
  • Understand that a group handler shadows individual handlers, with no fallback.

A deterministic backend fixture

city-temperature-service.ts is plain TypeScript. It maps the two aliases the exercise supports to fixed values:

export function getCityTemperatures(cities: string[]): CityTemperature[] {
  return cities.map((city) => ({
    city,
    temperature: temperatureForCity(city),
  }));
}

function temperatureForCity(city: string): number | null {
  const normalized = city.trim().toLowerCase();
  if (normalized === "nyc") return 83;
  if (normalized === "la") return 72;
  return null;
}

The fixture is intentionally local and deterministic. It makes the BasicFlow scenario reproducible while leaving MCP to the HotelFlow tutorial, where the pricing service has real stdio transport, typed request and response contracts, and lifecycle handling.

The model-facing tool stays conversational

The model sees one city per get_weather call, because that maps naturally to the conversation. It does not see a backend-specific batch API:

public defineTool(): ToolType[] {
  return [{
    name: "get_weather",
    description:
      "Look up one supported city. Call once with LA and once with NYC when both are supplied.",
    schema: z.object({
      cityName: z.string().describe("Supported city alias: exactly LA or NYC"),
    }),
  }];
}

The individual handler normalizes aliases, looks up one city, persists its temperature, and remains active until both values exist:

const [weather] = getCityTemperatures([stateCityName]);
if (weather?.temperature !== null && weather?.temperature !== undefined) {
  this.saveState({ [`city_${stateCityName}`]: weather.temperature });

  const LA = this.getState("city_LA");
  const NYC = this.getState("city_NYC");
  return LA !== undefined && NYC !== undefined
    ? go(FooLogicStep)
    : stay(`${stateCityName} was accepted. Ask the user for the remaining city.`);
}

Use explicit undefined checks rather than truthiness: zero is a valid temperature and must not block completion.

The group handler

When the model emits LA and NYC in one response, PicoFlow dispatches the two tool calls together:

@Tools(["get_weather"])
protected async get_weather_batch(
  calls: readonly ToolCall[],
): Promise<ToolResponseType> {
  const cityNames = calls.map((call) => {
    const cityName = call.args?.cityName;
    return typeof cityName === "string"
      ? this.normalizeCityName(cityName)
      : undefined;
  });

  if (
    cityNames.some((cityName) => cityName !== "LA" && cityName !== "NYC") ||
    new Set(cityNames).size !== cityNames.length
  ) {
    return stay("Provide LA and NYC exactly once so their weather can be compared.");
  }

  const weather = getCityTemperatures(cityNames);
  for (const entry of weather) {
    this.saveState({ [`city_${entry.city}`]: entry.temperature });
  }
  return go(FooLogicStep);
}

The handler receives raw ToolCall[], validates the complete set, invokes one backend operation, writes both state values, and returns one route. This is the key purpose of @Tools: one model response can require one coherent, application-level decision.

Names describe types, not counts

@Tools(["get_weather"]) matches any non-empty response whose distinct tool name set is exactly {get_weather}. It can receive one call, two calls, or ten; matching is order-independent and preserves every original call and ID.

@Tools is dispatch metadata only. defineTool() still publishes the schema to the model.

Shadowing has no fallback

A matching group handler wins over the individual @Tool handler, including when there is only one call. If the group handler returns an invalid result, PicoFlow throws; it does not retry through the individual handler. Group handlers therefore must always return a usable stay(...), go(...), or other routing response.

The runtime still emits a tool result for each original model call ID, even though this handler performs one operation and chooses one route.

Test it

The scripted BasicFlow scenario sends LA and NYC in one model response and asserts the durable values on WeatherStep:

BASIC_FLOW_USE_SCRIPTED_MODEL=1 npm run test:basic-flow

Next

17. Sessions, migration, batch mode explains the other meaning of batching: independent child sessions through spawnSteps().