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. The branch and later nodes can read the answers. See alsoAsk.
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,
  "lowConfidence": { "below": 0.4 },
  "summary": "Went to \"paranormal\" with 98%, a landslide over \"repair\" at 2% (confidence 0.89, 0.49 over the 0.40 low-confidence bar)."
}

The bar is recorded as lowConfidence.below whether or not it fired, so the summary can say how close the call was. 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. They don't influence the branch and they aren't part of the route's output, but they aren't lost either: the moment the call returns, every answer is filed under the route's id. The branch it picks, and anything after it, reads them as {{answers.front-desk.angry}} in a template or ctx.answers["front-desk"] in a step.

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: {
    // A template reads them by route id and key…
    repair: emit("Repair ticket. p(angry) = {{answers.front-desk.angry.noul}}"),
    // …and so does a step, where each one is a typed Answer.
    billing: step("apologise", (ticket: string, ctx) => {
      const angry = ctx.answers["front-desk"]?.angry;
      return angry?.type === "noul" && angry.noul > 0.5 ? `Sorry! ${ticket}` : ticket;
    }),
    paranormal,
  },
});

The route's own answer is filed there too, under decision, so a branch can check how sure Jev was about sending it there: {{answers.front-desk.decision.confidence}}. A template that reads a key the route never asks, or a route that can't have answered yet, is rejected before the run.

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.