NOETIC
Framework
Operators

routeSwitch

Route to one of several steps by a decision's answers.

Quick Example

import { callModel, decide, routeSwitch } from '@noetic-tools/core';

const triage = decide({
  id: 'triage',
  model: 'typesafe/jev-1.14',
  questions: {
    destination: {
      type: 'choice',
      instructions: 'Which team should handle this ticket?',
      choices: ['billing', 'technical-support', 'sales', 'spam'],
    },
  },
});

const reply = (id: string, instructions: string) =>
  callModel({
    id,
    model: 'openai/gpt-4o-mini',
    instructions,
  });

const handleTicket = routeSwitch({
  id: 'route-ticket',
  decide: triage,
  on: 'destination',
  cases: {
    billing: reply('billing', 'Resolve the billing question.'),
    'technical-support': reply('support', 'Debug the reported problem.'),
    sales: reply('sales', 'Answer the pricing question.'),
    spam: reply('spam', 'Reply with a short, polite decline.'),
  },
  minConfidence: 0.6,
  uncertain: reply('ask-human', 'Ask a human to triage this ticket.'),
});

What It Does

routeSwitch runs a decide step on its input, then runs the case its answers pick. The chosen case receives the original input, not the answers, and its output is the switch's output.

routeSwitch is a conditional built from a decision, not a new step kind. It runs the decide step through the run argument every conditional route receives, so the decision gets its own span and its onDecision notification, just as if it ran on its own. The decide step and every case are listed in _optimizable, so tree walks (tool collection, GEPA) see every child. The switch can also rebuild itself around new children, so when GEPA swaps a case or the decide step, the swapped step is the one that runs.

Two switches over the same decide step, one nested in the other, call the model once: the inner one reuses the outer one's answers (see Asking again).

There are two forms: route by one question (key form), or combine answers in a function (function form).

Key Form

Set on to a question id. cases must have exactly one step per outcome of that question, and uncertain is required:

Question typeCase keys
choiceeach choice
boolean'true' and 'false'
scoreeach level label (routed by level, the most probable level)
import { decide, routeSwitch, runCode } from '@noetic-tools/core';

const check = decide({
  id: 'check',
  model: 'typesafe/jev-1.14',
  questions: {
    isUrgent: {
      type: 'boolean',
      instructions: 'Is the customer reporting downtime?',
    },
    urgency: {
      type: 'score',
      instructions: 'How urgent is this ticket?',
      levels: ['low', 'medium', 'high'],
    },
  },
});

const page = runCode({ id: 'page', execute: async (input: string) => `paged: ${input}` });
const queue = runCode({ id: 'queue', execute: async (input: string) => `queued: ${input}` });
const triageByHand = runCode({ id: 'by-hand', execute: async (input: string) => `needs a human: ${input}` });

const byBoolean = routeSwitch({
  id: 'by-boolean',
  decide: check,
  on: 'isUrgent',
  cases: { true: page, false: queue },
  minConfidence: 0.8,
  uncertain: triageByHand,
});

const byLevel = routeSwitch({
  id: 'by-level',
  decide: check,
  on: 'urgency',
  cases: { low: queue, medium: queue, high: page },
  uncertain: triageByHand,
});

A missing case, a misspelled extra case, or an on that names no question is a type error.

Uncertain answers

The key form takes uncertain when:

  • the routed answer is a refusal, or
  • minConfidence is set and the answer's confidence is below it.

Confidence is the provider's confidence for choice and score answers. A boolean answer has none, so its confidence is the probability of the side value picked: probability for yes, 1 - probability for no. That respects the question's threshold: with threshold: 0.7 and probability: 0.6, value is false and its confidence is 0.4.

Because uncertain is required, every outcome runs a typed step. The switch never passes its input through as if it were a case's output.

Output Type

The switch's output type is the union of every case's output, plus uncertain (and default in the function form). Cases may return different types:

import { decide, routeSwitch, runCode } from '@noetic-tools/core';

const check = decide({
  id: 'check',
  model: 'typesafe/jev-1.14',
  questions: {
    isUrgent: {
      type: 'boolean',
      instructions: 'Is the customer reporting downtime?',
    },
  },
});

const page = runCode({ id: 'page', execute: async (input: string) => ({ paged: input }) });
const queue = runCode({ id: 'queue', execute: async (input: string) => input.length });
const triageByHand = runCode({ id: 'by-hand', execute: async (input: string) => `needs a human: ${input}` });

// StepConditional<..., string, { paged: string } | number | string>
const handle = routeSwitch({
  id: 'handle',
  decide: check,
  on: 'isUrgent',
  cases: { true: page, false: queue },
  uncertain: triageByHand,
});

StepInput<S> and StepOutput<S>, exported from @noetic-tools/core, pull a step's input and output types out of its type.

Function Form

Pass a function as on to combine several answers. It returns a case key; default is required and catches any key with no case.

import { decide, routeSwitch, runCode } from '@noetic-tools/core';

const check = decide({
  id: 'check',
  model: 'typesafe/jev-1.14',
  questions: {
    isUrgent: {
      type: 'boolean',
      instructions: 'Is the customer reporting downtime?',
    },
    urgency: {
      type: 'score',
      instructions: 'How urgent is this ticket?',
      levels: ['low', 'medium', 'high'],
    },
  },
});

const pageOnCall = runCode({ id: 'page', execute: async (input: string) => `paged: ${input}` });
const backlog = runCode({ id: 'backlog', execute: async (input: string) => `queued: ${input}` });

const escalate = routeSwitch({
  id: 'escalate',
  decide: check,
  on: (a) => (a.isUrgent.value && a.urgency.value >= 1.5 ? 'page' : 'queue'),
  cases: { page: pageOnCall, queue: backlog },
  default: backlog,
});

The function never sees a refusal. If any answer is refused, the switch takes uncertain, or default when uncertain is unset, and on does not run. So on receives answers typed without Refusal and can read value directly.

API Reference

Key form options

PropertyTypeRequiredDescription
idstringYesUnique step identifier
decideDecideStepYesThe decide step to run. Must come from decide(), which keeps the literal questions
onstringYesId of the question to route by
casesRecord<CaseKey, Step>YesExactly one step per outcome of on, and no other keys
minConfidencenumberNoBelow this confidence, take uncertain. In [0, 1]
uncertainStepYesTaken on refusal or low confidence

Function form options

PropertyTypeRequiredDescription
idstringYesUnique step identifier
decideDecideStepYesThe decide step to run
on(answers) => stringYesPicks a case key from the answers, refusals removed
casesPartial<Record<C, Step>>YesSteps by case key; keys must be ones on can return
defaultStepYesTaken when on returns a key with no case, and on refusal when uncertain is unset
uncertainStepNoTaken when any answer is refused

Build-time errors

routeSwitch() throws NoeticConfigError when you build it:

CodeCondition
EMPTY_STEP_IDempty id
UNKNOWN_ROUTE_QUESTIONkey form: on names no question of decide
INCOMPLETE_ROUTE_CASESkey form: an outcome of on has no case
INVALID_MIN_CONFIDENCEminConfidence outside [0, 1]
MISSING_UNCERTAIN_STEPkey form: no uncertain

Observability

Each run adds a route_switch event to ctx.span with stepId and case: the case taken, uncertain, or default.

  • decide -- the questions and answers a switch routes by.
  • conditional -- the general routing primitive.
  • Semantic Conditions -- semanticSwitch routes by embedding similarity, with no model call.
  • JSON Runtime -- the routeSwitch workflow node.

On this page