Hyperbolic Gateway

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:

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

  1. 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.
  2. clear_first and general form handling: decide replace-vs-append from target.field.prefilled.
  3. wait_for condition choice: pick from the condition vocabulary instead of the LLM hand-writing one.
  4. Screenshot economy: choose detail ∈ {full, economy, markdown} before burning image tokens.
  5. Challenge routing: {dismiss-consent, text-captcha, human-intervention, ignore} on a blocked page.
  6. Tool routing (runner-only): pick the next tool family without an LLM turn.

Guardrails

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

  1. observe(as:"components") β†’ the component table (role, name, value, region, actions; ids are mark ids).
  2. One decide call with all the questions at once β€” the TypeSafe "speculative fan-out" shape, where every target head only contains components whose actions include 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:

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.

  1. Execute the head matching the chosen operation: act(mark_id=…).
  2. Verify β€” the existing guarantees do the work: typing.landed, the delta, occlusion, the 0Γ—0 guard.
  3. 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.