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, recorded in the trace.
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 |value − bar| < margin, where the bar is min, or max if there's no min.
  • 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, right next to the 0.60 bar, so it took the “unsure” path.

▶ 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, short of the 0.60 bar easily (by 0.48), 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.