Skip to content

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.

Node 22.18 or later, so a TypeScript file runs as it is. huncho is ESM, so the project is too.

Terminal window
mkdir first-decision && cd first-decision
npm init -y && npm pkg set type=module
npm i huncho

The built-in providers call TypeSafe Jev. Put a key in .env.local:

Terminal window
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.

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:

Terminal window
node --env-file=.env.local decide.ts
page 0.83 bug

That 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.

  • noul asks for a probability of yes; choice asks for one of a named set with a distribution over the set. Questions and answers covers both and score, the third type.
  • when((a) => a.urgent.p, { enter: 0.8, exit: 0.6 }, "page") is a numeric clause with hysteresis. A ticket enters page at 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; else covers 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 and decision.previous is what it decided last time.

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.65
page -> triage 0.21

The 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.

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"