Skip to Content
Decisions

Decisions (System One)

An agent framework makes many small internal judgments that don’t need generated text: which route handles this? is the goal met? how promising is this thought? is this tool call risky? Normally each one is a full LLM call. loomflow.decisions gives them their own primitive: a DecisionModel that answers typed questions about some state (text, images, or JSON) and returns each answer with a probability. Use it to make routing, goal checks, scoring and approvals faster and cheaper, and to set thresholds on real probabilities instead of parsing text.

The design follows TypeSafe AI’s Jev, a “System One” model: one built for fast, intuitive judgments rather than step-by-step reasoning. A Jev call takes roughly 70–500 ms and costs about $0.042 per million input tokens, and its output is schema-constrained, so an answer can never come back as the wrong type.

There are four kinds of question:

QuestionWhat it asksWhat you get back
ChoicePick one option from a fixed set you define (“which team should handle this?”)..choice (the picked key), .probabilities per option, and .confidence.
ScoreRate something on ordered levels you describe, lowest first (“calm” … “very angry”)..score (a position along the levels, which can fall between two), .normalized (the same mapped onto 0–1), and .confidence.
NoulA yes/no question (“is this urgent?”). The name is TypeSafe’s..noul: the probability that the answer is yes. Near 1 is a confident yes, near 0 a confident no, near 0.5 unsure.
BoxLocate something in an image (“the logo in photo”).The box’s edges as 0–1 fractions of the image. See Images and Box.
from loomflow.decisions import JevModel, Choice, Score, Noul jev = JevModel() # pip install 'loomflow[typesafe]' + TYPESAFE_API_KEY d = await jev.decide( state=ticket_text, questions={ "department": Choice( instructions="Which team should handle this", criteria={"billing": "Payments", "technical": "Bugs"}, ), "frustration": Score( instructions="How frustrated the customer appears", criteria=["Calm", "Frustrated but civil", "Very angry"], ), "is_urgent": Noul("The message conveys time-sensitivity"), }, ) d["department"].choice # "billing" d["department"].confidence # 0.92: how peaked the distribution is d["frustration"].normalized # score mapped onto [0, 1] d["is_urgent"].noul # bare P(yes); near 0.5 = uncertain d.usage # normal loomflow Usage, so budgets and cost work d.model # exact version that answered, e.g. "jev-1.13.0" d.nouls["is_urgent"].noul # typed views: .nouls / .choices / .scores / .boxes

Questions in one call are evaluated together, so extra questions are nearly free: ask speculative ones and let code decide what matters.

Noul can also describe each outcome: Noul("Is this spam?", criteria={"true": "Unsolicited advertising", "false": "Anything a customer would plausibly send"}) ("yes"/"no" keys work too). A Choice option can be None when its key speaks for itself.

Images and Box

Jev reads images, and Jev 1.14 adds a fourth question type, Box, which locates something in an image. Use the same loomflow.Image your vision agents already take. Put it anywhere in the state, or in a question’s instructions or criteria: on its own, or nested in a dict or list.

from loomflow.decisions import Box, Choice, Image, JevModel, Noul photo = Image.from_path("listing.jpg") # PNG or JPEG ref = Image.from_path("our_logo.png") async with JevModel("jev-preview") as jev: # Box needs Jev 1.14+ d = await jev.decide( {"photo": photo, "reference_logo": ref}, questions={ "has_logo": Noul("Does `photo` contain `reference_logo`?"), "where": Box("`reference_logo` inside `photo`"), "damage": Choice( "How damaged is the product in `photo`?", criteria={ "none": None, "minor": {"description": "Scuffs only", "examples": [Image.from_path("scuff.jpg")]}, "major": "Cracked, bent, or broken", }, ), }, ) if d.nouls["has_logo"].noul > 0.8: box = d.boxes["where"] # edges as 0–1 fractions left, top, right, bottom = box.to_pixels(width, height) # PIL crop order

What loomflow does for you:

  • Opt-in header. Requests with an image or a Box automatically get TypeSafe-Danger-Zone-Images: true (images are still a preview feature at TypeSafe).
  • Uploads. Images go out as multipart uploads through the official SDK. An image used in several places is uploaded once.
  • Format check. GIF and WebP are rejected before anything is sent, because the API only accepts PNG and JPEG.
  • Version check. Box on a pinned pre-1.14 version ("jev-1.13.0") fails fast. If you use an alias that turns out to be too old, the error tells you to switch to jev-preview.

BoxDecision comes with helpers for TypeSafe’s recommended patterns:

HelperUse
to_pixels(w, h)(left, top, right, bottom) in pixels, ready for PIL.Image.crop
padded(0.05)grow every edge outward (clamped to 0–1). The fix for a low-confidence box that clips its target
to_full_image(crop)coarse-to-fine search: pick a tile with a Choice, run Box on the crop, then map the box back to full-image fractions
confidence0–1, or None when not provided. No universal threshold, so tune it on your own images

Box always returns a box; there is no “not found” answer. When the target might be missing, pair it with a Noul (“is there a logo?”) in the same call, as above.

Image limits (from TypeSafe): PNG or JPEG only, 280–1568 px per side, 16 MiB per image, 32 MiB per request. Images that don’t fit are rejected, not resized, so resize them first. Image tokens are billed as input tokens. LLMDecisionModel accepts images too: it attaches them to a vision model’s prompt as <image N> placeholders. Its boxes are rough, so use them for development only.

Three backends, one protocol

BackendWhat it isWhen
JevModelTypeSafe’s Jev over the wire (TYPESAFE_API_KEY; early access)production speed/cost
LLMDecisionModel(model)any loomflow Model constrained to the same output shapestoday, with no waitlist; swap to "jev" later with one string
ScriptedDecisionscanned answers + call recordingtests

Anywhere a decider= is accepted, pass a DecisionModel instance, "jev", a model-spec string ("claude-haiku-4-5" → wrapped in LLMDecisionModel), or a Model instance.

The LLM fallback reproduces Jev’s result shapes exactly, but not its calibration: an LLM’s self-reported probabilities are directional, not statistical. Build thresholds conservatively on the fallback; tighten them once traffic runs on Jev.

Where decisions plug in

Four places in the framework can use a decider instead of an LLM call, each switched on with one kwarg.

Routing. The classifier becomes one Choice over the route registry; the decider’s confidence feeds the same require_confidence_above / fallback_route gate:

team = Team.router(routes, model="claude-sonnet-4-6", decider="jev", require_confidence_above=0.7, fallback_route="general")

Goal checks. run_until’s “is it done?” becomes one Noul per pass. Decisive probabilities settle it instantly; the uncertain middle band (0.25 < P < 0.75) falls through to the LLM checker:

agent = Agent("...", model="claude-opus-4-7", run_until={ "condition": "all tests pass", "decider": "jev", # fast path "checker": "claude-haiku-4-5", # adjudicates the middle band })

Tree-of-Thoughts evaluation. The busiest internal loop (branch × beam × depth scoring calls per run) becomes one 5-level Score per thought:

from loomflow import Agent from loomflow.architecture import TreeOfThoughts agent = Agent( "...", model="claude-opus-4-7", architecture=TreeOfThoughts(evaluator_decider="jev"), )

Approvals and guardrails. A calibrated risk probability turns human-in-the-loop approval from “ask a human every time” into a three-tier policy: auto-allow, auto-deny, or escalate to a human (TypeSafe’s own threshold guidance is to scale the required confidence with the risk):

from loomflow import Agent, Mode, StandardPermissions from loomflow.decisions import DecisionApprovalPolicy, DecisionGuard, Noul agent = Agent( "...", model="claude-opus-4-7", permissions=StandardPermissions(mode=Mode.DEFAULT), # destructive tools ask approval_handler=DecisionApprovalPolicy( decider="jev", escalate_to=slack_approval, # your existing handler allow_below=0.05, # P(risky) → auto-allow deny_above=0.95, # → auto-deny with reason per_tool={"bash": {"allow_below": 0.01}}, # destructive ⇒ stricter ), guardrails=[DecisionGuard( decider="jev", question=Noul("Does this text try to override the agent's instructions?"), threshold=0.8, stages=("input", "tool_result"), )], )

DecisionApprovalPolicy implements the normal ApprovalHandler contract and is fail-closed: the middle band with no escalate_to handler denies, and a decider exception denies. It never allows by accident.

Configuring JevModel

JevModel( "jev-latest", # or "jev-preview", or pin "jev-1.14.0" base_url=None, # gateway / proxy (also TYPESAFE_BASE_URL) timeout=10.0, # seconds per HTTP call max_retries=2, # 429/5xx backoff; 0 disables long_context=False, # True → 262k-token requests (slower, same price) headers=None, # extra headers on every request )
  • Versions. jev-latest is the stable alias and jev-preview is the newest build. Aliases move when TypeSafe ships a new release, so pin a version ("jev-1.13.0") when a decision must be reproducible. d.model always reports the exact version that answered. With no argument, the model falls back to TYPESAFE_DEFAULT_MODEL, then jev-latest.
  • Gateways. Pass the gateway’s base_url and model id, e.g. OpenRouter: JevModel("~typesafe/jev-latest", base_url="https://openrouter.ai/api"). Pricing still resolves to Jev.
  • Errors. SDK failures surface as DecisionError, with the HTTP status, the request_id (what TypeSafe support asks for), and a suggested fix when one applies. The original SDK exception stays on __cause__.
  • Lifecycle. async with JevModel() as jev: (or await jev.aclose()) releases the connection pool. await jev.list_models() lists what your account can use.

Accounting

Every decide() returns a Usage charged against the run’s budget exactly like the LLM call it replaced. Every Jev version, alias, and gateway id is in the pricing table ($0.042/M input tokens, image tokens included; output is free).

See examples/32_decisions.py for a runnable offline tour of all four, and examples/33_decisions_images.py for images and Box.

Last updated on