Decision Models & Hyperbolic Gateway β where a "Jev-style" head fits
Status: positioning note + substrate (2026-10-20) Β· Related: ../TODO_RESEARCH_CRAWL.md, PROPOSED_ABSTRACTIONS.md
What "Jev" is
Jev (TypeSafe AI, Sept 2026) is a decision model, not a language model. You send it program state plus a fixed list of typed options and it returns a categorical probability distribution with confidence scores β no free-form text, typically 70β500 ms, orders of magnitude cheaper than an LLM. It is discriminative: the answer is always one of the options you supplied, and it cannot handle an option set it wasn't asked about. Related work: LLM-as-Jev (arXiv 2610.02076) and An Empirical Study of Jev in Agent Workflows (arXiv 2610.13092).
The natural reading of that literature: agents do not need an LLM for every decision. Constrained decisions (classify, route, score, verify) are better served by a fast typed head, with the LLM reserved for open-ended reasoning and generation.
The rule
Hyperbolic Gateway surfaces typed facts and evidence. The decision maker lives above it.
Hyperbolic Gateway is an MCP tool surface, not an agent framework. A runner-side decision model wins precisely where it replaces an LLM turn; a model buried inside the browser server would only move the choice, because the agent still had to spend a turn to ask for it. So:
- In the runner (e.g. Eve / agent-orcha): epistemic decisions β what is the goal, which tool family, is the task done, which of N candidate actions next, when to hand over to a human. This is where a Jev-style head belongs, and where gains in latency and cost actually materialise.
- In Hyperbolic Gateway: perception and mechanics β did the click land, is the target stale, is this field already
filled, which wait condition fits. These must run inside a tool call at sub-100 ms, are invisible to the
agent, and are already implemented as deterministic heuristics (e.g.
typeMarkre-resolves and re-clicks once if the target didn't take focus;actemits triage hints on a failedawait_conditions).
Hyperbolic Gateway therefore does not ship a policy model, and should not: it would add a heavyweight, network-bound dependency to a local-first tool, hard-code today's decision taxonomy, and hide decisions from the agent.
What Hyperbolic Gateway provides for a decision layer
Everything below is already emitted, typed, and observable from the tool result:
| Signal | Where | Use for a decision head |
|---|---|---|
| Enumerated option sets | act.action, take_screenshot.detail, observe.as, extract.kind, the wait_for condition vocabulary, clear_capture.scope, llm_fetch.as |
The options are already a fixed list β exactly Jev's input shape |
| Typed field facts | act result β target.field (prefilled, valueLength, editable, disabled, required, name, type, inShadowRoot) β also on dry_run |
Decide clear_first from data instead of guessing; pre-flight without acting |
| Outcome delta | act result β delta (urlChanged, revisionBefore/After, marksBefore/After) + durationMs |
Learn/predict "will this action change anything?" |
| Typing verdict | act result β typing (landed, changed, beforeLength, afterLength, keydowns, beforeinputs, inputs) and ok:false + error.code: "TYPE_NO_EFFECT" on a provable no-op |
Label a no-op without a second round trip, and separate "input never arrived" (0 key events) from "the control refused it" (events, value unchanged) β the two need different remedies |
| Post-condition result | act result β awaited.ok (+ hint) |
Distinguish acted-but-nothing-happened from not-ready-yet |
| Triage hints | awaited.hint, error.hint (viewport 0Γ0, unfocused window, wrong deepest activeElement, control rejects input) |
Classify a no-op click or a no-op type without another round trip |
| Typed errors | STALE_REF, TIMEOUT, NO_TARGET, BAD_REQUEST, NOT_SUPPORTED, TYPE_NO_EFFECT |
Deterministic classes to route on |
| Advisory recommendations | llm_surface.recommended (llms_txt/llms_full/markdown/text, or null when there is none) + next (llm_fetch(as) | observe(as:"markdown")) and scope (origin|page) |
Precedent for "recommend, don't silently switch" β and for keeping a strategy out of the representation union |
| Action ledger | data/transitions.jsonl (local, gitignored) |
A labelled (state, action) β outcome dataset |
The dataset: data/transitions.jsonl
One JSON object per executed action (never plaintext β typed text is a character count):
{"ts":1750000000000,"action":"type","addressMode":"selector","address":"#origin",
"target":{"selector":"#origin","tag":"input","field":{"tag":"input","name":"Origin","valueLength":11,"isEmpty":false,"prefilled":true,"editable":true}},
"url":"https://example.com/search","revisionBefore":42,"revisionAfter":42,"marksBefore":18,"marksAfter":18,
"urlChanged":false,"marksChanged":false,"ok":true,"awaitOk":false,
"hints":"focus is on \"#origin\", not the target <INPUT> β the click may have been occluded",
"durationMs":812,"typed":{"chars":6,"secret":false,"landed":true},
"typing":{"beforeLength":11,"afterLength":17,"changed":true,"keydowns":6,"beforeinputs":6,"inputs":6,"landed":true},
"clearFirst":false}
That single line is a complete supervised example: the state signature, the action and its addressing, the
field's prior contents, the outcome, and the triage hint. Filtered by urlChanged/marksChanged/awaitOk,
it separates did something from did nothing β the exact distinction a triage head needs.
What a runner-side head could do with it
- Outcome triage (highest value): classify a failed action into
{stale-ref, occluded-click, validation-blocked, genuine-no-op, success}β the failure mode that made an earlier agent re-click a Search button forever while a corrupted field silently blocked the form. clear_firstand general form handling: decide replace-vs-append fromtarget.field.prefilled.wait_forcondition choice: pick from the condition vocabulary instead of the LLM hand-writing one.- Screenshot economy: choose
detailβ{full, economy, markdown}before burning image tokens. - Challenge routing:
{dismiss-consent, text-captcha, human-intervention, ignore}on a blocked page. - Tool routing (runner-only): pick the next tool family without an LLM turn.
Guardrails
- Advisory, not silent. Return the decision and its confidence, keep the deterministic path as the
fallback for low confidence, and never let Hyperbolic Gateway mutate on a model's say-so. This is the same call already
made twice:
llm_surfacerecommends a surface rather than fetching it, andactreturns a delta rather than retrying behind the agent's back. - Discriminative limits. It can only choose from the options given, cannot explain itself, and degrades on out-of-distribution pages. Keep the LLM path for novelty, planning and prose.
- Local-first. If it must call a hosted decision API, that is a deliberate, documented exception β the ledger and the field facts stay on the machine either way.
The optional decide surface
decide ships off. With no backend configured it is not advertised at all, and calling it returns
NOT_CONFIGURED with setup instructions. When it is configured it does exactly one thing: forwards
state + typed questions to the configured model and returns the answers verbatim β no prompt
assembly, no retries that rewrite the question, no synthesis, and no fabricated answer if the upstream
fails (UPSTREAM_ERROR / TIMEOUT, with answers absent).
Configure with environment variables (authoritative) or data/decision.json (gitignored):
{ "protocol": "typesafe", "url": "https://api.typesafe.ai", "model": "jev-1.13", "key": "β¦" }
{ "protocol": "local-onnx", "model": "kev-0.6b", "device": "cpu" }
| Key | Env | Meaning |
|---|---|---|
protocol |
KUANG_DECISION_PROTOCOL |
typesafe (hosted Jev) or local-onnx (in-process, open-jev) |
url |
KUANG_DECISION_URL |
Base URL for typesafe (β¦/v1/systemone or just the host) |
model |
KUANG_DECISION_MODEL |
jev-1.13 (default, hosted) or kev-0.6b (default, local) |
key |
KUANG_DECISION_KEY |
Bearer token for the hosted API (never sent to a local model) |
device |
KUANG_DECISION_DEVICE |
cpu (default) or webgpu for the local model |
timeoutMs |
KUANG_DECISION_TIMEOUT_MS |
Upstream deadline (default 30 s) |
Two backends, deliberately. Hosted Jev answers in 70β500 ms at ~$0.042/Mtok input (output is free)
and is the reference implementation. The local path runs open-jev (Transformers.js / ONNX) in-process
with no model bundled: kev-0.6b (Qwen3-0.6B, 0.34 GB) by default, or open-jev (DeBERTa-v3-large,
0.35 GB). Install once with pnpm add open-jev @huggingface/transformers; weights download on first use.
Local model status β measured, not assumed
scripts/bench-decision-model.mjs runs four realistic page states (empty password + a dead submit, an
already-signed-in page, a cookie banner, a CAPTCHA) using the exact payload Hyperbolic Gateway sends β a goal plus the
component list, with target options restricted by each component's actions. Run it against the configured
backend (--bridge) or a local model (kev-0.6b cpu):
| Backend | Field answers correct | p50 latency |
|---|---|---|
| Jev 1.13 (OpenCode Console) | 10 / 10 | 376 ms |
kev-0.6b (local, Qwen3-0.6B) |
7 / 10 | 830 ms |
open-jev (local, DeBERTa-v3-large) |
6 / 10 | 1,252 ms |
All three were given the same information: each option carries a one-line description (criteria for
TypeSafe, descriptions for open-jev). That matters β with bare option keys kev-0.6b scored 5/10, and
with descriptions 7/10; Jev's CAPTCHA miss (click instead of blocked) disappeared entirely once the
blocked criterion actually described a wall. Write real criteria, not "Choose \"click\"".
Jev answers every scenario exactly right, including blocked at 0.96 and done at 0.96. The local models
never once chose blocked, which is the single most important decision on a hostile page. Use Jev for page
decisions; treat the local path as experimental.
Question shape: choice takes criteria, not options
TypeSafe's choice question expects a map of option β description in criteria:
{ "type": "choice",
"instructions": "What is the next operation?",
"criteria": { "click": "Click a control", "type_text": "Type into a field", "done": "The goal is met" } }
Sending options: [...] instead does not return a validation error β the OpenCode gateway answers
422 {"message":"Upstream request failed: Endpoint is unavailable."}, which reads like an auth failure or
an outage and sent us chasing the key for a while. noul and score are unaffected (score takes an
array of level criteria). open-jev, by contrast, takes options.
d1-3B is not supported by open-jev. Its detectFamily() only accepts configs with an open_jev or
kev section, and LiquidAI's d1-3B is lfm2_vl β the snippet on its model card does not work against the
published library. Running d1 means implementing its prompt + readout directly against
@huggingface/transformers (pinning e.g. 294fdcdβ¦), feeding the VLM graph including images, and running
it where WebGPU exists (the Electron renderer, not the Node main process). That is a separate piece of
work, and on CPU it would be seconds per decision.
The loop it is designed for
observe(as:"components")β the component table (role, name, value, region, actions; ids are mark ids).- One
decidecall with all the questions at once β the TypeSafe "speculative fan-out" shape, where every target head only contains components whoseactionsinclude that operation:
{
"operation": { "type": "choice", "instructions": "What is the next operation?", "criteria": { "click": "Click a control", "type_text": "Type into a field", "done": "The goal is met", "blocked": "A wall: CAPTCHA, consent, login" } },
"click_target": { "type": "choice", "instructions": "Which component to click?", "criteria": { "7": "Email field", "9": "Sign in button", "12": "Dismiss banner" } },
"type_target": { "type": "choice", "instructions": "Which component to type into?", "criteria": { "1": "Email field", "2": "Password field" } },
"is_done": { "type": "noul", "instructions": "Does the visible state satisfy the goal?" }
}
choice takes criteria (option β description), not options β see the note above.
Who writes the text? (the answer is: the agent)
Jev is decision-only. It answers choice / score / noul over a state and never generates text β
there is no prose path in it at all. So the split is:
Jev (decide) β operation = "type_text", target = "8" a decision
the agent (LLM) β the string to type generation, over MCP
Hyperbolic Gateway (act) β focus, keys, verify (typing.landed) execution + proof
This is deliberate: Hyperbolic Gateway ships no text-generation model. jev-ultrafast embeds a small LLM
(mercury-2.5 in its example) because it owns the whole loop; here the loop lives in the agent, which
already has a model. That keeps Hyperbolic Gateway local-first and key-free, and keeps the words under the agent's
control. In practice: decide says which field and that typing is next; the agent supplies the text
via act(mark_id, action:"type", text:"β¦").
Roadmap: an optional text helper β the equivalent of jev-ultrafast's small model β for runners that
have no LLM of their own. It would sit behind the same off-by-default config as decide (an
OpenAI-compatible endpoint or open-jev-style local model), be called only for TYPE_TEXT, and would
still hand its output to act, which verifies it. Not built; the agent is the text model today.
Handing over to a human (HIL) β a question, not a tool
Yes: the decision model is the right place to decide that a human is needed, because that is an epistemic judgement over the page, not an action. Keep it as a question and let the agent execute it:
{ "operation": { "type": "choice", "instructions": "What is the next operation?", "criteria": { "click": "β¦", "type_text": "β¦", "done": "β¦", "blocked": "β¦" } },
"handoff": { "type": "noul", "instructions": "Does this situation require a HUMAN β a CAPTCHA, a 2FA/one-time code, a payment confirmation, an identity check, or anything the agent must not do itself?" },
"handoff_for": { "type": "choice", "instructions": "What should the human do?", "criteria": { "captcha": "Solve the CAPTCHA", "otp": "Read and enter the code sent to the account", "consent": "Decide on the consent prompt", "payment": "Approve or complete payment", "identity": "Complete an identity check" } } }
Then the runner branches on the probability, not on the model's prose:
const d = decide(state, questions, guard, { max_attempts: 3 })
if (d.answers?.handoff?.noul >= 0.6) request_human_intervention({ reason: d.answers.handoff_for.choice })
else if (d.accepted) act(mark_id = d.answers.target.choice, action = d.answers.operation.choice)
else /* fallback: agent decides */
Two guardrails worth keeping: the model never calls HIL itself (it returns a probability; the agent
acts), and the existing mechanical signals β TYPE_NO_EFFECT after the input ladder, blocked, an
unchanged delta β remain valid reasons to hand over even when the model says otherwise. The human is the
last rung of the same ladder: model β eliminate + retry β deterministic checks β agent β human.
Who decides that a human is needed
| signal | who decides | why |
|---|---|---|
| Deterministic gate β age-verification or CAPTCHA copy is present | Hyperbolic Gateway β human (hard stop, auto HIL) | A compliance boundary is not a judgement call. Hyperbolic Gateway raises the hand-off itself and returns 409. |
Model answers blocked, but no gate copy matches |
the agent | "Blocked" may be a login wall, a paywall, a dead end, or a badly-classified normal page. The agent can see the goal and the site; it decides, and may call request_human_intervention itself. The result carries a hint saying exactly that. |
| Anything else | the agent | Normal loop. |
So the rule is: if it cannot be determined that a human is required, it goes to the agent, not the human. Hyperbolic Gateway only spends a human's attention on a boundary it can name and quote.
Sensitive gates β age verification and CAPTCHA are never the model's call
Some walls are not obstacles to route around. Confirming an age, solving a CAPTCHA, or asserting "I am human" is a statement only a person can make, so Hyperbolic Gateway checks for them before any model is asked:
state contains age-gate copy ("verify your age/birthday", "birthday to continue",
"18 or older", "adult content", "mature content", β¦)
β HTTP 409 { code: "HUMAN_REQUIRED", gate: "age_verification", error, hint: matched lines }
β no model call, no elimination, no retry β call request_human_intervention
The same applies to CAPTCHA / "verify you are human" copy. Three properties matter:
It sees the real page. Guarded calls append the live page text to the state before asking (
include_page_text, on by default forguard/eliminate, off for a rawdecide), because a wall announces itself in copy that the component table does not carry β/r/petiteshows only two buttons, "Not Now" and "Verify", while the page says "Verify your birthday to continue. This page may contain mature content." β the exact case that must not be clicked.It runs first, so a confident model cannot out-vote it β a 0.98 "blocked" is still a decision, and the rule here is do not decide at all. The gate spends zero model calls (asserted in
test:decide).It is narrow. A
Date of birthfield in a signup form is ordinary data entry and does not match; only gate copy does. That false-positive case is a test too.
When a decision loop sees HUMAN_REQUIRED, it must stop and notify a human. Hyperbolic Gateway now does the notifying
itself: the gate opens a HIL request for the page (reason + matched lines), throttled to one per URL per
five minutes and skipped while another request is open. In the HUD that is the alert-warning banner,
anchored bottom-right so it never covers the page the human must act on.
Seeing the page: include_screenshot
Text and the component table miss things a picture makes obvious β a CAPTCHA, an age gate, a modal that
looks like a form but is an ad. decide(include_screenshot: true) captures the viewport, scales it into
TypeSafe's image limits (longest side β€ 1568, shortest β₯ 280), and attaches it as an asset referenced from
the state, with the TypeSafe-Danger-Zone-Images: true header.
Known limitation (2026-10): the OpenCode gateway does not forward headers. TypeSafe needs
TypeSafe-Danger-Zone-Images: true; opencode.ai/inference/... drops it (verified with a direct request:
the same 400 with and without the header). Hyperbolic Gateway therefore disables the attachment when the configured
endpoint is opencode.ai and reports
screenshot: "disabled: the OpenCode gateway does not forward TypeSafe headers β images only work against api.typesafe.ai". Screenshots work today only when url points at api.typesafe.ai with a TypeSafe key.
The local ONNX models are text-only and say so. (As a safety net for other gateways, a 400 mentioning
"Images are experimental" is retried once without the image rather than failing the decision.)
Clicking near a gate: a warning, not a refusal
act(action:"click") adds warning to its result when the target's label looks gate-like and the page
copy matches a gate. It does not refuse: the human may have resolved the hand-off and asked for exactly that
click. The warning exists so an agent cannot claim it was not told.
{ "ok": true, "address": "#2",
"warning": "\"Verify\" sits behind an age-verification wall. If a human has not resolved the hand-off, stop and call request_human_intervention." }
Driving a UI: drive
drive({ goal, values }) runs the loop in code β observe components β decide β act β repeat β with
one decision call per step instead of an agent turn per step. It is plumbing, not policy: the questions
are fixed, it never interprets the goal, and it never generates text.
drive({ goal: "search London β Maldives, 1β7 Nov", values: { "Where from?": "London" } })
β { status: "needs_input", field: { id: 11, name: "Where from?" } } β supply the value, call again
β { status: "done" | "blocked" | "human_required" | "needs_agent" | "failed" | "max_steps",
steps: [{ step, operation, target, targetName, typedChars, ok, urlChanged, warning }] }
Stops, all typed: done Β· blocked (no deterministic gate matched β you decide) Β· human_required
(age/CAPTCHA, hand-off raised) Β· needs_agent (guard rejected everything, or a contradiction) Β· failed
Β· max_steps. dry_run plans the first step without acting.
Measured on the Skyscanner βLondon β Maldives, 1β7 Novβ form: four steps in 18 s (destination,
autocomplete suggestion, both dates), then needs_agent because the model wanted to type again but kept
choosing click-only components. That is the design: the loop eats the repetitive steps and hands back the
moment it cannot justify a move. The agent finished the flow with one click on Search.
Runner-side alternative: scripts/drive-example.mjs implements the same
loop over the plain HTTP API (~80 lines) for runners that want their own retries, logging or policy. Both
paths use the same guard, the same verification and the same ledger.
Guard + elimination β what happens when the model is wrong
A decision is only acted on if it passes deterministic checks Hyperbolic Gateway can make without judgement:
| check | rejects when |
|---|---|
| confidence gate | the chosen option's confidence is below guard.threshold |
| target membership | the chosen id was not in the guard.components list that was offered |
| affordance match | the component's actions do not include the chosen operation (a textbox cannot be clicked) |
| contradiction | the done question is true while the operation is something else |
With eliminate set, a rejected option is removed from that question and the model is asked again
with what's left β one call per attempt, capped at max_attempts (default 3, max 5):
{ "guard": { "threshold": 0.55, "components": [ {"id": 7, "actions": ["type"]}, {"id": 9, "actions": ["click"]} ] },
"eliminate": { "max_attempts": 3 } }
When every candidate is eliminated the result is accepted:false, exhausted:true, fallback:"agent"
with reasons, rejections and the removed map β no action is taken, and the agent decides. That is the
ladder: model β remove the rejected option and retry β (if exhausted) deterministic checks and the agent.
A failed act afterwards is the last signal: typing.landed:false or an unchanged delta means the chosen
step did not work, so re-observe and try the next candidate, then hand over to a human.
- Execute the head matching the chosen operation:
act(mark_id=β¦). - Verify β the existing guarantees do the work:
typing.landed, the delta, occlusion, the 0Γ0 guard. - The ledger records what the policy chose and whether it worked, which is the data to evaluate it with.
Nothing the model returns is ever turned into a selector, a coordinate or a script: it picks a component id, and Hyperbolic Gateway addresses, executes and verifies it.