feelings

September 19, 2026 · View on GitHub

.feels() on anything. A programming language for LLM workflows where the "AI if statement" is a real, typed method — powered by TypeSafe AI's Jev and BAML.

let email = baml.io.input("email: ");

if (email.feels("urgent")) {
    baml.io.println(email.ask("Draft a reply"));
}

This started as a reply to @southpolesteve's Probably, a toy language with feels baked in. The point of this repo: you don't need a new language. feels is ~20 lines of BAML — an interface with a blanket implementation — and because it's a real language, you get types, generics, loops, concurrency, tests, and everything else for free.

Jev makes the decisions, an LLM does the writing, and BAML ties it together.

Try it

git clone https://github.com/boundaryml/feelings && cd feelings
baml toolchain use nightly && baml toolchain update   # latest nightly

cp .env.example .env             # TYPESAFE_API_KEY (Jev) + ANTHROPIC_API_KEY (for .ask)
set -a; source .env; set +a

echo "prod is down and customers can't log in" | baml run urgent
baml run inbox -- --email "any chance you have 30 min next week to chat?"
baml run demo                    # enums, literal unions, class fan-out, ints…
git log --format=%s | baml run grep_with_vibes -- --feeling "scary to revert"
baml test                        # offline — inspects the Jev requests, no key needed

The language, in a few minutes

Everything below is ordinary BAML. The only thing this repo adds is baml_src/vibes.baml.

1. Make a judgment with .feels()

feels compares any value with a quoted description and returns a bool (Jev's probability, thresholded at 0.5).

if (message.feels("genuinely urgent")) {
    log.info("Drop everything.");
}

2. Keep the confidence with .how()

Same question, but you get the probability and pick the threshold yourself — so "otherwise maybe" is just an else if.

let urgency = message.how("genuinely urgent");
if (urgency >= 0.8)      { log.info("Drop everything.") }
else if (urgency >= 0.5) { log.info("Ask a follow-up question.") }
else                     { log.info("It can wait.") }

3. Route between descriptions with .matches<T>() + match

T must be a finite set of choices: an enum, a literal union, or a union of those (Team?, Team | "hold"). Jev picks one; BAML's match is exhaustive, so forgetting a branch is a compile error. An enum's @@description is the question; for a literal union, pass the question with .judge<T>("…").

type Kind = "a bug report" | "a sales pitch" | "a meeting request" | "something else";

let strategy = match (email.judge<Kind>("What kind of email is this?")) {
    "a bug report"      => "Acknowledge the bug, ask for reproduction steps.",
    "a sales pitch"     => "Politely decline in two sentences.",
    "a meeting request" => "Propose two concrete time slots next week.",
    "something else"    => "Reply briefly and helpfully.",
};

4. Several judgments at once with .fill<T>()

T extends reflect.AnyClass: give it a class and every field becomes a question — one request, one typed result. Enum descriptions are the criteria; field descriptions are the questions. (.matches<Triage>() is rejected before any HTTP call with a pointer to .fill; .fill<Team>() is a compile error.)

enum Team {
    Billing   @description("Payments, charges, refunds, or invoices"),
    Technical @description("Bugs, outages, or integrations"),
    Account   @description("Login, identity, or account access"),
    @@description("Which team should handle this ticket?")
}

class Triage {
    urgent: bool      @description("Does this ticket require immediate attention?"),
    route: Team,
    churn_risk: float @description("How likely is this customer to churn over this?"),
    mood: "calm" | "concerned" | "angry" @description("What is the customer's emotional state?"),
}

let tri = ticket.fill<Triage>();
// Triage { urgent: true, route: Technical, churn_risk: 0.56, mood: "angry" }

ticket here is a class, not a string. .feels() works on any value — strings, ints, classes, arrays — because the implementation is a blanket impl:

1000000.feels("like a suspiciously round number")   // true
1337.feels("like a suspiciously round number")      // false

5. Generate text with .ask()

Jev only classifies. .ask() hands the value to a generative model.

let draft = email.ask("Draft a reply. Keep it under 80 words.");

6. Keep trying with while … feels

It's a normal while, so you bound it however you like.

let attempts = 0;
while (attempts < 5 && draft.feels("full of corporate jargon")) {
    draft = draft.ask("Rewrite this plainly, keeping the meaning.");
    attempts += 1;
}

7. Do it concurrently — and from the shell

Because it's a real language, judging a batch is spawn + await, and a function that reads stdin is a shell tool. grep_with_vibes is grep where the pattern is a feeling:

function grep_with_vibes(feeling: string) -> void {
    let lines = read_stdin_lines();
    let hits = await baml.future.all(lines.map((l) -> { spawn { l.feels(feeling) } }));
    // print lines[i] where hits[i]
}
$ git log --format=%s | baml run grep_with_vibes -- --feeling "like it would be scary to revert"
Prevent GC starvation across spawn handoffs (#4847)
Defer completion GC and reclaim idle native heaps (#4844)
Trigger full GC from reserved object-slot spending (#4840)

Forty commit subjects, judged in parallel, under a second.

How .feels() works

vibes.baml, abridged:

interface Vibes {
    function feels(self, quality: string) -> bool throws unknown;
    function how(self, quality: string) -> float throws unknown;
    function matches<T>(self) -> T throws unknown;                      // enum / union
    function judge<T>(self, question: string) -> T throws unknown;      // …with a question
    function fill<T extends reflect.AnyClass>(self) -> T throws unknown; // class
    function ask(self, instruction: string) -> string throws unknown;
}

// Blanket implementation: every type S gets these methods.
implements<S> Vibes for S {
    function feels(self, quality: string) -> bool { Feels(state_of(self), quality) }
    function matches<T>(self) -> T { require_choice<T>(); Matches<T>(state_of(self)) }
    function fill<T extends reflect.AnyClass>(self) -> T { Matches<T>(state_of(self)) }
    // ...
}

// The return type IS the question. bool → Noul, float → probability,
// enum / literal union → Choice, class → one question per field.
function Feels(state: string, quality: string) -> bool {
    client: "typesafeai/jev-latest"
    prompt: `
        ${role("instructions")}
        Does this feel ${quality}?
        ${role("user")}
        ${state}
    `
}

function Matches<T>(state: string) -> T {
    client: "typesafeai/jev-latest"
    prompt: `${state}`
}

Strings are passed to Jev verbatim; everything else is serialized as JSON. See BAML's TypeSafe AI integration post for the full return-type → question mapping.

What it doesn't do

Jev is a classifier. .matches<T>() needs an enum or union; .fill<T>() needs a class whose fields are bool, float, enums, or literal unions. string, int, arrays, and maps are rejected before any HTTP call — use .ask() for text.

Files

filewhat
baml_src/vibes.bamlthe Vibes interface, blanket impl, and Jev-backed functions
baml_src/urgent.bamlthe tweet
baml_src/main.bamlsame thing as a function with an --email arg
baml_src/inbox.bamlconfidence gate → route → draft → rewrite loop → subject
baml_src/triage.bamlenums, literal unions, class fan-out, ints, concurrency
baml_src/grep_with_vibes.bamlgrep, but the pattern is a feeling — a stdin-driven shell tool
baml_src/vibes_test.bamloffline tests that inspect the Jev request shape