skip to content
docs / building blocks

gate()

Continue only when a number clears a bar. Give close calls their own path, or halt.

Basics#

gate asks one question, turns the answer into a single number, and checks it against a bar. Clear it and the input flows on to then. Miss it and the input goes to otherwise, or, if you didn't give one, the run stops.

is-urgent.ts
import { gate, noul, emit } from "jevchain";

const urgent = gate("is-urgent", {
  ask: noul("Is the user blocked right now?"),
  pass: { min: 0.7 },
  then: pageOnCall,
  otherwise: emit("file a ticket"),
});
gate(id, config)
askQuestionA choice, score or noul.
pass{ min?, max?, label? }The bar. Set min, max or both (inclusive). label is required for a choice and not allowed otherwise.
thenJevNodeRuns when the bar is cleared.
otherwiseJevNodedefault haltRuns when it isn't. Omit it to halt the run instead.
unsure{ margin?, minConfidence?, then }A third path for close calls. Checked before pass/fail.
alsoAskQuestionsExtra questions in the same call. Downstream nodes read them as {{answers.<id>.<key>}}. See route.
state / model…As in ask.

Like a route, a gate passes its input through unchanged. Its output type is the union of whichever paths you gave it.

Thresholds per question type#

What gets compared to the bar depends on the question:

noulmetric: noulp(yes), 0–1.
scoremetric: scoreThe probability-weighted level, where 0 is the first level. A 5-level rubric gives 0–4, so bars aren't limited to 0–1.
choicemetric: probabilityp(pass.label). The label is typed against the question's labels.
thresholds.ts
// noul: measures p(yes)
gate("safe", { ask: noul("Is anyone in danger?"), pass: { max: 0.5 }, then: carryOn });

// score: measures the probability-weighted level (0 = first level)
gate("needs-a-meeting", {
  ask: score("How much does this need people live?", ["slack", "email", "doc", "call", "meeting"]),
  pass: { min: 2.5 },
  then: keepIt,
});

// choice: measures p(label). The label is required, and typed.
gate("is-billing", {
  ask: choice("What is this?", ["billing", "bug", "vibes"]),
  pass: { label: "billing", min: 0.8 },
  then: toBilling,
});

// min and max together: a window
gate("goldilocks", { ask: noul("Is the porridge hot?"), pass: { min: 0.4, max: 0.6 }, then: eat });

Every gate decision gets a one-sentence summary in the trace, templated from the numbers:

  • Passed: p(yes) = 0.83, clearing the 0.60 bar comfortably (by 0.23).
  • Blocked: the score came in at 1.40, short of the 2.50 bar easily (by 1.10), so took "otherwise".
  • Passed: p(yes) = 0.04, under the 0.50 ceiling easily (by 0.46).

The unsure band#

A value of 0.61 against a 0.60 bar isn't a pass. It's a shrug with a decimal point. unsuregives close calls their own path, and it's checked before pass/fail. There are two triggers, and either one is enough:

  • margin: unsure when the value is less than margin from the bar's nearest edge. For a single min or max that's just |value − bar| < margin. For a window it's whichever of min and max is closer, on either side of it. Exactly margin away is outside.
  • minConfidence: unsure when the answer's confidence is below it. For choice and score answers that's Jev's confidence. For a noul it's |p − 0.5| × 2, the distance from a coin flip (see confidenceOf).
unsure.ts
gate("dress-code", {
  ask: noul("Is this outfit appropriate for a fancy rooftop bar?"),
  pass: { min: 0.6 },
  then: welcome,
  otherwise: turnAway,
  unsure: { margin: 0.1, then: getManager },    // 0.5 < p(yes) < 0.7
});

gate("vibe-check", {
  ask: choice("Tone?", ["friendly", "hostile", "neutral"]),
  pass: { label: "friendly", min: 0.5 },
  then: reply,
  unsure: { minConfidence: 0.3, then: askAHuman }, // flat distributions go to a human
});

The trace says so plainly: Too close to call: p(yes) = 0.64, 0.04 over the 0.60 bar, inside the 0.10 margin, so it took the "unsure" path.

A window has two edges, so it has two sets of close calls. Give the goldilocks gate above a 0.1 margin and everything strictly between 0.3 and 0.7 is unsure, bar one value: Too close to call: p(yes) = 0.55, 0.05 under the 0.60 ceiling of the 0.40–0.60 window, inside the 0.10 margin, so it took the "unsure" path. Dead centre, 0.5 is exactly 0.10 from both edges, which is outside the margin, so it passes. A window narrower than two margins is mostly shrug.

▶ run itCould This Meeting Be An Email?gallerysource →
A score gate at 2.5 on a 0–4 rubric, with a 0.4 margin. Anything strictly between 2.1 and 2.9 gets a counter-offer instead of a coin flip.
gate · needs-a-meetinggateDoes this need a meeting?emit · keepemitkeepemit · declineemitdeclineemit · counter-offeremitcounter-offerthenotherwiseunsure

{"title":"Weekly sync","description":"Going around the room with status updates.","attendees":14,"minutes":60}

open in studio →

Halting#

Leave out otherwise and a gate becomes a guard: miss the bar and the run stops right there. Halting isn't an error. Nothing threw; the chain decided to stop. run resolves with status "halted", no output, and a trace.halted saying where and why.

halt.ts
const result = await jev.run(bouncer, "Running shorts and one AirPod.");

result.status;        // "halted"
result.output;        // undefined
result.trace.halted;  // {
                      //   path: "$", nodeId: "dress-code",
                      //   summary: "Blocked: p(yes) = 0.12, 0.48 short of the 0.60 bar and clear of its 0.10 unsure margin easily (by 0.38), so the run stopped here."
                      // }

In graphs and traces#

  • graphOf draws a halt vertex hanging off the gate, so the stop is visible before anything runs.
  • The gate's decision has taken: "halt", and its span, plus any enclosing ones, end with status halted. Siblings still running in a parallel are closed too.
  • explainTrace ends with Halted at dress-code. followed by the gate's summary.
▶ run itRooftop bouncerdocs chainchains.ts ↗
No otherwise: the gym fit halts the run. The smart-ish outfit tends to land in the unsure band and get the manager.
chains.ts
const bouncer = gate("dress-code", {
  title: "Rooftop bar dress code",
  ask: noul("Is this outfit appropriate for a fancy rooftop bar?", {
    true: "smart, deliberate, dressed for the occasion",
    false: "gym clothes, pyjamas, or a costume",
  }),
  pass: { min: 0.6 },
  then: emit("Welcome in. The view is on the left.", { id: "welcome" }),
  // Close calls get a human, not a coin flip.
  unsure: { margin: 0.1, then: emit("Wait here. The manager is coming.", { id: "get-manager" }) },
  // No `otherwise`: a failed gate halts the run. Nothing after it executes.
});
gate · dress-codegateRooftop bar dress codeemit · welcomeemitwelcomehalt · dress-codehaltemit · get-manageremitget-managerthenhaltunsure

Navy linen suit, white shirt, loafers, no socks.

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.