Getting started
huncho asks a decision model typed questions and turns the answers into an outcome through a policy you write in code. This page goes from an empty directory to a decision you can run, hold and replay.
Install
Section titled “Install”Node 22.18 or later, so a TypeScript file runs as it is. huncho is ESM, so the project is too.
mkdir first-decision && cd first-decisionnpm init -y && npm pkg set type=modulenpm i hunchoThe built-in providers call TypeSafe Jev. Put a key in .env.local:
TYPESAFE_API_KEY=...jev() reads the variable the first time it is called; nothing is read at import. OpenRouter and Vercel AI Gateway have their own entries and variables; Providers has all three.
First decision
Section titled “First decision”Save this as decide.ts:
import { choice, huncho, noul } from "huncho";import { jev } from "huncho/jev";
const route = huncho("support.route", { model: jev() }) .ask({ urgent: noul("Does this need a human within the hour?"), topic: choice("What is it about?", ["billing", "bug", "other"]), }) .when((a) => a.urgent.p, { enter: 0.8, exit: 0.6 }, "page") .when((a) => a.topic.is("billing", 0.7), "billing") .else("triage");
const decision = await route.decide("Checkout is down. Every customer gets a 500 at payment.", { key: "T-1041",});
console.log(decision.outcome, decision.answers.urgent.p.toFixed(2), decision.answers.topic.choice);Run it:
node --env-file=.env.local decide.tspage 0.83 bugThat is a decision: a named huncho, two typed questions, a policy of three clauses, and an outcome the code around it can switch on. decision.outcome is typed "page" | "billing" | "triage", and decision.answers.topic.p("refund") is a compile error because refund was never offered.
What each line did
Section titled “What each line did”noulasks for a probability of yes;choiceasks for one of a named set with a distribution over the set. Questions and answers covers both andscore, the third type.when((a) => a.urgent.p, { enter: 0.8, exit: 0.6 }, "page")is a numeric clause with hysteresis. A ticket enterspageat 0.8 and stays there until urgency drops below 0.6, so a value that wobbles around one threshold does not flip the outcome on every update. Clauses are checked in order; the first active one wins;elsecovers the rest. Policy has the semantics.key: "T-1041"names the thing the decision is about. The hold is per key: decide the same key again anddecision.previousis what it decided last time.
Hold it, record it, replay it
Section titled “Hold it, record it, replay it”Decide the same ticket twice more as it calms down and watch page hold through the dip:
for (const update of [ "Payments are still failing for about half our customers, but the rate is dropping.", "Checkout is back for everyone. Filing this so the incident is on record.",]) { const next = await route.decide(update, { key: "T-1041" }); console.log(next.previous, "->", next.outcome, next.answers.urgent.p.toFixed(2));}page -> page 0.65page -> triage 0.21The second update is below enter and above exit, so page holds. The third is below exit, so the hold ends and the clauses are checked afresh; nothing enters, and else gives triage. next.via says which it was, hold for the second and else for the third, and the journal record carries the same field, so a held outcome can be told from a fresh one without recomputing the policy.
Give the huncho a journal and every decision is written as a record you can replay against a changed policy with no model call:
import { huncho, replay } from "huncho";import { fileJournal, readJournal } from "huncho/node";
const journaled = huncho("support.route", { model: jev(), journal: fileJournal("decisions.jsonl") }) .ask({ urgent: noul("Does this need a human within the hour?") }) .when((a) => a.urgent.p, { enter: 0.8, exit: 0.6 }, "page") .else("triage");
// ...decide a batch, then:const stricter = journaled.with({ page: { enter: 0.9, exit: 0.7 } });const { changed, outcomes } = replay(await readJournal("decisions.jsonl"), stricter);Journal and replay is the record contract and what replay does with it; Calibration is how to find out whether the probabilities meant anything once you know what really happened.
Without a key
Section titled “Without a key”huncho/testing exports scriptedModel, a model that answers from a script, so the code around a decision is testable with no network and no key:
import { huncho, noul } from "huncho";import { scriptedModel } from "huncho/testing";
const { model } = scriptedModel([{ answers: { urgent: { type: "noul", noul: 0.9 } } }]);const route = huncho("support.route", { model }) .ask({ urgent: noul("Does this need a human within the hour?") }) .when((a) => a.urgent.p, { enter: 0.8, exit: 0.6 }, "page") .else("triage");
(await route.decide("anything")).outcome; // "page"Where next
Section titled “Where next”- The cookbook runs the routing decision above through three updates of one ticket, gates an agent’s tool calls, reranks passages with a
scorequestion, replays a journal after a threshold change, and labels decisions and calibrates against what happened. - Closing the loop goes from these decisions to labels, calibration and a sweep that reads the thresholds from the journal.
- Nested decisions hang one huncho under an outcome of another.
- Observability sees every decision as it happens: a hook, or one OpenTelemetry span per
decide.