No EZGraph key required. Free production use. No runtime fee. Bring your own model provider. Support Jev. Run in your own infrastructure. Optional support is available.

6. Catalog lookup and disambiguation

A car insurance quote is only as good as its vehicle. “A 2019 Camry” is not a rating input; a specific catalog record with a risk group and a price is. VehicleNode makes that distinction explicit with two tools. One finds the exact vehicle; the other records how it is used, and it refuses to run until the first has succeeded.

The goal

  • Look up a record before capturing facts that depend on it.
  • Return several candidates to the model instead of choosing one.
  • Keep a lookup result in node state between turns.
  • Make a second tool check that the first one really ran.

The catalog

backend/vehicle-catalog.ts loads data/vehicles.json once and imports nothing from EZGraph:

export type CatalogVehicle = {
  id: string;          // e.g. "2019-toyota-camry-se"
  year: number;
  make: string;
  model: string;
  trim: string;
  bodyStyle: string;
  riskGroup: number;   // 1 (lowest premium impact) to 5 (highest)
  msrp: number;
};

export class VehicleCatalog {
  static search(criteria: { year: number; make: string; model: string; trim?: string }): CatalogVehicle[];
  static fetch(id: string): CatalogVehicle | undefined;
  static yearsFor(make: string, model: string): number[];
  static summarize(): string;
}

search() compares make, model, and trim after trimming and lower-casing, so “toyota camry” and “Toyota Camry” match. The demo catalog has twelve vehicles. The 2019 Toyota Camry appears three times, as the LE, SE, and XSE trims, which is what produces the replay’s disambiguation turn.

Two tools, in order

{
  name: "resolve_vehicle",
  description: "Look the vehicle up in the ratable-vehicle catalog by year, make, and model.",
  schema: z.object({
    year: z.number().int().min(1990).max(2035),
    make: z.string().min(1),
    model: z.string().min(1),
    trim: z.string().min(1).optional(),
  }),
},
{
  name: "capture_vehicle_use",
  description: "Capture ownership, mileage, and parking for the resolved vehicle.",
  schema: z.object({
    vehicleId: z.string().min(1),
    ownership: z.enum(["own", "finance", "lease"]),
    annualMileage: z.number().int().min(1000).max(60000),
    parking: z.enum(["garage", "driveway", "street"]),
  }),
},

vehicle.md gives the order: ask for year, make, and model; call resolve_vehicle as soon as all three are known, including trim only if the customer volunteered it; then collect ownership, mileage, and parking; then call capture_vehicle_use with the resolved ID.

Resolving: every outcome is a stay()

@Tool("resolve_vehicle")
async resolveVehicle(input: ResolveVehicleInput): Promise<ToolResponse> {
  const matches = VehicleCatalog.search(input);
  if (matches.length === 0) {
    const years = VehicleCatalog.yearsFor(input.make, input.model);
    if (years.length > 0) {
      return reject(
        `No ${input.year} ${input.make} ${input.model} is in the catalog; supported years for that model: ${years.join(", ")}.`,
      );
    }
    return reject(`That vehicle cannot be rated. Supported vehicles: ${VehicleCatalog.summarize()}.`);
  }
  if (matches.length > 1) {
    return stay(JSON.stringify({
      accepted: false,
      needsTrim: true,
      candidates: matches.map((candidate) => ({ vehicleId: candidate.id, trim: candidate.trim })),
      error: "Several trims match; ask the customer which one.",
    }));
  }
  const vehicle = matches[0]!;
  this.saveState({ resolvedVehicleId: vehicle.id });
  return stay(JSON.stringify({ accepted: true, vehicle }));
}
Outcome State What the model gets
no match, but the model exists in other years unchanged the supported years
no match at all unchanged the list of every supported vehicle
several trims unchanged needsTrim: true and the candidate IDs and trims
exactly one resolvedVehicleId saved accepted: true and the full record

Resolving never leaves the node. Even success returns stay(), because the stage is not finished: the model still has to ask how the car is used. The tool result is written for the model’s next step. An unsupported vehicle comes back with the alternatives, so the model can offer them, as vehicle.md step 3 instructs.

The trim case is the important one. The handler could pick the cheapest or the first trim and move on, and most customers would never notice. Instead it returns the candidates and saves nothing, and the model asks. In the replay that is turn 4, “Which one is yours: LE, SE, or XSE?”, and turn 5 resolves the SE.

Capturing use: prove the lookup happened

@Tool("capture_vehicle_use")
async captureVehicleUse(input: CaptureVehicleUseInput): Promise<ToolResponse> {
  const local = this.getState() as QuoteGraphNodeState<"VehicleNode">;
  if (input.vehicleId !== local.resolvedVehicleId) {
    return reject("Resolve the vehicle with resolve_vehicle before capturing its use.");
  }
  const vehicle = {
    vehicleId: input.vehicleId,
    ownership: input.ownership,
    annualMileage: input.annualMileage,
    parking: input.parking,
  };
  this.saveState({ resolvedVehicleId: vehicle.vehicleId, vehicle });
  return go(HistoryNode).withMessage(new HumanMessage("Collect the driving and insurance history."));
}

The model supplies a vehicleId, but the handler accepts only the one resolve_vehicle saved. A plausible ID the model made up, or a trim the customer mentioned but never confirmed, is refused. Because resolvedVehicleId is in node state, the check works across turns: in the replay, the vehicle was resolved on turn 5 and its use captured on turn 7.

The prompt knows what has been resolved

VehicleNode.getPrompt() fills {{RESOLVED_VEHICLE}} with the resolved catalog record, or null:

const local = this.state(state) as QuoteGraphNodeState<"VehicleNode">;
const resolved = local.resolvedVehicleId ? VehicleCatalog.fetch(local.resolvedVehicleId) : undefined;

On a later turn, the model can see that the vehicle is already settled, and ask only about use. The same method adds the one-time acknowledgement of the saved driver described in lesson 4.

Why it is written this way

The graph separates “which thing is this?” from “what does the customer tell us about it?”. The first is a lookup with a deterministic answer or a set of candidates. The second is conversation. Tying the second tool to the first tool’s saved result means a rating input can only ever be a catalog record the application resolved, never a vehicle the model described convincingly.

Common mistakes

  • Picking one of several matches silently. Return the candidates and let the customer choose.
  • Ending the stage on a successful lookup. Return stay() when there is more to collect.
  • Trusting an ID the model supplies. Compare it with the ID your lookup saved.
  • Keeping lookup results only in the conversation. Save them in node state so later turns and tools can check them.
  • Unhelpful “not found” results. Return what is supported, so the model can offer it.

Next

Continue to 7. Time, sessions, and history spaces.