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.
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>| name | type | default | what it does |
|---|---|---|---|
| ask | ChoiceQuestion<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). | |
| alsoAsk | Questions | Extra questions in the same call, recorded in the trace. | |
| state | string | (input) => Entry | default the input | What Jev reads. Same as ask. |
| model | string | default client's model | Pin this node to a model. |
{{input.item}}, plus a smell test for when Jev isn't sure. The mystery tub usually takes that path.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" }),
},
});{"item":"curry","age":"1 day","notes":"covered, smells amazing"}
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:
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.
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.
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.
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.My toaster whispers my name at 3am and the bread comes out cold.