Probabilistic control flow for TypeScript.
Hunch lets you ask questions about text or application data and use the answers in your TypeScript code.
if (await hunch.almostCertainly("this order is fraudulent", { given: order })) {
await holdOrder(order)
}You can get a probability, choose between options, or rate something on a scale. Questions are written in plain English; answers come back as typed values.
Hunch asks a System One model, such as TypeSafe's Jev, which answers with calibrated probabilities rather than text. It is a port of the hunch Ruby gem.
npm install @carldaws/hunchPoint Hunch at a System One endpoint. Jev is available through OpenRouter:
import { Hunch, SystemOne } from "@carldaws/hunch"
export const hunch = new Hunch({
backend: new SystemOne({
url: "https://openrouter.ai/api/alpha/decisions",
apiKey: process.env.OPENROUTER_API_KEY,
model: "typesafe/jev-1.13",
}),
})chance returns a probability between 0 and 1:
await hunch.chance("written by a real human, not spam", { given: bio }) // 0.87Use a named threshold when you want a boolean:
await hunch.possibly("fraudulent", { given: order }) // chance >= 0.25
await hunch.likely("fraudulent", { given: order }) // chance >= 0.5
await hunch.probably("fraudulent", { given: order }) // chance >= 0.75
await hunch.almostCertainly("fraudulent", { given: order }) // chance >= 0.93
const hunch = new Hunch({ backend, levels: { definitely: 0.99 } })
await hunch.definitely("fraudulent", { given: order }) // chance >= 0.99Change a built-in threshold the same way, for example
new Hunch({ backend, levels: { probably: 0.8 } }).
pick chooses one of the options you provide:
await hunch.pick(["ham", "spam"], { given: email }) // "spam"
await hunch.pick({ urgent: "needs a reply today", routine: "can wait" },
{ given: ticket }) // "routine"rate returns a rating with a position on an ordered scale:
const mood = await hunch.rate(["calm", "frustrated", "livid"], { given: email })
mood.level // "frustrated"
mood.position // 1.4
mood.atLeast("livid") // falseUse pick for categories such as billing, support, and sales. Use rate
for ordered levels such as low, medium, and high.
Both accept an array of names, an object of name: "description" pairs, or
an array mixing the two. Add question if the options need more context.
given supplies the data to evaluate. The answer's type is the union of
your option names.
Use hunch.decide to ask several questions about the same data in one API call:
const result = await hunch.decide({ given: email.raw }, (q) =>
q
.probably("urgent", "does this convey urgency?")
.pick("team", { billing: "payments", technical: "bugs", sales: "pricing" })
.rate("mood", ["calm", "frustrated", "livid"]),
)
result.urgent // true, at or above probably
result.probabilities.urgent // 0.92
result.team // "technical"
result.probabilities.team // { billing: 0.08, technical: 0.85, sales: 0.07 }
result.confidence.team // 0.82
result.mood.level // "livid"The example/ app contains these examples and their tests. Each
one imports the client above from lib/hunch.ts.
Check a display name and bio in a Zod schema:
import { APIError } from "@carldaws/hunch"
import { z } from "zod"
import { hunch } from "./hunch"
const failOpen = (check: Promise<boolean>) =>
check.catch((error) => {
if (error instanceof APIError) return true
throw error
})
export const Signup = z.object({
email: z.email(),
displayName: z
.string()
.optional()
.refine(
(name) =>
!name ||
failOpen(hunch.likely("a plausible human or company name, not an advert or URL", { given: name })),
"doesn't look like a name",
),
bio: z
.string()
.optional()
.refine(
(bio) => !bio || failOpen(hunch.probably("a genuine human bio, not spam or keyword stuffing", { given: bio })),
"reads like spam",
),
})Then use the schema in a server action:
"use server"
import { z } from "zod"
import { db } from "@/lib/db"
import { Signup } from "@/lib/signup"
export type SignupState = { errors?: Partial<Record<"email" | "displayName" | "bio", string[]>>; signedUp?: boolean }
export async function signUp(_state: SignupState, form: FormData): Promise<SignupState> {
const parsed = await Signup.safeParseAsync(Object.fromEntries(form))
if (!parsed.success) return { errors: z.flattenError(parsed.error).fieldErrors }
const { email, displayName, bio } = parsed.data
db.prepare("INSERT INTO signups (email, display_name, bio) VALUES (?, ?, ?)").run(email, displayName ?? null, bio ?? null)
return { signedUp: true }
}These checks treat APIError as a pass, so an API failure adds no
validation error. Other checks, including the email format, still apply.
Route incoming email by its contents:
import { db } from "./db"
import { hunch } from "./hunch"
export async function sortEmail({ subject, text }: { subject: string; text: string }) {
const team = await hunch.pick(
{
support: "questions about using or configuring the product",
billing: "invoices, payments, refunds",
spam: "unsolicited bulk or scam email",
},
{ given: `Subject: ${subject}\n\n${text}` },
)
if (team === "spam") return
db.prepare("INSERT INTO tickets (team, subject, body) VALUES (?, ?, ?)").run(team, subject, text)
return { team, subject }
}app/api/inbound-email/route.ts:
import { sortEmail } from "@/lib/tickets"
export async function POST(request: Request) {
const ticket = await sortEmail(await request.json())
return ticket ? Response.json(ticket, { status: 201 }) : new Response(null, { status: 204 })
}Choose whether to ignore an error, send a notification, or page someone:
import { hunch } from "./hunch"
import { pagerduty } from "./pagerduty"
import { slack } from "./slack"
export async function triage(error: Error, { path, routeType }: { path: string; routeType: string }) {
const verdict = await hunch.rate(
{
ignore: "known noise, expected in normal operation",
notify: "worth a look during working hours",
page: "users are impacted right now",
},
{ given: { name: error.name, message: error.message, path, routeType } },
)
switch (verdict.level) {
case "page":
pagerduty.trigger(error)
break
case "notify":
slack.post(error)
break
}
return verdict.level
}import type { Instrumentation } from "next"
import { triage } from "@/lib/error-triage"
export const onRequestError: Instrumentation.onRequestError = async (error, request, context) => {
await triage(error as Error, { path: request.path, routeType: context.routeType })
}Map imported text to a known status:
export const STATUSES = ["pending", "shipped", "delivered", "cancelled"] as const
export function importStatus(raw: string) {
return hunch.pick(STATUSES, { given: raw, question: "which order status does this text describe?" })
}await importStatus("sent it out tuesday??") // "shipped"export async function moderate(body: string) {
const tone = await hunch.rate(["civil", "heated", "abusive"], {
given: body,
question: "how abusive is this comment?",
})
switch (tone.level) {
case "civil":
return "published"
case "heated":
return "held"
case "abusive":
return "rejected"
}
}
export async function postComment(body: string) {
const status = await moderate(body)
db.prepare("INSERT INTO comments (body, status) VALUES (?, ?)").run(body, status)
return status
}Use the stub backend to supply answers without making API calls:
import { Stub } from "@carldaws/hunch"
hunch.backend = new Stub({ fraud: 0.95, team: "billing", mood: "calm" })Use the question's key to supply its answer, or answer for calls outside
hunch.decide. Stub values depend on the method:
chanceand its levels: a probability or boolean.pick: an option name or an object of probabilities.rate: a level name or numeric position.
Missing answers throw unless you pass a fallback, as in
new Stub({}, { fallback: 0.5 }). The stub records each call in calls
for assertions. Here deliver posts to the inbound email route and rows
reads a table:
test("spam is dropped without a ticket", async () => {
hunch.backend = new Stub({ answer: "spam" })
const response = await deliver({ subject: "You have WON", text: "claim your prize" })
expect(response.status).toBe(204)
expect(rows("tickets")).toEqual([])
})new Hunch({
backend: new SystemOne({
url: "https://openrouter.ai/api/alpha/decisions",
apiKey: process.env.OPENROUTER_API_KEY, // a missing key throws when you ask
model: "typesafe/jev-1.13",
timeout: 5000, // milliseconds per attempt
maxRetries: 2, // 429/5xx/timeouts, with backoff, honours Retry-After
}),
levels: { definitely: 0.99 },
})APIError covers failures worth working around: timeouts, lost
connections, rate limits, server errors, and answers the backend got wrong
(InvalidAnswerError). AuthenticationError, ValidationError, and
ConfigurationError mean your setup needs fixing, so they are not
APIErrors.
SystemOne talks to any endpoint that speaks the System One decisions
protocol of noul, choice, and score questions. Stub is for
testing. A backend is any object with a
decide({ state, questions }) method that returns { answers }.
MIT
