skip to content
docs / building blocks

route()

Branch on a choice. Every label needs a branch, and the compiler checks.

Basics#

route asks one choice question about its input, then runs the branch for whichever label won. The branch gets the route's input, not the answer: routes decide where data goes, they don't change it. The route's output is whatever the chosen branch outputs, so its type is the union of the branch outputs.

triage.ts
import { route, choice, emit } from "jevchain";

const triage = route("triage", {
  ask: choice("What is this message about?", {
    billing: "money, invoices, refunds",
    bug: "something is broken",
    vibes: "no actionable content, just vibes",
  }),
  branches: {
    billing: toBilling,
    bug: toOnCall,
    vibes: emit("reply with a gif"),
  },
});
// JevNode<string, OutputOf<typeof toBilling> | OutputOf<typeof toOnCall> | string>
route(id, config)
askChoiceQuestion<L>The deciding question. Must be a choice.
branches{ [K in L]: JevNode }One node per label. No more, no fewer.
lowConfidence{ below, then }Take then instead when the answer's confidence is under below (0–1).
alsoAskQuestionsExtra questions in the same call, recorded in the trace.
statestring | (input) => Entrydefault the inputWhat Jev reads. Same as ask.
modelstringdefault client's modelPin this node to a model.
▶ run itFridge verdictdocs chainchains.ts ↗
Three leaves that template the input with {{input.item}}, plus a smell test for when Jev isn't sure. The mystery tub usually takes that path.
chains.ts
const fridge = route("fridge-verdict", {
  title: "What do we do with this leftover?",
  ask: choice("What should happen to this leftover?", {
    eat: "still good, and honestly it'll be better today",
    freeze: "fine now, but won't survive the week",
    bin: "past saving: fuzzy, sour, or of unknown origin",
  }),
  // Jev's confidence is a second axis: under 0.45, don't guess.
  lowConfidence: { below: 0.45, then: emit("Smell it. Report back.", { id: "smell-test" }) },
  branches: {
    eat: emit("Eat the {{input.item}}. Tonight. No notes.", { id: "eat" }),
    freeze: emit("Freeze the {{input.item}}. Future you says thanks.", { id: "freeze" }),
    bin: emit("Bin the {{input.item}}. Do not open the lid first.", { id: "bin" }),
  },
});
route · fridge-verdictrouteWhat do we do with this lef…emit · eatemiteatemit · freezeemitfreezeemit · binemitbinemit · smell-testemitsmell-testeatfreezebinunsure

{"item":"curry","age":"1 day","notes":"covered, smells amazing"}

open in studio →

Exhaustive at compile time#

The labels of the choice become a type, and branches must have exactly those keys. Add a fourth label and forget its branch, and tsc tells you before production does:

triage.ts
✗ tsc
const triage = route("triage", {
  ask: choice("What is this?", ["billing", "bug", "vibes"]),
  branches: {
    billing: toBilling, bug: toOnCall,
  },
});
error TS2322: Type '{ billing: …; bug: …; }' is not assignable to type 'NoExtraKeys<RouteBranches<"billing" | "bug" | "vibes">, …>'.
  Property 'vibes' is missing in type '{ billing: …; bug: …; }' but required in type 'RouteBranches<"billing" | "bug" | "vibes">'.

Extra keys are errors too: a branch for a label the question doesn't offer can never be taken, so it's rejected rather than silently dead.

Low confidence#

A choice always has a winner, even when the distribution is nearly flat. lowConfidence uses the answer's confidence as a second axis: if it's under below, the route takes lowConfidence.then instead of guessing.

front-desk.ts
route("front-desk", {
  ask: choice("Which team should handle this ticket?", ["repair", "billing", "paranormal"]),
  lowConfidence: { below: 0.4, then: emit("A human will read this. Probably Dave.") },
  branches: { repair, billing, paranormal },
});

Either way the trace records what happened. Every label appears as an edge with its probability, taken or not, alongside a templated, human-readable summary:

{
  "kind": "route",
  "question": "decision",
  "taken": "paranormal",
  "edges": [
    { "edge": "repair",        "value": 0.02,  "taken": false },
    { "edge": "billing",       "value": 0.004, "taken": false },
    { "edge": "paranormal",    "value": 0.976, "taken": true  },
    { "edge": "lowConfidence", "value": 0.887, "taken": false }
  ],
  "metric": "probability",
  "value": 0.976,
  "confidence": 0.887,
  "summary": "Went to \"paranormal\" with 98%, a landslide over \"repair\" at 2% (confidence 0.89)."
}

When the fallback wins, the decision is flagged and the summary says what it would have picked:

  • Jev leaned “repair” but only at 0.31 confidence, under the 0.40 bar, so it took the low-confidence path instead of guessing.

In the graph, this edge is labelled unsure. In the decision, its key is lowConfidence and its value is the confidence.

alsoAsk#

Sometimes you're already paying for a call and want to know something else about the same input for later: sentiment, language, whether the customer is joking. alsoAsk adds questions to the route's request. Their answers land in the call recorded in the trace, but they don't influence the branch and aren't part of the output.

front-desk.ts
route("front-desk", {
  ask: choice("Which team should handle this ticket?", ["repair", "billing", "paranormal"]),
  alsoAsk: {
    sarcastic: noul("Is the customer joking or being sarcastic?"),
    angry: noul("Is the customer angry?"),
  },
  branches: { repair, billing, paranormal },
});

Nesting#

A branch is any node: an emit, a step, a gate, another route, a whole chain. That's how multi-step triage is built. Pick the department, then let the department decide. Each nested node records its own span and decision, at a path like $/paranormal/0/then.

▶ run itHaunted Appliance Support Deskgallerysource →
The paranormal branch is a chain holding a safety gate, whose then is a second route. Three decisions deep, and never more than three calls on any one path.
route · front-deskrouteFront deskemit · book-technicianemitbook-technicianemit · forward-billingemitforward-billinggate · anyone-in-dangergateAnyone in danger?route · classify-entityrouteWhat are we dealing with?emit · book-exorcistemitbook-exorcistemit · power-cycleemitpower-cycleemit · close-windowemitclose-windowemit · evacuateemitevacuateemit · ask-daveemitask-daverepairbillingpoltergeistpossessed-firmwarejust-a-draftthenotherwiseparanormalunsure

My toaster whispers my name at 3am and the bread comes out cold.

open in studio →

api key

bring your own typesafe key, or ride the shared one (rate-limited, be nice).

shared key
checking…
your key
not set

your key stays in this browser (localStorage, jevchain.byok). it only travels to this site's /api/jev proxy in an x-typesafe-key header, which forwards it to typesafe and immediately forgets it. nothing is logged or stored server-side. requests on your own key get a much roomier rate limit.

keyboard shortcuts

fewer clicks, more chains. these work anywhere outside a text field.