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.

3. Typed decision routing

The goal

Use a decision model to classify a request without letting it invent a route or perform an effect. The graph’s decisionConfig selects TypeSafe’s Jev (provider: "typesafe", model: "jev-latest"), a non-generative model that returns probabilities over declared labels rather than text.

Declare the allowed answers

RouterDecisionNode extends DecisionNode and defines two choice questions. destination selects among dates, budget, room type, amenities, distance, review, search, exit, and unclear. request_delivery decides whether the latest request contains a value the selected collector must apply, or whether that criterion is merely the next unresolved field.

export const ROUTING_QUESTIONS = {
  destination: {
    type: "choice",
    criteria: {
      dates: "Set or revise dates",
      // budget, room_type, amenities, distance, review, search, exit ...
      unclear: "Ambiguous or outside this flow",
    },
  },
  request_delivery: {
    type: "choice",
    criteria: {
      apply_request: "The latest request contains a new value or revision that the selected collector must apply",
      prompt_next: "The selected criterion is merely the next unresolved field",
      none: "The destination is not a criterion collector",
    },
  },
} as const satisfies DecisionQuestionMap;

The decision provider can return only one of those declared labels, and EZGraph rejects any other answer before onDecision() runs. The node’s code maps the answer to go(...), directTo(...), or finish(...); it does not execute a provider-generated function name or route string. getPrompt() supplies the saved criteria and unresolved issues as shared guidance, and getDecisionEvidence() adds the same criteria as JSON evidence.

if (route === "exit") {
  return finish("Thanks for considering Hilton hotels in Portland.");
}
if (route === "search") {
  if (context.request.trim().toLowerCase() !== "search") {
    // Not an explicit search: prompt for the next missing criterion, or show the summary.
    return issues.length
      ? directTo(CriteriaHelper.nextNode(issues[0]!), CriteriaHelper.criteriaPrompt(issues[0]!.field))
      : directTo(RouterDecisionNode, `${CriteriaHelper.renderCriteriaSummary(criteria)}\n\n...`);
  }
  return issues.length
    ? go(CriteriaHelper.nextNode(issues[0]!))
    : go(CriteriaReadinessDecisionNode);
}

A search runs only when the customer literally asks for one. A classification of search alone is not enough to trigger the backend.

Preserve a cross-step request

When a user revises dates while the graph is prompting for amenities, the router selects DateRangeNode. If request_delivery is apply_request, or dates were already answered, it forwards the customer’s actual message:

return go(CriteriaHelper.nextNode(route)).withMessage(new HumanMessage(context.request));

Otherwise it asks that criterion’s question with directTo(...) and waits for the next turn. The destination therefore receives a customer instruction, not a synthetic internal command.

Why it is written this way

Decision selection is bounded; routing and effects remain readable application code. That gives the system flexibility for ordinary language while retaining an auditable answer to “what can this request cause the graph to do?”

Next

Continue to 4. Criteria ownership and corrections.